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/)
+ })
+})