Files
UHD_Media_Tool/packages/adapters/src/fs/fs.test.ts
T
rootandClaude Opus 5.5 f9c20c67dc perf(fs): 1MiB stream chunks, full sha256 via sha256sum subprocess, walk cap
- copy streams use 1MiB highWaterMark
- forced full sha256 uses Deno.Command(sha256sum), falls back to in-process stream;
  method recorded as hashVia and counted
- per-file organize.transfer timing log
- ORGANIZE_WALK_MAX_FILES caps manual path walks; dryRun returns truncated

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 22:12:16 +08:00

306 lines
12 KiB
TypeScript

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,
filesMatchSha256,
moveSafe,
resetSha256Probe,
scanIncremental,
sha256File,
sha256Full,
sha256Stats,
statSafe,
sweepStalePartials,
} from "./index.ts";
const trees: TempTree[] = [];
async function tree(...a: Parameters<typeof createTempTree>) {
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(["sha256sum", "stream"]).toContain(r.sha256Via);
}
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("全量 sha256(子进程优先,回退进程内)", () => {
const hasSha256sum = (() => {
try {
execFileSync("sha256sum", ["--version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
})();
it("与进程内流式结果一致;记录所用方式与计数", async () => {
resetSha256Probe();
const t = await tree({ "a.bin": 2 * 1024 * 1024 + 5, "b.bin": 2 * 1024 * 1024 + 5 });
const before = sha256Stats().counts;
const r = await sha256Full(t.path("a.bin"));
expect(r.digest).toBe(await sha256File(t.path("a.bin")));
expect(r.via).toBe(hasSha256sum ? "sha256sum" : "stream");
expect(sha256Stats().counts[r.via]).toBe(before[r.via] + 1);
// 同尺寸不同内容:不一致
const m = await filesMatchSha256(t.path("a.bin"), t.path("b.bin"));
expect(m.match).toBe(false);
expect(m.via).toBe(r.via);
});
it("文件不存在 → FsError", async () => {
resetSha256Probe();
await expect(sha256Full("/nonexistent/umt-x.bin")).rejects.toBeInstanceOf(Error);
});
});
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");
// 默认 hash=size 时,已存在的同尺寸异内容目标也必须判为不一致,不得删源
const r5 = await crossDeviceMove(t.path("src/b.mkv"), t.path("dst/b.mkv"));
expect(r5.status).toBe("failed");
expect(await readFile(t.path("src/b.mkv"), "utf8")).toBe("bbbb");
});
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<string, string> = {};
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" });
}
});
});