fix(desktop): share screenshot deadline across paint and capture

This commit is contained in:
Mohamed Boudra
2026-09-10 09:27:35 +02:00
parent 4cf4b70ee0
commit 7eda0d89e8
2 changed files with 35 additions and 4 deletions
@@ -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<unknown> {
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;
@@ -162,8 +162,8 @@ async function runSerializedPixelCapture<T>(capture: () => Promise<T>): Promise<
async function capturePixelFrameWithRetry<T>(
contents: TabContents,
capture: () => Promise<T>,
deadline: number,
): Promise<T> {
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<void> {
async function waitForPaint(contents: TabContents, deadline: number): Promise<void> {
// 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<T>(
): Promise<T> {
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);
}),
);
}