mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
import { type ChildProcess, spawn } from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
import { isToolInputError } from "@snapotter/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { MediaInfo } from "../src/ffprobe.js";
|
|
|
|
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
|
|
|
|
interface FakeChild {
|
|
proc: ChildProcess;
|
|
stdout: EventEmitter;
|
|
stderr: EventEmitter;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
function makeChild(): FakeChild {
|
|
const stdout = new EventEmitter();
|
|
const stderr = new EventEmitter();
|
|
const kill = vi.fn(() => true);
|
|
const proc = new EventEmitter() as unknown as ChildProcess;
|
|
Object.assign(proc, { stdout, stderr, kill, pid: 99, killed: false });
|
|
return { proc, stdout, stderr, kill };
|
|
}
|
|
|
|
/** Runs probeMedia against a canned JSON payload emitted on stdout then close(0). */
|
|
async function probeWith(json: unknown, path = "/media/clip.mp4"): Promise<MediaInfo> {
|
|
const child = makeChild();
|
|
vi.mocked(spawn).mockReturnValue(child.proc);
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
const p = probeMedia(path);
|
|
child.stdout.emit("data", Buffer.from(JSON.stringify(json)));
|
|
child.proc.emit("close", 0, null);
|
|
return p;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.mocked(spawn).mockReset();
|
|
process.env.FFPROBE_PATH = "/fake/ffprobe";
|
|
delete process.env.SUBPROCESS_MEMORY_LIMIT_MB;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
delete process.env.FFPROBE_PATH;
|
|
delete process.env.SUBPROCESS_MEMORY_LIMIT_MB;
|
|
});
|
|
|
|
describe("probeMedia: argv construction", () => {
|
|
it("passes the exact ffprobe argv with the file path last", async () => {
|
|
const child = makeChild();
|
|
vi.mocked(spawn).mockReturnValue(child.proc);
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
const p = probeMedia("/media/input.mkv");
|
|
const [bin, args, opts] = vi.mocked(spawn).mock.calls[0];
|
|
expect(bin).toBe("/fake/ffprobe");
|
|
expect(args).toEqual([
|
|
"-v",
|
|
"error",
|
|
"-analyzeduration",
|
|
"10M",
|
|
"-probesize",
|
|
"25M",
|
|
"-print_format",
|
|
"json",
|
|
"-show_format",
|
|
"-show_streams",
|
|
"/media/input.mkv",
|
|
]);
|
|
expect(opts).toMatchObject({ stdio: ["ignore", "pipe", "pipe"] });
|
|
child.stdout.emit("data", Buffer.from("{}"));
|
|
child.proc.emit("close", 0, null);
|
|
await p;
|
|
});
|
|
|
|
it("routes argv through the memory-limit wrapper when the cap is set", async () => {
|
|
process.env.SUBPROCESS_MEMORY_LIMIT_MB = "64";
|
|
const child = makeChild();
|
|
vi.mocked(spawn).mockReturnValue(child.proc);
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
const p = probeMedia("/media/input.mkv");
|
|
const [bin, args] = vi.mocked(spawn).mock.calls[0];
|
|
expect(bin).toBe("/bin/sh");
|
|
expect(args).toEqual([
|
|
"-c",
|
|
'ulimit -v "$1" 2>/dev/null || true; shift; exec "$@"',
|
|
"sh",
|
|
"65536", // 64 * 1024
|
|
"/fake/ffprobe",
|
|
"-v",
|
|
"error",
|
|
"-analyzeduration",
|
|
"10M",
|
|
"-probesize",
|
|
"25M",
|
|
"-print_format",
|
|
"json",
|
|
"-show_format",
|
|
"-show_streams",
|
|
"/media/input.mkv",
|
|
]);
|
|
child.stdout.emit("data", Buffer.from("{}"));
|
|
child.proc.emit("close", 0, null);
|
|
await p;
|
|
});
|
|
});
|
|
|
|
describe("probeMedia: format-level parsing", () => {
|
|
it("maps container, duration and bitrate (bit_rate 1_536_000 -> 1536 kbps)", async () => {
|
|
const info = await probeWith({
|
|
format: { format_name: "mov,mp4,m4a", duration: "12.345", bit_rate: "1536000" },
|
|
});
|
|
expect(info.container).toBe("mov,mp4,m4a");
|
|
expect(info.durationS).toBe(12.345);
|
|
expect(info.bitrateKbps).toBe(1536);
|
|
});
|
|
|
|
it("rounds bitrate to the nearest kbps (1_500_999 -> 1501)", async () => {
|
|
const info = await probeWith({ format: { bit_rate: "1500999" } });
|
|
expect(info.bitrateKbps).toBe(1501);
|
|
});
|
|
|
|
it("defaults container to 'unknown' and duration/bitrate to null when format is empty", async () => {
|
|
const info = await probeWith({ format: {} });
|
|
expect(info.container).toBe("unknown");
|
|
expect(info.durationS).toBeNull();
|
|
expect(info.bitrateKbps).toBeNull();
|
|
});
|
|
|
|
it("defaults everything when there is no format object at all", async () => {
|
|
const info = await probeWith({});
|
|
expect(info.container).toBe("unknown");
|
|
expect(info.durationS).toBeNull();
|
|
expect(info.bitrateKbps).toBeNull();
|
|
expect(info.streams).toEqual([]);
|
|
expect(info.tags).toBeUndefined();
|
|
});
|
|
|
|
it("returns null bitrate for a non-numeric bit_rate string", async () => {
|
|
const info = await probeWith({ format: { bit_rate: "N/A" } });
|
|
expect(info.bitrateKbps).toBeNull();
|
|
});
|
|
|
|
it("returns null duration for a non-numeric duration string", async () => {
|
|
const info = await probeWith({ format: { duration: "N/A" } });
|
|
expect(info.durationS).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("probeMedia: tags parsing", () => {
|
|
it("lowercases keys, drops non-string and empty values", async () => {
|
|
const info = await probeWith({
|
|
format: {
|
|
tags: { title: "Hello", COMMENT: "world", empty: "", count: 5, missing: null },
|
|
},
|
|
});
|
|
expect(info.tags).toEqual({ title: "Hello", comment: "world" });
|
|
});
|
|
|
|
it("omits tags entirely when none survive filtering", async () => {
|
|
const info = await probeWith({ format: { tags: { empty: "", n: 7 } } });
|
|
expect(info.tags).toBeUndefined();
|
|
});
|
|
|
|
it("omits tags when the tags object is absent", async () => {
|
|
const info = await probeWith({ format: { format_name: "wav" } });
|
|
expect(info.tags).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("probeMedia: stream parsing", () => {
|
|
it("maps a video stream with width and height", async () => {
|
|
const info = await probeWith({
|
|
streams: [{ codec_type: "video", codec_name: "h264", width: 1920, height: 1080 }],
|
|
});
|
|
expect(info.streams[0]).toEqual({
|
|
type: "video",
|
|
codec: "h264",
|
|
width: 1920,
|
|
height: 1080,
|
|
sampleRate: undefined,
|
|
channels: undefined,
|
|
});
|
|
});
|
|
|
|
it("maps an audio stream with sample rate and channels", async () => {
|
|
const info = await probeWith({
|
|
streams: [{ codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2 }],
|
|
});
|
|
expect(info.streams[0]).toEqual({
|
|
type: "audio",
|
|
codec: "aac",
|
|
width: undefined,
|
|
height: undefined,
|
|
sampleRate: 48000,
|
|
channels: 2,
|
|
});
|
|
});
|
|
|
|
it("classifies a subtitle stream (neither video nor audio) as 'other'", async () => {
|
|
const info = await probeWith({ streams: [{ codec_type: "subtitle", codec_name: "mov_text" }] });
|
|
expect(info.streams[0].type).toBe("other");
|
|
expect(info.streams[0].codec).toBe("mov_text");
|
|
});
|
|
|
|
it("defaults codec to 'unknown' and type to 'other' for an empty stream object", async () => {
|
|
const info = await probeWith({ streams: [{}] });
|
|
expect(info.streams[0]).toEqual({
|
|
type: "other",
|
|
codec: "unknown",
|
|
width: undefined,
|
|
height: undefined,
|
|
sampleRate: undefined,
|
|
channels: undefined,
|
|
});
|
|
});
|
|
|
|
it("drops a zero sample_rate (> 0 guard) to undefined", async () => {
|
|
const info = await probeWith({
|
|
streams: [{ codec_type: "audio", codec_name: "flac", sample_rate: "0", channels: 2 }],
|
|
});
|
|
expect(info.streams[0].sampleRate).toBeUndefined();
|
|
expect(info.streams[0].channels).toBe(2);
|
|
});
|
|
|
|
it("drops a zero channel count (> 0 guard) to undefined", async () => {
|
|
const info = await probeWith({
|
|
streams: [{ codec_type: "audio", codec_name: "flac", sample_rate: "44100", channels: 0 }],
|
|
});
|
|
expect(info.streams[0].channels).toBeUndefined();
|
|
expect(info.streams[0].sampleRate).toBe(44100);
|
|
});
|
|
|
|
it("drops a non-numeric channels value to undefined", async () => {
|
|
const info = await probeWith({
|
|
streams: [{ codec_type: "audio", codec_name: "aac", channels: "2" }],
|
|
});
|
|
expect(info.streams[0].channels).toBeUndefined();
|
|
});
|
|
|
|
it("drops a non-finite sample_rate to undefined", async () => {
|
|
const info = await probeWith({
|
|
streams: [{ codec_type: "audio", codec_name: "aac", sample_rate: "abc" }],
|
|
});
|
|
expect(info.streams[0].sampleRate).toBeUndefined();
|
|
});
|
|
|
|
it("preserves stream order across a mixed set", async () => {
|
|
const info = await probeWith({
|
|
streams: [
|
|
{ codec_type: "video", codec_name: "hevc", width: 3840, height: 2160 },
|
|
{ codec_type: "audio", codec_name: "opus", sample_rate: "48000", channels: 6 },
|
|
{ codec_type: "data", codec_name: "bin_data" },
|
|
],
|
|
});
|
|
expect(info.streams.map((s) => s.type)).toEqual(["video", "audio", "other"]);
|
|
expect(info.streams.map((s) => s.codec)).toEqual(["hevc", "opus", "bin_data"]);
|
|
expect(info.streams[0].width).toBe(3840);
|
|
expect(info.streams[0].height).toBe(2160);
|
|
expect(info.streams[1].channels).toBe(6);
|
|
});
|
|
});
|
|
|
|
describe("probeMedia: error paths", () => {
|
|
it("rejects when stdout is not valid JSON", async () => {
|
|
const child = makeChild();
|
|
vi.mocked(spawn).mockReturnValue(child.proc);
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
const p = probeMedia("/media/clip.mp4");
|
|
child.stdout.emit("data", Buffer.from("{ this is not json"));
|
|
child.proc.emit("close", 0, null);
|
|
await expect(p).rejects.toBeInstanceOf(Error);
|
|
});
|
|
|
|
it("rejects with 'ffprobe exited <code>: <tail>' on non-zero exit", async () => {
|
|
const child = makeChild();
|
|
vi.mocked(spawn).mockReturnValue(child.proc);
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
const p = probeMedia("/media/clip.mp4");
|
|
child.stderr.emit("data", Buffer.from("some probe failure"));
|
|
child.proc.emit("close", 2, null);
|
|
await expect(p).rejects.toThrow("ffprobe exited 2: some probe failure");
|
|
});
|
|
|
|
it("uses the signal name when the exit code is null", async () => {
|
|
const child = makeChild();
|
|
vi.mocked(spawn).mockReturnValue(child.proc);
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
const p = probeMedia("/media/clip.mp4");
|
|
child.stderr.emit("data", Buffer.from("killed"));
|
|
child.proc.emit("close", null, "SIGKILL");
|
|
await expect(p).rejects.toThrow("ffprobe exited SIGKILL: killed");
|
|
});
|
|
|
|
it("marks the rejection as a tool input error when stderr matches an input pattern", async () => {
|
|
const child = makeChild();
|
|
vi.mocked(spawn).mockReturnValue(child.proc);
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
const p = probeMedia("/media/clip.mp4");
|
|
child.stderr.emit("data", Buffer.from("Invalid data found when processing input"));
|
|
child.proc.emit("close", 1, null);
|
|
let caught: unknown;
|
|
try {
|
|
await p;
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
expect(isToolInputError(caught)).toBe(true);
|
|
});
|
|
|
|
it("does NOT mark the rejection for a generic probe failure", async () => {
|
|
const child = makeChild();
|
|
vi.mocked(spawn).mockReturnValue(child.proc);
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
const p = probeMedia("/media/clip.mp4");
|
|
child.stderr.emit("data", Buffer.from("some unrelated failure"));
|
|
child.proc.emit("close", 1, null);
|
|
let caught: unknown;
|
|
try {
|
|
await p;
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
expect(isToolInputError(caught)).toBe(false);
|
|
});
|
|
|
|
it("keeps only the last 4096 bytes of stderr for the error tail", async () => {
|
|
const child = makeChild();
|
|
vi.mocked(spawn).mockReturnValue(child.proc);
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
const p = probeMedia("/media/clip.mp4");
|
|
// Emit >4096 of stderr; the tail slice(-1000) in the message proves capture worked.
|
|
child.stderr.emit("data", Buffer.from("Q".repeat(5000)));
|
|
child.proc.emit("close", 1, null);
|
|
let caught: unknown;
|
|
try {
|
|
await p;
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
const msg = (caught as Error).message;
|
|
// Message embeds err.slice(-1000): exactly 1000 Q's after the prefix.
|
|
expect(msg).toBe(`ffprobe exited 1: ${"Q".repeat(1000)}`);
|
|
});
|
|
|
|
it("rejects on the spawn 'error' event", async () => {
|
|
const child = makeChild();
|
|
vi.mocked(spawn).mockReturnValue(child.proc);
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
const p = probeMedia("/media/clip.mp4");
|
|
child.proc.emit("error", new Error("spawn EACCES"));
|
|
await expect(p).rejects.toThrow("spawn EACCES");
|
|
});
|
|
});
|
|
|
|
describe("probeMedia: timeout", () => {
|
|
it("rejects with 'ffprobe timed out after 15s' at the default timeout and SIGKILLs", async () => {
|
|
vi.useFakeTimers();
|
|
const child = makeChild();
|
|
vi.mocked(spawn).mockReturnValue(child.proc);
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
const p = probeMedia("/media/clip.mp4");
|
|
const assertion = expect(p).rejects.toThrow("ffprobe timed out after 15s");
|
|
vi.advanceTimersByTime(15_000);
|
|
await assertion;
|
|
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
|
});
|
|
|
|
it("honors a custom timeoutMs and reports it in seconds (3000ms -> 3s)", async () => {
|
|
vi.useFakeTimers();
|
|
const child = makeChild();
|
|
vi.mocked(spawn).mockReturnValue(child.proc);
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
const p = probeMedia("/media/clip.mp4", { timeoutMs: 3000 });
|
|
const assertion = expect(p).rejects.toThrow("ffprobe timed out after 3s");
|
|
vi.advanceTimersByTime(3000);
|
|
await assertion;
|
|
});
|
|
|
|
it("does not fire the timeout when the probe completes in time", async () => {
|
|
vi.useFakeTimers();
|
|
const child = makeChild();
|
|
vi.mocked(spawn).mockReturnValue(child.proc);
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
const p = probeMedia("/media/clip.mp4", { timeoutMs: 3000 });
|
|
vi.advanceTimersByTime(2999);
|
|
child.stdout.emit("data", Buffer.from('{"format":{"format_name":"wav"}}'));
|
|
child.proc.emit("close", 0, null);
|
|
const info = await p;
|
|
expect(info.container).toBe("wav");
|
|
expect(child.kill).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("probeMedia: missing binary", () => {
|
|
it("rejects with a clear message and never spawns when ffprobe is unavailable", async () => {
|
|
vi.doMock("../src/binaries.js", () => ({
|
|
resolveFfmpeg: () => null,
|
|
resolveFfprobe: () => null,
|
|
}));
|
|
const { probeMedia } = await import("../src/ffprobe.js");
|
|
await expect(probeMedia("/media/clip.mp4")).rejects.toThrow(
|
|
"ffprobe binary not found (set FFPROBE_PATH or install ffmpeg)",
|
|
);
|
|
expect(vi.mocked(spawn)).not.toHaveBeenCalled();
|
|
vi.doUnmock("../src/binaries.js");
|
|
});
|
|
});
|