mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Merge pull request #401 from shenlvkang-collab/pr/loopback-links-webtab
feat(webview): open localhost links through a proxied web tab from another device
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
---
|
||||
"aicodeman": minor
|
||||
---
|
||||
|
||||
feat(webview): open `localhost` links from the terminal and the Response Viewer through a proxied web tab
|
||||
|
||||
An agent prints `http://localhost:5173/` and the user taps it on a phone: that address
|
||||
only exists on the Codeman box, so the link was a guaranteed connection error from any
|
||||
other device. A loopback link (`localhost`, `*.localhost`, 127/8, 0.0.0.0, ::1) clicked in
|
||||
the terminal or in the Response Viewer now opens as a proxied web tab whenever the
|
||||
Codeman page itself is not on that box — reusing a saved proxied dashboard on the same
|
||||
origin (with the link's own path opened inside it) or saving one under its host:port.
|
||||
LAN and tailnet addresses, which the device may reach directly, keep opening in a new
|
||||
browser tab, and on the box itself every link opens directly.
|
||||
@@ -52,6 +52,25 @@ sandbox, cookies, CORS, CSP, or any reverse proxy sitting in front of Codeman, s
|
||||
passing Test does not guarantee the embedded page will render (see the
|
||||
cookie-authenticated reverse proxy caveat below).
|
||||
|
||||
## Links to `localhost` from another device
|
||||
|
||||
An agent prints `http://localhost:5173/` (a dev server, a preview, a report it just
|
||||
served) and you tap it on your phone. That address only exists on the Codeman box, so
|
||||
the phone's browser can never load it — but the web-tab proxy fetches from the server,
|
||||
where it works.
|
||||
|
||||
So a **loopback** link (`localhost`, `*.localhost`, `127.0.0.0/8`, `0.0.0.0`, `::1`) clicked
|
||||
in the terminal or in the Response Viewer opens as a **proxied web tab** whenever the
|
||||
Codeman page itself is not on that box. A saved proxied dashboard on the same origin is
|
||||
reused (one tab per dev server, with the link's own path opened inside it); otherwise
|
||||
one is saved under its `host:port` so it is in the Run dropdown next time. Sandboxed by
|
||||
default, like any other web tab.
|
||||
|
||||
Only loopback is routed this way. A LAN or tailnet address (`192.168.…`, `100.…`,
|
||||
`box.ts.net`) may well be reachable from the device — a VPN, the same Wi-Fi — and a
|
||||
direct open is the cheaper, richer path, so those links still open in a new browser tab.
|
||||
On the box itself (a browser on `localhost`) every link opens directly.
|
||||
|
||||
## The sandbox, and when to turn it off
|
||||
|
||||
Because a proxied dashboard is served from Codeman's own address, it is
|
||||
|
||||
@@ -2149,6 +2149,16 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// A `localhost` URL in the agent's answer: from another device that can
|
||||
// only load through the server, so hand it to a proxied web tab
|
||||
// (webview-tabs.js). Every other link keeps its new-tab default.
|
||||
const urlLink = ev.target.closest('a[href]');
|
||||
if (urlLink && this.openLinkThroughWebTabIfLoopback?.(urlLink.href)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// One-click copy: lift the raw source from the sibling <pre><code>.
|
||||
const copyBtn = ev.target.closest('.rv-copy-btn');
|
||||
if (copyBtn) {
|
||||
|
||||
@@ -1448,6 +1448,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
range: { start, end },
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(_event, text) {
|
||||
// A `localhost` link tapped from another device can only work
|
||||
// through the server: route it into a proxied web tab
|
||||
// (webview-tabs.js). Anything else opens as before.
|
||||
if (self.openLinkThroughWebTabIfLoopback?.(text)) return;
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
hover() {
|
||||
|
||||
@@ -19,7 +19,114 @@
|
||||
* @loadorder 12.5 of 16, after session-ui.js (needs the tab strip), before api-client.js
|
||||
*/
|
||||
|
||||
// ── Loopback links ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// An agent prints `http://localhost:5173/` and the user taps it on a phone.
|
||||
// That link can only ever resolve on the Codeman box itself, so opening it in
|
||||
// the browser is a guaranteed connection error from anywhere else — while the
|
||||
// proxied web tab fetches from the server, where it works. Only loopback is
|
||||
// routed this way: a LAN or tailnet address may well be reachable from the
|
||||
// device (a VPN, the same Wi-Fi), and a direct open is the cheaper, richer path.
|
||||
|
||||
const LOOPBACK_HOSTNAMES = new Set(['localhost', '0.0.0.0', '::1', '[::1]', '::', '[::]']);
|
||||
|
||||
/** `localhost`, `*.localhost`, 127.0.0.0/8, 0.0.0.0 and the IPv6 loopback forms. */
|
||||
function isLoopbackHostname(hostname) {
|
||||
const host = String(hostname || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\.$/, '');
|
||||
if (!host) return false;
|
||||
if (LOOPBACK_HOSTNAMES.has(host) || host.endsWith('.localhost')) return true;
|
||||
const ipv4 = /^(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(host);
|
||||
return !!ipv4 && Number(ipv4[1]) === 127;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a link should open through a proxied web tab rather than directly:
|
||||
* an http(s) URL on a loopback host, viewed from a page that is NOT itself on
|
||||
* that host (on the box, the browser can reach localhost and the direct open
|
||||
* keeps devtools, extensions and the real origin).
|
||||
*/
|
||||
function linkNeedsWebTabProxy(rawUrl, pageHostname) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(rawUrl || ''));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||
if (!isLoopbackHostname(url.hostname)) return false;
|
||||
return !isLoopbackHostname(pageHostname);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanWebviewLinks = { isLoopbackHostname, linkNeedsWebTabProxy };
|
||||
}
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ── Loopback links ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Take a link the device cannot reach and open it through a proxied web tab.
|
||||
* Returns true when it took the link; false leaves the caller's own opening
|
||||
* path (window.open, an anchor's default) untouched.
|
||||
*/
|
||||
openLinkThroughWebTabIfLoopback(rawUrl) {
|
||||
if (!linkNeedsWebTabProxy(rawUrl, window.location?.hostname)) return false;
|
||||
void this.openUrlInWebTab(rawUrl);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Open an arbitrary URL as a proxied web tab, deep path included. A saved
|
||||
* proxied dashboard on the same origin is reused (one tab per dev server,
|
||||
* not one per link); otherwise one is saved under the host:port name so it
|
||||
* is there in the Run dropdown next time.
|
||||
*/
|
||||
async openUrlInWebTab(rawUrl) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(rawUrl || ''));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!this.webviews) await this.refreshWebviews();
|
||||
if (!this.webviews) {
|
||||
this.showToast?.('Could not open URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let existing = null;
|
||||
for (const webview of this.webviews.values()) {
|
||||
if (webview.managed || (webview.embedMode ?? 'proxy') !== 'proxy') continue;
|
||||
try {
|
||||
if (new URL(webview.url).origin === url.origin) {
|
||||
existing = webview;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* a saved URL that no longer parses is not a match */
|
||||
}
|
||||
}
|
||||
|
||||
let id = existing?.id;
|
||||
if (!id) {
|
||||
const created = await this._apiJson('/api/webviews', {
|
||||
method: 'POST',
|
||||
body: { name: url.host.slice(0, 60), url: `${url.origin}/`, embedMode: 'proxy', trusted: false },
|
||||
});
|
||||
if (!created?.id) {
|
||||
this.showToast?.('Could not open URL', 'error');
|
||||
return;
|
||||
}
|
||||
await this.refreshWebviews();
|
||||
id = created.id;
|
||||
}
|
||||
const path = `${url.pathname}${url.search}${url.hash}`;
|
||||
await this.openWebview(id, { path: path === '/' ? '' : path });
|
||||
},
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Load the saved list and restore which tabs were open. */
|
||||
@@ -133,7 +240,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
* memory-only and expire, so a tab reopened after a server restart must not reuse
|
||||
* the dead URL from the previous run.
|
||||
*/
|
||||
async openWebview(id) {
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {{path?: string}} [options] `path` (pathname+search+hash) opens a
|
||||
* deep link inside the dashboard: appended to the proxy prefix, or resolved
|
||||
* against the real URL in direct mode. A mounted frame is navigated there
|
||||
* rather than left on whatever page it was showing.
|
||||
*/
|
||||
async openWebview(id, options = {}) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) return;
|
||||
|
||||
@@ -149,8 +263,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
if (data.webview) this.webviews.set(id, data.webview);
|
||||
|
||||
const src = data.embedUrl || data.webview?.url || webview.url;
|
||||
this._mountWebviewFrame(id, src, data.webview || webview);
|
||||
let src = data.embedUrl || data.webview?.url || webview.url;
|
||||
const path = typeof options.path === 'string' ? options.path : '';
|
||||
if (path) {
|
||||
// The proxy prefix is `/webview/<cap>/`; a wildcard rides after it. In
|
||||
// direct mode the deep link resolves against the dashboard's own origin.
|
||||
src = data.embedUrl ? `${data.embedUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}` : new URL(path, src).href;
|
||||
}
|
||||
this._mountWebviewFrame(id, src, data.webview || webview, { navigate: !!path });
|
||||
this.activeWebviewId = id;
|
||||
this.hideWelcome?.();
|
||||
document.querySelector('.main')?.classList.add('webview-active');
|
||||
@@ -162,11 +282,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/** Create the frame if absent, then reveal it and hide its siblings. */
|
||||
_mountWebviewFrame(id, src, webview) {
|
||||
_mountWebviewFrame(id, src, webview, { navigate = false } = {}) {
|
||||
const layer = document.getElementById('webviewLayer');
|
||||
if (!layer) return;
|
||||
|
||||
let wrap = layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`);
|
||||
if (wrap && navigate) {
|
||||
// A deep link into an already-mounted dashboard: navigate the live frame
|
||||
// instead of tearing it down, so its login and state survive.
|
||||
const frame = wrap.querySelector('iframe');
|
||||
if (frame) frame.src = CodemanBase.url(src);
|
||||
}
|
||||
if (!wrap) {
|
||||
wrap = document.createElement('div');
|
||||
wrap.className = 'webview-frame';
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @fileoverview Loopback links open through a proxied web tab (webview-tabs.js).
|
||||
*
|
||||
* An agent prints `http://localhost:5173/` and the user taps it on a phone. The
|
||||
* browser there can never reach the Codeman box's loopback, so the link was a
|
||||
* guaranteed connection error — while the web-tab proxy fetches from the server,
|
||||
* where it works. Pinned here:
|
||||
*
|
||||
* 1. The decision: only http(s) on a loopback host, and only when the page
|
||||
* itself is not on that host. A LAN/tailnet address stays a direct open.
|
||||
* 2. A saved proxied dashboard on the same origin is reused, with the deep
|
||||
* path appended to the minted proxy prefix; a mounted frame is navigated,
|
||||
* not torn down.
|
||||
* 3. An unknown origin is saved under its host:port and opened.
|
||||
* 4. The terminal link provider and the response viewer consult the hook
|
||||
* before their own opening path.
|
||||
*
|
||||
* Same in-test JSDOM boot as webview-menu-rows.test.ts (no per-file env).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const CONSTANTS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
||||
const WEBVIEW_TABS = readFileSync(new URL('../src/web/public/webview-tabs.js', import.meta.url), 'utf-8');
|
||||
const TERMINAL_UI = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf-8');
|
||||
const APP_JS = readFileSync(new URL('../src/web/public/app.js', import.meta.url), 'utf-8');
|
||||
|
||||
type Webview = { id: string; name: string; url: string; embedMode?: string; managed?: string };
|
||||
|
||||
interface AppLike {
|
||||
webviews: Map<string, Webview>;
|
||||
webviewOrder: string[];
|
||||
activeWebviewId: string | null;
|
||||
renderSessionTabs(): void;
|
||||
refreshWebviews(): Promise<void>;
|
||||
openLinkThroughWebTabIfLoopback(url: string): boolean;
|
||||
openUrlInWebTab(url: string): Promise<void>;
|
||||
openWebview(id: string, options?: { path?: string }): Promise<void>;
|
||||
_apiJson(path: string, opts?: { method?: string; body?: unknown }): Promise<unknown>;
|
||||
_updateActiveWebviewTab(): void;
|
||||
showToast?: (msg: string, kind: string) => void;
|
||||
}
|
||||
|
||||
function boot(pageUrl = 'http://192.168.1.135:8095/') {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><body><div class="main"></div><div id="sessionTabs"></div><div id="webviewLayer"></div></body>`,
|
||||
{ url: pageUrl, runScripts: 'outside-only' }
|
||||
);
|
||||
const win = dom.window as unknown as Window &
|
||||
typeof globalThis & { app: AppLike; CodemanApp: new () => AppLike; CodemanWebviewLinks: WebviewLinks };
|
||||
(win as unknown as { eval: (s: string) => void }).eval(
|
||||
[
|
||||
'window.CodemanApp = class CodemanApp {};',
|
||||
'window.CodemanBase = { url: (p) => p };',
|
||||
'if (!window.CSS) window.CSS = { escape: (s) => s };',
|
||||
'window.requestAnimationFrame = (fn) => { fn(); return 1; };',
|
||||
CONSTANTS,
|
||||
WEBVIEW_TABS,
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const calls: Array<{ path: string; method: string; body?: unknown }> = [];
|
||||
const app = new win.CodemanApp();
|
||||
app.webviews = new Map([
|
||||
['dev', { id: 'dev', name: 'localhost:5173', url: 'http://localhost:5173/' }],
|
||||
['direct', { id: 'direct', name: 'Direct', url: 'https://localhost:9443/', embedMode: 'direct' }],
|
||||
]);
|
||||
app.webviewOrder = [];
|
||||
app.activeWebviewId = null;
|
||||
app.renderSessionTabs = () => {};
|
||||
app._updateActiveWebviewTab = () => {};
|
||||
app.refreshWebviews = async () => {};
|
||||
app._apiJson = async (path: string, opts: { method?: string; body?: unknown } = {}) => {
|
||||
calls.push({ path, method: opts.method || 'GET', body: opts.body });
|
||||
if (path === '/api/webviews' && opts.method === 'POST') {
|
||||
const body = opts.body as { name: string; url: string };
|
||||
const created = { id: 'new-id', name: body.name, url: body.url, embedMode: 'proxy' };
|
||||
app.webviews.set(created.id, created);
|
||||
return created;
|
||||
}
|
||||
const open = /^\/api\/webviews\/([^/]+)\/open$/.exec(path);
|
||||
if (open) {
|
||||
const id = decodeURIComponent(open[1]);
|
||||
const webview = app.webviews.get(id);
|
||||
if (!webview) return null;
|
||||
return webview.embedMode === 'direct' ? { webview } : { webview, embedUrl: `/webview/cap-${id}/` };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
win.app = app;
|
||||
return { win, app, calls };
|
||||
}
|
||||
|
||||
interface WebviewLinks {
|
||||
isLoopbackHostname(host: string): boolean;
|
||||
linkNeedsWebTabProxy(url: string, pageHostname: string): boolean;
|
||||
}
|
||||
|
||||
const frameSrc = (win: Window, id: string) =>
|
||||
(
|
||||
win.document.querySelector(`.webview-frame[data-webview-id="${id}"] iframe`) as HTMLIFrameElement | null
|
||||
)?.getAttribute('src');
|
||||
|
||||
describe('loopback link decision', () => {
|
||||
const { win } = boot();
|
||||
const links = win.CodemanWebviewLinks;
|
||||
|
||||
it('recognises every loopback spelling and nothing else', () => {
|
||||
for (const host of [
|
||||
'localhost',
|
||||
'LOCALHOST',
|
||||
'app.localhost',
|
||||
'127.0.0.1',
|
||||
'127.1.2.3',
|
||||
'0.0.0.0',
|
||||
'[::1]',
|
||||
'::1',
|
||||
]) {
|
||||
expect(links.isLoopbackHostname(host), host).toBe(true);
|
||||
}
|
||||
for (const host of [
|
||||
'192.168.1.135',
|
||||
'10.9.0.4',
|
||||
'172.16.0.2',
|
||||
'box.ts.net',
|
||||
'128.0.0.1',
|
||||
'',
|
||||
'localhost.example.com',
|
||||
]) {
|
||||
expect(links.isLoopbackHostname(host), host).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('proxies a loopback http(s) link only when the page is not on that box', () => {
|
||||
expect(links.linkNeedsWebTabProxy('http://localhost:5173/', '192.168.1.135')).toBe(true);
|
||||
expect(links.linkNeedsWebTabProxy('https://127.0.0.1:8443/x?y=1', 'box.ts.net')).toBe(true);
|
||||
// On the box itself the browser reaches localhost directly.
|
||||
expect(links.linkNeedsWebTabProxy('http://localhost:5173/', 'localhost')).toBe(false);
|
||||
expect(links.linkNeedsWebTabProxy('http://localhost:5173/', '127.0.0.1')).toBe(false);
|
||||
// A LAN address may be reachable from the device; leave it direct.
|
||||
expect(links.linkNeedsWebTabProxy('http://192.168.1.135:3000/', '192.168.1.135')).toBe(false);
|
||||
expect(links.linkNeedsWebTabProxy('http://10.9.0.4:8095/', '192.168.1.135')).toBe(false);
|
||||
// Not a web URL at all.
|
||||
expect(links.linkNeedsWebTabProxy('ftp://localhost/', '192.168.1.135')).toBe(false);
|
||||
expect(links.linkNeedsWebTabProxy('not a url', '192.168.1.135')).toBe(false);
|
||||
expect(links.linkNeedsWebTabProxy('', '192.168.1.135')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openLinkThroughWebTabIfLoopback', () => {
|
||||
it('reuses the saved proxied dashboard on that origin and opens the deep path', async () => {
|
||||
const { win, app, calls } = boot();
|
||||
expect(app.openLinkThroughWebTabIfLoopback('http://localhost:5173/pages/report?tab=2#top')).toBe(true);
|
||||
await vi.waitFor(() => expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/pages/report?tab=2#top'));
|
||||
expect(calls.some((c) => c.path === '/api/webviews' && c.method === 'POST')).toBe(false);
|
||||
expect(app.activeWebviewId).toBe('dev');
|
||||
expect(app.webviewOrder).toEqual(['dev']);
|
||||
});
|
||||
|
||||
it('navigates an already-mounted frame instead of remounting it', async () => {
|
||||
const { win, app } = boot();
|
||||
await app.openWebview('dev');
|
||||
const first = win.document.querySelector('.webview-frame[data-webview-id="dev"] iframe');
|
||||
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/');
|
||||
|
||||
await app.openUrlInWebTab('http://localhost:5173/other');
|
||||
expect(win.document.querySelector('.webview-frame[data-webview-id="dev"] iframe')).toBe(first);
|
||||
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/other');
|
||||
expect(win.document.querySelectorAll('.webview-frame').length).toBe(1);
|
||||
});
|
||||
|
||||
it('saves an unknown origin under its host:port, then opens it', async () => {
|
||||
const { win, app, calls } = boot();
|
||||
expect(app.openLinkThroughWebTabIfLoopback('http://127.0.0.1:3000/')).toBe(true);
|
||||
await vi.waitFor(() => expect(frameSrc(win, 'new-id')).toBe('/webview/cap-new-id/'));
|
||||
const post = calls.find((c) => c.path === '/api/webviews' && c.method === 'POST');
|
||||
expect(post?.body).toEqual({
|
||||
name: '127.0.0.1:3000',
|
||||
url: 'http://127.0.0.1:3000/',
|
||||
embedMode: 'proxy',
|
||||
trusted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reuse a direct-mode dashboard, which cannot show a loopback page from elsewhere', async () => {
|
||||
const { win, app, calls } = boot();
|
||||
expect(app.openLinkThroughWebTabIfLoopback('https://localhost:9443/admin')).toBe(true);
|
||||
await vi.waitFor(() => expect(frameSrc(win, 'new-id')).toBe('/webview/cap-new-id/admin'));
|
||||
expect(calls.find((c) => c.method === 'POST' && c.path === '/api/webviews')?.body).toMatchObject({
|
||||
url: 'https://localhost:9443/',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a reachable link alone so the caller opens it directly', () => {
|
||||
const { app, calls } = boot();
|
||||
expect(app.openLinkThroughWebTabIfLoopback('http://192.168.1.135:3000/')).toBe(false);
|
||||
expect(app.openLinkThroughWebTabIfLoopback('https://example.com/')).toBe(false);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('opens loopback links directly when the page itself is on the box', () => {
|
||||
const { app, calls } = boot('http://localhost:8095/');
|
||||
expect(app.openLinkThroughWebTabIfLoopback('http://localhost:5173/')).toBe(false);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callers consult the hook first', () => {
|
||||
it('terminal URL links try the web tab before window.open', () => {
|
||||
const activate = TERMINAL_UI.indexOf('activate(_event, text) {');
|
||||
expect(activate).toBeGreaterThan(-1);
|
||||
const body = TERMINAL_UI.slice(activate, TERMINAL_UI.indexOf('},', activate));
|
||||
expect(body.indexOf('openLinkThroughWebTabIfLoopback?.(text)')).toBeGreaterThan(-1);
|
||||
expect(body.indexOf('openLinkThroughWebTabIfLoopback?.(text)')).toBeLessThan(body.indexOf('window.open('));
|
||||
});
|
||||
|
||||
it('response viewer links route through the hook and keep the file-path handler first', () => {
|
||||
const bind = APP_JS.indexOf('_bindResponseViewerInteractions(body) {');
|
||||
const section = APP_JS.slice(bind, bind + 2500);
|
||||
const pathHandler = section.indexOf("closest('a.rv-path')");
|
||||
const urlHandler = section.indexOf("closest('a[href]')");
|
||||
expect(pathHandler).toBeGreaterThan(-1);
|
||||
expect(urlHandler).toBeGreaterThan(pathHandler);
|
||||
expect(section.indexOf('openLinkThroughWebTabIfLoopback?.(urlLink.href)')).toBeGreaterThan(urlHandler);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user