Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit d5dc289e475f85c64d90b29e77613a67802a1323
parent 7d56055f0b367966b51117811ff106441cbc0fb7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 28 Apr 2026 00:39:47 -0400

test(editor): migrate read-only specs to playwright

Six cy specs (branding, dashboard, dashboard-paths, channels,
channels-counts, settings) re-authored against role/text/label
queries; the testids those replace are stripped from source. Add
aria-label / role hints (StreamActionLog log+banner, settings
status+alert, dashboard stat groups, channels-table count cells)
where the natural query needed a hook. Cypress still covers the
remaining 8 specs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/components/StreamActionLog.tsx | 12++++++++++++
Meditor/app/channels/page.tsx | 21+++++++--------------
Meditor/app/layout.tsx | 1-
Meditor/app/page.tsx | 5+++--
Meditor/app/settings/_components/SettingsForm.tsx | 10+++-------
Meditor/app/settings/page.tsx | 4++--
Deditor/cypress/e2e/branding.cy.ts | 44--------------------------------------------
Deditor/cypress/e2e/channels-counts.cy.ts | 66------------------------------------------------------------------
Deditor/cypress/e2e/channels.cy.ts | 90-------------------------------------------------------------------------------
Deditor/cypress/e2e/dashboard-paths.cy.ts | 20--------------------
Deditor/cypress/e2e/dashboard.cy.ts | 45---------------------------------------------
Deditor/cypress/e2e/settings.cy.ts | 40----------------------------------------
Aeditor/e2e/branding.spec.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/channels-counts.spec.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/channels.spec.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/dashboard-paths.spec.ts | 33+++++++++++++++++++++++++++++++++
Aeditor/e2e/dashboard.spec.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/settings.spec.ts | 41+++++++++++++++++++++++++++++++++++++++++
Deditor/e2e/smoke.spec.ts | 15---------------
19 files changed, 328 insertions(+), 346 deletions(-)

diff --git a/common/components/StreamActionLog.tsx b/common/components/StreamActionLog.tsx @@ -7,6 +7,12 @@ type Props = { trigger: () => Promise<StreamActionResult>; buttonLabel: string; runningLabel?: string; + /** + * Used to derive the accessible name on the log box and queue banner + * (e.g. label="Sync" → log gets aria-label="Sync output"). Falls back to + * buttonLabel if not provided so callers don't have to thread it twice. + */ + label?: string; testId?: string; cancelAction?: (id: string) => Promise<{ ok: boolean }>; }; @@ -21,9 +27,11 @@ export function StreamActionLog({ trigger, buttonLabel, runningLabel = "Running…", + label, testId, cancelAction, }: Props) { + const accessibleName = label ?? buttonLabel; const [running, setRunning] = useState(false); const [log, setLog] = useState(""); const [error, setError] = useState<string | null>(null); @@ -157,6 +165,8 @@ export function StreamActionLog({ )} {running && poll?.status === "queued" && poll.queueKey && ( <div + role="status" + aria-label={`${accessibleName} queue status`} data-testid={testId ? `${testId}-queue-banner` : undefined} className="rounded border border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900 px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300" > @@ -170,6 +180,8 @@ export function StreamActionLog({ <pre ref={preRef} onScroll={handleScroll} + role="log" + aria-label={`${accessibleName} output`} data-testid={testId ? `${testId}-log` : undefined} className="text-xs font-mono bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded p-3 h-96 overflow-auto whitespace-pre-wrap" > diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -18,17 +18,11 @@ export default async function ChannelsPage() { </Link> </div> {channels.length === 0 ? ( - <p - data-testid="channels-empty" - className="text-sm text-zinc-500 border border-dashed border-zinc-300 dark:border-zinc-700 rounded p-4" - > + <p className="text-sm text-zinc-500 border border-dashed border-zinc-300 dark:border-zinc-700 rounded p-4"> No channels yet. </p> ) : ( - <table - data-testid="channels-table" - className="text-sm border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden" - > + <table className="text-sm border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden"> <thead className="bg-zinc-100 dark:bg-zinc-900"> <tr> <Th>Slug</Th> @@ -44,7 +38,6 @@ export default async function ChannelsPage() { {channels.map((c) => ( <tr key={c.slug} - data-testid={`channel-row-${c.slug}`} className="border-t border-zinc-200 dark:border-zinc-800" > <Td className="font-mono"> @@ -60,13 +53,13 @@ export default async function ChannelsPage() { <Td className="text-right">{c.videoCount}</Td> <Td className="text-right" - testId={`channel-row-playlist-${c.slug}`} + ariaLabel={`playlist count for ${c.slug}`} > {c.playlistCount ?? "—"} </Td> <Td className="text-right" - testId={`channel-row-archive-${c.slug}`} + ariaLabel={`archive count for ${c.slug}`} > {c.archiveCount ?? "—"} </Td> @@ -101,14 +94,14 @@ function Th({ function Td({ children, className = "", - testId, + ariaLabel, }: { children: React.ReactNode; className?: string; - testId?: string; + ariaLabel?: string; }) { return ( - <td className={`px-3 py-2 ${className}`} data-testid={testId}> + <td className={`px-3 py-2 ${className}`} aria-label={ariaLabel}> {children} </td> ); diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -34,7 +34,6 @@ export default async function RootLayout({ <div className="px-4 py-4 border-b border-zinc-200 dark:border-zinc-800"> <Link href="/" - data-testid="sidebar-brand" className="font-semibold tracking-tight block leading-tight" > {headerLabel} diff --git a/editor/app/page.tsx b/editor/app/page.tsx @@ -92,7 +92,8 @@ export default async function Dashboard() { function Stat({ label, value }: { label: string; value: string }) { return ( <div - data-testid={`stat-${label.replace(/\s+/g, "-")}`} + role="group" + aria-label={label} className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3" > <div className="text-xs uppercase tracking-wide text-zinc-500"> @@ -115,7 +116,7 @@ function StatLink({ return ( <Link href={href} - data-testid={`stat-${label.replace(/\s+/g, "-")}`} + aria-label={label} className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3 hover:border-zinc-400 dark:hover:border-zinc-600" > <div className="text-xs uppercase tracking-wide text-zinc-500"> diff --git a/editor/app/settings/_components/SettingsForm.tsx b/editor/app/settings/_components/SettingsForm.tsx @@ -17,11 +17,7 @@ export function SettingsForm({ initial }: Props) { undefined, ); return ( - <form - action={formAction} - data-testid="settings-form" - className="flex flex-col gap-4 max-w-xl" - > + <form action={formAction} className="flex flex-col gap-4 max-w-xl"> <Field label="Site title" name="siteTitle" @@ -60,7 +56,7 @@ export function SettingsForm({ initial }: Props) { </button> {state?.ok === true && ( <span - data-testid="settings-saved" + role="status" className="text-sm text-green-700 dark:text-green-300" > Saved. @@ -68,7 +64,7 @@ export function SettingsForm({ initial }: Props) { )} {state && state.ok === false && ( <span - data-testid="settings-error" + role="alert" className="text-sm text-red-700 dark:text-red-300" > {state.error} diff --git a/editor/app/settings/page.tsx b/editor/app/settings/page.tsx @@ -37,7 +37,7 @@ export default function SettingsPage() { </section> <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"> - <details data-testid="system-paths"> + <details> <summary className="cursor-pointer text-lg font-semibold"> System paths </summary> @@ -47,7 +47,7 @@ export default function SettingsPage() { WHISPER_BIN, WHISPER_MODEL, PARALLEL_TRANSCRIBE_LIMIT) before launching to override. </p> - <table className="text-sm mt-3" data-testid="system-paths-table"> + <table className="text-sm mt-3"> <tbody> {pathRows.map(([key, value]) => ( <tr diff --git a/editor/cypress/e2e/branding.cy.ts b/editor/cypress/e2e/branding.cy.ts @@ -1,44 +0,0 @@ -describe("Editor branding from settings.json", () => { - it("renders siteTitle in document title and headerTitle in sidebar", () => { - cy.resetData("empty"); - cy.writeSettings({ - siteTitle: "Cypress Browser", - siteDescription: "x", - headerTitle: "Cypress HQ", - homeTagline: "", - maxTranscriptPageBytes: 8388608, - }); - // Re-invalidate the in-memory settings cache after the file was rewritten. - cy.request({ url: "/api/test/invalidate-cache", failOnStatusCode: false }); - - cy.visit("/"); - cy.title().should("include", "Cypress Browser"); - cy.findByTestId("sidebar-brand").should("contain.text", "Cypress HQ"); - }); - - it("falls back to siteTitle when headerTitle is empty", () => { - cy.resetData("empty"); - cy.writeSettings({ - siteTitle: "Just A Title", - siteDescription: "", - headerTitle: "", - homeTagline: "", - maxTranscriptPageBytes: 8388608, - }); - cy.request({ url: "/api/test/invalidate-cache", failOnStatusCode: false }); - - cy.visit("/"); - cy.findByTestId("sidebar-brand").should("contain.text", "Just A Title"); - }); - - it("reflects sidebar header after saving via the settings form", () => { - cy.resetData("empty"); - cy.visit("/settings"); - cy.findByLabelText(/header title/i).clear().type("Saved Header"); - cy.findByRole("button", { name: /save settings/i }).click(); - cy.findByTestId("settings-saved", { timeout: 10_000 }); - - cy.visit("/"); - cy.findByTestId("sidebar-brand").should("contain.text", "Saved Header"); - }); -}); diff --git a/editor/cypress/e2e/channels-counts.cy.ts b/editor/cypress/e2e/channels-counts.cy.ts @@ -1,66 +0,0 @@ -describe("Channel list playlist/archive counts", () => { - it("shows em-dash when neither file exists", () => { - cy.resetData("one-youtube-channel"); - cy.visit("/channels"); - cy.findByTestId("channel-row-playlist-test-youtube").should( - "contain.text", - "—", - ); - cy.findByTestId("channel-row-archive-test-youtube").should( - "contain.text", - "—", - ); - }); - - it("shows entry counts when files exist", () => { - cy.resetData("channel-with-counts"); - cy.visit("/channels"); - cy.findByTestId("channel-row-playlist-counted").should( - "have.text", - "7", - ); - cy.findByTestId("channel-row-archive-counted").should( - "have.text", - "3", - ); - }); - - it("counts update after store-playlist and download", () => { - cy.resetData("test-pipeline"); - cy.visit("/channels"); - cy.findByTestId("channel-row-playlist-test-pipeline").should( - "contain.text", - "—", - ); - - 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.visit("/channels"); - cy.findByTestId("channel-row-playlist-test-pipeline").should( - "have.text", - "5", - ); - cy.findByTestId("channel-row-archive-test-pipeline").should( - "contain.text", - "—", - ); - - cy.visit("/channels/test-pipeline"); - cy.findByTestId("pipeline-download").find("button").click(); - cy.findByTestId("pipeline-download-log", { timeout: 30_000 }).should( - "contain.text", - "download complete", - ); - - cy.visit("/channels"); - cy.findByTestId("channel-row-archive-test-pipeline").should( - "have.text", - "5", - ); - }); -}); diff --git a/editor/cypress/e2e/channels.cy.ts b/editor/cypress/e2e/channels.cy.ts @@ -1,90 +0,0 @@ -describe("Channels", () => { - it("shows empty state with no channels", () => { - cy.resetData("empty"); - cy.visit("/channels"); - cy.get('[data-testid="channels-empty"]').should("exist"); - }); - - it("creates a youtube channel", () => { - cy.resetData("empty"); - cy.visit("/channels/new"); - cy.findByLabelText(/^name/i).type("New Test Channel"); - cy.findByLabelText(/^slug/i).clear().type("new-test"); - cy.findByLabelText(/^url/i).type( - "https://www.youtube.com/@newtest/videos", - ); - cy.findByRole("button", { name: /create channel/i }).click(); - cy.location("pathname", { timeout: 10000 }).should( - "eq", - "/channels/new-test", - ); - cy.task("readJson", "test-transcripts/channels/new-test/config.json").then( - (config) => { - expect(config).to.deep.equal({ - handling: "youtube", - name: "New Test Channel", - url: "https://www.youtube.com/@newtest/videos", - }); - }, - ); - }); - - it("auto-derives slug from name when blank", () => { - cy.resetData("empty"); - cy.visit("/channels/new"); - cy.findByLabelText(/^name/i).type("Auto Slug Channel"); - cy.findByRole("button", { name: /create channel/i }).click(); - cy.location("pathname", { timeout: 10000 }).should( - "eq", - "/channels/auto-slug-channel", - ); - }); - - it("rejects duplicate slug", () => { - cy.resetData("one-youtube-channel"); - cy.visit("/channels/new"); - cy.findByLabelText(/^name/i).type("Test YouTube Channel"); - cy.findByLabelText(/^slug/i).clear().type("test-youtube"); - cy.findByRole("button", { name: /create channel/i }).click(); - cy.contains(/already exists/i); - }); - - it("edits an existing channel", () => { - cy.resetData("one-youtube-channel"); - cy.visit("/channels/test-youtube"); - cy.findByLabelText(/^name/i).clear().type("Renamed Channel"); - cy.findByRole("button", { name: /save changes/i }).click(); - cy.contains("Renamed Channel"); - cy.task( - "readJson", - "test-transcripts/channels/test-youtube/config.json", - ).then((config) => { - expect((config as { name?: string }).name).to.equal("Renamed Channel"); - }); - }); - - it("requires typed-confirmation to delete", () => { - cy.resetData("one-youtube-channel"); - cy.visit("/channels/test-youtube"); - // Wrong slug → error - cy.findByPlaceholderText("test-youtube").type("wrong-slug"); - cy.findByRole("button", { name: /delete channel/i }).click(); - cy.contains(/type the channel slug/i); - cy.task("pathExists", "test-transcripts/channels/test-youtube").then( - (exists) => expect(exists).to.equal(true), - ); - // Correct slug → deletes - cy.findByPlaceholderText("test-youtube").clear().type("test-youtube"); - cy.findByRole("button", { name: /delete channel/i }).click(); - cy.location("pathname", { timeout: 10000 }).should("eq", "/channels"); - cy.task("pathExists", "test-transcripts/channels/test-youtube").then( - (exists) => expect(exists).to.equal(false), - ); - }); - - it("hides transcribe form fields for youtube channels (cosmetic)", () => { - cy.resetData("one-transcribe-channel"); - cy.visit("/channels/test-transcribe"); - cy.findByLabelText(/audio format/i).should("have.value", "m4a"); - }); -}); diff --git a/editor/cypress/e2e/dashboard-paths.cy.ts b/editor/cypress/e2e/dashboard-paths.cy.ts @@ -1,20 +0,0 @@ -describe("Dashboard hides system paths", () => { - it("does not display 'transcripts dir' or the actual path on the dashboard", () => { - cy.resetData("empty"); - cy.task("resolvePath", "test-transcripts").then((abs) => { - cy.visit("/"); - cy.get("body").should("not.contain.text", "transcripts dir"); - cy.get("body").should("not.contain.text", "Transcripts dir"); - cy.get("body").should("not.contain.text", abs as string); - }); - }); - - it("hides system paths under a closed details on settings, reveals on click", () => { - cy.resetData("empty"); - cy.visit("/settings"); - cy.findByTestId("system-paths").should("not.have.attr", "open"); - cy.findByTestId("system-paths").find("summary").click(); - cy.findByTestId("system-paths").should("have.attr", "open"); - cy.findByText("transcriptsDir").should("be.visible"); - }); -}); diff --git a/editor/cypress/e2e/dashboard.cy.ts b/editor/cypress/e2e/dashboard.cy.ts @@ -1,45 +0,0 @@ -describe("Dashboard", () => { - beforeEach(() => { - cy.resetData(); - }); - - it("shows zero channels in an empty fixture", () => { - cy.visit("/"); - cy.findByRole("heading", { level: 1, name: /dashboard/i }); - cy.get('[data-testid="stat-channels"]').findByText("0"); - cy.get('[data-testid="stat-videos"]').findByText("0"); - }); - - it("sidebar links to Channels, Build, Jobs, Settings", () => { - cy.visit("/"); - cy.get("aside nav").within(() => { - cy.findByRole("link", { name: /channels/i }).should( - "have.attr", - "href", - "/channels", - ); - cy.findByRole("link", { name: /build/i }).should( - "have.attr", - "href", - "/build", - ); - cy.findByRole("link", { name: /jobs/i }).should( - "have.attr", - "href", - "/jobs", - ); - cy.findByRole("link", { name: /settings/i }).should( - "have.attr", - "href", - "/settings", - ); - }); - }); - - it("settings page lists getPaths() values", () => { - cy.visit("/settings"); - cy.findByText("transcriptsDir"); - cy.findByText("ytdlpBin"); - cy.findByText("whisperBin"); - }); -}); diff --git a/editor/cypress/e2e/settings.cy.ts b/editor/cypress/e2e/settings.cy.ts @@ -1,40 +0,0 @@ -describe("Settings", () => { - it("shows resolved paths and current settings", () => { - cy.resetData("empty"); - cy.visit("/settings"); - cy.findByText("transcriptsDir"); - cy.findByText("ytdlpBin"); - cy.findByLabelText(/site title/i) - .invoke("val") - .should("not.be.empty"); - cy.findByLabelText(/header title/i) - .invoke("val") - .should("not.be.empty"); - }); - - it("saves settings via the form", () => { - cy.resetData("empty"); - cy.visit("/settings"); - cy.findByLabelText(/site title/i).clear().type("Cypress Title"); - cy.findByLabelText(/header title/i).clear().type("Cypress Header"); - cy.findByLabelText(/site description/i).clear().type("Cypress description"); - cy.findByRole("button", { name: /save settings/i }).click(); - cy.findByTestId("settings-saved", { timeout: 10_000 }); - cy.task("readJson", "test-settings.json").then((s) => { - expect((s as { siteTitle: string }).siteTitle).to.equal("Cypress Title"); - expect((s as { headerTitle: string }).headerTitle).to.equal( - "Cypress Header", - ); - }); - }); - - it("rejects out-of-range maxTranscriptPageBytes", () => { - cy.resetData("empty"); - cy.visit("/settings"); - cy.findByLabelText(/max transcript page bytes/i) - .clear() - .type("42"); - cy.findByRole("button", { name: /save settings/i }).click(); - cy.findByTestId("settings-error").should("contain.text", "between"); - }); -}); diff --git a/editor/e2e/branding.spec.ts b/editor/e2e/branding.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from "@playwright/test"; +import { resetData, writeSettings } from "./helpers"; + +test.beforeEach(async () => { + await resetData("empty"); +}); + +test("renders siteTitle in document title and headerTitle in sidebar", async ({ + page, +}) => { + await writeSettings({ + siteTitle: "Cypress Browser", + siteDescription: "x", + headerTitle: "Cypress HQ", + homeTagline: "", + maxTranscriptPageBytes: 8388608, + }); + await page.goto("/"); + await expect(page).toHaveTitle(/Cypress Browser/); + await expect( + page.locator("aside").getByRole("link", { name: "Cypress HQ" }), + ).toBeVisible(); +}); + +test("falls back to siteTitle when headerTitle is empty", async ({ page }) => { + await writeSettings({ + siteTitle: "Just A Title", + siteDescription: "", + headerTitle: "", + homeTagline: "", + maxTranscriptPageBytes: 8388608, + }); + await page.goto("/"); + await expect( + page.locator("aside").getByRole("link", { name: "Just A Title" }), + ).toBeVisible(); +}); + +test("reflects sidebar header after saving via the settings form", async ({ + page, +}) => { + await page.goto("/settings"); + await page.getByLabel(/header title/i).fill("Saved Header"); + await page.getByRole("button", { name: /save settings/i }).click(); + await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible(); + + await page.goto("/"); + await expect( + page.locator("aside").getByRole("link", { name: "Saved Header" }), + ).toBeVisible(); +}); diff --git a/editor/e2e/channels-counts.spec.ts b/editor/e2e/channels-counts.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from "@playwright/test"; +import { resetData } from "./helpers"; + +test("shows em-dash when neither file exists", async ({ page }) => { + await resetData("one-youtube-channel"); + await page.goto("/channels"); + await expect( + page.getByLabel("playlist count for test-youtube"), + ).toContainText("—"); + await expect( + page.getByLabel("archive count for test-youtube"), + ).toContainText("—"); +}); + +test("shows entry counts when files exist", async ({ page }) => { + await resetData("channel-with-counts"); + await page.goto("/channels"); + await expect(page.getByLabel("playlist count for counted")).toHaveText("7"); + await expect(page.getByLabel("archive count for counted")).toHaveText("3"); +}); + +test("counts update after store-playlist and download", async ({ page }) => { + await resetData("test-pipeline"); + await page.goto("/channels"); + await expect( + page.getByLabel("playlist count for test-pipeline"), + ).toContainText("—"); + + 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.goto("/channels"); + await expect( + page.getByLabel("playlist count for test-pipeline"), + ).toHaveText("5"); + await expect( + page.getByLabel("archive count for test-pipeline"), + ).toContainText("—"); + + await page.goto("/channels/test-pipeline"); + await page.getByRole("button", { name: "Download from playlist" }).click(); + await expect( + page.getByLabel("Download from playlist output"), + ).toContainText("download complete", { timeout: 30_000 }); + + await page.goto("/channels"); + await expect( + page.getByLabel("archive count for test-pipeline"), + ).toHaveText("5"); +}); diff --git a/editor/e2e/channels.spec.ts b/editor/e2e/channels.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from "@playwright/test"; +import { pathExists, readJson, resetData } from "./helpers"; + +test("shows empty state with no channels", async ({ page }) => { + await resetData("empty"); + await page.goto("/channels"); + await expect(page.getByText("No channels yet.")).toBeVisible(); +}); + +test("creates a youtube channel", async ({ page }) => { + await resetData("empty"); + await page.goto("/channels/new"); + await page.getByLabel(/^name/i).fill("New Test Channel"); + await page.getByLabel(/^slug/i).fill("new-test"); + await page + .getByLabel(/^url/i) + .fill("https://www.youtube.com/@newtest/videos"); + await page.getByRole("button", { name: /create channel/i }).click(); + await page.waitForURL("**/channels/new-test", { timeout: 10_000 }); + expect( + await readJson("test-transcripts/channels/new-test/config.json"), + ).toEqual({ + handling: "youtube", + name: "New Test Channel", + url: "https://www.youtube.com/@newtest/videos", + }); +}); + +test("auto-derives slug from name when blank", async ({ page }) => { + await resetData("empty"); + await page.goto("/channels/new"); + await page.getByLabel(/^name/i).fill("Auto Slug Channel"); + await page.getByRole("button", { name: /create channel/i }).click(); + await page.waitForURL("**/channels/auto-slug-channel", { timeout: 10_000 }); +}); + +test("rejects duplicate slug", async ({ page }) => { + await resetData("one-youtube-channel"); + await page.goto("/channels/new"); + await page.getByLabel(/^name/i).fill("Test YouTube Channel"); + await page.getByLabel(/^slug/i).fill("test-youtube"); + await page.getByRole("button", { name: /create channel/i }).click(); + await expect(page.getByText(/already exists/i)).toBeVisible(); +}); + +test("edits an existing channel", async ({ page }) => { + await resetData("one-youtube-channel"); + await page.goto("/channels/test-youtube"); + await page.getByLabel(/^name/i).fill("Renamed Channel"); + await page.getByRole("button", { name: /save changes/i }).click(); + await expect(page.getByText("Renamed Channel").first()).toBeVisible(); + const config = await readJson<{ name?: string }>( + "test-transcripts/channels/test-youtube/config.json", + ); + expect(config.name).toBe("Renamed Channel"); +}); + +test("requires typed-confirmation to delete", async ({ page }) => { + await resetData("one-youtube-channel"); + await page.goto("/channels/test-youtube"); + + await page.getByPlaceholder("test-youtube").fill("wrong-slug"); + await page.getByRole("button", { name: /delete channel/i }).click(); + await expect(page.getByText(/type the channel slug/i)).toBeVisible(); + expect(await pathExists("test-transcripts/channels/test-youtube")).toBe(true); + + await page.getByPlaceholder("test-youtube").fill("test-youtube"); + await page.getByRole("button", { name: /delete channel/i }).click(); + await page.waitForURL("**/channels", { timeout: 10_000 }); + expect(await pathExists("test-transcripts/channels/test-youtube")).toBe(false); +}); + +test("hides transcribe form fields for youtube channels (cosmetic)", async ({ + page, +}) => { + await resetData("one-transcribe-channel"); + await page.goto("/channels/test-transcribe"); + await expect(page.getByLabel(/audio format/i)).toHaveValue("m4a"); +}); diff --git a/editor/e2e/dashboard-paths.spec.ts b/editor/e2e/dashboard-paths.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test"; +import { resetData, resolvePath } from "./helpers"; + +test("dashboard does not display 'transcripts dir' or the actual path", async ({ + page, +}) => { + await resetData("empty"); + const abs = resolvePath("test-transcripts"); + await page.goto("/"); + const body = page.locator("body"); + await expect(body).not.toContainText("transcripts dir"); + await expect(body).not.toContainText("Transcripts dir"); + await expect(body).not.toContainText(abs); +}); + +test("settings hides system paths under a closed details, reveals on click", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/settings"); + + const details = page + .locator("details") + .filter({ has: page.getByText("System paths") }); + + await expect(details).not.toHaveAttribute("open", /.*/); + await expect(page.getByText("transcriptsDir")).not.toBeVisible(); + + await page.locator("summary").filter({ hasText: "System paths" }).click(); + + await expect(details).toHaveAttribute("open", /.*/); + await expect(page.getByText("transcriptsDir")).toBeVisible(); +}); diff --git a/editor/e2e/dashboard.spec.ts b/editor/e2e/dashboard.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from "@playwright/test"; +import { resetData } from "./helpers"; + +test.beforeEach(async () => { + await resetData(); +}); + +test("shows zero channels in an empty fixture", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByRole("heading", { level: 1, name: /dashboard/i }), + ).toBeVisible(); + await expect(page.getByRole("group", { name: "channels" })).toContainText("0"); + await expect(page.getByRole("group", { name: "videos" })).toContainText("0"); +}); + +test("sidebar links to Channels, Build, Jobs, Settings", async ({ page }) => { + await page.goto("/"); + const sidebar = page.locator("aside"); + await expect(sidebar.getByRole("link", { name: /channels/i })).toHaveAttribute( + "href", + "/channels", + ); + await expect(sidebar.getByRole("link", { name: /build/i })).toHaveAttribute( + "href", + "/build", + ); + await expect(sidebar.getByRole("link", { name: /jobs/i })).toHaveAttribute( + "href", + "/jobs", + ); + await expect( + sidebar.getByRole("link", { name: /settings/i }), + ).toHaveAttribute("href", "/settings"); +}); + +test("settings page lists getPaths() values", async ({ page }) => { + await page.goto("/settings"); + await page.locator("summary").filter({ hasText: "System paths" }).click(); + await expect(page.getByText("transcriptsDir")).toBeVisible(); + await expect(page.getByText("ytdlpBin")).toBeVisible(); + await expect(page.getByText("whisperBin")).toBeVisible(); +}); diff --git a/editor/e2e/settings.spec.ts b/editor/e2e/settings.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "@playwright/test"; +import { readJson, resetData } from "./helpers"; + +test.beforeEach(async () => { + await resetData("empty"); +}); + +test("shows resolved paths and current settings", async ({ page }) => { + await page.goto("/settings"); + await page.locator("summary").filter({ hasText: "System paths" }).click(); + await expect(page.getByText("transcriptsDir")).toBeVisible(); + await expect(page.getByText("ytdlpBin")).toBeVisible(); + await expect(page.getByLabel(/site title/i)).not.toHaveValue(""); + await expect(page.getByLabel(/header title/i)).not.toHaveValue(""); +}); + +test("saves settings via the form", async ({ page }) => { + await page.goto("/settings"); + await page.getByLabel(/site title/i).fill("Cypress Title"); + await page.getByLabel(/header title/i).fill("Cypress Header"); + await page.getByLabel(/site description/i).fill("Cypress description"); + await page.getByRole("button", { name: /save settings/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: "Saved" }), + ).toBeVisible(); + const saved = await readJson<{ + siteTitle: string; + headerTitle: string; + }>("test-settings.json"); + expect(saved.siteTitle).toBe("Cypress Title"); + expect(saved.headerTitle).toBe("Cypress Header"); +}); + +test("rejects out-of-range maxTranscriptPageBytes", async ({ page }) => { + await page.goto("/settings"); + await page.getByLabel(/max transcript page bytes/i).fill("42"); + await page.getByRole("button", { name: /save settings/i }).click(); + await expect(page.locator("form").getByRole("alert")).toContainText( + "between", + ); +}); diff --git a/editor/e2e/smoke.spec.ts b/editor/e2e/smoke.spec.ts @@ -1,15 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { resetData } from "./helpers"; - -test.beforeEach(async () => { - await resetData(); -}); - -test("dashboard renders heading and zero stats on empty fixture", async ({ - page, -}) => { - await page.goto("/"); - await expect( - page.getByRole("heading", { level: 1, name: /dashboard/i }), - ).toBeVisible(); -});