Files
hyperframes/packages/cli/src/commands/render.test.ts
T
Miguel Ángel 5d3a7404fa fix(render): consolidate preflight and local recovery (#2403)
* fix(render): fall back when libx264 is unavailable

* fix(render): recover orphaned browsers before retry

* fix(render): check disk space on write volumes

* test(engine): accept resolved ffmpeg binary paths

* fix(render): harden H.264 capability fallback

* chore(ci): scope inherited Fallow findings

* fix(render): diagnose encoder probe failures
2026-07-14 21:55:39 -04:00

1229 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// fallow-ignore-file code-duplication
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const producerState = vi.hoisted(() => ({
createdJobs: [] as Array<Record<string, unknown>>,
resolveConfigCalls: [] as Array<Record<string, unknown>>,
// Overridable per-test hook so the DE-parallel-router-trial tests can
// mutate the job (perfSummary/errorDetails) or throw, without perturbing
// every other test in this file that expects a plain no-op resolve.
executeImpl: async (_job: Record<string, unknown>): Promise<void> => undefined,
}));
// Defaults to "trial already fired" so the pre-existing renderLocal tests
// below (which predate the DE-parallel-router trial and don't expect
// HF_DE_PARALLEL_ROUTER to be touched) keep their exact prior behavior.
//
// `disk` is the authoritative "file"; `cache` models config.ts's real
// process-lifetime cachedConfig. Modeling them SEPARATELY matters: a mock
// where readConfig/readConfigFresh both read one live object hides exactly
// the class of bug where production code reads the stale cache when it
// needed a fresh disk read (review finding). `failWrites` simulates the
// real writeConfig's silent fs-error swallowing (unwritable ~/.hyperframes):
// the next N writes are recorded but never reach `disk`.
const configState = vi.hoisted(
(): {
disk: Record<string, unknown>;
cache: Record<string, unknown> | null;
writeConfigCalls: Array<Record<string, unknown>>;
failWrites: number;
} => ({
disk: { telemetryEnabled: true, deParallelRouterTrialFired: true },
cache: null,
writeConfigCalls: [],
failWrites: 0,
}),
);
const trackingState = vi.hoisted(() => ({
// maybeEnableDeParallelRouterTrial gates on the real shouldTrack(), which
// (via isDevMode()) always returns false when this file itself runs as
// `.ts` source under vitest — mocked here so the CLI-trial tests can
// control it directly instead of inheriting that environment quirk.
shouldTrack: true,
renderObservations: [] as Array<Record<string, unknown>>,
}));
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" },
},
}));
const ffmpegEncoderState = vi.hoisted(() => ({
mode: "software" as "software" | "gpu",
error: null as Error | null,
}));
const orphanCleanupState = vi.hoisted(() => ({
calls: 0,
killed: 0,
}));
vi.mock("../utils/producer.js", () => ({
loadProducer: vi.fn(async () => ({
resolveConfig: vi.fn((overrides: Record<string, unknown>) => {
producerState.resolveConfigCalls.push(overrides);
return { ...overrides, resolved: true };
}),
createRenderJob: vi.fn((config: Record<string, unknown>) => {
producerState.createdJobs.push(config);
return { config, progress: 100, outcome: "completed", warnings: [] };
}),
executeRenderJob: vi.fn(async (job: Record<string, unknown>) => producerState.executeImpl(job)),
})),
}));
vi.mock("../telemetry/config.js", () => ({
readConfig: vi.fn(() => {
if (!configState.cache) configState.cache = { ...configState.disk };
return { ...configState.cache };
}),
readConfigFresh: vi.fn(() => {
configState.cache = { ...configState.disk };
return { ...configState.disk };
}),
writeConfig: vi.fn((config: Record<string, unknown>) => {
configState.writeConfigCalls.push({ ...config });
if (configState.failWrites > 0) {
configState.failWrites--;
return false; // swallowed silently, like the real writeConfig's catch {}
}
configState.disk = { ...config };
configState.cache = { ...config };
return true;
}),
}));
vi.mock("../telemetry/client.js", () => ({
shouldTrack: vi.fn(() => trackingState.shouldTrack),
}));
vi.mock("../telemetry/events.js", () => ({
trackRenderComplete: vi.fn(),
trackRenderError: vi.fn(),
trackRenderObservation: vi.fn((props: Record<string, unknown>) => {
trackingState.renderObservations.push(props);
}),
}));
vi.mock("../browser/ffmpeg.js", () => ({
detectH264EncoderMode: vi.fn(() => {
if (ffmpegEncoderState.error) throw ffmpegEncoderState.error;
return ffmpegEncoderState.mode;
}),
findFFmpeg: vi.fn(() => "/usr/bin/ffmpeg"),
getFFmpegInstallHint: vi.fn(() => "brew install ffmpeg"),
}));
vi.mock("../browser/preflight.js", () => ({
runEnvironmentChecks: vi.fn(async () => preflightState.result),
}));
vi.mock("../utils/orphanCleanup.js", () => ({
killOrphanedProcesses: vi.fn(() => {
orphanCleanupState.calls += 1;
return orphanCleanupState.killed;
}),
}));
describe("renderLocal browser GPU config", () => {
const savedEnv = new Map<string, string | undefined>();
// 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;
let renderLintContinuationHint: typeof import("./render.js").renderLintContinuationHint;
let resetTrialState: typeof import("./render.js").__resetDeParallelRouterTrialStateForTests;
beforeAll(async () => {
({
renderLocal,
resolveBrowserGpuForCli,
renderLintContinuationHint,
__resetDeParallelRouterTrialStateForTests: resetTrialState,
} = await import("./render.js"));
});
it("points strict warning-only renders to --strict-all", () => {
expect(renderLintContinuationHint(true)).toContain("--strict-all");
expect(renderLintContinuationHint(true)).not.toContain("Use --strict to block");
});
it("points non-strict renders to --strict for lint errors", () => {
expect(renderLintContinuationHint(false)).toContain("Use --strict to block errors");
});
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 = [];
producerState.executeImpl = async () => undefined;
configState.disk = { telemetryEnabled: true, deParallelRouterTrialFired: true };
configState.cache = null;
configState.failWrites = 0;
configState.writeConfigCalls = [];
trackingState.shouldTrack = true;
trackingState.renderObservations = [];
ffmpegEncoderState.mode = "software";
ffmpegEncoderState.error = null;
orphanCleanupState.calls = 0;
orphanCleanupState.killed = 0;
resetTrialState();
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);
savedEnv.set("HF_DE_PARALLEL_ROUTER", process.env.HF_DE_PARALLEL_ROUTER);
delete process.env.HYPERFRAMES_FFMPEG_PATH;
delete process.env.HYPERFRAMES_FFPROBE_PATH;
delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
delete process.env.HF_DE_PARALLEL_ROUTER;
});
it("cleans orphaned browser trees before starting a local render", async () => {
orphanCleanupState.killed = 1;
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(orphanCleanupState.calls).toBe(1);
});
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 render stage start and end lifecycle events to telemetry", async () => {
producerState.executeImpl = async (job) => {
const logger = (job.config as { logger: { info: (message: string, meta: object) => void } })
.logger;
logger.info("[Render:trace]", {
renderJobId: "render-lifecycle",
phase: "capture_streaming",
status: "start",
elapsedMs: 100,
workerCount: 1,
captureMode: "screenshot",
captureOperation: "captureScreenshot",
framesCompleted: 12,
totalFrames: 900,
});
logger.info("[Render:trace]", {
renderJobId: "render-lifecycle",
phase: "capture_streaming",
status: "end",
elapsedMs: 250,
durationMs: 150,
});
};
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
skipFeedback: true,
});
expect(trackingState.renderObservations).toEqual([
expect.objectContaining({
renderJobId: "render-lifecycle",
phase: "capture_streaming",
status: "start",
captureOperation: "captureScreenshot",
framesCompleted: 12,
totalFrames: 900,
}),
expect.objectContaining({
renderJobId: "render-lifecycle",
phase: "capture_streaming",
status: "end",
durationMs: 150,
}),
]);
});
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("falls back to hardware encoding when FFmpeg omits libx264", async () => {
ffmpegEncoderState.mode = "gpu";
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "high",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "force-sdr",
quiet: true,
});
expect(producerState.createdJobs[0]?.useGpu).toBe(true);
});
it("lets the encoder surface its own error when capability detection fails", async () => {
ffmpegEncoderState.error = new Error("encoder probe timed out");
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "high",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "force-sdr",
quiet: true,
});
expect(producerState.createdJobs[0]?.useGpu).toBe(false);
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("encoder probe timed out"));
});
it("diagnoses advisory encoder probe failures unless quiet", async () => {
ffmpegEncoderState.error = new Error("encoder probe timed out");
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "high",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "force-sdr",
quiet: false,
});
expect(producerState.createdJobs[0]?.useGpu).toBe(false);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("encoder probe timed out"));
});
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("defaults to best-effort readiness", 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]?.strictness).toBe("best-effort");
});
it("forwards an explicit strict readiness opt-in", 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,
bestEffort: false,
});
expect(producerState.createdJobs[0]?.strictness).toBe("strict");
});
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("renderLocal — DE parallel-router CLI trial", () => {
let renderLocal: typeof import("./render.js").renderLocal;
let resetTrialState: typeof import("./render.js").__resetDeParallelRouterTrialStateForTests;
const savedEnv = new Map<string, string | undefined>();
beforeAll(async () => {
({ renderLocal, __resetDeParallelRouterTrialStateForTests: resetTrialState } =
await import("./render.js"));
});
beforeEach(() => {
producerState.createdJobs = [];
producerState.executeImpl = async () => undefined;
configState.cache = null;
configState.failWrites = 0;
configState.writeConfigCalls = [];
trackingState.shouldTrack = true;
// The "managed by us" flag lives at module scope in render.ts (real CLI
// processes only ever run one --batch sequence, so it never needs
// resetting there) — reset explicitly here so tests don't leak arm/
// consume state into each other via shared module instance + test order.
resetTrialState();
savedEnv.clear();
savedEnv.set("HF_DE_PARALLEL_ROUTER", process.env.HF_DE_PARALLEL_ROUTER);
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.HF_DE_PARALLEL_ROUTER;
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();
});
const baseOptions = {
fps: { num: 30, den: 1 },
quality: "standard" as const,
format: "mp4" as const,
gpu: false,
browserGpuMode: "software" as const,
hdrMode: "auto" as const,
quiet: true,
// The trial is OPT-IN (review): only the CLI's own sequential call sites
// set this. These tests simulate those call sites.
enableDeParallelRouterTrial: true,
};
it("enables the trial (sets the env var) on a fresh install with telemetry on", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBe("true");
});
it("does not override an env var the user already set themselves", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
process.env.HF_DE_PARALLEL_ROUTER = "false";
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBe("false");
});
it("does not enable the trial once it has already fired for this install", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: true,
telemetryNoticeShown: true,
};
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBeUndefined();
});
it("does not enable the trial when shouldTrack() is false (dev mode / DO_NOT_TRACK)", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
trackingState.shouldTrack = false;
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBeUndefined();
});
it("does not enable the trial when config.telemetryEnabled is false, even if shouldTrack() is stale-true (e.g. `hyperframes telemetry off` mid-batch)", async () => {
configState.disk = {
telemetryEnabled: false,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
trackingState.shouldTrack = true;
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBeUndefined();
});
it("does not enable the trial before the first-run telemetry disclosure has been shown at least once", async () => {
// cli.ts shows this notice via a fire-and-forget, unawaited dynamic
// import — there's no guarantee it printed before renderLocal runs on a
// brand-new install's very first invocation. Requiring
// telemetryNoticeShown means the trial never races an opt-in message
// against the disclosure it depends on.
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: false,
};
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBeUndefined();
});
it("does NOT persist the trial as fired on a clean 'routed' success — keeps trying on future renders", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
producerState.executeImpl = async (job) => {
job.perfSummary = {
resolution: { width: 100, height: 100 },
drawElement: { parallelRouter: "routed" },
};
};
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
// A write DOES happen — the render-count backstop is tracked on every
// engaged render — but it must not flip deParallelRouterTrialFired.
expect(configState.writeConfigCalls).toContainEqual(
expect.objectContaining({
deParallelRouterTrialFired: false,
deParallelRouterTrialRenderCount: 1,
}),
);
});
it("persists the trial as fired when the router's own safety net actually reverted", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
producerState.executeImpl = async (job) => {
job.perfSummary = {
resolution: { width: 100, height: 100 },
drawElement: { parallelRouter: "reverted" },
};
};
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(configState.writeConfigCalls).toContainEqual(
expect.objectContaining({ deParallelRouterTrialFired: true }),
);
});
it("does not persist the trial as fired or increment the render count when the router never became eligible for this render", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
producerState.executeImpl = async (job) => {
// aggregateDrawElement (perfSummary.ts) ALWAYS defaults parallelRouter
// to the string "none" for every render, whether or not drawElement
// ever ran — never undefined. This fixture must match that shape, not
// an unrealistic empty object, or the test doesn't actually exercise
// the "none"-vs-undefined distinction (review finding).
job.perfSummary = {
resolution: { width: 100, height: 100 },
drawElement: { parallelRouter: "none" },
};
};
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(configState.writeConfigCalls).toHaveLength(0);
});
it("does NOT persist the trial as fired when a render merely 'routed' crashes for an unrelated reason (e.g. cancellation) — not a router failure", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
producerState.executeImpl = async (job) => {
job.errorDetails = { observability: { capture: { deParallelRouter: "routed" } } };
throw new Error("render cancelled");
};
await renderLocal("/tmp/project", "/tmp/out.mp4", { ...baseOptions, throwOnError: true }).catch(
() => {},
);
// Still counts toward the render-count backstop (the router DID engage),
// but must not flip deParallelRouterTrialFired — the crash wasn't the
// router's own safety net firing.
expect(configState.writeConfigCalls).toContainEqual(
expect.objectContaining({
deParallelRouterTrialFired: false,
deParallelRouterTrialRenderCount: 1,
}),
);
});
it("persists the trial as fired from the failure path when the router's safety net reverted but the retry still failed", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
producerState.executeImpl = async (job) => {
job.errorDetails = { observability: { capture: { deParallelRouter: "reverted" } } };
throw new Error("worker crashed even after fallback");
};
await renderLocal("/tmp/project", "/tmp/out.mp4", { ...baseOptions, throwOnError: true }).catch(
() => {},
);
expect(configState.writeConfigCalls).toContainEqual(
expect.objectContaining({ deParallelRouterTrialFired: true }),
);
});
it("persists a later --batch row's revert even though this process already armed the trial on an earlier row", async () => {
// Regression test for the exact scenario a --batch run hits: multiple
// renderLocal calls in one process. Before the fix, row 2's
// maybeEnableDeParallelRouterTrial saw process.env.HF_DE_PARALLEL_ROUTER
// already "true" (set by row 1) and mistook that for "the user set it",
// returning trialArmed=false — silently dropping row 2's revert.
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
producerState.executeImpl = async (job) => {
job.perfSummary = {
resolution: { width: 100, height: 100 },
drawElement: { parallelRouter: "routed" },
};
};
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBe("true");
expect(configState.disk.deParallelRouterTrialFired).toBe(false);
producerState.executeImpl = async (job) => {
job.perfSummary = {
resolution: { width: 100, height: 100 },
drawElement: { parallelRouter: "reverted" },
};
};
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(configState.writeConfigCalls).toContainEqual(
expect.objectContaining({ deParallelRouterTrialFired: true }),
);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBeUndefined();
});
it("does not arm the trial for programmatic callers that never opted in (opt-in polarity — also covers --batch-concurrency N>=2, which leaves it unset)", async () => {
// The trial's process-wide env var and module-level flags are only safe
// under sequential invocation, so enableDeParallelRouterTrial is OPT-IN
// (review): a programmatic renderLocal consumer that doesn't know about
// the trial must get no trial. The CLI's concurrent-batch path relies on
// the same default by leaving the option unset.
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
const { enableDeParallelRouterTrial: _omitted, ...programmaticOptions } = baseOptions;
await renderLocal("/tmp/project", "/tmp/out.mp4", programmaticOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBeUndefined();
expect(configState.writeConfigCalls).toHaveLength(0);
});
it("does not override an env var the user set between two renders in the same process", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
producerState.executeImpl = async (job) => {
job.perfSummary = {
resolution: { width: 100, height: 100 },
drawElement: { parallelRouter: "routed" },
};
};
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBe("true");
// A real interactive user can't do this mid-batch, but a wrapper script
// invoking the CLI programmatically in the same process could — the
// explicit override must still win on the next call.
process.env.HF_DE_PARALLEL_ROUTER = "false";
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBe("false");
});
it("caps exposure at DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS even when the router never reverts", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
producerState.executeImpl = async (job) => {
job.perfSummary = {
resolution: { width: 100, height: 100 },
drawElement: { parallelRouter: "routed" },
};
};
for (let i = 0; i < 25; i++) {
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
}
expect(configState.writeConfigCalls).toContainEqual(
expect.objectContaining({
deParallelRouterTrialFired: true,
deParallelRouterTrialRenderCount: 25,
}),
);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBeUndefined();
// The 26th eligible render must not re-arm it.
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBeUndefined();
});
it("observes a telemetry opt-out written by another process mid-batch (arm site reads fresh, not cached)", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
// Row 1 arms and primes the config cache.
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBe("true");
// Another process runs `hyperframes telemetry off`, writing straight to
// "disk" — this process's cache still says telemetryEnabled: true, so a
// cached read at the arm site would keep arming (review finding).
configState.disk = { ...configState.disk, telemetryEnabled: false };
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBeUndefined();
});
it("re-asserts the fired flag when the write is lost (concurrent clobber / transient failure), without re-counting the render", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
configState.failWrites = 1; // the consume's main write is silently dropped
producerState.executeImpl = async (job) => {
job.perfSummary = {
resolution: { width: 100, height: 100 },
drawElement: { parallelRouter: "reverted" },
};
};
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
// The fired flag was verified and re-asserted (idempotent)...
expect(configState.disk.deParallelRouterTrialFired).toBe(true);
// ...but the render counter is deliberately NOT re-applied — a lost
// increment under a race is benign, a re-applied one double-counts the
// render and trips the exposure cap early (review finding).
expect(configState.disk.deParallelRouterTrialRenderCount).toBeUndefined();
});
it("blocks re-arming for the rest of the process when the fired flag can never persist (unwritable config)", async () => {
configState.disk = {
telemetryEnabled: true,
deParallelRouterTrialFired: false,
telemetryNoticeShown: true,
};
configState.failWrites = Number.MAX_SAFE_INTEGER; // ~/.hyperframes is unwritable
producerState.executeImpl = async (job) => {
job.perfSummary = {
resolution: { width: 100, height: 100 },
drawElement: { parallelRouter: "reverted" },
};
};
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
// Nothing could persist...
expect(configState.disk.deParallelRouterTrialFired).toBe(false);
// ...but the in-process latch still blocks the next render from
// re-running the experiment that just failed (review finding).
producerState.executeImpl = async () => undefined;
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
expect(process.env.HF_DE_PARALLEL_ROUTER).toBeUndefined();
});
});
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) =>
`<html><body><div data-composition-id="root" data-width="${w}" data-height="${h}"></div></body></html>`;
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("<html><body></body></html>", "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<string, { default?: unknown } | undefined>;
const fpsArg = args.fps;
expect(fpsArg).toBeDefined();
expect(fpsArg?.default).toBeUndefined();
});
});
describe("render command explicit composition", () => {
it("renders an explicit composition from a project with no index.html", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-explicit-"));
const outputPath = join(projectDir, "out.mp4");
writeFileSync(
join(projectDir, "standalone.html"),
`<html><body>
<div data-composition-id="standalone" data-width="1920" data-height="1080" data-duration="1"></div>
<script>window.__timelines = { standalone: gsap.timeline({ paused: true }) };</script>
</body></html>`,
);
vi.useFakeTimers();
try {
const command = (await import("./render.js")).default;
await command.run?.({
args: {
dir: projectDir,
composition: "standalone.html",
output: outputPath,
quiet: true,
quality: "standard",
format: "mp4",
},
} as never);
expect(producerState.createdJobs.at(-1)).toMatchObject({
entryFile: "standalone.html",
});
} finally {
vi.clearAllTimers();
rmSync(projectDir, { recursive: true, force: true });
}
}, 60_000);
});
// Variables-helper tests live in `../utils/variables.test.ts`.