mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
test(engine): add ffprobe-unavailable fallback regression tests (#379)
## Summary Mock `node:child_process.spawn` to surface `ENOENT` and verify ffprobe's three callers behave correctly when ffprobe is missing. ## Why `Chunk 9B` of `plans/hdr-followups.md`. The PNG cICP fallback in `extractMediaMetadata` was added to support environments without ffprobe, but no test pinned the behavior — silently regressing it would break HDR image support on any system without ffprobe installed. ## What changed `packages/engine/src/utils/ffprobe.test.ts`: mocks `child_process.spawn` to surface `ENOENT` and asserts: - `extractMediaMetadata` falls back to PNG cICP metadata for image inputs. - `extractMediaMetadata` rethrows for non-image inputs lacking a still-image fallback. - `extractAudioMetadata` + `analyzeKeyframeIntervals` propagate the install-hint error verbatim. ## Test plan - [x] All new tests pass. - [x] No production code changes — pure regression coverage of existing fallback behavior. ## Stack Chunk 9B of `plans/hdr-followups.md`. Test-only change, independent of all other chunks.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { EventEmitter } from "events";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { extractMediaMetadata, extractPngMetadataFromBuffer } from "./ffprobe.js";
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
@@ -107,3 +108,135 @@ describe("extractPngMetadataFromBuffer", () => {
|
||||
expect(extractPngMetadataFromBuffer(fixture)?.colorSpace?.colorTransfer).toBe("smpte2084");
|
||||
});
|
||||
});
|
||||
|
||||
interface SpawnCall {
|
||||
command: string;
|
||||
args: readonly string[];
|
||||
}
|
||||
|
||||
interface FakeProc extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
|
||||
type SpawnOutcome =
|
||||
| { kind: "missing" }
|
||||
| { kind: "error"; message: string; code?: string }
|
||||
| { kind: "exit"; code: number; stdout?: string; stderr?: string };
|
||||
|
||||
function createSpawnSpy(outcomes: SpawnOutcome[]): {
|
||||
spawn: (command: string, args: readonly string[]) => FakeProc;
|
||||
calls: SpawnCall[];
|
||||
} {
|
||||
const calls: SpawnCall[] = [];
|
||||
let invocation = 0;
|
||||
const spawn = (command: string, args: readonly string[]): FakeProc => {
|
||||
calls.push({ command, args });
|
||||
const outcome = outcomes[invocation] ?? outcomes[outcomes.length - 1];
|
||||
invocation += 1;
|
||||
|
||||
const proc = new EventEmitter() as FakeProc;
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
|
||||
process.nextTick(() => {
|
||||
if (!outcome) return;
|
||||
if (outcome.kind === "missing") {
|
||||
const err = new Error("spawn ffprobe ENOENT") as NodeJS.ErrnoException;
|
||||
err.code = "ENOENT";
|
||||
proc.emit("error", err);
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === "error") {
|
||||
const err = new Error(outcome.message) as NodeJS.ErrnoException;
|
||||
if (outcome.code) err.code = outcome.code;
|
||||
proc.emit("error", err);
|
||||
return;
|
||||
}
|
||||
if (outcome.stdout) proc.stdout.emit("data", Buffer.from(outcome.stdout));
|
||||
if (outcome.stderr) proc.stderr.emit("data", Buffer.from(outcome.stderr));
|
||||
proc.emit("close", outcome.code);
|
||||
});
|
||||
|
||||
return proc;
|
||||
};
|
||||
return { spawn, calls };
|
||||
}
|
||||
|
||||
describe("ffprobe missing-binary fallback", () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("child_process");
|
||||
});
|
||||
|
||||
it("extractMediaMetadata falls back to PNG cICP metadata when ffprobe is missing", async () => {
|
||||
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
|
||||
vi.resetModules();
|
||||
vi.doMock("child_process", () => ({ spawn }));
|
||||
|
||||
const { extractMediaMetadata: extractMediaMetadataMocked } = await import("./ffprobe.js");
|
||||
const fixture = resolve(
|
||||
__dirname,
|
||||
"../../../producer/tests/hdr-regression/src/hdr-photo-pq.png",
|
||||
);
|
||||
const meta = await extractMediaMetadataMocked(fixture);
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]?.command).toBe("ffprobe");
|
||||
expect(meta.videoCodec).toBe("png");
|
||||
expect(meta.durationSeconds).toBe(0);
|
||||
expect(meta.fps).toBe(0);
|
||||
expect(meta.hasAudio).toBe(false);
|
||||
expect(meta.isVFR).toBe(false);
|
||||
expect(meta.colorSpace?.colorTransfer).toBe("smpte2084");
|
||||
expect(meta.colorSpace?.colorPrimaries).toBe("bt2020");
|
||||
});
|
||||
|
||||
it("extractMediaMetadata rethrows ffprobe-missing error for non-image files without fallback", async () => {
|
||||
const { spawn } = createSpawnSpy([{ kind: "missing" }]);
|
||||
vi.resetModules();
|
||||
vi.doMock("child_process", () => ({ spawn }));
|
||||
|
||||
const { extractMediaMetadata: extractMediaMetadataMocked } = await import("./ffprobe.js");
|
||||
|
||||
await expect(extractMediaMetadataMocked("/tmp/no-such-video.mp4")).rejects.toThrow(/ffprobe/);
|
||||
});
|
||||
|
||||
it("extractAudioMetadata surfaces a ffprobe-missing error verbatim", async () => {
|
||||
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
|
||||
vi.resetModules();
|
||||
vi.doMock("child_process", () => ({ spawn }));
|
||||
|
||||
const { extractAudioMetadata } = await import("./ffprobe.js");
|
||||
|
||||
await expect(extractAudioMetadata("/tmp/no-such-audio.wav")).rejects.toThrow(
|
||||
/ffprobe not found/,
|
||||
);
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]?.command).toBe("ffprobe");
|
||||
});
|
||||
|
||||
it("analyzeKeyframeIntervals surfaces a ffprobe-missing error verbatim", async () => {
|
||||
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
|
||||
vi.resetModules();
|
||||
vi.doMock("child_process", () => ({ spawn }));
|
||||
|
||||
const { analyzeKeyframeIntervals } = await import("./ffprobe.js");
|
||||
|
||||
await expect(analyzeKeyframeIntervals("/tmp/no-such-video.mp4")).rejects.toThrow(
|
||||
/ffprobe not found/,
|
||||
);
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]?.command).toBe("ffprobe");
|
||||
});
|
||||
|
||||
it("ffprobe-missing error message includes install hint", async () => {
|
||||
const { spawn } = createSpawnSpy([{ kind: "missing" }]);
|
||||
vi.resetModules();
|
||||
vi.doMock("child_process", () => ({ spawn }));
|
||||
|
||||
const { extractAudioMetadata } = await import("./ffprobe.js");
|
||||
|
||||
await expect(extractAudioMetadata("/tmp/example.mp3")).rejects.toThrow(/install FFmpeg/i);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user