import { test, expect, type Page } from "@playwright/test"; import { mkdir, writeFile } from "node:fs/promises"; import { channelStage, generateReport, jobRowByKind, pathExists, resetData, resolvePath, writeChannelConfig, writeSite, } from "./helpers"; // /channels renders as one table SECTIONED by the active site's channel groups, // each section header carrying that group's slice of the pipeline. Groups // partition a single site's channels, so the sections only exist when a single // site is the active scope — under "all sites" the table stays flat. // A group's , addressed by the header it is labelled by. Row indices are // only ever taken WITHIN one of these — the page-wide row list spans every group // and its headers. const rowgroup = (page: Page, groupId: string) => page.locator(`tbody[aria-labelledby="group-${groupId}"]`); // News sorts FIRST (order 1) even though it is second in the file — sortGroups // orders by explicit order, then name. const GROUPS = [ { id: "default", name: "All channels", selectedByDefault: true, order: 2, }, { id: "news", name: "News", description: "Shows where Jeremy is the guest", selectedByDefault: false, order: 1, }, ]; // Two groups over the shared two-channel pool, plus a third channel so one // group holds two rows (which is what makes a within-group sort observable). async function seed() { await resetData("two-slow-channels"); await writeChannelConfig("slow-c", { name: "Slow C" }); await writeSite("alpha", { siteTitle: "Alpha", groups: GROUPS, channels: [ { slug: "slow-a" }, { slug: "slow-c" }, { slug: "slow-b", groupId: "news" }, ], }); } test("a single site's channels render sectioned by group, with each group's authored description", async ({ page, }) => { await seed(); await page.goto("/channels?site=alpha"); // Section order is sortGroups order, not file order. await expect(page.getByTestId("group-name")).toHaveText([ "News", "All channels", ]); // One rowheader per group, named by the group. scope="rowgroup" keeps these // out of the columnheader bucket channels-sort.spec.ts iterates. await expect(page.getByRole("rowheader")).toHaveCount(2); await expect(page.getByRole("rowheader", { name: "News" })).toBeVisible(); // The authored description — the first time this field is rendered anywhere in // the editor — and the not-preselected note beside it. await expect( page.getByText("Shows where Jeremy is the guest"), ).toBeVisible(); await expect(page.getByText("off by default")).toBeVisible(); // Membership: slow-b is in News, the other two in the default group. await expect( rowgroup(page, "news").getByRole("link", { name: "slow-b" }), ).toBeVisible(); await expect(rowgroup(page, "news").getByRole("row")).toHaveCount(2); // header + 1 await expect( rowgroup(page, "default").getByRole("link", { name: "slow-a" }), ).toBeVisible(); await expect( rowgroup(page, "default").getByRole("link", { name: "slow-c" }), ).toBeVisible(); // Unticking "Group by section" flattens without leaving the site. await page.getByLabel("Group by section").uncheck(); await expect(page.getByRole("rowheader")).toHaveCount(0); await expect(page.getByRole("link", { name: "slow-b" })).toBeVisible(); }); test("under all sites the table stays flat — there is no one grouping across the pool", async ({ page, }) => { await seed(); await page.goto("/channels?site=__all__"); await expect(page.getByRole("rowheader")).toHaveCount(0); await expect(page.getByTestId("group-name")).toHaveCount(0); await expect(page.getByRole("link", { name: "slow-a" })).toBeVisible(); await expect(page.getByRole("link", { name: "slow-b" })).toBeVisible(); }); test("sorting applies within each group, leaving the section order alone", async ({ page, }) => { await seed(); await page.goto("/channels?site=alpha"); const defaultRows = rowgroup(page, "default").getByRole("row"); // Row 0 of a section's rowgroup is its header row. await page.getByRole("button", { name: "sort by Slug" }).click(); await expect(defaultRows.nth(1)).toContainText("slow-a"); await expect(defaultRows.nth(2)).toContainText("slow-c"); // Flip: the rows swap INSIDE the section and the sections stay put. await page.getByRole("button", { name: "sort by Slug" }).click(); await expect(defaultRows.nth(1)).toContainText("slow-c"); await expect(defaultRows.nth(2)).toContainText("slow-a"); await expect(page.getByTestId("group-name")).toHaveText([ "News", "All channels", ]); }); test("a group's Sync queues that group's channels and nothing else", async ({ page, }) => { await seed(); await page.goto("/channels?site=alpha"); await page.getByLabel("sync group News").click(); await expect(page.getByLabel("sync group News result")).toContainText( /Queued 1 . skipped 0/, { timeout: 15_000 }, ); await page.goto("/jobs"); // The machine kind, never the rendered label (/jobs renders jobKindLabel). const syncRows = jobRowByKind(page, "sync"); await expect(syncRows).toHaveCount(1); await expect(syncRows).toContainText("slow-b"); }); test("transcribe is open to a youtube channel; a station with no eligible channel is disabled and says why", async ({ page, }) => { await seed(); await page.goto("/channels?site=alpha"); // Both fixture channels are handling: "youtube" — and that no longer shuts // the station: a youtube video that came down with no captions is whisper // work like any other. Neither channel has reported, so the figure is the // honest unknown, as the download station's is below. const transcribe = page.getByLabel("transcribe group News"); await expect(transcribe).toBeEnabled(); await expect(transcribe).toHaveText("Transcribe —"); // The default test settings enable no operation on the backfill lane, so the // speakers station reads "off" — NOT 0 (which reads as finished) and not — // (which reads as unknown). Its label is derived from the operations that are // on, and with none on it falls back to the honest generic name. const speakers = page.getByLabel("speakers group News"); await expect(speakers).toHaveText("Derived data off"); await expect(speakers).toBeDisabled(); await expect(speakers).toHaveAttribute("title", /not a finished one/); }); test("a group figure reads — until a channel reports, then a real number", async ({ page, }) => { test.setTimeout(120_000); // One site configured → resolveActiveSite defaults to it, so grouping is the // render with no ?site= param at all. await resetData("test-pipeline"); await writeSite("solo", { siteTitle: "Solo", channels: [{ slug: "test-pipeline" }], }); await page.goto("/channels"); const download = page.getByLabel("download group All channels"); // No report yet: the page cannot say, so it says so. Never 0. await expect(download).toContainText("—"); await generateReport(page, "test-pipeline"); await page.goto(channelStage("test-pipeline", "playlist")); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( "Wrote 5 URLs", { timeout: 20_000 }, ); // The snapshot scheduler debounces (~1s), so reload until the figure lands. await expect .poll( async () => { await page.goto("/channels"); return page.getByLabel("download group All channels").textContent(); }, { timeout: 30_000, intervals: [500, 1000, 2000] }, ) .toMatch(/Download 5$/); }); // An auto-caption-only video, shaped like real yt-dlp --write-auto-subs output // (the provenance sniff reads cue settings and inline word timings), with its // audio on disk — i.e. the `downloadedAutoSubsOnly` bucket. const ASR_VTT = `WEBVTT Kind: captions Language: en 00:00:00.030 --> 00:00:03.919 align:start position:0% so<00:00:00.719> today<00:00:01.199> we're<00:00:01.439> going<00:00:01.680> to 00:00:03.919 --> 00:00:03.929 align:start position:0% so today we're going to 00:00:03.929 --> 00:00:07.070 align:start position:0% so today we're going to talk<00:00:04.320> about<00:00:04.639> the<00:00:04.879> whole<00:00:05.199> thing `; test("a youtube group's Transcribe counts and queues its auto-caption-only videos", async ({ page, }) => { test.setTimeout(120_000); await seed(); const id = "asrgrp0001"; const dataRel = `test-transcripts/channels/slow-b/data/${id}`; const dir = resolvePath(dataRel); await mkdir(dir, { recursive: true }); await writeFile(`${dir}/transcript.en.vtt`, ASR_VTT); await writeFile( `${dir}/metadata.info.json`, JSON.stringify({ id, title: `Synthetic ${id}`, upload_date: "20240101", duration: 60, extractor_key: "Youtube", webpage_url: `https://www.youtube.com/watch?v=${id}`, subtitles: {}, automatic_captions: { en: [{ ext: "vtt", url: "fake://subs" }] }, }), ); await writeFile(`${dir}/audio.mp3`, `fake audio ${id}\n`); await writeFile( resolvePath("test-transcripts/channels/slow-b/playlist"), `https://www.youtube.com/watch?v=${id}\n`, ); await generateReport(page, "slow-b"); await page.goto("/channels?site=alpha"); const transcribe = page.getByLabel("transcribe group News"); await expect(transcribe).toHaveText("Transcribe 1"); await expect(transcribe).toBeEnabled(); page.once("dialog", (d) => void d.accept()); await transcribe.click(); await expect(page.getByLabel("transcribe group News result")).toContainText( /Queued 1 . skipped 0/, { timeout: 15_000 }, ); await page.goto("/jobs"); const rows = jobRowByKind(page, "whisper-bucket-auto-subs"); await expect(rows).toHaveCount(1); await expect(rows).toContainText("slow-b"); // Nothing was captionless, so neither the by-id no-transcript batch nor the // scan rode along. await expect( jobRowByKind(page, "whisper-bucket-downloaded-no-transcript"), ).toHaveCount(0); await expect(jobRowByKind(page, "whisper-all")).toHaveCount(0); // Let the fake whisper finish before the next spec's resetData. await expect .poll(() => pathExists(`${dataRel}/transcript.json`), { timeout: 60_000 }) .toBe(true); });