diff --git a/packages/desktop/src/features/browser-automation/service.test.ts b/packages/desktop/src/features/browser-automation/service.test.ts index 8b33b3d89d..cbce7ae3bc 100644 --- a/packages/desktop/src/features/browser-automation/service.test.ts +++ b/packages/desktop/src/features/browser-automation/service.test.ts @@ -67,6 +67,7 @@ class FakeTab implements TabContents { public networkEntries: unknown[] = []; public consoleMessages: BrowserAutomationConsoleLogEntry[] = []; public dialogsToCapture: BrowserAutomationDialogEvent[] = []; + public paintDelayMs = 0; public captureNeverPaints = false; public captureThrows = false; public captureErrorMessage = "capture failed"; @@ -118,6 +119,9 @@ class FakeTab implements TabContents { public async executeJavaScript(code: string): Promise { this.scripts.push(code); + if (code.includes("requestAnimationFrame") && this.paintDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.paintDelayMs)); + } if (code.includes("document.body.innerText")) { return this.bodyText; } @@ -1591,6 +1595,31 @@ describe("executeAutomationCommand", () => { } }); + test("paint and pixel capture share the five-second screenshot budget", async () => { + vi.useFakeTimers(); + try { + const browser = new BrowserAutomationHarness(); + browser.tab.paintDelayMs = 4_000; + browser.tab.captureNeverPaints = true; + let result: unknown; + void browser + .execute({ + command: "screenshot", + args: { browserId: BROWSER_A }, + }) + .then((value) => { + result = value; + return value; + }); + await vi.advanceTimersByTimeAsync(4_999); + expect(result).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(result).toMatchObject({ ok: false, error: { code: "screenshot_no_frame" } }); + } finally { + vi.useRealTimers(); + } + }); + test("screenshot surfaces ordinary viewport capture errors", async () => { const browser = new BrowserAutomationHarness(); browser.tab.captureThrows = true; diff --git a/packages/desktop/src/features/browser-automation/service.ts b/packages/desktop/src/features/browser-automation/service.ts index a146708e17..a5e89378c9 100644 --- a/packages/desktop/src/features/browser-automation/service.ts +++ b/packages/desktop/src/features/browser-automation/service.ts @@ -162,8 +162,8 @@ async function runSerializedPixelCapture(capture: () => Promise): Promise< async function capturePixelFrameWithRetry( contents: TabContents, capture: () => Promise, + deadline: number, ): Promise { - const deadline = Date.now() + PIXEL_CAPTURE_TIMEOUT_MS; while (Date.now() < deadline) { try { contents.invalidate(); @@ -190,13 +190,14 @@ function isKnownNoFrameCaptureError(error: unknown): boolean { ); } -async function waitForPaint(contents: TabContents): Promise { +async function waitForPaint(contents: TabContents, deadline: number): Promise { // A hidden page may have unpainted DOM updates. The first animation callback // precedes paint; the next frame ensures capture cannot reuse the old surface. await withPixelCaptureTimeout( contents.executeJavaScript( "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))", ), + deadline - Date.now(), ); } @@ -206,8 +207,9 @@ async function runPaintedPixelCapture( ): Promise { return runSerializedPixelCapture(() => contents.withFrameProduction(async () => { - await waitForPaint(contents); - return capturePixelFrameWithRetry(contents, capture); + const deadline = Date.now() + PIXEL_CAPTURE_TIMEOUT_MS; + await waitForPaint(contents, deadline); + return capturePixelFrameWithRetry(contents, capture, deadline); }), ); }