- 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]>
264 lines
11 KiB
TypeScript
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" });
|
|
}
|
|
});
|
|
});
|