diff --git a/resources/mcp-bridge.js b/resources/mcp-bridge.js index 45b4f5ce..cb86d329 100644 --- a/resources/mcp-bridge.js +++ b/resources/mcp-bridge.js @@ -85,9 +85,18 @@ function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n') } -function callControl(method, path, body) { +// Backstop only. Node's http client has no default request timeout, so a +// control-server handler that never responds would hang the tool call — and +// therefore the agent — forever. Sits well above every server-side timeout so +// the server's own (more specific) error wins the race in the normal case. +const CALL_TIMEOUT_MS = Number(process.env.HARNESS_CALL_TIMEOUT_MS) || 60_000 +// Worktree creation legitimately blocks on git fetch / PR checkout. +const WORKTREE_CREATE_TIMEOUT_MS = 300_000 + +function callControl(method, path, body, timeoutMs) { return new Promise((resolve, reject) => { const data = body ? JSON.stringify(body) : undefined + const limit = timeoutMs || CALL_TIMEOUT_MS const req = http.request( { host: '127.0.0.1', @@ -117,12 +126,40 @@ function callControl(method, path, body) { }) } ) + req.setTimeout(limit, () => { + // The 'timeout' event only fires — it doesn't abort — so destroy first. + req.destroy(new Error('Ness did not respond within ' + limit + 'ms: ' + method + ' ' + path)) + }) req.on('error', reject) if (data) req.write(data) req.end() }) } +// Every other browser tool bounds its output (console logs at 200 entries, +// clickables at 500 items, screenshots at JPEG q70). Raw outerHTML is 1-5MB on +// a heavy page, which is a context-blowing tool result. +const DOM_DEFAULT_MAX_BYTES = 100_000 +const DOM_HARD_MAX_BYTES = 2_000_000 + +function truncateDom(html, maxBytes) { + const requested = Number(maxBytes) + const cap = Number.isFinite(requested) && requested > 0 + ? Math.min(Math.round(requested), DOM_HARD_MAX_BYTES) + : DOM_DEFAULT_MAX_BYTES + const total = Buffer.byteLength(html, 'utf-8') + if (total <= cap) return html + const head = Buffer.from(html, 'utf-8').subarray(0, cap).toString('utf-8') + return ( + head + + '\n' + ) +} + // Appended to create_worktree's description, and removed again by // stripForkAffordance when the feature is off. Kept as its own constant so the // two stay in sync — a literal that drifts would silently stop being stripped. @@ -334,11 +371,15 @@ const TOOLS = [ { name: 'get_tab_dom', description: - "Return the serialized outer HTML of the tab's document. Useful for inspecting rendered DOM that an HTTP fetch wouldn't see.", + "Return the serialized outer HTML of the tab's document. Useful for inspecting rendered DOM that an HTTP fetch wouldn't see. Truncated to 100KB by default — a heavy page's markup will blow your context otherwise, so prefer get_tab_clickables when you just need something to click.", inputSchema: { type: 'object', properties: { - tab_id: { type: 'string', description: 'Browser tab id from list_browser_tabs.' } + tab_id: { type: 'string', description: 'Browser tab id from list_browser_tabs.' }, + max_bytes: { + type: 'number', + description: 'Truncate the markup to this many bytes. Default 100000, max 2000000.' + } }, required: ['tab_id'] } @@ -656,18 +697,23 @@ async function handleToolCall(name, args) { ) { throw new Error('agentKind must be "claude" or "codex"') } - const r = await callControl('POST', '/worktrees', { - terminalId: TERMINAL_ID, - repoRoot: args.repoRoot, - branchName: args.branchName, - prNumber: prNumber, - baseBranch: args.baseBranch, - initialPrompt: args.initialPrompt, - agentKind: args.agentKind, - model: args.model, - alias: args.alias, - forkConversation: args.forkConversation === true - }) + const r = await callControl( + 'POST', + '/worktrees', + { + terminalId: TERMINAL_ID, + repoRoot: args.repoRoot, + branchName: args.branchName, + prNumber: prNumber, + baseBranch: args.baseBranch, + initialPrompt: args.initialPrompt, + agentKind: args.agentKind, + model: args.model, + alias: args.alias, + forkConversation: args.forkConversation === true + }, + WORKTREE_CREATE_TIMEOUT_MS + ) const agentLabel = args.agentKind === 'codex' ? 'Codex' : 'Claude' const modelSuffix = args.model ? ` (model: ${args.model})` : '' const aliasSuffix = args.alias && args.alias.trim() ? ` (alias: "${args.alias.trim()}")` : '' @@ -785,7 +831,7 @@ async function handleToolCall(name, args) { '/browser/dom?tabId=' + encodeURIComponent(args.tab_id) ) if (r == null || r.html == null) throw new Error(r && r.error ? r.error : 'dom read failed') - return r.html + return truncateDom(r.html, args.max_bytes) } if (name === 'get_tab_url') { if (!args || !args.tab_id) throw new Error('tab_id is required') diff --git a/resources/mcp-bridge.test.js b/resources/mcp-bridge.test.js index fdc0feea..44fdfe50 100644 --- a/resources/mcp-bridge.test.js +++ b/resources/mcp-bridge.test.js @@ -40,13 +40,14 @@ function startStub(handler) { }) } -function spawnBridge(port, token) { +function spawnBridge(port, token, extraEnv) { const proc = spawn(process.execPath, [BRIDGE], { env: { ...process.env, HARNESS_PORT: String(port), HARNESS_TOKEN: token, - HARNESS_TERMINAL_ID: 'test-terminal' + HARNESS_TERMINAL_ID: 'test-terminal', + ...extraEnv }, stdio: ['pipe', 'pipe', 'pipe'] }) @@ -307,6 +308,76 @@ describe('mcp-bridge create_worktree', () => { }) }) +describe('mcp-bridge get_tab_dom', () => { + let stub + let bridge + + afterEach(async () => { + if (bridge) await bridge.kill() + if (stub) await stub.close() + }) + + async function callGetDom(args, extraEnv) { + bridge.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + await bridge.next() + bridge.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'get_tab_dom', arguments: args } + }) + return bridge.next() + } + + function serveDom(html) { + return startStub((req, body, res) => { + if (req.url === '/scope') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ scope: null, browser: { enabled: true, mode: 'full' } })) + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ html })) + }) + } + + it('passes a small document through untouched', async () => { + stub = await serveDom('hi') + bridge = spawnBridge(stub.port, 'tok') + const response = await callGetDom({ tab_id: 'tab-1' }) + expect(response.result.content[0].text).toBe('hi') + }) + + it('caps a heavy page so the markup cannot blow the caller context', async () => { + stub = await serveDom('

' + 'x'.repeat(500_000) + '

') + bridge = spawnBridge(stub.port, 'tok') + const text = (await callGetDom({ tab_id: 'tab-1' })).result.content[0].text + expect(text.length).toBeLessThan(101_000) + expect(text).toMatch(/truncated by Ness: showing the first 100000 of 500007 bytes/) + }) + + it('honours an explicit max_bytes', async () => { + stub = await serveDom('y'.repeat(5000)) + bridge = spawnBridge(stub.port, 'tok') + const text = (await callGetDom({ tab_id: 'tab-1', max_bytes: 1000 })).result.content[0].text + expect(text.startsWith('y'.repeat(1000))).toBe(true) + expect(text).toMatch(/first 1000 of 5000 bytes/) + }) + + it('reports a timeout instead of hanging when the server never responds', async () => { + stub = await startStub((req, body, res) => { + if (req.url === '/scope') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ scope: null, browser: { enabled: true, mode: 'full' } })) + } + // Deliberately never respond — the pre-fix hang. + }) + bridge = spawnBridge(stub.port, 'tok', { HARNESS_CALL_TIMEOUT_MS: '400' }) + const response = await callGetDom({ tab_id: 'tab-1' }) + expect(response.result.isError).toBe(true) + expect(response.result.content[0].text).toMatch(/did not respond within 400ms/) + }, 10_000) +}) + describe('mcp-bridge log-size cap on startup', () => { let tmpDir diff --git a/src/main/browser-eval.test.ts b/src/main/browser-eval.test.ts new file mode 100644 index 00000000..ac65343b --- /dev/null +++ b/src/main/browser-eval.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import { EVAL_TIMEOUT_MS, evalWithTimeout, evalBlockedReason } from './browser-eval' + +const LIVE = { hasDocument: true, lastLoadError: null, crashed: false, crashReason: null } + +describe('evalWithTimeout', () => { + it('resolves with the value when the eval settles', async () => { + await expect(evalWithTimeout(async () => 'html', 'getDom')).resolves.toBe('html') + }) + + it('rejects rather than hanging when the eval never settles', async () => { + vi.useFakeTimers() + try { + const pending = evalWithTimeout(() => new Promise(() => {}), 'getDom tab=t1', 5000) + const assertion = expect(pending).rejects.toThrow(/getDom tab=t1 timed out after 5000ms/) + await vi.advanceTimersByTimeAsync(5000) + await assertion + } finally { + vi.useRealTimers() + } + }) + + it('propagates a real eval failure unchanged', async () => { + await expect( + evalWithTimeout(async () => { + throw new Error('SyntaxError') + }, 'getDom') + ).rejects.toThrow('SyntaxError') + }) + + it('clears the timer on the success path so it cannot hold the event loop open', async () => { + vi.useFakeTimers() + try { + await evalWithTimeout(async () => 'ok', 'getDom') + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('defaults to a bound short enough to beat the caller running out of patience', () => { + expect(EVAL_TIMEOUT_MS).toBeLessThanOrEqual(10_000) + }) +}) + +describe('evalBlockedReason', () => { + it('names the reload for a tab whose renderer died', () => { + expect(evalBlockedReason({ ...LIVE, crashed: true, crashReason: 'crashed' })).toBe( + 'tab renderer crashed (reason: crashed) — reload the tab' + ) + }) + + it('still reports a crash when the reason is unknown', () => { + expect(evalBlockedReason({ ...LIVE, crashed: true })).toBe( + 'tab renderer crashed — reload the tab' + ) + }) + + it('prefers the crash over the ERR_FAILED the crash also produced', () => { + expect( + evalBlockedReason({ + hasDocument: false, + lastLoadError: 'ERR_FAILED (-2)', + crashed: true, + crashReason: 'oom' + }) + ).toMatch(/renderer crashed \(reason: oom\)/) + }) + + it('reports the load failure for a tab that never committed a document', () => { + expect( + evalBlockedReason({ + ...LIVE, + hasDocument: false, + lastLoadError: "ERR_FAILED (-2) loading 'http://localhost:8765/local.html'" + }) + ).toBe( + "tab has no document loaded (last load failed: ERR_FAILED (-2) loading 'http://localhost:8765/local.html')" + ) + }) + + it('allows the eval once a document has committed, even after an earlier failure', () => { + expect(evalBlockedReason({ ...LIVE, lastLoadError: 'ERR_FAILED (-2)' })).toBeNull() + }) + + it('allows the eval for a tab still on its first load, so it queues as before', () => { + expect(evalBlockedReason({ ...LIVE, hasDocument: false })).toBeNull() + }) +}) diff --git a/src/main/browser-eval.ts b/src/main/browser-eval.ts new file mode 100644 index 00000000..f593945c --- /dev/null +++ b/src/main/browser-eval.ts @@ -0,0 +1,60 @@ +// Guards for evaluating JavaScript inside a browser tab. Shared by both +// BrowserManagerLike implementations (Electron WebContentsView and +// Playwright), so it deliberately imports nothing runtime-specific. + +/** Long enough for a slow real page's eval, far shorter than the patience of + * whoever is waiting on the MCP tool call. */ +export const EVAL_TIMEOUT_MS = 5_000 + +export interface TabEvalState { + /** True once any document has committed in the main frame. */ + hasDocument: boolean + /** Description of the last main-frame load failure, cleared on commit. */ + lastLoadError: string | null + /** True while the tab's renderer process is gone. */ + crashed: boolean + /** Why the renderer died, from `render-process-gone`. */ + crashReason: string | null +} + +/** `webContents.executeJavaScript` on a view with no live renderer — one whose + * main frame never committed, or whose renderer process died — neither + * resolves nor rejects. It queues for a frame that never arrives, so a + * try/catch around it can't rescue the caller. Race it against a timer. */ +export async function evalWithTimeout( + run: () => Promise, + what: string, + timeoutMs: number = EVAL_TIMEOUT_MS +): Promise { + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + run(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${what} timed out after ${timeoutMs}ms`)), + timeoutMs + ) + }) + ]) + } finally { + clearTimeout(timer) + } +} + +/** The actionable reason a tab can never be evaluated, or null when evaluating + * is worth attempting. Beats waiting out the timeout: it names the fix + * (reload the tab / the URL was dead) instead of just the symptom. + * + * A tab mid-first-load has no document yet but no error either — its eval + * queues until the frame commits, which is the behaviour callers want. */ +export function evalBlockedReason(state: TabEvalState): string | null { + if (state.crashed) { + const why = state.crashReason ? ` (reason: ${state.crashReason})` : '' + return `tab renderer crashed${why} — reload the tab` + } + if (!state.hasDocument && state.lastLoadError) { + return `tab has no document loaded (last load failed: ${state.lastLoadError})` + } + return null +} diff --git a/src/main/browser-manager-playwright.ts b/src/main/browser-manager-playwright.ts index 59c77bd3..61c98e6e 100644 --- a/src/main/browser-manager-playwright.ts +++ b/src/main/browser-manager-playwright.ts @@ -25,6 +25,7 @@ import type { BrowserManagerLike, CaptureResult, ConsoleLog } from './browser-ma import type { Store } from './store' import { log } from './debug' import { normalizeBrowserUrl } from './browser-url' +import { evalWithTimeout } from './browser-eval' const CONSOLE_LOG_CAP = 200 @@ -565,18 +566,20 @@ export class PlaywrightBrowserManager implements BrowserManagerLike { async scrollTab(tabId: string, deltaX: number, deltaY: number): Promise { const inst = this.instances.get(tabId) if (!inst) return - try { - await inst.page.evaluate( - ({ dx, dy }) => window.scrollBy(dx, dy), - { dx: Number(deltaX) || 0, dy: Number(deltaY) || 0 } - ) - } catch (err) { + await evalWithTimeout( + () => + inst.page.evaluate(({ dx, dy }) => window.scrollBy(dx, dy), { + dx: Number(deltaX) || 0, + dy: Number(deltaY) || 0 + }), + `scroll tab=${tabId}` + ).catch((err: unknown) => { log( 'browser-playwright', `scroll failed tab=${tabId}`, err instanceof Error ? err.message : err ) - } + }) } async showCursor( @@ -590,30 +593,36 @@ export class PlaywrightBrowserManager implements BrowserManagerLike { const px = Math.round(Number(x) || 0) const py = Math.round(Number(y) || 0) const pulse = opts?.pulse ? 1 : 0 - try { - await inst.page.evaluate(CURSOR_SCRIPT(px, py, pulse)) - } catch (err) { + await evalWithTimeout( + () => inst.page.evaluate(CURSOR_SCRIPT(px, py, pulse)), + `showCursor tab=${tabId}` + ).catch((err: unknown) => { log( 'browser-playwright', `showCursor failed tab=${tabId}`, err instanceof Error ? err.message : err ) - } + }) } + // A Playwright page always has a committed document (about:blank at worst), + // so the frameless never-settles hang the Electron backend guards against + // can't happen here — but page.evaluate has no timeout of its own, so bound + // it anyway to keep the two backends behaving the same under a wedged page. async getClickables(tabId: string): Promise { const inst = this.instances.get(tabId) if (!inst) return null - try { - return await inst.page.evaluate(CLICKABLES_SCRIPT) - } catch (err) { + return await evalWithTimeout( + () => inst.page.evaluate(CLICKABLES_SCRIPT), + `getClickables tab=${tabId}` + ).catch((err: unknown) => { log( 'browser-playwright', `getClickables failed tab=${tabId}`, err instanceof Error ? err.message : err ) - return null - } + throw err + }) } async capturePage( @@ -644,15 +653,15 @@ export class PlaywrightBrowserManager implements BrowserManagerLike { async getDom(tabId: string): Promise { const inst = this.instances.get(tabId) if (!inst) return null - try { - return await inst.page.content() - } catch (err) { - log( - 'browser-playwright', - `getDom failed tab=${tabId}`, - err instanceof Error ? err.message : err - ) - return null - } + return await evalWithTimeout(() => inst.page.content(), `getDom tab=${tabId}`).catch( + (err: unknown) => { + log( + 'browser-playwright', + `getDom failed tab=${tabId}`, + err instanceof Error ? err.message : err + ) + throw err + } + ) } } diff --git a/src/main/browser-manager.ts b/src/main/browser-manager.ts index 88b93490..49bafc44 100644 --- a/src/main/browser-manager.ts +++ b/src/main/browser-manager.ts @@ -9,6 +9,7 @@ import { viewportCaptureError } from './browser-screenshot' import { normalizeBrowserUrl } from './browser-url' +import { evalWithTimeout, evalBlockedReason } from './browser-eval' export type { ConsoleLog } @@ -27,6 +28,13 @@ export interface BrowserInstance { /** Viewport to give the view while parked — the size it last had on screen, * or the default for a tab that has never been displayed. */ parkedSize: { width: number; height: number } + /** True once any document has committed in the main frame. */ + hasDocument: boolean + /** Description of the last main-frame load failure, cleared on commit. */ + lastLoadError: string | null + /** Why the renderer process died, from `render-process-gone`. Cleared when a + * fresh document commits. */ + crashReason: string | null } const CONSOLE_LOG_CAP = 200 @@ -339,7 +347,10 @@ export class BrowserManager implements BrowserManagerLike { lastBounds: null, visible: false, parked: false, - parkedSize: { ...DEFAULT_VIEW_SIZE } + parkedSize: { ...DEFAULT_VIEW_SIZE }, + hasDocument: false, + lastLoadError: null, + crashReason: null } this.instances.set(tabId, inst) this.wireEvents(tabId, inst) @@ -365,6 +376,22 @@ export class BrowserManager implements BrowserManagerLike { } wc.on('did-navigate', nav) wc.on('did-navigate-in-page', nav) + wc.on('dom-ready', () => { + inst.hasDocument = true + inst.lastLoadError = null + inst.crashReason = null + }) + wc.on('did-fail-load', (_e, errorCode, errorDescription, validatedURL, isMainFrame) => { + // ERR_ABORTED means a newer navigation superseded this one, not a failure. + if (!isMainFrame || errorCode === -3) return + inst.lastLoadError = `${errorDescription} (${errorCode}) loading ${validatedURL}` + log('browser', `did-fail-load tab=${tabId}`, inst.lastLoadError) + }) + wc.on('render-process-gone', (_e, details) => { + if (details.reason === 'clean-exit') return + inst.crashReason = details.reason + log('browser', `render-process-gone tab=${tabId}`, details.reason) + }) wc.on('did-start-loading', () => { this.dispatchState(tabId, { loading: true }) }) @@ -568,13 +595,12 @@ export class BrowserManager implements BrowserManagerLike { async scrollTab(tabId: string, deltaX: number, deltaY: number): Promise { const inst = this.instances.get(tabId) if (!inst) return - try { - await inst.view.webContents.executeJavaScript( - `window.scrollBy(${Number(deltaX) || 0}, ${Number(deltaY) || 0})` - ) - } catch (err) { - log('browser', `scrollTab failed tab=${tabId}`, err instanceof Error ? err.message : err) - } + await this.evalInTab( + tabId, + inst, + `window.scrollBy(${Number(deltaX) || 0}, ${Number(deltaY) || 0})`, + 'scrollTab' + ).catch(() => {}) } async showCursor( @@ -619,10 +645,35 @@ export class BrowserManager implements BrowserManagerLike { ); } })()` + await this.evalInTab(tabId, inst, script, 'showCursor').catch(() => {}) + } + + /** Every executeJavaScript call goes through here. A tab with a dead + * renderer can never run script, and one that is merely slow must not wedge + * the caller forever — see browser-eval.ts. Rejects so the control server + * turns the reason into a 500 the agent can act on. */ + private async evalInTab( + tabId: string, + inst: BrowserInstance, + script: string, + what: string + ): Promise { + const wc = inst.view.webContents + const blocked = evalBlockedReason({ + hasDocument: inst.hasDocument, + lastLoadError: inst.lastLoadError, + crashed: inst.crashReason !== null || (!wc.isDestroyed() && wc.isCrashed()), + crashReason: inst.crashReason + }) + if (blocked) { + log('browser', `${what} unusable tab=${tabId}`, blocked) + throw new Error(blocked) + } try { - await inst.view.webContents.executeJavaScript(script) + return await evalWithTimeout(() => wc.executeJavaScript(script), `${what} tab=${tabId}`) } catch (err) { - log('browser', `showCursor failed tab=${tabId}`, err instanceof Error ? err.message : err) + log('browser', `${what} failed tab=${tabId}`, err instanceof Error ? err.message : err) + throw err } } @@ -630,19 +681,14 @@ export class BrowserManager implements BrowserManagerLike { const inst = this.instances.get(tabId) if (!inst) return null if (!inst.visible) this.park(inst) - try { - const result = await inst.view.webContents.executeJavaScript(CLICKABLES_SCRIPT) - // The script only reports in-viewport elements, so a 0×0 viewport looks - // like "this page has no buttons" rather than a failure. - const viewport = (result as { viewport?: { w: number; h: number } } | null)?.viewport - if (viewport && (viewport.w < 1 || viewport.h < 1)) { - log('browser', `getClickables tab=${tabId} has a ${viewport.w}x${viewport.h} viewport`) - } - return result ?? null - } catch (err) { - log('browser', `getClickables failed tab=${tabId}`, err instanceof Error ? err.message : err) - return null + const result = await this.evalInTab(tabId, inst, CLICKABLES_SCRIPT, 'getClickables') + // The script only reports in-viewport elements, so a 0×0 viewport looks + // like "this page has no buttons" rather than a failure. + const viewport = (result as { viewport?: { w: number; h: number } } | null)?.viewport + if (viewport && (viewport.w < 1 || viewport.h < 1)) { + log('browser', `getClickables tab=${tabId} has a ${viewport.w}x${viewport.h} viewport`) } + return result ?? null } /** Render a view that isn't on screen. @@ -744,15 +790,13 @@ export class BrowserManager implements BrowserManagerLike { async getDom(tabId: string): Promise { const inst = this.instances.get(tabId) if (!inst) return null - try { - const result = await inst.view.webContents.executeJavaScript( - 'document.documentElement.outerHTML' - ) - return typeof result === 'string' ? result : null - } catch (err) { - log('browser', `getDom failed tab=${tabId}`, err instanceof Error ? err.message : err) - return null - } + const result = await this.evalInTab( + tabId, + inst, + 'document.documentElement.outerHTML', + 'getDom' + ) + return typeof result === 'string' ? result : null } getTabInfo(tabId: string): { id: string; url: string; title: string } | null { diff --git a/src/main/control-server.test.ts b/src/main/control-server.test.ts index 3284f32b..4aac26f5 100644 --- a/src/main/control-server.test.ts +++ b/src/main/control-server.test.ts @@ -62,6 +62,9 @@ const runPendingPR = vi.fn< const BROWSER_TAB = 'browser-tab-1' let browserEnabled = false let captureResult: CaptureResult | null = null +/** Stands in for BrowserManager.getDom, which rejects when the tab can't be + * evaluated (load failed, or the eval timed out). */ +let domResult: () => Promise = async () => null const deps: ControlServerDeps = { getRepoRoots: () => ['/repo'], @@ -82,7 +85,7 @@ const deps: ControlServerDeps = { getTabUrl: () => null, getTabConsoleLogs: () => [], screenshotTab: async () => captureResult, - getTabDom: async () => null, + getTabDom: () => domResult(), getTabClickables: async () => null, navigateTab: () => {}, backTab: () => {}, @@ -566,3 +569,47 @@ describe('control-server /browser/screenshot endpoint', () => { expect(r.json.error).toMatch(/no longer available/) }) }) + +describe('control-server /browser/dom endpoint', () => { + beforeAll(() => { + browserEnabled = true + }) + afterAll(() => { + browserEnabled = false + domResult = async () => null + }) + + it('returns the markup', async () => { + domResult = async () => '' + const r = await get(`/browser/dom?tabId=${BROWSER_TAB}`) + expect(r.status).toBe(200) + expect(r.json.html).toBe('') + }) + + it('tells the caller to reload when the tab renderer crashed', async () => { + domResult = async () => { + throw new Error('tab renderer crashed (reason: crashed) — reload the tab') + } + const r = await get(`/browser/dom?tabId=${BROWSER_TAB}`) + expect(r.status).toBe(500) + expect(r.json.error).toMatch(/renderer crashed .* reload the tab/) + }) + + it('answers instead of hanging when the eval times out', async () => { + domResult = async () => { + throw new Error('getDom tab=t1 timed out after 5000ms') + } + const r = await get(`/browser/dom?tabId=${BROWSER_TAB}`) + expect(r.status).toBe(500) + expect(r.json.error).toMatch(/timed out after 5000ms/) + }) + + it('surfaces the failed-load reason so the caller knows the URL was dead', async () => { + domResult = async () => { + throw new Error("tab has no document loaded (last load failed: ERR_FAILED (-2) loading 'http://localhost:8765/')") + } + const r = await get(`/browser/dom?tabId=${BROWSER_TAB}`) + expect(r.status).toBe(500) + expect(r.json.error).toMatch(/tab has no document loaded/) + }) +})