// 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 `