diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-cloak/actions.ts index c12b35a5..19f7d499 100644 --- a/src/browser/runtime/local-cloak/actions.ts +++ b/src/browser/runtime/local-cloak/actions.ts @@ -62,6 +62,20 @@ function invalidRequest(command: BrowserRuntimeCommand, error: string): BrowserR return { id: command.id, ok: false, errorCode: 'invalid_request', error }; } +/** + * Translate the command vocabulary ('load' | 'none') into Playwright's for a + * `page.goto` call. 'none' maps to '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. + * + * Every Playwright-backed navigation in this runtime goes through here, so a + * future waitUntil value reaches all of them at once — the hardcoded literal at + * the second call site is what left `tab new --url` hanging after #106/#107. + */ +function toGotoWaitUntil(waitUntil: BrowserRuntimeCommand['waitUntil']): 'load' | 'commit' { + return waitUntil === 'none' ? 'commit' : 'load'; +} + async function resolveLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { const profileId = resolveCloakCommandProfileId(manager, command); if (command.page) { @@ -204,9 +218,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 +233,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 +400,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: runId: command.runId, idleTimeout: command.idleTimeout, url: command.url, + waitUntil: toGotoWaitUntil(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/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index ef74a144..eb4f7796 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -1531,3 +1531,82 @@ describe('CloakSessionManager', () => { expect(launchPersistentContext).toHaveBeenCalledTimes(2); }); }); + +describe('waitUntil plumbing', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + function managerWithPage() { + const launched = fakeContext(); + launched.context.newPage.mockResolvedValue(launched.page); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + return { manager, page: launched.page }; + } + + // 'none' has to reach Playwright as 'commit'. Waiting for 'load' on a site that + // never goes idle is the hang #106 was filed about. + it('maps waitUntil none to commit when opening a tab with a url', async () => { + const { manager, page } = managerWithPage(); + + await dispatchCloakAction(manager, { + id: 'cmd-tab-none', + action: 'tabs', + op: 'new', + session: 'work', + surface: 'browser', + url: 'https://example.com/', + waitUntil: 'none', + }); + + expect(page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'commit' }); + }); + + it('defaults a tab opened without waitUntil to load', async () => { + const { manager, page } = managerWithPage(); + + await dispatchCloakAction(manager, { + id: 'cmd-tab-default', + action: 'tabs', + op: 'new', + session: 'work', + surface: 'browser', + url: 'https://example.com/', + }); + + expect(page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'load' }); + }); + + it('still maps waitUntil none to commit on navigate', async () => { + const { manager, page } = managerWithPage(); + + await dispatchCloakAction(manager, { + id: 'cmd-navigate-none', + action: 'navigate', + session: 'work', + surface: 'browser', + url: 'https://example.com/', + waitUntil: 'none', + }); + + expect(page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'commit' }); + }); + + it('defaults navigate without waitUntil to load', async () => { + const { manager, page } = managerWithPage(); + + await dispatchCloakAction(manager, { + id: 'cmd-navigate-default', + action: 'navigate', + session: 'work', + surface: 'browser', + url: 'https://example.com/', + }); + + expect(page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'load' }); + }); +}); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 49cef1ae..d11d3eba 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -57,6 +57,16 @@ export interface SessionKeyInput { freshPage?: boolean; } +export type NewPageInput = SessionKeyInput & { + url?: string; + /** + * Playwright `goto` readiness for `url`, already translated from the command + * vocabulary by the caller. Defaults to 'load'; 'commit' skips waiting for the + * load event on sites that never go idle. + */ + waitUntil?: 'load' | 'commit'; +}; + type PageEntry = { page: PlaywrightPage; pageId: string; @@ -414,7 +424,7 @@ export class CloakSessionManager { }))); } - async newPage(input: SessionKeyInput & { url?: string }): Promise { + async newPage(input: NewPageInput): Promise { return this.newPageAttempt(input, 0); } @@ -422,7 +432,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: NewPageInput, attempt: number): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); const sessionId = requireSessionId(input); @@ -433,7 +443,7 @@ export class CloakSessionManager { }); if (input.url) { try { - await acquired.page.goto(input.url, { waitUntil: 'load' }); + await acquired.page.goto(input.url, { waitUntil: input.waitUntil ?? 'load' }); } catch (error) { if (attempt === 0 && isClosedContextError(error)) { this.invalidateProfileRuntime(profileId, acquired.runtime);