commit 26bb594c8cea7565d64bd144521214bc33aacebf parent d5dc289e475f85c64d90b29e77613a67802a1323 Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st> Date: Tue, 28 Apr 2026 00:45:33 -0400 test(editor): migrate action specs to playwright Five cy specs (pipeline, whisper, build, sync-break-on-existing, cancel) re-authored against role/text/label queries; whisper-* and build-export testids gone, replaced with section-scoped accessible names. StreamActionLog error gains role="alert"/aria-label so the "no error" assertion stays user-like. Fake-yt-dlp's --cy-* flags renamed to --test-* (and fixtures + remaining cy specs updated to match the new log line). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Diffstat:
20 files changed, 312 insertions(+), 324 deletions(-)
diff --git a/common/components/StreamActionLog.tsx b/common/components/StreamActionLog.tsx @@ -157,6 +157,8 @@ export function StreamActionLog({ </div> {error && ( <div + role="alert" + aria-label={`${accessibleName} error`} data-testid={testId ? `${testId}-error` : undefined} className="rounded border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-3 py-2 text-sm text-red-700 dark:text-red-300" > diff --git a/editor/app/build/_components/BuildButtons.tsx b/editor/app/build/_components/BuildButtons.tsx @@ -55,7 +55,6 @@ export function BuildButtons({ existingQueues }: Props) { cancelAction={cancelJobAction} buttonLabel="Build static export" runningLabel="Building static export…" - testId="build-export" /> </section> </div> diff --git a/editor/app/channels/[slug]/_components/WhisperPanel.tsx b/editor/app/channels/[slug]/_components/WhisperPanel.tsx @@ -38,7 +38,6 @@ export function WhisperPanel({ slug, existingQueues }: Props) { cancelAction={cancelJobAction} buttonLabel="Transcribe missing" runningLabel="Transcribing…" - testId="whisper-all" /> </div> <div className="flex flex-col gap-2"> @@ -51,7 +50,6 @@ export function WhisperPanel({ slug, existingQueues }: Props) { cancelAction={cancelJobAction} buttonLabel="Retry failures" runningLabel="Retrying…" - testId="whisper-retry" /> </div> <div className="flex flex-col gap-2"> @@ -81,7 +79,7 @@ function VerifyButton({ slug }: { slug: string }) { } return ( - <div className="flex flex-col gap-2" data-testid="whisper-verify"> + <div className="flex flex-col gap-2"> <button type="button" onClick={handleClick} @@ -91,13 +89,16 @@ function VerifyButton({ slug }: { slug: string }) { {running ? "Verifying…" : "Verify"} </button> {result?.ok === false && ( - <div className="rounded border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-3 py-2 text-sm text-red-700 dark:text-red-300"> + <div + role="alert" + className="rounded border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-3 py-2 text-sm text-red-700 dark:text-red-300" + > {result.error} </div> )} {result?.ok === true && ( <div - data-testid="whisper-verify-result" + aria-label="verify result" className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm" > <Bucket label="Duplicates" items={result.duplicates} /> diff --git a/editor/cypress/e2e/build.cy.ts b/editor/cypress/e2e/build.cy.ts @@ -1,48 +0,0 @@ -describe("Build", () => { - it("build index against an empty fixture writes a 0-transcript manifest", () => { - cy.resetData("empty"); - cy.visit("/build"); - cy.findByTestId("build-index").find("button").click(); - cy.findByTestId("build-index-log", { timeout: 20_000 }).should( - "contain.text", - "Done", - ); - cy.task( - "readJson", - "test-transcripts/.export-public/summaries/manifest.json", - ).then((manifest) => { - expect((manifest as { totalCount: number }).totalCount).to.equal(0); - }); - }); - - it("build index ingests synthetic video data and emits a transcript page", () => { - cy.resetData("one-youtube-channel-with-data"); - cy.visit("/build"); - cy.findByTestId("build-index").find("button").click(); - cy.findByTestId("build-index-log", { timeout: 20_000 }).should( - "contain.text", - "Done", - ); - cy.task( - "readJson", - "test-transcripts/.export-public/summaries/manifest.json", - ).then((manifest) => { - const m = manifest as { totalCount: number; channels: { name: string }[] }; - expect(m.totalCount).to.equal(1); - expect(m.channels.map((c) => c.name)).to.include("Test YouTube Channel"); - }); - cy.task( - "pathExists", - "test-transcripts/.export-public/transcripts/test-youtube/page-0000.json", - ).then((exists) => expect(exists).to.equal(true)); - }); - - it("build static export button renders and is enabled", () => { - cy.resetData("empty"); - cy.visit("/build"); - cy.findByTestId("build-export") - .find("button") - .should("not.be.disabled") - .and("contain.text", "Build static export"); - }); -}); diff --git a/editor/cypress/e2e/cancel.cy.ts b/editor/cypress/e2e/cancel.cy.ts @@ -1,42 +0,0 @@ -describe("Cancel job", () => { - it("cancels a running pipeline job from the channel page", () => { - cy.resetData("slow-pipeline-channel"); - cy.visit("/channels/slow-channel"); - cy.findByTestId("pipeline-sync").find("button").click(); - // Wait for the slow fake-ytdlp to start logging. - cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( - "contain.text", - "cypress-slow", - ); - cy.findByTestId("pipeline-sync-cancel").click(); - // After cancel, the run button should re-enable (Cancel button gone). - cy.findByTestId("pipeline-sync-cancel", { timeout: 15_000 }).should( - "not.exist", - ); - cy.findByTestId("pipeline-sync") - .find("button") - .should("not.be.disabled") - .and("contain.text", "Sync"); - // No data dir should have been written. - cy.task( - "pathExists", - "test-transcripts/channels/slow-channel/data", - ).then((exists) => expect(exists).to.equal(false)); - }); - - it("cancels a running job from the jobs list", () => { - cy.resetData("slow-pipeline-channel"); - cy.visit("/channels/slow-channel"); - cy.findByTestId("pipeline-sync").find("button").click(); - cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( - "contain.text", - "cypress-slow", - ); - - cy.visit("/jobs"); - cy.contains("running"); - cy.get('[data-testid^="cancel-"]').first().click(); - // Row's status flips to cancelled (refresh runs, page rerenders). - cy.contains("cancelled", { timeout: 15_000 }); - }); -}); diff --git a/editor/cypress/e2e/jobs-channel.cy.ts b/editor/cypress/e2e/jobs-channel.cy.ts @@ -31,7 +31,7 @@ describe("Job→channel link and jobs list auto-refresh", () => { cy.findByTestId("pipeline-sync").find("button").click(); cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( "contain.text", - "cypress-slow", + "test-slow", ); // Switch to the jobs list and don't manually reload. The status should diff --git a/editor/cypress/e2e/pipeline.cy.ts b/editor/cypress/e2e/pipeline.cy.ts @@ -1,110 +0,0 @@ -describe("Pipeline", () => { - it("store playlist writes a playlist file with 5 URLs", () => { - cy.resetData("test-pipeline"); - cy.visit("/channels/test-pipeline"); - cy.findByTestId("pipeline-store").find("button").click(); - cy.findByTestId("pipeline-store-log", { timeout: 20_000 }).should( - "contain.text", - "Wrote 5 URLs", - ); - cy.task( - "readJson", - "test-transcripts/channels/test-pipeline/config.json", - ); - cy.task( - "pathExists", - "test-transcripts/channels/test-pipeline/playlist", - ).then((exists) => expect(exists).to.equal(true)); - }); - - it("download from playlist fetches all 5 URLs and writes the archive", () => { - cy.resetData("test-pipeline"); - cy.visit("/channels/test-pipeline"); - cy.findByTestId("pipeline-store").find("button").click(); - cy.findByTestId("pipeline-store-log", { timeout: 20_000 }).should( - "contain.text", - "Wrote 5 URLs", - ); - - cy.findByTestId("pipeline-download").find("button").click(); - cy.findByTestId("pipeline-download-log", { timeout: 30_000 }).should( - "contain.text", - "5 new, 0 already archived", - ); - cy.findByTestId("pipeline-download-log").should( - "contain.text", - "download complete", - ); - - for (let i = 1; i <= 5; i++) { - cy.task( - "pathExists", - `test-transcripts/channels/test-pipeline/data/fake0000000${i}/metadata.info.json`, - ).then((exists) => expect(exists).to.equal(true)); - } - cy.task("readJson", "test-transcripts/channels/test-pipeline/config.json") - .then((c) => { - expect((c as { lastFullDownloadAt?: string }).lastFullDownloadAt).to.be - .a("string"); - }); - }); - - it("re-running download skips already-archived entries", () => { - cy.resetData("test-pipeline"); - cy.visit("/channels/test-pipeline"); - cy.findByTestId("pipeline-store").find("button").click(); - cy.findByTestId("pipeline-store-log", { timeout: 20_000 }).should( - "contain.text", - "Wrote 5 URLs", - ); - cy.findByTestId("pipeline-download").find("button").click(); - cy.findByTestId("pipeline-download-log", { timeout: 30_000 }).should( - "contain.text", - "download complete", - ); - // Second click — prefilter should skip everything and we should never see - // yt-dlp invoked with a non-empty playlist. - cy.findByTestId("pipeline-download").find("button").click(); - cy.findByTestId("pipeline-download-log", { timeout: 20_000 }).should( - "contain.text", - "0 new, 5 already archived", - ); - cy.findByTestId("pipeline-download-log").should( - "contain.text", - "Nothing to fetch", - ); - }); - - it("sync downloads one new entry and writes lastSyncedAt", () => { - cy.resetData("test-pipeline"); - cy.visit("/channels/test-pipeline"); - cy.findByTestId("pipeline-sync").find("button").click(); - cy.findByTestId("pipeline-sync-log", { timeout: 30_000 }).should( - "contain.text", - "fakeSync0001 done", - ); - cy.task( - "pathExists", - "test-transcripts/channels/test-pipeline/data/fakeSync0001/metadata.info.json", - ).then((exists) => expect(exists).to.equal(true)); - cy.task("readJson", "test-transcripts/channels/test-pipeline/config.json") - .then((c) => { - expect((c as { lastSyncedAt?: string }).lastSyncedAt).to.be.a("string"); - }); - }); - - it("blocks pipeline buttons when channel has no url", () => { - cy.resetData("empty"); - cy.visit("/channels/new"); - cy.findByLabelText(/^name/i).type("No URL Channel"); - cy.findByLabelText(/^slug/i).clear().type("no-url"); - // Leave url blank - cy.findByRole("button", { name: /create channel/i }).click(); - cy.location("pathname", { timeout: 10000 }).should( - "eq", - "/channels/no-url", - ); - cy.contains(/set a/i); - cy.findByTestId("pipeline-store").should("not.exist"); - }); -}); diff --git a/editor/cypress/e2e/queues.cy.ts b/editor/cypress/e2e/queues.cy.ts @@ -5,7 +5,7 @@ describe("Named job queues", () => { cy.findByTestId("pipeline-sync").find("button").click(); cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( "contain.text", - "cypress-slow", + "test-slow", ); cy.visit("/jobs"); cy.get('[data-testid^="job-row-queue-"]') @@ -23,7 +23,7 @@ describe("Named job queues", () => { cy.findByTestId("pipeline-sync").find("button").click(); cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( "contain.text", - "cypress-slow", + "test-slow", ); cy.visit("/channels/slow-b"); @@ -66,7 +66,7 @@ describe("Named job queues", () => { cy.findByTestId("pipeline-sync").find("button").click(); cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( "contain.text", - "cypress-slow", + "test-slow", ); cy.visit("/channels/slow-b"); @@ -76,7 +76,7 @@ describe("Named job queues", () => { cy.findByTestId("pipeline-sync").find("button").click(); cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( "contain.text", - "cypress-slow", + "test-slow", ); cy.visit("/jobs"); @@ -94,7 +94,7 @@ describe("Named job queues", () => { cy.findByTestId("pipeline-sync").find("button").click(); cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( "contain.text", - "cypress-slow", + "test-slow", ); cy.visit("/channels/slow-b"); diff --git a/editor/cypress/e2e/sync-break-on-existing.cy.ts b/editor/cypress/e2e/sync-break-on-existing.cy.ts @@ -1,45 +0,0 @@ -describe("Sync with --break-on-existing (yt-dlp exit 101)", () => { - it("treats yt-dlp exit code 101 as success and updates lastSyncedAt", () => { - cy.resetData("sync-already-archived"); - cy.visit("/channels/archived-channel"); - - cy.findByTestId("pipeline-sync").find("button").click(); - cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( - "contain.text", - "exit 101", - ); - - // Run button should re-enable and there's no Cancel button (i.e., not still - // running and not in an error state). - cy.findByTestId("pipeline-sync") - .find("button") - .should("not.be.disabled") - .and("contain.text", "Sync"); - cy.findByTestId("pipeline-sync-cancel").should("not.exist"); - cy.findByTestId("pipeline-sync-error").should("not.exist"); - - // Config now has lastSyncedAt set to a parseable ISO string. - cy.task( - "readJson", - "test-transcripts/channels/archived-channel/config.json", - ).then((config) => { - const c = config as { lastSyncedAt?: string }; - expect(c.lastSyncedAt, "lastSyncedAt").to.be.a("string"); - expect( - Number.isNaN(Date.parse(c.lastSyncedAt!)), - "lastSyncedAt parses", - ).to.equal(false); - }); - - // The job lands as `done`, not `failed`. - cy.visit("/jobs"); - cy.findByTestId("jobs-table").within(() => { - cy.contains("sync"); - }); - cy.get('[data-testid^="job-row-"]') - .first() - .within(() => { - cy.contains(/^done$/i); - }); - }); -}); diff --git a/editor/cypress/e2e/whisper.cy.ts b/editor/cypress/e2e/whisper.cy.ts @@ -1,58 +0,0 @@ -describe("Whisper", () => { - it("transcribes every audio file with no transcript", () => { - cy.resetData("one-transcribe-channel-with-audio"); - cy.visit("/channels/test-transcribe"); - cy.findByTestId("whisper-all").find("button").click(); - cy.findByTestId("whisper-all-log", { timeout: 30_000 }).should( - "contain.text", - "3 succeeded", - ); - for (const id of ["vidA", "vidB", "vidC"]) { - cy.task( - "pathExists", - `test-transcripts/channels/test-transcribe/data/${id}/transcript.json`, - ).then((exists) => expect(exists).to.equal(true)); - } - }); - - it("verify reports nothing missing once transcribed", () => { - cy.resetData("one-transcribe-channel-with-audio"); - cy.visit("/channels/test-transcribe"); - cy.findByTestId("whisper-all").find("button").click(); - cy.findByTestId("whisper-all-log", { timeout: 30_000 }).should( - "contain.text", - "3 succeeded", - ); - cy.findByTestId("whisper-verify").find("button").click(); - cy.findByTestId("whisper-verify-result", { timeout: 10_000 }) - .should("contain.text", "Duplicates (0)") - .and("contain.text", "Missing (0)"); - }); - - it("verify flags missing transcripts before whisper runs", () => { - cy.resetData("one-transcribe-channel-with-audio"); - cy.visit("/channels/test-transcribe"); - cy.findByTestId("whisper-verify").find("button").click(); - cy.findByTestId("whisper-verify-result", { timeout: 10_000 }).should( - "contain.text", - "Missing (3)", - ); - }); - - it("retry-failures runs cleanly with an empty failure list", () => { - cy.resetData("one-transcribe-channel-with-audio"); - cy.visit("/channels/test-transcribe"); - cy.findByTestId("whisper-retry").find("button").click(); - cy.findByTestId("whisper-retry-log", { timeout: 30_000 }).should( - "contain.text", - "0 succeeded", - ); - }); - - it("whisper panel hidden for youtube channels", () => { - cy.resetData("one-youtube-channel"); - cy.visit("/channels/test-youtube"); - cy.findByTestId("whisper-all").should("not.exist"); - cy.findByTestId("whisper-verify").should("not.exist"); - }); -}); diff --git a/editor/cypress/fixtures/bin/fake-ytdlp.mjs b/editor/cypress/fixtures/bin/fake-ytdlp.mjs @@ -125,17 +125,17 @@ function sleep(ms) { } async function main() { - // Cypress hook for cancellation tests — sleep before doing any work so - // a Cancel click during the sleep can cleanly SIGTERM us. - if (has("--cy-slow")) { + // Test hook for cancellation tests — sleep before doing any work so a + // Cancel click during the sleep can cleanly SIGTERM us. + if (has("--test-slow")) { // Stderr because store-playlist mode buffers stdout for the URL list. - process.stderr.write(`[fake-ytdlp] sleeping (cypress-slow)\n`); + process.stderr.write(`[fake-ytdlp] sleeping (test-slow)\n`); await sleep(30_000); } // Emit a friendly message and exit 101 so the editor's exit-code handling // can be tested against the "nothing new to download" case. - if (has("--cy-break-on-existing")) { + if (has("--test-break-on-existing")) { process.stderr.write(`[fake-ytdlp] all entries already in archive\n`); process.exit(101); } diff --git a/editor/cypress/fixtures/test-transcripts/slow-pipeline-channel/channels/slow-channel/config.json b/editor/cypress/fixtures/test-transcripts/slow-pipeline-channel/channels/slow-channel/config.json @@ -2,5 +2,5 @@ "handling": "youtube", "name": "Slow Channel", "url": "https://www.youtube.com/@slow/videos", - "ytdlpExtraArgs": ["--cy-slow"] + "ytdlpExtraArgs": ["--test-slow"] } diff --git a/editor/cypress/fixtures/test-transcripts/sync-already-archived/channels/archived-channel/config.json b/editor/cypress/fixtures/test-transcripts/sync-already-archived/channels/archived-channel/config.json @@ -2,5 +2,5 @@ "handling": "youtube", "name": "Archived Channel", "url": "https://www.youtube.com/@archived/videos", - "ytdlpExtraArgs": ["--cy-break-on-existing"] + "ytdlpExtraArgs": ["--test-break-on-existing"] } diff --git a/editor/cypress/fixtures/test-transcripts/two-slow-channels/channels/slow-a/config.json b/editor/cypress/fixtures/test-transcripts/two-slow-channels/channels/slow-a/config.json @@ -2,5 +2,5 @@ "handling": "youtube", "name": "Slow A", "url": "https://www.youtube.com/@slow-a/videos", - "ytdlpExtraArgs": ["--cy-slow"] + "ytdlpExtraArgs": ["--test-slow"] } diff --git a/editor/cypress/fixtures/test-transcripts/two-slow-channels/channels/slow-b/config.json b/editor/cypress/fixtures/test-transcripts/two-slow-channels/channels/slow-b/config.json @@ -2,5 +2,5 @@ "handling": "youtube", "name": "Slow B", "url": "https://www.youtube.com/@slow-b/videos", - "ytdlpExtraArgs": ["--cy-slow"] + "ytdlpExtraArgs": ["--test-slow"] } diff --git a/editor/e2e/build.spec.ts b/editor/e2e/build.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; +import { pathExists, readJson, resetData } from "./helpers"; + +test("build index against an empty fixture writes a 0-transcript manifest", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/build"); + await page.getByRole("button", { name: "Build index" }).click(); + await expect(page.getByLabel("Build index output")).toContainText("Done", { + timeout: 20_000, + }); + const manifest = await readJson<{ totalCount: number }>( + "test-transcripts/.export-public/summaries/manifest.json", + ); + expect(manifest.totalCount).toBe(0); +}); + +test("build index ingests synthetic video data and emits a transcript page", async ({ + page, +}) => { + await resetData("one-youtube-channel-with-data"); + await page.goto("/build"); + await page.getByRole("button", { name: "Build index" }).click(); + await expect(page.getByLabel("Build index output")).toContainText("Done", { + timeout: 20_000, + }); + const manifest = await readJson<{ + totalCount: number; + channels: { name: string }[]; + }>("test-transcripts/.export-public/summaries/manifest.json"); + expect(manifest.totalCount).toBe(1); + expect(manifest.channels.map((c) => c.name)).toContain("Test YouTube Channel"); + expect( + await pathExists( + "test-transcripts/.export-public/transcripts/test-youtube/page-0000.json", + ), + ).toBe(true); +}); + +test("build static export button renders and is enabled", async ({ page }) => { + await resetData("empty"); + await page.goto("/build"); + const exportButton = page.getByRole("button", { name: "Build static export" }); + await expect(exportButton).toBeEnabled(); + await expect(exportButton).toContainText("Build static export"); +}); diff --git a/editor/e2e/cancel.spec.ts b/editor/e2e/cancel.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from "@playwright/test"; +import { pathExists, resetData } from "./helpers"; + +test("cancels a running pipeline job from the channel page", async ({ page }) => { + await resetData("slow-pipeline-channel"); + await page.goto("/channels/slow-channel"); + await page.getByRole("button", { name: "Sync" }).click(); + await expect(page.getByLabel("Sync output")).toContainText("test-slow", { + timeout: 15_000, + }); + + await page.getByRole("button", { name: /^Cancel$/ }).click(); + + // Cancel button gone; run button re-enabled. + await expect( + page.getByRole("button", { name: /^Cancel$/ }), + ).not.toBeVisible({ timeout: 15_000 }); + const syncButton = page.getByRole("button", { name: /^Sync$/ }); + await expect(syncButton).toBeEnabled(); + + // No data dir was written. + expect( + await pathExists("test-transcripts/channels/slow-channel/data"), + ).toBe(false); +}); + +test("cancels a running job from the jobs list", async ({ page }) => { + await resetData("slow-pipeline-channel"); + await page.goto("/channels/slow-channel"); + await page.getByRole("button", { name: "Sync" }).click(); + await expect(page.getByLabel("Sync output")).toContainText("test-slow", { + timeout: 15_000, + }); + + await page.goto("/jobs"); + const runningRow = page + .getByRole("row") + .filter({ hasText: "slow-channel" }) + .first(); + await expect(runningRow).toContainText("running"); + await runningRow.getByRole("button", { name: /cancel/i }).click(); + await expect(runningRow).toContainText("cancelled", { timeout: 15_000 }); +}); diff --git a/editor/e2e/pipeline.spec.ts b/editor/e2e/pipeline.spec.ts @@ -0,0 +1,100 @@ +import { test, expect } from "@playwright/test"; +import { pathExists, readJson, resetData } from "./helpers"; + +test("store playlist writes a playlist file with 5 URLs", async ({ page }) => { + await resetData("test-pipeline"); + await page.goto("/channels/test-pipeline"); + await page.getByRole("button", { name: "Store playlist" }).click(); + await expect(page.getByLabel("Store playlist output")).toContainText( + "Wrote 5 URLs", + { timeout: 20_000 }, + ); + expect( + await pathExists("test-transcripts/channels/test-pipeline/playlist"), + ).toBe(true); +}); + +test("download from playlist fetches all 5 URLs and writes the archive", async ({ + page, +}) => { + await resetData("test-pipeline"); + await page.goto("/channels/test-pipeline"); + await page.getByRole("button", { name: "Store playlist" }).click(); + await expect(page.getByLabel("Store playlist output")).toContainText( + "Wrote 5 URLs", + { timeout: 20_000 }, + ); + + await page.getByRole("button", { name: "Download from playlist" }).click(); + const downloadLog = page.getByLabel("Download from playlist output"); + await expect(downloadLog).toContainText("5 new, 0 already archived", { + timeout: 30_000, + }); + await expect(downloadLog).toContainText("download complete"); + + for (let i = 1; i <= 5; i++) { + expect( + await pathExists( + `test-transcripts/channels/test-pipeline/data/fake0000000${i}/metadata.info.json`, + ), + ).toBe(true); + } + const config = await readJson<{ lastFullDownloadAt?: string }>( + "test-transcripts/channels/test-pipeline/config.json", + ); + expect(typeof config.lastFullDownloadAt).toBe("string"); +}); + +test("re-running download skips already-archived entries", async ({ page }) => { + await resetData("test-pipeline"); + await page.goto("/channels/test-pipeline"); + await page.getByRole("button", { name: "Store playlist" }).click(); + await expect(page.getByLabel("Store playlist output")).toContainText( + "Wrote 5 URLs", + { timeout: 20_000 }, + ); + + await page.getByRole("button", { name: "Download from playlist" }).click(); + const downloadLog = page.getByLabel("Download from playlist output"); + await expect(downloadLog).toContainText("download complete", { + timeout: 30_000, + }); + + await page.getByRole("button", { name: "Download from playlist" }).click(); + await expect(downloadLog).toContainText("0 new, 5 already archived", { + timeout: 20_000, + }); + await expect(downloadLog).toContainText("Nothing to fetch"); +}); + +test("sync downloads one new entry and writes lastSyncedAt", async ({ page }) => { + await resetData("test-pipeline"); + await page.goto("/channels/test-pipeline"); + await page.getByRole("button", { name: "Sync" }).click(); + await expect(page.getByLabel("Sync output")).toContainText( + "fakeSync0001 done", + { timeout: 30_000 }, + ); + expect( + await pathExists( + "test-transcripts/channels/test-pipeline/data/fakeSync0001/metadata.info.json", + ), + ).toBe(true); + const config = await readJson<{ lastSyncedAt?: string }>( + "test-transcripts/channels/test-pipeline/config.json", + ); + expect(typeof config.lastSyncedAt).toBe("string"); +}); + +test("blocks pipeline buttons when channel has no url", async ({ page }) => { + await resetData("empty"); + await page.goto("/channels/new"); + await page.getByLabel(/^name/i).fill("No URL Channel"); + await page.getByLabel(/^slug/i).fill("no-url"); + await page.getByRole("button", { name: /create channel/i }).click(); + await page.waitForURL("**/channels/no-url", { timeout: 10_000 }); + await expect(page.getByText(/set a/i)).toBeVisible(); + await expect( + page.getByRole("button", { name: "Store playlist" }), + ).not.toBeVisible(); +}); diff --git a/editor/e2e/sync-break-on-existing.spec.ts b/editor/e2e/sync-break-on-existing.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from "@playwright/test"; +import { readJson, resetData } from "./helpers"; + +test("treats yt-dlp exit code 101 as success and updates lastSyncedAt", async ({ + page, +}) => { + await resetData("sync-already-archived"); + await page.goto("/channels/archived-channel"); + + await page.getByRole("button", { name: "Sync" }).click(); + await expect(page.getByLabel("Sync output")).toContainText("exit 101", { + timeout: 15_000, + }); + + // Run button re-enabled with original label, no Cancel/error visible. + const syncButton = page.getByRole("button", { name: /^Sync$/ }); + await expect(syncButton).toBeEnabled(); + await expect( + page.getByRole("button", { name: /^Cancel$/ }), + ).not.toBeVisible(); + await expect(page.getByLabel("Sync error")).not.toBeVisible(); + + // Config now has lastSyncedAt set to a parseable ISO string. + const config = await readJson<{ lastSyncedAt?: string }>( + "test-transcripts/channels/archived-channel/config.json", + ); + expect(typeof config.lastSyncedAt).toBe("string"); + expect(Number.isNaN(Date.parse(config.lastSyncedAt!))).toBe(false); + + // The job lands as `done`, not `failed`. + await page.goto("/jobs"); + const syncRow = page + .getByRole("row") + .filter({ hasText: "archived-channel" }) + .first(); + await expect(syncRow).toContainText("sync"); + await expect(syncRow).toContainText("done"); +}); diff --git a/editor/e2e/whisper.spec.ts b/editor/e2e/whisper.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "@playwright/test"; +import { pathExists, resetData } from "./helpers"; + +test("transcribes every audio file with no transcript", async ({ page }) => { + await resetData("one-transcribe-channel-with-audio"); + await page.goto("/channels/test-transcribe"); + await page.getByRole("button", { name: "Transcribe missing" }).click(); + await expect(page.getByLabel("Transcribe missing output")).toContainText( + "3 succeeded", + { timeout: 30_000 }, + ); + for (const id of ["vidA", "vidB", "vidC"]) { + expect( + await pathExists( + `test-transcripts/channels/test-transcribe/data/${id}/transcript.json`, + ), + ).toBe(true); + } +}); + +test("verify reports nothing missing once transcribed", async ({ page }) => { + await resetData("one-transcribe-channel-with-audio"); + await page.goto("/channels/test-transcribe"); + await page.getByRole("button", { name: "Transcribe missing" }).click(); + await expect(page.getByLabel("Transcribe missing output")).toContainText( + "3 succeeded", + { timeout: 30_000 }, + ); + await page.getByRole("button", { name: "Verify" }).click(); + const result = page.getByLabel("verify result"); + await expect(result).toContainText("Duplicates (0)", { timeout: 10_000 }); + await expect(result).toContainText("Missing (0)"); +}); + +test("verify flags missing transcripts before whisper runs", async ({ page }) => { + await resetData("one-transcribe-channel-with-audio"); + await page.goto("/channels/test-transcribe"); + await page.getByRole("button", { name: "Verify" }).click(); + await expect(page.getByLabel("verify result")).toContainText("Missing (3)", { + timeout: 10_000, + }); +}); + +test("retry-failures runs cleanly with an empty failure list", async ({ page }) => { + await resetData("one-transcribe-channel-with-audio"); + await page.goto("/channels/test-transcribe"); + await page.getByRole("button", { name: "Retry failures" }).click(); + await expect(page.getByLabel("Retry failures output")).toContainText( + "0 succeeded", + { timeout: 30_000 }, + ); +}); + +test("whisper panel hidden for youtube channels", async ({ page }) => { + await resetData("one-youtube-channel"); + await page.goto("/channels/test-youtube"); + await expect( + page.getByRole("button", { name: "Transcribe missing" }), + ).toHaveCount(0); + await expect(page.getByRole("button", { name: "Verify" })).toHaveCount(0); +});