mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
test(producer): add cross-worker idempotency unit test
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Cross-worker idempotency: rendering the same `(planDir, chunkIndex)` on two
|
||||
* different workers MUST produce byte-identical output. This is what makes a
|
||||
* fan-out architecture safe — Worker A can crash mid-chunk and Worker B can
|
||||
* pick up the same slice without producing a frame that disagrees with what
|
||||
* Worker A would have written.
|
||||
*
|
||||
* `renderChunk.test.ts` already pins the byte-identical-retry contract for
|
||||
* png-sequence by comparing the engineered `ChunkResult.sha256` fingerprint.
|
||||
* This file complements that with:
|
||||
*
|
||||
* 1. Explicit `Buffer.equals` comparison of the raw bytes of every output
|
||||
* file, not just a derived fingerprint. This independently verifies the
|
||||
* property `renderChunk`'s sha256 helper is supposed to imply.
|
||||
* 2. The mp4 path. mp4 chunks go through the BeginFrame capture path +
|
||||
* libx264 encode; png-sequence chunks go through the screenshot capture
|
||||
* path with no encoder. Both must be byte-identical across temp dirs;
|
||||
* pinning only one would let an mp4-specific regression slip past.
|
||||
*
|
||||
* Both subtests soft-skip when `chrome-headless-shell` on the host can't
|
||||
* render — the Docker harness exercises the same code paths against a
|
||||
* matched chrome + ffmpeg build.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { plan } from "./plan.js";
|
||||
import { renderChunk } from "./renderChunk.js";
|
||||
|
||||
// Tiny composition shared by both subtests. 5 frames at 30fps lands a chunk
|
||||
// within a few seconds inside Docker and keeps host runs (where this test
|
||||
// soft-skips most of the time) cheap when they do exercise the full path.
|
||||
const FIXTURE_HTML = `<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>cross-worker idempotency fixture</title></head>
|
||||
<body style="margin:0;background:#000;color:#fff;font:32px sans-serif">
|
||||
<div data-composition-id="root" data-width="160" data-height="120" data-duration="0.16667">
|
||||
<p style="padding:1rem">chunk fixture</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Patterns that indicate a host's chrome-headless-shell can't render — same
|
||||
// set `renderChunk.test.ts` uses. We soft-skip rather than fail; the docker
|
||||
// harness covers the determinism contract against a known-good image.
|
||||
const HOST_CHROME_FAILURE_PATTERNS =
|
||||
/chrome:\/\/gpu|BROWSER_GPU_NOT_SOFTWARE|SwiftShader|HeadlessExperimental\.beginFrame|Target closed/i;
|
||||
|
||||
let runRoot: string;
|
||||
let projectDir: string;
|
||||
let pngPlanDir: string;
|
||||
let mp4PlanDir: string;
|
||||
let pngPlanReady = false;
|
||||
let mp4PlanReady = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
runRoot = mkdtempSync(join(tmpdir(), "hf-cross-worker-test-"));
|
||||
projectDir = join(runRoot, "project");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
|
||||
|
||||
// Plan once per format. plan() is cheap for this fixture (statically-resolvable
|
||||
// duration means the probe stage never launches a browser), and re-planning
|
||||
// per `it()` would dominate the test wall time.
|
||||
//
|
||||
// A plan failure is treated as a soft skip — most commonly it's the
|
||||
// ffmpeg-version readout fighting a host with no ffmpeg on PATH, or the
|
||||
// compile stage hitting a missing font binary. Either way, the Docker
|
||||
// harness exercises the same code path against a working image, so the
|
||||
// host failure is informational rather than load-bearing.
|
||||
pngPlanDir = join(runRoot, "plan-pngseq");
|
||||
mkdirSync(pngPlanDir, { recursive: true });
|
||||
try {
|
||||
await plan(
|
||||
projectDir,
|
||||
{ fps: 30, width: 160, height: 120, format: "png-sequence" },
|
||||
pngPlanDir,
|
||||
);
|
||||
pngPlanReady = true;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[crossWorkerIdempotency.test] png-sequence plan() failed on host — subtest will soft-skip.",
|
||||
"Diagnostic:",
|
||||
(err instanceof Error ? err.message : String(err)).slice(0, 240),
|
||||
);
|
||||
}
|
||||
|
||||
mp4PlanDir = join(runRoot, "plan-mp4");
|
||||
mkdirSync(mp4PlanDir, { recursive: true });
|
||||
try {
|
||||
await plan(projectDir, { fps: 30, width: 160, height: 120, format: "mp4" }, mp4PlanDir);
|
||||
mp4PlanReady = true;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[crossWorkerIdempotency.test] mp4 plan() failed on host — subtest will soft-skip.",
|
||||
"Diagnostic:",
|
||||
(err instanceof Error ? err.message : String(err)).slice(0, 240),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Compare two chunk outputs byte-by-byte. For `file` outputs (mp4/mov) the
|
||||
* whole file is compared; for `frame-dir` outputs (png-sequence) every PNG is
|
||||
* compared (including the directory listing, so a missing or extra frame
|
||||
* trips the assertion).
|
||||
*/
|
||||
function assertBytesEqual(
|
||||
outA: string,
|
||||
outB: string,
|
||||
kind: "file" | "frame-dir",
|
||||
label: string,
|
||||
): void {
|
||||
if (kind === "file") {
|
||||
const bytesA = readFileSync(outA);
|
||||
const bytesB = readFileSync(outB);
|
||||
expect(bytesA.byteLength).toBe(bytesB.byteLength);
|
||||
// Buffer.equals returns boolean — `toBe(true)` gives a clearer message on
|
||||
// failure than `toEqual` on two large Buffers.
|
||||
expect(bytesA.equals(bytesB)).toBe(true);
|
||||
return;
|
||||
}
|
||||
const framesA = readdirSync(outA).sort();
|
||||
const framesB = readdirSync(outB).sort();
|
||||
expect(framesA).toEqual(framesB);
|
||||
for (const name of framesA) {
|
||||
const a = readFileSync(join(outA, name));
|
||||
const b = readFileSync(join(outB, name));
|
||||
if (a.byteLength !== b.byteLength || !a.equals(b)) {
|
||||
throw new Error(`${label}: frame ${name} differs (a=${a.byteLength}B, b=${b.byteLength}B)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("cross-worker idempotency", () => {
|
||||
// Generous timeout for slower CI: cold Chrome start + 5-frame capture +
|
||||
// ffmpeg encode is the dominant cost, repeated twice.
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
it(
|
||||
"png-sequence: chunk 0 is byte-identical across two distinct output dirs",
|
||||
async () => {
|
||||
if (!pngPlanReady) {
|
||||
console.warn(
|
||||
"[crossWorkerIdempotency.test] skipping png-sequence — plan() didn't complete on host",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const outA = join(runRoot, "pngseq-chunk-a");
|
||||
const outB = join(runRoot, "pngseq-chunk-b");
|
||||
let a, b;
|
||||
try {
|
||||
a = await renderChunk(pngPlanDir, 0, outA);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (HOST_CHROME_FAILURE_PATTERNS.test(message)) {
|
||||
console.warn(
|
||||
"[crossWorkerIdempotency.test] skipping png-sequence — host Chrome can't render. ",
|
||||
"Diagnostic:",
|
||||
message.slice(0, 240),
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
b = await renderChunk(pngPlanDir, 0, outB);
|
||||
|
||||
expect(a.outputKind).toBe("frame-dir");
|
||||
expect(b.outputKind).toBe("frame-dir");
|
||||
expect(a.framesEncoded).toBeGreaterThan(0);
|
||||
expect(b.framesEncoded).toBe(a.framesEncoded);
|
||||
|
||||
// sha256 fingerprint match — the contract `ChunkResult.sha256` implies.
|
||||
expect(a.sha256).toBe(b.sha256);
|
||||
// Independent byte-level verification. If the sha256 helper ever
|
||||
// regresses (e.g. starts hashing metadata instead of pixels), this
|
||||
// assertion still fails the test honestly.
|
||||
assertBytesEqual(outA, outB, "frame-dir", "png-sequence chunk 0");
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"mp4: chunk 0 is byte-identical across two distinct output paths",
|
||||
async () => {
|
||||
if (!mp4PlanReady) {
|
||||
console.warn("[crossWorkerIdempotency.test] skipping mp4 — plan() didn't complete on host");
|
||||
return;
|
||||
}
|
||||
const outDir = join(runRoot, "mp4-chunks");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const outA = join(outDir, "chunk-a.mp4");
|
||||
const outB = join(outDir, "chunk-b.mp4");
|
||||
let a, b;
|
||||
try {
|
||||
a = await renderChunk(mp4PlanDir, 0, outA);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (HOST_CHROME_FAILURE_PATTERNS.test(message)) {
|
||||
console.warn(
|
||||
"[crossWorkerIdempotency.test] skipping mp4 — host Chrome can't render. ",
|
||||
"Diagnostic:",
|
||||
message.slice(0, 240),
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
b = await renderChunk(mp4PlanDir, 0, outB);
|
||||
|
||||
expect(a.outputKind).toBe("file");
|
||||
expect(b.outputKind).toBe("file");
|
||||
expect(a.framesEncoded).toBeGreaterThan(0);
|
||||
expect(b.framesEncoded).toBe(a.framesEncoded);
|
||||
|
||||
expect(a.sha256).toBe(b.sha256);
|
||||
assertBytesEqual(outA, outB, "file", "mp4 chunk 0");
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user