import { test, expect } from "@playwright/test"; import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, renameSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; // --------------------------------------------------------------------------- // A report's deliverables behind the per-project switch (release 17, slice U2). // // `storage.deliverables` in the manifest says where clips/ and every share-*/ // live: in the project ("local", the default) or on the media root behind a // link ("media"). `umtool storage deliverables` moves them and then sets it; // a cut or a batch makes a missing directory where it says; a reader opens // `clips/.mp4` by path either way. // // Only THIS spec's CLI is given UMTOOL_MEDIA_DIR (make-fixture's media root, // shared with storage.spec.ts and reset every run). The app is not -- which is // what this spec leans on: it cuts THROUGH a linked clips/ without knowing a // media root exists, refuses a NEW batch it could only make on the wrong // drive, and brings everything home with the bench's own button. // // The tests run in order and hand the project's state on. // --------------------------------------------------------------------------- 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/deliverables-fixture"; const DIR = path.join(FIXTURE, PROJECT); const MIRROR = path.join(MEDIA, PROJECT); 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[]) => spawnSync("node", ["bin/umtool.mjs", ...args, "--json"], { cwd: UMTOOL, encoding: "utf8", env }); const umtoolJson = (args: string[]) => { const r = umtool(args); expect(r.status, r.stderr).toBe(0); return JSON.parse(r.stdout); }; const isLink = (p: string) => lstatSync(p, { throwIfNoEntry: false })?.isSymbolicLink() === true; const isRealDir = (p: string) => lstatSync(p, { throwIfNoEntry: false })?.isDirectory() === true; const manifestStorage = () => JSON.parse(readFileSync(path.join(DIR, "video.manifest.json"), "utf8")).storage as | { deliverables?: string } | undefined; const seconds = (file: string): number => Number( execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", file]) .toString() .trim(), ); async function waitForJob(page: import("@playwright/test").Page, timeout = 120_000) { await expect(page.locator("[data-deliver-job]")).toHaveAttribute("data-deliver-state", /done|failed/, { timeout }); const state = await page.locator("[data-deliver-job]").getAttribute("data-deliver-state"); const log = (await page.locator("[data-deliver-log]").textContent()) ?? ""; return { state, log }; } test.describe.configure({ mode: "serial" }); test.afterAll(() => { if (existsSync(UNPLUGGED) && !existsSync(MEDIA)) renameSync(UNPLUGGED, MEDIA); }); test("the switch moves clips/ and every share-* to the media root, then says media", () => { expect(isRealDir(path.join(DIR, "clips"))).toBe(true); expect(manifestStorage()).toBeUndefined(); // A dry run measures and changes nothing -- not even the switch. const dry = umtoolJson(["storage", "deliverables", PROJECT, "--to", "media", "--dry-run"]); expect(dry.results.map((r: { name: string; state: string }) => [r.name, r.state])).toEqual([ ["clips", "would-move"], ["share-first", "would-move"], ]); expect(dry.written).toBe(false); expect(isRealDir(path.join(DIR, "clips"))).toBe(true); expect(manifestStorage()).toBeUndefined(); const moved = umtoolJson(["storage", "deliverables", PROJECT, "--to", "media"]); expect(moved.ok).toBe(true); expect(moved.written).toBe(true); for (const name of ["clips", "share-first"]) { expect(isLink(path.join(DIR, name)), name).toBe(true); expect(readlinkSync(path.join(DIR, name))).toBe(path.join(MIRROR, name)); } expect(existsSync(path.join(MIRROR, "clips", "d03.mp4"))).toBe(true); expect(manifestStorage()).toEqual({ deliverables: "media" }); // Again: nothing to do, and the manifest is not rewritten. const again = umtoolJson(["storage", "deliverables", PROJECT, "--to", "media"]); expect(again.results.map((r: { state: string }) => r.state)).toEqual(["already", "already"]); expect(again.written).toBe(false); // `umtool storage` names them; `umtool check` has nothing to say. const status = umtoolJson(["storage", PROJECT]); expect(status.projects[0].deliverables.mode).toBe("media"); expect(status.projects[0].deliverables.dirs.map((d: { state: string }) => d.state)).toEqual(["link", "link"]); const check = JSON.parse(umtool(["check", PROJECT]).stdout); expect(check.decisions.filter((d: { kind: string }) => d.kind.startsWith("storage-"))).toEqual([]); }); test("the app cuts through the linked clips/, and the move waits for the cut", async ({ page, request }) => { test.setTimeout(180_000); await page.goto(`/browse/${PROJECT}`); await expect(page.locator("[data-deliverables]")).toHaveAttribute("data-deliverables", "media"); await expect(page.locator('[data-deliverable="clips"]')).toHaveAttribute("data-deliverable-state", "link"); const move = page.locator('[data-action="deliver-move"]'); await expect(move).toHaveAttribute("data-move-to", "local"); await expect(move).toBeEnabled(); await page.locator('[data-action="deliver-cut"]').click(); await expect(page.locator("[data-deliver-progress]")).toContainText(/of 2|2 steps/); // While the cut writes into clips/, the move that would carry clips/ away // is disabled, and says why. await expect(move).toBeDisabled(); await expect(page.locator("[data-move-reason]")).toContainText("is running — move when it has finished"); // And the route refuses a move asked for any other way while the cut runs. const during = await request.post("/api/report/deliver", { data: { project: PROJECT, action: "move", to: "local" } }); expect(during.status()).toBe(409); expect(((await during.json()) as { error: string }).error).toContain("a job is already running"); const { state, log } = await waitForJob(page); expect(state, log).toBe("done"); // Written through the link: the files are on the media root, clips/ is still a link. expect(isLink(path.join(DIR, "clips"))).toBe(true); for (const id of ["d01", "d02"]) { expect(seconds(path.join(MIRROR, "clips", `${id}.mp4`))).toBeCloseTo(3.0, 1); } expect(readdirSync(path.join(MIRROR, "clips")).filter((n) => n.startsWith("."))).toEqual([]); await page.reload(); await expect(page.locator("[data-deliver]")).toHaveAttribute("data-deliver-need-cut", "0"); await expect(move).toBeEnabled(); }); test("a new batch: refused by an app with no media root, made as a link by a CLI with one", async ({ page, request }) => { test.setTimeout(180_000); await page.goto(`/browse/${PROJECT}`); // The app could only make share-/ in the project, which is not where this // project's deliverables live: it says so rather than splitting them. await expect(page.locator('[data-action="deliver-share"]')).toBeDisabled(); await expect(page.locator("[data-deliver-blocked]")).toContainText("UMTOOL_MEDIA_DIR is not set in umtool's environment"); const refused = await request.post("/api/report/deliver", { data: { project: PROJECT, action: "share", name: "second" } }); expect(refused.status()).toBe(409); expect(existsSync(path.join(DIR, "share-second"))).toBe(false); // The batch's CLI, with the media root: share-second is made as a link, and // share-first is read THROUGH its link, so d03 is not shipped again. const r = spawnSync("node", ["bin/share-batch.mjs", "--project", PROJECT, "--name", "second"], { cwd: UMTOOL, encoding: "utf8", env, }); expect(r.status, r.stderr).toBe(0); const root = path.join(DIR, "share-second"); expect(isLink(root)).toBe(true); expect(readlinkSync(root)).toBe(path.join(MIRROR, "share-second")); const list = readFileSync(path.join(MIRROR, "share-second", "LIST.md"), "utf8"); expect(list).toContain("d01_2025-03-01"); expect(list).toContain("d02_2025-03-02"); expect(list).not.toContain("d03_"); expect(list).toContain("1 already shared (d03)"); // And the panel lists it, through its link. Three ids, not two: a batch's // LIST.md is read for the ids it says were "already shared" as well as the // files it names (deliver.mjs sharedIdsIn), so d03 counts as shipped here too. await page.reload(); await expect(page.locator('[data-batch="share-second"]')).toContainText("share-second (3)"); await expect(page.locator('[data-deliverable="share-second"]')).toHaveAttribute("data-deliverable-state", "link"); }); test("an unplugged media root: check blocks, the panel refuses, nothing is made", async ({ page, request }) => { renameSync(MEDIA, UNPLUGGED); try { const check = umtool(["check", PROJECT]); expect(check.status).toBe(1); const unreachable = JSON.parse(check.stdout) .decisions.filter((d: { kind: string }) => d.kind === "storage-unreachable") .map((d: { target: string; severity: string; why: string }) => [d.target, d.severity, d.why.includes("is the media drive mounted?")]); expect(unreachable).toEqual([ ["clips", "blocking", true], ["share-first", "blocking", true], ["share-second", "blocking", true], ]); await page.goto(`/browse/${PROJECT}`); await expect(page.locator('[data-deliverable="clips"]')).toHaveAttribute("data-deliverable-state", "dangling"); await expect(page.locator('[data-action="deliver-move"]')).toBeDisabled(); await expect(page.locator("[data-move-reason]")).toContainText("is the media drive mounted?"); await expect(page.locator('[data-action="deliver-cut"]')).toBeDisabled(); await expect(page.locator("[data-deliver-blocked]")).toContainText("is the media drive mounted?"); // The route says the same to a caller that is not the panel. const cut = await request.post("/api/report/deliver", { data: { project: PROJECT, action: "cut" } }); expect(cut.status()).toBe(409); expect(((await cut.json()) as { error: string }).error).toContain("is the media drive mounted?"); // Nothing was made in the links' place, and the root was not recreated. expect(isLink(path.join(DIR, "clips"))).toBe(true); expect(existsSync(MEDIA)).toBe(false); } finally { renameSync(UNPLUGGED, MEDIA); } }); test("the bench moves the deliverables home, and offers no move to media without a media root", async ({ page }) => { test.setTimeout(180_000); await page.goto(`/browse/${PROJECT}`); const move = page.locator('[data-action="deliver-move"]'); await expect(move).toHaveText("Move deliverables to local"); await move.click(); const { state, log } = await waitForJob(page); expect(state, log).toBe("done"); // The app has no media root, so it brings each copy home and leaves the // media side where it is, saying so -- it cannot tell it is the project's own. expect(log).toContain("left in place"); expect(log).toContain("storage.deliverables: media -> local"); for (const name of ["clips", "share-first", "share-second"]) { expect(isRealDir(path.join(DIR, name)), name).toBe(true); } expect(readdirSync(path.join(DIR, "clips")).sort()).toEqual(["d01.mp4", "d02.mp4", "d03.mp4"]); expect(manifestStorage()).toEqual({ deliverables: "local" }); await page.reload(); await expect(page.locator("[data-deliverables]")).toHaveAttribute("data-deliverables", "local"); await expect(move).toHaveText("Move deliverables to media"); await expect(move).toBeDisabled(); await expect(page.locator("[data-move-reason]")).toContainText("UMTOOL_MEDIA_DIR is not set in umtool's environment"); // Local again: a batch is the app's to make. await expect(page.locator('[data-action="deliver-share"]')).toBeDisabled(); // nothing left to ship await expect(page.locator("[data-deliver-blocked]")).toHaveCount(0); });