// A report's deliverables (clips/, every share-*/) behind the per-project // switch (release 17, slice U2): `storage.deliverables` in the manifest, // deliverableDir for the writers, moveDeliverables for the move, and the // readers that must follow a moved batch. // // Every call names its own roots (`reportsRoot`, `mediaRoot`), as // storage.test.mjs does, so nothing touches the process's REPORTS_ROOT. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; import { execFileSync, spawn, spawnSync } from "node:child_process"; import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { namesProject, pipelineProcessesFor } from "./busy.mjs"; import { listBatches, sharedIdsIn } from "./deliver.mjs"; import { updateStorage } from "./manifest.mjs"; import { deliverableDir, deliverableNames, deliverablesMode, deliverablesProblems, deliverablesState, isDeliverableName, moveDeliverables, } from "./storage.mjs"; /** A reports root with one report project, its deliverables, and a media root beside it. */ async function world({ manifest = { title: "fixture", timeline: [] }, deliverables = true } = {}) { const base = await mkdtemp(path.join(tmpdir(), "umtool-deliverables-")); const reportsRoot = path.join(base, "reports"); const mediaRoot = path.join(base, "media"); const projectDir = path.join(reportsRoot, "folder", "proj"); await mkdir(projectDir, { recursive: true }); await mkdir(mediaRoot); await writeFile(path.join(projectDir, "video.manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); if (deliverables) { await mkdir(path.join(projectDir, "clips")); await writeFile(path.join(projectDir, "clips", "a01.mp4"), Buffer.alloc(3000, 1)); await mkdir(path.join(projectDir, "share-first", "orig", "A"), { recursive: true }); await writeFile(path.join(projectDir, "share-first", "orig", "A", "a01_2025-01-01_x.mp4"), Buffer.alloc(1000, 2)); await writeFile(path.join(projectDir, "share-first", "LIST.md"), "\n"); } const roots = { reportsRoot, mediaRoot }; const untiered = { reportsRoot, mediaRoot: reportsRoot }; const mirror = path.join(mediaRoot, "folder", "proj"); return { base, reportsRoot, mediaRoot, projectDir, roots, untiered, mirror, done: () => rm(base, { recursive: true, force: true }) }; } const kind = async (p) => { const st = await lstat(p).catch(() => null); if (!st) return "missing"; return st.isSymbolicLink() ? "link" : st.isDirectory() ? "dir" : "file"; }; const readManifest = async (w) => JSON.parse(await readFile(path.join(w.projectDir, "video.manifest.json"), "utf8")); const writeMode = (dir, mode) => updateStorage(dir, { deliverables: mode }); // --------------------------------------------------------------------------- // The switch, and the names // --------------------------------------------------------------------------- test("storage.deliverables: absent is local; the two values; anything else is an error", async () => { const w = await world({ deliverables: false }); try { assert.equal((await deliverablesMode(w.projectDir)).mode, "local"); await writeFile(path.join(w.projectDir, "video.manifest.json"), JSON.stringify({ storage: { deliverables: "media" } })); assert.equal((await deliverablesMode(w.projectDir)).mode, "media"); await writeFile(path.join(w.projectDir, "video.manifest.json"), JSON.stringify({ storage: { deliverables: "platter" } })); const bad = await deliverablesMode(w.projectDir); assert.equal(bad.mode, null); assert.match(bad.error, /"platter" — it is "local" or "media"/); } finally { await w.done(); } }); test("updateStorage writes the switch, keeps the rest, and does not rewrite a no-op", async () => { const w = await world({ manifest: { title: "kept", storage: { note: "kept too" } }, deliverables: false }); try { await assert.rejects(updateStorage(w.projectDir, { deliverables: "platter" }), /"local" or "media"/); const r = await updateStorage(w.projectDir, { deliverables: "media" }); assert.equal(r.changed, true); const m = await readManifest(w); assert.deepEqual(m.storage, { note: "kept too", deliverables: "media" }); assert.equal(m.title, "kept"); // The CLI's formatting: two-space indent and a trailing newline. assert.match(await readFile(path.join(w.projectDir, "video.manifest.json"), "utf8"), /\n "storage": \{\n[\s\S]*\}\n$/); const mtime = (await stat(path.join(w.projectDir, "video.manifest.json"))).mtimeMs; const again = await updateStorage(w.projectDir, { deliverables: "media" }); assert.equal(again.changed, false); assert.equal((await stat(path.join(w.projectDir, "video.manifest.json"))).mtimeMs, mtime); } finally { await w.done(); } }); test("deliverable names: clips and share-*, never a leftover, a file or another directory", async () => { assert.ok(isDeliverableName("clips") && isDeliverableName("share-first") && isDeliverableName("share-batch-2026.10.01")); for (const no of ["out", "share-", "shares", "clips.moved-20261001T000000Z", "share-x.incoming", "../clips", "share-a/b"]) { assert.equal(isDeliverableName(no), false, no); } const w = await world(); try { await mkdir(path.join(w.projectDir, "share-b.moved-20261001T000000Z")); await mkdir(path.join(w.projectDir, "share-c.incoming")); await writeFile(path.join(w.projectDir, "share-file"), "not a directory"); await mkdir(path.join(w.projectDir, "revisions")); const other = path.join(w.base, "elsewhere"); await mkdir(other); await symlink(other, path.join(w.projectDir, "share-linked")); // clips first; a batch present only as a leftover is named, so a move finishes it. assert.deepEqual(await deliverableNames(w.projectDir), ["clips", "share-b", "share-c", "share-first", "share-linked"]); } finally { await w.done(); } }); // --------------------------------------------------------------------------- // deliverableDir: what a cut and a batch call before they write // --------------------------------------------------------------------------- test("deliverableDir, local: a directory in the project, tiered or not", async () => { const w = await world({ deliverables: false }); try { const clips = await deliverableDir(w.projectDir, "clips", w.roots); assert.equal(clips, path.join(w.projectDir, "clips")); assert.equal(await kind(clips), "dir"); assert.deepEqual(await readdir(w.mediaRoot), []); await assert.rejects(deliverableDir(w.projectDir, "out", w.roots), /not a deliverable directory/); } finally { await w.done(); } }); test("deliverableDir, media: the first writer makes the mirror and the link; an existing directory is kept", async () => { const w = await world({ manifest: { storage: { deliverables: "media" } }, deliverables: false }); try { const share = await deliverableDir(w.projectDir, "share-next", w.roots); assert.equal(await kind(share), "link"); assert.equal(await readlink(share), path.join(w.mirror, "share-next")); // Written through the link, the file lands on the media root. await mkdir(path.join(share, "orig"), { recursive: true }); assert.equal(await kind(path.join(w.mirror, "share-next", "orig")), "dir"); // Again: the link is it. assert.equal(await deliverableDir(w.projectDir, "share-next", w.roots), share); // A real clips/ is never replaced by a writer: moving it is the switch's job. await mkdir(path.join(w.projectDir, "clips")); assert.equal(await deliverableDir(w.projectDir, "clips", w.roots), path.join(w.projectDir, "clips")); assert.equal(await kind(path.join(w.projectDir, "clips")), "dir"); } finally { await w.done(); } }); test("deliverableDir refuses, and creates nothing: no media root here, an unplugged root, a dangling link, a bad switch", async () => { const w = await world({ manifest: { storage: { deliverables: "media" } }, deliverables: false }); try { const clips = path.join(w.projectDir, "clips"); await assert.rejects(deliverableDir(w.projectDir, "clips", w.untiered), /UMTOOL_MEDIA_DIR is not set here.*--to local/); assert.equal(await kind(clips), "missing"); await rename(w.mediaRoot, `${w.mediaRoot}.unplugged`); await assert.rejects(deliverableDir(w.projectDir, "clips", w.roots), /is not there — is its drive mounted\? Nothing was created/); assert.equal(await kind(clips), "missing"); assert.equal(await kind(w.mediaRoot), "missing"); await symlink(path.join(w.mirror, "clips"), clips); await assert.rejects(deliverableDir(w.projectDir, "clips", w.roots), /is the media drive mounted\? Nothing was written/); assert.equal(await kind(w.mediaRoot), "missing"); await rename(`${w.mediaRoot}.unplugged`, w.mediaRoot); await rm(clips); await writeFile(path.join(w.projectDir, "video.manifest.json"), JSON.stringify({ storage: { deliverables: "platter" } })); await assert.rejects(deliverableDir(w.projectDir, "clips", w.roots), /"platter" — it is "local" or "media"/); assert.equal(await kind(clips), "missing"); } finally { await w.done(); } }); test("deliverableDir refuses over a cut move's leftover and names the switch that finishes it", async () => { const w = await world({ deliverables: false }); try { await mkdir(path.join(w.projectDir, "clips.moved-20261001T000000Z")); await assert.rejects( deliverableDir(w.projectDir, "clips", w.roots), /a move of clips\/ .* was cut .*umtool storage deliverables --to media/, ); assert.equal(await kind(path.join(w.projectDir, "clips")), "missing"); } finally { await w.done(); } }); // --------------------------------------------------------------------------- // moveDeliverables: the switch // --------------------------------------------------------------------------- test("moveDeliverables to media and back: every deliverable moves, then the switch is set", async () => { const w = await world(); try { const r = await moveDeliverables(w.projectDir, "media", { ...w.roots, writeMode }); assert.equal(r.ok, true); assert.equal(r.written, true); assert.equal(r.before, null); assert.deepEqual(r.results.map((x) => [x.name, x.state]), [["clips", "moved"], ["share-first", "moved"]]); for (const name of ["clips", "share-first"]) { assert.equal(await kind(path.join(w.projectDir, name)), "link"); assert.equal(await readlink(path.join(w.projectDir, name)), path.join(w.mirror, name)); } // A relative reference still resolves through the link. assert.deepEqual(await readFile(path.join(w.projectDir, "clips", "a01.mp4")), Buffer.alloc(3000, 1)); assert.equal((await readManifest(w)).storage.deliverables, "media"); assert.equal((await readManifest(w)).title, "fixture"); // Idempotent: all "already", and the manifest is not rewritten. const mtime = (await stat(path.join(w.projectDir, "video.manifest.json"))).mtimeMs; const again = await moveDeliverables(w.projectDir, "media", { ...w.roots, writeMode }); assert.deepEqual(again.results.map((x) => x.state), ["already", "already"]); assert.equal(again.written, false); assert.equal((await stat(path.join(w.projectDir, "video.manifest.json"))).mtimeMs, mtime); // The state readers see. const st = await deliverablesState(w.projectDir, w.roots); assert.equal(st.mode, "media"); assert.deepEqual(st.dirs.map((d) => [d.name, d.state]), [["clips", "link"], ["share-first", "link"]]); assert.deepEqual(deliverablesProblems(st, "share-next"), []); const back = await moveDeliverables(w.projectDir, "local", { ...w.roots, writeMode }); assert.equal(back.ok, true); for (const name of ["clips", "share-first"]) assert.equal(await kind(path.join(w.projectDir, name)), "dir"); assert.equal((await readManifest(w)).storage.deliverables, "local"); // The project's mirror is gone, the media root is kept. assert.equal(await kind(w.mirror), "missing"); assert.equal(await kind(w.mediaRoot), "dir"); } finally { await w.done(); } }); test("moveDeliverables: a dry run changes nothing; no media root or no manifest refuses", async () => { const w = await world(); try { const dry = await moveDeliverables(w.projectDir, "media", { ...w.roots, writeMode, dryRun: true }); assert.deepEqual(dry.results.map((x) => x.state), ["would-move", "would-move"]); assert.equal(dry.results[0].bytes, 3000); assert.equal(dry.written, false); assert.equal(await kind(path.join(w.projectDir, "clips")), "dir"); assert.equal((await readManifest(w)).storage, undefined); assert.deepEqual(await readdir(w.mediaRoot), []); await assert.rejects(moveDeliverables(w.projectDir, "media", { ...w.untiered, writeMode }), /UMTOOL_MEDIA_DIR is not set/); await assert.rejects(moveDeliverables(w.projectDir, "nowhere", { ...w.roots, writeMode }), /--to is "media" or "local"/); await rm(path.join(w.projectDir, "video.manifest.json")); await assert.rejects(moveDeliverables(w.projectDir, "media", { ...w.roots, writeMode }), /no video\.manifest\.json/); } finally { await w.done(); } }); test("moveDeliverables: a project with nothing cut yet just sets the switch, and the first cut makes the link", async () => { const w = await world({ deliverables: false }); try { const r = await moveDeliverables(w.projectDir, "media", { ...w.roots, writeMode }); assert.deepEqual(r.results, []); assert.equal(r.written, true); const clips = await deliverableDir(w.projectDir, "clips", w.roots); assert.equal(await kind(clips), "link"); } finally { await w.done(); } }); test("moveDeliverables: one failure leaves the switch where it was", async () => { const w = await world(); try { const r = await moveDeliverables(w.projectDir, "media", { ...w.roots, writeMode, rsyncBin: "false" }); assert.equal(r.ok, false); assert.equal(r.written, false); assert.ok(r.results.every((x) => x.state === "failed" && /rsync failed/.test(x.error))); assert.equal((await readManifest(w)).storage, undefined); assert.equal(await kind(path.join(w.projectDir, "clips")), "dir"); } finally { await w.done(); } }); test("moveDeliverables finishes a move cut between the park and the link", async () => { const w = await world(); try { // What a cut leaves for clips/: its verified copy on the media root, the source parked. await mkdir(w.mirror, { recursive: true }); execFileSync("cp", ["-a", path.join(w.projectDir, "clips"), path.join(w.mirror, "clips")]); await rename(path.join(w.projectDir, "clips"), path.join(w.projectDir, "clips.moved-20261001T000000Z")); const st = await deliverablesState(w.projectDir, w.roots); assert.deepEqual(st.dirs[0], { name: "clips", state: "absent", leftovers: ["clips.moved-20261001T000000Z"] }); assert.match(deliverablesProblems(st)[0], /a move of clips\/ was cut .*--to media/); const r = await moveDeliverables(w.projectDir, "media", { ...w.roots, writeMode }); assert.equal(r.ok, true); assert.equal(r.results[0].resumed, true); assert.equal(await kind(path.join(w.projectDir, "clips")), "link"); assert.deepEqual((await readdir(w.projectDir)).sort(), ["clips", "share-first", "video.manifest.json", "video.manifest.json.bak"]); } finally { await w.done(); } }); // --------------------------------------------------------------------------- // What a dangling deliverable stops, and the readers that follow a moved batch // --------------------------------------------------------------------------- test("deliverablesProblems: a dangling link stops everything; media without a root stops only a new directory", async () => { const w = await world(); try { await moveDeliverables(w.projectDir, "media", { ...w.roots, writeMode }); // The same project seen by a process with no media root: existing links // still work, a NEW batch could not be made. const here = await deliverablesState(w.projectDir, w.untiered); assert.deepEqual(deliverablesProblems(here, "clips"), []); assert.match(deliverablesProblems(here, "share-next")[0], /UMTOOL_MEDIA_DIR is not set in umtool's environment/); await rename(w.mediaRoot, `${w.mediaRoot}.unplugged`); const gone = await deliverablesState(w.projectDir, w.roots); assert.deepEqual(gone.dirs.map((d) => d.state), ["dangling", "dangling"]); const problems = deliverablesProblems(gone, "clips"); assert.equal(problems.length, 2); assert.match(problems[0], /clips\/ is a link to .*, which is not there — is the media drive mounted\?/); } finally { await w.done(); } }); test("listBatches and sharedIdsIn follow a moved batch; a dangling one is listed with no ids; a leftover is no batch", async () => { const w = await world(); try { await moveDeliverables(w.projectDir, "media", { ...w.roots, writeMode }); // A nested link inside a batch is followed too. const extra = path.join(w.base, "extra"); await mkdir(extra); await writeFile(path.join(extra, "b07_2025-02-02_y.mp4"), "x"); await symlink(extra, path.join(w.projectDir, "share-first", "small")); assert.deepEqual([...(await sharedIdsIn(path.join(w.projectDir, "share-first")))].sort(), ["a01", "b07"]); await mkdir(path.join(w.projectDir, "share-old.moved-20261001T000000Z")); const batches = await listBatches(w.projectDir); assert.deepEqual(batches.map((b) => [b.name, b.dangling, b.ids, b.hasList]), [["share-first", false, ["a01", "b07"], true]]); await rename(w.mediaRoot, `${w.mediaRoot}.unplugged`); const gone = await listBatches(w.projectDir); assert.deepEqual(gone.map((b) => [b.name, b.dangling, b.ids]), [["share-first", true, []]]); } finally { await w.done(); } }); // --------------------------------------------------------------------------- // The busy scan (lib/report/busy.mjs) and the CLI's refusal (review H1), and // `umtool check` on a link under no key (review N1) // --------------------------------------------------------------------------- const UMTOOL_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); test("namesProject: a script names a project by path, or by --project id, name or cwd-relative dir — whole values only", () => { const p = { dir: "/r/folder/proj", id: "folder/proj", name: "proj" }; const cut = (v, cwd = "/umtool") => namesProject(["node", "/umtool/bin/cut-from-cache.mjs", "--project", v, "--clip", "a01"], cwd, p); assert.equal(cut("folder/proj"), true); assert.equal(cut("proj"), true); assert.equal(cut("/r/folder/proj"), true); assert.equal(cut("../proj", "/r/folder/other"), true); for (const no of ["folder/proj-other", "folder/pro", "proj-2", "folder", "/r/folder/proj2"]) assert.equal(cut(no), false, no); assert.equal(namesProject(["node", "share-batch.mjs", "--project", "folder/proj", "--name", "x"], null, p), true); // A build step names the manifest by path. assert.equal(namesProject(["node", "build-video.mjs", "/r/folder/proj/video.manifest.json"], null, p), true); assert.equal(namesProject(["node", "build-video.mjs", "/r/folder/proj-2/video.manifest.json"], null, p), false); // The report's own scripts, by working directory. assert.equal(namesProject(["python3", "apply-manifest.py"], "/r/folder/proj", p), true); assert.equal(namesProject(["python3", "build.py"], "/r/folder/proj-2", p), false); // Anything else is not a pipeline process, whatever it names. assert.equal(namesProject(["ffmpeg", "-i", "/r/folder/proj/clips/a.mp4"], null, p), false); }); /** A process that does nothing for a while, with `cut-from-cache.mjs --project ` in its argv. */ function dummyCut(value) { const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 20000)", "cut-from-cache.mjs", "--project", value], { stdio: "ignore", }); return child; } test("pipelineProcessesFor sees a cut the app started with --project , and not -other", async () => { const w = await world(); const mine = dummyCut("folder/proj"); const other = dummyCut("folder/proj-other"); try { await new Promise((r) => setTimeout(r, 300)); const pids = pipelineProcessesFor({ dir: w.projectDir, id: "folder/proj", name: "proj" }); assert.ok(pids.includes(mine.pid), "the cut is seen"); assert.ok(!pids.includes(other.pid), "a project whose id merely starts with this one's is not"); } finally { mine.kill(); other.kill(); await w.done(); } }); /** The CLI against a temp reports root, with no media root unless given. */ function cli(w, args, extra = {}) { return spawnSync(process.execPath, [path.join(UMTOOL_DIR, "bin", "umtool.mjs"), ...args, "--json"], { cwd: UMTOOL_DIR, encoding: "utf8", env: { ...process.env, REPORTS_DIR: w.reportsRoot, SONG_DIR: path.join(w.base, "no-song-data"), UMTOOL_CACHE_DIR: path.join(w.base, "cache"), UMTOOL_MEDIA_DIR: "", ...extra, }, }); } test("umtool storage deliverables refuses, as busy, while a cut runs in the project", async () => { const w = await world(); const ls = cli(w, ["ls"]); assert.equal(ls.status, 0, ls.stderr); const id = JSON.parse(ls.stdout)[0].id; const mine = dummyCut(id); try { await new Promise((r) => setTimeout(r, 300)); const r = cli(w, ["storage", "deliverables", id, "--to", "local"]); assert.equal(r.status, 1); const j = JSON.parse(r.stdout); assert.ok(j.busy.includes(mine.pid), r.stdout); assert.match(j.error, /a pipeline process is working in it .* nothing moved/); assert.equal((await readManifest(w)).storage, undefined); } finally { mine.kill(); } const other = dummyCut(`${id}-other`); try { await new Promise((r) => setTimeout(r, 300)); const r = cli(w, ["storage", "deliverables", id, "--to", "local"]); assert.equal(r.status, 0, r.stderr + r.stdout); assert.equal((await readManifest(w)).storage.deliverables, "local"); } finally { other.kill(); await w.done(); } }); test("umtool check: a link into the media root under no key is a mismatch, as under an explicit local", async () => { const w = await world({ deliverables: false }); try { await mkdir(path.join(w.mirror, "clips"), { recursive: true }); await symlink(path.join(w.mirror, "clips"), path.join(w.projectDir, "clips")); const id = JSON.parse(cli(w, ["ls"]).stdout)[0].id; const kinds = (extra) => JSON.parse(cli(w, ["check", id], extra).stdout) .decisions.filter((d) => d.kind.startsWith("storage-")) .map((d) => [d.kind, d.target, d.severity]); assert.deepEqual(kinds({ UMTOOL_MEDIA_DIR: w.mediaRoot }), [["storage-mismatch", "clips", "open"]]); await updateStorage(w.projectDir, { deliverables: "local" }); assert.deepEqual(kinds({ UMTOOL_MEDIA_DIR: w.mediaRoot }), [["storage-mismatch", "clips", "open"]]); await updateStorage(w.projectDir, { deliverables: "media" }); assert.deepEqual(kinds({ UMTOOL_MEDIA_DIR: w.mediaRoot }), []); } finally { await w.done(); } });