import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, generateReport, pathExists, readJson, resetData, resolvePath, writeSettings, } from "./helpers"; import { baseUrl } from "./baseUrl"; // End-to-end coverage for the automatic priority-queue runner // (common/controller/autoRunner.ts). Uses the fake-whisper fixture (wired via // WHISPER_BIN in dev:test) so transcriptions complete near-instantly, a single // worker + maxWorkers:1 so picks are fully serialized and deterministic, and the // runner's pick log (/api/auto-queue/status) to assert the cross-channel order // the policy produced. The pure selection rules are unit-tested in // common/jobs/autoQueuePolicy.test.ts; this proves the runner actually drives // transcriptions in that order across channels. type Leaf = { id: string; match: { type: string; value?: string; bucket?: string; operation?: string }; weight?: number; maxWorkers?: number | null; }; type Group = { id: string; mode: string; children: (Group | Leaf)[]; maxWorkers?: number | null; }; // One enabled local worker using the fake whisper engine. const ONE_WORKER = [ { id: "w1", name: "W1", kind: "local", enabled: true, priority: 0, appId: "whisper-cpp", config: {}, }, ]; // Two enabled local workers; tests can then disable one via a saved default. const TWO_WORKERS = [ { id: "gpu", name: "GPU", kind: "local", enabled: true, priority: 0, appId: "whisper-cpp", config: {} }, { id: "cpu", name: "CPU", kind: "local", enabled: true, priority: 1, appId: "whisper-cpp", config: {} }, ]; async function makeChannel(slug: string, ids: string[]) { const root = resolvePath(`test-transcripts/channels/${slug}`); await mkdir(`${root}/data`, { recursive: true }); await writeFile( `${root}/config.json`, JSON.stringify({ handling: "transcribe", name: slug, url: `https://example.com/@${slug}`, audioFormat: "mp3", }), ); for (const id of ids) { await mkdir(`${root}/data/${id}`, { recursive: true }); await writeFile(`${root}/data/${id}/audio.mp3`, `fake audio ${id}\n`); } // Seed a static snapshot so the runner sees these as downloaded-no-transcript. await writeFile( `${root}/snapshot.json`, JSON.stringify({ generatedAt: "2026-06-01T00:00:00.000Z", totals: { videos: ids.length, transcribed: 0, downloaded: ids.length }, buckets: { noTranscript: [], downloadedNoTranscript: ids, untranscoded: [], multipleAudioFormats: [], transcribedWithAudio: [], untranscribable: [], noMetadata: [], failedListed: [], missingFromArchive: [], duplicateDirs: [], partialDownloads: [], corruptSource: [], nonStandardVtt: [], skippedByFilter: [], }, undownloadedIds: [], }), ); } function transcriptionAutoQueue(root: Group, maxWorkers: number | null = 1) { return { transcription: { enabled: true, maxWorkers, root }, download: {} }; } function downloadAutoQueue(root: Group, maxWorkers: number | null = null) { return { transcription: {}, download: { enabled: true, maxWorkers, root } }; } // A YouTube channel with undownloaded videos: a playlist of watch URLs whose ids // match, and a snapshot listing them as undownloadedIds. Same platform for both // channels so the runner's per-platform gate serializes downloads → a // deterministic cross-channel order. async function makeDownloadChannel(slug: string, ids: string[]) { const root = resolvePath(`test-transcripts/channels/${slug}`); await mkdir(`${root}/data`, { recursive: true }); await writeFile( `${root}/config.json`, JSON.stringify({ handling: "youtube", name: slug, url: `https://www.youtube.com/@${slug}/videos`, }), ); await writeFile( `${root}/playlist`, ids.map((id) => `https://www.youtube.com/watch?v=${id}`).join("\n") + "\n", ); await writeFile( `${root}/snapshot.json`, JSON.stringify({ generatedAt: "2026-06-01T00:00:00.000Z", totals: { videos: ids.length, transcribed: 0, downloaded: 0 }, buckets: { noTranscript: [], downloadedNoTranscript: [], untranscoded: [], multipleAudioFormats: [], transcribedWithAudio: [], untranscribable: [], noMetadata: [], failedListed: [], missingFromArchive: [], duplicateDirs: [], partialDownloads: [], corruptSource: [], nonStandardVtt: [], skippedByFilter: [], }, undownloadedIds: ids, }), ); } async function startRunner( request: import("@playwright/test").APIRequestContext, kind: "transcription" | "download" = "transcription", ) { const res = await request.post(`${baseUrl}/api/auto-queue/control`, { // Behind the ops token since release 19 (A3): it starts and stops lanes. headers: { authorization: "Bearer test-worker-token" }, data: { kind, action: "start" }, }); expect(res.ok()).toBeTruthy(); return res.json(); } async function stopRunner( request: import("@playwright/test").APIRequestContext, kind: "transcription" | "download" = "transcription", ) { await request.post(`${baseUrl}/api/auto-queue/control`, { // Behind the ops token since release 19 (A3): it starts and stops lanes. headers: { authorization: "Bearer test-worker-token" }, data: { kind, action: "stop" }, }); } type KindStatus = { runner: { running: boolean; jobId: string | null; idleReason: string | null; }; policy: { order?: string; snoozeUntil?: number | null }; pendingByLeaf: Record; pendingHeadByLeaf: Record; nextUp: { videoId: string; leafId: string } | null; picks: { leafId: string; videoId: string; channelSlug: string }[]; }; type Status = { transcription: KindStatus; download: KindStatus }; async function getStatus( request: import("@playwright/test").APIRequestContext, ): Promise { const res = await request.get(`${baseUrl}/api/auto-queue/status`); expect(res.ok()).toBeTruthy(); return res.json(); } // Chronological pick order (the status log is newest-first). function pickOrder( status: Status, kind: "transcription" | "download" = "transcription", ): string[] { return status[kind].picks.map((p) => p.videoId).reverse(); } async function allTranscribed(slug: string, ids: string[]): Promise { for (const id of ids) { if ( !(await pathExists(`test-transcripts/channels/${slug}/data/${id}/transcript.json`)) ) { return false; } } return true; } test.afterEach(async ({ request }) => { // Stop any runner left from a test so its loop can't bleed into the next spec. await stopRunner(request, "transcription"); await stopRunner(request, "download"); }); test("strict priority: drains the high-priority channel first, then falls back", async ({ request, }) => { await resetData(null); await makeChannel("alpha", ["a1", "a2"]); await makeChannel("beta", ["b1", "b2"]); const root: Group = { id: "root", mode: "strict", children: [ { id: "leaf-alpha", match: { type: "channel", value: "alpha" } }, { id: "leaf-beta", match: { type: "channel", value: "beta" } }, ], }; await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, workers: ONE_WORKER, autoQueue: transcriptionAutoQueue(root), }); await startRunner(request); await expect .poll(async () => (await getStatus(request)).transcription.picks.length, { timeout: 60_000, }) .toBe(4); // Every alpha video is picked before any beta video, and in-channel order // holds. THIS is what the test is about, and it stays an exact assertion. expect(pickOrder(await getStatus(request))).toEqual(["a1", "a2", "b1", "b2"]); // The transcripts are POLLED, not asserted outright. A pick is recorded when // the runner hands work out; a transcript appears when that work finishes, so // the fourth pick is observable before the fourth transcript is written — and // asserting instantly is a ~1-in-3 race under load, which is how this landed // red. Polling does not weaken the claim: all four transcripts must still // exist, and the ordering assertion above is untouched. await expect .poll( async () => (await allTranscribed("alpha", ["a1", "a2"])) && (await allTranscribed("beta", ["b1", "b2"])), { timeout: 30_000 }, ) .toBe(true); }); test("respects the saved worker default: a disabled worker is never used", async ({ request, }) => { await resetData(null); await makeChannel("alpha", ["a1", "a2", "a3", "a4"]); // Both workers are enabled in settings, but the saved default // (.worker-defaults.json) enables only gpu — cpu must stay disabled, and the // runner must honor that (regression for the boot-time reconfigure() bug that // bypassed applyDefaults()). await mkdir(resolvePath("test-transcripts/.workers"), { recursive: true }); await writeFile( resolvePath("test-transcripts/.workers/defaults.json"), JSON.stringify({ enabledWorkerIds: ["gpu"] }), ); const root: Group = { id: "root", mode: "strict", children: [{ id: "leaf-alpha", match: { type: "channel", value: "alpha" } }], }; await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, workers: TWO_WORKERS, // No runner cap, so without the fix the runner would use BOTH workers. autoQueue: transcriptionAutoQueue(root, null), }); await startRunner(request); const ids = ["a1", "a2", "a3", "a4"]; await expect .poll(async () => allTranscribed("alpha", ids), { timeout: 60_000 }) .toBe(true); // Every transcription ran on gpu — cpu (disabled by the saved default) was never used. for (const id of ids) { const outcome = await readJson<{ worker?: { id?: string } }>( `test-transcripts/channels/alpha/data/${id}/transcribe-outcome.json`, ); expect(outcome.worker?.id).toBe("gpu"); } }); test("round-robin: alternates between the two channels", async ({ request }) => { await resetData(null); await makeChannel("alpha", ["a1", "a2"]); await makeChannel("beta", ["b1", "b2"]); const root: Group = { id: "root", mode: "round-robin", children: [ { id: "leaf-alpha", match: { type: "channel", value: "alpha" } }, { id: "leaf-beta", match: { type: "channel", value: "beta" } }, ], }; await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, workers: ONE_WORKER, autoQueue: transcriptionAutoQueue(root), }); await startRunner(request); await expect .poll(async () => (await getStatus(request)).transcription.picks.length, { timeout: 60_000, }) .toBe(4); // SWRR with equal weights interleaves the channels: a, b, a, b. const order = pickOrder(await getStatus(request)); expect(order).toEqual(["a1", "b1", "a2", "b2"]); }); test("UI: build a policy in the editor, save it, and start the runner", async ({ page, }) => { await resetData(null); await makeChannel("alpha", ["a1", "a2"]); await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, workers: ONE_WORKER, autoQueue: { transcription: { enabled: false, maxWorkers: 1, root: { id: "root", mode: "strict", children: [] }, }, download: {}, }, }); await page.goto("/operations/transcription"); await expect( page.getByRole("heading", { name: "Transcription", exact: true }), ).toBeVisible(); // Scope to the Auto-transcribe section. There is exactly one runner section // per page now — the lane that // chose which lane you could act on; it is the switcher itself now, and each // row's NAME is the link. That is what these three tests pin: the board lists // every pipeline, the rows go somewhere, and the old address still lands. test("UI: the board carries every pipeline, and each row links to it", async ({ page, }) => { await resetData(null); await makeChannel("alpha", ["a1", "a2"]); await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, workers: ONE_WORKER, autoQueue: transcriptionAutoQueue(ALPHA_ROOT), }); await page.goto("/operations"); await expect( page.getByRole("heading", { name: "Operations", exact: true }), ).toBeVisible(); await awaitHydration(page.locator('[data-board="operations"]')); // The rail carries a row per pipeline — the two the runners dispatch, the one // digest owns, every speaker operation that is switched on, and sync, the one // channel-scoped row. It is the CATALOG view: what this install can do, not // what it is doing. const rail = page.locator("li[data-operation]"); await expect(rail.filter({ hasText: "Sync" }).first()).toBeVisible(); await expect(rail.filter({ hasText: "Download" }).first()).toBeVisible(); await expect(rail.filter({ hasText: "Transcription" }).first()).toBeVisible(); await expect(rail.filter({ hasText: "Digest" }).first()).toBeVisible(); // THE ROW'S NAME IS THE LINK, and its accessible name is the operation alone // — a link wrapping the whole row would be named by figures that change on // every poll. await expect( rail.getByRole("link", { name: "Transcription", exact: true }), ).toHaveAttribute("href", "/operations/transcription"); // Sync's row is composed rather than folded from a band — it has no per-video // population — but it is the same anatomy, and its name links to its page. await expect( rail.getByRole("link", { name: "Sync", exact: true }), ).toHaveAttribute("href", "/operations/sync"); await rail.getByRole("link", { name: "Digest", exact: true }).click(); await expect(page).toHaveURL(/\/operations\/digest$/); // NOTHING ON THE BOARD IS A RUNNER PANEL. The board is about every operation; // one operation in full is what /operations/ is for. await page.goto("/operations"); await expect(page.locator("section[data-lane]")).toHaveCount(0); }); test("UI: /auto-queue still lands, and an unknown operation is a 404", async ({ page, }) => { await resetData(null); await makeChannel("alpha", ["a1"]); await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, workers: ONE_WORKER, autoQueue: transcriptionAutoQueue(ALPHA_ROOT), }); // A RETIRED ROUTE REDIRECTS, NEVER 404s — the nav rule this repo shares with // umtool. Every bookmark, every link in a commit message, every note in // plans/ that says /auto-queue still lands on the console. await page.goto("/auto-queue"); await expect(page).toHaveURL(/\/operations$/); await expect( page.getByRole("heading", { name: "Operations", exact: true }), ).toBeVisible(); // The catalog IS the route table, so an id the registry does not know is a // 404 rather than an empty console. const res = await page.goto("/operations/not-an-operation"); expect(res?.status()).toBe(404); }); test("UI: each operation page carries its own lane, and the rail as context", async ({ page, }) => { await resetData(null); await makeChannel("alpha", ["a1", "a2"]); await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, workers: ONE_WORKER, autoQueue: transcriptionAutoQueue(ALPHA_ROOT), }); // ONE RUNNER SECTION PER PAGE. Auto-download is not hidden here, it is simply // not on this page — which is what deleting the switcher bought: no second // pane to keep mounted, and no policy edit to lose across a switch. await page.goto("/operations/transcription"); const transcribe = page.locator("section", { has: page.getByRole("heading", { name: "Auto-transcribe" }), }); await awaitHydration(transcribe); await expect(transcribe).toBeVisible(); await expect(page.locator('section[data-lane="download"]')).toHaveCount(0); // The rail is still above it — on this corpus a lane is usually idle BECAUSE // another one is, so the context travels with the operation. await expect(page.locator("li[data-operation]").first()).toBeVisible(); await page.goto("/operations/download"); await expect( page.locator("section", { has: page.getByRole("heading", { name: "Auto-download" }), }), ).toBeVisible(); // A RUNNER PAGE HAS ITS PAUSE TOO, beside Start/Drain/Stop. Those three act on // the runner; this holds the LANE, which outlives it — and until slice 7 the // only way to hold a runner lane was the dashboard. // // It writes the same settings key the dashboard's button does, so the key is // what is asserted, not just the label. Since slice 1.4 that key is the lane's // own `autoQueue.download.held`, not the loose `downloadsPaused` flag. The // pause is released again at the end so this test leaves the fixture as it // found it. const download = page.locator('section[data-lane="download"]'); await awaitHydration(download); const downloadsPaused = async () => ( await readJson<{ autoQueue?: { download?: { held?: boolean } } }>( "test-settings.json", ).catch(() => ({}) as { autoQueue?: { download?: { held?: boolean } } }) ).autoQueue?.download?.held ?? false; const pause = download.getByRole("button", { name: "Pause Downloads" }); await expect(pause).toBeVisible(); await pause.click(); await expect.poll(downloadsPaused, { timeout: 20_000 }).toBe(true); const resume = download.getByRole("button", { name: "Resume Downloads" }); await expect(resume).toBeVisible(); await resume.click(); await expect.poll(downloadsPaused, { timeout: 20_000 }).toBe(false); // The digest lane is a page of its own, and it is a RUNNER console like the // two above it — the same section, the same four controls. Until slice 1.3 it // was a sweep panel on `data-sweep-lane`; that attribute is gone, and no // second section on the page answers to a lane. await page.goto("/operations/digest"); const digest = page.locator('section[data-lane="digest"]'); await awaitHydration(digest); await expect(page.locator("section[data-sweep-lane]")).toHaveCount(0); // THE ARBITER'S BUTTONS, ASSERTED GONE. Its three tests were deleted with it, // so without this nothing in the suite would notice `ArbiterBar` coming back — // and it was a live Start button over a dispatcher that never ran a unit in // production. The board is where it sat, so the board is where it is missed. await page.goto("/operations"); await awaitHydration(page.locator('[data-board="operations"]')); await expect( page.getByRole("button", { name: /the arbiter/i }), ).toHaveCount(0); await page.goto("/operations/digest"); await awaitHydration(digest); await expect( digest.getByRole("heading", { name: "Auto-digest" }), ).toBeVisible(); await expect( digest.getByRole("button", { name: "Start Auto-digest" }), ).toBeVisible(); // THE RUNNER AND THE GATE ARE DIFFERENT CONTROLS, and both must be here — // conflating them is how an operator loses a week of GPU time. await expect( digest.getByRole("button", { name: "Pause Digests" }), ).toBeVisible(); // The sweep's own controls are gone from this page and from everywhere else — // BOTH halves. Asserting only the arm half would let a disarm button survive // over an action that no longer exists. await expect( page.getByRole("button", { name: "Start Digest sweep" }), ).toHaveCount(0); await expect( page.getByRole("button", { name: "Stop Digest sweep" }), ).toHaveCount(0); await expect(page.getByRole("button", { name: /Stop sweeping/i })).toHaveCount( 0, ); }); test("the digest lane offers Shortest first, and has no Reach axis", async ({ page, }) => { await resetData(null); await makeChannel("alpha", ["a1"]); await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, workers: ONE_WORKER, autoQueue: transcriptionAutoQueue(ALPHA_ROOT), }); await page.goto("/operations/digest"); const digest = page.locator('section[data-lane="digest"]'); await awaitHydration(digest); // REACH RETIRED WITH THE SWEEP. A runner's order already applies across every // channel and bucket a rule claims, so there is no second axis to offer — // which rule goes FIRST is the tree's job. The control is gone, not disabled: // a disabled dropdown would imply a setting that does not exist. await expect(digest.getByLabel("Reach", { exact: true })).toHaveCount(0); // "Shortest first" is offered on this lane ALONE, because it is the only lane // whose runner can price its candidates. const order = digest.getByLabel("video order for auto-digest"); await expect(order.locator("option")).toHaveText([ "Listed order", "Newest first", "Oldest first", "Shortest first", ]); // HOLD THE LANE FIRST, and this is not decoration. `saveAutoQueueAction` // spells every policy field explicitly — its own comment says why — so a // field it forgets is dropped on every save, and since slice 1.4 the lane's // GATE is such a field. A rule reorder that silently resumed a held lane is // the exact regression this half is here to catch. await digest.getByRole("button", { name: "pause digests" }).click(); await expect .poll( async () => ( await readJson<{ autoQueue?: { digest?: { held?: boolean } }; }>("test-settings.json").catch(() => null) )?.autoQueue?.digest?.held ?? null, { timeout: 15_000 }, ) .toBe(true); await order.selectOption("newest"); await digest.getByRole("button", { name: "Save policy" }).click(); // It lands on the LANE POLICY, not in settings.digest — `recencyOrder` and // `recencyReach` retired with the sweep that read them — and the hold above // is still there afterwards. await expect .poll( async () => { const s = await readJson<{ autoQueue?: { digest?: { order?: string; held?: boolean } }; digest?: { recencyOrder?: string; recencyReach?: string }; }>("test-settings.json").catch(() => null); return [ s?.autoQueue?.digest?.order, s?.digest?.recencyOrder ?? "gone", s?.digest?.recencyReach ?? "gone", `held=${String(s?.autoQueue?.digest?.held)}`, ].join("/"); }, { timeout: 15_000 }, ) .toBe("newest/gone/gone/held=true"); }); // THE DELETION, FROM THE BROWSER. Until S0-pause a settings.json spelling one // of the four RETIRED pause fields and carrying no `held` still held its lane: // `isGateHeld` fell back to the field and `getSettings` copied the answer onto // the lane. Both are gone, on the precondition that the live settings.json had // already been written with all four `held` keys — so a file that still spells // a retired field must now hold NOTHING, and must lose the field the first time // the editor writes it. // // That second half is what a unit test cannot see: the strip happens in // `writeSettings`, which builds its output from only the known operational // fields, and this asserts it through a real save from a real page. // // The DIGEST lane, not transcription, and that is the asymmetry rather than a // shortcut: transcription's live hold is the worker pool, and no UI surface may // read its stored value (see lib/pauseGates.ts), so a fixture flag could not be // observed through a page without contradicting that rule. test("a retired pause field holds nothing, and does not survive a write", async ({ page, }) => { await resetData(null); await makeChannel("alpha", ["a1"]); await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, workers: ONE_WORKER, // The pre-1.4 spelling, and nothing else: no `autoQueue.digest` block, so // no `held` for the sanitizer to find either. digest: { digestsPaused: true }, }); const res = await page.request.get(`${baseUrl}/api/auto-queue/status`); expect(res.ok()).toBeTruthy(); const payload = (await res.json()) as Record; expect(payload.digest?.held).toBe(false); await page.goto("/operations/digest"); const digest = page.locator('section[data-lane="digest"]'); await awaitHydration(digest); // The lane reads as running, so the control on offer is the pause. const pause = digest.getByRole("button", { name: "pause digests" }); await expect(pause).toBeEnabled({ timeout: 30_000 }); await pause.click(); // The click persists the KEY — and the write drops the retired field, so a // stale settings.json cannot resurrect a pause after an operator lifts one. await expect .poll( async () => { const s = await readJson<{ autoQueue?: { digest?: { held?: boolean } }; digest?: { digestsPaused?: boolean }; }>("test-settings.json").catch(() => null); if (!s) return null; return `held=${String(s.autoQueue?.digest?.held)}/retired=${String( "digestsPaused" in (s.digest ?? {}), )}`; }, { timeout: 20_000 }, ) .toBe("held=true/retired=false"); }); test("/jobs: a runner is a lane on a strip, not a card", async ({ page, request, }) => { await resetData(null); await makeChannel("alpha", []); // enabled, nothing pending -> running but idle await writeSettings(IDLE_SETTINGS(ALPHA_ROOT)); await startRunner(request); await page.goto("/jobs"); const lane = page.locator( "section[aria-label='System jobs: Auto-transcribe']", ); // Still addressable exactly as before — heading, aria-label, controls. await expect(lane.getByRole("heading", { name: "Auto-transcribe" })).toBeVisible(); await expect(lane.getByRole("button", { name: /^Cancel$/ })).toBeVisible(); // What is NEW: it says why it is not working, instead of a card that said // nothing at all. await expect(lane.getByText(/nothing pending/)).toBeVisible({ timeout: 15_000, }); // The sweep-fed lanes are on the strip too, with no job of their own. await expect( page.locator("section[aria-label='System jobs: Digest']"), ).toBeVisible(); // And the generic bucket stays gone. await expect(page.getByRole("heading", { name: "Other" })).toHaveCount(0); });