diff --git a/docs/browser.md b/docs/browser.md index 48b89e264..54cfe5cad 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -189,6 +189,7 @@ Actions: Notes: - `upload` and `dialog` are **arming** calls; run them before the click/press that triggers the chooser/dialog. +- The arm default timeout is **30s**; pass `timeoutMs` if you need longer. - `snapshot --format ai` returns AI snapshot markup used for ref-based actions. ## Security & privacy notes diff --git a/src/browser/pw-tools-core.test.ts b/src/browser/pw-tools-core.test.ts index 4c2cfbd65..d56cf48f1 100644 --- a/src/browser/pw-tools-core.test.ts +++ b/src/browser/pw-tools-core.test.ts @@ -2,17 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; let currentPage: Record | null = null; let currentRefLocator: Record | null = null; +let pageState: { console: unknown[]; armIdUpload: number; armIdDialog: number }; const sessionMocks = vi.hoisted(() => ({ getPageForTargetId: vi.fn(async () => { if (!currentPage) throw new Error("missing page"); return currentPage; }), - ensurePageState: vi.fn(() => ({ - console: [], - armIdUpload: 0, - armIdDialog: 0, - })), + ensurePageState: vi.fn(() => pageState), refLocator: vi.fn(() => { if (!currentRefLocator) throw new Error("missing locator"); return currentRefLocator; @@ -29,6 +26,7 @@ describe("pw-tools-core", () => { beforeEach(() => { currentPage = null; currentRefLocator = null; + pageState = { console: [], armIdUpload: 0, armIdDialog: 0 }; for (const fn of Object.values(sessionMocks)) fn.mockClear(); }); @@ -107,4 +105,122 @@ describe("pw-tools-core", () => { }), ).rejects.toThrow(/fullPage is not supported/i); }); + + it("arms the next file chooser and sets files (default timeout)", async () => { + const fileChooser = { setFiles: vi.fn(async () => {}) }; + const waitForEvent = vi.fn( + async (_event: string, _opts: unknown) => fileChooser, + ); + currentPage = { + waitForEvent, + keyboard: { press: vi.fn(async () => {}) }, + }; + + const mod = await importModule(); + await mod.armFileUploadViaPlaywright({ + cdpPort: 18792, + targetId: "T1", + paths: ["/tmp/a.txt"], + }); + + // waitForEvent is awaited immediately; handler continues async. + await Promise.resolve(); + + expect(waitForEvent).toHaveBeenCalledWith("filechooser", { + timeout: 30_000, + }); + expect(fileChooser.setFiles).toHaveBeenCalledWith(["/tmp/a.txt"]); + }); + + it("arms the next file chooser and escapes if no paths provided", async () => { + const fileChooser = { setFiles: vi.fn(async () => {}) }; + const press = vi.fn(async () => {}); + const waitForEvent = vi.fn(async () => fileChooser); + currentPage = { + waitForEvent, + keyboard: { press }, + }; + + const mod = await importModule(); + await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: [] }); + await Promise.resolve(); + + expect(fileChooser.setFiles).not.toHaveBeenCalled(); + expect(press).toHaveBeenCalledWith("Escape"); + }); + + it("last file-chooser arm wins", async () => { + let resolve1: ((value: unknown) => void) | null = null; + let resolve2: ((value: unknown) => void) | null = null; + + const fc1 = { setFiles: vi.fn(async () => {}) }; + const fc2 = { setFiles: vi.fn(async () => {}) }; + + const waitForEvent = vi + .fn() + .mockImplementationOnce( + () => + new Promise((r) => { + resolve1 = r; + }) as Promise, + ) + .mockImplementationOnce( + () => + new Promise((r) => { + resolve2 = r; + }) as Promise, + ); + + currentPage = { + waitForEvent, + keyboard: { press: vi.fn(async () => {}) }, + }; + + const mod = await importModule(); + await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: ["/tmp/1"] }); + await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: ["/tmp/2"] }); + + resolve1?.(fc1); + resolve2?.(fc2); + await Promise.resolve(); + + expect(fc1.setFiles).not.toHaveBeenCalled(); + expect(fc2.setFiles).toHaveBeenCalledWith(["/tmp/2"]); + }); + + it("arms the next dialog and accepts/dismisses (default timeout)", async () => { + const accept = vi.fn(async () => {}); + const dismiss = vi.fn(async () => {}); + const dialog = { accept, dismiss }; + const waitForEvent = vi.fn(async () => dialog); + currentPage = { + waitForEvent, + }; + + const mod = await importModule(); + await mod.armDialogViaPlaywright({ + cdpPort: 18792, + accept: true, + promptText: "x", + }); + await Promise.resolve(); + + expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 30_000 }); + expect(accept).toHaveBeenCalledWith("x"); + expect(dismiss).not.toHaveBeenCalled(); + + accept.mockClear(); + dismiss.mockClear(); + waitForEvent.mockClear(); + + await mod.armDialogViaPlaywright({ + cdpPort: 18792, + accept: false, + }); + await Promise.resolve(); + + expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 30_000 }); + expect(dismiss).toHaveBeenCalled(); + expect(accept).not.toHaveBeenCalled(); + }); }); diff --git a/src/browser/pw-tools-core.ts b/src/browser/pw-tools-core.ts index e69a87e7b..d28c44973 100644 --- a/src/browser/pw-tools-core.ts +++ b/src/browser/pw-tools-core.ts @@ -222,7 +222,7 @@ export async function armFileUploadViaPlaywright(opts: { }): Promise { const page = await getPageForTargetId(opts); const state = ensurePageState(page); - const timeout = Math.max(500, Math.min(60_000, opts.timeoutMs ?? 10_000)); + const timeout = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 30_000)); state.armIdUpload = nextUploadArmId += 1; const armId = state.armIdUpload; @@ -256,7 +256,7 @@ export async function armDialogViaPlaywright(opts: { }): Promise { const page = await getPageForTargetId(opts); const state = ensurePageState(page); - const timeout = Math.max(500, Math.min(60_000, opts.timeoutMs ?? 10_000)); + const timeout = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 30_000)); state.armIdDialog = nextDialogArmId += 1; const armId = state.armIdDialog; diff --git a/src/cli/browser-cli-actions-input.ts b/src/cli/browser-cli-actions-input.ts index c7e92a926..a18bc7be1 100644 --- a/src/cli/browser-cli-actions-input.ts +++ b/src/cli/browser-cli-actions-input.ts @@ -268,7 +268,7 @@ export function registerBrowserActionInputCommands( .option("--target-id ", "CDP target id (or unique prefix)") .option( "--timeout-ms ", - "How long to wait for the next file chooser (default: 10000)", + "How long to wait for the next file chooser (default: 30000)", (v: string) => Number(v), ) .action(async (paths: string[], opts, cmd) => { @@ -332,7 +332,7 @@ export function registerBrowserActionInputCommands( .option("--target-id ", "CDP target id (or unique prefix)") .option( "--timeout-ms ", - "How long to wait for the next dialog (default: 10000)", + "How long to wait for the next dialog (default: 30000)", (v: string) => Number(v), ) .action(async (opts, cmd) => {