fix(capture): bound scroll/evaluate timeouts so heavy pages keep capturing (#3236)

* fix(capture): bound scroll/evaluate timeouts so heavy pages keep capturing

Fonts/Vercel-class sites were failing after navigation when a single in-page
scroll/evaluate hung until protocolTimeout. Drive lazy scroll from Node,
degrade on evaluate timeouts, and stop labeling those failures as bot blocks.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(capture): enforce stage budgets and surface evaluate degradation

Bound each scroll/content evaluate with remaining stage time so a wedged
page.evaluate cannot outlive the advertised 15s/8s budgets, skip recovery
CDP calls after expiry, and return/propagate timed-out animation and
screenshot work so caller warnings are reachable.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(capture): prefer TimeoutError instanceof for timeout classification

Use puppeteer-core TimeoutError as the primary signal for navigation vs
evaluate/protocol timeouts, with message checks only as a fallback for
string formatting and non-TimeoutError cases.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xuanru Li
2026-08-11 18:26:22 -07:00
committed by GitHub
co-authored by Cursor
parent 93fbb6d7fd
commit 43dba22057
12 changed files with 621 additions and 106 deletions
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { collectAnimationCatalog } from "./animationCataloger.js";
describe("collectAnimationCatalog degradation", () => {
it("surfaces evaluate timeouts via timedOut instead of silent empty success", async () => {
const page = {
evaluate: vi.fn(() => new Promise(() => undefined)),
};
const cdp = {
send: vi.fn(async () => undefined),
};
const outcome = await collectAnimationCatalog(page as never, [], cdp as never, {
scrollBudgetMs: 20,
evaluateBudgetMs: 20,
});
expect(outcome.timedOut).toBe(true);
expect(outcome.catalog.summary.webAnimations).toBe(0);
expect(cdp.send).toHaveBeenCalledWith("Animation.disable");
});
});
+52 -34
View File
@@ -16,6 +16,8 @@
*/
import type { Page, CDPSession } from "puppeteer-core";
import { isDegradableEvaluateTimeoutError, withRemainingBudget } from "./captureTimeout.js";
import { lazyScrollForCapture } from "./lazyScrollForCapture.js";
export interface AnimationCatalog {
/** Active animations via document.getAnimations() — includes keyframes */
@@ -127,32 +129,36 @@ export async function startCdpAnimationCapture(
return { cdp, animations };
}
/**
* Collect the full animation catalog after page has loaded and settled.
* Should be called after scrolling through the page to trigger all animations.
*/
const EMPTY_ANIMATION_SCAN = {
webAnimations: [] as WebAnimationEntry[],
cssDeclarations: [] as CssAnimationEntry[],
scrollTargets: [] as ScrollTarget[],
canvasCount: 0,
};
export interface CollectAnimationCatalogOutcome {
catalog: AnimationCatalog;
timedOut: boolean;
}
export async function collectAnimationCatalog(
page: Page,
cdpAnimations: CdpAnimationEntry[],
cdp: CDPSession,
): Promise<AnimationCatalog> {
// Scroll through page to trigger scroll-based animations
await page.evaluate(`(async () => {
var height = document.body.scrollHeight;
for (var y = 0; y < height; y += window.innerHeight * 0.5) {
window.scrollTo(0, y);
await new Promise(function(r) { setTimeout(r, 400); });
}
window.scrollTo(0, 0);
await new Promise(function(r) { setTimeout(r, 1000); });
})()`);
opts: { scrollBudgetMs?: number; evaluateBudgetMs?: number } = {},
): Promise<CollectAnimationCatalogOutcome> {
const scrollBudgetMs = opts.scrollBudgetMs ?? 8_000;
const evaluateBudgetMs = opts.evaluateBudgetMs ?? 15_000;
const scroll = await lazyScrollForCapture(page, scrollBudgetMs);
let timedOut = scroll.degraded || scroll.timedOut;
// Collect from Web Animations API + computed styles + IO targets
const result = (await page.evaluate(`(() => {
let result = EMPTY_ANIMATION_SCAN;
try {
result = (await withRemainingBudget(
page.evaluate(`(() => {
var webAnimations = [];
var cssDeclarations = [];
// 1. Web Animations API
try {
var anims = document.getAnimations();
webAnimations = anims.map(function(anim) {
@@ -180,7 +186,6 @@ export async function collectAnimationCatalog(
});
} catch(e) {}
// 2. CSS animation/transition scan
var allEls = document.querySelectorAll('*');
for (var i = 0; i < allEls.length && i < 5000; i++) {
var el = allEls[i];
@@ -202,31 +207,44 @@ export async function collectAnimationCatalog(
} catch(e) {}
}
// 3. IO targets (collected by monkey-patch)
var scrollTargets = (window.__hf_io_targets || []).map(function(t) {
return { selector: t.selector, rect: t.rect };
});
// 4. Canvas summary
var canvasCount = document.querySelectorAll('canvas').length;
return { webAnimations: webAnimations, cssDeclarations: cssDeclarations, scrollTargets: scrollTargets, canvasCount: canvasCount };
})()`)) as any;
})()`),
evaluateBudgetMs,
"animation-catalog",
)) as typeof EMPTY_ANIMATION_SCAN;
} catch (err) {
if (!isDegradableEvaluateTimeoutError(err)) {
throw err;
}
timedOut = true;
}
// Stop CDP listener
await cdp.send("Animation.disable");
try {
await cdp.send("Animation.disable");
} catch {
/* session may already be closed */
}
return {
webAnimations: result.webAnimations,
cssDeclarations: result.cssDeclarations,
scrollTargets: result.scrollTargets,
cdpAnimations,
summary: {
webAnimations: result.webAnimations.length,
cssDeclarations: result.cssDeclarations.length,
scrollTargets: result.scrollTargets.length,
cdpAnimations: cdpAnimations.length,
canvases: result.canvasCount,
catalog: {
webAnimations: result.webAnimations,
cssDeclarations: result.cssDeclarations,
scrollTargets: result.scrollTargets,
cdpAnimations,
summary: {
webAnimations: result.webAnimations.length,
cssDeclarations: result.cssDeclarations.length,
scrollTargets: result.scrollTargets.length,
cdpAnimations: cdpAnimations.length,
canvases: result.canvasCount,
},
},
timedOut,
};
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { TimeoutError } from "puppeteer-core";
import {
PUPPETEER_DEFAULT_PROTOCOL_TIMEOUT_MS,
StageBudgetTimeoutError,
captureProtocolTimeoutMs,
formatCaptureFailureReason,
isNavigationTimeoutError,
isProtocolEvaluateTimeoutError,
withRemainingBudget,
} from "./captureTimeout.js";
describe("captureProtocolTimeoutMs", () => {
it("uses the larger of nav timeout and post-nav budget", () => {
expect(captureProtocolTimeoutMs(120_000, 90_000)).toBe(120_000);
expect(captureProtocolTimeoutMs(30_000, 120_000)).toBe(120_000);
});
it("floors at 60s", () => {
expect(captureProtocolTimeoutMs(5_000, 5_000)).toBe(60_000);
});
it("uses puppeteer default when inputs are non-finite", () => {
expect(captureProtocolTimeoutMs(Number.POSITIVE_INFINITY, 120_000)).toBe(
PUPPETEER_DEFAULT_PROTOCOL_TIMEOUT_MS,
);
});
});
describe("isNavigationTimeoutError", () => {
it("matches Puppeteer TimeoutError navigation timeouts", () => {
expect(
isNavigationTimeoutError(new TimeoutError("Navigation timeout of 30000 ms exceeded")),
).toBe(true);
});
it("does not treat plain Error as navigation timeout", () => {
expect(isNavigationTimeoutError(new Error("Navigation timeout of 30000 ms exceeded"))).toBe(
false,
);
});
it("still classifies message strings for BLOCKED.md formatting", () => {
expect(isNavigationTimeoutError("Navigation timeout of 30000 ms exceeded")).toBe(true);
});
});
describe("isProtocolEvaluateTimeoutError", () => {
it("matches Puppeteer TimeoutError evaluate timeouts by instanceof", () => {
expect(
isProtocolEvaluateTimeoutError(
new TimeoutError(
"Runtime.evaluate timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed.",
),
),
).toBe(true);
});
it("treats non-navigation TimeoutError as evaluate/protocol timeout even if wording drifts", () => {
expect(
isProtocolEvaluateTimeoutError(new TimeoutError("Timed out after waiting 180000ms")),
).toBe(true);
});
it("ignores navigation TimeoutErrors", () => {
expect(
isProtocolEvaluateTimeoutError(new TimeoutError("Navigation timeout of 30000 ms exceeded")),
).toBe(false);
});
});
describe("withRemainingBudget", () => {
it("returns before a never-resolving promise when the budget elapses", async () => {
const never = new Promise<string>(() => undefined);
const started = Date.now();
await expect(withRemainingBudget(never, 20, "never-resolve")).rejects.toBeInstanceOf(
StageBudgetTimeoutError,
);
expect(Date.now() - started).toBeLessThan(200);
});
it("resolves when work finishes inside the budget", async () => {
await expect(withRemainingBudget(Promise.resolve("ok"), 100, "fast")).resolves.toBe("ok");
});
it("fails immediately when no budget remains", async () => {
await expect(withRemainingBudget(Promise.resolve("ok"), 0, "empty")).rejects.toBeInstanceOf(
StageBudgetTimeoutError,
);
});
});
describe("formatCaptureFailureReason", () => {
it("describes evaluate/protocol timeouts as extraction failures", () => {
const reason = formatCaptureFailureReason(
"Runtime.evaluate timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed.",
);
expect(reason).toMatch(/extraction timed out/i);
expect(reason).not.toMatch(/navigation timed out/i);
});
it("keeps navigation timeout wording for nav failures", () => {
expect(formatCaptureFailureReason("Navigation timeout of 30000 ms exceeded")).toMatch(
/navigation timed out/i,
);
});
});
@@ -0,0 +1,97 @@
import { TimeoutError } from "puppeteer-core";
export const PUPPETEER_DEFAULT_PROTOCOL_TIMEOUT_MS = 180_000;
export class StageBudgetTimeoutError extends Error {
readonly budgetMs: number;
constructor(label: string, budgetMs: number) {
super(`stage budget exceeded while ${label} (${budgetMs}ms)`);
this.name = "StageBudgetTimeoutError";
this.budgetMs = budgetMs;
}
}
export function captureProtocolTimeoutMs(navTimeoutMs: number, postNavBudgetMs: number): number {
if (!Number.isFinite(navTimeoutMs) || !Number.isFinite(postNavBudgetMs)) {
return PUPPETEER_DEFAULT_PROTOCOL_TIMEOUT_MS;
}
return Math.max(60_000, Math.max(0, navTimeoutMs), Math.max(0, postNavBudgetMs));
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function hasNavigationTimeoutMessage(msg: string): boolean {
return /navigation timeout/i.test(msg);
}
function hasProtocolEvaluateTimeoutMessage(msg: string): boolean {
return /Runtime\.evaluate timed out|protocolTimeout|protocol timeout/i.test(msg);
}
export function isNavigationTimeoutError(err: unknown): boolean {
if (err instanceof TimeoutError) {
return hasNavigationTimeoutMessage(err.message);
}
// String path used by BLOCKED.md formatting.
return typeof err === "string" && hasNavigationTimeoutMessage(err);
}
function isStageBudgetTimeoutError(err: unknown): boolean {
return err instanceof StageBudgetTimeoutError;
}
export function isProtocolEvaluateTimeoutError(err: unknown): boolean {
if (err instanceof TimeoutError) {
return !hasNavigationTimeoutMessage(err.message);
}
return hasProtocolEvaluateTimeoutMessage(errorMessage(err));
}
export function isDegradableEvaluateTimeoutError(err: unknown): boolean {
return isStageBudgetTimeoutError(err) || isProtocolEvaluateTimeoutError(err);
}
export async function withRemainingBudget<T>(
work: Promise<T>,
remainingMs: number,
label: string,
): Promise<T> {
if (!(remainingMs > 0)) {
throw new StageBudgetTimeoutError(label, Math.max(0, remainingMs));
}
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
work,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
reject(new StageBudgetTimeoutError(label, remainingMs));
}, remainingMs);
}),
]);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
export function formatCaptureFailureReason(errMsg: string): string {
if (hasProtocolEvaluateTimeoutMessage(errMsg) || /stage budget exceeded/i.test(errMsg)) {
return (
`Page extraction timed out while running in-page script (${errMsg}). ` +
"The page likely opened, but a later capture step hung."
);
}
if (hasNavigationTimeoutMessage(errMsg)) {
return "Page navigation timed out — the site may be blocking headless browsers or requires authentication.";
}
if (/timeout|timed out/i.test(errMsg)) {
return `Capture timed out: ${errMsg}`;
}
return `Capture failed: ${errMsg}`;
}
+92 -53
View File
@@ -43,6 +43,12 @@ import type { VisionCaptionOutcome } from "./contentExtractor.js";
import { loadEnvFile, generateProjectScaffold } from "./scaffolding.js";
import { detectBlockedPage } from "./pageBlockDetection.js";
import { navigateForCapture } from "./navigateForCapture.js";
import {
captureProtocolTimeoutMs,
isDegradableEvaluateTimeoutError,
withRemainingBudget,
} from "./captureTimeout.js";
import { lazyScrollForCapture } from "./lazyScrollForCapture.js";
import type { CaptureOptions, CapturePhase, CapturePhaseProgress, CaptureResult } from "./types.js";
export type { CaptureOptions, CaptureResult } from "./types.js";
@@ -123,6 +129,7 @@ export async function captureWebsite(
const chromeBrowser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
protocolTimeout: captureProtocolTimeoutMs(timeout, budgetMs),
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
@@ -253,27 +260,51 @@ export async function captureWebsite(
postNavigationDeadline = Date.now() + budgetMs;
await new Promise((r) => setTimeout(r, settleTime));
// Check if the page loaded real content or an anti-bot challenge
// Combine structural evidence with the main response status/title. Low text
// alone stays non-fatal so image-led sites are not rejected.
const pageContentCheck = (await page1.evaluate(`(() => {
var text = (document.body.innerText || "").trim();
var title = document.title || "";
// Structural: Cloudflare Turnstile widget or challenge iframe
var hasCfTurnstile = !!document.querySelector('.cf-turnstile, [data-sitekey], iframe[src*="challenges.cloudflare.com"], #challenge-running, #challenge-form');
// Structural: page is almost empty (challenge pages have minimal DOM)
var bodyChildCount = document.body.children.length;
return { textLength: text.length, title: title, hasChallengeElement: hasCfTurnstile, bodyChildCount: bodyChildCount };
})()`)) as {
let pageContentCheck: {
textLength: number;
title: string;
hasChallengeElement: boolean;
bodyChildCount: number;
} = {
textLength: 0,
title: "",
hasChallengeElement: false,
bodyChildCount: Number.POSITIVE_INFINITY,
};
let contentCheckTimedOut = false;
try {
pageContentCheck = (await withRemainingBudget(
page1.evaluate(`(() => {
var text = (document.body && document.body.innerText || "").trim();
var title = document.title || "";
var hasCfTurnstile = !!document.querySelector('.cf-turnstile, [data-sitekey], iframe[src*="challenges.cloudflare.com"], #challenge-running, #challenge-form');
var bodyChildCount = document.body ? document.body.children.length : 0;
return { textLength: text.length, title: title, hasChallengeElement: hasCfTurnstile, bodyChildCount: bodyChildCount };
})()`),
Math.min(5_000, remainingMs()),
"content-check",
)) as typeof pageContentCheck;
} catch (err) {
if (!isDegradableEvaluateTimeoutError(err)) {
throw err;
}
contentCheckTimedOut = true;
const message =
"post-navigation content check timed out; continuing with HTTP-status blocked-page detection only";
warnings.push(message);
progress("warn", message);
}
const blockedReason = detectBlockedPage({
httpStatus: navigationResponse?.status() ?? null,
...pageContentCheck,
...(contentCheckTimedOut
? {
title: "",
textLength: 0,
bodyChildCount: 0,
hasChallengeElement: false,
}
: pageContentCheck),
});
if (blockedReason) {
phase("navigation", "degraded", "blocked");
@@ -283,7 +314,7 @@ export async function captureWebsite(
phase("navigation", "completed");
phase("core-extraction", "started");
if (pageContentCheck.textLength < 100) {
if (!contentCheckTimedOut && pageContentCheck.textLength < 100) {
const reason =
"Page has very little text content (" +
pageContentCheck.textLength +
@@ -292,42 +323,18 @@ export async function captureWebsite(
progress("warn", reason);
}
// Scroll through page to trigger lazy-loaded images and Lottie animations
// Framer and other modern sites use IntersectionObserver — images only load
// when scrolled into view. We scroll the full page, then wait for all images
// to finish loading before proceeding.
const lazyLoadBudgetMs = Math.min(15_000, remainingMs());
if (lazyLoadBudgetMs > 0) {
await page1.evaluate(`(async () => {
var lazyLoadDeadline = Date.now() + ${lazyLoadBudgetMs};
var h = document.body.scrollHeight;
for (var y = 0; y < h; y += window.innerHeight * 0.7) {
if (Date.now() >= lazyLoadDeadline) break;
window.scrollTo(0, y);
await new Promise(function(r) { setTimeout(r, 400); });
}
// Scroll to very bottom to catch footer lazy-loads
if (Date.now() < lazyLoadDeadline) {
window.scrollTo(0, document.body.scrollHeight);
await new Promise(function(r) { setTimeout(r, Math.min(800, Math.max(0, lazyLoadDeadline - Date.now()))); });
}
// Wait for all images to finish loading
var imgs = Array.from(document.querySelectorAll('img'));
var pending = imgs.filter(function(img) { return !img.complete; });
var imageWaitMs = Math.min(5000, Math.max(0, lazyLoadDeadline - Date.now()));
if (pending.length > 0 && imageWaitMs > 0) {
await Promise.race([
Promise.all(pending.map(function(img) {
return new Promise(function(r) { img.onload = r; img.onerror = r; });
})),
new Promise(function(r) { setTimeout(r, imageWaitMs); })
]);
}
window.scrollTo(0, 0);
})()`);
const lazyScroll = await lazyScrollForCapture(page1, lazyLoadBudgetMs, {
onWarning: (message) => {
warnings.push(message);
progress("warn", message);
},
});
if (lazyScroll.timedOut && !lazyScroll.degraded) {
const message = `lazy-scroll stopped after ${lazyScroll.steps} steps (budget ${lazyLoadBudgetMs}ms)`;
warnings.push(message);
progress("warn", message);
}
await page1.evaluate(`window.scrollTo(0, 0)`);
await new Promise((r) => setTimeout(r, 300));
// Save discovered Lottie animations
@@ -434,15 +441,47 @@ export async function captureWebsite(
warnings.push(`Design style extraction failed: ${errMsg}`);
}
// Collect animation catalog
progress("animations", "Cataloging animations...");
animationCatalog = await collectAnimationCatalog(page1, cdpAnims, cdp);
try {
const animationOutcome = await collectAnimationCatalog(page1, cdpAnims, cdp, {
scrollBudgetMs: Math.min(8_000, remainingMs()),
evaluateBudgetMs: Math.min(15_000, remainingMs()),
});
animationCatalog = animationOutcome.catalog;
if (animationOutcome.timedOut) {
const message =
"animation catalog evaluate timed out; continuing without animation catalog";
warnings.push(message);
progress("warn", message);
}
} catch (err) {
if (!isDegradableEvaluateTimeoutError(err)) {
throw err;
}
const message = "animation catalog evaluate timed out; continuing without animation catalog";
warnings.push(message);
progress("warn", message);
try {
await cdp.send("Animation.disable");
} catch {
/* ignore */
}
}
// Capture scroll-position viewport screenshots
progress("screenshots", "Capturing scroll screenshots...");
const { captureScrollScreenshots } = await import("./screenshotCapture.js");
const screenshots = await captureScrollScreenshots(page1, outputDir, { remainingMs });
progress("screenshots", `${screenshots.length} scroll screenshots captured`);
let screenshots: string[] = [];
try {
screenshots = await captureScrollScreenshots(page1, outputDir, { remainingMs });
progress("screenshots", `${screenshots.length} scroll screenshots captured`);
} catch (err) {
if (!isDegradableEvaluateTimeoutError(err)) {
throw err;
}
const message = "scroll screenshots timed out; continuing without screenshots";
warnings.push(message);
progress("warn", message);
}
// Catalog all assets (must run before extractHtml which converts img src to data URLs)
progress("design", "Cataloging assets...");
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
import { lazyScrollForCapture } from "./lazyScrollForCapture.js";
describe("lazyScrollForCapture", () => {
it("no-ops when budget is empty", async () => {
const evaluate = vi.fn();
const result = await lazyScrollForCapture({ evaluate }, 0);
expect(result).toEqual({ steps: 0, timedOut: false, degraded: false });
expect(evaluate).not.toHaveBeenCalled();
});
it("scrolls in node-driven steps until the bottom", async () => {
const heights = [{ atBottom: false }, { atBottom: false }, { atBottom: true }];
const evaluate = vi.fn(async (expr: string) => {
if (expr.includes("atBottom")) {
return heights.shift() ?? { atBottom: true };
}
return 0;
});
const sleep = vi.fn(async () => undefined);
const result = await lazyScrollForCapture({ evaluate }, 15_000, { stepDelayMs: 10, sleep });
expect(result.steps).toBe(3);
expect(result.timedOut).toBe(false);
expect(result.degraded).toBe(false);
});
it("returns within the stage budget when evaluate never resolves", async () => {
const evaluate = vi.fn(() => new Promise(() => undefined));
const started = Date.now();
const warnings: string[] = [];
const result = await lazyScrollForCapture({ evaluate }, 25, {
onWarning: (message) => warnings.push(message),
sleep: async () => undefined,
});
expect(result.degraded).toBe(true);
expect(result.timedOut).toBe(true);
expect(Date.now() - started).toBeLessThan(250);
expect(warnings[0]).toMatch(/lazy-scroll evaluate timed out/i);
});
it("does not issue a recovery evaluate after the budget is exhausted", async () => {
let now = 1_000_000;
const dateSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
const evaluate = vi.fn(() => {
now += 100;
return new Promise(() => undefined);
});
try {
const result = await lazyScrollForCapture({ evaluate }, 30, { sleep: async () => undefined });
expect(result.degraded).toBe(true);
expect(evaluate).toHaveBeenCalledTimes(1);
} finally {
dateSpy.mockRestore();
}
});
});
@@ -0,0 +1,141 @@
import { isDegradableEvaluateTimeoutError, withRemainingBudget } from "./captureTimeout.js";
const LAZY_SCROLL_STEP_DELAY_MS = 400;
const LAZY_SCROLL_MAX_IMAGE_WAIT_MS = 5_000;
const LAZY_SCROLL_BOTTOM_SETTLE_MS = 800;
const LAZY_SCROLL_IMAGE_POLL_MS = 250;
export interface LazyScrollPage {
evaluate(pageFunction: string): Promise<unknown>;
}
export interface LazyScrollResult {
steps: number;
timedOut: boolean;
degraded: boolean;
}
async function evaluateWithinBudget(
page: LazyScrollPage,
expression: string,
deadline: number,
label: string,
): Promise<unknown> {
return withRemainingBudget(page.evaluate(expression), deadline - Date.now(), label);
}
async function settleAfterScroll(
page: LazyScrollPage,
deadline: number,
sleep: (ms: number) => Promise<void>,
): Promise<void> {
const bottomSettleMs = Math.min(LAZY_SCROLL_BOTTOM_SETTLE_MS, Math.max(0, deadline - Date.now()));
if (bottomSettleMs > 0) {
await evaluateWithinBudget(
page,
`window.scrollTo(0, document.body.scrollHeight)`,
deadline,
"lazy-scroll-bottom",
);
await sleep(bottomSettleMs);
}
const imageDeadline = Math.min(deadline, Date.now() + LAZY_SCROLL_MAX_IMAGE_WAIT_MS);
while (Date.now() < imageDeadline) {
const pending = (await evaluateWithinBudget(
page,
`Array.from(document.images).filter(function(img) { return !img.complete; }).length`,
imageDeadline,
"lazy-scroll-image-pending",
)) as number;
if (!(pending > 0)) {
break;
}
const waitMs = Math.min(LAZY_SCROLL_IMAGE_POLL_MS, imageDeadline - Date.now());
if (waitMs <= 0) {
break;
}
await sleep(waitMs);
}
if (deadline - Date.now() > 0) {
await evaluateWithinBudget(page, `window.scrollTo(0, 0)`, deadline, "lazy-scroll-reset");
}
}
// fallow-ignore-next-line complexity
export async function lazyScrollForCapture(
page: LazyScrollPage,
budgetMs: number,
opts: {
stepDelayMs?: number;
onWarning?: (message: string) => void;
sleep?: (ms: number) => Promise<void>;
} = {},
): Promise<LazyScrollResult> {
if (budgetMs <= 0) {
return { steps: 0, timedOut: false, degraded: false };
}
const stepDelayMs = opts.stepDelayMs ?? LAZY_SCROLL_STEP_DELAY_MS;
const sleep = opts.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const deadline = Date.now() + budgetMs;
let steps = 0;
let timedOut = false;
let degraded = false;
try {
while (Date.now() < deadline) {
const state = (await evaluateWithinBudget(
page,
`(() => {
var y = window.scrollY || window.pageYOffset || 0;
var view = window.innerHeight || 0;
var height = document.body ? document.body.scrollHeight : 0;
var next = Math.min(y + view * 0.7, Math.max(0, height - view));
var atBottom = height <= view + 2 || next <= y + 1;
window.scrollTo(0, atBottom ? height : next);
return { atBottom: atBottom };
})()`,
deadline,
"lazy-scroll-step",
)) as { atBottom: boolean };
steps += 1;
if (state.atBottom) {
break;
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
timedOut = true;
break;
}
await sleep(Math.min(stepDelayMs, remaining));
}
if (Date.now() >= deadline) {
timedOut = true;
}
if (deadline - Date.now() > 0) {
await settleAfterScroll(page, deadline, sleep);
}
} catch (err) {
if (!isDegradableEvaluateTimeoutError(err)) {
throw err;
}
degraded = true;
timedOut = true;
opts.onWarning?.("lazy-scroll evaluate timed out; continuing with current page state");
if (deadline - Date.now() > 0) {
try {
await evaluateWithinBudget(page, `window.scrollTo(0, 0)`, deadline, "lazy-scroll-recovery");
} catch {
/* page may be wedged; do not spend another unbounded protocol wait */
}
}
}
return { steps, timedOut, degraded };
}
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { TimeoutError } from "puppeteer-core";
import {
isNavigationTimeoutError,
navigateForCapture,
@@ -17,10 +18,10 @@ describe("networkIdleAttemptTimeoutMs", () => {
});
describe("isNavigationTimeoutError", () => {
it("matches Puppeteer navigation timeouts", () => {
expect(isNavigationTimeoutError(new Error("Navigation timeout of 30000 ms exceeded"))).toBe(
true,
);
it("matches Puppeteer TimeoutError navigation timeouts", () => {
expect(
isNavigationTimeoutError(new TimeoutError("Navigation timeout of 30000 ms exceeded")),
).toBe(true);
});
it("ignores unrelated failures", () => {
@@ -51,7 +52,7 @@ describe("navigateForCapture", () => {
const response = { status: () => 200 };
const goto = vi
.fn()
.mockRejectedValueOnce(new Error("Navigation timeout of 30000 ms exceeded"))
.mockRejectedValueOnce(new TimeoutError("Navigation timeout of 30000 ms exceeded"))
.mockResolvedValueOnce(response);
const result = await navigateForCapture({ goto }, "https://www.yahoo.com/", 120_000);
@@ -81,7 +82,7 @@ describe("navigateForCapture", () => {
const response = { status: () => 200 };
const goto = vi
.fn()
.mockRejectedValueOnce(new Error("Navigation timeout of 30000 ms exceeded"))
.mockRejectedValueOnce(new TimeoutError("Navigation timeout of 30000 ms exceeded"))
.mockResolvedValueOnce(response);
await navigateForCapture({ goto }, "https://www.yahoo.com/", totalTimeoutMs);
@@ -106,7 +107,7 @@ describe("navigateForCapture", () => {
});
it("does not fall back when the caller timeout already equals the idle attempt", async () => {
const err = new Error("Navigation timeout of 10000 ms exceeded");
const err = new TimeoutError("Navigation timeout of 10000 ms exceeded");
const goto = vi.fn().mockRejectedValue(err);
await expect(navigateForCapture({ goto }, "https://example.com", 10_000)).rejects.toBe(err);
@@ -1,3 +1,7 @@
import { isNavigationTimeoutError } from "./captureTimeout.js";
export { isNavigationTimeoutError };
export const NETWORK_IDLE_ATTEMPT_MS = 30_000;
export type CaptureGotoWaitUntil = "networkidle2" | "domcontentloaded";
@@ -22,11 +26,6 @@ export function networkIdleAttemptTimeoutMs(totalTimeoutMs: number): number {
return Math.min(NETWORK_IDLE_ATTEMPT_MS, Math.max(0, totalTimeoutMs));
}
export function isNavigationTimeoutError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return /navigation timeout/i.test(msg);
}
export async function navigateForCapture<TResponse>(
page: CaptureGotoPage<TResponse>,
url: string,
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TimeoutError } from "puppeteer-core";
import type { Page } from "puppeteer-core";
import { captureScrollScreenshots } from "./screenshotCapture.js";
describe("captureScrollScreenshots degradation", () => {
it("rethrows protocol evaluate timeouts for the caller warning path", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-scroll-degrade-"));
const page = {
evaluate: vi.fn(async () => {
throw new TimeoutError(
"Runtime.evaluate timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed.",
);
}),
screenshot: vi.fn(),
} as unknown as Page;
await expect(
captureScrollScreenshots(page, dir, { remainingMs: () => 5_000 }),
).rejects.toBeInstanceOf(TimeoutError);
});
});
+10 -2
View File
@@ -6,6 +6,7 @@
*/
import type { Page } from "puppeteer-core";
import { isDegradableEvaluateTimeoutError } from "./captureTimeout.js";
import { writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
@@ -197,7 +198,11 @@ export async function captureScrollScreenshots(
node = walker.nextNode();
}
})
.catch(() => {});
.catch((err: unknown) => {
if (isDegradableEvaluateTimeoutError(err)) {
throw err;
}
});
await new Promise((r) => setTimeout(r, 400));
const scrollHeight = (await page.evaluate(
@@ -257,7 +262,10 @@ export async function captureScrollScreenshots(
const plate = await captureFullPagePlate(page, screenshotsDir, budget);
if (plate) filePaths.push(plate);
}
} catch {
} catch (err) {
if (isDegradableEvaluateTimeoutError(err)) {
throw err;
}
/* scroll screenshots are non-critical */
}