Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 86 additions & 1 deletion src/browser/run/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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('<p>content</p>');
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<void>(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('<img src="http://127.0.0.1:${port}/hang">', { timeout: 50 });
`)).rejects.toMatchObject({
code: 'BROWSER_RUN_TIMEOUT',
hint: SET_CONTENT_TIMEOUT_HINT,
});
} finally {
server.closeAllConnections();
await new Promise<void>(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('<p>content</p>');
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');`, {
Expand Down
29 changes: 27 additions & 2 deletions src/browser/run/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.';

Expand All @@ -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;
}

Expand All @@ -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);
}

Expand Down Expand Up @@ -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)) {
Expand Down