diff --git a/packages/cli/src/capture/assetDownloader.ts b/packages/cli/src/capture/assetDownloader.ts index 9e783b9348..21a881f3b9 100644 --- a/packages/cli/src/capture/assetDownloader.ts +++ b/packages/cli/src/capture/assetDownloader.ts @@ -264,8 +264,10 @@ export async function downloadAndRewriteFonts( if (fontUrls.size === 0) return css; - // Limit font downloads to avoid bloat. Google Fonts serves 20+ unicode-range - // subsets per weight — we only need a few per family for video production. + // Limit font download attempts to bound worst-case egress and latency. Google Fonts serves + // 20+ unicode-range subsets per weight, so successes alone cannot be the bound: six transient + // failures can intentionally suppress later URLs in that family. Latin-priority sorting below + // makes the limited attempts useful while keeping this failure tradeoff explicit. const MAX_FONTS_PER_FAMILY = 6; const MAX_TOTAL_FONTS = 30; const familyCounts = new Map(); diff --git a/packages/cli/src/capture/contentExtractor.test.ts b/packages/cli/src/capture/contentExtractor.test.ts index a929d29382..fa1e077611 100644 --- a/packages/cli/src/capture/contentExtractor.test.ts +++ b/packages/cli/src/capture/contentExtractor.test.ts @@ -2,7 +2,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { captionImagesWithGemini } from "./contentExtractor.js"; +import { + captionImagesWithGemini, + resolveVisionPhaseCompletion, + type VisionCaptionOutcome, +} from "./contentExtractor.js"; const { generateContentMock } = vi.hoisted(() => ({ generateContentMock: vi.fn(), @@ -151,9 +155,20 @@ describe("captionImagesWithGemini — OpenRouter provider", () => { ); const warnings: string[] = []; - const captions = await captionImagesWithGemini(dir, () => {}, warnings); + let outcome: VisionCaptionOutcome | undefined; + const captions = await captionImagesWithGemini(dir, () => {}, warnings, { + onOutcome: (value) => { + outcome = value; + }, + }); expect(captions).toEqual({ "success.png": "Successful image." }); + expect(outcome).toEqual({ timedOutRequests: 1, budgetExhausted: false }); + if (!outcome) throw new Error("Expected vision caption outcome"); + expect(resolveVisionPhaseCompletion(outcome, 10_000)).toEqual({ + status: "degraded", + reason: "request-timeout", + }); }); it("does not call a configured provider when vision is skipped", async () => { diff --git a/packages/cli/src/capture/contentExtractor.ts b/packages/cli/src/capture/contentExtractor.ts index 824ee01788..c5a3f2eb61 100644 --- a/packages/cli/src/capture/contentExtractor.ts +++ b/packages/cli/src/capture/contentExtractor.ts @@ -17,9 +17,30 @@ import type { DesignTokens } from "./types.js"; const DEFAULT_VISION_REQUEST_TIMEOUT_MS = 30_000; +export interface VisionCaptionOutcome { + timedOutRequests: number; + budgetExhausted: boolean; +} + interface VisionCaptionOptions { skipVision?: boolean; remainingMs?: () => number; + onOutcome?: (outcome: VisionCaptionOutcome) => void; +} + +export function resolveVisionPhaseCompletion( + outcome: VisionCaptionOutcome, + remainingMs: number, +): + | { status: "completed" } + | { status: "degraded"; reason: "budget-exhausted" | "request-timeout" } { + if (outcome.budgetExhausted || remainingMs <= 0) { + return { status: "degraded", reason: "budget-exhausted" }; + } + if (outcome.timedOutRequests > 0) { + return { status: "degraded", reason: "request-timeout" }; + } + return { status: "completed" }; } class VisionRequestTimeoutError extends Error { @@ -214,10 +235,21 @@ export async function captionImagesWithGemini( options: VisionCaptionOptions = {}, ): Promise> { const geminiCaptions: Record = {}; - if (options.skipVision) return geminiCaptions; + let timedOutCount = 0; + let budgetExhausted = false; + const reportOutcome = (): void => { + options.onOutcome?.({ timedOutRequests: timedOutCount, budgetExhausted }); + }; + if (options.skipVision) { + reportOutcome(); + return geminiCaptions; + } const openRouterKey = process.env.OPENROUTER_API_KEY; const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; - if (!openRouterKey && !geminiKey) return geminiCaptions; + if (!openRouterKey && !geminiKey) { + reportOutcome(); + return geminiCaptions; + } // OpenRouter takes priority when both keys are set — it's the explicit opt-in // for users without Google access. Both providers satisfy the same @@ -321,7 +353,6 @@ export async function captionImagesWithGemini( // on Promise.allSettled so a rate-limited image degrades to "" rather than // failing the batch. const BATCH_SIZE = 20; - let timedOutCount = 0; const collectCaptionResults = ( results: PromiseSettledResult<{ file: string; caption: string }>[], ): number => { @@ -340,7 +371,10 @@ export async function captionImagesWithGemini( }; for (let i = 0; i < imageFiles.length; i += BATCH_SIZE) { const remainingMs = options.remainingMs?.() ?? Number.POSITIVE_INFINITY; - if (remainingMs <= 0) break; + if (remainingMs <= 0) { + budgetExhausted = true; + break; + } const timeoutMs = Math.max(1, Math.min(requestTimeoutMs, remainingMs)); const batch = imageFiles.slice(i, i + BATCH_SIZE); const results = await Promise.allSettled( @@ -404,6 +438,7 @@ export async function captionImagesWithGemini( `Skipped ${svgFiles.length} SVG caption(s): sharp could not load (${(err as Error).message}). ` + `Reinstall with optional dependencies enabled (e.g. \`npm i sharp\`) to caption SVG assets.`, ); + reportOutcome(); return geminiCaptions; } progress("design", `Rasterizing + captioning ${svgFiles.length} SVGs via vision API...`); @@ -412,7 +447,10 @@ export async function captionImagesWithGemini( let svgsSkipped = 0; for (let i = 0; i < svgFiles.length; i += SVG_BATCH) { const remainingMs = options.remainingMs?.() ?? Number.POSITIVE_INFINITY; - if (remainingMs <= 0) break; + if (remainingMs <= 0) { + budgetExhausted = true; + break; + } const timeoutMs = Math.max(1, Math.min(requestTimeoutMs, remainingMs)); const batch = svgFiles.slice(i, i + SVG_BATCH); const results = await Promise.allSettled( @@ -489,6 +527,7 @@ export async function captionImagesWithGemini( warnings.push(`${providerName} captioning failed: ${err}`); } + reportOutcome(); return geminiCaptions; } diff --git a/packages/cli/src/capture/index.ts b/packages/cli/src/capture/index.ts index 8b90164bd0..b9955f1214 100644 --- a/packages/cli/src/capture/index.ts +++ b/packages/cli/src/capture/index.ts @@ -37,7 +37,9 @@ import { extractVisibleText, captionImagesWithGemini, generateAssetDescriptions, + resolveVisionPhaseCompletion, } from "./contentExtractor.js"; +import type { VisionCaptionOutcome } from "./contentExtractor.js"; import { loadEnvFile, generateProjectScaffold } from "./scaffolding.js"; import type { CaptureOptions, CapturePhase, CapturePhaseProgress, CaptureResult } from "./types.js"; @@ -343,10 +345,11 @@ export async function captureWebsite( if (discoveredLotties.length > 0 && remainingMs() > 0) { const lottieDir = join(outputDir, "assets", "lottie"); mkdirSync(lottieDir, { recursive: true }); - const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir); + const lottieBudget = { remainingMs }; + const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir, lottieBudget); // Generate manifest + preview thumbnails so the agent can SEE what each animation is if (savedCount > 0 && remainingMs() > 0) { - await renderLottiePreviews(chromeBrowser, lottieDir, outputDir); + await renderLottiePreviews(chromeBrowser, lottieDir, outputDir, lottieBudget); progress("lottie", `${savedCount} Lottie animation(s) saved`); } } @@ -494,6 +497,7 @@ export async function captureWebsite( networkVideoUrls: discoveredVideoUrls, // Layer 1 (live Set, read after sampling) sampleMs: Math.min(12000, videoBudgetMs), // Layer 2: poll DOM within the shared budget downloadBudgetMs: videoBudgetMs, + remainingMs, }); } } catch { @@ -670,13 +674,21 @@ export async function captureWebsite( phase("vision", "degraded", "budget-exhausted"); } else { phase("vision", "started"); + let visionOutcome: VisionCaptionOutcome = { + timedOutRequests: 0, + budgetExhausted: false, + }; geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings, { remainingMs, + onOutcome: (outcome) => { + visionOutcome = outcome; + }, }); + const completion = resolveVisionPhaseCompletion(visionOutcome, remainingMs()); phase( "vision", - remainingMs() > 0 ? "completed" : "degraded", - remainingMs() > 0 ? undefined : "budget-exhausted", + completion.status, + completion.status === "degraded" ? completion.reason : undefined, ); } diff --git a/packages/cli/src/capture/indexBudget.test.ts b/packages/cli/src/capture/indexBudget.test.ts deleted file mode 100644 index fb6af9a459..0000000000 --- a/packages/cli/src/capture/indexBudget.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; - -const source = readFileSync(new URL("./index.ts", import.meta.url), "utf-8"); - -describe("website capture post-navigation budget", () => { - it("bounds the lazy-load scroll loop by the shared remaining budget", () => { - expect(source).toContain("const lazyLoadBudgetMs = Math.min(15_000, remainingMs());"); - expect(source).toContain("var lazyLoadDeadline = Date.now() + ${lazyLoadBudgetMs};"); - expect(source).toContain("if (Date.now() >= lazyLoadDeadline) break;"); - }); -}); diff --git a/packages/cli/src/capture/mediaCapture.test.ts b/packages/cli/src/capture/mediaCapture.test.ts index b8d56ea358..10aabdf121 100644 --- a/packages/cli/src/capture/mediaCapture.test.ts +++ b/packages/cli/src/capture/mediaCapture.test.ts @@ -1,8 +1,127 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { remainingVideoDownloadTimeoutMs } from "./mediaCapture.js"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Browser, Page } from "puppeteer-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + captureVideoManifest, + remainingVideoDownloadTimeoutMs, + renderLottiePreviews, + saveLottieAnimations, +} from "./mediaCapture.js"; -const source = readFileSync(new URL("./mediaCapture.ts", import.meta.url), "utf-8"); +const tempDirs: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "hf-media-budget-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); + tempDirs.length = 0; +}); + +describe("Lottie capture budget", () => { + it("does not start another Lottie fetch after the live budget expires", async () => { + const dir = tempDir(); + let remainingMs = 10_000; + const fetchMock = vi.fn(async () => { + remainingMs = 0; + return new Response(JSON.stringify({ w: 100, h: 100, layers: [] }), { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const saved = await saveLottieAnimations( + [{ url: "https://one.example/a.json" }, { url: "https://two.example/b.json" }], + dir, + { remainingMs: () => remainingMs }, + ); + + expect(saved).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not open another Lottie preview page after the live budget expires", async () => { + const dir = tempDir(); + const lottieDir = join(dir, "assets", "lottie"); + mkdirSync(join(dir, "extracted"), { recursive: true }); + mkdirSync(lottieDir, { recursive: true }); + const lottie = JSON.stringify({ w: 100, h: 100, fr: 30, ip: 0, op: 30, layers: [] }); + writeFileSync(join(lottieDir, "a.json"), lottie); + writeFileSync(join(lottieDir, "b.json"), lottie); + + let remainingMs = 10_000; + const previewPage = { + setViewport: vi.fn(async () => undefined), + setContent: vi.fn(async () => undefined), + evaluate: vi.fn(async () => undefined), + waitForFunction: vi.fn(async () => undefined), + screenshot: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + }; + const newPage = vi.fn(async () => { + remainingMs = 0; + return previewPage; + }); + const browser = { newPage } as unknown as Browser; + + await renderLottiePreviews(browser, lottieDir, dir, { remainingMs: () => remainingMs }); + + expect(newPage).toHaveBeenCalledTimes(1); + expect(previewPage.setViewport).not.toHaveBeenCalled(); + expect(previewPage.screenshot).not.toHaveBeenCalled(); + }); +}); + +describe("video capture live budget", () => { + it("does not preview or download when the budget expires during DOM sampling", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const dir = tempDir(); + mkdirSync(join(dir, "extracted"), { recursive: true }); + const descriptor = { + src: "https://video.example/hero.mp4", + filename: "hero.mp4", + width: 640, + height: 360, + sourceWidth: 640, + sourceHeight: 360, + top: 0, + left: 0, + heading: "Hero", + caption: "Demo", + ariaLabel: "", + }; + const screenshot = vi.fn(async () => Buffer.from("preview")); + const evaluate = vi.fn(async (expression: unknown) => + typeof expression === "function" ? { x: 0, y: 0, width: 640, height: 360 } : [descriptor], + ); + const page = { evaluate, screenshot } as unknown as Page; + const fetchMock = vi.fn( + async () => + new Response(Buffer.alloc(2048), { + status: 200, + headers: { "content-type": "video/mp4" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const capture = captureVideoManifest(page, dir, () => {}, { + sampleMs: 10_000, + downloadBudgetMs: 10_000, + remainingMs: () => Math.max(0, 1_000 - Date.now()), + }); + await vi.runAllTimersAsync(); + await capture; + + expect(screenshot).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); describe("remainingVideoDownloadTimeoutMs", () => { it("caps a video request to the remaining capture budget", () => { @@ -16,10 +135,4 @@ describe("remainingVideoDownloadTimeoutMs", () => { it("retains the existing per-request ceiling when more budget remains", () => { expect(remainingVideoDownloadTimeoutMs(1_000, 300_000, 2_000)).toBe(120_000); }); - - it("checks the aggregate budget before starting each preview/download iteration", () => { - expect(source).toContain( - "if (remainingVideoDownloadTimeoutMs(dlStart, downloadBudgetMs) <= 0) break;", - ); - }); }); diff --git a/packages/cli/src/capture/mediaCapture.ts b/packages/cli/src/capture/mediaCapture.ts index acfa887833..e5add76747 100644 --- a/packages/cli/src/capture/mediaCapture.ts +++ b/packages/cli/src/capture/mediaCapture.ts @@ -20,6 +20,14 @@ export interface DiscoveredLottie { frameRate?: number; } +interface RemainingBudget { + remainingMs?: () => number; +} + +function liveRemainingMs(budget: RemainingBudget, fallbackMs: number): number { + return budget.remainingMs?.() ?? fallbackMs; +} + /** * Download and save discovered Lottie animations to disk. * @@ -30,11 +38,13 @@ export interface DiscoveredLottie { export async function saveLottieAnimations( discoveredLotties: DiscoveredLottie[], lottieDir: string, + budget: RemainingBudget = {}, ): Promise { let savedCount = 0; const savedHashes = new Set(); // Deduplicate by content for (let li = 0; li < discoveredLotties.length && li < 10; li++) { + if (liveRemainingMs(budget, 10_000) <= 0) break; const lottieItem = discoveredLotties[li]!; try { let jsonData: string | undefined; @@ -43,9 +53,11 @@ export async function saveLottieAnimations( // Already have the JSON data from network interception jsonData = JSON.stringify(lottieItem.data); } else if (lottieItem.url) { + const requestTimeoutMs = Math.min(10_000, liveRemainingMs(budget, 10_000)); + if (requestTimeoutMs <= 0) break; // SSRF guard — safeFetch re-checks the denylist on every redirect hop const res = await safeFetch(lottieItem.url, { - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(requestTimeoutMs), headers: { "User-Agent": "HyperFrames/1.0" }, }); if (!res || !res.ok) continue; @@ -117,6 +129,7 @@ export async function renderLottiePreviews( chromeBrowser: Browser, lottieDir: string, outputDir: string, + budget: RemainingBudget = {}, ): Promise { const manifest: Array<{ file: string; @@ -133,6 +146,7 @@ export async function renderLottiePreviews( for (const file of readdirSync(lottieDir)) { if (!file.endsWith(".json")) continue; + if (liveRemainingMs(budget, 1) <= 0) break; try { const raw = JSON.parse(readFileSync(join(lottieDir, file), "utf-8")); const fr = raw.fr || 30; @@ -146,7 +160,9 @@ export async function renderLottiePreviews( let previewPage; try { + if (liveRemainingMs(budget, 1) <= 0) break; previewPage = await chromeBrowser.newPage(); + if (liveRemainingMs(budget, 1) <= 0) break; await previewPage.setViewport({ width: 400, height: 400 }); const animData = JSON.parse(readFileSync(join(lottieDir, file), "utf-8")); const midFrame = Math.floor(((raw.op || 0) - (raw.ip || 0)) * 0.3); @@ -180,11 +196,13 @@ export async function renderLottiePreviews( await previewPage .waitForFunction(() => (window as any).__READY === true, { timeout: 5000 }) .catch(() => {}); - await previewPage.screenshot({ - path: join(previewDir, previewName), - type: "png", - omitBackground: true, - }); + if (liveRemainingMs(budget, 1) > 0) { + await previewPage.screenshot({ + path: join(previewDir, previewName), + type: "png", + omitBackground: true, + }); + } } catch { /* preview rendering failed — non-critical */ } finally { @@ -362,11 +380,12 @@ async function sampleVideoDom( page: Page, budgetMs: number, netSet: Set, + budget: RemainingBudget, ): Promise { const seen = new Map(); const start = Date.now(); let stale = 0; - while (Date.now() - start < budgetMs && stale < 3) { + while (Date.now() - start < budgetMs && stale < 3 && liveRemainingMs(budget, 1) > 0) { let grew = false; const netBefore = netSet.size; for (const d of await scanVideoDom(page)) { @@ -377,7 +396,12 @@ async function sampleVideoDom( } if (netSet.size > netBefore) grew = true; stale = grew ? 0 : stale + 1; - await new Promise((r) => setTimeout(r, 2000)); + const waitMs = Math.min( + 2000, + Math.max(0, budgetMs - (Date.now() - start)), + Math.max(0, liveRemainingMs(budget, 2000)), + ); + if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs)); } return [...seen.values()]; } @@ -407,7 +431,12 @@ export async function captureVideoManifest( page: Page, outputDir: string, progress: (stage: string, detail?: string) => void, - opts?: { networkVideoUrls?: Set; sampleMs?: number; downloadBudgetMs?: number }, + opts?: { + networkVideoUrls?: Set; + sampleMs?: number; + downloadBudgetMs?: number; + remainingMs?: () => number; + }, ): Promise { const netSet = opts?.networkVideoUrls ?? new Set(); const sampleMs = opts?.sampleMs ?? 0; @@ -416,7 +445,9 @@ export async function captureVideoManifest( // DOM scan, optionally sampled over time (Layer 2) when videos are present. const initial = await scanVideoDom(page); const domVideos = - initial.length > 0 && sampleMs > 0 ? await sampleVideoDom(page, sampleMs, netSet) : initial; + initial.length > 0 && sampleMs > 0 + ? await sampleVideoDom(page, sampleMs, netSet, opts ?? {}) + : initial; // Merge DOM (rich) + network-only (thin, Layer 1), deduped by download // filename so a clip seen in both lands once. netSet is read here — AFTER @@ -472,7 +503,11 @@ export async function captureVideoManifest( const dlStart = Date.now(); for (let vi = 0; vi < merged.length && vi < 20; vi++) { - if (remainingVideoDownloadTimeoutMs(dlStart, downloadBudgetMs) <= 0) break; + const iterationRemainingMs = liveRemainingMs( + opts ?? {}, + remainingVideoDownloadTimeoutMs(dlStart, downloadBudgetMs), + ); + if (iterationRemainingMs <= 0) break; const v = merged[vi]!; let preview: string | undefined; @@ -496,16 +531,18 @@ export async function captureVideoManifest( }, v.filename)) as { x: number; y: number; width: number; height: number } | null; if (rect && rect.width >= 10) { await new Promise((r) => setTimeout(r, 200)); // let decoder settle - await page.screenshot({ - path: join(previewDir, previewName), - clip: { - x: Math.max(0, rect.x), - y: Math.max(0, rect.y), - width: Math.min(rect.width, 1920), - height: Math.min(rect.height, 1080), - }, - }); - preview = `assets/videos/previews/${previewName}`; + if (liveRemainingMs(opts ?? {}, 1) > 0) { + await page.screenshot({ + path: join(previewDir, previewName), + clip: { + x: Math.max(0, rect.x), + y: Math.max(0, rect.y), + width: Math.min(rect.width, 1920), + height: Math.min(rect.height, 1080), + }, + }); + preview = `assets/videos/previews/${previewName}`; + } } } catch { /* preview failed — non-critical */ @@ -516,7 +553,10 @@ export async function captureVideoManifest( // direct file. Cumulative budget caps total download time so a throttled // host or many large clips can't stall capture — over budget, keep the // preview (if any) and stop fetching bodies. - const downloadTimeoutMs = remainingVideoDownloadTimeoutMs(dlStart, downloadBudgetMs); + const downloadTimeoutMs = Math.min( + remainingVideoDownloadTimeoutMs(dlStart, downloadBudgetMs), + liveRemainingMs(opts ?? {}, VIDEO_DOWNLOAD_TIMEOUT_MS), + ); const savedPath = downloadTimeoutMs > 0 ? await downloadVideoBody(v.src, v.filename, videoManifestDir, downloadTimeoutMs) diff --git a/packages/cli/src/capture/screenshotCapture.test.ts b/packages/cli/src/capture/screenshotCapture.test.ts index c3f89b207e..4ac69d58be 100644 --- a/packages/cli/src/capture/screenshotCapture.test.ts +++ b/packages/cli/src/capture/screenshotCapture.test.ts @@ -122,6 +122,61 @@ describe("captureScrollScreenshots — capture budget", () => { expect(evaluate).not.toHaveBeenCalled(); expect(screenshot).not.toHaveBeenCalled(); }); + + it("re-checks the budget after settling and before each viewport screenshot", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const dir = mkdtempSync(join(tmpdir(), "hf-scroll-expiring-budget-")); + const evaluate = vi.fn(async (expression: unknown) => { + const source = String(expression); + if (source.includes("Math.max(document.body.scrollHeight")) return 1080; + if (source === "window.innerHeight") return 1080; + return undefined; + }); + const screenshot = vi.fn(async () => pngBuffer(1080)); + const page = { evaluate, screenshot } as unknown as Page; + + try { + const capture = captureScrollScreenshots(page, dir, { + remainingMs: () => Math.max(0, 600 - Date.now()), + }); + await vi.runAllTimersAsync(); + const files = await capture; + + expect(files).toEqual([]); + expect(screenshot).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("captureFullPagePlate — capture budget", () => { + it("re-checks the budget immediately before the full-page screenshot", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const dir = mkdtempSync(join(tmpdir(), "hf-plate-expiring-budget-")); + const screenshot = vi.fn(async () => pngBuffer(8000)); + const evaluate = vi.fn(async (expression: unknown) => { + if (String(expression).includes("scrollHeight")) { + vi.setSystemTime(100); + return 8000; + } + return undefined; + }); + const page = { evaluate, screenshot } as unknown as Page; + + try { + const plate = await captureFullPagePlate(page, dir, { + remainingMs: () => Math.max(0, 50 - Date.now()), + }); + + expect(plate).toBeNull(); + expect(screenshot).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); }); describe("captureFullPagePlate — guards against a silently clipped plate", () => { diff --git a/packages/cli/src/capture/screenshotCapture.ts b/packages/cli/src/capture/screenshotCapture.ts index 3e2878faa0..159ac3cd21 100644 --- a/packages/cli/src/capture/screenshotCapture.ts +++ b/packages/cli/src/capture/screenshotCapture.ts @@ -59,6 +59,7 @@ export function pngHeight(buf: Uint8Array): number | null { export async function captureFullPagePlate( page: Page, screenshotsDir: string, + budget: { remainingMs?: () => number } = {}, ): Promise { // Record the inline value before overwriting so the page is handed back unchanged — the // caller keeps using it (asset extraction, DOM reads) after this returns. @@ -81,6 +82,7 @@ export async function captureFullPagePlate( `Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)`, )) as number; if (docHeight > MAX_PLATE_HEIGHT_PX) return null; + if ((budget.remainingMs?.() ?? 1) <= 0) return null; const buffer = await page.screenshot({ type: "png", fullPage: true }); // Confirm what Chrome produced instead of trusting the measurement: the capture itself can @@ -236,6 +238,7 @@ export async function captureScrollScreenshots( ); const filename = `scroll-${String(Math.min(pct, 100)).padStart(3, "0")}.png`; const filePath = join(screenshotsDir, filename); + if ((budget.remainingMs?.() ?? 1) <= 0) break; const buffer = await page.screenshot({ type: "png" }); writeFileSync(filePath, buffer); filePaths.push(`screenshots/${filename}`); @@ -251,7 +254,7 @@ export async function captureScrollScreenshots( // was about it as a *comprehension* artifact. The scroll shot is a different consumer: it // needs one continuous plate, which no set of viewport tiles can substitute for.) if ((budget.remainingMs?.() ?? 1) > 0) { - const plate = await captureFullPagePlate(page, screenshotsDir); + const plate = await captureFullPagePlate(page, screenshotsDir, budget); if (plate) filePaths.push(plate); } } catch { diff --git a/packages/cli/src/capture/types.ts b/packages/cli/src/capture/types.ts index e627b80d9a..2ecacf390b 100644 --- a/packages/cli/src/capture/types.ts +++ b/packages/cli/src/capture/types.ts @@ -26,7 +26,7 @@ export interface CapturePhaseProgress { status: "started" | "completed" | "degraded"; /** Null before the post-navigation budget begins. */ remainingMs: number | null; - reason?: "budget-exhausted" | "disabled"; + reason?: "budget-exhausted" | "disabled" | "request-timeout"; } export interface CaptureOptions { diff --git a/packages/cli/src/commands/capture.test.ts b/packages/cli/src/commands/capture.test.ts index eb3439afb2..c85ff09f97 100644 --- a/packages/cli/src/commands/capture.test.ts +++ b/packages/cli/src/commands/capture.test.ts @@ -52,6 +52,19 @@ describe("capture command — vision control", () => { }); }); + it("declares --capture-budget separately from navigation --timeout", () => { + const captureBudgetArg = captureCommand.args + ? Reflect.get(captureCommand.args, "capture-budget") + : undefined; + expect(captureBudgetArg).toMatchObject({ type: "string" }); + const description = captureBudgetArg?.description.toLowerCase(); + expect(description).toContain("post-navigation"); + expect(description).toContain("cooperative"); + expect(description).toContain("not a hard wall-clock timeout"); + expect(description).toContain("already-started native/core work"); + expect(captureBudgetArg?.description).toContain("--timeout"); + }); + it("plumbs --skip-vision into capture options", async () => { vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); @@ -72,6 +85,56 @@ describe("capture command — vision control", () => { ); }); + it.each([ + ["1", 1], + ["45000", 45_000], + ])( + "plumbs positive integer --capture-budget %s into the post-navigation budget", + async (captureBudget, expectedBudget) => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await captureCommand.run!({ + args: { + url: "https://example.com", + output: "/tmp/hf-capture-budget-test", + "skip-assets": false, + "skip-vision": false, + "capture-budget": captureBudget, + json: true, + }, + } as never); + + expect(captureWebsiteMock).toHaveBeenCalledWith( + expect.objectContaining({ postNavigationBudgetMs: expectedBudget }), + undefined, + ); + }, + ); + + it.each(["0", "-1", "0.5", "Infinity", "not-a-number"])( + "rejects invalid --capture-budget %s before capture starts", + async (captureBudget) => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + captureCommand.run!({ + args: { + url: "https://example.com", + output: "/tmp/hf-invalid-capture-budget-test", + "skip-assets": false, + "skip-vision": false, + "capture-budget": captureBudget, + json: true, + }, + } as never), + ).rejects.toBeInstanceOf(CliRuntimeError); + + expect(captureWebsiteMock).not.toHaveBeenCalled(); + }, + ); + it("emits a versioned phase record without the captured URL", async () => { vi.spyOn(console, "log").mockImplementation(() => {}); const error = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/packages/cli/src/commands/capture.ts b/packages/cli/src/commands/capture.ts index b1766683fb..f9b4c11adb 100644 --- a/packages/cli/src/commands/capture.ts +++ b/packages/cli/src/commands/capture.ts @@ -12,6 +12,16 @@ function emitCapturePhase(event: CapturePhaseProgress): void { diag.notice(`${CAPTURE_PHASE_PREFIX}${JSON.stringify(event)}`); } +function parseCaptureBudget(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + console.error("--capture-budget must be a positive integer in milliseconds."); + failCommand(); + } + return parsed; +} + export const examples: Example[] = [ ["Capture a website into ./capture/", "hyperframes capture https://stripe.com"], ["Capture to a different directory", "hyperframes capture https://linear.app -o linear-video"], @@ -60,6 +70,11 @@ export default defineCommand({ type: "string", description: "Page load timeout in ms (default: 120000)", }, + "capture-budget": { + type: "string", + description: + "Cooperative post-navigation budget in ms (default: 120000), separate from page-load --timeout; not a hard wall-clock timeout and cannot interrupt already-started native/core work", + }, json: { type: "boolean", description: "Output JSON (for AI agents / programmatic use)", @@ -112,6 +127,8 @@ export default defineCommand({ failCommand(); } + const captureBudgetMs = parseCaptureBudget(args["capture-budget"] as string | undefined); + const isDefaultOutput = !args.output; let outputName = (args.output as string | undefined) ?? "capture"; let outputDir = resolve(outputName); @@ -157,6 +174,7 @@ export default defineCommand({ ? parseInt(args["max-screenshots"] as string) : undefined, timeout: args.timeout ? parseInt(args.timeout as string) : undefined, + postNavigationBudgetMs: captureBudgetMs, json: isJson, onPhase: emitCapturePhase, },