import { mkdir, stat, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { generateReport, readJson, resetData, resolvePath, writeChannelConfig, writeSettings, } from "./helpers"; import { baseUrl } from "./baseUrl"; // /cleanup's hold accounting: WHY the audio that isn't reclaimable isn't, and // which of those holds a queued job will ever clear. // // The sweep skips videos for four reasons and reports them only as a line in a // job log after the fact, with no bytes attached. The snapshot now attributes // every audio byte to the FIRST gate it hits (cleanAudioFromTranscribed's own // order), and the page renders that as a sieve. Two things have to hold or the // page lies: the five figures must be a partition (no double-counting), and a // report written before the accounting existed must render "—", never "0". const SLUG = "test-transcribe"; const STALE = "stale-report"; const SNAPSHOT_REL = `test-transcripts/channels/${SLUG}/snapshot.json`; function dataRel(videoId: string, file: string): string { return `test-transcripts/channels/${SLUG}/data/${videoId}/${file}`; } async function seedTranscript(videoId: string): Promise { await writeFile( resolvePath(dataRel(videoId, "transcript.json")), '{"transcription":[]}\n', ); } async function audioBytes(videoId: string): Promise { return (await stat(resolvePath(dataRel(videoId, "audio.m4a")))).size; } type Held = { noTranscript: number; keepLatest: number; doNotClean: number; awaitingDiarization: number; diarizationNeverClears: number; }; type HoldSnapshot = { totalAudioBytes?: number; heldAudioBytes?: Held; heldAudioCounts?: Held; cleanupBytes?: { transcribedWithAudio: number }; }; test("the sieve attributes every held byte to exactly one gate, and names the hold nothing will clear", async ({ page, }) => { // Generating a report and then walking three pages does not fit the default // 30s budget once the box is busy: this measured 19.3s idle and timed out at // 30s while another suite was running. The assertions are cheap; the setup is // not, so the test gets room rather than a retry. test.setTimeout(120_000); await resetData("one-transcribe-channel-with-audio"); // The guard the sweep actually reads is `diarization.enabled` ALONE. Enabled // without segModel/embModel is the case the page exists to expose: the guard // holds the audio, the diarize KIND reports itself disabled, and so no queued // job will ever release it. (Deliberately safe in a test: a disabled kind is // dropped from the backfill lane, so nothing is dispatched.) await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, verifyAvailabilityBeforeClean: false, syncScheduler: { fullSweepIntervalMinutes: 0 }, diarization: { enabled: true }, }); // vidA — transcribed and PINNED (and undiarized: the first-gate-wins case). // vidB — audio, no transcript at all. // vidC — transcribed, undiarized. // vidD — transcribed AND diarized: the only reclaimable one. await seedTranscript("vidA"); await seedTranscript("vidC"); await mkdir(resolvePath(dataRel("vidD", "")), { recursive: true }); await writeFile(resolvePath(dataRel("vidD", "audio.m4a")), "x".repeat(4096)); await seedTranscript("vidD"); await writeFile( resolvePath(dataRel("vidD", "diarization.json")), JSON.stringify({ segments: [], engine: "test", createdAt: "2026-01-01" }), ); await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); await page.goto(`/channels/${SLUG}/videos/vidA`); await page .getByRole("button", { name: "mark video vidA do not clean" }) .click(); await expect(page.getByLabel("media archived")).toBeVisible(); await generateReport(page, SLUG); await page.goto(`/channels/${SLUG}`); const [aBytes, bBytes, cBytes, dBytes] = await Promise.all([ audioBytes("vidA"), audioBytes("vidB"), audioBytes("vidC"), audioBytes("vidD"), ]); const snap = await readJson(SNAPSHOT_REL); const held = snap.heldAudioBytes; const counts = snap.heldAudioCounts; if (!held || !counts) throw new Error("snapshot carries no hold accounting"); // One gate each, by the sweep's order. expect(held.noTranscript).toBe(bBytes); expect(held.doNotClean).toBe(aBytes); expect(held.awaitingDiarization).toBe(cBytes); expect(held.keepLatest).toBe(0); expect(counts.noTranscript).toBe(1); expect(counts.doNotClean).toBe(1); expect(counts.awaitingDiarization).toBe(1); // FIRST GATE WINS. vidA is both pinned and undiarized; it is counted once, // under the pin — counting it twice would report a hold bigger than the disk. expect(counts.doNotClean + counts.awaitingDiarization).toBe(2); // The permanent hold: the diarize lane is not running at all, so every byte // it holds is a byte no queued job will ever release. expect(held.diarizationNeverClears).toBe(cBytes); // THE INVARIANT — the four gates plus the reclaimable remainder are the whole // of the audio on disk. const reclaimable = snap.cleanupBytes?.transcribedWithAudio ?? 0; expect(reclaimable).toBe(dBytes); expect( held.noTranscript + held.keepLatest + held.doNotClean + held.awaitingDiarization + reclaimable, ).toBe(snap.totalAudioBytes); expect(snap.totalAudioBytes).toBe(aBytes + bBytes + cBytes + dBytes); // --- The page ------------------------------------------------------------ await page.goto("/cleanup"); const sieve = page.getByLabel("audio hold sieve"); await expect(sieve).toBeVisible(); const gate = (n: number) => sieve.locator(`li[data-gate="${n}"]`); await expect(gate(1)).toHaveAttribute("data-held-bytes", String(bBytes)); await expect(gate(1)).toHaveAttribute("data-held-count", "1"); await expect(gate(2)).toHaveAttribute("data-held-bytes", "0"); await expect(gate(3)).toHaveAttribute("data-held-bytes", String(aBytes)); await expect(gate(4)).toHaveAttribute("data-held-bytes", String(cBytes)); // A gate that holds nothing shows "—", not "0". await expect(gate(2).getByLabel("keep-latest window held")).toHaveText("—"); // The permanent hold is stated on the page, not just in the file. await expect(gate(4)).toContainText("will not clear on its own"); // The remainder steps down to the figure the hero already prints. await expect(sieve.getByLabel("reclaimable after holds")).toHaveText(/4\.0 KB/); // And the ledger prescribes the two runs, ranked by held bytes. const ledger = page.getByLabel("release ledger"); // The row is labelled by slug and reads as the channel's NAME, so assert the // row exists rather than that the slug is printed in it. await expect(ledger.getByLabel(`no transcript ${SLUG}`)).toHaveAttribute( "data-held-bytes", String(bBytes), ); await expect( ledger.getByRole("button", { name: "Diarize", exact: true }), ).toBeVisible(); }); test("a report written before the hold accounting renders '—', not a zero nobody measured", async ({ page, }) => { await resetData(); await writeChannelConfig(STALE); // Exactly what an older snapshot looks like: reclaim estimate, no accounting. await writeFile( resolvePath(`test-transcripts/channels/${STALE}/snapshot.json`), JSON.stringify({ generatedAt: "2026-01-01T00:00:00.000Z", totals: { videos: 1, transcribed: 1, downloaded: 1 }, buckets: { transcribedWithAudio: ["vidA"] }, cleanupBytes: { transcribedWithAudio: 1024 }, }), ); await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); await page.goto("/cleanup"); const sieve = page.getByLabel("audio hold sieve"); await expect(sieve.getByLabel("total audio on disk")).toHaveText("—"); // And it says whose measurement is missing rather than implying there is none. await expect(sieve).toContainText("Refresh reports"); });