import { test, expect } from "@playwright/test"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; // --------------------------------------------------------------------------- // "Tag cited videos as …" — handing a walked report's judgement to the archive. // // The editor is STUBBED (e2e/fixtures/editor-stub.mjs, a second webServer), for // the same reason the fetch spec stubs it: a real one would arm its runners // against whatever corpus it found, and here it would also WRITE that corpus. // What the stub records is the part nothing on disk can prove — that the // archive's ids, the op, the provenance and the bearer token crossed the wire. // // Two assertions carry the weight: // * b02's LOCAL name is a URL slug and its archive name is an embed id; the // editor must be asked with the archive's, or every Rumble clip silently // tags a key nothing looks up. // * the number shown is the EDITOR'S `changed`, not this tool's count of what // it sent. The stub answers with one fewer on purpose. // --------------------------------------------------------------------------- const HERE = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = path.join(HERE, "..", ".e2e-song"); const STUB_LOG = path.join(FIXTURE, "editor-stub.requests.json"); const PROJECT = "reports/editor-tag-fixture"; type StubRequest = { route?: string; tag?: string; op?: string; source?: string; videos?: { slug: string; id: string }[]; authorization?: string; }; const tagAsks = (): StubRequest[] => { try { return (JSON.parse(readFileSync(STUB_LOG, "utf8")) as StubRequest[]).filter( (r) => r.route === "tag-videos", ); } catch { return []; } }; test("the tag route asks the editor with the ARCHIVE's ids and the project's provenance", async ({ request, baseURL, }) => { const before = tagAsks().length; const read = await request.get( `${baseURL}/api/report/tag?project=${encodeURIComponent(PROJECT)}`, ); expect(read.status(), await read.text()).toBe(200); const state = (await read.json()) as { configured: boolean; reachable: boolean; tags: { id: string }[]; sections: { letter: string; clips: number; videos: number }[]; videos: number; }; expect(state.configured).toBe(true); expect(state.reachable).toBe(true); expect(state.tags.map((t) => t.id)).toContain("eva-collab"); // Four clips, three archive videos: section A cites one recording twice, and // the tag lands on it once. expect(state.videos).toBe(3); expect(state.sections.map((s) => s.letter)).toEqual(["A", "B"]); expect(state.sections.find((s) => s.letter === "A")).toMatchObject({ clips: 2, videos: 1, }); const post = await request.post(`${baseURL}/api/report/tag`, { data: { project: PROJECT, tag: "eva-collab" }, }); expect(post.status(), await post.text()).toBe(200); const answer = (await post.json()) as { changed: number; videos: number }; expect(answer.videos).toBe(3); // The stub answers one fewer than it was sent; the route must pass the // EDITOR's number through rather than counting its own request. expect(answer.changed).toBe(2); const asks = tagAsks().slice(before); expect(asks.length).toBe(1); const ask = asks[0]; expect(ask.authorization).toBe("Bearer umtool-e2e-token"); expect(ask.tag).toBe("eva-collab"); expect(ask.op).toBe("add"); // WHO ASKED. A pin that came from a report can be traced back to it — by the // project's own id, folder and all, which is the string that finds it again. expect(ask.source).toBe(`umtool:${PROJECT}`); const sent = (ask.videos ?? []).map((v) => `${v.slug}/${v.id}`).sort(); expect(sent).toEqual([ "testchan-rumble/v2embedid", "testchan/vid1", "testchan/vid2", ]); }); test("a section is its own scope, and the control on the project page uses it", async ({ page, request, baseURL, }) => { const before = tagAsks().length; // The API first: section B is two videos, one of them the two-id clip. const post = await request.post(`${baseURL}/api/report/tag`, { data: { project: PROJECT, tag: "eva-topic", section: "B" }, }); expect(post.status(), await post.text()).toBe(200); expect((await post.json()).videos).toBe(2); expect(tagAsks().slice(before)[0].videos?.length).toBe(2); // And through the page, which is where an operator actually does it. const asksBeforeUi = tagAsks().length; await page.goto(`/browse/${PROJECT}`); const control = page.locator("[data-tag-cited]"); await expect(control).toBeVisible(); await control.locator("[data-tag-cited-tag]").selectOption("eva-collab"); await control.locator("[data-tag-cited-scope]").selectOption("A"); const button = control.locator('[data-action="tag-cited"]'); await expect(button).toContainText("tag 1 video"); await button.click(); await expect(control.locator("[data-tag-cited-msg]")).toContainText("tagged"); const ui = tagAsks().slice(asksBeforeUi); expect(ui.length).toBe(1); expect(ui[0].tag).toBe("eva-collab"); expect(ui[0].videos).toEqual([{ slug: "testchan", id: "vid1" }]); }); // A CLIP THE TOOL CANNOT NAME IS NAMED BACK. // // A clip carries its channel, or the manifest's provenance does. When neither // does there is no archive key to send, and the honest answer is to say which // clips those were — a tag the operator believes covers the whole report, // quietly covering less of it, is the failure this reports instead. test("clips with no channel are listed, not silently dropped", async ({ request, baseURL, }) => { const project = "reports/editor-tag-nochannel-fixture"; const read = await request.get( `${baseURL}/api/report/tag?project=${encodeURIComponent(project)}`, ); expect(read.status(), await read.text()).toBe(200); const state = (await read.json()) as { videos: number; missing: string[] }; expect(state.videos).toBe(1); expect(state.missing).toEqual(["a02"]); const post = await request.post(`${baseURL}/api/report/tag`, { data: { project, tag: "eva-collab" }, }); expect(post.status(), await post.text()).toBe(200); const answer = (await post.json()) as { videos: number; missing?: string[]; }; expect(answer.videos).toBe(1); expect(answer.missing).toEqual(["a02"]); });