diff --git a/package-lock.json b/package-lock.json index 2b41dd18..a10e3642 100644 --- a/package-lock.json +++ b/package-lock.json @@ -198,6 +198,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -244,6 +245,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -275,6 +277,7 @@ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -2675,6 +2678,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2687,6 +2691,7 @@ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -3044,6 +3049,7 @@ "integrity": "sha512-6w9FwtT8WQqRAyTNR+Z+86kghRqpmOLjXUrBlBT6T+CQGDuIMm0VmAqaFUFBIeKDTGobE6/YSigZYLeomzBaRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -3108,6 +3114,7 @@ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", diff --git a/src/browser/page.test.ts b/src/browser/page.test.ts index 6ae25c35..8484bf74 100644 --- a/src/browser/page.test.ts +++ b/src/browser/page.test.ts @@ -434,6 +434,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 bf546fd8..3eaa121e 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -227,10 +227,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 c12b35a5..5087f1f2 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'; @@ -204,9 +204,6 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: case 'navigate': { if (!command.url) return invalidRequest(command, 'Missing url'); const profileId = resolveCloakCommandProfileId(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. const lease = await manager.navigatePage( { profileId, @@ -222,7 +219,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: windowMode: command.windowMode, }, command.url, - command.waitUntil === 'none' ? 'commit' : 'load', + toGotoWaitUntil(command.waitUntil), ); return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url(), timedOut: false }, page: lease.pageId }; } @@ -389,6 +386,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: runId: command.runId, 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 daf0187d..fffa8b1a 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -270,6 +270,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[0].goto).toHaveBeenCalledWith('https://second.example/', expect.objectContaining({ waitUntil: 'commit' })); + }); + it('does not execute a queued command after its daemon deadline expires', async () => { const { provider, page } = makeProviderWithFakePage(); let releaseFirst!: () => void; diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index f2e91dfe..ccb87a63 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -39,6 +39,17 @@ export function resolveCloakBrowserVersion(): string | undefined { return cachedCloakBrowserVersion; } +/** + * 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; @@ -414,7 +425,7 @@ export class CloakSessionManager { }))); } - async newPage(input: SessionKeyInput & { url?: string }): Promise { + async newPage(input: SessionKeyInput & { url?: string; waitUntil?: 'load' | 'none' }): Promise { return this.newPageAttempt(input, 0); } @@ -422,7 +433,7 @@ export class CloakSessionManager { return this.navigatePageAttempt(input, url, waitUntil, 0); } - private async newPageAttempt(input: SessionKeyInput & { url?: string }, attempt: number): Promise { + private async newPageAttempt(input: SessionKeyInput & { url?: string; waitUntil?: 'load' | 'none' }, attempt: number): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); const sessionId = requireSessionId(input); @@ -433,7 +444,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 (attempt === 0 && isClosedContextError(error)) { this.invalidateProfileRuntime(profileId, acquired.runtime); diff --git a/src/types.ts b/src/types.ts index 903379b0..1c23ba62 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;