From 8da23e711a92839274999b11c93f5210878ff36c Mon Sep 17 00:00:00 2001 From: Ayushraj06-bit Date: Mon, 14 Sep 2026 01:30:36 +0530 Subject: [PATCH] fix(browser): name the Cloak setContent hang instead of advising a longer timeout page.setContent() applies its markup and then never resolves on the local Cloak runtime: Playwright settles the call on a console.debug sentinel it writes from the utility world, and Cloak's Chromium accepts Runtime.enable without ever emitting Runtime.consoleAPICalled. A program that opens with setContent therefore spends its whole budget on the first statement and is told to increase --timeout, which cannot help. Classify the timeout through the existing timeoutKind path and name the document.open/write/close recovery CloakBrowser publishes on CloakHQ/cloakbrowser#360, which keeps the page on its current origin. setContent is checked after popup and download so neither loses a timeout it claims today, and message detection requires the timeout as well as the "setting frame content" call log so a detached frame is not retyped. Refs #448, which reported this as a filechooser failure; filechooser itself forwards correctly through the Cloak bridge. --- docs/cli-reference.mdx | 14 ++++++ src/browser/run/runner.test.ts | 87 +++++++++++++++++++++++++++++++++- src/browser/run/runner.ts | 29 +++++++++++- 3 files changed, 127 insertions(+), 3 deletions(-) diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index e0cbd26b2..dd716fbf3 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -73,6 +73,20 @@ passively inspect request and response events. They cannot access Node.js, the filesystem, environment variables, raw CDP endpoints, browser launch APIs, browser-context ownership, or `context.newPage()`. +On the local Cloak runtime, `page.setContent()` applies the markup but never +resolves, because Playwright settles that call on a console event Cloak does not +emit. Write the markup directly instead, which keeps the page on its current +origin: + +```js +await page.evaluate(html => { + document.open(); + document.write(html); + document.close(); +}, html); +await page.waitForLoadState('load'); +``` + The public browser surface is `tabs`, `bind`, `run`, and `snapshot`. ## Direct URL Fetch diff --git a/src/browser/run/runner.test.ts b/src/browser/run/runner.test.ts index c885bbf3a..eca66baad 100644 --- a/src/browser/run/runner.test.ts +++ b/src/browser/run/runner.test.ts @@ -23,7 +23,12 @@ import { LocalBrowserRunArtifactSink } from './artifacts.js'; import { MemorySnapshotBaselineStore } from '../snapshot/index.js'; import { PlaywrightTransport, unsupportedApiMessage } from './playwright-transport.js'; import { QuickJSHost } from './quickjs-host.js'; -import { DOWNLOAD_WAIT_TIMEOUT_HINT, POPUP_WAIT_TIMEOUT_HINT, runBrowserProgram } from './runner.js'; +import { + DOWNLOAD_WAIT_TIMEOUT_HINT, + POPUP_WAIT_TIMEOUT_HINT, + SET_CONTENT_TIMEOUT_HINT, + runBrowserProgram, +} from './runner.js'; const playwrightServer = createRequire(import.meta.url)( 'playwright-core/lib/coreBundle', @@ -998,6 +1003,86 @@ afterAll(async () => { expect(POPUP_WAIT_TIMEOUT_HINT).not.toMatch(/page\.goto on the current page/); }); + // page.setContent() applies the markup and then never settles on the local Cloak + // runtime, because Playwright resolves it on a console sentinel Cloak never emits. + // The run burns its whole budget on the first statement, so a generic "increase + // --timeout" sends the caller back around the same 30s wall. + it('tells the caller to navigate instead when a run that sets content times out', async () => { + await expect(run(` + await page.setContent('

content

'); + await new Promise(() => {}); + `, { timeoutMs: 25 })).rejects.toMatchObject({ + code: 'BROWSER_RUN_TIMEOUT', + hint: SET_CONTENT_TIMEOUT_HINT, + }); + }); + + it('types a setContent timeout as a browser-run timeout', async () => { + // A subresource that never answers keeps the load event pending, which is the + // shape Cloak produces for every setContent call. + const server = http.createServer(() => {}); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as import('node:net').AddressInfo; + try { + await expect(run(` + await page.setContent('', { timeout: 50 }); + `)).rejects.toMatchObject({ + code: 'BROWSER_RUN_TIMEOUT', + hint: SET_CONTENT_TIMEOUT_HINT, + }); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } + }); + + it('keeps the popup hint for a timed-out program that also sets content', async () => { + await expect(run(` + await page.setContent('

content

'); + await page.waitForEvent('popup'); + `, { timeoutMs: 25 })).rejects.toMatchObject({ + code: 'BROWSER_RUN_TIMEOUT', + hint: POPUP_WAIT_TIMEOUT_HINT, + }); + }); + + it('leaves a timeout with no setContent call on the generic hint', async () => { + await expect(run(` + await new Promise(() => {}); + `, { timeoutMs: 25 })).rejects.toMatchObject({ + code: 'BROWSER_RUN_TIMEOUT', + hint: expect.stringContaining('increase --timeout'), + }); + }); + + it('does not retype a non-timeout failure that merely names setContent', async () => { + const error = await runError("throw new Error('page.setContent(html) rejected upstream');"); + + expect(error.code).toBeUndefined(); + expect(error.message).toContain('page.setContent(html) rejected upstream'); + }); + + it('does not retype a setContent failure that is not a timeout', async () => { + // A detached frame logs the same "setting frame content" line as a timeout does. + const error = await runError( + "throw new Error('Frame was detached\\nCall log:\\n - setting frame content, waiting until \"load\"');", + ); + + expect(error.code).toBeUndefined(); + expect(error.message).toContain('Frame was detached'); + }); + + it('names the document.write recovery in the setContent hint', () => { + // The content is already in the page when this fires, so an agent told only to + // retry re-runs a call that can never return. This is the recovery CloakBrowser + // publishes on CloakHQ/cloakbrowser#360, and unlike a data: URL it keeps the + // page on its current origin. + expect(SET_CONTENT_TIMEOUT_HINT).toContain('document.write(html)'); + expect(SET_CONTENT_TIMEOUT_HINT).toContain('waitForLoadState'); + expect(SET_CONTENT_TIMEOUT_HINT).toContain('Cloak'); + expect(SET_CONTENT_TIMEOUT_HINT).not.toMatch(/increase --timeout/); + }); + it('cancels an in-flight run through its abort signal', async () => { const controller = new AbortController(); const pending = run(`await page.waitForEvent('popup');`, { diff --git a/src/browser/run/runner.ts b/src/browser/run/runner.ts index edce49930..bc9fcab01 100644 --- a/src/browser/run/runner.ts +++ b/src/browser/run/runner.ts @@ -63,6 +63,11 @@ export const POPUP_WAIT_TIMEOUT_HINT = [ 'Recovery is the same either way: open a tracked tab with context.newPage(), then goto the destination URL on that new page. Do not page.goto on the opener.', "Cap waitForEvent('popup') with a short timeout instead of the default.", ].join(' '); +export const SET_CONTENT_TIMEOUT_HINT = [ + 'page.setContent() never resolves on the local Cloak runtime.', + 'Playwright settles it on a console sentinel it writes next to the markup, and Cloak emits no console events, so the HTML is applied but the call hangs until the run limit — the page may already hold the content.', + 'Write it directly instead: await page.evaluate(html => { document.open(); document.write(html); document.close(); }, html), then await page.waitForLoadState("load").', +].join(' '); const NO_CAPTURE_HINT = 'The program completed without captured evidence; return structured data, console.log concise evidence, or call writeArtifact(filename, bytes) to save files.'; const NODE_SANDBOX_HINT = 'Node require/fs are not available inside browser run. Use Playwright page/context/browser APIs, page.request for HTTP, or writeArtifact(filename, bytes) for files.'; @@ -76,9 +81,24 @@ function isPopupOrNewTabWait(text: string): boolean { || /waitForEvent\(\s*['"](?:popup|page)['"]\s*\)/.test(text); } -function timeoutKind(message: string, source?: string): 'popup' | 'download' | undefined { +// Split in two because, unlike a popup or download wait, a bare `.setContent(` says nothing +// about timing out: it must only classify a program's source, never an arbitrary message. +// The sandbox client reports the bare `Timeout 200ms exceeded.` without the `page.setContent:` +// prefix the Node client adds, so the call log is what identifies the call — and a detached +// frame or a closed target logs that same line, so the timeout itself has to be there too. +function isSetContentTimeout(text: string): boolean { + return /setting frame content/i.test(text) && /timeout .*exceeded/i.test(text); +} + +function callsSetContent(source: string): boolean { + return /\.setContent\(/.test(source); +} + +// setContent is checked last so popup and download keep the timeout they already claim. +function timeoutKind(message: string, source?: string): 'popup' | 'download' | 'setContent' | undefined { if (isPopupOrNewTabWait(message) || (source !== undefined && isPopupOrNewTabWait(source))) return 'popup'; if (isDownloadWait(message) || (source !== undefined && isDownloadWait(source))) return 'download'; + if (isSetContentTimeout(message) || (source !== undefined && callsSetContent(source))) return 'setContent'; return undefined; } @@ -98,6 +118,11 @@ function timeoutRunError(message: string, source?: string): BrowserRunError { DOWNLOAD_WAIT_TIMEOUT_HINT, ); } + // The source only proves the program calls setContent, not that it hung there, so the + // timeout keeps its own message and gains the hint. + if (kind === 'setContent') { + return new BrowserRunError('BROWSER_RUN_TIMEOUT', message, SET_CONTENT_TIMEOUT_HINT); + } return new BrowserRunError('BROWSER_RUN_TIMEOUT', message, GENERIC_TIMEOUT_HINT); } @@ -162,7 +187,7 @@ function normalizeExecutionError(error: unknown): Error { NODE_SANDBOX_HINT, ); } - if (isPopupOrNewTabWait(message) || isDownloadWait(message)) { + if (isPopupOrNewTabWait(message) || isDownloadWait(message) || isSetContentTimeout(message)) { return timeoutRunError(sanitize(message)); } if (/interrupted|execution timeout|timed out/i.test(message)) {