import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { test, expect } from "@playwright/test"; import { resetData, resolvePath, readJson, jobRowByKind, generateReport} from "./helpers"; // The channel-work tables on the operation pages, and the report controls on // /channels. This file was actionable.spec.ts: /actionable rendered all of it // on one page, and slice 4 sent each section to the page that owns the work. const YT_SNAPSHOT_REL = "test-transcripts/channels/test-youtube/snapshot.json"; const YT_CONFIG_REL = "test-transcripts/channels/test-youtube/config.json"; const TR_SNAPSHOT_REL = "test-transcripts/channels/test-transcribe/snapshot.json"; async function writeJson(relPath: string, value: unknown): Promise { const full = resolvePath(relPath); await mkdir(dirname(full), { recursive: true }); await writeFile(full, JSON.stringify(value, null, 2) + "\n"); } // The page-wide "Everything is up to date." state went with the page. Each // section now says whether IT is empty, which is the thing an operator on one // operation's page is asking. test("a section with no work says so on its operation's page", async ({ page, }) => { await resetData("empty"); await page.goto("/operations/download"); await expect(page.getByLabel("undownloaded empty")).toContainText( "Nothing pending.", ); }); test("Actionable is gone from the sidebar", async ({ page }) => { await resetData("empty"); await page.goto("/"); await expect( page.locator("aside").getByRole("link", { name: /^Actionable$/ }), ).toHaveCount(0); }); test("populates the undownloaded section after a snapshot exists", async ({ page, }) => { await resetData("youtube-with-playlist"); // Visiting the channel detail page auto-generates the snapshot. await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); await page.goto("/operations/download"); const section = page.getByRole("region", { name: "undownloaded", exact: true, }); await expect(section).toBeVisible(); await expect(section.getByRole("heading", { name: /undownloaded videos/i })).toBeVisible(); const row = section.getByLabel("undownloaded row test-youtube"); await expect(row).toBeVisible(); await expect(row).toContainText("4"); await expect(row.getByRole("link", { name: "test-youtube" })).toHaveAttribute( "href", "/channels/test-youtube", ); }); test("populates the awaiting-transcription section", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.goto("/operations/transcription"); const section = page.getByRole("region", { name: "untranscribed", exact: true, }); const row = section.getByLabel("untranscribed row test-transcribe"); await expect(row).toBeVisible(); // vidA, vidB, vidC all have audio but no transcript. await expect(row).toContainText("3"); }); test("flags channels whose report is older than the last sync", async ({ page, }) => { await resetData("one-youtube-channel"); // Backdate the snapshot relative to lastSyncedAt to force the stale check. const cfg = await readJson>(YT_CONFIG_REL); cfg.lastSyncedAt = "2026-05-21T12:00:00.000Z"; await writeJson(YT_CONFIG_REL, cfg); await writeJson(YT_SNAPSHOT_REL, { generatedAt: "2026-05-20T12:00:00.000Z", totals: { videos: 0, transcribed: 0, downloaded: 0 }, buckets: { noTranscript: [], downloadedNoTranscript: [], untranscoded: [], multipleAudioFormats: [], untranscribable: [], noMetadata: [], failedListed: [], missingFromArchive: [], duplicateDirs: [], partialDownloads: [], }, undownloadedIds: [], }); await page.goto("/channels"); await expect(page.getByLabel("report age for test-youtube")).toContainText( "stale", ); }); test("'Update all reports' queues a refresh-report job per channel", async ({ page, }) => { await resetData("one-youtube-channel"); await page.goto("/channels"); await page.getByRole("button", { name: "update all reports" }).click(); await expect(page.getByLabel("update all reports result")).toContainText( /Queued 1/, ); await page.goto("/jobs"); // refresh-report is the ONE kind /jobs hides by default // (DEFAULT_HIDDEN_KINDS in app/jobs/jobsFilterStorage.ts), so un-hide it // before asserting. Without this the test passes only by RACING ITS OWN // HYDRATION: JobsTable renders every job while `filters` is still null // (`if (!filters) return jobs`) and drops refresh-report as soon as // localStorage loads. That race is won under `next dev` and lost under // `next start`, which is why this failed only in the sharded route. // The chip is always present: `kinds` is derived from ALL jobs, not the // visible ones. Retried because a click before hydration fires nothing. const kindChip = page.getByRole("button", { name: "refresh-report", exact: true, }); await expect(async () => { await kindChip.click(); await expect(jobRowByKind(page, "refresh-report").first()).toBeVisible({ timeout: 2_000, }); }).toPass({ timeout: 20_000 }); }); test("syncing a channel auto-regenerates its report", async ({ page }) => { // one-transcribe-channel-with-audio uses an odysee URL — sync will fail // (the URL is fake), but the report regen happens after runYtdlp regardless // because we only swallow regen errors, not sync errors. To avoid relying // on a working sync, drive the auto-regen by visiting the channel page // which generates the snapshot if missing — and assert the snapshot file // appears with a fresh timestamp. // // Then trigger an actual pipeline action that completes locally — // Refresh report — and verify the snapshot's generatedAt advances. await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); const first = await readJson<{ generatedAt: string }>(TR_SNAPSHOT_REL); await new Promise((r) => setTimeout(r, 50)); // ensure a distinct ISO timestamp await page.goto("/channels"); const refresh = page.getByRole("button", { name: "refresh report test-transcribe", }); await refresh.waitFor({ state: "visible" }); // Retried, and the timeout is generous: a click landing before React hydrates // fires NOTHING — no request, no job, no error — which is the long-standing // flake pattern in this suite. Regenerating a report is idempotent, so an // extra click costs nothing and a missed one costs the whole test. await expect .poll( async () => { await refresh.click({ timeout: 5_000 }).catch(() => {}); for (let i = 0; i < 20; i++) { const cur = await readJson<{ generatedAt: string }>(TR_SNAPSHOT_REL); if (cur.generatedAt > first.generatedAt) return true; await new Promise((r) => setTimeout(r, 250)); } return false; }, { timeout: 60_000, intervals: [0, 0, 0] }, ) .toBe(true); }); test("inline 'Download missing' queues a download-missing job", async ({ page, }) => { await resetData("youtube-with-playlist"); await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); // auto-generate snapshot await page.goto("/operations/download"); await page .getByRole("button", { name: "download missing test-youtube" }) .click(); await expect( page.getByLabel("download missing test-youtube job"), ).toBeVisible({ timeout: 10_000 }); await page.goto("/jobs"); await expect(jobRowByKind(page, "download-missing").first()).toBeVisible({ timeout: 10_000, }); }); test("inline 'Transcribe pending' queues a whisper-all job", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); // auto-generate snapshot await page.goto("/operations/transcription"); await page .getByRole("button", { name: "transcribe pending test-transcribe" }) .click(); await expect( page.getByLabel("transcribe pending test-transcribe job"), ).toBeVisible({ timeout: 10_000 }); await page.goto("/jobs"); await expect(jobRowByKind(page, "whisper-all").first()).toBeVisible({ timeout: 10_000, }); }); // The cockpit redesign (c63f5cb) renamed this card from "Needs attention" to // "Needs work" and changed its shape: it is now a
that always renders // — listing channels when there is work and "Everything's handled." when there // isn't — rather than a single link that disappeared when idle. The old // "hides the card" assertion passed VACUOUSLY once the label changed (a count // of an element nobody labels is always 0), so it is rewritten to assert the // empty state it actually means. test("dashboard surfaces the 'Needs work' card when work is pending", async ({ page, }) => { await resetData("youtube-with-playlist"); await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); // auto-generate snapshot await page.goto("/"); // getByRole("region"), not getByLabel: the label match is a case-insensitive // substring, so getByLabel("Needs work") also picks up each //
  • inside the card. const card = page.getByRole("region", { name: "Needs work" }); await expect(card).toBeVisible(); await expect(card.getByLabel("needs work test-youtube")).toBeVisible(); // The card's channel-count link is what navigates to the full list. await expect(card.locator('a[href="/operations"]')).toBeVisible(); }); test("dashboard's 'Needs work' card reads as empty when nothing is pending", async ({ page, }) => { await resetData("empty"); await page.goto("/"); await expect(page.getByLabel("needs work empty")).toBeVisible(); });