diff --git a/packages/cli/src/capture/contactSheet.test.ts b/packages/cli/src/capture/contactSheet.test.ts
index 5b2dc74464..71432a754b 100644
--- a/packages/cli/src/capture/contactSheet.test.ts
+++ b/packages/cli/src/capture/contactSheet.test.ts
@@ -1,9 +1,13 @@
-import { mkdtempSync, rmSync } from "node:fs";
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
-import { createContactSheet } from "./contactSheet.js";
+import {
+ createContactSheet,
+ createScrollContactSheet,
+ createSvgContactSheet,
+} from "./contactSheet.js";
function tempDir(): string {
return mkdtempSync(join(tmpdir(), "hf-contact-sheet-test-"));
@@ -58,3 +62,64 @@ describe("createContactSheet", () => {
}
}, 60_000);
});
+
+describe("contact-sheet capture budget", () => {
+ it("does not start another Sharp page after the budget is exhausted", async () => {
+ const dir = tempDir();
+ try {
+ for (let i = 0; i < 10; i++) {
+ await sharp({
+ create: {
+ width: 4,
+ height: 4,
+ channels: 3,
+ background: { r: i, g: i, b: i },
+ },
+ })
+ .png()
+ .toFile(join(dir, `scroll-${String(i).padStart(3, "0")}.png`));
+ }
+
+ let checks = 0;
+ const output = join(dir, "contact-sheet.jpg");
+ const sheets = await createScrollContactSheet(dir, output, {
+ remainingMs: () => (checks++ === 0 ? 1000 : 0),
+ });
+
+ expect(sheets).toEqual([join(dir, "contact-sheet-1.jpg")]);
+ expect(existsSync(join(dir, "contact-sheet-2.jpg"))).toBe(false);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ }, 60_000);
+
+ it("stops SVG thumbnail rasterization before the next native operation", async () => {
+ const dir = tempDir();
+ try {
+ const svgs = join(dir, "svgs");
+ const { mkdirSync } = await import("node:fs");
+ mkdirSync(svgs);
+ writeFileSync(
+ join(svgs, "a.svg"),
+ '',
+ );
+ writeFileSync(
+ join(svgs, "b.svg"),
+ '',
+ );
+ let checks = 0;
+
+ const sheets = await createSvgContactSheet(
+ svgs,
+ join(dir, "svg-contact-sheet.jpg"),
+ undefined,
+ { remainingMs: () => (checks++ === 0 ? 1000 : 0) },
+ );
+
+ expect(sheets).toEqual([]);
+ expect(checks).toBeGreaterThanOrEqual(2);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ }, 60_000);
+});
diff --git a/packages/cli/src/capture/contentExtractor.test.ts b/packages/cli/src/capture/contentExtractor.test.ts
index 9683653848..a929d29382 100644
--- a/packages/cli/src/capture/contentExtractor.test.ts
+++ b/packages/cli/src/capture/contentExtractor.test.ts
@@ -4,17 +4,29 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { captionImagesWithGemini } from "./contentExtractor.js";
+const { generateContentMock } = vi.hoisted(() => ({
+ generateContentMock: vi.fn(),
+}));
+
+vi.mock("@google/genai", () => ({
+ GoogleGenAI: class {
+ models = { generateContent: generateContentMock };
+ },
+}));
+
// These tests exercise the OpenRouter provider path only — it makes a plain
// `fetch` call we can stub, with no native (`sharp`) or `@google/genai`
// dependency. OpenRouter wins over Gemini when OPENROUTER_API_KEY is set, so we
// don't need to clear the Gemini keys for the OpenRouter cases.
-function makeProjectWithImage(): string {
+function makeProjectWithImages(files = ["hero.png"]): string {
const dir = mkdtempSync(join(tmpdir(), "hf-caption-"));
mkdirSync(join(dir, "assets"), { recursive: true });
// Contents are irrelevant to the OpenRouter path (it just base64-encodes the
// bytes); only the .png extension matters for the image filter.
- writeFileSync(join(dir, "assets", "hero.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
+ for (const file of files) {
+ writeFileSync(join(dir, "assets", file), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
+ }
return dir;
}
@@ -28,7 +40,7 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
});
it("captions via OpenRouter when OPENROUTER_API_KEY is set", async () => {
- const dir = makeProjectWithImage();
+ const dir = makeProjectWithImages();
dirs.push(dir);
vi.stubEnv("OPENROUTER_API_KEY", "or-test-key");
vi.stubEnv("HYPERFRAMES_OPENROUTER_MODEL", "google/gemini-3.1-flash-lite");
@@ -63,7 +75,7 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
});
it("degrades gracefully (no throw, no captions) when OpenRouter returns a non-OK status", async () => {
- const dir = makeProjectWithImage();
+ const dir = makeProjectWithImages();
dirs.push(dir);
vi.stubEnv("OPENROUTER_API_KEY", "or-bad-key");
@@ -83,8 +95,97 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
expect(captions).toEqual({});
});
+ it("terminates captioning when the vision provider never responds", async () => {
+ const dir = makeProjectWithImages();
+ dirs.push(dir);
+ vi.stubEnv("OPENROUTER_API_KEY", "or-hanging-key");
+ vi.stubEnv("HYPERFRAMES_VISION_TIMEOUT_MS", "20");
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((_url: string, init?: RequestInit) => {
+ return new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), {
+ once: true,
+ });
+ });
+ }),
+ );
+
+ const warnings: string[] = [];
+ const result = await Promise.race([
+ captionImagesWithGemini(dir, () => {}, warnings),
+ new Promise<"hung">((resolve) => setTimeout(() => resolve("hung"), 250)),
+ ]);
+
+ expect(result).toEqual({});
+ });
+
+ it("keeps a successful sibling caption when another request times out", async () => {
+ const dir = makeProjectWithImages(["hang.png", "success.png"]);
+ dirs.push(dir);
+ vi.stubEnv("OPENROUTER_API_KEY", "or-mixed-key");
+ vi.stubEnv("HYPERFRAMES_VISION_TIMEOUT_MS", "20");
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((_url: string, init?: RequestInit) => {
+ const body = JSON.parse(typeof init?.body === "string" ? init.body : "{}");
+ const imageUrl = body.messages?.[0]?.content?.[1]?.image_url?.url;
+ if (typeof imageUrl === "string" && imageUrl.includes("iVBORw==")) {
+ // Both fixtures contain the same bytes, so use call order: the first
+ // request hangs while the second succeeds independently.
+ const fetchMock = vi.mocked(fetch);
+ if (fetchMock.mock.calls.length === 1) return new Promise(() => {});
+ }
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({ choices: [{ message: { content: "Successful image." } }] }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ );
+ }),
+ );
+
+ const warnings: string[] = [];
+ const captions = await captionImagesWithGemini(dir, () => {}, warnings);
+
+ expect(captions).toEqual({ "success.png": "Successful image." });
+ });
+
+ it("does not call a configured provider when vision is skipped", async () => {
+ const dir = makeProjectWithImages();
+ dirs.push(dir);
+ vi.stubEnv("OPENROUTER_API_KEY", "or-unused-key");
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+
+ const captions = await captionImagesWithGemini(dir, () => {}, [], { skipVision: true });
+
+ expect(captions).toEqual({});
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("does not start a vision batch after the capture budget is exhausted", async () => {
+ const dir = makeProjectWithImages();
+ dirs.push(dir);
+ vi.stubEnv("OPENROUTER_API_KEY", "or-budget-key");
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+
+ const captions = await captionImagesWithGemini(dir, () => {}, [], {
+ remainingMs: () => 0,
+ });
+
+ expect(captions).toEqual({});
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
it("skips captioning entirely when no provider key is present", async () => {
- const dir = makeProjectWithImage();
+ const dir = makeProjectWithImages();
dirs.push(dir);
vi.stubEnv("OPENROUTER_API_KEY", "");
vi.stubEnv("GEMINI_API_KEY", "");
@@ -100,3 +201,30 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
});
+
+describe("captionImagesWithGemini — Gemini provider", () => {
+ const dirs: string[] = [];
+
+ afterEach(() => {
+ generateContentMock.mockReset();
+ vi.unstubAllEnvs();
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
+ dirs.length = 0;
+ });
+
+ it("terminates captioning when Gemini never settles even if the SDK ignores abort", async () => {
+ const dir = makeProjectWithImages();
+ dirs.push(dir);
+ vi.stubEnv("OPENROUTER_API_KEY", "");
+ vi.stubEnv("GEMINI_API_KEY", "gemini-hanging-key");
+ vi.stubEnv("HYPERFRAMES_VISION_TIMEOUT_MS", "20");
+ generateContentMock.mockImplementation(() => new Promise(() => {}));
+
+ const result = await Promise.race([
+ captionImagesWithGemini(dir, () => {}, []),
+ new Promise<"hung">((resolve) => setTimeout(() => resolve("hung"), 250)),
+ ]);
+
+ expect(result).toEqual({});
+ });
+});
diff --git a/packages/cli/src/commands/capture.test.ts b/packages/cli/src/commands/capture.test.ts
new file mode 100644
index 0000000000..eb3439afb2
--- /dev/null
+++ b/packages/cli/src/commands/capture.test.ts
@@ -0,0 +1,132 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { CliRuntimeError } from "../utils/commandResult.js";
+
+const { captureWebsiteMock } = vi.hoisted(() => ({
+ captureWebsiteMock: vi.fn(async (options: unknown) => {
+ if (typeof options === "object" && options !== null) {
+ const onPhase = Reflect.get(options, "onPhase");
+ if (typeof onPhase === "function") {
+ onPhase({
+ schema: "hyperframes.capture.phase.v1",
+ phase: "vision",
+ status: "degraded",
+ remainingMs: 0,
+ reason: "budget-exhausted",
+ });
+ }
+ }
+ return {
+ ok: true,
+ projectDir: "/tmp/capture",
+ url: "https://example.com",
+ title: "Example",
+ extracted: {},
+ screenshots: [],
+ tokens: { sections: [], fonts: [] },
+ assets: [],
+ warnings: [],
+ };
+ }),
+}));
+
+vi.mock("../capture/index.js", () => ({ captureWebsite: captureWebsiteMock }));
+
+import captureCommand from "./capture.js";
+
+describe("capture command — vision control", () => {
+ afterEach(() => {
+ captureWebsiteMock.mockClear();
+ vi.restoreAllMocks();
+ });
+
+ it("declares --skip-vision as an opt-in boolean", () => {
+ const skipVisionArg = captureCommand.args
+ ? Reflect.get(captureCommand.args, "skip-vision")
+ : undefined;
+ expect(skipVisionArg).toMatchObject({
+ type: "boolean",
+ default: false,
+ });
+ });
+
+ it("plumbs --skip-vision into capture options", async () => {
+ vi.spyOn(console, "log").mockImplementation(() => {});
+ vi.spyOn(console, "error").mockImplementation(() => {});
+
+ await captureCommand.run!({
+ args: {
+ url: "https://example.com",
+ output: "/tmp/hf-skip-vision-test",
+ "skip-assets": false,
+ "skip-vision": true,
+ json: true,
+ },
+ } as never);
+
+ expect(captureWebsiteMock).toHaveBeenCalledWith(
+ expect.objectContaining({ skipVision: true }),
+ undefined,
+ );
+ });
+
+ it("emits a versioned phase record without the captured URL", async () => {
+ vi.spyOn(console, "log").mockImplementation(() => {});
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ await captureCommand.run!({
+ args: {
+ url: "https://user:secret@example.com/private",
+ output: "/tmp/hf-phase-progress-test",
+ "skip-assets": false,
+ "skip-vision": false,
+ json: true,
+ },
+ } as never);
+
+ const line = error.mock.calls.find(
+ ([value]) => typeof value === "string" && value.startsWith("HYPERFRAMES_CAPTURE_PHASE "),
+ )?.[0];
+ expect(typeof line).toBe("string");
+ if (typeof line !== "string") throw new Error("Expected capture phase diagnostic");
+ const event = JSON.parse(line.slice("HYPERFRAMES_CAPTURE_PHASE ".length));
+ expect(event).toEqual({
+ schema: "hyperframes.capture.phase.v1",
+ phase: "vision",
+ status: "degraded",
+ remainingMs: 0,
+ reason: "budget-exhausted",
+ });
+ expect(line).not.toContain("secret");
+ expect(line).not.toContain("/private");
+ });
+
+ it("keeps required capture failures nonzero", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "hf-capture-failure-"));
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ captureWebsiteMock.mockRejectedValueOnce(new Error("required extraction failed"));
+
+ try {
+ await expect(
+ captureCommand.run!({
+ args: {
+ url: "https://example.com",
+ output: dir,
+ "skip-assets": false,
+ "skip-vision": false,
+ json: true,
+ },
+ } as never),
+ ).rejects.toBeInstanceOf(CliRuntimeError);
+ const payload = log.mock.calls.at(-1)?.[0];
+ expect(typeof payload).toBe("string");
+ if (typeof payload !== "string") throw new Error("Expected JSON failure payload");
+ expect(JSON.parse(payload)).toMatchObject({ ok: false, error: "required extraction failed" });
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});