mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
test: coverage campaign and mutation testing across five packages (#628)
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.
This commit is contained in:
@@ -9,12 +9,15 @@
|
||||
"lint": "biome check src/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:mutation": "stryker run",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@snapotter/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stryker-mutator/core": "^9.6.1",
|
||||
"@stryker-mutator/vitest-runner": "^9.6.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker-js/master/packages/api/schema/stryker-core.json",
|
||||
"testRunner": "vitest",
|
||||
"plugins": ["@stryker-mutator/vitest-runner"],
|
||||
"mutate": ["src/**/*.ts", "!src/index.ts"],
|
||||
"incremental": true,
|
||||
"incrementalFile": "reports/stryker-incremental.json",
|
||||
"reporters": ["html", "clear-text", "progress"],
|
||||
"htmlReporter": { "fileName": "reports/mutation/mutation.html" },
|
||||
"thresholds": { "high": 80, "low": 60, "break": null },
|
||||
"tempDirName": ".stryker-tmp",
|
||||
"ignoreStatic": true
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", () => ({ spawnSync: vi.fn() }));
|
||||
|
||||
type SpawnSyncReturn = { status: number | null; stdout: string; stderr: string };
|
||||
function whichResult(over: Partial<SpawnSyncReturn> = {}): SpawnSyncReturn {
|
||||
return { status: 0, stdout: "", stderr: "", ...over };
|
||||
}
|
||||
|
||||
describe("resolveFfmpeg / resolveFfprobe", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mocked(spawnSync).mockReset();
|
||||
delete process.env.FFMPEG_PATH;
|
||||
delete process.env.FFPROBE_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.FFMPEG_PATH;
|
||||
delete process.env.FFPROBE_PATH;
|
||||
});
|
||||
|
||||
it("FFMPEG_PATH env override wins and skips the which() probe", async () => {
|
||||
process.env.FFMPEG_PATH = "/opt/bin/ffmpeg";
|
||||
const { resolveFfmpeg } = await import("../src/binaries.js");
|
||||
expect(resolveFfmpeg()).toBe("/opt/bin/ffmpeg");
|
||||
expect(vi.mocked(spawnSync)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("FFPROBE_PATH env override wins and skips the which() probe", async () => {
|
||||
process.env.FFPROBE_PATH = "/opt/bin/ffprobe";
|
||||
const { resolveFfprobe } = await import("../src/binaries.js");
|
||||
expect(resolveFfprobe()).toBe("/opt/bin/ffprobe");
|
||||
expect(vi.mocked(spawnSync)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to which() and returns the first line on status 0", async () => {
|
||||
// The code trims the whole stdout then splits on "\n" and takes index 0.
|
||||
vi.mocked(spawnSync).mockReturnValue(
|
||||
whichResult({ stdout: "\n/usr/bin/ffmpeg\n/usr/local/bin/ffmpeg\n" }) as never,
|
||||
);
|
||||
const { resolveFfmpeg } = await import("../src/binaries.js");
|
||||
expect(resolveFfmpeg()).toBe("/usr/bin/ffmpeg");
|
||||
});
|
||||
|
||||
it("invokes which/where with the binary name argument", async () => {
|
||||
vi.mocked(spawnSync).mockReturnValue(whichResult({ stdout: "/usr/bin/ffprobe\n" }) as never);
|
||||
const { resolveFfprobe } = await import("../src/binaries.js");
|
||||
resolveFfprobe();
|
||||
const [cmd, args, opts] = vi.mocked(spawnSync).mock.calls[0];
|
||||
expect(cmd).toBe(process.platform === "win32" ? "where" : "which");
|
||||
expect(args).toEqual(["ffprobe"]);
|
||||
expect(opts).toMatchObject({ encoding: "utf8" });
|
||||
});
|
||||
|
||||
it("returns null when which() exits non-zero", async () => {
|
||||
vi.mocked(spawnSync).mockReturnValue(whichResult({ status: 1, stdout: "" }) as never);
|
||||
const { resolveFfmpeg } = await import("../src/binaries.js");
|
||||
expect(resolveFfmpeg()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when which() exits 0 but stdout is blank", async () => {
|
||||
// status 0 with empty/whitespace stdout must still be null (both conditions matter).
|
||||
vi.mocked(spawnSync).mockReturnValue(whichResult({ status: 0, stdout: " \n" }) as never);
|
||||
const { resolveFfmpeg } = await import("../src/binaries.js");
|
||||
expect(resolveFfmpeg()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when status is null (spawn failure)", async () => {
|
||||
vi.mocked(spawnSync).mockReturnValue(whichResult({ status: null, stdout: "/x\n" }) as never);
|
||||
const { resolveFfprobe } = await import("../src/binaries.js");
|
||||
expect(resolveFfprobe()).toBeNull();
|
||||
});
|
||||
|
||||
it("caches the resolved path: which() runs only once across calls", async () => {
|
||||
vi.mocked(spawnSync).mockReturnValue(whichResult({ stdout: "/usr/bin/ffmpeg\n" }) as never);
|
||||
const { resolveFfmpeg } = await import("../src/binaries.js");
|
||||
expect(resolveFfmpeg()).toBe("/usr/bin/ffmpeg");
|
||||
expect(resolveFfmpeg()).toBe("/usr/bin/ffmpeg");
|
||||
expect(resolveFfmpeg()).toBe("/usr/bin/ffmpeg");
|
||||
expect(vi.mocked(spawnSync)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("caches null too: a failed probe is not retried", async () => {
|
||||
vi.mocked(spawnSync).mockReturnValue(whichResult({ status: 1 }) as never);
|
||||
const { resolveFfmpeg } = await import("../src/binaries.js");
|
||||
expect(resolveFfmpeg()).toBeNull();
|
||||
expect(resolveFfmpeg()).toBeNull();
|
||||
expect(vi.mocked(spawnSync)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ffmpegAvailable", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mocked(spawnSync).mockReset();
|
||||
delete process.env.FFMPEG_PATH;
|
||||
delete process.env.FFPROBE_PATH;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.FFMPEG_PATH;
|
||||
delete process.env.FFPROBE_PATH;
|
||||
});
|
||||
|
||||
it("is true only when both binaries resolve", async () => {
|
||||
process.env.FFMPEG_PATH = "/opt/ffmpeg";
|
||||
process.env.FFPROBE_PATH = "/opt/ffprobe";
|
||||
const { ffmpegAvailable } = await import("../src/binaries.js");
|
||||
expect(ffmpegAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when ffprobe is missing even if ffmpeg is present", async () => {
|
||||
process.env.FFMPEG_PATH = "/opt/ffmpeg";
|
||||
// ffprobe has no env override and which() fails.
|
||||
vi.mocked(spawnSync).mockReturnValue(whichResult({ status: 1 }) as never);
|
||||
const { ffmpegAvailable } = await import("../src/binaries.js");
|
||||
expect(ffmpegAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when ffmpeg is missing even if ffprobe is present", async () => {
|
||||
process.env.FFPROBE_PATH = "/opt/ffprobe";
|
||||
vi.mocked(spawnSync).mockReturnValue(whichResult({ status: 1 }) as never);
|
||||
const { ffmpegAvailable } = await import("../src/binaries.js");
|
||||
expect(ffmpegAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type EncoderTarget, resolveEncoder } from "../src/encoders.js";
|
||||
|
||||
/**
|
||||
* resolveEncoder reads process.env.SNAPOTTER_HW_ACCEL at call time, so each
|
||||
* test sets or clears it. Assertions are exact strings: an existence-only test
|
||||
* would leave every map-entry mutant alive.
|
||||
*/
|
||||
afterEach(() => {
|
||||
delete process.env.SNAPOTTER_HW_ACCEL;
|
||||
});
|
||||
|
||||
const ALL_TARGETS: EncoderTarget[] = ["h264", "hevc", "av1", "vp9", "aac", "opus", "mp3"];
|
||||
|
||||
describe("resolveEncoder: software (no accel set)", () => {
|
||||
const expected: Record<EncoderTarget, string> = {
|
||||
h264: "libx264",
|
||||
hevc: "libx265",
|
||||
av1: "libsvtav1",
|
||||
vp9: "libvpx-vp9",
|
||||
aac: "aac",
|
||||
opus: "libopus",
|
||||
mp3: "libmp3lame",
|
||||
};
|
||||
for (const target of ALL_TARGETS) {
|
||||
it(`${target} -> ${expected[target]}`, () => {
|
||||
delete process.env.SNAPOTTER_HW_ACCEL;
|
||||
expect(resolveEncoder(target)).toBe(expected[target]);
|
||||
});
|
||||
}
|
||||
|
||||
it("falls back to software when SNAPOTTER_HW_ACCEL is empty string", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "";
|
||||
expect(resolveEncoder("h264")).toBe("libx264");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEncoder: nvenc", () => {
|
||||
it("h264 -> h264_nvenc", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "nvenc";
|
||||
expect(resolveEncoder("h264")).toBe("h264_nvenc");
|
||||
});
|
||||
it("hevc -> hevc_nvenc", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "nvenc";
|
||||
expect(resolveEncoder("hevc")).toBe("hevc_nvenc");
|
||||
});
|
||||
it("av1 -> av1_nvenc", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "nvenc";
|
||||
expect(resolveEncoder("av1")).toBe("av1_nvenc");
|
||||
});
|
||||
it("vp9 has no nvenc entry -> software libvpx-vp9", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "nvenc";
|
||||
expect(resolveEncoder("vp9")).toBe("libvpx-vp9");
|
||||
});
|
||||
it("aac has no nvenc entry -> software aac", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "nvenc";
|
||||
expect(resolveEncoder("aac")).toBe("aac");
|
||||
});
|
||||
it("opus has no nvenc entry -> software libopus", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "nvenc";
|
||||
expect(resolveEncoder("opus")).toBe("libopus");
|
||||
});
|
||||
it("mp3 has no nvenc entry -> software libmp3lame", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "nvenc";
|
||||
expect(resolveEncoder("mp3")).toBe("libmp3lame");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEncoder: vaapi", () => {
|
||||
it("h264 -> h264_vaapi", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "vaapi";
|
||||
expect(resolveEncoder("h264")).toBe("h264_vaapi");
|
||||
});
|
||||
it("hevc -> hevc_vaapi", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "vaapi";
|
||||
expect(resolveEncoder("hevc")).toBe("hevc_vaapi");
|
||||
});
|
||||
it("av1 has no vaapi entry -> software libsvtav1", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "vaapi";
|
||||
expect(resolveEncoder("av1")).toBe("libsvtav1");
|
||||
});
|
||||
it("vp9 has no vaapi entry -> software libvpx-vp9", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "vaapi";
|
||||
expect(resolveEncoder("vp9")).toBe("libvpx-vp9");
|
||||
});
|
||||
it("mp3 has no vaapi entry -> software libmp3lame", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "vaapi";
|
||||
expect(resolveEncoder("mp3")).toBe("libmp3lame");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEncoder: unknown accel and casing", () => {
|
||||
it("unknown accel value falls back to software", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "quicksync";
|
||||
expect(resolveEncoder("h264")).toBe("libx264");
|
||||
expect(resolveEncoder("hevc")).toBe("libx265");
|
||||
});
|
||||
|
||||
it("lowercases the env value: NVENC selects the nvenc family", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "NVENC";
|
||||
expect(resolveEncoder("h264")).toBe("h264_nvenc");
|
||||
});
|
||||
|
||||
it("lowercases the env value: VaApi selects the vaapi family", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "VaApi";
|
||||
expect(resolveEncoder("hevc")).toBe("hevc_vaapi");
|
||||
});
|
||||
|
||||
it("does not partial-match: 'nvenc-extra' falls back to software", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "nvenc-extra";
|
||||
expect(resolveEncoder("h264")).toBe("libx264");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { isSafeMessageError, isToolInputError } from "@snapotter/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FfmpegProgress } from "../src/progress.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: 4242, killed: false });
|
||||
return { proc, stdout, stderr, kill };
|
||||
}
|
||||
|
||||
function mockSpawnReturns(child: FakeChild): void {
|
||||
vi.mocked(spawn).mockReturnValue(child.proc);
|
||||
}
|
||||
|
||||
async function loadRunFfmpeg() {
|
||||
const mod = await import("../src/ffmpeg.js");
|
||||
return mod.runFfmpeg;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mocked(spawn).mockReset();
|
||||
process.env.FFMPEG_PATH = "/fake/ffmpeg";
|
||||
delete process.env.SUBPROCESS_MEMORY_LIMIT_MB;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
delete process.env.FFMPEG_PATH;
|
||||
delete process.env.SUBPROCESS_MEMORY_LIMIT_MB;
|
||||
});
|
||||
|
||||
describe("runFfmpeg: argv construction", () => {
|
||||
it("wraps user args with the fixed flags and -progress pipe:1 in order", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["-i", "in.mp4", "-vf", "scale=2", "out.mp4"]);
|
||||
const [bin, args, opts] = vi.mocked(spawn).mock.calls[0];
|
||||
expect(bin).toBe("/fake/ffmpeg");
|
||||
expect(args).toEqual([
|
||||
"-hide_banner",
|
||||
"-nostdin",
|
||||
"-y",
|
||||
"-i",
|
||||
"in.mp4",
|
||||
"-vf",
|
||||
"scale=2",
|
||||
"out.mp4",
|
||||
"-progress",
|
||||
"pipe:1",
|
||||
]);
|
||||
expect(opts).toMatchObject({ stdio: ["ignore", "pipe", "pipe"] });
|
||||
child.proc.emit("close", 0, null);
|
||||
await p;
|
||||
});
|
||||
|
||||
it("keeps -progress pipe:1 as the LAST two args with empty user args", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg([]);
|
||||
const args = vi.mocked(spawn).mock.calls[0][1] as string[];
|
||||
expect(args).toEqual(["-hide_banner", "-nostdin", "-y", "-progress", "pipe:1"]);
|
||||
expect(args.slice(-2)).toEqual(["-progress", "pipe:1"]);
|
||||
child.proc.emit("close", 0, null);
|
||||
await p;
|
||||
});
|
||||
|
||||
it("routes argv through the memory-limit wrapper when SUBPROCESS_MEMORY_LIMIT_MB is set", async () => {
|
||||
process.env.SUBPROCESS_MEMORY_LIMIT_MB = "128";
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["-i", "in.mp4", "out.mp4"]);
|
||||
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",
|
||||
"131072", // 128 * 1024
|
||||
"/fake/ffmpeg",
|
||||
"-hide_banner",
|
||||
"-nostdin",
|
||||
"-y",
|
||||
"-i",
|
||||
"in.mp4",
|
||||
"out.mp4",
|
||||
"-progress",
|
||||
"pipe:1",
|
||||
]);
|
||||
child.proc.emit("close", 0, null);
|
||||
await p;
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFfmpeg: progress parsing from stdout", () => {
|
||||
it("fires onProgress with parsed values from a single block", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const seen: FfmpegProgress[] = [];
|
||||
const p = runFfmpeg(["out.mp4"], { onProgress: (x) => seen.push(x) });
|
||||
child.stdout.emit("data", Buffer.from("out_time_us=1500\nprogress=continue\n"));
|
||||
child.proc.emit("close", 0, null);
|
||||
await p;
|
||||
expect(seen).toEqual([
|
||||
{ outTimeMs: 2, done: false, raw: { out_time_us: "1500", progress: "continue" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits two blocks delivered in one chunk with correct times", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const times: Array<number | null> = [];
|
||||
const p = runFfmpeg(["out.mp4"], { onProgress: (x) => times.push(x.outTimeMs) });
|
||||
child.stdout.emit(
|
||||
"data",
|
||||
Buffer.from("out_time_us=1000000\nprogress=continue\nout_time_us=2000000\nprogress=end\n"),
|
||||
);
|
||||
child.proc.emit("close", 0, null);
|
||||
await p;
|
||||
expect(times).toEqual([1000, 2000]);
|
||||
});
|
||||
|
||||
it("marks done=true when the block reports progress=end", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const seen: FfmpegProgress[] = [];
|
||||
const p = runFfmpeg(["out.mp4"], { onProgress: (x) => seen.push(x) });
|
||||
child.stdout.emit("data", Buffer.from("out_time_us=4000000\nprogress=end\n"));
|
||||
child.proc.emit("close", 0, null);
|
||||
await p;
|
||||
expect(seen[0].done).toBe(true);
|
||||
expect(seen[0].outTimeMs).toBe(4000);
|
||||
});
|
||||
|
||||
it("holds a block until the newline terminating the progress= line arrives", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const times: Array<number | null> = [];
|
||||
const p = runFfmpeg(["out.mp4"], { onProgress: (x) => times.push(x.outTimeMs) });
|
||||
child.stdout.emit("data", Buffer.from("out_time_us=500000\nprogress=cont"));
|
||||
expect(times).toEqual([]); // no terminating newline yet
|
||||
child.stdout.emit("data", Buffer.from("inue\n"));
|
||||
expect(times).toEqual([500]);
|
||||
child.proc.emit("close", 0, null);
|
||||
await p;
|
||||
});
|
||||
|
||||
it("does not call onProgress when no progress= line is present", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const onProgress = vi.fn();
|
||||
const p = runFfmpeg(["out.mp4"], { onProgress });
|
||||
child.stdout.emit("data", Buffer.from("frame=10\nfps=25\n"));
|
||||
child.proc.emit("close", 0, null);
|
||||
await p;
|
||||
expect(onProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates an onProgress callback throw as a rejection and kills the process", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"], {
|
||||
onProgress: () => {
|
||||
throw new Error("callback exploded");
|
||||
},
|
||||
});
|
||||
child.stdout.emit("data", Buffer.from("out_time_us=1\nprogress=continue\n"));
|
||||
await expect(p).rejects.toThrow("callback exploded");
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFfmpeg: resolution and stderr capture", () => {
|
||||
it("resolves with the captured stderr tail on exit code 0", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"]);
|
||||
child.stderr.emit("data", Buffer.from("[silencedetect] "));
|
||||
child.stderr.emit("data", Buffer.from("silence_start: 1.5"));
|
||||
child.proc.emit("close", 0, null);
|
||||
await expect(p).resolves.toBe("[silencedetect] silence_start: 1.5");
|
||||
});
|
||||
|
||||
it("resolves with an empty string when no stderr was produced", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"]);
|
||||
child.proc.emit("close", 0, null);
|
||||
await expect(p).resolves.toBe("");
|
||||
});
|
||||
|
||||
it("keeps only the last 16KB of stderr (STDERR_RING_MAX)", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"]);
|
||||
const RING = 16 * 1024;
|
||||
child.stderr.emit("data", Buffer.from("X".repeat(RING)));
|
||||
child.stderr.emit("data", Buffer.from("TAIL_MARKER"));
|
||||
child.proc.emit("close", 0, null);
|
||||
const tail = await p;
|
||||
expect(tail.length).toBe(RING); // (RING + 11) sliced back to RING
|
||||
expect(tail.endsWith("TAIL_MARKER")).toBe(true);
|
||||
// The first 11 'X' chars were pushed out of the ring.
|
||||
expect(tail.startsWith("X".repeat(RING))).toBe(false);
|
||||
expect(tail).toBe(`${"X".repeat(RING - "TAIL_MARKER".length)}TAIL_MARKER`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFfmpeg: non-zero exit", () => {
|
||||
it("rejects with 'ffmpeg exited <code>: <tail>' including the last 2000 chars", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"]);
|
||||
child.stderr.emit("data", Buffer.from("Unknown encoder 'libbogus'"));
|
||||
child.proc.emit("close", 3, null);
|
||||
await expect(p).rejects.toThrow("ffmpeg exited 3: Unknown encoder 'libbogus'");
|
||||
});
|
||||
|
||||
it("uses the signal name when exit code is null", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"]);
|
||||
child.stderr.emit("data", Buffer.from("killed"));
|
||||
child.proc.emit("close", null, "SIGSEGV");
|
||||
await expect(p).rejects.toThrow("ffmpeg exited SIGSEGV: killed");
|
||||
});
|
||||
|
||||
it("marks the rejection as a tool input error when stderr matches an input pattern", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"]);
|
||||
child.stderr.emit("data", Buffer.from("moov atom not found"));
|
||||
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 (non-input) failure", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"]);
|
||||
child.stderr.emit("data", Buffer.from("Conversion failed! generic"));
|
||||
child.proc.emit("close", 1, null);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(isToolInputError(caught)).toBe(false);
|
||||
});
|
||||
|
||||
it("truncates the reject message tail to the last 2000 chars", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"]);
|
||||
child.stderr.emit("data", Buffer.from("Z".repeat(5000)));
|
||||
child.proc.emit("close", 1, null);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
const msg = (caught as Error).message;
|
||||
const prefix = "ffmpeg exited 1: ";
|
||||
expect(msg.slice(prefix.length).length).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFfmpeg: timeout", () => {
|
||||
it("rejects with a SafeError (constant message, operational, code=timeout) and SIGKILLs", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"], { timeoutMs: 5000 });
|
||||
const assertion = expect(p).rejects.toSatisfy((e: unknown) => {
|
||||
const err = e as Error & { kind?: string; code?: string };
|
||||
return (
|
||||
err instanceof Error &&
|
||||
err.message === "ffmpeg timed out" &&
|
||||
isSafeMessageError(err) &&
|
||||
err.kind === "operational" &&
|
||||
err.code === "timeout"
|
||||
);
|
||||
});
|
||||
vi.advanceTimersByTime(5000);
|
||||
await assertion;
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
});
|
||||
|
||||
it("does not fire before the timeout elapses", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"], { timeoutMs: 5000 });
|
||||
vi.advanceTimersByTime(4999);
|
||||
// still pending: complete it cleanly and expect resolution, not the timeout rejection.
|
||||
child.proc.emit("close", 0, null);
|
||||
await expect(p).resolves.toBe("");
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not arm a timer when timeoutMs is undefined", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"]);
|
||||
vi.advanceTimersByTime(10 * 60 * 1000);
|
||||
child.proc.emit("close", 0, null);
|
||||
await expect(p).resolves.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFfmpeg: abort signal", () => {
|
||||
it("rejects immediately with 'Canceled' when the signal is already aborted", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const p = runFfmpeg(["out.mp4"], { signal: controller.signal });
|
||||
await expect(p).rejects.toThrow("Canceled");
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
});
|
||||
|
||||
it("rejects with 'Canceled' when aborted mid-run", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const controller = new AbortController();
|
||||
const p = runFfmpeg(["out.mp4"], { signal: controller.signal });
|
||||
controller.abort();
|
||||
await expect(p).rejects.toThrow("Canceled");
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
});
|
||||
|
||||
it("resolves normally when the signal never aborts", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const controller = new AbortController();
|
||||
const p = runFfmpeg(["out.mp4"], { signal: controller.signal });
|
||||
child.proc.emit("close", 0, null);
|
||||
await expect(p).resolves.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFfmpeg: spawn 'error' event and settle-once", () => {
|
||||
it("rejects with the spawn error", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"]);
|
||||
child.proc.emit("error", new Error("spawn ENOENT"));
|
||||
await expect(p).rejects.toThrow("spawn ENOENT");
|
||||
});
|
||||
|
||||
it("ignores a later close(0) after a failure has already settled the promise", async () => {
|
||||
const child = makeChild();
|
||||
mockSpawnReturns(child);
|
||||
const runFfmpeg = await loadRunFfmpeg();
|
||||
const p = runFfmpeg(["out.mp4"]);
|
||||
child.proc.emit("error", new Error("first failure"));
|
||||
child.proc.emit("close", 0, null); // must be a no-op
|
||||
await expect(p).rejects.toThrow("first failure");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFfmpeg: missing binary", () => {
|
||||
it("rejects with a clear message and never spawns when ffmpeg is unavailable", async () => {
|
||||
vi.doMock("../src/binaries.js", () => ({
|
||||
resolveFfmpeg: () => null,
|
||||
resolveFfprobe: () => null,
|
||||
}));
|
||||
const { runFfmpeg } = await import("../src/ffmpeg.js");
|
||||
await expect(runFfmpeg(["out.mp4"])).rejects.toThrow(
|
||||
"ffmpeg binary not found (set FFMPEG_PATH or install ffmpeg)",
|
||||
);
|
||||
expect(vi.mocked(spawn)).not.toHaveBeenCalled();
|
||||
vi.doUnmock("../src/binaries.js");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
|
||||
|
||||
const DEJAVU = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
|
||||
const ARIAL = "/System/Library/Fonts/Supplemental/Arial.ttf";
|
||||
const HELVETICA = "/System/Library/Fonts/Helvetica.ttc";
|
||||
|
||||
describe("resolveFontFile", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mocked(existsSync).mockReset();
|
||||
delete process.env.SNAPOTTER_FONT_FILE;
|
||||
delete process.env.SNAPOTTER_FONT_FAMILY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.SNAPOTTER_FONT_FILE;
|
||||
delete process.env.SNAPOTTER_FONT_FAMILY;
|
||||
});
|
||||
|
||||
it("env override wins without touching the filesystem, default family 'Sans'", async () => {
|
||||
process.env.SNAPOTTER_FONT_FILE = "/custom/MyFont.ttf";
|
||||
const { resolveFontFile } = await import("../src/fonts.js");
|
||||
expect(resolveFontFile()).toEqual({ file: "/custom/MyFont.ttf", family: "Sans" });
|
||||
expect(vi.mocked(existsSync)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("env override uses SNAPOTTER_FONT_FAMILY when set", async () => {
|
||||
process.env.SNAPOTTER_FONT_FILE = "/custom/MyFont.ttf";
|
||||
process.env.SNAPOTTER_FONT_FAMILY = "My Font";
|
||||
const { resolveFontFile } = await import("../src/fonts.js");
|
||||
expect(resolveFontFile()).toEqual({ file: "/custom/MyFont.ttf", family: "My Font" });
|
||||
});
|
||||
|
||||
it("returns the first existing candidate (DejaVu) with its exact family", async () => {
|
||||
vi.mocked(existsSync).mockImplementation((p) => p === DEJAVU);
|
||||
const { resolveFontFile } = await import("../src/fonts.js");
|
||||
expect(resolveFontFile()).toEqual({ file: DEJAVU, family: "DejaVu Sans" });
|
||||
});
|
||||
|
||||
it("falls through to Arial when DejaVu is missing", async () => {
|
||||
vi.mocked(existsSync).mockImplementation((p) => p === ARIAL);
|
||||
const { resolveFontFile } = await import("../src/fonts.js");
|
||||
expect(resolveFontFile()).toEqual({ file: ARIAL, family: "Arial" });
|
||||
});
|
||||
|
||||
it("falls through to Helvetica when DejaVu and Arial are missing", async () => {
|
||||
vi.mocked(existsSync).mockImplementation((p) => p === HELVETICA);
|
||||
const { resolveFontFile } = await import("../src/fonts.js");
|
||||
expect(resolveFontFile()).toEqual({ file: HELVETICA, family: "Helvetica" });
|
||||
});
|
||||
|
||||
it("returns the earliest match when several candidates exist (order matters)", async () => {
|
||||
// Both Arial and Helvetica present, DejaVu absent: Arial comes first.
|
||||
vi.mocked(existsSync).mockImplementation((p) => p === ARIAL || p === HELVETICA);
|
||||
const { resolveFontFile } = await import("../src/fonts.js");
|
||||
expect(resolveFontFile()).toEqual({ file: ARIAL, family: "Arial" });
|
||||
});
|
||||
|
||||
it("returns null when no candidate file exists", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
const { resolveFontFile } = await import("../src/fonts.js");
|
||||
expect(resolveFontFile()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { isToolInputError } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markIfInputError } from "../src/ffmpeg.js";
|
||||
|
||||
/**
|
||||
* markIfInputError tags the error with the isToolInputError marker when stderr
|
||||
* matches one of INPUT_ERROR_PATTERNS. One matching string per pattern kills
|
||||
* the corresponding array-element mutant; a non-matching string proves the
|
||||
* marker is NOT applied indiscriminately (kills the `.some(...) ? ... : err`
|
||||
* conditional and the boolean-return mutants).
|
||||
*/
|
||||
describe("markIfInputError: matches each INPUT_ERROR_PATTERN", () => {
|
||||
const matches: Array<[string, string]> = [
|
||||
["received no packets", "Output file #0 does not contain any stream: received no packets"],
|
||||
["invalid data found when processing input", "pipe:: Invalid data found when processing input"],
|
||||
["could not find codec parameters", "Could not find codec parameters for stream 0 (Video)"],
|
||||
["moov atom not found", "[mov,mp4] moov atom not found\nError opening input file"],
|
||||
];
|
||||
|
||||
for (const [label, stderr] of matches) {
|
||||
it(`marks input error for "${label}"`, () => {
|
||||
const err = markIfInputError(new Error("ffmpeg exited 1"), stderr);
|
||||
expect(isToolInputError(err)).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("markIfInputError: does not mark unrelated stderr", () => {
|
||||
it("leaves a generic ffmpeg error unmarked", () => {
|
||||
const err = markIfInputError(new Error("boom"), "Conversion failed! Unknown encoder 'libfoo'");
|
||||
expect(isToolInputError(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves empty stderr unmarked", () => {
|
||||
const err = markIfInputError(new Error("boom"), "");
|
||||
expect(isToolInputError(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markIfInputError: case-insensitive matching and identity", () => {
|
||||
it("matches regardless of case (uppercase 'MOOV ATOM NOT FOUND')", () => {
|
||||
const err = markIfInputError(new Error("x"), "MOOV ATOM NOT FOUND");
|
||||
expect(isToolInputError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns the same error object it was given", () => {
|
||||
const original = new Error("x");
|
||||
expect(markIfInputError(original, "moov atom not found")).toBe(original);
|
||||
});
|
||||
|
||||
it("returns the same object when it does not match (no copy)", () => {
|
||||
const original = new Error("x");
|
||||
expect(markIfInputError(original, "nothing matches here")).toBe(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseProgressBlock } from "../src/progress.js";
|
||||
|
||||
describe("parseProgressBlock: out_time_us path", () => {
|
||||
it("rounds microseconds to milliseconds (1500us -> 2ms)", () => {
|
||||
const p = parseProgressBlock("out_time_us=1500\nprogress=continue");
|
||||
expect(p.outTimeMs).toBe(2);
|
||||
});
|
||||
|
||||
it("rounds down below the .5 boundary (2499us -> 2ms)", () => {
|
||||
expect(parseProgressBlock("out_time_us=2499\nprogress=continue").outTimeMs).toBe(2);
|
||||
});
|
||||
|
||||
it("rounds up at the .5 boundary (2500us -> 3ms)", () => {
|
||||
expect(parseProgressBlock("out_time_us=2500\nprogress=continue").outTimeMs).toBe(3);
|
||||
});
|
||||
|
||||
it("rounds 500us up to 1ms", () => {
|
||||
expect(parseProgressBlock("out_time_us=500\nprogress=continue").outTimeMs).toBe(1);
|
||||
});
|
||||
|
||||
it("rounds 499us down to 0ms (not null)", () => {
|
||||
expect(parseProgressBlock("out_time_us=499\nprogress=continue").outTimeMs).toBe(0);
|
||||
});
|
||||
|
||||
it("divides by 1000, not 1_000_000 (1_000_000us -> 1000ms)", () => {
|
||||
expect(parseProgressBlock("out_time_us=1000000\nprogress=continue").outTimeMs).toBe(1000);
|
||||
});
|
||||
|
||||
it("prefers out_time_us over out_time_ms when both are present", () => {
|
||||
// us=1_000_000 -> 1000ms; if it wrongly used ms=999_000 it would be 999.
|
||||
const p = parseProgressBlock("out_time_us=1000000\nout_time_ms=999000\nprogress=continue");
|
||||
expect(p.outTimeMs).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseProgressBlock: out_time_ms path (microseconds despite the name)", () => {
|
||||
it("treats out_time_ms as microseconds (3_000_000 -> 3000ms)", () => {
|
||||
expect(parseProgressBlock("out_time_ms=3000000\nprogress=end").outTimeMs).toBe(3000);
|
||||
});
|
||||
|
||||
it("rounds the out_time_ms path too (1500 -> 2)", () => {
|
||||
expect(parseProgressBlock("out_time_ms=1500\nprogress=continue").outTimeMs).toBe(2);
|
||||
});
|
||||
|
||||
it("uses out_time_ms only when out_time_us is absent", () => {
|
||||
// Presence of out_time_us (even NaN) takes the us branch, which yields null here.
|
||||
const p = parseProgressBlock("out_time_us=notanumber\nout_time_ms=5000000\nprogress=continue");
|
||||
expect(p.outTimeMs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseProgressBlock: non-finite and missing values", () => {
|
||||
it("returns null for a non-numeric out_time_us", () => {
|
||||
expect(parseProgressBlock("out_time_us=abc\nprogress=continue").outTimeMs).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for Infinity out_time_us", () => {
|
||||
expect(parseProgressBlock("out_time_us=Infinity\nprogress=continue").outTimeMs).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a non-numeric out_time_ms", () => {
|
||||
expect(parseProgressBlock("out_time_ms=NaN\nprogress=continue").outTimeMs).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when neither time key is present", () => {
|
||||
expect(parseProgressBlock("frame=10\nprogress=continue").outTimeMs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseProgressBlock: done flag", () => {
|
||||
it("is true only when progress === 'end'", () => {
|
||||
expect(parseProgressBlock("progress=end").done).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for progress=continue", () => {
|
||||
expect(parseProgressBlock("progress=continue").done).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for any other progress value", () => {
|
||||
expect(parseProgressBlock("progress=ended").done).toBe(false);
|
||||
expect(parseProgressBlock("progress=START").done).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when there is no progress key at all", () => {
|
||||
expect(parseProgressBlock("frame=10").done).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseProgressBlock: key/value splitting (idx > 0)", () => {
|
||||
it("skips a line whose '=' is at position 0", () => {
|
||||
const p = parseProgressBlock("=weird\nkey=val\nprogress=end");
|
||||
expect(p.raw).toEqual({ key: "val", progress: "end" });
|
||||
expect(Object.keys(p.raw)).not.toContain("");
|
||||
});
|
||||
|
||||
it("skips a line with no '=' at all", () => {
|
||||
const p = parseProgressBlock("nolineeq\nfoo=bar\nprogress=end");
|
||||
expect(p.raw).toEqual({ foo: "bar", progress: "end" });
|
||||
expect(Object.keys(p.raw)).not.toContain("nolineeq");
|
||||
});
|
||||
|
||||
it("splits only on the FIRST '=' so values may contain '='", () => {
|
||||
const p = parseProgressBlock("k=a=b\nprogress=continue");
|
||||
expect(p.raw.k).toBe("a=b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseProgressBlock: raw carries trimmed key/value", () => {
|
||||
it("trims whitespace around both key and value", () => {
|
||||
const p = parseProgressBlock(" frame = 10 \nprogress=continue");
|
||||
expect(p.raw).toEqual({ frame: "10", progress: "continue" });
|
||||
});
|
||||
|
||||
it("stores raw values as strings, not numbers", () => {
|
||||
const p = parseProgressBlock("out_time_us=1500\nprogress=continue");
|
||||
expect(p.raw.out_time_us).toBe("1500");
|
||||
expect(typeof p.raw.out_time_us).toBe("string");
|
||||
});
|
||||
|
||||
it("returns an empty raw map for an empty block", () => {
|
||||
const p = parseProgressBlock("");
|
||||
expect(p.raw).toEqual({});
|
||||
expect(p.outTimeMs).toBeNull();
|
||||
expect(p.done).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user