mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
test(cli): cover bounded capture stages
This commit is contained in:
@@ -1,9 +1,13 @@
|
|||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createContactSheet } from "./contactSheet.js";
|
import {
|
||||||
|
createContactSheet,
|
||||||
|
createScrollContactSheet,
|
||||||
|
createSvgContactSheet,
|
||||||
|
} from "./contactSheet.js";
|
||||||
|
|
||||||
function tempDir(): string {
|
function tempDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "hf-contact-sheet-test-"));
|
return mkdtempSync(join(tmpdir(), "hf-contact-sheet-test-"));
|
||||||
@@ -58,3 +62,64 @@ describe("createContactSheet", () => {
|
|||||||
}
|
}
|
||||||
}, 60_000);
|
}, 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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,17 +4,29 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { captionImagesWithGemini } from "./contentExtractor.js";
|
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
|
// These tests exercise the OpenRouter provider path only — it makes a plain
|
||||||
// `fetch` call we can stub, with no native (`sharp`) or `@google/genai`
|
// `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
|
// 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.
|
// 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-"));
|
const dir = mkdtempSync(join(tmpdir(), "hf-caption-"));
|
||||||
mkdirSync(join(dir, "assets"), { recursive: true });
|
mkdirSync(join(dir, "assets"), { recursive: true });
|
||||||
// Contents are irrelevant to the OpenRouter path (it just base64-encodes the
|
// Contents are irrelevant to the OpenRouter path (it just base64-encodes the
|
||||||
// bytes); only the .png extension matters for the image filter.
|
// 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;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +40,7 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("captions via OpenRouter when OPENROUTER_API_KEY is set", async () => {
|
it("captions via OpenRouter when OPENROUTER_API_KEY is set", async () => {
|
||||||
const dir = makeProjectWithImage();
|
const dir = makeProjectWithImages();
|
||||||
dirs.push(dir);
|
dirs.push(dir);
|
||||||
vi.stubEnv("OPENROUTER_API_KEY", "or-test-key");
|
vi.stubEnv("OPENROUTER_API_KEY", "or-test-key");
|
||||||
vi.stubEnv("HYPERFRAMES_OPENROUTER_MODEL", "google/gemini-3.1-flash-lite");
|
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 () => {
|
it("degrades gracefully (no throw, no captions) when OpenRouter returns a non-OK status", async () => {
|
||||||
const dir = makeProjectWithImage();
|
const dir = makeProjectWithImages();
|
||||||
dirs.push(dir);
|
dirs.push(dir);
|
||||||
vi.stubEnv("OPENROUTER_API_KEY", "or-bad-key");
|
vi.stubEnv("OPENROUTER_API_KEY", "or-bad-key");
|
||||||
|
|
||||||
@@ -83,8 +95,97 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
|
|||||||
expect(captions).toEqual({});
|
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 () => {
|
it("skips captioning entirely when no provider key is present", async () => {
|
||||||
const dir = makeProjectWithImage();
|
const dir = makeProjectWithImages();
|
||||||
dirs.push(dir);
|
dirs.push(dir);
|
||||||
vi.stubEnv("OPENROUTER_API_KEY", "");
|
vi.stubEnv("OPENROUTER_API_KEY", "");
|
||||||
vi.stubEnv("GEMINI_API_KEY", "");
|
vi.stubEnv("GEMINI_API_KEY", "");
|
||||||
@@ -100,3 +201,30 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
|
|||||||
expect(fetchMock).not.toHaveBeenCalled();
|
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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user