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:
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();
-});