import { test, expect, type APIRequestContext } from "@playwright/test"; import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, lstatSync, readdirSync, readlinkSync, renameSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; // --------------------------------------------------------------------------- // A project's render scratch on a media root (release 17, slice U1). // // With UMTOOL_MEDIA_DIR set, a project's out/ is a link to the same // project-relative path under it: made by the first writer, or moved there by // `umtool storage move-out`. Only THIS spec's CLI is given the variable (the // media root is make-fixture's `-media`, reset every run); the app is // not, which is the point of half of it -- a reader, and a build the app runs, // go through the link without knowing a media root exists. // // The tests run in order and hand the project's state on: moved out, built // through, unplugged, plugged back, moved back. // --------------------------------------------------------------------------- const HERE = path.dirname(fileURLToPath(import.meta.url)); const UMTOOL = path.join(HERE, ".."); const FIXTURE = path.join(UMTOOL, ".e2e-song"); const MEDIA = `${FIXTURE}-media`; const UNPLUGGED = `${MEDIA}.unplugged`; const PROJECT = "reports/storage-fixture"; const FRESH = "reports/storage-fresh-fixture"; const dirOf = (id: string) => path.join(FIXTURE, id); const mirrorOf = (id: string) => path.join(MEDIA, id); const env = { ...process.env, SONG_REPORTS_DIR: path.join(FIXTURE, "reports"), SONG_DIR: path.join(FIXTURE, "data"), CHANNELS_DIR: path.join(FIXTURE, "channels"), UMTOOL_CACHE_DIR: path.join(FIXTURE, "cache"), YTDLP_BIN: path.join(FIXTURE, "bin", "yt-dlp"), UMTOOL_MEDIA_DIR: MEDIA, }; const umtool = (args: string[]) => JSON.parse(execFileSync("node", ["bin/umtool.mjs", ...args, "--json"], { cwd: UMTOOL, encoding: "utf8", env })); // The pipeline's first writer, as a build's step 1 runs it. const checkAvailability = (id: string) => spawnSync( "node", [ path.join(UMTOOL, "report-to-video", "check-availability.mjs"), path.join(dirOf(id), "video.manifest.json"), "--out", path.join(dirOf(id), "out"), "--allow-missing", ], { cwd: path.join(UMTOOL, "report-to-video"), encoding: "utf8", env }, ); const isLink = (p: string) => existsSync(path.dirname(p)) && lstatSync(p, { throwIfNoEntry: false })?.isSymbolicLink() === true; const isRealDir = (p: string) => lstatSync(p, { throwIfNoEntry: false })?.isDirectory() === true; const parked = (id: string) => readdirSync(dirOf(id)).filter((n) => n.startsWith("out.moved-") || n === "out.incoming"); async function projectRow(request: APIRequestContext, id: string) { const j = (await (await request.get("/api/browse/projects")).json()) as { projects: { id: string; state: string; facts: string[] }[]; }; return j.projects.find((p) => p.id === id); } test.describe.configure({ mode: "serial" }); test.afterAll(() => { // A failure mid-way must not leave the root unplugged for the next run's // reader of this file -- make-fixture resets it anyway. if (existsSync(UNPLUGGED) && !existsSync(MEDIA)) renameSync(UNPLUGGED, MEDIA); }); test("move-out leaves a link to the media root, and readers see the same project", async ({ request }) => { const out = path.join(dirOf(PROJECT), "out"); expect(isRealDir(out)).toBe(true); const before = await projectRow(request, PROJECT); expect(before).toBeTruthy(); // A dry run measures and changes nothing. const dry = umtool(["storage", "move-out", PROJECT, "--dry-run"]); expect(dry.results[0].state).toBe("would-move"); expect(dry.results[0].bytes).toBeGreaterThan(0); expect(isRealDir(out)).toBe(true); expect(existsSync(mirrorOf(PROJECT))).toBe(false); const moved = umtool(["storage", "move-out", PROJECT]); expect(moved.ok).toBe(true); expect(moved.results[0].state).toBe("moved"); expect(isLink(out)).toBe(true); expect(readlinkSync(out)).toBe(path.join(mirrorOf(PROJECT), "out")); expect(existsSync(path.join(mirrorOf(PROJECT), "out", "clips-raw", "vid1_0.00-9.00.mp4"))).toBe(true); expect(parked(PROJECT)).toEqual([]); // The index and the page read /out by path; the link changes nothing. const after = await projectRow(request, PROJECT); expect(after?.state).toBe(before?.state); expect(after?.facts).toEqual(before?.facts); // Again: nothing to do. expect(umtool(["storage", "move-out", PROJECT]).results[0].state).toBe("already"); const status = umtool(["storage", PROJECT]); expect(status.projects[0].state).toBe("link"); }); test("a build the app runs writes through the link, and leaves it a link", async ({ request }) => { const start = await request.post("/api/report/build", { data: { project: PROJECT, preset: "fast" } }); expect(start.ok()).toBeTruthy(); const { job } = (await start.json()) as { job: { id: string } }; let state = "running"; for (let i = 0; i < 150 && state === "running"; i += 1) { const j = (await (await request.get("/api/jobs")).json()) as { jobs: { id: string; state: string }[] }; state = j.jobs.find((x) => x.id === job.id)?.state ?? "running"; if (state === "running") await new Promise((r) => setTimeout(r, 200)); } expect(state).toBe("done"); const out = path.join(dirOf(PROJECT), "out"); expect(isLink(out)).toBe(true); expect(existsSync(path.join(mirrorOf(PROJECT), "out", "storage-fixture.mp4"))).toBe(true); expect(existsSync(path.join(mirrorOf(PROJECT), "out", "availability.json"))).toBe(true); }); test("an unplugged media root refuses loudly and materialises nothing", () => { renameSync(MEDIA, UNPLUGGED); try { expect(umtool(["storage", PROJECT]).projects[0].state).toBe("dangling"); // A moved project: the link dangles, and the first writer says why. const r = checkAvailability(PROJECT); expect(r.status).not.toBe(0); expect(r.stderr).toContain("is the media drive mounted?"); expect(isLink(path.join(dirOf(PROJECT), "out"))).toBe(true); // A project with no out/ yet: the root is stat'd, never created. const fresh = checkAvailability(FRESH); expect(fresh.status).not.toBe(0); expect(fresh.stderr).toContain("is not there"); expect(existsSync(path.join(dirOf(FRESH), "out"))).toBe(false); // Neither recreated the root on the disk it was "on". expect(existsSync(MEDIA)).toBe(false); // The doctor says so, and exits 1. const doctor = spawnSync("node", ["bin/umtool.mjs", "doctor", "--json"], { cwd: UMTOOL, encoding: "utf8", env }); expect(doctor.status).toBe(1); const roots = JSON.parse(doctor.stdout).roots; expect(roots.media.tiered).toBe(true); expect(roots.media.problem).toContain("is not there"); expect(roots.cache.path).toBe(path.join(FIXTURE, "cache")); } finally { renameSync(UNPLUGGED, MEDIA); } }); test("the first writer of a project with no out/ makes the link", () => { const r = checkAvailability(FRESH); expect(r.status, r.stderr).toBe(0); const out = path.join(dirOf(FRESH), "out"); expect(isLink(out)).toBe(true); expect(readlinkSync(out)).toBe(path.join(mirrorOf(FRESH), "out")); expect(existsSync(path.join(mirrorOf(FRESH), "out", "availability.json"))).toBe(true); }); test("move-back makes out/ a real directory again and removes the media copy", () => { const back = umtool(["storage", "move-back", PROJECT]); expect(back.ok).toBe(true); expect(back.results[0].state).toBe("moved"); const out = path.join(dirOf(PROJECT), "out"); expect(isRealDir(out)).toBe(true); expect(existsSync(path.join(out, "storage-fixture.mp4"))).toBe(true); expect(parked(PROJECT)).toEqual([]); // The project's mirror is gone; the root, and the other project's, are not. expect(existsSync(mirrorOf(PROJECT))).toBe(false); expect(existsSync(MEDIA)).toBe(true); expect(isLink(path.join(dirOf(FRESH), "out"))).toBe(true); expect(umtool(["storage", "move-back", PROJECT]).results[0].state).toBe("already"); });