From 45aead84c9467398cd18bc0878d6f4943daf65fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 31 Jul 2026 19:29:49 +0000 Subject: [PATCH] test(cli): cover bounded capture stages --- packages/cli/src/capture/contactSheet.test.ts | 69 ++++++++- .../cli/src/capture/contentExtractor.test.ts | 138 +++++++++++++++++- packages/cli/src/commands/capture.test.ts | 132 +++++++++++++++++ 3 files changed, 332 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/commands/capture.test.ts 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 }); + } + }); +});