import { test, expect } from "@playwright/test"; import { caps, NO_SONG_DATA } from "./capabilities"; // The browse pages, against the synthesised videos/ tree in make-fixture.mjs: // // alpha wide 6s, wide-short 3s, vertical 5s, vertical-short 2s // variants/ wide-nokit 7s, wide-short-nokit 4s, vertical-short-crop 1s // beta wide 8s, wide-short 9s -- and NO vertical of either length // // Every duration is distinct, so "which file ended up shipping" has an answer // that cannot be faked by a response body. const dur = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; test("the index lists both songs, and says which cuts are missing", async ({ page }) => { await page.goto("/browse"); // A project id is a PATH now, so the suffix is what identifies a song // regardless of how deep the fixture nests it. await expect(page.locator("[data-project$='/alpha']")).toBeVisible(); await expect(page.locator("[data-project$='/beta']")).toBeVisible(); // beta has wide and wide-short only. A cut list derived from the directory // would make it look complete; the list is fixed precisely so it cannot. await expect(page.locator("[data-project$='/beta'] [data-missing]")).toHaveAttribute( "data-missing", "vertical,vertical-short", ); await expect(page.locator("[data-project$='/alpha'] [data-missing]")).toHaveCount(0); }); // THE assertion this whole feature turns on. `wide-short-nokit` must attribute // to `wide-short`; a plain startsWith() over an unordered cut list binds it to // `wide`, and then every short's variants collect under the long one. test("a variant binds to the LONGEST matching cut name", async ({ page }) => { await page.goto("/browse/alpha"); const wide = page.locator("[data-cut=wide]"); const wideShort = page.locator("[data-cut='wide-short']"); await expect(wide.locator("[data-variant]")).toHaveCount(1); await expect(wide.locator("[data-variant='variants/wide-nokit.mp4']")).toHaveAttribute( "data-variant-tag", "nokit", ); // ...and NOT the short's variant. await expect(wide.locator("[data-variant='variants/wide-short-nokit.mp4']")).toHaveCount(0); await expect(wideShort.locator("[data-variant='variants/wide-short-nokit.mp4']")).toHaveAttribute( "data-variant-tag", "nokit", ); await expect(page.locator("[data-cut='vertical-short'] [data-variant-tag=crop]")).toHaveCount(1); }); test("a song with a hole shows the hole", async ({ page }) => { await page.goto("/browse/beta"); await expect(page.locator("[data-cut=wide]")).toHaveAttribute("data-present", "1"); await expect(page.locator("[data-cut=vertical]")).toHaveAttribute("data-present", "0"); await expect(page.locator("[data-cut='vertical-short']")).toHaveAttribute("data-present", "0"); }); test("posters come from thumbs/ at the song level and are extracted below it", async ({ request }) => { const song = await request.get("/api/browse/poster?song=alpha"); expect(song.ok()).toBe(true); expect(song.headers()["x-poster"]).toBe("thumb"); // beta has no cover, so it falls through to a frame out of its own first cut. const fell = await request.get("/api/browse/poster?song=beta"); expect(fell.ok()).toBe(true); expect(["fresh", "cached"]).toContain(fell.headers()["x-poster"]); const first = await request.get("/api/browse/poster?song=alpha&rel=wide.mp4"); expect(first.ok()).toBe(true); expect(first.headers()["content-type"]).toContain("image/jpeg"); // Keyed by path+mtime+size, so the second request must not re-run ffmpeg. const second = await request.get("/api/browse/poster?song=alpha&rel=wide.mp4"); expect(second.headers()["x-poster"]).toBe("cached"); }); test("a name that is not in the scan is refused, however well-formed", async ({ request }) => { for (const q of [ "song=alpha&rel=../../../etc/passwd", "song=../..&rel=wide.mp4", "song=alpha&rel=/etc/passwd", "song=alpha&rel=nope.mp4", ]) { const r = await request.get(`/api/browse/poster?${q}`); expect(r.ok(), q).toBe(false); } }); test("a verdict round-trips and survives a reload", async ({ page, request }) => { const post = await request.post("/api/browse/verdict", { data: { song: "alpha", rel: "variants/wide-nokit.mp4", verdict: "keep", note: "the kit is wrong" }, }); expect(post.ok()).toBe(true); await page.goto("/browse/alpha"); const chip = page.locator("[data-verdict-for='variants/wide-nokit.mp4']"); await expect(chip.locator("[data-verdict=keep]")).toHaveAttribute("data-on", "1"); await expect(chip.getByText("the kit is wrong")).toBeVisible(); // Undecided with no note is the default, so it is REMOVED rather than stored. const clear = await request.post("/api/browse/verdict", { data: { song: "alpha", rel: "variants/wide-nokit.mp4", verdict: "undecided" }, }); expect(clear.ok()).toBe(true); await page.reload(); await expect(chip.locator("[data-verdict=keep]")).toHaveAttribute("data-on", "0"); }); test("promote MOVES, and the outgoing cut survives as a variant", async ({ request }) => { // wide-short is 3s; its nokit variant is 4s. After promoting, the shipped cut // must BE the 4s file and the 3s one must still exist under variants/. const before = await request.get("/api/browse/poster?song=alpha&rel=wide-short.mp4"); expect(before.ok()).toBe(true); const r = await request.post("/api/browse/promote", { data: { song: "alpha", rel: "variants/wide-short-nokit.mp4", action: "promote" }, }); expect(r.ok()).toBe(true); const { moved } = await r.json(); // Two moves, in order: the incumbent steps aside FIRST. expect(moved).toHaveLength(2); expect(moved[0].from).toBe("wide-short.mp4"); expect(moved[0].to).toMatch(/^variants\/wide-short-prev-/); expect(moved[1]).toEqual({ from: "variants/wide-short-nokit.mp4", to: "wide-short.mp4" }); }); test("the demoted cut comes back under the SAME cut, with the right length", async ({ page }) => { // Runs after the promote above (the suite is serial, workers: 1). await page.goto("/browse/alpha"); const wideShort = page.locator("[data-cut='wide-short']"); // The shipped file is now the 4s variant... await expect(wideShort.getByText(dur(4), { exact: true }).first()).toBeVisible(); // ...and the 3s incumbent is a variant of wide-short, not of wide. const prev = wideShort.locator("[data-variant^='variants/wide-short-prev-']"); await expect(prev).toHaveCount(1); await expect(page.locator("[data-cut=wide] [data-variant^='variants/wide-short-prev-']")).toHaveCount(0); }); test("promote refuses a variant that names no cut", async ({ request }) => { const r = await request.post("/api/browse/promote", { data: { song: "alpha", rel: "variants/nope.mp4", action: "promote" }, }); expect(r.ok()).toBe(false); for (const bad of [ { song: "../..", rel: "wide.mp4", action: "promote" }, { song: "alpha", rel: "../../../etc/passwd", action: "promote" }, { song: "alpha", rel: "wide.mp4", action: "retire" }, // a shipped cut is not retirable ]) { const res = await request.post("/api/browse/promote", { data: bad }); expect(res.ok(), JSON.stringify(bad)).toBe(false); } }); test("provenance joins the plan to the archive with a 3s lead-in", async ({ page }) => { // NEEDS A PLAN, AND A PLAN NEEDS A CANDIDATE. make-fixture writes // `fixture-build.plan.json` from the first COPIED cand2 entry, and it copies // one only when its wav is present — so without the song bulk data there is // no plan, no provenance row, and nothing here to join. test.skip(!caps.song, NO_SONG_DATA); await page.goto("/browse/alpha/wide"); // The per-note table is collapsed by default -- 880 rows is the normal case, // so it opens on demand. await page.getByText(/^every note \(\d+\)$/).click(); const row = page.locator("[data-clip]").first(); await expect(row).toBeVisible(); const clipId = (await row.getAttribute("data-clip"))!; const srcStart = Number(clipId.slice(clipId.lastIndexOf("@") + 1)); const expected = Math.max(0, Math.floor(srcStart) - 3); const link = row.getByRole("link", { name: "archive" }); const href = (await link.getAttribute("href"))!; // Built literally, not by re-calling viewerMomentUrl -- a test that recomputes // the value it is checking would pass through a change of origin. expect(href).toContain("https://jeralyzer.pages.dev/?v=the-quartering%2F"); expect(href).toContain(`&t=${expected}`); }); test("the plan picker offers a choice rather than guessing", async ({ page }) => { // One plan is written only when a candidate was copied — see above. test.skip(!caps.song, NO_SONG_DATA); await page.goto("/browse/alpha/wide"); await expect(page.locator("[data-plan='alpha.plan.json']")).toBeVisible(); }); // --------------------------------------------------------------------------- // The spec sheet: what a song declares is what it can do. // --------------------------------------------------------------------------- test("operations follow the declared structures", async ({ request }) => { const before = await request.get("/api/browse/spec?song=alpha"); const b = await before.json(); expect(b.operations.find((o: { id: string }) => o.id === "trim-hooks").blocked).toBe("not declared"); const put = await request.put("/api/browse/spec", { data: { song: "alpha", spec: { version: 1, song: "alpha", title: "Alpha", hooks: { trimSet: "mk-hooks" }, background: { path: "bg.mp4", fit: "pad" }, }, }, }); expect(put.ok()).toBe(true); const j = await put.json(); const trim = j.operations.find((o: { id: string }) => o.id === "trim-hooks"); expect(trim.blocked).toBeUndefined(); expect(trim.href).toBe("/browse/trim/mk-hooks"); expect(j.problems).toHaveLength(0); // A structure pointing at nothing is REPORTED, not refused -- you point the // background at a file you are about to render. Containment alone would pass // this name, so the check that earns its keep is existence. const pending = await request.put("/api/browse/spec", { data: { song: "alpha", spec: { version: 1, song: "alpha", background: { path: "not-here.mp4" } } }, }); expect(pending.ok()).toBe(true); const pendingProblems = (await pending.json()).problems; expect(pendingProblems).toHaveLength(1); expect(pendingProblems[0].level).toBe("pending"); // Escaping the roots is a different thing, and it is an error. const escaped = await request.put("/api/browse/spec", { data: { song: "alpha", spec: { version: 1, song: "alpha", background: { path: "/etc/passwd" } } }, }); expect(escaped.ok()).toBe(true); expect((await escaped.json()).problems[0].level).toBe("error"); }); test("the song page links to the trim editor once hooks are declared", async ({ page, request }) => { await request.put("/api/browse/spec", { data: { song: "alpha", spec: { version: 1, song: "alpha", hooks: { trimSet: "mk-hooks" } } }, }); await page.goto("/browse/alpha"); await expect(page.locator("[data-op=trim-hooks]")).toHaveAttribute("data-blocked", "0"); await page.locator("[data-op=trim-hooks]").getByRole("link").click(); await expect(page).toHaveURL(/\/browse\/trim\/mk-hooks/); }); // --------------------------------------------------------------------------- // The hook trimmer. // --------------------------------------------------------------------------- test("peaks and audio come back for a window of the stem", async ({ request }) => { const peaks = await request.get("/api/trim/mk-hooks/peaks?from=1&to=4&n=200"); expect(peaks.ok()).toBe(true); const p = await peaks.json(); expect(p.min).toHaveLength(200); expect(p.to - p.from).toBeCloseTo(3, 1); const audio = await request.get("/api/trim/mk-hooks/audio?from=1&to=2"); expect(audio.ok()).toBe(true); expect(audio.headers()["content-type"]).toBe("audio/wav"); }); test("a selection inside the catalogue window is clip-relative; outside it is a stem re-cut", async ({ request, }) => { // hook-01's window is stem 2.0 -> 3.0. const inside = await request.put("/api/trim/mk-hooks", { // 2.25 -> 2.75 in stem seconds is 0.25 -> 0.75 relative to the clip. data: { trims: [{ id: "hook-01", file: "hook-01.wav", start: 0.25, end: 0.75 }] }, }); expect(inside.ok()).toBe(true); expect((await inside.json()).trims[0]).toEqual({ id: "hook-01", file: "hook-01.wav", start: 0.25, end: 0.75, }); // Opening BEFORE the window is the case the editor exists for -- no amount of // trimming inside a clip recovers a syllable the window cut off. const outside = await request.put("/api/trim/mk-hooks", { data: { trims: [{ id: "hook-01", file: "hook-01.wav", source: "stem", start: 1.6, end: 3.0 }] }, }); expect(outside.ok()).toBe(true); expect((await outside.json()).trims[0].source).toBe("stem"); // A hook the catalogue does not know would be copied through silently. const unknown = await request.put("/api/trim/mk-hooks", { data: { trims: [{ file: "hook-99.wav", start: 0, end: 1 }] }, }); expect(unknown.ok()).toBe(false); }); test("the apply chain runs in the order that keeps the take selection", async ({ request }) => { const r = await request.post("/api/browse/build?dry=1", { data: { recipe: "mk-hooks-remix", set: "mk-hooks", body: "song.mp4" }, }); expect(r.ok()).toBe(true); const { steps } = await r.json(); expect(steps).toHaveLength(3); // 1. trims are taken against hooks-VERB -- what pick-take scored. The script // itself defaults to mkvocals/hooks, which is the wrong audio. expect(steps[0].argv.join(" ")).toContain("apply-hook-trims.mjs"); expect(steps[0].env.HOOKDIR).toBe("mkvocals/hooks-verb"); // 2. the take is re-picked into a STAMPED directory, never the shipped // hooks-best, and the overlays output is a new file rather than its input. expect(steps[1].argv.join(" ")).toContain("pick-take.mjs"); expect(steps[1].argv).toContain("--apply"); expect(steps[1].env.TAKE_DIR).not.toBe("mkvocals/hooks-best"); expect(steps[1].env.TAKE_DIR).toMatch(/^mkvocals\/hooks-best-/); const applyAt = steps[1].argv.indexOf("--apply"); expect(steps[1].argv[applyAt + 1]).not.toBe(steps[1].argv[applyAt + 2]); // 3. THE ONE THAT MATTERS. HOOKDIR overrides by BASENAME, so setting it here // would restore each occurrence's own take and throw the selection away. expect(steps[2].argv.join(" ")).toContain("mk-mix-hooks.mjs"); expect(Object.keys(steps[2].env)).not.toContain("HOOKDIR"); }); test("a body outside the roots is refused, and an unknown recipe with it", async ({ request }) => { const outside = await request.post("/api/browse/build?dry=1", { data: { recipe: "mk-hooks-remix", set: "mk-hooks", body: "/etc/passwd" }, }); expect(outside.ok()).toBe(false); const unknown = await request.post("/api/browse/build?dry=1", { data: { recipe: "rm-rf", set: "mk-hooks", body: "song.mp4" }, }); expect(unknown.ok()).toBe(false); }); // --------------------------------------------------------------------------- // Starting a song, notes on anything, and the paste. // --------------------------------------------------------------------------- test("a new song gets the shape but NO cut files", async ({ request, page }) => { // Through the one "new project" door, which took over from /api/browse/init. const r = await request.post("/api/projects/new", { data: { kind: "song", slug: "gamma", title: "Gamma" } }); expect(r.ok()).toBe(true); expect((await r.json()).href).toBe("/browse/gamma"); await page.goto("/browse/gamma"); // Every cut must read as a hole. A zero-byte wide.mp4 would read as built. for (const cut of ["wide", "wide-short", "vertical", "vertical-short"]) { await expect(page.locator(`[data-cut=${cut}]`)).toHaveAttribute("data-present", "0"); } // Same name twice, and names that are not names. expect((await request.post("/api/projects/new", { data: { kind: "song", slug: "gamma" } })).status()).toBe(409); for (const bad of ["../escape", "a/b", "", ".hidden"]) { const res = await request.post("/api/projects/new", { data: { kind: "song", slug: bad } }); expect(res.ok(), bad).toBe(false); } // A kind that cannot be scaffolded, and one nobody registered. expect((await request.post("/api/projects/new", { data: { kind: "sweep-report", slug: "nope" } })).ok()).toBe(false); expect((await request.post("/api/projects/new", { data: { kind: "no-such-kind", slug: "nope" } })).ok()).toBe(false); }); test("notes attach to anything selectable, including things that do not exist yet", async ({ request, }) => { const targets = [ "song", "file:wide.mp4", // The cut SLOT, for a cut beta has never built -- the note is most useful // exactly when the thing is missing. "cut:vertical", "plan:alpha.plan.json", "source:abc123", ]; for (const target of targets) { const r = await request.post("/api/browse/notes", { data: { song: "alpha", target, note: `note about ${target}\nsecond line` }, }); expect(r.ok(), target).toBe(true); } const read = await request.get("/api/browse/notes?song=alpha"); const { notes } = await read.json(); for (const t of targets) expect(notes[t].note, t).toContain(`note about ${t}`); // Multi-line is the normal case, not an edge one. expect(notes["song"].note).toContain("\nsecond line"); // Empty removes rather than storing "". await request.post("/api/browse/notes", { data: { song: "alpha", target: "source:abc123", note: "" } }); const after = await (await request.get("/api/browse/notes?song=alpha")).json(); expect(after.notes["source:abc123"]).toBeUndefined(); const bad = await request.post("/api/browse/notes", { data: { song: "alpha", target: "file:../../etc/passwd", note: "x" }, }); expect(bad.ok()).toBe(false); }); test("a mark resolves through the plan to the clip playing there", async ({ request }) => { // "Through the plan" is the point, and the plan is candidate-derived. test.skip(!caps.song, NO_SONG_DATA); // alpha's clips.csv places its only note at songTime 1.500. await request.post("/api/browse/notes", { data: { song: "alpha", target: "moment:wide.mp4@2.00", note: "the kit is late here" }, }); const r = await request.get("/api/browse/context?song=alpha&rel=wide.mp4&t=2.0¬e=the%20kit%20is%20late"); expect(r.ok()).toBe(true); const body = await r.text(); expect(body).toContain("[alpha wide.mp4 @ 0:02.0]"); expect(body).toContain("melody:"); // The citation carries the archive link, with the 3s lead-in applied. expect(body).toContain("https://jeralyzer.pages.dev/?v=the-quartering%2F"); // Without a `t`, every mark on that file. const all = await request.get("/api/browse/context?song=alpha&rel=wide.mp4"); expect(await all.text()).toContain("the kit is late here"); }); test("marks and notes follow the file through a promote", async ({ request }) => { await request.post("/api/browse/notes", { data: { song: "alpha", target: "file:variants/vertical-short-crop.mp4", note: "the crop is tight" }, }); await request.post("/api/browse/notes", { data: { song: "alpha", target: "moment:variants/vertical-short-crop.mp4@0.50", note: "here" }, }); const p = await request.post("/api/browse/promote", { data: { song: "alpha", rel: "variants/vertical-short-crop.mp4", action: "promote" }, }); expect(p.ok()).toBe(true); const { notes } = await (await request.get("/api/browse/notes?song=alpha")).json(); // Both keys embed the filename, so both have to be rewritten -- a mark // orphaned by a promote is orphaned exactly when the file starts shipping. expect(notes["file:vertical-short.mp4"].note).toBe("the crop is tight"); expect(notes["moment:vertical-short.mp4@0.50"].note).toBe("here"); expect(notes["file:variants/vertical-short-crop.mp4"]).toBeUndefined(); }); test("the digest is one paste with the whole picture in it", async ({ request }) => { // THE MARK IS POSTED HERE, not inherited from the test above. // // It used to rely on "a mark resolves through the plan to the clip playing // there" having run first and left this note behind — and the moment that // test started SKIPPING on a machine with no song bulk data, this one failed // for a reason that had nothing to do with what it tests. A spec that needs a // row should write the row; storing a note needs no plan and no candidate, // which is exactly why this one does not skip. await request.post("/api/browse/notes", { data: { song: "alpha", target: "moment:wide.mp4@2.00", note: "the kit is late here" }, }); const r = await request.get("/api/browse/context?song=alpha"); expect(r.headers()["content-type"]).toContain("text/plain"); const md = await r.text(); for (const section of ["## What it is", "## Cuts and variants", "## Marks", "## Operations available"]) { expect(md, section).toContain(section); } expect(md).toContain("the kit is late here"); expect(md).toContain("note about song"); // beta is the one with the hole; alpha has all four cuts. const betaMd = await (await request.get("/api/browse/context?song=beta")).text(); expect(betaMd).toContain("**vertical** — NOT BUILT"); // Every song at once, for the widest paste. const all = await (await request.get("/api/browse/context")).text(); expect(all).toContain("(`alpha`)"); expect(all).toContain("(`beta`)"); expect(all).toContain("(`gamma`)"); }); test("a hook note is stored against the trim SET, not a song", async ({ request }) => { const r = await request.post("/api/browse/notes", { data: { set: "mk-hooks", hook: "hook-01", note: "clipped at the front" }, }); expect(r.ok()).toBe(true); const { notes } = await (await request.get("/api/browse/notes?set=mk-hooks")).json(); expect(notes["mk-hooks:hook-01"].note).toBe("clipped at the front"); const bad = await request.post("/api/browse/notes", { data: { set: "nope", hook: "hook-01", note: "x" }, }); expect(bad.ok()).toBe(false); });