Skip to content

Commit 808b10c

Browse files
authored
Merge pull request #844 from heygen-com/05-14-test_producer_add_cross-worker_idempotency_unit_test
test(producer): add cross-worker idempotency unit test
2 parents cc6bc2e + 4b9a17d commit 808b10c

1 file changed

Lines changed: 227 additions & 0 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/**
2+
* Cross-worker idempotency: rendering the same `(planDir, chunkIndex)` on two
3+
* different workers MUST produce byte-identical output. This is what makes a
4+
* fan-out architecture safe — Worker A can crash mid-chunk and Worker B can
5+
* pick up the same slice without producing a frame that disagrees with what
6+
* Worker A would have written.
7+
*
8+
* `renderChunk.test.ts` already pins the byte-identical-retry contract for
9+
* png-sequence by comparing the engineered `ChunkResult.sha256` fingerprint.
10+
* This file complements that with:
11+
*
12+
* 1. Explicit `Buffer.equals` comparison of the raw bytes of every output
13+
* file, not just a derived fingerprint. This independently verifies the
14+
* property `renderChunk`'s sha256 helper is supposed to imply.
15+
* 2. The mp4 path. mp4 chunks go through the BeginFrame capture path +
16+
* libx264 encode; png-sequence chunks go through the screenshot capture
17+
* path with no encoder. Both must be byte-identical across temp dirs;
18+
* pinning only one would let an mp4-specific regression slip past.
19+
*
20+
* Both subtests soft-skip when `chrome-headless-shell` on the host can't
21+
* render — the Docker harness exercises the same code paths against a
22+
* matched chrome + ffmpeg build.
23+
*/
24+
25+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
26+
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
27+
import { tmpdir } from "node:os";
28+
import { join } from "node:path";
29+
import { plan } from "./plan.js";
30+
import { renderChunk } from "./renderChunk.js";
31+
32+
// Tiny composition shared by both subtests. 5 frames at 30fps lands a chunk
33+
// within a few seconds inside Docker and keeps host runs (where this test
34+
// soft-skips most of the time) cheap when they do exercise the full path.
35+
const FIXTURE_HTML = `<!doctype html>
36+
<html>
37+
<head><meta charset="utf-8"><title>cross-worker idempotency fixture</title></head>
38+
<body style="margin:0;background:#000;color:#fff;font:32px sans-serif">
39+
<div data-composition-id="root" data-width="160" data-height="120" data-duration="0.16667">
40+
<p style="padding:1rem">chunk fixture</p>
41+
</div>
42+
</body>
43+
</html>`;
44+
45+
// Patterns that indicate a host's chrome-headless-shell can't render — same
46+
// set `renderChunk.test.ts` uses. We soft-skip rather than fail; the docker
47+
// harness covers the determinism contract against a known-good image.
48+
const HOST_CHROME_FAILURE_PATTERNS =
49+
/chrome:\/\/gpu|BROWSER_GPU_NOT_SOFTWARE|SwiftShader|HeadlessExperimental\.beginFrame|Target closed/i;
50+
51+
let runRoot: string;
52+
let projectDir: string;
53+
let pngPlanDir: string;
54+
let mp4PlanDir: string;
55+
let pngPlanReady = false;
56+
let mp4PlanReady = false;
57+
58+
beforeAll(async () => {
59+
runRoot = mkdtempSync(join(tmpdir(), "hf-cross-worker-test-"));
60+
projectDir = join(runRoot, "project");
61+
mkdirSync(projectDir, { recursive: true });
62+
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
63+
64+
// Plan once per format. plan() is cheap for this fixture (statically-resolvable
65+
// duration means the probe stage never launches a browser), and re-planning
66+
// per `it()` would dominate the test wall time.
67+
//
68+
// A plan failure is treated as a soft skip — most commonly it's the
69+
// ffmpeg-version readout fighting a host with no ffmpeg on PATH, or the
70+
// compile stage hitting a missing font binary. Either way, the Docker
71+
// harness exercises the same code path against a working image, so the
72+
// host failure is informational rather than load-bearing.
73+
pngPlanDir = join(runRoot, "plan-pngseq");
74+
mkdirSync(pngPlanDir, { recursive: true });
75+
try {
76+
await plan(
77+
projectDir,
78+
{ fps: 30, width: 160, height: 120, format: "png-sequence" },
79+
pngPlanDir,
80+
);
81+
pngPlanReady = true;
82+
} catch (err) {
83+
console.warn(
84+
"[crossWorkerIdempotency.test] png-sequence plan() failed on host — subtest will soft-skip.",
85+
"Diagnostic:",
86+
(err instanceof Error ? err.message : String(err)).slice(0, 240),
87+
);
88+
}
89+
90+
mp4PlanDir = join(runRoot, "plan-mp4");
91+
mkdirSync(mp4PlanDir, { recursive: true });
92+
try {
93+
await plan(projectDir, { fps: 30, width: 160, height: 120, format: "mp4" }, mp4PlanDir);
94+
mp4PlanReady = true;
95+
} catch (err) {
96+
console.warn(
97+
"[crossWorkerIdempotency.test] mp4 plan() failed on host — subtest will soft-skip.",
98+
"Diagnostic:",
99+
(err instanceof Error ? err.message : String(err)).slice(0, 240),
100+
);
101+
}
102+
});
103+
104+
afterAll(() => {
105+
rmSync(runRoot, { recursive: true, force: true });
106+
});
107+
108+
/**
109+
* Compare two chunk outputs byte-by-byte. For `file` outputs (mp4/mov) the
110+
* whole file is compared; for `frame-dir` outputs (png-sequence) every PNG is
111+
* compared (including the directory listing, so a missing or extra frame
112+
* trips the assertion).
113+
*/
114+
function assertBytesEqual(
115+
outA: string,
116+
outB: string,
117+
kind: "file" | "frame-dir",
118+
label: string,
119+
): void {
120+
if (kind === "file") {
121+
const bytesA = readFileSync(outA);
122+
const bytesB = readFileSync(outB);
123+
expect(bytesA.byteLength).toBe(bytesB.byteLength);
124+
// Buffer.equals returns boolean — `toBe(true)` gives a clearer message on
125+
// failure than `toEqual` on two large Buffers.
126+
expect(bytesA.equals(bytesB)).toBe(true);
127+
return;
128+
}
129+
const framesA = readdirSync(outA).sort();
130+
const framesB = readdirSync(outB).sort();
131+
expect(framesA).toEqual(framesB);
132+
for (const name of framesA) {
133+
const a = readFileSync(join(outA, name));
134+
const b = readFileSync(join(outB, name));
135+
if (a.byteLength !== b.byteLength || !a.equals(b)) {
136+
throw new Error(`${label}: frame ${name} differs (a=${a.byteLength}B, b=${b.byteLength}B)`);
137+
}
138+
}
139+
}
140+
141+
describe("cross-worker idempotency", () => {
142+
// Generous timeout for slower CI: cold Chrome start + 5-frame capture +
143+
// ffmpeg encode is the dominant cost, repeated twice.
144+
const TIMEOUT_MS = 120_000;
145+
146+
it(
147+
"png-sequence: chunk 0 is byte-identical across two distinct output dirs",
148+
async () => {
149+
if (!pngPlanReady) {
150+
console.warn(
151+
"[crossWorkerIdempotency.test] skipping png-sequence — plan() didn't complete on host",
152+
);
153+
return;
154+
}
155+
const outA = join(runRoot, "pngseq-chunk-a");
156+
const outB = join(runRoot, "pngseq-chunk-b");
157+
let a, b;
158+
try {
159+
a = await renderChunk(pngPlanDir, 0, outA);
160+
} catch (err) {
161+
const message = err instanceof Error ? err.message : String(err);
162+
if (HOST_CHROME_FAILURE_PATTERNS.test(message)) {
163+
console.warn(
164+
"[crossWorkerIdempotency.test] skipping png-sequence — host Chrome can't render. ",
165+
"Diagnostic:",
166+
message.slice(0, 240),
167+
);
168+
return;
169+
}
170+
throw err;
171+
}
172+
b = await renderChunk(pngPlanDir, 0, outB);
173+
174+
expect(a.outputKind).toBe("frame-dir");
175+
expect(b.outputKind).toBe("frame-dir");
176+
expect(a.framesEncoded).toBeGreaterThan(0);
177+
expect(b.framesEncoded).toBe(a.framesEncoded);
178+
179+
// sha256 fingerprint match — the contract `ChunkResult.sha256` implies.
180+
expect(a.sha256).toBe(b.sha256);
181+
// Independent byte-level verification. If the sha256 helper ever
182+
// regresses (e.g. starts hashing metadata instead of pixels), this
183+
// assertion still fails the test honestly.
184+
assertBytesEqual(outA, outB, "frame-dir", "png-sequence chunk 0");
185+
},
186+
TIMEOUT_MS,
187+
);
188+
189+
it(
190+
"mp4: chunk 0 is byte-identical across two distinct output paths",
191+
async () => {
192+
if (!mp4PlanReady) {
193+
console.warn("[crossWorkerIdempotency.test] skipping mp4 — plan() didn't complete on host");
194+
return;
195+
}
196+
const outDir = join(runRoot, "mp4-chunks");
197+
mkdirSync(outDir, { recursive: true });
198+
const outA = join(outDir, "chunk-a.mp4");
199+
const outB = join(outDir, "chunk-b.mp4");
200+
let a, b;
201+
try {
202+
a = await renderChunk(mp4PlanDir, 0, outA);
203+
} catch (err) {
204+
const message = err instanceof Error ? err.message : String(err);
205+
if (HOST_CHROME_FAILURE_PATTERNS.test(message)) {
206+
console.warn(
207+
"[crossWorkerIdempotency.test] skipping mp4 — host Chrome can't render. ",
208+
"Diagnostic:",
209+
message.slice(0, 240),
210+
);
211+
return;
212+
}
213+
throw err;
214+
}
215+
b = await renderChunk(mp4PlanDir, 0, outB);
216+
217+
expect(a.outputKind).toBe("file");
218+
expect(b.outputKind).toBe("file");
219+
expect(a.framesEncoded).toBeGreaterThan(0);
220+
expect(b.framesEncoded).toBe(a.framesEncoded);
221+
222+
expect(a.sha256).toBe(b.sha256);
223+
assertBytesEqual(outA, outB, "file", "mp4 chunk 0");
224+
},
225+
TIMEOUT_MS,
226+
);
227+
});

0 commit comments

Comments
 (0)