import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, generateReport, pathExists, resetData, resolvePath, writeSettings, } from "./helpers"; import { baseUrl } from "./baseUrl"; // 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 // tests pin the threshold absurdly high (so the real test disk always reads as // "below floor") to exercise the blocked path, and 0 to exercise the disabled // path. The per-video stop gate in runManagedDownloads shares the same // diskGate as the preflight — with a static threshold the preflight // always intercepts first, so a mid-batch stop only happens on a real-time // drop, which isn't reproducible here; the preflight + disabled paths cover the // shared logic. // // The hysteresis rule itself (stop at the floor, resume only at floor + margin) // is pinned as a SEQUENCE of decisions in common/lib/diskSpace.test.ts, where a // fake free-space number can be fed in. What can only be checked here is that // the unattended auto-download runner consults the gate at all — it did not, // which was the single largest hole in Phase A, and it is asserted below. const HUGE_FLOOR_GB = 100000; // == MIN_FREE_DISK_GB_MAX; far above any real disk test("prevents a download from starting when free disk is below the floor", async ({ page, }) => { await resetData("test-pipeline"); await writeSettings({ minFreeDiskGB: HUGE_FLOOR_GB }); await generateReport(page, "test-pipeline"); await page.goto(channelStage("test-pipeline", "download")); // 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. await page.getByRole("button", { name: "Download from playlist" }).click(); await expect( page.getByLabel("Download from playlist error"), ).toContainText("Low disk space", { timeout: 15_000 }); // Nothing was downloaded. expect( await pathExists( "test-transcripts/channels/test-pipeline/data/fake00000001/metadata.info.json", ), ).toBe(false); }); test("lets downloads proceed when the gate is disabled (0)", async ({ page, }) => { test.setTimeout(120_000); await resetData("test-pipeline"); await writeSettings({ minFreeDiskGB: 0 }); await generateReport(page, "test-pipeline"); await page.goto(channelStage("test-pipeline", "playlist")); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( "Wrote 5 URLs", { timeout: 20_000 }, ); await page.goto(channelStage("test-pipeline", "download")); await page.getByRole("button", { name: "Download from playlist" }).click(); await expect( page.getByLabel("Download from playlist output"), ).toContainText("Managed download complete", { timeout: 60_000 }); expect( await pathExists( "test-transcripts/channels/test-pipeline/data/fake00000001/metadata.info.json", ), ).toBe(true); }); // A YouTube channel with undownloaded videos, enough for the auto-download // runner to have something to pick. Mirrors auto-queue.spec's fixture. async function makeDownloadChannel(slug: string, ids: string[]) { const root = resolvePath(`test-transcripts/channels/${slug}`); await mkdir(`${root}/data`, { recursive: true }); await writeFile( `${root}/config.json`, JSON.stringify({ handling: "youtube", name: slug, url: `https://www.youtube.com/@${slug}/videos`, }), ); await writeFile( `${root}/playlist`, ids.map((id) => `https://www.youtube.com/watch?v=${id}`).join("\n") + "\n", ); await writeFile( `${root}/snapshot.json`, JSON.stringify({ generatedAt: "2026-06-01T00:00:00.000Z", totals: { videos: ids.length, transcribed: 0, downloaded: 0 }, buckets: { noTranscript: [], downloadedNoTranscript: [], untranscoded: [], multipleAudioFormats: [], transcribedWithAudio: [], untranscribable: [], noMetadata: [], failedListed: [], missingFromArchive: [], duplicateDirs: [], partialDownloads: [], corruptSource: [], nonStandardVtt: [], skippedByFilter: [], }, undownloadedIds: ids, }), ); } test("the unattended auto-download runner idles below the floor", async ({ request, }) => { // THE REGRESSION. autoRunner.ts is the one path that dispatches downloads for // days with nobody watching, and before Phase A it contained no reference to // disk at all — every manual action had a preflight, the thing that runs by // itself did not. Without the gate this test dispatches and yt-dlp starts. await resetData(null); await makeDownloadChannel("alpha", ["d1", "d2"]); await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: HUGE_FLOOR_GB, autoQueue: { transcription: {}, download: { enabled: true, maxWorkers: null, root: { id: "root", mode: "strict", children: [ { id: "leaf-alpha", match: { type: "channel", value: "alpha" } }, ], }, }, }, }); const start = await request.post(`${baseUrl}/api/auto-queue/control`, { // Behind the ops token since release 19 (A3): it starts and stops lanes. headers: { authorization: "Bearer test-worker-token" }, data: { kind: "download", action: "start" }, }); expect(start.ok()).toBeTruthy(); try { // Give the runner real time to tick. It must stay running (the gate IDLES, // it does not stop the runner — otherwise freeing space would need a manual // restart) while never handing out a single video. await page_waitMs(6000); const status = await request.get(`${baseUrl}/api/auto-queue/status`); const body = (await status.json()) as { download: { runner: { running: boolean }; picks: unknown[] }; }; expect(body.download.runner.running).toBe(true); expect(body.download.picks).toHaveLength(0); // And nothing landed on disk. expect( await pathExists("test-transcripts/channels/alpha/data/d1"), ).toBe(false); } finally { await request.post(`${baseUrl}/api/auto-queue/control`, { // Behind the ops token since release 19 (A3): it starts and stops lanes. headers: { authorization: "Bearer test-worker-token" }, data: { kind: "download", action: "stop" }, }); } }); // A plain sleep. The assertion here is an ABSENCE (no picks), so there is // nothing to poll toward — the test has to give the runner room to misbehave. function page_waitMs(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } test("the dashboard tells a disk stop apart from the manual pause", async ({ page, }) => { // The defect: PipelineBand rendered one red "downloads paused" that read the // MANUAL toggle. A real disk stop said nothing, and when both were true the // operator would un-pause and watch nothing happen. // // The manual toggle is `autoQueue.download.held` since slice 1.4 — the lane's // own gate, spelled here the way a settings.json written by the editor now // spells it. (The field it replaced still reads as a fallback; the spec that // proves THAT is in auto-queue.spec.ts.) await resetData("test-pipeline"); // Two distinct labels, because they are two distinct facts. (getByLabel // matches by substring, so both are exact-matched — "Pipeline" alone pulls in // five unrelated nodes.) const diskStop = page.getByLabel("disk low", { exact: true }); const manualPause = page.getByLabel("downloads paused manually", { exact: true, }); // Disk stop only. The band must name the disk, and must NOT claim a manual // pause — reading a disk stop as the manual toggle is the original defect. await writeSettings({ minFreeDiskGB: HUGE_FLOOR_GB, autoQueue: { download: { held: false } }, }); await page.goto("/"); await expect(diskStop).toContainText("disk", { timeout: 15_000 }); await expect(manualPause).toHaveCount(0); // Both at once: two instruments, so un-pausing is visibly not enough. await writeSettings({ minFreeDiskGB: HUGE_FLOOR_GB, autoQueue: { download: { held: true } }, }); await page.reload(); await expect(manualPause).toBeVisible({ timeout: 15_000 }); await expect(diskStop).toBeVisible(); // Gate off, still manually paused: only the manual instrument remains. await writeSettings({ minFreeDiskGB: 0, autoQueue: { download: { held: true } } }); await page.reload(); await expect(manualPause).toBeVisible({ timeout: 15_000 }); await expect(diskStop).toHaveCount(0); }); test("active-jobs API and monitor widget report low disk", async ({ page }) => { await resetData("test-pipeline"); // Enabled + below floor → low. await writeSettings({ minFreeDiskGB: HUGE_FLOOR_GB }); const lowRes = await page.request.get("/api/jobs/active"); expect(lowRes.ok()).toBe(true); const lowPayload = (await lowRes.json()) as { disk: { enabled: boolean; low: boolean; reason: string; message: string; resumeBytes: number; thresholdBytes: number; }; }; expect(lowPayload.disk.enabled).toBe(true); expect(lowPayload.disk.low).toBe(true); // The payload says WHY, so a surface never has to guess between the manual // toggle and the gate. expect(lowPayload.disk.reason).toBe("below-floor"); expect(lowPayload.disk.message).toContain("below the"); // Resume needs strictly more headroom than the floor (default margin 2 GB). expect(lowPayload.disk.resumeBytes).toBeGreaterThan( lowPayload.disk.thresholdBytes, ); // The monitor widget surfaces the warning. await page.goto("/widget"); await expect(page.getByLabel("Disk space")).toContainText("downloads paused"); // Disabled (0) → indicator hidden, gate off. await writeSettings({ minFreeDiskGB: 0 }); const offRes = await page.request.get("/api/jobs/active"); const offPayload = (await offRes.json()) as { disk: { enabled: boolean; low: boolean }; }; expect(offPayload.disk.enabled).toBe(false); expect(offPayload.disk.low).toBe(false); }); // THE HARNESS'S OWN INVARIANT, pinned here because breaking it is invisible. // // writeSettings() used to REPLACE test-settings.json rather than merge onto // fixtures/test-settings.default.json, so a spec that did not name a key // inherited the PRODUCT default instead of the fixture's — a different set of // numbers, chosen for operators and not for a test host. Two of them cost: // minFreeDiskGB 5 GB instead of 0 arms the low-disk gate, so every media action // in that spec depends on how much room the host has left; and // sleepBetweenDownloadsSeconds 10 instead of 0 puts ten seconds between every // download in a fixture batch. Eighteen spec files write settings without // naming the floor and fourteen without naming the sleep; none of them is about // either. // // The failure that costs is not a red assertion about disk or about time, it is // a red assertion about something else: the preflight in pipelineActions returns // `{ ok: false }`, the action never runs, and the spec reports an empty list — // or the batch simply does not finish inside the timeout. This asserts the // fixture's intent survives a spec's own write, and that a spec's own value // still wins over it. test("a spec's settings are the fixture's plus what it names", async ({ page, }) => { await resetData("test-pipeline"); await writeSettings({ adminTitle: "Test Admin" }); await page.goto("/settings"); // Unnamed: both come from the fixture, not from the product defaults (5, 10). await expect(page.locator('input[name="minFreeDiskGB"]')).toHaveValue("0"); await expect( page.locator('input[name="sleepBetweenDownloadsSeconds"]'), ).toHaveValue("0"); // Named: the spec's own value survives the merge. await expect(page.locator('input[name="adminTitle"]')).toHaveValue( "Test Admin", ); // And the gate itself agrees — the same payload the indicator reads. const res = await page.request.get("/api/jobs/active"); const payload = (await res.json()) as { disk: { enabled: boolean; low: boolean }; }; expect(payload.disk.enabled).toBe(false); expect(payload.disk.low).toBe(false); // A named floor still wins, which is what every case above this one needs. await writeSettings({ minFreeDiskGB: HUGE_FLOOR_GB }); await page.goto("/settings"); await expect(page.locator('input[name="minFreeDiskGB"]')).toHaveValue( String(HUGE_FLOOR_GB), ); // ...and the fixture's other keys are still underneath it. await expect( page.locator('input[name="sleepBetweenDownloadsSeconds"]'), ).toHaveValue("0"); });