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:
SnapOtter
2026-07-24 17:36:57 +08:00
committed by GitHub
parent eee6f0d470
commit 301e6eb01a
129 changed files with 30900 additions and 276 deletions
+3
View File
@@ -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"
},
+13
View File
@@ -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");
});
});
+423
View File
@@ -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");
});
});
+409
View File
@@ -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");
});
});
+68
View File
@@ -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);
});
});
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
},
});