Archilyzer · Source

archilyzer

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

commit 888310546a552f16b0400a137270e03b3d36bea8
parent 1a57f3c51f08ef206a897b21526563bccad2be2f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  4 Aug 2026 22:18:35 -0400

Stop the pulse endpoint from constructing the singletons it reports on

Two real bugs found by running the full e2e suite.

1. /api/pulse called getRegistry() / getWorkerPool() / getScheduler(). Those
   are LAZY singletons — calling them creates the thing if it is absent. A
   status poll running every few seconds in every open tab therefore rebuilt
   whatever /api/test/invalidate-cache had just cleared between specs,
   re-seeding a worker pool from settings mid-reset. The symptom was ~16
   unrelated specs (branding, build, bookmarks, download pipeline) failing in
   shifting combinations while each passed in isolation. It now reads the
   globals directly and treats "not created yet" as "nothing to report": a
   pulse must be able to say the system is idle without making a system to ask.

   This is not test-only. The same construct-on-observe would have had the
   editor's own status poll racing process startup in production.

2. The sidebar badges hydrated with a mismatch. They sit inside a <Suspense>
   boundary, so they hydrate later than the rest of the tree — by which time
   the shared poller, started by a component that hydrated earlier, had already
   written new numbers into the module. Initialising component state from that
   module made the client's first render disagree with the server's HTML
   (server: no badge at 0 bytes; client: a badge). usePulse now returns null
   until the first poll after mount, and callers render their server seed until
   then.

Also migrates the remaining e2e specs onto the explicit generateReport()
helper. The channel page no longer regenerates a missing report as a side
effect of being viewed, and far more specs depended on that than the ones
carrying a comment about it.

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

Diffstat:
Meditor/app/api/pulse/route.ts | 57+++++++++++++++++++++++++++++++++++++++++----------------
Meditor/app/components/SidebarBadges.tsx | 16++++++++--------
Meditor/app/components/pulse.ts | 14++++++++++++--
Meditor/e2e/audio-check-scenarios.spec.ts | 3++-
Meditor/e2e/auto-queue.spec.ts | 4++--
Meditor/e2e/auto-subs-replace.spec.ts | 9+++++++--
Meditor/e2e/availability-backfill.spec.ts | 4+++-
Meditor/e2e/availability.spec.ts | 10++++++++--
Meditor/e2e/bookmarks.spec.ts | 7++++++-
Meditor/e2e/bulk-actions.spec.ts | 9++++++++-
Meditor/e2e/cancel.spec.ts | 4+++-
Meditor/e2e/channel-embedded-video.spec.ts | 5++++-
Meditor/e2e/channel-rename.spec.ts | 3++-
Meditor/e2e/channel-site-membership.spec.ts | 6+++++-
Meditor/e2e/channels-actions.spec.ts | 3++-
Meditor/e2e/channels-counts.spec.ts | 4+++-
Meditor/e2e/channels.spec.ts | 9++++++++-
Meditor/e2e/chough.spec.ts | 4+++-
Meditor/e2e/cookies-mode.spec.ts | 8++++++--
Meditor/e2e/digest.spec.ts | 7+++++--
Meditor/e2e/disk-space.spec.ts | 4+++-
Meditor/e2e/download-format-guard.spec.ts | 4++--
Meditor/e2e/download-part-files.spec.ts | 3++-
Meditor/e2e/exclude-from-counts.spec.ts | 5++++-
Meditor/e2e/helpers.ts | 8+++++++-
Meditor/e2e/import-video.spec.ts | 4+++-
Meditor/e2e/job-stream-cancel.spec.ts | 3++-
Meditor/e2e/jobs-active-order.spec.ts | 4+++-
Meditor/e2e/jobs-batch-tasks-drain.spec.ts | 11++++++++++-
Meditor/e2e/jobs-channel.spec.ts | 4+++-
Meditor/e2e/jobs-reorder.spec.ts | 3++-
Meditor/e2e/maybe-missing.spec.ts | 5++++-
Meditor/e2e/new-channel-onboarding.spec.ts | 7++++++-
Meditor/e2e/no-subs-fallback.spec.ts | 6+++++-
Meditor/e2e/parakeet-partial.spec.ts | 8+++++++-
Meditor/e2e/parakeet.spec.ts | 4+++-
Meditor/e2e/partial-downloads-bucket.spec.ts | 5++++-
Meditor/e2e/pipeline.spec.ts | 8+++++++-
Meditor/e2e/pre-clean-availability.spec.ts | 4++--
Meditor/e2e/queues.spec.ts | 13++++++++++++-
Meditor/e2e/reconcile.spec.ts | 11++++++-----
Meditor/e2e/retry-bucket.spec.ts | 5++++-
Meditor/e2e/saved-videos.spec.ts | 3++-
Meditor/e2e/shard.spec.ts | 11++++++++++-
Meditor/e2e/skip-live.spec.ts | 6++++--
Meditor/e2e/social-channel.spec.ts | 8+++++++-
Meditor/e2e/sync-break-on-existing.spec.ts | 3++-
Meditor/e2e/transcript-source.spec.ts | 3++-
Meditor/e2e/truncated-check.spec.ts | 4+++-
Meditor/e2e/undownloaded.spec.ts | 6+++++-
Meditor/e2e/video-filter-combine.spec.ts | 3++-
Meditor/e2e/video-page.spec.ts | 6+++++-
Meditor/e2e/whisper.spec.ts | 13++++++++++++-
Meditor/e2e/worker-remote.spec.ts | 4+++-
Meditor/e2e/workers.spec.ts | 4+++-
55 files changed, 299 insertions(+), 90 deletions(-)

diff --git a/editor/app/api/pulse/route.ts b/editor/app/api/pulse/route.ts @@ -33,17 +33,37 @@ export type PulsePayload = { busy: boolean; }; -// Everything here is either in-memory or a stat(). NO readdir, no corpus -// contact, no JSON parsing of anything large — asserted by a test, because the -// entire point of this endpoint is that it is cheap enough to poll. +// ⚠️ THIS ENDPOINT OBSERVES; IT MUST NEVER CONSTRUCT. +// +// getRegistry() / getWorkerPool() / getScheduler() are lazy singletons: calling +// them CREATES the thing if it doesn't exist. That is fine for a page acting on +// jobs and catastrophic for a status poll running every few seconds in the +// background, because /api/test/invalidate-cache clears exactly these globals +// between e2e specs — a poll landing a moment later silently rebuilt them, +// re-seeding a worker pool from settings mid-reset. The symptom was ~16 +// unrelated specs (branding, build, bookmarks, downloads) failing in +// non-deterministic combinations while each passed in isolation. // -// The singletons are read LIVE inside the request rather than captured at -// module scope: e2e/helpers.ts's resetData clears them via -// /api/test/invalidate-cache, and a closed-over reference would keep polling a -// registry the tests had already thrown away. +// So read the globals directly and treat "not created yet" as "nothing to +// report". A pulse must be able to say "the system is idle" without making a +// system to ask. +// +// Everything here is either in-memory or a stat(). NO readdir, no corpus +// contact, no large JSON parse — asserted by e2e/pulse.spec.ts, because the +// entire point of this endpoint is that it is cheap enough to poll forever. +declare global { + // eslint-disable-next-line no-var + var __yttJobRegistry__: ReturnType<typeof getRegistry> | undefined; + // eslint-disable-next-line no-var + var __yttWorkerPool__: ReturnType<typeof getWorkerPool> | undefined; + // eslint-disable-next-line no-var + var __yttScheduler__: ReturnType<typeof getScheduler> | undefined; +} + function computeRev(): { rev: string; activeJobs: number; runningJobs: number; busy: boolean } { const paths = getPaths(); - const jobs = getRegistry().list(); + const registry = globalThis.__yttJobRegistry__; + const jobs = registry ? registry.list() : []; let activeJobs = 0; let runningJobs = 0; @@ -70,15 +90,20 @@ function computeRev(): { rev: string; activeJobs: number; runningJobs: number; b ); } - // Queue shape and worker-pool state, both in-memory. - const scheduler = getScheduler(); - for (const q of scheduler.queues()) { - parts.push(`q:${q.name}:${q.running.join(",")}:${q.queued.join(",")}`); + // Queue shape and worker-pool state, both in-memory — and both skipped + // entirely when the singleton doesn't exist yet (see above). + const scheduler = globalThis.__yttScheduler__; + if (scheduler) { + for (const q of scheduler.queues()) { + parts.push(`q:${q.name}:${q.running.join(",")}:${q.queued.join(",")}`); + } + } + const pool = globalThis.__yttWorkerPool__; + if (pool) { + const workers = pool.summary(); + parts.push(`w:${pool.isPaused() ? "paused" : "live"}:${workers.length}`); + for (const w of workers) parts.push(`w:${w.id}:${w.busy ? 1 : 0}`); } - const pool = getWorkerPool(); - const workers = pool.summary(); - parts.push(`w:${pool.isPaused() ? "paused" : "live"}:${workers.length}`); - for (const w of workers) parts.push(`w:${w.id}:${w.busy ? 1 : 0}`); // Files the layout renders from. mtime only — neither is read here. parts.push(`s:${mtime(paths.settingsFile)}`); diff --git a/editor/app/components/SidebarBadges.tsx b/editor/app/components/SidebarBadges.tsx @@ -22,13 +22,13 @@ export function JobsBadge({ metric: "active" | "running"; }) { const pulse = usePulse(); - // Until the first poll completes there is no rev, so trust the server seed. - const count = - pulse.rev === null - ? seed - : metric === "active" - ? pulse.activeJobs - : pulse.runningJobs; + // Null until the first poll after mount — until then the server seed IS the + // truth, and rendering anything else would be a hydration mismatch. + const count = !pulse + ? seed + : metric === "active" + ? pulse.activeJobs + : pulse.runningJobs; if (count <= 0) return null; const noun = metric === "running" ? "running job" : "active job"; return ( @@ -44,7 +44,7 @@ export function JobsBadge({ export function CleanableBadgeLive({ seed }: { seed: number }) { const pulse = usePulse(); - const bytes = pulse.rev === null ? seed : pulse.cleanableBytes; + const bytes = pulse ? pulse.cleanableBytes : seed; if (bytes <= 0) return null; return ( <span diff --git a/editor/app/components/pulse.ts b/editor/app/components/pulse.ts @@ -130,8 +130,18 @@ function onVisibility() { } // Subscribe a component to the shared poller. -export function usePulse(): PulseState { - const [local, setLocal] = useState<PulseState>(state); +// +// Returns null until the first poll AFTER this component mounts, and callers +// fall back to their server-rendered seed until then. That is not caution, it +// is a hydration requirement: the badges live inside a <Suspense> boundary, so +// they hydrate LATER than the rest of the tree — by which time the shared +// poller (started by a component that hydrated earlier) may already have new +// numbers in this module. Initialising state from the module would then make +// the client's first render disagree with the server's HTML, which is exactly +// the mismatch this used to produce: the server rendered no badge (0 bytes) and +// the client rendered one. +export function usePulse(): PulseState | null { + const [local, setLocal] = useState<PulseState | null>(null); useEffect(() => subscribePulse(setLocal), []); return local; } diff --git a/editor/e2e/audio-check-scenarios.spec.ts b/editor/e2e/audio-check-scenarios.spec.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath } from "./helpers"; +import { pathExists, readJson, resetData, resolvePath, generateReport } from "./helpers"; import { baseUrl } from "./baseUrl"; import type { DownloadOutcomeRecord, @@ -68,6 +68,7 @@ async function maxChunkGapMs(): Promise<number> { } async function triggerDownload(page: import("@playwright/test").Page) { + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Download videos" }).click(); } diff --git a/editor/e2e/auto-queue.spec.ts b/editor/e2e/auto-queue.spec.ts @@ -5,8 +5,7 @@ import { resolvePath, pathExists, readJson, - writeSettings, -} from "./helpers"; + writeSettings, generateReport } from "./helpers"; import { baseUrl } from "./baseUrl"; // End-to-end coverage for the automatic priority-queue runner @@ -606,6 +605,7 @@ test("Drain completes when an auto-transcribe unit is parked behind a busy worke }); // 1) Manual foreground transcription holds the single worker (slowop → ~50s). + await generateReport(page, "busy"); await page.goto("/channels/busy"); await page.getByRole("button", { name: "Transcribe missing" }).click(); // Wait until it's actually running (worker leased), so the auto unit will park. diff --git a/editor/e2e/auto-subs-replace.spec.ts b/editor/e2e/auto-subs-replace.spec.ts @@ -21,8 +21,7 @@ import { readJson, resetData, resolvePath, - writeSettings, -} from "./helpers"; + writeSettings, generateReport } from "./helpers"; import { baseUrl } from "./baseUrl"; const SLUG = "test-auto-subs"; @@ -172,6 +171,7 @@ test("walks an auto-caption video through fetch → transcribe → purge", async ]); // --- Step 0: classification ----------------------------------------------- + await generateReport(page, SLUG); await page.goto(`/channels/${SLUG}`); await expectBuckets({ autoSubsOnly: ["asrvid0001"] }); @@ -200,6 +200,7 @@ test("walks an auto-caption video through fetch → transcribe → purge", async expect(await pathExists(dataRel("asrvid0001", "audio.mp3"))).toBe(true); // --- Step 2: transcribe over the auto-captions ---------------------------- + await generateReport(page, SLUG); await page.goto(`/channels/${SLUG}`); await expectBuckets({ downloadedAutoSubsOnly: ["asrvid0001"] }); @@ -222,6 +223,7 @@ test("walks an auto-caption video through fetch → transcribe → purge", async ); // --- Step 3: the backup shows up as purgeable inventory ------------------- + await generateReport(page, SLUG); await page.goto(`/channels/${SLUG}`); await expectBuckets({ supersededAutoSubs: ["asrvid0001"] }); @@ -257,6 +259,7 @@ test("walks an auto-caption video through fetch → transcribe → purge", async true, ); + await generateReport(page, SLUG); await page.goto(`/channels/${SLUG}`); await expectBuckets({}); }); @@ -277,6 +280,7 @@ test("never buckets or purges captions it can't prove are auto-generated", async { id: "asrkeep0001", captions: "asr", whisper: true, doNotClean: true }, ]); + await generateReport(page, SLUG); await page.goto(`/channels/${SLUG}`); // Only the unprotected ASR-plus-whisper video is listed as a backup, and no // manual-caption video appears in the work lane at all. @@ -339,6 +343,7 @@ test("the auto-transcribe runner transcribes over auto-captions when opted in", // Manual captions + audio: the runner must never pick this one up. { id: "manvid0003", captions: "manual", audio: true }, ]); + await generateReport(page, SLUG); await page.goto(`/channels/${SLUG}`); await expectBuckets({ downloadedAutoSubsOnly: ["asrvid0003"] }); diff --git a/editor/e2e/availability-backfill.spec.ts b/editor/e2e/availability-backfill.spec.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath } from "./helpers"; +import { pathExists, readJson, resetData, resolvePath, generateReport } from "./helpers"; type AvailabilityFile = { availability: string; @@ -39,6 +39,7 @@ test("download backfills availability.json from metadata", async ({ page }) => { await seedVideo("seededPrivate1", { availability: "private" }); await seedVideo("seededPlain1", {}); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); // Sync pages the flat-playlist (fake emits fake0000000{1..5}) and downloads // each via the managed per-URL path. After it finishes, runYtdlp calls @@ -92,6 +93,7 @@ test("backfill skips videos that already have an availability sidecar", async ({ }), ); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("backfill", { diff --git a/editor/e2e/availability.spec.ts b/editor/e2e/availability.spec.ts @@ -5,8 +5,7 @@ import { readJson, resetData, resolvePath, - writeSite, -} from "./helpers"; + writeSite, generateReport } from "./helpers"; const CHANNEL = "availability-test"; const FIXTURE = "availability-baseline"; @@ -39,6 +38,7 @@ test("resume checks classify each fixture video into its expected bucket", async page, }) => { await resetData(FIXTURE); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Resume checks" }).click(); const log = page.getByLabel("Resume availability checks output"); @@ -61,6 +61,7 @@ test("resume checks classify each fixture video into its expected bucket", async test("resume is idempotent: a second run skips all videos", async ({ page }) => { await resetData(FIXTURE); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Resume checks" }).click(); const log = page.getByLabel("Resume availability checks output"); @@ -76,6 +77,7 @@ test("resume log breaks down already-checked vs unchecked counts", async ({ page, }) => { await resetData(FIXTURE); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Resume checks" }).click(); const log = page.getByLabel("Resume availability checks output"); @@ -97,6 +99,7 @@ test("recheck non-deleted skips deleted but advances other checkedAt", async ({ page, }) => { await resetData(FIXTURE); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Resume checks" }).click(); await expect( @@ -124,6 +127,7 @@ test("recheck non-deleted skips deleted but advances other checkedAt", async ({ test("recheck all re-probes every video including deleted", async ({ page }) => { await resetData(FIXTURE); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Resume checks" }).click(); await expect( @@ -154,6 +158,7 @@ test("build index marks deleted videos in the export summaries", async ({ page, }) => { await resetData(FIXTURE); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Resume checks" }).click(); await expect( @@ -197,6 +202,7 @@ test("build index marks deleted videos in the export summaries", async ({ // schema changes break this test rather than silently breaking the search UI. test("availability.json shape matches the type contract", async ({ page }) => { await resetData(FIXTURE); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Resume checks" }).click(); await expect( diff --git a/editor/e2e/bookmarks.spec.ts b/editor/e2e/bookmarks.spec.ts @@ -14,7 +14,7 @@ import { mkdir, rename, rm, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath } from "./helpers"; +import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; const CHANNEL = "test-transcribe"; const ROOT = `test-transcripts/channels/${CHANNEL}`; @@ -23,6 +23,7 @@ const ROOT = `test-transcripts/channels/${CHANNEL}`; // whisper-all job from the jobs table (newest bookmarkable row). Ends on /jobs, // where the compact bookmarks menu is now shown. async function transcribeAndBookmark(page: import("@playwright/test").Page) { + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( @@ -148,6 +149,7 @@ test("management page: Run again retries whatever is partial now, then delete it ); // Original run: resume the partial download (vidA). + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page .getByLabel("retry resume partial downloads bucket") @@ -181,6 +183,7 @@ test("management page: Run again retries whatever is partial now, then delete it resolvePath(`${ROOT}/data/vidB/audio.m4a.part`), ); await rm(resolvePath(`${ROOT}/snapshot.json`), { force: true }); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); // regenerates snapshot when absent await expect( page.getByRole("heading", { name: /Partial downloads \(1\)/ }), @@ -306,6 +309,7 @@ test("management page: an empty re-derived bucket reads as a neutral notice, not resolvePath(`${ROOT}/data/vidA/audio.m4a.part`), ); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page .getByLabel("retry resume partial downloads bucket") @@ -333,6 +337,7 @@ test("management page: an empty re-derived bucket reads as a neutral notice, not // vidA is fully downloaded now, so the partial-downloads bucket is empty. // Refresh the snapshot to reflect that. await rm(resolvePath(`${ROOT}/snapshot.json`), { force: true }); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await expect( page.getByRole("heading", { name: /Partial downloads/ }), diff --git a/editor/e2e/bulk-actions.spec.ts b/editor/e2e/bulk-actions.spec.ts @@ -1,6 +1,6 @@ import { readFile, rename, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath } from "./helpers"; +import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; // The channel video list's checkbox bulk bar must queue like every other batch // feature: ONE managed batch job (whisper-bucket / retry-bucket), not one @@ -41,6 +41,7 @@ test("bulk transcribe submits a single batch job on the transcription queue", as page, }) => { await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await selectAllAndOpenBar(page); @@ -67,6 +68,7 @@ test("bulk transcribe submits a single batch job on the transcription queue", as test("bulk transcribe honors a custom queue", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await selectAllAndOpenBar(page); @@ -96,6 +98,7 @@ test("bulk retry download submits a single retry-bucket job on the platform queu page, }) => { await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await selectAllAndOpenBar(page); @@ -129,6 +132,7 @@ test("Select failed + Delete directories removes the dirs and queues no job", as // Mark vidA as a failed download. await writeFile(resolvePath(failedTranscriptionsFile), "vidA\n"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); // The quick-select helper grabs exactly the failed video. @@ -170,6 +174,7 @@ test("Clear failed markers prunes both failure files and clears the glyphs", asy await writeFile(resolvePath(failedTranscriptionsFile), "vidA\n"); await writeFile(resolvePath(failedTranscodingsFile), "vidB\n"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); // Two failure glyphs are shown before clearing. @@ -208,6 +213,7 @@ test("Select wrong-format + Remove wrong-format audio removes off-target files a resolvePath(`${dataDir}/vidA/audio.webm`), ); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); // The quick-select helper grabs exactly the wrong-format video. @@ -241,6 +247,7 @@ test("Remove audio files deletes finalized audio but keeps .part partials, queue // vidB has a finalized audio.m4a plus an in-progress .part download. await writeFile(resolvePath(`${dataDir}/vidB/audio.m4a.part`), "partial\n"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.getByLabel("select vidB").check(); diff --git a/editor/e2e/cancel.spec.ts b/editor/e2e/cancel.spec.ts @@ -1,8 +1,9 @@ import { test, expect } from "@playwright/test"; -import { pathExists, resetData } from "./helpers"; +import { pathExists, resetData, generateReport } from "./helpers"; test("cancels a running pipeline job from the channel page", async ({ page }) => { await resetData("slow-pipeline-channel"); + await generateReport(page, "slow-channel"); await page.goto("/channels/slow-channel"); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { @@ -26,6 +27,7 @@ test("cancels a running pipeline job from the channel page", async ({ page }) => test("cancels a running job from the jobs list", async ({ page }) => { await resetData("slow-pipeline-channel"); + await generateReport(page, "slow-channel"); await page.goto("/channels/slow-channel"); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { diff --git a/editor/e2e/channel-embedded-video.spec.ts b/editor/e2e/channel-embedded-video.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { resetData } from "./helpers"; +import { resetData, generateReport } from "./helpers"; // The embedded single-video view on the channel page is collapsed by default and // driven by the ?video= selection: clicking a video expands it and scrolls to it, @@ -9,6 +9,7 @@ test("embedded video view is collapsed by default and expands on selection", asy page, }) => { await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); // No video selected: the thin-strip expand button is shown, the panel is not. @@ -52,6 +53,7 @@ test("embedded video view auto-collapses when the selection is cleared", async ( await expect(page.getByLabel("files for vidB")).toBeVisible(); // Clearing the selection auto-collapses. + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await expect(page.getByLabel("expand video", { exact: true })).toBeVisible(); await expect(page.getByLabel("collapse video", { exact: true })).toHaveCount(0); @@ -62,6 +64,7 @@ test("on mobile the panel stacks below the list without a collapse strip", async }) => { await page.setViewportSize({ width: 600, height: 900 }); await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); // The desktop-only collapse affordances are hidden; the placeholder shows. diff --git a/editor/e2e/channel-rename.spec.ts b/editor/e2e/channel-rename.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { readJson, resetData, writeSite } from "./helpers"; +import { readJson, resetData, writeSite, generateReport } from "./helpers"; // Renaming a channel's slug lives in the Danger zone and requires typing the // current slug to confirm (mirroring delete). On success it migrates slug-keyed @@ -15,6 +15,7 @@ test("rename requires the exact current slug and then moves the channel", async // A site that lists the channel — its membership must follow the rename. await writeSite("mysite", { channels: [{ slug: OLD }] }); + await generateReport(page, OLD); await page.goto(`/channels/${OLD}`); const newSlug = page.getByLabel("new slug"); diff --git a/editor/e2e/channel-site-membership.spec.ts b/editor/e2e/channel-site-membership.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from "@playwright/test"; -import { readJson, resetData, writeSite } from "./helpers"; +import { readJson, resetData, writeSite, generateReport } from "./helpers"; // The channel form's "Sites" membership section: per-site checkbox + group // dropdown (+ inline "+ New group…") on both the create and edit screens. @@ -116,6 +116,7 @@ test("edit: switching news → (default) keeps order, sibling untouched", async page, }) => { await seed(); + await generateReport(page, "slow-b"); await page.goto("/channels/slow-b"); await expect(page.getByLabel("Include on Alpha")).toBeChecked(); await expect(page.getByLabel("Group for Alpha")).toHaveValue("news"); @@ -136,6 +137,7 @@ test("edit: unchecking removes the membership, sibling intact", async ({ page, }) => { await seed(); + await generateReport(page, "slow-b"); await page.goto("/channels/slow-b"); await expect(page.getByLabel("Include on Alpha")).toBeChecked(); @@ -149,6 +151,7 @@ test("edit: unchecking removes the membership, sibling intact", async ({ test("edit: + New group on another site", async ({ page }) => { await seed(); + await generateReport(page, "slow-a"); await page.goto("/channels/slow-a"); await expect(page.getByLabel("Include on Alpha")).toBeChecked(); await expect(page.getByLabel("Include on Beta")).not.toBeChecked(); @@ -176,6 +179,7 @@ test("zero sites: informational note, create still succeeds", async ({ page, }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await expect(page.getByText(/no sites configured/i)).toBeVisible(); diff --git a/editor/e2e/channels-actions.spec.ts b/editor/e2e/channels-actions.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { resetData } from "./helpers"; +import { resetData, generateReport } from "./helpers"; // The channel index page lets you launch jobs without drilling into the // detail page. Today: Sync + Check availability. Both default to the platform @@ -43,6 +43,7 @@ test("Transcribe missing defaults to the system-wide 'transcription' queue", asy page, }) => { await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( diff --git a/editor/e2e/channels-counts.spec.ts b/editor/e2e/channels-counts.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { resetData } from "./helpers"; +import { resetData, generateReport } from "./helpers"; test("shows zero/em-dash when no data exists", async ({ page }) => { await resetData("one-youtube-channel"); @@ -41,6 +41,7 @@ test("counts update after store-playlist and download", async ({ page }) => { page.getByLabel("downloads count for test-pipeline"), ).toHaveText("0"); + await generateReport(page, "test-pipeline"); await page.goto("/channels/test-pipeline"); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( @@ -56,6 +57,7 @@ test("counts update after store-playlist and download", async ({ page }) => { page.getByLabel("downloads count for test-pipeline"), ).toHaveText("0"); + await generateReport(page, "test-pipeline"); await page.goto("/channels/test-pipeline"); await page.getByRole("button", { name: "Download from playlist" }).click(); await expect( diff --git a/editor/e2e/channels.spec.ts b/editor/e2e/channels.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData } from "./helpers"; +import { pathExists, readJson, resetData, generateReport } from "./helpers"; test("shows empty state with no channels", async ({ page }) => { await resetData("empty"); @@ -9,6 +9,7 @@ test("shows empty state with no channels", async ({ page }) => { test("creates a youtube channel", async ({ page }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page.getByLabel(/^name/i).fill("New Test Channel"); await page.getByLabel(/^slug/i).fill("new-test"); @@ -29,6 +30,7 @@ test("creates a youtube channel", async ({ page }) => { test("auto-detects twitch platform from URL", async ({ page }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page.getByLabel(/^name/i).fill("Twitch Test Channel"); await page.getByLabel(/^slug/i).fill("twitch-test"); @@ -43,6 +45,7 @@ test("auto-detects twitch platform from URL", async ({ page }) => { test("auto-derives slug from name when blank", async ({ page }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page.getByLabel(/^name/i).fill("Auto Slug Channel"); await page.getByRole("button", { name: /create channel/i }).click(); @@ -51,6 +54,7 @@ test("auto-derives slug from name when blank", async ({ page }) => { test("rejects duplicate slug", async ({ page }) => { await resetData("one-youtube-channel"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page.getByLabel(/^name/i).fill("Test YouTube Channel"); await page.getByLabel(/^slug/i).fill("test-youtube"); @@ -60,6 +64,7 @@ test("rejects duplicate slug", async ({ page }) => { test("edits an existing channel", async ({ page }) => { await resetData("one-youtube-channel"); + await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); await page.getByLabel(/^name/i).fill("Renamed Channel"); await page.getByRole("button", { name: /save changes/i }).click(); @@ -72,6 +77,7 @@ test("edits an existing channel", async ({ page }) => { test("requires typed-confirmation to delete", async ({ page }) => { await resetData("one-youtube-channel"); + await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); // getByPlaceholder would be ambiguous: the rename form's confirm input @@ -91,6 +97,7 @@ test("hides transcribe form fields for youtube channels (cosmetic)", async ({ page, }) => { await resetData("one-transcribe-channel"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await expect( page.getByLabel(/audio format \(transcribe only\)/i), diff --git a/editor/e2e/chough.spec.ts b/editor/e2e/chough.spec.ts @@ -1,6 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, writeSettings } from "./helpers"; +import { pathExists, resetData, resolvePath, writeSettings, generateReport } from "./helpers"; const CHANNEL = "test-transcribe"; const DATA = `test-transcripts/channels/${CHANNEL}/data`; @@ -29,6 +29,7 @@ test("chough app transcribes audio and writes a transcript.json", async ({ }) => { await resetData("one-transcribe-channel-with-audio"); await selectChough(); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( @@ -56,6 +57,7 @@ test("chough output normalizes to chough-tagged cues", async ({ page }) => { // Give vidA metadata so the normalize pass produces transcript.cues.json. await writeFile(resolvePath(`${DATA}/vidA/metadata.info.json`), META); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( diff --git a/editor/e2e/cookies-mode.spec.ts b/editor/e2e/cookies-mode.spec.ts @@ -7,8 +7,7 @@ import { readJson, resetData, resolvePath, - writeSettings, -} from "./helpers"; + writeSettings, generateReport } from "./helpers"; // Configurable cookies-from-browser: global + per-channel value, and a cookie // MODE (always / when-required / defer). The fake yt-dlp's `cookiegated` @@ -98,6 +97,7 @@ test("channel form: overrides persist and clear back to inherit", async ({ page, }) => { await resetData(FIXTURE); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.locator("summary").filter({ hasText: "Advanced" }).click(); await page.getByLabel(/^cookies from browser/i).fill("chrome:Profile 1"); @@ -148,6 +148,7 @@ test("always mode: every yt-dlp invocation carries the cookie value", async ({ cookiesFromBrowser: COOKIES, cookieMode: "always", }); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); @@ -191,6 +192,7 @@ test("when-required: failed prefetch is retried with cookies -> ok-with-cookies" cookiesFromBrowser: COOKIES, // cookieMode omitted -> back-compat default "when-required" }); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); @@ -246,6 +248,7 @@ test("defer: excluded from batches, surfaced in Needs cookies, downloads via the // Run 1: the gated video fails cookie-less (no prefetch retry, no auth // retry — defer never uses cookies in normal runs). + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); @@ -321,6 +324,7 @@ test("members_only videos surface in Needs cookies regardless of mode", async ({ }), ); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); const bucket = page.getByLabel("retry needs cookies bucket"); await expect(bucket).toBeVisible(); diff --git a/editor/e2e/digest.spec.ts b/editor/e2e/digest.spec.ts @@ -20,8 +20,7 @@ import { resolvePath, writeChannelConfig, writeDigestVideo, - writeSettings, -} from "./helpers"; + writeSettings, generateReport } from "./helpers"; // The digest lane, end to end through the real job path. // @@ -76,6 +75,7 @@ function digestSettings(over: Record<string, unknown> = {}) { const BATCH_DONE = "Digest batch:"; async function runDigest(page: import("@playwright/test").Page, slug: string) { + await generateReport(page, slug); await page.goto(`/channels/${slug}`); await page.getByRole("button", { name: "Digest channel" }).click(); await expect(page.getByLabel("Digest channel output")).toContainText( @@ -425,6 +425,7 @@ test("the two lanes land on different queue keys", async ({ page }) => { await writeChannelConfig(CHANNEL); await writeDigestVideo({ channelSlug: CHANNEL, videoId: VIDEO }); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Digest channel" }).click(); await expect(page.getByLabel("Digest channel output")).toContainText( @@ -435,6 +436,7 @@ test("the two lanes land on different queue keys", async ({ page }) => { // Switch to the metered lane. The control follows the lane's own default key — // if it did not, both lanes would share one key and the registry's // concurrency-1-per-key rule would serialize a GPU lane behind a network one. + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByLabel("lane for Digest channel").selectOption("remote"); await expect(page.getByLabel("queue for Digest channel")).toHaveValue( @@ -477,6 +479,7 @@ test("the metered lane is refused while it is disabled in settings", async ({ await writeChannelConfig(CHANNEL); await writeDigestVideo({ channelSlug: CHANNEL, videoId: VIDEO }); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); const lane = page.getByLabel("lane for Digest channel"); // Nothing can spend money until it is explicitly turned on, so the option is diff --git a/editor/e2e/disk-space.spec.ts b/editor/e2e/disk-space.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { pathExists, resetData, writeSettings } from "./helpers"; +import { pathExists, resetData, writeSettings, generateReport } from "./helpers"; // The low-disk gate (common/lib/diskSpace.ts) is driven by the // minFreeDiskGB setting. e2e can't force the filesystem to fill up, so these @@ -19,6 +19,7 @@ test("prevents a download from starting when free disk is below the floor", asyn await resetData("test-pipeline"); await writeSettings({ minFreeDiskGB: HUGE_FLOOR_GB }); + await generateReport(page, "test-pipeline"); await page.goto("/channels/test-pipeline"); // The preflight runs before the job is queued (and before the playlist is // even read), so the action returns an error instead of starting yt-dlp. @@ -42,6 +43,7 @@ test("lets downloads proceed when the gate is disabled (0)", async ({ await resetData("test-pipeline"); await writeSettings({ minFreeDiskGB: 0 }); + await generateReport(page, "test-pipeline"); await page.goto("/channels/test-pipeline"); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( diff --git a/editor/e2e/download-format-guard.spec.ts b/editor/e2e/download-format-guard.spec.ts @@ -18,8 +18,7 @@ import { pathExists, readJson, resetData, - resolvePath, -} from "./helpers"; + resolvePath, generateReport } from "./helpers"; type DownloadOutcome = { status: string; @@ -62,6 +61,7 @@ async function makeChannel( } async function downloadAll(page: import("@playwright/test").Page, slug: string) { + await generateReport(page, slug); await page.goto(`/channels/${slug}`); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); diff --git a/editor/e2e/download-part-files.spec.ts b/editor/e2e/download-part-files.spec.ts @@ -1,6 +1,6 @@ import { rename } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { resetData, resolvePath } from "./helpers"; +import { resetData, resolvePath, generateReport } from "./helpers"; // `.part` files are yt-dlp's in-progress download placeholders. They should // NOT count as a complete audio download — otherwise the prefilter marks the @@ -21,6 +21,7 @@ test("audio.<ext>.part doesn't count as a complete audio file", async ({ ), ); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); // The "Downloaded but not transcribed" list shows videos with audio on diff --git a/editor/e2e/exclude-from-counts.spec.ts b/editor/e2e/exclude-from-counts.spec.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { test, expect } from "@playwright/test"; -import { resetData, resolvePath } from "./helpers"; +import { resetData, resolvePath, generateReport } from "./helpers"; const CHANNEL = "test-transcribe"; const FIXTURE = "one-transcribe-channel-with-audio"; @@ -38,6 +38,7 @@ test("members-only video is hidden from the Missing metadata bucket", async ({ await resetData(FIXTURE); await writeAvailability("vidA", "members_only"); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await expect( @@ -60,6 +61,7 @@ test("members-only video is hidden from the Failed transcriptions list", async ( "vidA\nvidB\n", ); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await expect( @@ -81,6 +83,7 @@ test("stage badge counts exclude members-only videos", async ({ page }) => { "vidA\nvidB\n", ); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); // Diagnostics pending = noMetadata (2: vidB, vidC) + missingFromArchive + duplicateDirs. diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts @@ -136,7 +136,13 @@ export async function generateReport( if (await pathExists(snapshotRel)) return; await page.goto(`/channels/${slug}`); const button = page.getByRole("button", { name: /refresh report/i }); - await button.waitFor({ state: "visible", timeout: 15_000 }); + // A social (posts) channel has no report and no button — it short-circuits + // the whole video pipeline view. Nothing to generate, so say so by leaving. + const offered = await button + .waitFor({ state: "visible", timeout: 10_000 }) + .then(() => true) + .catch(() => false); + if (!offered) return; // The click is RETRIED, deliberately. A click that lands before React has // hydrated fires nothing at all — no request, no job, no error — which is the diff --git a/editor/e2e/import-video.spec.ts b/editor/e2e/import-video.spec.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath } from "./helpers"; +import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; // The Playlist stage's "Import single video" panel fetches one off-playlist // video by URL into the channel using downloadOneManaged (the same primitive @@ -10,6 +10,7 @@ test("import single video by URL fetches it and appends to the archive", async ( }) => { test.setTimeout(60_000); await resetData("test-pipeline"); + await generateReport(page, "test-pipeline"); await page.goto("/channels/test-pipeline"); const importBtn = page.getByRole("button", { name: "Import video" }); @@ -52,6 +53,7 @@ test("import single video by URL fetches it and appends to the archive", async ( test("import rejects a non-URL input", async ({ page }) => { await resetData("test-pipeline"); + await generateReport(page, "test-pipeline"); await page.goto("/channels/test-pipeline"); await page.getByLabel("Video URL to import").fill("not a url"); diff --git a/editor/e2e/job-stream-cancel.spec.ts b/editor/e2e/job-stream-cancel.spec.ts @@ -6,7 +6,7 @@ // guards against the regression. import { test, expect } from "@playwright/test"; -import { resetData } from "./helpers"; +import { resetData, generateReport } from "./helpers"; import { baseUrl } from "./baseUrl"; async function readUncaughtCount(): Promise<{ @@ -35,6 +35,7 @@ test("disconnecting from a running job stream does not crash with 'Controller is // Start the slow sync — fake-ytdlp sleeps 30s before producing output, so // the producer is alive and pushing into the stream for the duration. + await generateReport(page, "slow-channel"); await page.goto("/channels/slow-channel"); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { diff --git a/editor/e2e/jobs-active-order.spec.ts b/editor/e2e/jobs-active-order.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { resetData } from "./helpers"; +import { resetData, generateReport } from "./helpers"; async function setQueueViaCustom( page: import("@playwright/test").Page, @@ -22,6 +22,7 @@ test("active jobs page sorts channels with running jobs before queued-only chann }) => { await resetData("two-slow-channels"); + await generateReport(page, "slow-a"); await page.goto("/channels/slow-a"); await setQueueViaCustom(page, "Sync", "qShared"); await page.getByRole("button", { name: "Sync" }).click(); @@ -29,6 +30,7 @@ test("active jobs page sorts channels with running jobs before queued-only chann timeout: 15_000, }); + await generateReport(page, "slow-b"); await page.goto("/channels/slow-b"); await page .getByLabel("queue for Sync", { exact: true }) diff --git a/editor/e2e/jobs-batch-tasks-drain.spec.ts b/editor/e2e/jobs-batch-tasks-drain.spec.ts @@ -6,7 +6,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath } from "./helpers"; +import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; import { baseUrl } from "./baseUrl"; async function invalidateCache() { @@ -54,6 +54,7 @@ test("active jobs shows a per-operation progress bar that advances", async ({ await makeTranscribeChannel("tasks-one", "Tasks One", ids); await invalidateCache(); + await generateReport(page, "tasks-one"); await page.goto("/channels/tasks-one"); await page.getByRole("button", { name: "Transcribe missing" }).click(); await page.goto("/jobs/active"); @@ -101,6 +102,7 @@ test("active jobs shows an estimated time remaining once a task completes", asyn await makeTranscribeChannel("eta-one", "Eta One", ids); await invalidateCache(); + await generateReport(page, "eta-one"); await page.goto("/channels/eta-one"); await page.getByRole("button", { name: "Transcribe missing" }).click(); await page.goto("/jobs/active"); @@ -135,8 +137,10 @@ test("draining a batch finishes in-flight work, skips the rest, and releases the await invalidateCache(); // Both default to the shared transcription queue, so B waits behind A. + await generateReport(page, "drain-a"); await page.goto("/channels/drain-a"); await page.getByRole("button", { name: "Transcribe missing" }).click(); + await generateReport(page, "drain-b"); await page.goto("/channels/drain-b"); await page.getByRole("button", { name: "Transcribe missing" }).click(); @@ -180,8 +184,10 @@ test("'Drain all' drains the running batch and cancels the queued one", async ({ await makeTranscribeChannel("drainall-b", "DrainAll B", bIds); await invalidateCache(); + await generateReport(page, "drainall-a"); await page.goto("/channels/drainall-a"); await page.getByRole("button", { name: "Transcribe missing" }).click(); + await generateReport(page, "drainall-b"); await page.goto("/channels/drainall-b"); await page.getByRole("button", { name: "Transcribe missing" }).click(); @@ -232,8 +238,10 @@ test("a queued job can be cancelled directly from its row without opening the lo await makeTranscribeChannel("qcancel-b", "QCancel B", bIds); await invalidateCache(); + await generateReport(page, "qcancel-a"); await page.goto("/channels/qcancel-a"); await page.getByRole("button", { name: "Transcribe missing" }).click(); + await generateReport(page, "qcancel-b"); await page.goto("/channels/qcancel-b"); await page.getByRole("button", { name: "Transcribe missing" }).click(); @@ -270,6 +278,7 @@ test("hard Cancel during a drain ends the job cancelled without stream errors", () => {}, ); + await generateReport(page, "cancel-drain"); await page.goto("/channels/cancel-drain"); await page.getByRole("button", { name: "Transcribe missing" }).click(); diff --git a/editor/e2e/jobs-channel.spec.ts b/editor/e2e/jobs-channel.spec.ts @@ -1,10 +1,11 @@ import { test, expect } from "@playwright/test"; -import { resetData } from "./helpers"; +import { resetData, generateReport } from "./helpers"; test("shows the originating channel as a link on the jobs list and detail", async ({ page, }) => { await resetData("test-pipeline"); + await generateReport(page, "test-pipeline"); await page.goto("/channels/test-pipeline"); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( @@ -26,6 +27,7 @@ test("shows the originating channel as a link on the jobs list and detail", asyn test("auto-refreshes the jobs list when a job is running", async ({ page }) => { await resetData("slow-pipeline-channel"); + await generateReport(page, "slow-channel"); await page.goto("/channels/slow-channel"); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { diff --git a/editor/e2e/jobs-reorder.spec.ts b/editor/e2e/jobs-reorder.spec.ts @@ -5,7 +5,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { resetData, resolvePath } from "./helpers"; +import { resetData, resolvePath, generateReport } from "./helpers"; import { baseUrl } from "./baseUrl"; async function invalidateCache() { @@ -49,6 +49,7 @@ async function syncToQueue( slug: string, queue: string, ) { + await generateReport(page, slug); await page.goto(`/channels/${slug}`); await setQueueViaCustom(page, "Sync", queue); await page.getByRole("button", { name: "Sync" }).click(); diff --git a/editor/e2e/maybe-missing.spec.ts b/editor/e2e/maybe-missing.spec.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { readJson, resetData, resolvePath } from "./helpers"; +import { readJson, resetData, resolvePath, generateReport } from "./helpers"; const CHANNEL = "availability-test"; const FIXTURE = "availability-baseline"; @@ -105,6 +105,7 @@ test("quick check flags videos missing from the fresh playlist", async ({ // Fresh playlist omits viddeleted1 and vidunlisted1. await setFreshPlaylist(BASELINE_IDS.filter((id) => id !== "viddeleted1")); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page .getByRole("button", { name: "Quick check (flat playlist)" }) @@ -144,6 +145,7 @@ test("full-check skips hard-excluded but re-checks unlisted/needs_auth", async ( // Fresh playlist omits the three we want flagged. await setFreshPlaylist(["vidpublic1", "vidpublic2", "vidprivate1"]); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page .getByRole("button", { name: "Quick check (flat playlist)" }) @@ -175,6 +177,7 @@ test("full-check resolves deleted vs unlisted and records history", async ({ await seedVideoDir("vidunlisted1"); await setFreshPlaylist(["vidpublic1", "vidpublic2"]); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page .getByRole("button", { name: "Quick check (flat playlist)" }) diff --git a/editor/e2e/new-channel-onboarding.spec.ts b/editor/e2e/new-channel-onboarding.spec.ts @@ -15,12 +15,13 @@ // select). Only the textbox has the exact accessible name "URL". import { test, expect } from "@playwright/test"; -import { resetData, readJson, pathExists } from "./helpers"; +import { resetData, readJson, pathExists, generateReport } from "./helpers"; test("derives platform, handling, slug and queue from a YouTube URL offline", async ({ page, }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page @@ -41,6 +42,7 @@ test("an unknown host is offered its own per-domain queue, marked (new)", async page, }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page.getByLabel("URL", { exact: true }).fill("https://vimeo.com/someuser"); @@ -58,6 +60,7 @@ test("an unknown host is offered its own per-domain queue, marked (new)", async test("Fetch details fills the name from the yt-dlp probe", async ({ page }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page @@ -77,6 +80,7 @@ test("creating with 'Fetch playlist now' stores the playlist", async ({ page, }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page @@ -108,6 +112,7 @@ test("'Add to top of auto-queue' prepends a channel leaf and enables the runner" page, }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page diff --git a/editor/e2e/no-subs-fallback.spec.ts b/editor/e2e/no-subs-fallback.spec.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, writeSettings } from "./helpers"; +import { pathExists, readJson, resetData, writeSettings, generateReport } from "./helpers"; const CHANNEL = "test-livechat"; const ROOT = `test-transcripts/channels/${CHANNEL}`; @@ -27,6 +27,7 @@ test("default off: live_chat-only video downloads audio but defers whisper", asy }) => { test.setTimeout(120_000); await resetData("livechat-fallback-channel"); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); @@ -79,6 +80,7 @@ test("default off: no-captions video downloads audio but defers whisper", async }) => { test.setTimeout(120_000); await resetData("livechat-fallback-channel"); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); @@ -107,6 +109,7 @@ test("video with real auto-subs does NOT trigger fallback (regression)", async ( }) => { test.setTimeout(120_000); await resetData("livechat-fallback-channel"); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); @@ -129,6 +132,7 @@ test("inlineTranscribeOnFallback=true runs whisper inline after the fallback", a ...(await defaultSettings()), inlineTranscribeOnFallback: true, }); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); diff --git a/editor/e2e/parakeet-partial.spec.ts b/editor/e2e/parakeet-partial.spec.ts @@ -5,7 +5,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath, writeSettings } from "./helpers"; +import { pathExists, readJson, resetData, resolvePath, writeSettings, generateReport } from "./helpers"; async function makeTranscribeChannel(slug: string, ids: string[]) { const root = resolvePath(`test-transcripts/channels/${slug}`); @@ -43,6 +43,7 @@ test("Stop & keep progress pauses a parakeet run, caches the window, and resumes const cachedWindow = `${dir}/.audio.mp3.parakeet/win-0000.json`; const transcript = `${dir}/transcript.json`; + await generateReport(page, "partial-chan"); await page.goto("/channels/partial-chan"); await page.getByRole("button", { name: "Transcribe missing" }).click(); @@ -65,6 +66,7 @@ test("Stop & keep progress pauses a parakeet run, caches the window, and resumes // Re-enable the worker (Stop disabled it), then re-running resumes from the // cached window and completes the transcript. await gpu.getByRole("button", { name: /enable GPU parakeet/i }).click(); + await generateReport(page, "partial-chan"); await page.goto("/channels/partial-chan"); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect.poll(() => pathExists(transcript), { timeout: 30_000 }).toBe(true); @@ -88,6 +90,7 @@ test("Pause Transcriptions stops a parakeet run after the current segment, cache const cachedWindow = `${dir}/.audio.mp3.parakeet/win-0000.json`; const transcript = `${dir}/transcript.json`; + await generateReport(page, "gpause-chan"); await page.goto("/channels/gpause-chan"); await page.getByRole("button", { name: "Transcribe missing" }).click(); @@ -109,6 +112,7 @@ test("Pause Transcriptions stops a parakeet run after the current segment, cache // Resume restores the worker to enabled; re-running resumes from the cached // window and completes the transcript. await page.getByRole("button", { name: "Resume Transcriptions" }).click(); + await generateReport(page, "gpause-chan"); await page.goto("/channels/gpause-chan"); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect.poll(() => pathExists(transcript), { timeout: 30_000 }).toBe(true); @@ -156,6 +160,7 @@ test("Stop & keep progress on a force-killed parakeet run is NOT recorded as a f const transcript = `${dir}/transcript.json`; const failedFile = "test-transcripts/channels/hangterm-chan/failed-transcriptions"; + await generateReport(page, "hangterm-chan"); await page.goto("/channels/hangterm-chan"); await page.getByRole("button", { name: "Transcribe missing" }).click(); @@ -182,6 +187,7 @@ test("Stop & keep progress on a force-killed parakeet run is NOT recorded as a f // it would sit in failed-transcriptions and be skipped forever, so the // transcript would never appear. await enable.click(); + await generateReport(page, "hangterm-chan"); await page.goto("/channels/hangterm-chan"); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect.poll(() => pathExists(transcript), { timeout: 30_000 }).toBe(true); diff --git a/editor/e2e/parakeet.spec.ts b/editor/e2e/parakeet.spec.ts @@ -1,6 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, writeSettings } from "./helpers"; +import { pathExists, resetData, resolvePath, writeSettings, generateReport } from "./helpers"; const CHANNEL = "test-transcribe"; const DATA = `test-transcripts/channels/${CHANNEL}/data`; @@ -29,6 +29,7 @@ test("parakeet app transcribes audio and writes a transcript.json", async ({ }) => { await resetData("one-transcribe-channel-with-audio"); await selectParakeet(); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( @@ -57,6 +58,7 @@ test("parakeet output normalizes to chough-tagged cues", async ({ page }) => { // Give vidA metadata so the normalize pass produces transcript.cues.json. await writeFile(resolvePath(`${DATA}/vidA/metadata.info.json`), META); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( diff --git a/editor/e2e/partial-downloads-bucket.spec.ts b/editor/e2e/partial-downloads-bucket.spec.ts @@ -5,7 +5,7 @@ import { rename, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath } from "./helpers"; +import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; const CHANNEL = "test-transcribe"; const CHANNEL_ROOT = `test-transcripts/channels/${CHANNEL}`; @@ -20,6 +20,7 @@ test("partial download surfaces in a bucket on the channel page", async ({ resolvePath(`${CHANNEL_ROOT}/data/vidA/audio.m4a.part`), ); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await expect( @@ -45,6 +46,7 @@ test("resume action re-downloads the partial video", async ({ page }) => { "https://www.youtube.com/watch?v=vidA\n", ); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page @@ -88,6 +90,7 @@ test("resume retries a partial that carries an audio-check snapshot", async ({ "https://www.youtube.com/watch?v=vidA\n", ); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page diff --git a/editor/e2e/pipeline.spec.ts b/editor/e2e/pipeline.spec.ts @@ -1,9 +1,10 @@ import { rm, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath } from "./helpers"; +import { pathExists, readJson, resetData, resolvePath, generateReport } from "./helpers"; test("store playlist writes a playlist file with 5 URLs", async ({ page }) => { await resetData("test-pipeline"); + await generateReport(page, "test-pipeline"); await page.goto("/channels/test-pipeline"); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( @@ -20,6 +21,7 @@ test("download from playlist fetches all 5 URLs and writes the archive", async ( }) => { test.setTimeout(120_000); await resetData("test-pipeline"); + await generateReport(page, "test-pipeline"); await page.goto("/channels/test-pipeline"); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( @@ -58,6 +60,7 @@ test("download from playlist fetches all 5 URLs and writes the archive", async ( test("re-running download skips already-archived entries", async ({ page }) => { test.setTimeout(120_000); await resetData("test-pipeline"); + await generateReport(page, "test-pipeline"); await page.goto("/channels/test-pipeline"); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( @@ -83,6 +86,7 @@ test("sync enumerates the channel, downloads new entries, writes lastSyncedAt", }) => { test.setTimeout(120_000); await resetData("test-pipeline"); + await generateReport(page, "test-pipeline"); await page.goto("/channels/test-pipeline"); await page.getByRole("button", { name: "Sync" }).click(); // Sync now pages the flat-playlist (fake emits 5 entries) and downloads the @@ -128,6 +132,7 @@ test("download videos skips a transcribed video even when its audio was cleaned ].join("\n") + "\n", ); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.getByRole("button", { name: "Download videos" }).click(); @@ -161,6 +166,7 @@ test("channel list sync button runs the platform-queue sync", async ({ test("blocks pipeline buttons when channel has no url", async ({ page }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page.getByLabel(/^name/i).fill("No URL Channel"); await page.getByLabel(/^slug/i).fill("no-url"); diff --git a/editor/e2e/pre-clean-availability.spec.ts b/editor/e2e/pre-clean-availability.spec.ts @@ -7,8 +7,7 @@ import { readJson, resetData, resolvePath, - writeSettings, -} from "./helpers"; + writeSettings, generateReport } from "./helpers"; import { baseUrl } from "./baseUrl"; // The transcribed-audio sweep deletes source audio irreversibly, so it first @@ -91,6 +90,7 @@ async function seedAll(): Promise<void> { } async function runCleanAudio(page: import("@playwright/test").Page) { + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Cleanup stage summary" }).click(); const button = page.getByRole("button", { name: "Clean audio", exact: true }); diff --git a/editor/e2e/queues.spec.ts b/editor/e2e/queues.spec.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { resetData, resolvePath } from "./helpers"; +import { resetData, resolvePath, generateReport } from "./helpers"; async function setQueueViaCustom( page: import("@playwright/test").Page, @@ -20,6 +20,7 @@ async function setQueueViaCustom( test("default queue is platform:<platform>", async ({ page }) => { await resetData("slow-pipeline-channel"); + await generateReport(page, "slow-channel"); await page.goto("/channels/slow-channel"); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { @@ -53,6 +54,7 @@ test("Sync is refused while its platform is in a rate-limit cooldown", async ({ }), ); + await generateReport(page, "slow-channel"); await page.goto("/channels/slow-channel"); await page.getByRole("button", { name: "Sync" }).click(); // The refusal is a neutral notice (info), naming the platform + cooldown. @@ -72,6 +74,7 @@ test("unknown platform infers its queue from the URL domain", async ({ page, }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page.getByLabel(/^name/i).fill("Vimeo Channel"); await page.getByLabel(/^slug/i).fill("vimeo-test"); @@ -91,6 +94,7 @@ test("queues a second job in the same queue, runs sequentially", async ({ }) => { await resetData("two-slow-channels"); + await generateReport(page, "slow-a"); await page.goto("/channels/slow-a"); await setQueueViaCustom(page, "Sync", "qShared"); await page.getByRole("button", { name: "Sync" }).click(); @@ -98,6 +102,7 @@ test("queues a second job in the same queue, runs sequentially", async ({ timeout: 15_000, }); + await generateReport(page, "slow-b"); await page.goto("/channels/slow-b"); await page .getByLabel("queue for Sync", { exact: true }) @@ -131,6 +136,7 @@ test("queues a second job in the same queue, runs sequentially", async ({ test("runs jobs in different queues in parallel", async ({ page }) => { await resetData("two-slow-channels"); + await generateReport(page, "slow-a"); await page.goto("/channels/slow-a"); await setQueueViaCustom(page, "Sync", "qA"); await page.getByRole("button", { name: "Sync" }).click(); @@ -138,6 +144,7 @@ test("runs jobs in different queues in parallel", async ({ page }) => { timeout: 15_000, }); + await generateReport(page, "slow-b"); await page.goto("/channels/slow-b"); await setQueueViaCustom(page, "Sync", "qB"); await page.getByRole("button", { name: "Sync" }).click(); @@ -157,6 +164,7 @@ test("cancels a queued job without disturbing the one running ahead of it", asyn }) => { await resetData("two-slow-channels"); + await generateReport(page, "slow-a"); await page.goto("/channels/slow-a"); await setQueueViaCustom(page, "Sync", "qShared"); await page.getByRole("button", { name: "Sync" }).click(); @@ -164,6 +172,7 @@ test("cancels a queued job without disturbing the one running ahead of it", asyn timeout: 15_000, }); + await generateReport(page, "slow-b"); await page.goto("/channels/slow-b"); await page .getByLabel("queue for Sync", { exact: true }) @@ -193,6 +202,7 @@ test("empty queue runs immediately even when another job is queued in the same n await resetData("two-slow-channels"); // Park slow-a's Sync into qShared and start it. + await generateReport(page, "slow-a"); await page.goto("/channels/slow-a"); await setQueueViaCustom(page, "Sync", "qShared"); await page.getByRole("button", { name: "Sync" }).click(); @@ -202,6 +212,7 @@ test("empty queue runs immediately even when another job is queued in the same n // slow-b sets queue to immediate (the default empty option) and starts; // it should bypass qShared and run right away. + await generateReport(page, "slow-b"); await page.goto("/channels/slow-b"); await page .getByLabel("queue for Sync", { exact: true }) diff --git a/editor/e2e/reconcile.spec.ts b/editor/e2e/reconcile.spec.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath } from "./helpers"; +import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; // yt-dlp names Twitch VOD dirs `v<id>` (its extractor id) while the app keys // videos by the canonical URL id `<id>`, so the audio/metadata land in @@ -63,15 +63,16 @@ test("channel snapshot reconciles Twitch v<id> dirs into canonical <id> dirs", a await resetData(null); await seedChannel(); + // Deliberately NOT pre-generated: this test is about what generating does. + // The page renders normally with an empty report and a banner offering to + // build one — which is what runs reconcileVideoDirs. await page.goto(`/channels/${CHANNEL}`); await expect( page.getByRole("heading", { name: "Twitch Reconcile" }), ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("no-report-banner")).toBeVisible(); - // No snapshot.json yet, so the page offers to build one instead of building - // it behind your back. Generating runs reconcileVideoDirs first. - await expect(page.getByRole("heading", { name: "No report yet" })).toBeVisible(); - await page.getByRole("button", { name: /refresh report/i }).click(); + await generateReport(page, CHANNEL); // The reconcile is the observable effect; poll for it rather than racing the // button's own status text. diff --git a/editor/e2e/retry-bucket.spec.ts b/editor/e2e/retry-bucket.spec.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath } from "./helpers"; +import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; const CHANNEL = "availability-test"; const FIXTURE = "availability-baseline"; @@ -44,6 +44,7 @@ test("retry control renders for needs_auth and is absent on excluded buckets", a await writeAvailability("vidprivate1", "private"); await writeAvailability("vidmembers1", "members_only"); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); const needsAuth = page.getByLabel("retry needs auth bucket"); @@ -66,6 +67,7 @@ test("retry control renders for the error bucket", async ({ page }) => { // test only cares about UI wiring, not the cause of the error. await writeAvailability("vidneedsauth1", "error", { error: "transient" }); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); const errorBucket = page.getByLabel("retry error bucket"); @@ -87,6 +89,7 @@ test("clicking retry on needs_auth re-downloads the listed video", async ({ "https://www.youtube.com/watch?v=vidneedsauth1\n", ); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page diff --git a/editor/e2e/saved-videos.spec.ts b/editor/e2e/saved-videos.spec.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { readJson, resetData, resolvePath } from "./helpers"; +import { readJson, resetData, resolvePath, generateReport } from "./helpers"; import { baseUrl } from "./baseUrl"; // The transcribe fixture's channel slug. @@ -84,6 +84,7 @@ test("channel Cleanup stage shows the retention & persistence section", async ({ await resetData("one-transcribe-channel-with-audio"); await seedSavedVideo("vidA", 1024); + await generateReport(page, SLUG); await page.goto(`/channels/${SLUG}`); await page.getByRole("button", { name: "Cleanup stage summary" }).click(); const section = page.getByLabel("retention and persistence section"); diff --git a/editor/e2e/shard.spec.ts b/editor/e2e/shard.spec.ts @@ -1,6 +1,6 @@ import { rm, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath } from "./helpers"; +import { pathExists, readJson, resetData, resolvePath, generateReport } from "./helpers"; const AVAILABILITY_CHANNEL = "availability-test"; @@ -18,6 +18,7 @@ test.describe("Availability check sharding", () => { page, }) => { await resetData("availability-baseline"); + await generateReport(page, AVAILABILITY_CHANNEL); await page.goto(`/channels/${AVAILABILITY_CHANNEL}`); await page.getByLabel("shard availability total").first().fill("2"); @@ -65,6 +66,7 @@ test.describe("Availability check sharding", () => { page, }) => { await resetData("availability-baseline"); + await generateReport(page, AVAILABILITY_CHANNEL); await page.goto(`/channels/${AVAILABILITY_CHANNEL}`); await page.getByLabel("shard availability total").first().fill("2"); await page.getByLabel("shard availability index").first().fill("1"); @@ -82,6 +84,7 @@ test.describe("Availability check sharding", () => { test("Clear button removes the saved shard config", async ({ page }) => { await resetData("availability-baseline"); + await generateReport(page, AVAILABILITY_CHANNEL); await page.goto(`/channels/${AVAILABILITY_CHANNEL}`); await page.getByLabel("shard availability total").first().fill("2"); await page.getByLabel("shard availability index").first().fill("1"); @@ -118,6 +121,7 @@ test.describe("Transcribe missing sharding", () => { page, }) => { await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page @@ -182,6 +186,7 @@ test.describe("Transcribe missing sharding", () => { ), JSON.stringify({ cues: [] }), ); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.getByLabel("shard transcribe-missing total").first().fill("1"); @@ -205,6 +210,7 @@ test.describe("Save shard (no run)", () => { page, }) => { await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.getByLabel("shard transcribe-missing total").first().fill("2"); @@ -242,6 +248,7 @@ test.describe("Save shard (no run)", () => { test("a run after Save reuses the saved slice", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.getByLabel("shard transcribe-missing total").first().fill("2"); @@ -305,6 +312,7 @@ test.describe("Download missing sharding", () => { }) => { await resetData("one-transcribe-channel-with-audio"); await setUpMissing(); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.getByLabel("shard download-missing total").first().fill("2"); @@ -346,6 +354,7 @@ test.describe("Download missing sharding", () => { }) => { await resetData("one-transcribe-channel-with-audio"); await setUpMissing(); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.getByLabel("shard download-missing total").first().fill("2"); diff --git a/editor/e2e/skip-live.spec.ts b/editor/e2e/skip-live.spec.ts @@ -7,8 +7,7 @@ import { readJson, resetData, resolvePath, - writeSettings, -} from "./helpers"; + writeSettings, generateReport } from "./helpers"; import { baseUrl } from "./baseUrl"; const CHANNEL = "test-live"; @@ -49,6 +48,7 @@ test("skip-live on by default: live + upcoming skipped, VOD + normal download", }) => { test.setTimeout(120_000); await resetData("skip-live-channel"); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); @@ -128,6 +128,7 @@ test("global skipLiveDownloads=false downloads live videos (split still runs)", ...(await defaultSettings()), skipLiveDownloads: false, }); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); @@ -165,6 +166,7 @@ test("per-channel skipLiveDownloads=false overrides the global default", async ( ); await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); diff --git a/editor/e2e/social-channel.spec.ts b/editor/e2e/social-channel.spec.ts @@ -8,7 +8,7 @@ import { test, expect } from "@playwright/test"; import { readFile } from "node:fs/promises"; -import { resetData, readJson, pathExists, resolvePath } from "./helpers"; +import { resetData, readJson, pathExists, resolvePath, generateReport } from "./helpers"; type ChannelConfig = { handling: string; @@ -35,6 +35,7 @@ test("an X URL derives a social source offline and hides the video-only controls page, }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page.locator('input[name="url"]').fill(X_URL); @@ -54,6 +55,7 @@ test("an X URL derives a social source offline and hides the video-only controls test("a Bluesky URL is also recognised as a social source", async ({ page }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page.locator('input[name="url"]').fill(BSKY_URL); @@ -67,6 +69,7 @@ test("a video URL still shows the video controls (no social regression)", async page, }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page.locator('input[name="url"]').fill("https://www.youtube.com/@Veritasium/videos"); @@ -80,6 +83,7 @@ test("creating a social channel round-trips through parseChannelConfig", async ( page, }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page.locator('input[name="url"]').fill(X_URL); @@ -112,6 +116,7 @@ test("fetch-posts writes month-sharded JSONL + a posts-archive, and re-running i page, }) => { await resetData("empty"); + await generateReport(page, "new"); await page.goto("/channels/new"); await page.locator('input[name="url"]').fill(X_URL); @@ -160,6 +165,7 @@ test("fetch-posts writes month-sharded JSONL + a posts-archive, and re-running i // Re-run: the fake binary is deterministic, so every record comes back — and // the archive must dedupe all of them rather than doubling the corpus. + await generateReport(page, "faketester"); await page.goto("/channels/faketester"); await page.getByRole("button", { name: /fetch posts/i }).first().click(); diff --git a/editor/e2e/sync-break-on-existing.spec.ts b/editor/e2e/sync-break-on-existing.spec.ts @@ -1,10 +1,11 @@ import { test, expect } from "@playwright/test"; -import { readJson, resetData } from "./helpers"; +import { readJson, resetData, generateReport } from "./helpers"; test("treats yt-dlp exit code 101 as success and updates lastSyncedAt", async ({ page, }) => { await resetData("sync-already-archived"); + await generateReport(page, "archived-channel"); await page.goto("/channels/archived-channel"); await page.getByRole("button", { name: "Sync" }).click(); diff --git a/editor/e2e/transcript-source.spec.ts b/editor/e2e/transcript-source.spec.ts @@ -1,6 +1,6 @@ import { rm, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath } from "./helpers"; +import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; const CHANNEL = "test-youtube"; const VIDEO_DIR = "20240101_test1234567"; @@ -75,6 +75,7 @@ test("diagnostics list a video with only transcript.en-US.vtt", async ({ await writeFile(resolvePath(`${DATA}/transcript.en-US.vtt`), VTT); // First channel-page load generates a fresh snapshot including the bucket. + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await expect( diff --git a/editor/e2e/truncated-check.spec.ts b/editor/e2e/truncated-check.spec.ts @@ -7,7 +7,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath } from "./helpers"; +import { pathExists, readJson, resetData, resolvePath, generateReport } from "./helpers"; import { baseUrl } from "./baseUrl"; const CHANNEL = "test-transcribe"; @@ -43,6 +43,7 @@ test("excluding a video from the truncated check clears the banner and the snaps await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); // Snapshot flags it. + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); { const snap = await readJson<{ @@ -70,6 +71,7 @@ test("excluding a video from the truncated check clears the banner and the snaps // Snapshot regen drops it from the bucket. The regen is debounced (~1s) after // the mutation, so poll the on-disk snapshot rather than reading it once. + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); await expect .poll( diff --git a/editor/e2e/undownloaded.spec.ts b/editor/e2e/undownloaded.spec.ts @@ -1,8 +1,9 @@ import { test, expect } from "@playwright/test"; -import { pathExists, resetData } from "./helpers"; +import { pathExists, resetData, generateReport } from "./helpers"; test("shows empty state when there is no playlist", async ({ page }) => { await resetData("one-youtube-channel"); + await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); await expect(page.getByText(/Undownloaded videos \(0\)/)).toBeVisible(); await expect(page.getByLabel("undownloaded videos empty")).toContainText( @@ -12,6 +13,7 @@ test("shows empty state when there is no playlist", async ({ page }) => { test("lists every playlist entry that is missing on disk", async ({ page }) => { await resetData("youtube-with-playlist"); + await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); await expect(page.getByText(/Undownloaded videos \(4\)/)).toBeVisible(); @@ -37,6 +39,7 @@ test("clicking an undownloaded entry lands on a usable video page", async ({ page, }) => { await resetData("youtube-with-playlist"); + await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); await page.getByLabel("undownloaded video fake00000002").click(); @@ -67,6 +70,7 @@ test("undownloaded list shrinks after a one-click whisper completes", async ({ ), ).toBe(true); + await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); await expect(page.getByText(/Undownloaded videos \(3\)/)).toBeVisible(); await expect( diff --git a/editor/e2e/video-filter-combine.spec.ts b/editor/e2e/video-filter-combine.spec.ts @@ -5,7 +5,7 @@ import { rename, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { resetData, resolvePath } from "./helpers"; +import { resetData, resolvePath, generateReport } from "./helpers"; const CHANNEL = "test-transcribe"; const DATA = `test-transcripts/channels/${CHANNEL}/data`; @@ -36,6 +36,7 @@ test("Transcribed + Partial filters intersect to the both-states video", async ( page, }) => { await seedThreeStates(); + await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); const list = page.getByLabel("videos", { exact: true }); diff --git a/editor/e2e/video-page.spec.ts b/editor/e2e/video-page.spec.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath } from "./helpers"; +import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; test("per-video page renders metadata and file list", async ({ page }) => { await resetData("one-youtube-channel-with-data"); @@ -35,6 +35,7 @@ test("WhisperPanel renders failure list as links to per-video pages", async ({ ), "vidA\nvidB\n", ); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await expect(page.getByLabel("failed transcription vidA")).toHaveAttribute( "href", @@ -53,6 +54,7 @@ test("failed entry stays in the list across reloads (skip semantics)", async ({ ), "vidA\n", ); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await expect(page.getByLabel("failed transcription vidA")).toBeVisible(); // Running Transcribe missing should skip the listed failure rather than @@ -157,6 +159,7 @@ test("audio + whisper for a YouTube video with no auto-subs", async ({ test("Go to video form navigates to per-video page", async ({ page }) => { await resetData("one-youtube-channel-with-data"); + await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); const form = page.getByLabel("Go to video"); await form.getByLabel("Video ID").fill("20240101_test1234567"); @@ -168,6 +171,7 @@ test("Go to video form navigates to per-video page", async ({ page }) => { test("Empty failure list shows muted placeholder", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await expect( page.getByLabel("failed transcriptions empty"), diff --git a/editor/e2e/whisper.spec.ts b/editor/e2e/whisper.spec.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath } from "./helpers"; +import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; // A video the downloader couldn't produce real audio for: the dir exists but the // only audio is an in-progress `.part`, and the audio-check pipeline recorded a @@ -32,6 +32,7 @@ async function writeCorruptSourceVideo(id: string, alreadyFailed: boolean) { test("transcribes every audio file with no transcript", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( @@ -51,6 +52,7 @@ test("Transcribe missing excludes VTT-only videos instead of failing them", asyn page, }) => { await resetData("youtube-with-playlist"); + await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); await page.getByRole("button", { name: "Transcribe missing" }).click(); const log = page.getByLabel("Transcribe missing output"); @@ -81,6 +83,7 @@ test("Transcribe missing skips corrupt-source videos and auto-prunes them from t await resetData("one-transcribe-channel-with-audio"); // vidBad has no real audio (only a .part) and is stale-listed as failed. await writeCorruptSourceVideo("vidBad", true); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.getByRole("button", { name: "Transcribe missing" }).click(); // The three genuinely-downloaded videos transcribe; vidBad is never attempted. @@ -110,6 +113,7 @@ test("a corrupt-source video surfaces as corrupt_source, not a failed transcript }) => { await resetData("one-transcribe-channel-with-audio"); await writeCorruptSourceVideo("vidBad", false); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); // The video row is labeled corrupt_source (download/source problem) rather // than failed, and the Download stage summary calls it out for re-download. @@ -123,6 +127,7 @@ test("a corrupt-source video surfaces as corrupt_source, not a failed transcript test("verify reports nothing missing once transcribed", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( @@ -137,6 +142,7 @@ test("verify reports nothing missing once transcribed", async ({ page }) => { test("verify flags missing transcripts before whisper runs", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.getByRole("button", { name: "Verify" }).click(); await expect(page.getByLabel("verify result")).toContainText("Missing (3)", { @@ -154,6 +160,7 @@ test("Retry failures button is gone; Clear failure list takes its place", async ), "vidA\nvidB\n", ); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await expect(page.getByRole("button", { name: "Retry failures" })).toHaveCount( 0, @@ -173,6 +180,7 @@ test("clearing the failure list lets the next Transcribe missing retry them", as ), "vidA\nvidB\n", ); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); // Confirm the button is gated behind typing "clear". const clearButton = page.getByRole("button", { @@ -215,6 +223,7 @@ test("Transcode failures re-encodes each listed video to the chosen format", asy ), "vidA\nvidB\n", ); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); // Default format on the new dropdown is mp3. await page @@ -250,6 +259,7 @@ test("Clean audio removes audio files only from transcribed videos", async ({ ), JSON.stringify({ result: { language: "en" }, transcription: [] }), ); + await generateReport(page, "test-transcribe"); await page.goto("/channels/test-transcribe"); await page.getByRole("button", { name: "Clean audio" }).click(); await expect(page.getByLabel("Clean audio output")).toContainText( @@ -277,6 +287,7 @@ test("Clean audio removes audio files only from transcribed videos", async ({ test("whisper panel is shown on youtube channels for hybrid use", async ({ page }) => { await resetData("one-youtube-channel"); + await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); // Channels can carry whisper transcripts alongside auto-subs; the // transcription panel actions should always be reachable. diff --git a/editor/e2e/worker-remote.spec.ts b/editor/e2e/worker-remote.spec.ts @@ -8,7 +8,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, writeSettings } from "./helpers"; +import { pathExists, resetData, resolvePath, writeSettings, generateReport } from "./helpers"; import { baseUrl } from "./baseUrl"; const TOKEN = "test-worker-token"; @@ -128,6 +128,7 @@ test("a batch dispatched to a remote worker transcribes via the HTTP round-trip" }); await makeTranscribeChannel("remote-chan", ["vidremote1"]); + await generateReport(page, "remote-chan"); await page.goto("/channels/remote-chan"); await page.getByRole("button", { name: "Transcribe missing" }).click(); @@ -161,6 +162,7 @@ test("an unreachable remote is auto-disabled and the video fails over to a local }); await makeTranscribeChannel("failover-chan", ["vidfo1"]); + await generateReport(page, "failover-chan"); await page.goto("/channels/failover-chan"); await page.getByRole("button", { name: "Transcribe missing" }).click(); diff --git a/editor/e2e/workers.spec.ts b/editor/e2e/workers.spec.ts @@ -4,7 +4,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath, writeSettings } from "./helpers"; +import { pathExists, readJson, resetData, resolvePath, writeSettings, generateReport } from "./helpers"; import { baseUrl } from "./baseUrl"; // Reset the in-memory worker pool (and job registry) without restarting the @@ -115,6 +115,7 @@ test("pausing all workers pauses a running batch instead of failing it; resume c const ids = ["slowop1", "slowop2", "slowop3"]; await makeTranscribeChannel("pause-batch", ids); + await generateReport(page, "pause-batch"); await page.goto("/channels/pause-batch"); await page.getByRole("button", { name: "Transcribe missing" }).click(); @@ -165,6 +166,7 @@ test("Workers page and Active jobs cross-reference the running task", async ({ }); await makeTranscribeChannel("xref-chan", ["slowopx1"]); + await generateReport(page, "xref-chan"); await page.goto("/channels/xref-chan"); await page.getByRole("button", { name: "Transcribe missing" }).click();