diff --git a/src/browser/page.test.ts b/src/browser/page.test.ts index 157af61..42fe9ec 100644 --- a/src/browser/page.test.ts +++ b/src/browser/page.test.ts @@ -432,6 +432,19 @@ describe('Page active target tracking', () => { })); }); + it('forwards waitUntil to the tabs/new command', async () => { + sendCommandFullMock.mockResolvedValueOnce({ data: {}, page: 'page-2' }); + + const page = new Page('default'); + await page.newTab?.('https://second.example', { waitUntil: 'none' }); + + expect(sendCommandFullMock).toHaveBeenCalledWith('tabs', expect.objectContaining({ + op: 'new', + url: 'https://second.example', + waitUntil: 'none', + })); + }); + it('closes a tab by explicit page identity', async () => { sendCommandMock.mockResolvedValueOnce({ closed: 'page-2' }); diff --git a/src/browser/page.ts b/src/browser/page.ts index 927de36..e118612 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -224,10 +224,11 @@ export class Page extends BasePage { return Array.isArray(result) ? result : []; } - async newTab(url?: string): Promise { + async newTab(url?: string, options?: { waitUntil?: 'load' | 'none' }): Promise { const result = await sendCommandFull('tabs', { op: 'new', ...(url !== undefined && { url }), + ...(options?.waitUntil && { waitUntil: options.waitUntil }), ...this._sessionOpts(), }); this._lastUrl = null; diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-cloak/actions.ts index b9e313e..d8cf005 100644 --- a/src/browser/runtime/local-cloak/actions.ts +++ b/src/browser/runtime/local-cloak/actions.ts @@ -10,7 +10,7 @@ import { import { redactText, redactUrl } from '../../../observation/redaction.js'; import { articleHtmlToMarkdown } from '../../../download/article-download.js'; import { waitForDownload } from './downloads.js'; -import type { CloakSessionManager } from './session-manager.js'; +import { toGotoWaitUntil, type CloakSessionManager } from './session-manager.js'; import type { BrowserContext, Frame, Page as PlaywrightPage } from 'playwright-core'; import { runBrowserProgram } from '../../run/runner.js'; import { BROWSER_RUN_MAX_SOURCE_BYTES } from '../../run/types.js'; @@ -182,10 +182,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: case 'navigate': { if (!command.url) return invalidRequest(command, 'Missing url'); const lease = await resolveLease(manager, command); - // 'none' maps to Playwright's 'commit': sites that stream analytics forever - // never fire the load event, so adapters gating readiness on their own - // selector waits must be able to skip it. - await lease.page.goto(command.url, { waitUntil: command.waitUntil === 'none' ? 'commit' : 'load' }); + await lease.page.goto(command.url, { waitUntil: toGotoWaitUntil(command.waitUntil) }); return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url(), timedOut: false }, page: lease.pageId }; } case 'exec': { @@ -336,6 +333,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: siteSession: command.siteSession, idleTimeout: command.idleTimeout, url: command.url, + waitUntil: command.waitUntil, windowMode: command.windowMode, }); return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url() }, page: lease.pageId }; diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index 147f7d9..d56db31 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -114,6 +114,22 @@ describe('LocalCloakRuntimeProvider', () => { expect(page.goto).toHaveBeenCalledWith('https://example.com/', expect.objectContaining({ waitUntil: 'commit' })); }); + it("maps waitUntil 'none' to a commit-only wait when opening a tab", async () => { + const { provider, pages } = makeProviderWithFakePage(); + const result = await provider.dispatch({ + id: 'new', + action: 'tabs', + op: 'new', + session: 'work', + surface: 'browser', + url: 'https://second.example/', + waitUntil: 'none', + profileId: 'default', + }); + expect(result).toMatchObject({ id: 'new', ok: true, page: expect.any(String) }); + expect(pages[1].goto).toHaveBeenCalledWith('https://second.example/', expect.objectContaining({ waitUntil: 'commit' })); + }); + it('evaluates JavaScript in the resolved page', async () => { const { provider } = makeProviderWithFakePage(); const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 429db9f..bfaa1f4 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -21,6 +21,17 @@ export function resolveCloakBrowserVersion(): string | undefined { } } +/** + * Map the protocol's navigation wait condition onto Playwright's `goto` option. + * 'none' becomes 'commit': sites that stream analytics forever never fire the + * load event, so callers gating readiness on their own selector waits must be + * able to skip it. Every `goto` in this runtime routes through here so a new + * call site cannot quietly reintroduce a hardcoded 'load'. + */ +export function toGotoWaitUntil(waitUntil?: 'load' | 'none'): 'load' | 'commit' { + return waitUntil === 'none' ? 'commit' : 'load'; +} + export type LaunchPersistentContext = typeof cloakLaunchPersistentContext; export type RecoverLockedProfile = (userDataDir: string) => Promise; @@ -257,7 +268,7 @@ export class CloakSessionManager { }))); } - async newPage(input: SessionKeyInput & { url?: string }): Promise { + async newPage(input: SessionKeyInput & { url?: string; waitUntil?: 'load' | 'none' }): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); const surface = normalizeSurface(input.surface); @@ -269,7 +280,7 @@ export class CloakSessionManager { ); if (input.url) { try { - await acquired.page.goto(input.url, { waitUntil: 'load' }); + await acquired.page.goto(input.url, { waitUntil: toGotoWaitUntil(input.waitUntil) }); } catch (error) { if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); throw error; diff --git a/src/types.ts b/src/types.ts index 903379b..1c23ba6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -110,7 +110,7 @@ export interface IPage { waitForDownload?(pattern?: string, timeoutMs?: number): Promise; tabs(): Promise; closeTab?(target?: number | string): Promise; - newTab?(url?: string): Promise; + newTab?(url?: string, options?: { waitUntil?: 'load' | 'none' }): Promise; selectTab(target: number | string): Promise; networkRequests(includeStatic?: boolean): Promise; consoleMessages(level?: string): Promise;