Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions packages/cli/src/capture/contactSheet.test.ts
Original file line number Diff line number Diff line change
@@ -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-"));
Expand Down Expand Up @@ -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"),
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="4" height="4"/></svg>',
);
writeFileSync(
join(svgs, "b.svg"),
'<svg xmlns="http://www.w3.org/2000/svg"><circle cx="2" cy="2" r="2"/></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);
});
138 changes: 133 additions & 5 deletions packages/cli/src/capture/contentExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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");
Expand Down Expand Up @@ -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");

Expand All @@ -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<Response>((_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<Response>(() => {});
}
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", "");
Expand All @@ -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({});
});
});
132 changes: 132 additions & 0 deletions packages/cli/src/commands/capture.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading