import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const producerState = vi.hoisted(() => ({ createdJobs: [] as Array>, resolveConfigCalls: [] as Array>, })); vi.mock("../utils/producer.js", () => ({ loadProducer: vi.fn(async () => ({ resolveConfig: vi.fn((overrides: Record) => { producerState.resolveConfigCalls.push(overrides); return { ...overrides, resolved: true }; }), createRenderJob: vi.fn((config: Record) => { producerState.createdJobs.push(config); return { config, progress: 100 }; }), executeRenderJob: vi.fn(async () => undefined), })), })); vi.mock("../telemetry/events.js", () => ({ trackRenderComplete: vi.fn(), trackRenderError: vi.fn(), })); describe("renderLocal browser GPU config", () => { const savedEnv = new Map(); function setEnv(key: string, value: string) { savedEnv.set(key, process.env[key]); process.env[key] = value; } beforeEach(() => { producerState.createdJobs = []; producerState.resolveConfigCalls = []; savedEnv.clear(); }); afterEach(() => { for (const [key, value] of savedEnv) { if (value === undefined) { delete process.env[key]; } else { process.env[key] = value; } } vi.clearAllMocks(); vi.useRealTimers(); vi.restoreAllMocks(); }); it("passes an explicit software override for --no-browser-gpu even when env requests hardware", async () => { setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware"); const { renderLocal } = await import("./render.js"); await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: 30, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, }); expect(producerState.resolveConfigCalls).toContainEqual({ browserGpuMode: "software" }); expect(producerState.createdJobs[0]?.producerConfig).toMatchObject({ browserGpuMode: "software", resolved: true, }); }); it("forwards browserGpuMode='auto' into producer config (probe-then-choose)", async () => { const { renderLocal } = await import("./render.js"); await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: 30, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "auto", hdrMode: "auto", quiet: true, }); expect(producerState.resolveConfigCalls).toContainEqual({ browserGpuMode: "auto" }); expect(producerState.createdJobs[0]?.producerConfig).toMatchObject({ browserGpuMode: "auto", resolved: true, }); }); it("passes an explicit hardware override for default local browser GPU", async () => { const { renderLocal } = await import("./render.js"); await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: 30, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "hardware", hdrMode: "auto", quiet: true, }); expect(producerState.resolveConfigCalls).toContainEqual({ browserGpuMode: "hardware" }); expect(producerState.createdJobs[0]?.producerConfig).toMatchObject({ browserGpuMode: "hardware", resolved: true, }); }); it("resolves browser GPU from CLI flags, Docker mode, and env fallback", async () => { const { resolveBrowserGpuForCli } = await import("./render.js"); // Default (no flag, no env): auto — engine probes and chooses. expect(resolveBrowserGpuForCli(false, undefined, undefined)).toBe("auto"); // Env override expect(resolveBrowserGpuForCli(false, undefined, "hardware")).toBe("hardware"); expect(resolveBrowserGpuForCli(false, undefined, "software")).toBe("software"); expect(resolveBrowserGpuForCli(false, undefined, "auto")).toBe("auto"); // Explicit CLI flag wins over env expect(resolveBrowserGpuForCli(false, true, "software")).toBe("hardware"); expect(resolveBrowserGpuForCli(false, false, "hardware")).toBe("software"); // Docker forces software regardless of flags/env expect(resolveBrowserGpuForCli(true, undefined, "hardware")).toBe("software"); expect(resolveBrowserGpuForCli(true, undefined, "auto")).toBe("software"); }); it("forwards parsed --variables payload to createRenderJob", async () => { const { renderLocal } = await import("./render.js"); await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: 30, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, variables: { title: "Hello", count: 3 }, }); expect(producerState.createdJobs[0]?.variables).toEqual({ title: "Hello", count: 3 }); }); it("forwards format: png-sequence through to createRenderJob", async () => { const { renderLocal } = await import("./render.js"); await renderLocal("/tmp/project", "/tmp/frames", { fps: 30, quality: "standard", format: "png-sequence", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, }); expect(producerState.createdJobs[0]?.format).toBe("png-sequence"); }); it("omits variables from createRenderJob when not provided", async () => { const { renderLocal } = await import("./render.js"); await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: 30, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, }); expect(producerState.createdJobs[0]?.variables).toBeUndefined(); }); it("forwards entryFile to createRenderJob when --composition is set", async () => { const { renderLocal } = await import("./render.js"); await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: 30, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, entryFile: "compositions/intro.html", }); expect(producerState.createdJobs[0]?.entryFile).toBe("compositions/intro.html"); }); it("omits entryFile from createRenderJob when --composition is not set", async () => { const { renderLocal } = await import("./render.js"); await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: 30, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, }); expect(producerState.createdJobs[0]?.entryFile).toBeUndefined(); }); it("can force the CLI process to exit after a successful local render", async () => { vi.useFakeTimers(); const exit = vi .spyOn(process, "exit") .mockImplementation((code?: string | number | null): never => { throw new Error(`process.exit:${code ?? ""}`); }); const { renderLocal } = await import("./render.js"); await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: 30, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "hardware", hdrMode: "auto", quiet: true, exitAfterComplete: true, }); expect(exit).not.toHaveBeenCalled(); expect(() => vi.advanceTimersByTime(100)).toThrow("process.exit:0"); expect(exit).toHaveBeenCalledWith(0); }); }); describe("parseVariablesArg", () => { let parseVariablesArg: typeof import("./render.js").parseVariablesArg; beforeAll(async () => { ({ parseVariablesArg } = await import("./render.js")); }); function expectErr( result: import("./render.js").VariablesParseResult, ): T { if (result.ok) throw new Error(`expected error, got ${JSON.stringify(result.value)}`); return result.error as T; } it("returns undefined when neither flag is set", () => { expect(parseVariablesArg(undefined, undefined)).toEqual({ ok: true, value: undefined }); }); it("parses inline JSON object", () => { expect(parseVariablesArg('{"title":"Hello","n":3}', undefined)).toEqual({ ok: true, value: { title: "Hello", n: 3 }, }); }); it("parses file JSON via injected reader", () => { const fakeReader = (path: string) => { if (path === "vars.json") return '{"theme":"dark"}'; throw new Error("unexpected path"); }; expect(parseVariablesArg(undefined, "vars.json", fakeReader)).toEqual({ ok: true, value: { theme: "dark" }, }); }); it("rejects when both flags are set", () => { const err = expectErr(parseVariablesArg('{"a":1}', "vars.json")); expect(err).toEqual({ kind: "conflict" }); }); it("rejects unparseable JSON with a source-aware kind", () => { expect(expectErr(parseVariablesArg("{not json", undefined))).toMatchObject({ kind: "parse-error", source: "inline", }); expect(expectErr(parseVariablesArg(undefined, "x", () => "{not json"))).toMatchObject({ kind: "parse-error", source: "file", }); }); it("rejects non-object payloads (array, string, null, number)", () => { for (const payload of ["[1,2]", '"hello"', "null", "42"]) { expect(expectErr(parseVariablesArg(payload, undefined))).toEqual({ kind: "shape-error" }); } }); it("surfaces filesystem errors from --variables-file", () => { const err = expectErr<{ kind: "read-error"; path: string; cause: string; }>( parseVariablesArg(undefined, "missing.json", () => { throw new Error("ENOENT: no such file"); }), ); expect(err.kind).toBe("read-error"); expect(err.path).toBe("missing.json"); expect(err.cause).toMatch(/ENOENT/); }); }); describe("validateVariablesAgainstProject", () => { let validateVariablesAgainstProject: typeof import("./render.js").validateVariablesAgainstProject; let tmpDir: string; let mkdtempSync: typeof import("node:fs").mkdtempSync; let writeFileSync: typeof import("node:fs").writeFileSync; let rmSync: typeof import("node:fs").rmSync; let join: typeof import("node:path").join; let tmpdir: typeof import("node:os").tmpdir; beforeAll(async () => { ({ validateVariablesAgainstProject } = await import("./render.js")); ({ mkdtempSync, writeFileSync, rmSync } = await import("node:fs")); ({ join } = await import("node:path")); ({ tmpdir } = await import("node:os")); }); beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "hf-validate-vars-")); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); function writeIndex(html: string): string { const path = join(tmpDir, "index.html"); writeFileSync(path, html); return path; } it("returns [] when the project has no data-composition-variables declarations", () => { const indexPath = writeIndex(`
`); expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]); }); it("returns [] when every value matches its declaration", () => { const indexPath = writeIndex( `
`, ); expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]); }); it("flags undeclared keys", () => { const indexPath = writeIndex( `
`, ); expect(validateVariablesAgainstProject(indexPath, { title: "Hello", extra: 1 })).toEqual([ { kind: "undeclared", variableId: "extra" }, ]); }); it("flags type mismatches", () => { const indexPath = writeIndex( `
`, ); expect(validateVariablesAgainstProject(indexPath, { count: "three" })).toEqual([ { kind: "type-mismatch", variableId: "count", expected: "number", actual: "string" }, ]); }); it("returns [] when the index file cannot be read (lint owns that diagnostic)", () => { expect( validateVariablesAgainstProject(join(tmpDir, "missing.html"), { title: "Hello" }), ).toEqual([]); }); });