import { execFileSync } from "node:child_process"; import { chmod, readFile, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createTempTree, findCrossDeviceBase, type TempTree } from "@umt/testkit"; import { afterEach, describe, expect, it } from "vitest"; import { checkRoot, copyWithVerify, crossDeviceMove, diskUsage, FsError, ffprobe, moveSafe, scanIncremental, statSafe, sweepStalePartials, } from "./index.ts"; const trees: TempTree[] = []; async function tree(...a: Parameters) { const t = await createTempTree(...a); trees.push(t); return t; } afterEach(async () => { await Promise.all(trees.splice(0).map((t) => t.cleanup())); }); const xdevBase = findCrossDeviceBase(); const isRoot = process.getuid?.() === 0; const hasFfmpeg = (() => { try { execFileSync("ffmpeg", ["-version"], { stdio: "ignore" }); return true; } catch { return false; } })(); describe("copyWithVerify", () => { it.each(["size", "sha256", "sample"] as const)("%s 校验后原子提交,保留 mtime,无残留临时文件", async (hash) => { const t = await tree({ "src/a.mkv": 3 * 1024 * 1024 + 17, "dst/": null }); const r = await copyWithVerify(t.path("src/a.mkv"), t.path("dst/a.mkv"), { hash, sampleChunkBytes: 4096 }); expect(r.bytes).toBe(3 * 1024 * 1024 + 17); expect(r.verifiedBy).toBe(hash); if (hash === "sha256") expect(r.sha256).toMatch(/^[0-9a-f]{64}$/); expect((await readFile(t.path("dst/a.mkv"))).equals(await readFile(t.path("src/a.mkv")))).toBe(true); expect(Math.round((await stat(t.path("dst/a.mkv"))).mtimeMs)).toBe( Math.round((await stat(t.path("src/a.mkv"))).mtimeMs), ); expect(await sweepStalePartials(t.path("dst"), -1)).toEqual([]); }); it("目标已存在 → EEXIST,不覆盖", async () => { const t = await tree({ "a.mkv": "new", "b.mkv": "old" }); const e = await copyWithVerify(t.path("a.mkv"), t.path("b.mkv")).catch((x) => x); expect(e).toBeInstanceOf(FsError); expect(e.code).toBe("EEXIST"); expect(await readFile(t.path("b.mkv"), "utf8")).toBe("old"); }); it("父目录不存在默认不创建;中止信号清理临时文件", async () => { const t = await tree({ "a.mkv": 1024 * 1024 }); expect((await copyWithVerify(t.path("a.mkv"), t.path("lib/x/a.mkv")).catch((e) => e)).code).toBe("ENOENT"); const ac = new AbortController(); ac.abort(); await t.write("lib/.keep", ""); const e = await copyWithVerify(t.path("a.mkv"), t.path("lib/a.mkv"), { signal: ac.signal }).catch((x) => x); expect(e.code).toBe("ABORTED"); expect(await sweepStalePartials(t.path("lib"), -1)).toEqual([]); await expect(stat(t.path("lib/a.mkv"))).rejects.toThrow(); }); it("残留临时文件清理只删过期的 .uhd-partial", async () => { const t = await tree({ "lib/.x.mkv.1.abc.uhd-partial": "x", "lib/keep.mkv": "k" }); const removed = await sweepStalePartials(t.path("lib"), -1); expect(removed).toEqual([t.path("lib/.x.mkv.1.abc.uhd-partial")]); expect(await sweepStalePartials(t.path("lib"), 60_000)).toEqual([]); }); }); describe("moveSafe / crossDeviceMove", () => { it("同设备移动;目标存在返回 exists 且不覆盖", async () => { const t = await tree({ "a.mkv": "A", "b.mkv": "B", "c.mkv": "C" }); expect(await moveSafe(t.path("a.mkv"), t.path("moved.mkv"))).toEqual({ status: "moved" }); await expect(stat(t.path("a.mkv"))).rejects.toThrow(); expect(await moveSafe(t.path("b.mkv"), t.path("c.mkv"))).toEqual({ status: "exists" }); expect(await readFile(t.path("c.mkv"), "utf8")).toBe("C"); const e = await moveSafe(t.path("nope.mkv"), t.path("x.mkv")).catch((x) => x); expect(e.code).toBe("ENOENT"); }); it("link 成功但 unlink 前中断(同 inode)→ 续跑补完删除", async () => { const t = await tree({ "a.mkv": "A" }); const { link } = await import("node:fs/promises"); await link(t.path("a.mkv"), t.path("b.mkv")); expect(await moveSafe(t.path("a.mkv"), t.path("b.mkv"))).toEqual({ status: "moved" }); await expect(stat(t.path("a.mkv"))).rejects.toThrow(); }); it.skipIf(!xdevBase)("跨设备:moveSafe 返回 exdev 不降级;crossDeviceMove 三步完成", async () => { // xdevBase 为 null 时跳过:本机找不到与 tmpdir 不同设备的可写目录 const a = await tree({ "a.mkv": 256 * 1024 }); const b = await tree({ "lib/": null }, { base: xdevBase! }); expect(await moveSafe(a.path("a.mkv"), b.path("lib/a.mkv"))).toEqual({ status: "exdev" }); await expect(stat(a.path("a.mkv"))).resolves.toBeTruthy(); const r = await crossDeviceMove(a.path("a.mkv"), b.path("lib/a.mkv"), { hash: "sha256" }); expect(r.status).toBe("completed"); expect(r.steps.map((s) => `${s.step}:${s.status}`)).toEqual(["copy:done", "verify:done", "delete_source:done"]); await expect(stat(a.path("a.mkv"))).rejects.toThrow(); }); it("crossDeviceMove 可重入:目标已一致则跳过复制;源已删且上次已验证 → completed;目标不同 → failed 不删源", async () => { const t = await tree({ "src/a.mkv": "same", "dst/a.mkv": "same", "src/b.mkv": "bbbb", "dst/b.mkv": "cccc" }); const r1 = await crossDeviceMove(t.path("src/a.mkv"), t.path("dst/a.mkv"), { hash: "sha256" }); expect(r1.steps.map((s) => `${s.step}:${s.status}`)).toEqual(["copy:skipped", "verify:done", "delete_source:done"]); const r2 = await crossDeviceMove(t.path("src/a.mkv"), t.path("dst/a.mkv"), { previous: r1.steps }); expect(r2.status).toBe("completed"); const r3 = await crossDeviceMove(t.path("src/b.mkv"), t.path("dst/b.mkv"), { hash: "sha256" }); expect(r3.status).toBe("failed"); expect(await readFile(t.path("src/b.mkv"), "utf8")).toBe("bbbb"); const r4 = await crossDeviceMove(t.path("src/none.mkv"), t.path("dst/b.mkv")); expect(r4.status).toBe("attention"); }); it.skipIf(isRoot)("删源失败 → attention(以 root 运行时跳过:root 无视目录权限)", async () => { const t = await tree({ "src/a.mkv": "A", "dst/": null }); await chmod(t.path("src"), 0o555); try { const r = await crossDeviceMove(t.path("src/a.mkv"), t.path("dst/a.mkv")); expect(r.status).toBe("attention"); expect(r.steps.at(-1)).toMatchObject({ step: "delete_source", status: "failed" }); } finally { await chmod(t.path("src"), 0o755); } }); }); describe("statSafe / checkRoot / diskUsage", () => { it("区分正常、缺失、根离线", async () => { const t = await tree({ "movies/a.mkv": "A" }, { sentinel: true }); const ok = await statSafe(t.path("movies/a.mkv"), { root: t.root }); expect(ok.kind).toBe("ok"); if (ok.kind === "ok") expect(ok.facts.size).toBe(1); expect((await statSafe(t.path("movies/b.mkv"), { root: t.root })).kind).toBe("missing"); expect(await checkRoot(t.root)).toMatchObject({ kind: "online" }); await unlink(t.path(".uhd-root")); expect(await statSafe(t.path("movies/b.mkv"), { root: t.root })).toMatchObject({ kind: "root_offline", reason: "sentinel_missing", }); expect(await checkRoot(join(t.root, "gone"))).toMatchObject({ kind: "offline", reason: "root_missing" }); }); it.skipIf(isRoot)("权限拒绝 → permission_denied(root 运行时跳过)", async () => { const t = await tree({ "locked/a.mkv": "A" }); await chmod(t.path("locked"), 0o000); try { expect((await statSafe(t.path("locked/a.mkv"))).kind).toBe("permission_denied"); } finally { await chmod(t.path("locked"), 0o755); } }); it("磁盘用量:statfs 成功给出比例,路径不存在返回 unknown", async () => { const t = await tree(); const u = await diskUsage(t.root); expect(u.status).toBe("ok"); if (u.status === "ok") { expect(u.totalBytes).toBeGreaterThan(0); expect(u.usedRatio).toBeGreaterThanOrEqual(0); expect(u.usedRatio).toBeLessThanOrEqual(1); } expect(await diskUsage(join(t.root, "nope"))).toMatchObject({ status: "unknown", code: "ENOENT" }); }); }); describe("scanIncremental", () => { it("按 I/O 上限分批,游标续扫覆盖全部且不重复", async () => { const spec: Record = {}; for (const d of ["A", "B", "C"]) for (let i = 0; i < 4; i++) spec[`${d}/s${i}/f${i}.mkv`] = `${d}${i}`; spec["B/.x.uhd-partial"] = "p"; const t = await tree(spec, { sentinel: true }); const seen: string[] = []; let cursor: string | null = null; let batches = 0; do { const r = await scanIncremental(t.root, { cursor, maxStats: 5 }); expect(r.statCount).toBeLessThanOrEqual(5); seen.push(...r.entries.map((e) => e.relPath)); cursor = r.cursor; batches++; if (r.status === "complete") break; expect(r.status).toBe("partial"); } while (batches < 10); expect(batches).toBe(3); expect(seen).toEqual([...seen].sort()); expect(new Set(seen).size).toBe(12); expect(seen).not.toContain("B/.x.uhd-partial"); }); it("标注 new/changed/unchanged;根离线不扫描;符号链接不跟随", async () => { const t = await tree({ "a.mkv": "A", "b.mkv": "B", "c.mkv": "C" }, { sentinel: true }); await symlink(t.path("a.mkv"), t.path("d.mkv")); const first = await scanIncremental(t.root); const prev = new Map(first.entries.map((e) => [e.relPath, e])); await writeFile(t.path("b.mkv"), "BBBB"); const second = await scanIncremental(t.root, { previous: (p) => prev.get(p) }); const byPath = Object.fromEntries(second.entries.map((e) => [e.relPath, e])); expect(byPath["a.mkv"]!.change).toBe("unchanged"); expect(byPath["b.mkv"]!.change).toBe("changed"); expect(byPath["d.mkv"]!.isSymlink).toBe(true); await rm(t.path(".uhd-root")); const off = await scanIncremental(t.root); expect(off.status).toBe("root_offline"); expect(off.entries).toEqual([]); }); }); describe("ffprobe", () => { it("可执行文件不存在 / 非媒体文件 → unknown,不抛", async () => { const t = await tree({ "a.txt": "hello" }); expect(await ffprobe(t.path("a.txt"), { bin: "/nonexistent/ffprobe" })).toEqual({ status: "unknown", reason: "ffprobe_unavailable", }); const r = await ffprobe(t.path("a.txt")); expect(r.status).toBe("unknown"); }); it.skipIf(!hasFfmpeg)("真实小样片:读出分辨率与音轨(本机无 ffmpeg 时跳过)", async () => { const t = await tree(); const out = t.path("s.mkv"); execFileSync("ffmpeg", [ "-v", "error", "-f", "lavfi", "-i", "testsrc=size=320x240:rate=5", "-f", "lavfi", "-i", "sine=frequency=440", "-t", "1", "-c:v", "mpeg4", "-c:a", "ac3", "-metadata:s:a:0", "language=jpn", out, ]); const r = await ffprobe(out); expect(r.status).toBe("ok"); if (r.status === "ok") { expect(r.video[0]).toMatchObject({ codec: "mpeg4", width: 320, height: 240 }); expect(r.video[0]!.hdr).toBeUndefined(); expect(r.audio[0]).toMatchObject({ codec: "ac3", language: "jpn" }); } }); });