Archilyzer · Source

archilyzer

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

commit fe729bbda3bdd1ffc42d0fd4ffad05b24d4a9610
parent 26bb594c8cea7565d64bd144521214bc33aacebf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 28 Apr 2026 00:53:30 -0400

test(editor): migrate jobs and queue specs to playwright

Final three cy specs (jobs, jobs-channel, queues) re-authored
against role/text/label queries. QueuePicker drops its testId
prop and gains form-control labels: <label>Queue</label> on the
select, <label>New queue name</label> on the input, save button
text "Save queue" so each accessible name is unambiguous.
Jobs page and JobLogTail strip every remaining data-testid;
StreamActionLog cancel button gets aria-label="Cancel <action>"
so it is queryable distinctly from CancelJobButton on /jobs.

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

Diffstat:
Mcommon/components/StreamActionLog.tsx | 9++-------
Meditor/app/_components/QueuePicker.tsx | 43++++++++++++++++---------------------------
Meditor/app/build/_components/BuildButtons.tsx | 2--
Meditor/app/channels/[slug]/_components/PipelinePanel.tsx | 4----
Meditor/app/channels/[slug]/_components/WhisperPanel.tsx | 1-
Meditor/app/jobs/[id]/_components/JobLogTail.tsx | 19++++++-------------
Meditor/app/jobs/[id]/page.tsx | 3+--
Meditor/app/jobs/_components/CancelJobButton.tsx | 4+---
Meditor/app/jobs/_components/ClearArchivedButton.tsx | 1-
Meditor/app/jobs/page.tsx | 26+++++---------------------
Deditor/cypress/e2e/jobs-channel.cy.ts | 49-------------------------------------------------
Deditor/cypress/e2e/jobs.cy.ts | 38--------------------------------------
Deditor/cypress/e2e/queues.cy.ts | 115-------------------------------------------------------------------------------
Meditor/e2e/cancel.spec.ts | 4++--
Aeditor/e2e/jobs-channel.spec.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/jobs.spec.ts | 37+++++++++++++++++++++++++++++++++++++
Aeditor/e2e/queues.spec.ts | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/sync-break-on-existing.spec.ts | 2+-
18 files changed, 240 insertions(+), 286 deletions(-)

diff --git a/common/components/StreamActionLog.tsx b/common/components/StreamActionLog.tsx @@ -13,7 +13,6 @@ type Props = { * buttonLabel if not provided so callers don't have to thread it twice. */ label?: string; - testId?: string; cancelAction?: (id: string) => Promise<{ ok: boolean }>; }; @@ -28,7 +27,6 @@ export function StreamActionLog({ buttonLabel, runningLabel = "Running…", label, - testId, cancelAction, }: Props) { const accessibleName = label ?? buttonLabel; @@ -130,7 +128,7 @@ export function StreamActionLog({ } return ( - <div className="flex flex-col gap-2" data-testid={testId}> + <div className="flex flex-col gap-2"> <div className="flex items-center gap-3 flex-wrap"> <button type="button" @@ -145,7 +143,7 @@ export function StreamActionLog({ type="button" onClick={handleCancel} disabled={cancelling} - data-testid={testId ? `${testId}-cancel` : undefined} + aria-label={`Cancel ${accessibleName}`} className="px-3 py-2 rounded-md border border-red-300 dark:border-red-800 text-sm font-medium text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50" > {cancelling ? "Cancelling…" : "Cancel"} @@ -159,7 +157,6 @@ export function StreamActionLog({ <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" > {error} @@ -169,7 +166,6 @@ export function StreamActionLog({ <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" > Queued in <code className="font-mono">{poll.queueKey}</code> @@ -184,7 +180,6 @@ export function StreamActionLog({ 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" > {log || "Waiting for output…"} diff --git a/editor/app/_components/QueuePicker.tsx b/editor/app/_components/QueuePicker.tsx @@ -7,7 +7,6 @@ type Props = { onChange: (next: string) => void; defaultQueueKey: string; existingQueues: string[]; - testId?: string; }; const NEW_QUEUE_OPTION = "__new__"; @@ -17,7 +16,6 @@ export function QueuePicker({ onChange, defaultQueueKey, existingQueues, - testId, }: Props) { const [creating, setCreating] = useState(false); const [draft, setDraft] = useState(""); @@ -29,18 +27,17 @@ export function QueuePicker({ if (creating) { return ( <div className="flex items-center gap-2"> - <label className="text-xs uppercase tracking-wide text-zinc-500"> - Queue + <label className="flex items-center gap-2 text-xs uppercase tracking-wide text-zinc-500"> + New queue name + <input + type="text" + value={draft} + onChange={(e) => setDraft(e.target.value)} + autoFocus + placeholder="new-queue-name" + className="text-sm font-mono px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 normal-case tracking-normal text-zinc-900 dark:text-zinc-100" + /> </label> - <input - type="text" - value={draft} - onChange={(e) => setDraft(e.target.value)} - autoFocus - placeholder="new-queue-name" - data-testid={testId ? `${testId}-input` : undefined} - className="text-sm font-mono px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900" - /> <button type="button" onClick={() => { @@ -51,10 +48,9 @@ export function QueuePicker({ setDraft(""); } }} - data-testid={testId ? `${testId}-save` : undefined} className="text-xs px-2 py-1 rounded bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900" > - Use + Save queue </button> <button type="button" @@ -64,22 +60,16 @@ export function QueuePicker({ }} className="text-xs text-zinc-500 underline" > - cancel + Discard new queue </button> </div> ); } return ( - <div className="flex items-center gap-2"> - <label - htmlFor={testId ? `${testId}-select` : undefined} - className="text-xs uppercase tracking-wide text-zinc-500" - > - Queue - </label> + <label className="flex items-center gap-2 text-xs uppercase tracking-wide text-zinc-500"> + Queue <select - id={testId ? `${testId}-select` : undefined} value={value} onChange={(e) => { const next = e.target.value; @@ -89,8 +79,7 @@ export function QueuePicker({ } onChange(next); }} - data-testid={testId} - className="text-sm font-mono px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900" + className="text-sm font-mono px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 normal-case tracking-normal text-zinc-900 dark:text-zinc-100" > {options.map((opt) => ( <option key={opt} value={opt}> @@ -99,6 +88,6 @@ export function QueuePicker({ ))} <option value={NEW_QUEUE_OPTION}>+ new queue…</option> </select> - </div> + </label> ); } diff --git a/editor/app/build/_components/BuildButtons.tsx b/editor/app/build/_components/BuildButtons.tsx @@ -21,7 +21,6 @@ export function BuildButtons({ existingQueues }: Props) { onChange={setQueueKey} defaultQueueKey={defaultQueueKey} existingQueues={existingQueues} - testId="build-queue" /> <section className="flex flex-col gap-3"> <div> @@ -37,7 +36,6 @@ export function BuildButtons({ existingQueues }: Props) { cancelAction={cancelJobAction} buttonLabel="Build index" runningLabel="Building index…" - testId="build-index" /> </section> diff --git a/editor/app/channels/[slug]/_components/PipelinePanel.tsx b/editor/app/channels/[slug]/_components/PipelinePanel.tsx @@ -35,7 +35,6 @@ export function PipelinePanel({ slug, hasUrl, existingQueues }: Props) { onChange={setQueueKey} defaultQueueKey={defaultQueueKey} existingQueues={existingQueues} - testId="pipeline-queue" /> <div className="flex flex-col gap-2"> <Heading @@ -47,7 +46,6 @@ export function PipelinePanel({ slug, hasUrl, existingQueues }: Props) { cancelAction={cancelJobAction} buttonLabel="Store playlist" runningLabel="Storing playlist…" - testId="pipeline-store" /> </div> <div className="flex flex-col gap-2"> @@ -60,7 +58,6 @@ export function PipelinePanel({ slug, hasUrl, existingQueues }: Props) { cancelAction={cancelJobAction} buttonLabel="Download from playlist" runningLabel="Downloading…" - testId="pipeline-download" /> </div> <div className="flex flex-col gap-2"> @@ -73,7 +70,6 @@ export function PipelinePanel({ slug, hasUrl, existingQueues }: Props) { cancelAction={cancelJobAction} buttonLabel="Sync" runningLabel="Syncing…" - testId="pipeline-sync" /> </div> </div> diff --git a/editor/app/channels/[slug]/_components/WhisperPanel.tsx b/editor/app/channels/[slug]/_components/WhisperPanel.tsx @@ -26,7 +26,6 @@ export function WhisperPanel({ slug, existingQueues }: Props) { onChange={setQueueKey} defaultQueueKey={defaultQueueKey} existingQueues={existingQueues} - testId="whisper-queue" /> <div className="flex flex-col gap-2"> <Heading diff --git a/editor/app/jobs/[id]/_components/JobLogTail.tsx b/editor/app/jobs/[id]/_components/JobLogTail.tsx @@ -12,7 +12,6 @@ import { cancelJobAction } from "../../actions"; type Props = { jobId: string; initiallyRunning: boolean; - testId?: string; }; type LogResponse = { @@ -23,7 +22,7 @@ type LogResponse = { queuePosition?: number; }; -export function JobLogTail({ jobId, initiallyRunning, testId }: Props) { +export function JobLogTail({ jobId, initiallyRunning }: Props) { const [log, setLog] = useState(""); const [status, setStatus] = useState<string>( initiallyRunning ? "running" : "loaded", @@ -95,19 +94,13 @@ export function JobLogTail({ jobId, initiallyRunning, testId }: Props) { const isQueued = status === "queued"; return ( - <div className="flex flex-col gap-2" data-testid={testId}> + <div className="flex flex-col gap-2"> <div className="flex items-center gap-3 flex-wrap"> <div className="text-xs text-zinc-500"> - Status:{" "} - <span data-testid={testId ? `${testId}-status` : undefined}> - {status} - </span> + Status: <span aria-label="Job status">{status}</span> </div> {isQueued && queueKey && queuePosition > 0 && ( - <div - className="text-xs text-zinc-500" - data-testid={testId ? `${testId}-queue-banner` : undefined} - > + <div role="status" className="text-xs text-zinc-500"> Queued in <code className="font-mono">{queueKey}</code>, position{" "} {queuePosition} </div> @@ -117,7 +110,6 @@ export function JobLogTail({ jobId, initiallyRunning, testId }: Props) { type="button" onClick={handleCancel} disabled={cancelling} - data-testid={testId ? `${testId}-cancel` : undefined} className="px-3 py-1.5 rounded-md border border-red-300 dark:border-red-800 text-sm font-medium text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50" > {cancelling ? "Cancelling…" : "Cancel"} @@ -127,7 +119,8 @@ export function JobLogTail({ jobId, initiallyRunning, testId }: Props) { <pre ref={preRef} onScroll={handleScroll} - data-testid={testId ? `${testId}-pre` : undefined} + role="log" + aria-label="Job log" 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" > {log || "(empty log)"} diff --git a/editor/app/jobs/[id]/page.tsx b/editor/app/jobs/[id]/page.tsx @@ -30,7 +30,7 @@ export default async function JobDetailPage({ </div> <h1 className="text-2xl font-semibold">{job.kind ?? "job"}</h1> {job.channelSlug && ( - <p className="text-sm" data-testid="job-channel"> + <p className="text-sm"> Channel:{" "} <Link href={`/channels/${job.channelSlug}`} @@ -67,7 +67,6 @@ export default async function JobDetailPage({ initiallyRunning={ job.status === "running" || job.status === "queued" } - testId="job-log" /> </div> ); diff --git a/editor/app/jobs/_components/CancelJobButton.tsx b/editor/app/jobs/_components/CancelJobButton.tsx @@ -6,10 +6,9 @@ import { cancelJobAction } from "../actions"; type Props = { jobId: string; - testId?: string; }; -export function CancelJobButton({ jobId, testId }: Props) { +export function CancelJobButton({ jobId }: Props) { const [busy, setBusy] = useState(false); const router = useRouter(); return ( @@ -25,7 +24,6 @@ export function CancelJobButton({ jobId, testId }: Props) { } }} disabled={busy} - data-testid={testId} className="px-2 py-1 rounded border border-red-300 dark:border-red-800 text-xs font-medium text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50" > {busy ? "Cancelling…" : "Cancel"} diff --git a/editor/app/jobs/_components/ClearArchivedButton.tsx b/editor/app/jobs/_components/ClearArchivedButton.tsx @@ -10,7 +10,6 @@ export function ClearArchivedButton() { return ( <button type="button" - data-testid="jobs-clear-archived" onClick={async () => { setBusy(true); try { diff --git a/editor/app/jobs/page.tsx b/editor/app/jobs/page.tsx @@ -45,17 +45,11 @@ export default async function JobsPage() { <ClearArchivedButton /> </div> {jobs.length === 0 ? ( - <p - data-testid="jobs-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 jobs have run yet. </p> ) : ( - <table - data-testid="jobs-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 className="text-left font-medium px-3 py-2">ID</th> @@ -80,7 +74,6 @@ export default async function JobsPage() { return ( <tr key={j.id} - data-testid={`job-row-${j.id}`} className="border-t border-zinc-200 dark:border-zinc-800" > <td className="px-3 py-2 font-mono text-xs"> @@ -92,10 +85,7 @@ export default async function JobsPage() { </Link> </td> <td className="px-3 py-2">{j.kind ?? "—"}</td> - <td - className="px-3 py-2 font-mono text-xs" - data-testid={`job-row-channel-${j.id}`} - > + <td className="px-3 py-2 font-mono text-xs"> {j.channelSlug ? ( <Link href={`/channels/${j.channelSlug}`} @@ -107,10 +97,7 @@ export default async function JobsPage() { "—" )} </td> - <td - className="px-3 py-2 font-mono text-xs" - data-testid={`job-row-queue-${j.id}`} - > + <td className="px-3 py-2 font-mono text-xs"> {j.queueKey ?? "—"} </td> <td className="px-3 py-2"> @@ -131,10 +118,7 @@ export default async function JobsPage() { </td> <td className="px-3 py-2 text-right"> {(j.status === "running" || j.status === "queued") && ( - <CancelJobButton - jobId={j.id} - testId={`cancel-${j.id}`} - /> + <CancelJobButton jobId={j.id} /> )} </td> </tr> diff --git a/editor/cypress/e2e/jobs-channel.cy.ts b/editor/cypress/e2e/jobs-channel.cy.ts @@ -1,49 +0,0 @@ -describe("Job→channel link and jobs list auto-refresh", () => { - it("shows the originating channel as a link on the jobs list and detail", () => { - 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.visit("/jobs"); - cy.get('[data-testid^="job-row-"]') - .first() - .invoke("attr", "data-testid") - .then((testId) => { - const id = (testId as string).replace(/^job-row-/, ""); - cy.findByTestId(`job-row-channel-${id}`) - .find("a") - .should("have.attr", "href", "/channels/test-pipeline") - .and("contain.text", "test-pipeline"); - cy.visit(`/jobs/${id}`); - cy.findByTestId("job-channel") - .find("a") - .should("have.attr", "href", "/channels/test-pipeline"); - }); - }); - - it("auto-refreshes the jobs list when a job is running", () => { - 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", - "test-slow", - ); - - // Switch to the jobs list and don't manually reload. The status should - // start as "running" and flip to "cancelled" within ~5s after we cancel - // the underlying job. - cy.visit("/jobs"); - cy.get('[data-testid^="job-row-"]').first().contains(/^running$/i); - - cy.get('[data-testid^="cancel-"]').first().click(); - // Don't visit again — the auto-refresh should pick up the status change. - cy.get('[data-testid^="job-row-"]') - .first() - .contains(/^cancelled$/i, { timeout: 10_000 }); - }); -}); diff --git a/editor/cypress/e2e/jobs.cy.ts b/editor/cypress/e2e/jobs.cy.ts @@ -1,38 +0,0 @@ -describe("Jobs", () => { - it("empty state when no jobs have run", () => { - cy.resetData("empty"); - // Clear any stray .jobs/*.log from other tests in this lifetime. - cy.visit("/jobs"); - cy.get('[data-testid="jobs-clear-archived"]').click(); - cy.visit("/jobs"); - cy.findByTestId("jobs-empty"); - }); - - it("kicks off a build-index job, lists it, and tails its log", () => { - cy.resetData("one-youtube-channel-with-data"); - cy.visit("/build"); - cy.findByTestId("build-index").find("button").click(); - cy.findByTestId("build-index-log", { timeout: 30_000 }).should( - "contain.text", - "Done", - ); - - cy.visit("/jobs"); - cy.findByTestId("jobs-table").within(() => { - cy.contains("build-index"); - }); - // Click into the most recent job. Pick the first job-row-* element we find. - cy.get('[data-testid^="job-row-"]') - .first() - .find("a") - .click(); - cy.findByTestId("job-log-pre", { timeout: 10_000 }).should( - "contain.text", - "Done", - ); - cy.findByTestId("job-log-status").should((el) => { - const text = el.text().trim(); - expect(text).to.be.oneOf(["done", "running"]); - }); - }); -}); diff --git a/editor/cypress/e2e/queues.cy.ts b/editor/cypress/e2e/queues.cy.ts @@ -1,115 +0,0 @@ -describe("Named job queues", () => { - it("default queue is channel:<slug>", () => { - 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", - "test-slow", - ); - cy.visit("/jobs"); - cy.get('[data-testid^="job-row-queue-"]') - .first() - .should("have.text", "channel:slow-channel"); - }); - - it("queues a second job in the same queue, runs sequentially", () => { - cy.resetData("two-slow-channels"); - - cy.visit("/channels/slow-a"); - cy.findByTestId("pipeline-queue").select("+ new queue…"); - cy.findByTestId("pipeline-queue-input").clear().type("qShared"); - cy.findByTestId("pipeline-queue-save").click(); - cy.findByTestId("pipeline-sync").find("button").click(); - cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( - "contain.text", - "test-slow", - ); - - cy.visit("/channels/slow-b"); - cy.findByTestId("pipeline-queue").select("qShared"); - cy.findByTestId("pipeline-sync").find("button").click(); - // Second job should display the queued banner. - cy.findByTestId("pipeline-sync-queue-banner", { timeout: 10_000 }).should( - "contain.text", - "qShared", - ); - - cy.visit("/jobs"); - // Two rows, both in queue qShared. Most recent first. - cy.get('[data-testid^="job-row-queue-"]') - .first() - .should("have.text", "qShared"); - cy.get('[data-testid^="job-row-queue-"]') - .eq(1) - .should("have.text", "qShared"); - - // Second row (slow-a, queued first) is currently running. First row - // (slow-b, queued second) is queued. - cy.contains("tr", "slow-a").contains(/^running$/i); - cy.contains("tr", "slow-b").contains(/^queued$/i); - - // Cancel the running job; the queued one should advance to running. - cy.contains("tr", "slow-a") - .find('[data-testid^="cancel-"]') - .click(); - cy.contains("tr", "slow-b").contains(/^running$/i, { timeout: 10_000 }); - }); - - it("runs jobs in different queues in parallel", () => { - cy.resetData("two-slow-channels"); - - cy.visit("/channels/slow-a"); - cy.findByTestId("pipeline-queue").select("+ new queue…"); - cy.findByTestId("pipeline-queue-input").clear().type("qA"); - cy.findByTestId("pipeline-queue-save").click(); - cy.findByTestId("pipeline-sync").find("button").click(); - cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( - "contain.text", - "test-slow", - ); - - cy.visit("/channels/slow-b"); - cy.findByTestId("pipeline-queue").select("+ new queue…"); - cy.findByTestId("pipeline-queue-input").clear().type("qB"); - cy.findByTestId("pipeline-queue-save").click(); - cy.findByTestId("pipeline-sync").find("button").click(); - cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( - "contain.text", - "test-slow", - ); - - cy.visit("/jobs"); - cy.contains("tr", "slow-a").contains(/^running$/i); - cy.contains("tr", "slow-b").contains(/^running$/i); - }); - - it("cancels a queued job without disturbing the one running ahead of it", () => { - cy.resetData("two-slow-channels"); - - cy.visit("/channels/slow-a"); - cy.findByTestId("pipeline-queue").select("+ new queue…"); - cy.findByTestId("pipeline-queue-input").clear().type("qShared"); - cy.findByTestId("pipeline-queue-save").click(); - cy.findByTestId("pipeline-sync").find("button").click(); - cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( - "contain.text", - "test-slow", - ); - - cy.visit("/channels/slow-b"); - cy.findByTestId("pipeline-queue").select("qShared"); - cy.findByTestId("pipeline-sync").find("button").click(); - cy.findByTestId("pipeline-sync-queue-banner", { timeout: 10_000 }); - - cy.visit("/jobs"); - cy.contains("tr", "slow-b").contains(/^queued$/i); - - cy.contains("tr", "slow-b") - .find('[data-testid^="cancel-"]') - .click(); - cy.contains("tr", "slow-b").contains(/^cancelled$/i, { timeout: 10_000 }); - // slow-a (in front) keeps running. - cy.contains("tr", "slow-a").contains(/^running$/i); - }); -}); diff --git a/editor/e2e/cancel.spec.ts b/editor/e2e/cancel.spec.ts @@ -9,11 +9,11 @@ test("cancels a running pipeline job from the channel page", async ({ page }) => timeout: 15_000, }); - await page.getByRole("button", { name: /^Cancel$/ }).click(); + await page.getByRole("button", { name: "Cancel Sync" }).click(); // Cancel button gone; run button re-enabled. await expect( - page.getByRole("button", { name: /^Cancel$/ }), + page.getByRole("button", { name: "Cancel Sync" }), ).not.toBeVisible({ timeout: 15_000 }); const syncButton = page.getByRole("button", { name: /^Sync$/ }); await expect(syncButton).toBeEnabled(); diff --git a/editor/e2e/jobs-channel.spec.ts b/editor/e2e/jobs-channel.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; +import { resetData } from "./helpers"; + +test("shows the originating channel as a link on the jobs list and detail", 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.goto("/jobs"); + const firstRow = page.getByRole("row").nth(1); // 0 is header + const channelLink = firstRow.getByRole("link", { name: "test-pipeline" }); + await expect(channelLink).toHaveAttribute("href", "/channels/test-pipeline"); + + // Click into the job detail page via its id link (first link in the row). + await firstRow.getByRole("link").first().click(); + await expect( + page.getByRole("link", { name: "test-pipeline" }), + ).toHaveAttribute("href", "/channels/test-pipeline"); +}); + +test("auto-refreshes the jobs list when a job is running", 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, + }); + + // Switch to the jobs list and don't manually reload. The status should + // start as "running" and flip to "cancelled" within ~10s after we cancel. + await page.goto("/jobs"); + const slowRow = page + .getByRole("row") + .filter({ hasText: "slow-channel" }) + .first(); + await expect(slowRow).toContainText("running"); + + await slowRow.getByRole("button", { name: /^Cancel$/ }).click(); + // Don't navigate again — auto-refresh should pick up the status change. + await expect(slowRow).toContainText("cancelled", { timeout: 10_000 }); +}); diff --git a/editor/e2e/jobs.spec.ts b/editor/e2e/jobs.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "@playwright/test"; +import { resetData } from "./helpers"; + +test("empty state when no jobs have run", async ({ page }) => { + await resetData("empty"); + // Clear any stray .jobs/*.log from other tests in this lifetime. + await page.goto("/jobs"); + await page.getByRole("button", { name: "Clear archived logs" }).click(); + await page.goto("/jobs"); + await expect(page.getByText("No jobs have run yet.")).toBeVisible(); +}); + +test("kicks off a build-index job, lists it, and tails its log", 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: 30_000, + }); + + await page.goto("/jobs"); + const buildRow = page + .getByRole("row") + .filter({ hasText: "build-index" }) + .first(); + await expect(buildRow).toBeVisible(); + + // Click into the most recent job. The job-id link is the first link in the row. + await buildRow.getByRole("link").first().click(); + await expect(page.getByLabel("Job log")).toContainText("Done", { + timeout: 10_000, + }); + const status = await page.getByLabel("Job status").textContent(); + expect(["done", "running"]).toContain((status ?? "").trim()); +}); diff --git a/editor/e2e/queues.spec.ts b/editor/e2e/queues.spec.ts @@ -0,0 +1,122 @@ +import { test, expect } from "@playwright/test"; +import { resetData } from "./helpers"; + +test("default queue is channel:<slug>", 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 firstRow = page.getByRole("row").nth(1); // 0 is header + await expect(firstRow).toContainText("channel:slow-channel"); +}); + +test("queues a second job in the same queue, runs sequentially", async ({ + page, +}) => { + await resetData("two-slow-channels"); + + await page.goto("/channels/slow-a"); + await page + .getByLabel("Queue") + .selectOption({ label: "+ new queue…" }); + await page.getByLabel("New queue name").fill("qShared"); + await page.getByRole("button", { name: "Save queue" }).click(); + await page.getByRole("button", { name: "Sync" }).click(); + await expect(page.getByLabel("Sync output")).toContainText("test-slow", { + timeout: 15_000, + }); + + await page.goto("/channels/slow-b"); + await page.getByLabel("Queue").selectOption({ label: "qShared" }); + await page.getByRole("button", { name: "Sync" }).click(); + // Second job should display the queued banner. + await expect(page.getByLabel("Sync queue status")).toContainText("qShared", { + timeout: 10_000, + }); + + await page.goto("/jobs"); + // Two body rows, both in queue qShared. Most recent first. + const row0 = page.getByRole("row").nth(1); + const row1 = page.getByRole("row").nth(2); + await expect(row0).toContainText("qShared"); + await expect(row1).toContainText("qShared"); + + // slow-a (queued first) currently running. slow-b (queued second) queued. + const slowARow = page.getByRole("row").filter({ hasText: "slow-a" }).first(); + const slowBRow = page.getByRole("row").filter({ hasText: "slow-b" }).first(); + await expect(slowARow.getByText("running", { exact: true })).toBeVisible(); + await expect(slowBRow.getByText("queued", { exact: true })).toBeVisible(); + + // Cancel the running job; the queued one advances to running. + await slowARow.getByRole("button", { name: /^Cancel$/ }).click(); + await expect(slowBRow.getByText("running", { exact: true })).toBeVisible({ + timeout: 10_000, + }); +}); + +test("runs jobs in different queues in parallel", async ({ page }) => { + await resetData("two-slow-channels"); + + await page.goto("/channels/slow-a"); + await page.getByLabel("Queue").selectOption({ label: "+ new queue…" }); + await page.getByLabel("New queue name").fill("qA"); + await page.getByRole("button", { name: "Save queue" }).click(); + await page.getByRole("button", { name: "Sync" }).click(); + await expect(page.getByLabel("Sync output")).toContainText("test-slow", { + timeout: 15_000, + }); + + await page.goto("/channels/slow-b"); + await page.getByLabel("Queue").selectOption({ label: "+ new queue…" }); + await page.getByLabel("New queue name").fill("qB"); + await page.getByRole("button", { name: "Save queue" }).click(); + await page.getByRole("button", { name: "Sync" }).click(); + await expect(page.getByLabel("Sync output")).toContainText("test-slow", { + timeout: 15_000, + }); + + await page.goto("/jobs"); + const slowARow = page.getByRole("row").filter({ hasText: "slow-a" }).first(); + const slowBRow = page.getByRole("row").filter({ hasText: "slow-b" }).first(); + await expect(slowARow.getByText("running", { exact: true })).toBeVisible(); + await expect(slowBRow.getByText("running", { exact: true })).toBeVisible(); +}); + +test("cancels a queued job without disturbing the one running ahead of it", async ({ + page, +}) => { + await resetData("two-slow-channels"); + + await page.goto("/channels/slow-a"); + await page.getByLabel("Queue").selectOption({ label: "+ new queue…" }); + await page.getByLabel("New queue name").fill("qShared"); + await page.getByRole("button", { name: "Save queue" }).click(); + await page.getByRole("button", { name: "Sync" }).click(); + await expect(page.getByLabel("Sync output")).toContainText("test-slow", { + timeout: 15_000, + }); + + await page.goto("/channels/slow-b"); + await page.getByLabel("Queue").selectOption({ label: "qShared" }); + await page.getByRole("button", { name: "Sync" }).click(); + await expect(page.getByLabel("Sync queue status")).toBeVisible({ + timeout: 10_000, + }); + + await page.goto("/jobs"); + const slowBRow = page.getByRole("row").filter({ hasText: "slow-b" }).first(); + await expect(slowBRow.getByText("queued", { exact: true })).toBeVisible(); + + await slowBRow.getByRole("button", { name: /^Cancel$/ }).click(); + await expect( + slowBRow.getByText("cancelled", { exact: true }), + ).toBeVisible({ timeout: 10_000 }); + + // slow-a (in front) keeps running. + const slowARow = page.getByRole("row").filter({ hasText: "slow-a" }).first(); + await expect(slowARow.getByText("running", { exact: true })).toBeVisible(); +}); diff --git a/editor/e2e/sync-break-on-existing.spec.ts b/editor/e2e/sync-break-on-existing.spec.ts @@ -16,7 +16,7 @@ test("treats yt-dlp exit code 101 as success and updates lastSyncedAt", async ({ const syncButton = page.getByRole("button", { name: /^Sync$/ }); await expect(syncButton).toBeEnabled(); await expect( - page.getByRole("button", { name: /^Cancel$/ }), + page.getByRole("button", { name: "Cancel Sync" }), ).not.toBeVisible(); await expect(page.getByLabel("Sync error")).not.toBeVisible();