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:
Vance Ingalls
2026-04-23 11:42:42 -07:00
committed by GitHub
parent 4101cb721a
commit 147bb737c9
+134 -1
View File
@@ -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);
});
});