import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, generateReport, pathExists, resetData, resolvePath, } from "./helpers"; test("per-video page renders metadata and file list", async ({ page }) => { await resetData("one-youtube-channel-with-data"); await page.goto("/channels/test-youtube/videos/20240101_test1234567"); // By NAME, not by level: the channel layout owns the page's single h1 (the // channel name) so the video title is an h2 — and so is every section // heading in the panel below it, which makes `{ level: 2 }` ambiguous. await expect( page.getByRole("heading", { name: "Synthetic Test Video" }), ).toBeVisible(); const fileList = page.getByLabel("files for 20240101_test1234567"); await expect( fileList.getByLabel("file name metadata.info.json"), ).toBeVisible(); await expect( fileList.getByLabel("file name transcript.en.vtt"), ).toBeVisible(); // This fixture has a VTT and NO transcript.cues.json — the state 1,942 videos // on the live corpus are in. The digest defers on it, and this is the first // place the reason is legible per video rather than as a channel-wide count. await expect(page.getByLabel("digest freshness")).toContainText( "held — waiting on a normalized transcript", ); }); test("per-video page 404s for missing channel/video", async ({ page }) => { await resetData("empty"); const res = await page.goto("/channels/does-not-exist/videos/whatever", { waitUntil: "domcontentloaded", }); expect(res?.status()).toBe(404); }); test("WhisperPanel renders failure list as links to per-video pages", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); await writeFile( resolvePath( "test-transcripts/channels/test-transcribe/failed-transcriptions", ), "vidA\nvidB\n", ); await generateReport(page, "test-transcribe"); await page.goto(channelStage("test-transcribe", "transcribe")); await expect(page.getByLabel("failed transcription vidA")).toHaveAttribute( "href", "/channels/test-transcribe/videos/vidA", ); await expect(page.getByLabel("failed transcription vidB")).toBeVisible(); }); test("failed entry stays in the list across reloads (skip semantics)", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); await writeFile( resolvePath( "test-transcripts/channels/test-transcribe/failed-transcriptions", ), "vidA\n", ); await generateReport(page, "test-transcribe"); await page.goto(channelStage("test-transcribe", "transcribe")); await expect(page.getByLabel("failed transcription vidA")).toBeVisible(); // Running Transcribe missing should skip the listed failure rather than // re-attempt it (the old retry-failures flow has been replaced by Clear). // Failed videos are filtered out of the batch up front, so only the other // two (vidB, vidC) are attempted and vidA is never started. await page.getByRole("button", { name: "Transcribe missing" }).click(); const out = page.getByLabel("Transcribe missing output"); await expect(out).toContainText( "Whisper batch: 2 succeeded, 0 failed, 0 skipped, 2 attempted.", { timeout: 30_000 }, ); // vidA was excluded, never transcribed… expect(await out.textContent()).not.toContain("Transcribe vidA"); // …and it stays on the failed list (skip semantics persist). const failList = await readFile( resolvePath( "test-transcripts/channels/test-transcribe/failed-transcriptions", ), "utf8", ); expect(failList).toContain("vidA"); await page.reload(); await expect(page.getByLabel("failed transcription vidA")).toBeVisible(); }); test("transcribe-one writes transcript.json for a specific audio file", async ({ page, }) => { // The disk poll plus the three UI waits below outrun the config's 30 s budget // on a slow box, and a budget timeout hides which assertion failed. test.setTimeout(120_000); await resetData("one-transcribe-channel-with-audio"); await page.goto("/channels/test-transcribe/videos/vidA"); await page.getByRole("button", { name: "Transcribe audio.m4a" }).click(); // Gate on the durable artifact the action produces rather than the streamed // "[fake-whisper] wrote …" line, which can race the assertion. await expect .poll( () => pathExists( "test-transcripts/channels/test-transcribe/data/vidA/transcript.json", ), { timeout: 30_000 }, ) .toBe(true); // AND THE ROW'S LOG SURVIVES THE REFRESH ITS OWN RUN FIRES. `hasTranscript` // is server-derived, so the transcript this run just wrote makes the row // render its "already present" line; when that line REPLACED the run panel, // StreamActionLog was unmounted and the log the operator was reading went // with it (plans/FACTS.md, "A run log lives in the panel's React state"). // Asserted only after that line is on screen, so the refresh has provably // landed — the same happens-before edge attribution.spec.ts takes on the // freshness pill. await expect( page.getByText("Transcribe disabled — transcript already present."), ).toBeVisible({ timeout: 15_000 }); await expect(page.getByLabel("Transcribe audio.m4a output")).toContainText( "Transcribe vidA done in", ); // Kept for its log, not for a second run. await expect( page.getByRole("button", { name: "Transcribe audio.m4a" }), ).toBeDisabled(); }); test("transcode keeps source and writes audio.", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await page.goto("/channels/test-transcribe/videos/vidA"); // Source is m4a, so the first non-source target offered is mp3. await page .getByRole("button", { name: /^Transcode audio\.m4a → mp3$/ }) .click(); await expect( page.getByLabel("Transcode audio.m4a to mp3 output"), ).toContainText("wrote", { timeout: 30_000 }); expect( await pathExists( "test-transcripts/channels/test-transcribe/data/vidA/audio.mp3", ), ).toBe(true); // Source file still on disk. expect( await pathExists( "test-transcripts/channels/test-transcribe/data/vidA/audio.m4a", ), ).toBe(true); }); test("audio + whisper for a YouTube video with no auto-subs", async ({ page, }) => { await resetData("one-youtube-channel-with-data"); // Stage a video dir whose name matches the URL id so fake-ytdlp's audio // output (data//audio.) lands back in the same dir we navigate to. const dir = resolvePath( "test-transcripts/channels/test-youtube/data/test1234567", ); await mkdir(dir, { recursive: true }); await writeFile( `${dir}/metadata.info.json`, JSON.stringify({ id: "test1234567", title: "No-subs video", webpage_url: "https://www.youtube.com/watch?v=test1234567", }), ); await page.goto("/channels/test-youtube/videos/test1234567"); await expect( page.getByRole("heading", { name: "No-subs video" }), ).toBeVisible(); await page.getByLabel("download mode").selectOption("whisper"); await page.getByRole("button", { name: /^Audio \+ Whisper$/ }).click(); await expect( page.getByLabel("Audio + Whisper for test1234567 output"), ).toContainText("Transcribe test1234567 done", { timeout: 30_000 }); expect( await pathExists( "test-transcripts/channels/test-youtube/data/test1234567/audio.mp3", ), ).toBe(true); }); test("Go to video form navigates to per-video page", async ({ page }) => { await resetData("one-youtube-channel-with-data"); await generateReport(page, "test-youtube"); await page.goto(channelStage("test-youtube", "diagnostics")); const form = page.getByLabel("Go to video"); await form.getByLabel("Video ID").fill("20240101_test1234567"); await form.getByRole("button", { name: "Open" }).click(); await expect(page).toHaveURL( "/channels/test-youtube/videos/20240101_test1234567", ); }); test("Empty failure list shows muted placeholder", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); await page.goto(channelStage("test-transcribe", "transcribe")); await expect( page.getByLabel("failed transcriptions empty"), ).toBeVisible(); }); test("Delete file removes a single file from the directory", async ({ page }) => { await resetData("one-youtube-channel-with-data"); await page.goto("/channels/test-youtube/videos/20240101_test1234567"); page.on("dialog", (d) => d.accept()); expect( await pathExists( "test-transcripts/channels/test-youtube/data/20240101_test1234567/transcript.en.vtt", ), ).toBe(true); await page.getByLabel("delete file transcript.en.vtt").click(); await expect(page.getByLabel("file name transcript.en.vtt")).toHaveCount(0); // metadata.info.json is untouched. await expect(page.getByLabel("file name metadata.info.json")).toBeVisible(); expect( await pathExists( "test-transcripts/channels/test-youtube/data/20240101_test1234567/transcript.en.vtt", ), ).toBe(false); }); test("Delete directory wrong-id confirmation shows an error", async ({ page, }) => { await resetData("one-youtube-channel-with-data"); await page.goto("/channels/test-youtube/videos/20240101_test1234567"); const form = page.getByLabel( "delete video directory 20240101_test1234567", ); await form.getByLabel("confirm video id").fill("nope"); await form.getByRole("button", { name: "Delete directory" }).click(); await expect(page.getByLabel("delete directory error")).toContainText( "Type the video id", ); // Still on the per-video page, dir intact. await expect(page).toHaveURL( "/channels/test-youtube/videos/20240101_test1234567", ); expect( await pathExists( "test-transcripts/channels/test-youtube/data/20240101_test1234567", ), ).toBe(true); }); test("Delete directory removes the dir and redirects to the channel page", async ({ page, }) => { await resetData("one-youtube-channel-with-data"); await page.goto("/channels/test-youtube/videos/20240101_test1234567"); const form = page.getByLabel( "delete video directory 20240101_test1234567", ); await form.getByLabel("confirm video id").fill("20240101_test1234567"); await form.getByRole("button", { name: "Delete directory" }).click(); await expect(page).toHaveURL("/channels/test-youtube"); expect( await pathExists( "test-transcripts/channels/test-youtube/data/20240101_test1234567", ), ).toBe(false); }); test("Mark untranscribable writes empty transcript and prunes failure list", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); await writeFile( resolvePath( "test-transcripts/channels/test-transcribe/failed-transcriptions", ), "vidA\nvidOther\n", ); await page.goto("/channels/test-transcribe/videos/vidA"); await page.getByLabel("mark video vidA untranscribable").click(); await expect( page.getByLabel("mark video vidA untranscribable"), ).toHaveCount(0); const raw = await readFile( resolvePath( "test-transcripts/channels/test-transcribe/data/vidA/transcript.json", ), "utf8", ); expect(JSON.parse(raw)).toEqual({ transcription: [] }); const list = await readFile( resolvePath( "test-transcripts/channels/test-transcribe/failed-transcriptions", ), "utf8", ); expect(list.trim().split("\n")).toEqual(["vidOther"]); }); test("Mark untranscribable button is absent when transcript already exists", async ({ page, }) => { // Hybrid channels: any video that already has a .vtt or transcript.json // hides the marker, regardless of channel handling. await resetData("one-youtube-channel-with-data"); await page.goto("/channels/test-youtube/videos/20240101_test1234567"); await expect( page.getByLabel("mark video 20240101_test1234567 untranscribable"), ).toHaveCount(0); }); test("text files render a preview, not the audio player", async ({ page }) => { await resetData("one-youtube-channel-with-data"); const dir = resolvePath( "test-transcripts/channels/test-youtube/data/20240101_test1234567", ); // Stage misleadingly-named text files plus a real audio.mp3 in the same // video dir. Old code matched name.startsWith("audio.") and would render //