// fallow-ignore-file code-duplication import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const producerState = vi.hoisted(() => ({ createdJobs: [] as Array>, resolveConfigCalls: [] as Array>, })); const preflightState = vi.hoisted(() => ({ result: { outcomes: [ { name: "FFmpeg", ok: true, level: "ok", detail: "/usr/bin/ffmpeg", path: "/usr/bin/ffmpeg" }, { name: "FFprobe", ok: true, level: "ok", detail: "/usr/bin/ffprobe", path: "/usr/bin/ffprobe", }, { name: "Chrome", ok: true, level: "ok", detail: "cache: /mock/chrome", path: "/mock/chrome", }, ], ffmpegPath: "/usr/bin/ffmpeg", ffprobePath: "/usr/bin/ffprobe", browser: { executablePath: "/mock/chrome", source: "cache" }, }, })); 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(), })); vi.mock("../browser/ffmpeg.js", () => ({ findFFmpeg: vi.fn(() => "/usr/bin/ffmpeg"), getFFmpegInstallHint: vi.fn(() => "brew install ffmpeg"), })); vi.mock("../browser/preflight.js", () => ({ runEnvironmentChecks: vi.fn(async () => preflightState.result), })); describe("renderLocal browser GPU config", () => { const savedEnv = new Map(); // Pre-resolve once. The first dynamic `import("./render.js")` in this file // cold-loads a heavy module graph (core + engine + producer, incl. linkedom), // slow under the parallel monorepo run — the generous hook timeout that // absorbs that contention now lives in vitest.config.ts (shared by all CLI // suites). Importing once in `beforeAll` keeps every test fast and isolated. let renderLocal: typeof import("./render.js").renderLocal; let resolveBrowserGpuForCli: typeof import("./render.js").resolveBrowserGpuForCli; beforeAll(async () => { ({ renderLocal, resolveBrowserGpuForCli } = await import("./render.js")); }); function setEnv(key: string, value: string) { if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]); process.env[key] = value; } beforeEach(() => { producerState.createdJobs = []; producerState.resolveConfigCalls = []; savedEnv.clear(); savedEnv.set("HYPERFRAMES_FFMPEG_PATH", process.env.HYPERFRAMES_FFMPEG_PATH); savedEnv.set("HYPERFRAMES_FFPROBE_PATH", process.env.HYPERFRAMES_FFPROBE_PATH); savedEnv.set("PRODUCER_HEADLESS_SHELL_PATH", process.env.PRODUCER_HEADLESS_SHELL_PATH); delete process.env.HYPERFRAMES_FFMPEG_PATH; delete process.env.HYPERFRAMES_FFPROBE_PATH; delete process.env.PRODUCER_HEADLESS_SHELL_PATH; }); 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"); await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, 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, }); }, 15_000); it("forwards browserGpuMode='auto' into producer config (probe-then-choose)", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, 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 () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, 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("passes preflight-resolved FFmpeg, FFprobe, and browser paths through env", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, }); expect(process.env.HYPERFRAMES_FFMPEG_PATH).toBe("/usr/bin/ffmpeg"); expect(process.env.HYPERFRAMES_FFPROBE_PATH).toBe("/usr/bin/ffprobe"); expect(process.env.PRODUCER_HEADLESS_SHELL_PATH).toBe("/mock/chrome"); }); it("resolves browser GPU from CLI flags, Docker mode, and env fallback", () => { // 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 () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, 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 () => { await renderLocal("/tmp/project", "/tmp/frames", { fps: { num: 30, den: 1 }, quality: "standard", format: "png-sequence", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, }); expect(producerState.createdJobs[0]?.format).toBe("png-sequence"); }); it("forwards format: gif and gifLoop through to createRenderJob", async () => { await renderLocal("/tmp/project", "/tmp/demo.gif", { fps: { num: 15, den: 1 }, quality: "standard", format: "gif", gifLoop: 3, gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, }); expect(producerState.createdJobs[0]?.format).toBe("gif"); expect(producerState.createdJobs[0]?.gifLoop).toBe(3); }); it("forwards videoFrameFormat to createRenderJob", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, videoFrameFormat: "png", }); expect(producerState.createdJobs[0]?.videoFrameFormat).toBe("png"); }); it("forwards debug mode to createRenderJob", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, debug: true, }); expect(producerState.createdJobs[0]?.debug).toBe(true); }); it("omits variables from createRenderJob when not provided", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, 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 () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, 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 () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, }); expect(producerState.createdJobs[0]?.entryFile).toBeUndefined(); }); it("forwards --browser-timeout into resolveConfig as pageNavigationTimeout (ms)", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, pageNavigationTimeoutMs: 180_000, }); expect(producerState.resolveConfigCalls[0]).toMatchObject({ pageNavigationTimeout: 180_000, }); }); it("forwards vp9CpuUsed into resolveConfig when set", async () => { await renderLocal("/tmp/project", "/tmp/out.webm", { fps: { num: 30, den: 1 }, quality: "standard", format: "webm", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, vp9CpuUsed: 2, }); expect(producerState.resolveConfigCalls[0]).toMatchObject({ vp9CpuUsed: 2, }); }); it("omits pageNavigationTimeout from resolveConfig when --browser-timeout is not set", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, }); // Issue #1199: when the flag is omitted, the engine's DEFAULT_CONFIG must // own the navigation timeout. Forwarding `undefined` would override // `pageNavigationTimeout: 60_000` to `undefined` and re-introduce the // bug in a different shape. expect(producerState.resolveConfigCalls[0]).not.toHaveProperty("pageNavigationTimeout"); }); it("forwards outputResolution to createRenderJob when --resolution is set", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, outputResolution: "landscape-4k", }); expect(producerState.createdJobs[0]?.outputResolution).toBe("landscape-4k"); }); it("omits outputResolution from createRenderJob by default", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, quality: "standard", format: "mp4", gpu: false, browserGpuMode: "software", hdrMode: "auto", quiet: true, }); expect(producerState.createdJobs[0]?.outputResolution).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 ?? ""}`); }); await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, 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("checkRenderResolutionPreflight", () => { let checkRenderResolutionPreflight: typeof import("./render.js").checkRenderResolutionPreflight; // Cold-imports render.js (heavy graph); the generous hook timeout for parallel // CI contention lives in vitest.config.ts. See the note above. beforeAll(async () => { ({ checkRenderResolutionPreflight } = await import("./render.js")); }); // Dims must be read the same way the producer's compiler reads them: // `data-width` / `data-height` on the `[data-composition-id]` root. const comp = (w: number, h: number) => `
`; const portraitHtml = comp(1080, 1920); const landscapeHtml = comp(1920, 1080); const noModes = { alphaRequested: false, hdrRequested: false } as const; it("returns undefined when no outputResolution is requested", async () => { expect(await checkRenderResolutionPreflight(portraitHtml, undefined, noModes)).toBeUndefined(); }); it("returns undefined when the preset matches the composition orientation", async () => { expect(await checkRenderResolutionPreflight(portraitHtml, "portrait", noModes)).toBeUndefined(); }); it("returns a suggestion + aspect-mismatch kind when a landscape preset is used on a portrait composition", async () => { const result = await checkRenderResolutionPreflight(portraitHtml, "landscape", noModes); expect(result?.message).toContain("--resolution portrait"); expect(result?.kind).toBe("aspect-mismatch"); }); it("suggests landscape for a landscape composition rendered with a portrait preset", async () => { const result = await checkRenderResolutionPreflight(landscapeHtml, "portrait", noModes); expect(result?.message).toContain("--resolution landscape"); }); it("preserves the 4K tier when suggesting a matching preset (square comp + landscape-4k → square-4k)", async () => { // Tier-aware suggestion is the load-bearing new behavior; square-4k is the // preset that only surfaces via a same-tier swap, so guard it explicitly. const result = await checkRenderResolutionPreflight(comp(2160, 2160), "landscape-4k", noModes); expect(result?.message).toContain("--resolution square-4k"); }); it("does not false-abort a landscape registry-block composition (data-width/height, no data-resolution)", async () => { // Regression guard: registry blocks carry data-width/height and no // data-resolution — a preset-snapping heuristic would misread this as // portrait and wrongly reject the correct --resolution landscape. expect( await checkRenderResolutionPreflight(landscapeHtml, "landscape", noModes), ).toBeUndefined(); }); it("flags alpha output combined with outputResolution", async () => { const result = await checkRenderResolutionPreflight(landscapeHtml, "landscape-4k", { alphaRequested: true, hdrRequested: false, }); expect(result?.message).toContain("alpha output"); expect(result?.kind).toBe("alpha-incompatible"); }); // The three remaining kinds share the same rejection sink (→ one emit each); // guard their classification so the telemetry dimension stays accurate. it("classifies an HDR + outputResolution combination as hdr-incompatible", async () => { const result = await checkRenderResolutionPreflight(landscapeHtml, "landscape", { alphaRequested: false, hdrRequested: true, }); expect(result?.kind).toBe("hdr-incompatible"); }); it("classifies a preset smaller than the composition as downsampling", async () => { // 3840×2160 comp + landscape (1920×1080): same 16:9 aspect, target smaller. const result = await checkRenderResolutionPreflight(comp(3840, 2160), "landscape", noModes); expect(result?.kind).toBe("downsampling"); }); it("classifies a non-integer upscale as non-integer-scale", async () => { // 1280×720 comp + landscape (1920×1080): same 16:9 aspect, 1.5× scale. const result = await checkRenderResolutionPreflight(comp(1280, 720), "landscape", noModes); expect(result?.kind).toBe("non-integer-scale"); }); it("returns undefined when composition dimensions can't be determined (defers to the pipeline)", async () => { // No [data-composition-id] root / no data-width/height → defer, never guess. expect(await checkRenderResolutionPreflight("", "landscape", noModes)).toBeUndefined(); expect( await checkRenderResolutionPreflight("", "landscape", noModes), ).toBeUndefined(); }); }); describe("render fps arg definition", () => { it("declares no citty default for --fps (so data-fps resolution can run)", async () => { // Regression guard: a `default: "30"` here makes citty set args.fps="30" // on omission, which short-circuits resolveDefaultFpsArg (explicitFps is // never null) and silently reverts the command to always-30 — the exact // no-op caught in review. The "30" fallback must live at the // parseFps(fpsArg ?? "30") call, not on the arg. const cmd = (await import("./render.js")).default; // citty types `args` as Resolvable (it could be a promise/factory); in // practice it's the literal object, so read it through a plain record. const args = cmd.args as unknown as Record; const fpsArg = args.fps; expect(fpsArg).toBeDefined(); expect(fpsArg?.default).toBeUndefined(); }); }); // Variables-helper tests live in `../utils/variables.test.ts`.