Files
UHD_Media_Tool/packages/adapters/src/fs/fs.test.ts
T
rootandClaude Fable 5.1 304feea141 chore(deno): migrate toolchain from Node 24 + pnpm to Deno 2.9.7 + TypeScript 7.0.2 (W1.5)
- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock;
  remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports)
- tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions),
  test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types
- tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl
- server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting;
  drop @hono/node-server, tsx, esbuild build.mjs
- logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests
- relative imports get explicit extensions (sites/mp-sync paths left for follow-up)
- crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas
- single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml
- docs: README, runbook, ADR-001 superseded, plan §1 validation row

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-27 18:53:06 +08:00

264 lines
11 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,
moveSafe,
scanIncremental,
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((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<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" });
}
});
});