// fallow-ignore-file code-duplication
/**
* Unit tests for `services/distributed/plan.ts`.
*
* Covers:
* - Golden planDir layout produced from a tiny fixture (no browser probe
* required — the fixture declares `data-duration` so the probe stage
* short-circuits).
* - planHash determinism across two `plan()` calls on the same inputs.
* - Chunking math (`resolveChunkPlan`, `buildChunkSlices`).
*
* The "no browser probe" path is deliberate: spinning Chrome inside `bun test`
* is expensive and flaky. The chunking helpers + planDir layout are tested
* with synchronous compile-only fixtures; the BeginFrame / probe path lives
* inside the regression harness (`bun run --cwd packages/producer docker:test`).
*/
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { applyConcreteGpuScreenshotClamp, buildChromeArgs } from "@hyperframes/engine";
import { recomputePlanHashFromPlanDir } from "../render/stages/freezePlan.js";
import { RenderQualityError } from "../renderOrchestrator.js";
import { CURRENT_PLAN_PROTOCOL } from "./planProtocol.js";
import {
applyDistributedAudioWarningPolicy,
buildChunkSlices,
DEFAULT_CHUNK_SIZE,
DEFAULT_MAX_PARALLEL_CHUNKS,
MIN_CHUNK_SIZE,
plan,
resolveDistributedEngineConfig,
resolveChunkPlan,
} from "./plan.js";
import { buildSyntheticRenderJob } from "./shared.js";
// Composition the tests render. `data-duration="1"` keeps the probe stage's
// `needsBrowser` gate `false` so plan() completes without launching Chrome.
const FIXTURE_HTML = `
plan-test fixture
plan-test fixture
`;
let projectDir: string;
let runRoot: string;
beforeAll(() => {
runRoot = mkdtempSync(join(tmpdir(), "hf-plan-test-"));
projectDir = join(runRoot, "project");
mkdirSync(projectDir, { recursive: true });
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
});
afterAll(() => {
rmSync(runRoot, { recursive: true, force: true });
});
describe("distributed warning policy", () => {
const createJob = (strictness: "strict" | "best-effort") =>
buildSyntheticRenderJob({
fps: { num: 30, den: 1 },
format: "mp4",
quality: "high",
hdrMode: "force-sdr",
strictness,
entryFile: "index.html",
});
it("rejects distributed audio degradation in strict mode", () => {
const job = createJob("strict");
expect(() => applyDistributedAudioWarningPolicy(job, "mix failed")).toThrow(RenderQualityError);
expect(job.warnings.map((warning) => warning.code)).toEqual(["audio_processing_failed"]);
});
it("rejects distributed audio degradation in best-effort mode", () => {
const job = createJob("best-effort");
expect(() =>
applyDistributedAudioWarningPolicy(job, "mix failed", [
{
stage: "mix",
reason: "ffmpeg_unsupported",
owner: "system",
retryable: false,
detail: "Option not found",
},
]),
).toThrow(RenderQualityError);
expect(job.warnings.map((warning) => warning.code)).toEqual(["audio_processing_failed"]);
expect(job.warnings[0]?.details).toEqual(
expect.objectContaining({
failureReasons: ["ffmpeg_unsupported"],
failureStages: ["mix"],
failureOwner: "system",
retryable: false,
}),
);
});
it("only marks a multi-cause audio failure retryable when every cause is retryable", () => {
const job = createJob("best-effort");
expect(() =>
applyDistributedAudioWarningPolicy(job, "mixed failure", [
{
stage: "download",
reason: "download_failed",
owner: "system",
retryable: true,
detail: "temporary download failure",
},
{
stage: "prepare",
reason: "invalid_media",
owner: "user",
retryable: false,
detail: "invalid media",
},
]),
).toThrow(RenderQualityError);
expect(job.warnings[0]?.details).toEqual(
expect.objectContaining({
failureOwner: "system",
retryable: false,
}),
);
});
it("does not invent ownership or retryability for legacy untyped failures", () => {
const job = createJob("best-effort");
expect(() => applyDistributedAudioWarningPolicy(job, "legacy failure")).toThrow(
RenderQualityError,
);
expect(job.warnings[0]?.details?.failureOwner).toBeUndefined();
expect(job.warnings[0]?.details?.retryable).toBeUndefined();
});
});
describe("distributed synthetic render job", () => {
it("threads render variables into the plan browser probe job", () => {
const variables = {
voiceoverSrc: "assets/voiceover.wav",
narrationDurationSeconds: 56.738,
};
const job = buildSyntheticRenderJob({
fps: { num: 30, den: 1 },
format: "mp4",
quality: "high",
hdrMode: "force-sdr",
entryFile: "index.html",
variables,
});
expect(job.config.variables).toEqual(variables);
});
it("keeps the production software-GPU launch on BeginFrame control", () => {
const cfg = resolveDistributedEngineConfig({
fps: 30,
width: 320,
height: 240,
format: "mp4",
});
const forceScreenshot = applyConcreteGpuScreenshotClamp(
cfg.forceScreenshot,
"software",
cfg,
{},
);
const captureMode = forceScreenshot ? "screenshot" : "beginframe";
const args = buildChromeArgs({ width: 320, height: 240, captureMode, platform: "linux" }, cfg);
expect(forceScreenshot).toBe(false);
expect(args).toContain("--enable-begin-frame-control");
});
});
describe("resolveChunkPlan", () => {
it("returns 1 chunk when totalFrames fits in configChunkSize", () => {
const result = resolveChunkPlan(60, 240, 16);
expect(result.chunkCount).toBe(1);
expect(result.effectiveChunkSize).toBeGreaterThanOrEqual(60);
});
it("caps chunkCount at maxParallelChunks for very long renders", () => {
// 54000 frames / 240 = 225 naive chunks → must cap at 16.
const result = resolveChunkPlan(54000, 240, 16);
expect(result.chunkCount).toBe(16);
// 54000 / 16 = 3375 → chunkSize at least that big so the union covers
// all frames in 16 slices.
expect(result.effectiveChunkSize).toBeGreaterThanOrEqual(Math.ceil(54000 / 16));
});
it("naive count drives chunkCount when below cap", () => {
// 600 frames / 240 = 3 naive chunks; well below the 16 cap.
const result = resolveChunkPlan(600, 240, 16);
expect(result.chunkCount).toBe(3);
expect(result.effectiveChunkSize).toBe(240);
});
it("rejects non-positive totalFrames", () => {
expect(() => resolveChunkPlan(0, 240, 16)).toThrow();
expect(() => resolveChunkPlan(-1, 240, 16)).toThrow();
expect(() => resolveChunkPlan(Number.NaN, 240, 16)).toThrow();
});
it("rejects non-positive configChunkSize / maxParallelChunks", () => {
expect(() => resolveChunkPlan(60, 0, 16)).toThrow();
expect(() => resolveChunkPlan(60, 240, 0)).toThrow();
});
it("rejects non-integer inputs (would produce fractional endFrames)", () => {
expect(() => resolveChunkPlan(10.5, 240, 16)).toThrow(/positive integer/);
expect(() => resolveChunkPlan(60, 240.5, 16)).toThrow(/positive integer/);
expect(() => resolveChunkPlan(60, 240, 16.5)).toThrow(/positive integer/);
expect(() => resolveChunkPlan(60, 240, Number.POSITIVE_INFINITY)).toThrow(/positive integer/);
});
// ── Auto-size when configChunkSize is undefined ───────────────────────
// The auto-sizer picks `max(MIN_CHUNK_SIZE, ceil(totalFrames /
// maxParallelChunks))` whenever the caller leaves `chunkSize` undefined,
// honoring `maxParallelChunks` instead of clamping at a 240-frame default.
it("explicit chunkSize wins: 660 frames + chunkSize=240 + maxParallelChunks=16 → 3 chunks", () => {
// Regression guard for the "explicit number still works" half of the
// contract — passing 240 explicitly must not get auto-sized.
const result = resolveChunkPlan(660, 240, 16);
expect(result.chunkCount).toBe(3);
expect(result.effectiveChunkSize).toBe(240);
});
it("auto-sizes when chunkSize=undefined: 660 frames + maxParallelChunks=16 → 16 chunks", () => {
// ceil(660 / 16) = 42; max(MIN_CHUNK_SIZE=10, 42) = 42. naiveCount =
// ceil(660 / 42) = 16, which lands exactly at the cap.
const result = resolveChunkPlan(660, undefined, 16);
expect(result.chunkCount).toBe(16);
expect(result.effectiveChunkSize).toBe(42);
});
it("auto-size floor: tiny renders cap at MIN_CHUNK_SIZE rather than fragmenting infinitely", () => {
// 50 frames / 16 workers naively gives a 4-frame chunk size, which
// would produce 13 chunks of 4 frames each — per-chunk fixed overhead
// dwarfs the parallelism gain. The MIN_CHUNK_SIZE=10 floor pins
// chunkSize at 10, producing ceil(50/10) = 5 chunks instead.
const result = resolveChunkPlan(50, undefined, 16);
expect(result.chunkCount).toBe(5);
expect(result.effectiveChunkSize).toBe(MIN_CHUNK_SIZE);
});
it("tightens chunkCount so an explicit small chunkSize leaves no empty trailing slice", () => {
// 121 frames, chunkSize=10, maxParallelChunks=12: ceil(121/10)=13 naive →
// capped at 12, but effectiveChunkSize rounds up to ceil(121/12)=11. Eleven
// 11-frame chunks already cover [0,121), so a 12th would be the empty slice
// [121,121) that renderChunk rejects (framesInChunk <= 0). chunkCount must
// tighten to 11.
const result = resolveChunkPlan(121, 10, 12);
expect(result.effectiveChunkSize).toBe(11);
expect(result.chunkCount).toBe(11);
const slices = buildChunkSlices(121, result.chunkCount, result.effectiveChunkSize);
expect(slices).toHaveLength(11);
expect(slices[slices.length - 1]).toEqual({ index: 10, startFrame: 110, endFrame: 121 });
});
it("never emits an empty or inverted slice across a grid of explicit chunk sizes", () => {
for (const totalFrames of [37, 50, 121, 333, 1000, 4001]) {
for (const maxParallel of [2, 4, 12, 16, 40]) {
for (const chunkSize of [10, 11, 15, 24, 100]) {
const { chunkCount, effectiveChunkSize } = resolveChunkPlan(
totalFrames,
chunkSize,
maxParallel,
);
expect(chunkCount).toBeLessThanOrEqual(maxParallel);
const slices = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize);
let cursor = 0;
for (const s of slices) {
expect(s.startFrame).toBe(cursor); // contiguous from 0
expect(s.endFrame).toBeGreaterThan(s.startFrame); // non-empty
cursor = s.endFrame;
}
expect(cursor).toBe(totalFrames); // union is exactly [0, totalFrames)
}
}
}
});
// ── targetChunkFrames (optional per-chunk frame ceiling) ──
it("targetChunkFrames omitted is a no-op: identical to the 3-arg auto-sized result", () => {
// The default path must be byte-identical whether the 4th arg is absent or
// explicitly undefined.
for (const totalFrames of [50, 660, 1466, 54000]) {
for (const maxParallel of [8, 16, 64]) {
const base = resolveChunkPlan(totalFrames, undefined, maxParallel);
const withUndef = resolveChunkPlan(totalFrames, undefined, maxParallel, undefined);
expect(withUndef).toEqual(base);
}
}
});
it("targetChunkFrames collapses a short video to fewer chunks than the parallelism cap", () => {
// 1466 frames, target 300, cap 16: ceil(1466/300)=5 chunks (not 16), each
// ~293 frames. Fewer chunks → less per-chunk fixed overhead.
const result = resolveChunkPlan(1466, undefined, 16, 300);
expect(result.chunkCount).toBe(5);
expect(result.effectiveChunkSize).toBeLessThanOrEqual(300);
});
it("targetChunkFrames bounds a long video's per-chunk frames, adding chunks up to the cap", () => {
// 54000 frames (30 min @30fps), target 1600, cap 64: ceil(54000/1600)=34
// chunks, each <= 1600 frames so per-chunk render time stays under budget.
const result = resolveChunkPlan(54000, undefined, 64, 1600);
expect(result.chunkCount).toBe(34);
expect(result.effectiveChunkSize).toBeLessThanOrEqual(1600);
});
it("targetChunkFrames is clamped by maxParallelChunks: an extreme length stays at the cap (still over budget)", () => {
// 216000 frames (2 h), target 1600, cap 64: needs 135 chunks but clamps to
// 64; per-chunk frames then exceed the target — the genuine tier ceiling.
const result = resolveChunkPlan(216000, undefined, 64, 1600);
expect(result.chunkCount).toBe(64);
expect(result.effectiveChunkSize).toBeGreaterThan(1600);
});
it("explicit chunkSize wins over targetChunkFrames (targetChunkFrames is a no-op)", () => {
const withTarget = resolveChunkPlan(54000, 240, 64, 1600);
const chunkSizeOnly = resolveChunkPlan(54000, 240, 64);
expect(withTarget).toEqual(chunkSizeOnly);
});
it("rejects a non-positive or non-integer targetChunkFrames", () => {
expect(() => resolveChunkPlan(1466, undefined, 16, 0)).toThrow(/positive integer/);
expect(() => resolveChunkPlan(1466, undefined, 16, -100)).toThrow(/positive integer/);
expect(() => resolveChunkPlan(1466, undefined, 16, 300.5)).toThrow(/positive integer/);
});
it("never emits an empty or inverted slice across a grid of targetChunkFrames", () => {
for (const totalFrames of [37, 660, 1466, 12793, 54000]) {
for (const maxParallel of [8, 16, 64]) {
for (const target of [100, 300, 1600]) {
const { chunkCount, effectiveChunkSize } = resolveChunkPlan(
totalFrames,
undefined,
maxParallel,
target,
);
expect(chunkCount).toBeGreaterThanOrEqual(1);
expect(chunkCount).toBeLessThanOrEqual(maxParallel);
const slices = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize);
let cursor = 0;
for (const s of slices) {
expect(s.startFrame).toBe(cursor);
expect(s.endFrame).toBeGreaterThan(s.startFrame);
cursor = s.endFrame;
}
expect(cursor).toBe(totalFrames);
}
}
}
});
});
describe("buildChunkSlices", () => {
it("produces consecutive non-overlapping ranges covering all frames", () => {
const slices = buildChunkSlices(700, 3, 240);
expect(slices).toHaveLength(3);
expect(slices[0]).toEqual({ index: 0, startFrame: 0, endFrame: 240 });
expect(slices[1]).toEqual({ index: 1, startFrame: 240, endFrame: 480 });
// Last chunk absorbs the remainder so endFrame === totalFrames exactly.
expect(slices[2]).toEqual({ index: 2, startFrame: 480, endFrame: 700 });
});
it("handles a single-chunk render", () => {
const slices = buildChunkSlices(50, 1, 240);
expect(slices).toHaveLength(1);
expect(slices[0]).toEqual({ index: 0, startFrame: 0, endFrame: 50 });
});
});
describe("plan() defaults", () => {
it("exports the documented chunking defaults", () => {
expect(DEFAULT_CHUNK_SIZE).toBe(240);
expect(DEFAULT_MAX_PARALLEL_CHUNKS).toBe(16);
expect(MIN_CHUNK_SIZE).toBe(10);
});
});
describe("plan() — golden planDir + planHash determinism", () => {
// Each `plan()` call is reasonably expensive (compile pass parses + inlines
// the HTML), so we run it once for the layout assertions and once more for
// the determinism assertion. The 30s timeout absorbs cold-start font /
// runtime resolution variance on the CI host.
const TIMEOUT_MS = 30_000;
it(
"produces the documented planDir layout",
async () => {
const planDir = join(runRoot, "plan-layout");
mkdirSync(planDir, { recursive: true });
// Pin chunkSize=240 so this fixture exercises the single-chunk path
// (totalFrames=30 → ceil(30/240)=1 chunk). The auto-sized variant
// (chunkSize=undefined) is exercised by the dedicated test below.
const result = await plan(
projectDir,
{ fps: 30, width: 320, height: 240, format: "mp4", chunkSize: 240 },
planDir,
);
// planDir directory layout
expect(existsSync(join(planDir, "plan.json"))).toBe(true);
expect(existsSync(join(planDir, "compiled", "index.html"))).toBe(true);
expect(existsSync(join(planDir, "video-frames"))).toBe(true);
// No audio in the fixture — audio.aac must NOT exist.
expect(existsSync(join(planDir, "audio.aac"))).toBe(false);
expect(existsSync(join(planDir, "meta", "composition.json"))).toBe(true);
expect(existsSync(join(planDir, "meta", "encoder.json"))).toBe(true);
expect(existsSync(join(planDir, "meta", "chunks.json"))).toBe(true);
// The temporary work tree must be cleaned up.
expect(existsSync(join(planDir, ".plan-work"))).toBe(false);
// ── PlanResult contract ─────────────────────────────────────────────
expect(result.planDir).toBe(planDir);
expect(result.planProtocol).toEqual(CURRENT_PLAN_PROTOCOL);
expect(result.planHash).toMatch(/^[0-9a-f]{64}$/);
expect(result.chunkCount).toBe(1);
expect(result.totalFrames).toBe(30); // 1s @ 30fps
expect(result.width).toBe(320);
expect(result.height).toBe(240);
expect(result.format).toBe("mp4");
expect(result.ffmpegVersion).toMatch(/ffmpeg/i);
expect(result.producerVersion).toMatch(/^\d+\.\d+\.\d+/);
// ── chunks.json shape ───────────────────────────────────────────────
const chunks = JSON.parse(
readFileSync(join(planDir, "meta", "chunks.json"), "utf-8"),
) as Array<{ index: number; startFrame: number; endFrame: number }>;
expect(chunks).toHaveLength(result.chunkCount);
// Slices must cover [0, totalFrames) with no gaps.
let cursor = 0;
for (const chunk of chunks) {
expect(chunk.startFrame).toBe(cursor);
cursor = chunk.endFrame;
}
expect(cursor).toBe(result.totalFrames);
// ── plan.json shape ─────────────────────────────────────────────────
const planJson = JSON.parse(readFileSync(join(planDir, "plan.json"), "utf-8")) as Record<
string,
unknown
>;
expect(planJson.planHash).toBe(result.planHash);
expect(planJson.protocol).toEqual(CURRENT_PLAN_PROTOCOL);
expect(planJson.hasAudio).toBe(false);
expect(planJson.totalFrames).toBe(result.totalFrames);
},
TIMEOUT_MS,
);
it(
"auto-sizes chunkSize end-to-end when caller omits it",
async () => {
// Integration check that the auto-sizer wired through plan() actually
// produces multi-chunk output for the same fixture that single-chunks
// when chunkSize is pinned. With totalFrames=30 and the default
// maxParallelChunks=16, the auto-sizer picks
// max(MIN_CHUNK_SIZE=10, ceil(30/16)=2) = 10 → ceil(30/10) = 3 chunks.
const planDir = join(runRoot, "plan-autosized");
mkdirSync(planDir, { recursive: true });
const result = await plan(
projectDir,
{ fps: 30, width: 320, height: 240, format: "mp4" },
planDir,
);
expect(result.chunkCount).toBe(3);
const chunks = JSON.parse(
readFileSync(join(planDir, "meta", "chunks.json"), "utf-8"),
) as Array<{ index: number; startFrame: number; endFrame: number }>;
expect(chunks).toHaveLength(3);
// Encoder gopSize must follow the auto-sized chunk so chunk-boundary
// IDR keyframes still land at frame 0 of each chunk.
const encoder = JSON.parse(
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
) as Record;
expect(encoder.gopSize).toBe(10);
expect(encoder.chunkSize).toBe(10);
},
TIMEOUT_MS,
);
it(
"produces a byte-identical planHash on a second invocation",
async () => {
const planDirA = join(runRoot, "plan-determinism-a");
const planDirB = join(runRoot, "plan-determinism-b");
mkdirSync(planDirA, { recursive: true });
mkdirSync(planDirB, { recursive: true });
const config = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
const a = await plan(projectDir, config, planDirA);
const b = await plan(projectDir, config, planDirB);
expect(a.planHash).toBe(b.planHash);
expect(a.chunkCount).toBe(b.chunkCount);
expect(a.totalFrames).toBe(b.totalFrames);
// Encoder JSON must be byte-identical — its bytes feed planHash, so any
// drift here would silently change the hash framing.
const encoderA = readFileSync(join(planDirA, "meta", "encoder.json"));
const encoderB = readFileSync(join(planDirB, "meta", "encoder.json"));
expect(encoderA.equals(encoderB)).toBe(true);
},
TIMEOUT_MS,
);
it(
"plan.json.planHash matches recomputePlanHashFromPlanDir(planDir) on the same disk",
async () => {
// Regression guard for a real-world bug observed on audio-bearing
// fixtures: plan() left a temporary `.plan-work/` subtree inside
// planDir while freezePlan walked it, so the hash baked into
// plan.json included artifacts the chunk worker would never see.
// The chunk worker's `recomputePlanHashFromPlanDir` walk then
// returned a different hash, tripping PLAN_HASH_MISMATCH at the
// first chunk invocation.
//
// This test verifies that the hash plan() writes matches the hash
// recomputed from the on-disk planDir contents — i.e. the chunk
// worker's view. Holds for any plan, audio or not.
const planDir = join(runRoot, "plan-hash-recompute");
mkdirSync(planDir, { recursive: true });
const result = await plan(
projectDir,
{ fps: 30, width: 320, height: 240, format: "mp4" },
planDir,
);
const recomputed = recomputePlanHashFromPlanDir(planDir);
expect(recomputed).toBe(result.planHash);
const planJson = JSON.parse(readFileSync(join(planDir, "plan.json"), "utf-8")) as {
planHash: string;
protocol?: unknown;
};
expect(planJson.planHash).toBe(result.planHash);
expect(planJson.protocol).toEqual(CURRENT_PLAN_PROTOCOL);
delete planJson.protocol;
writeFileSync(join(planDir, "plan.json"), `${JSON.stringify(planJson, null, 2)}\n`, "utf-8");
expect(recomputePlanHashFromPlanDir(planDir)).toBe(result.planHash);
planJson.protocol = CURRENT_PLAN_PROTOCOL;
writeFileSync(join(planDir, "plan.json"), `${JSON.stringify(planJson, null, 2)}\n`, "utf-8");
expect(recomputePlanHashFromPlanDir(planDir)).toBe(result.planHash);
},
TIMEOUT_MS,
);
// Audio-bearing variant of the planHash recompute test. The pre-fix bug
// surfaced because `runAudioStage` downloads/mixes source audio into
// `/.plan-work/`, and `freezePlan` walked that subtree before
// plan.ts cleaned it up. A composition without `