import { mkdir, readdir, rename, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { test, expect } from "@playwright/test"; import { baseUrl } from "./baseUrl"; import { generateReport, readJson, resetData, resolvePath, writeChannelConfig, writeSettings, } from "./helpers"; // /channels AS THE STORAGE WORKING SURFACE. // // Operator ask, 2026-09-20, verbatim: "I'd like to see disk space and current // storage volume as columns on the channels menu, and let me filter by volume // for easy checking of things that may need moving or other processing." // // So: a Location column, a Size column, one free-space read-out per VOLUME (not // per row — 71 copies of one number is what the focus bar taught us not to do), // a `?location=` filter beside the existing `?site=`, and a helper that ticks // the largest in-place channels until a target is met. // // THE SIZES COME OFF THE REPORTS, so every test here generates one first. That // is not ceremony: a channel with no report has NO measured size, and the // distinction between "0 bytes" and "nobody measured" is the thing half of this // feature exists to keep straight. // // `minFreeDiskGB: 0` on every settings write — a spec that does not say so // inherits the 5 GB default and fails on a nearly full disk (repo memory, // 549d). const SLUG = "test-youtube"; const VIDEO = "20240101_test1234567"; // THE SIZE COLUMN IS THE MEDIA TIER (release 17): the audio and the raw live // chat, what a move carries. The fixture has none, so one is written. async function giveAudio(slug: string, id: string, bytes = 4096) { await writeFile( resolvePath(`test-transcripts/channels/${slug}/data/${id}/audio.mp3`), "x".repeat(bytes), ); } // Put a channel's media tier on `root` the way a finished relocation leaves it // (release 17): each audio file moved to `//media//` with its // relative link left in `data//`, an absolute symlink at // channels//media, and config.mediaDir naming the target. Built directly // rather than by running a move — what is under test is the COLUMN, and a real // rsync buys nothing here. async function relocateOnDisk(slug: string, root: string): Promise { const channelDir = resolvePath(`test-transcripts/channels/${slug}`); const target = join(root, slug, "media"); await mkdir(target, { recursive: true }); for (const id of await readdir(join(channelDir, "data"))) { const names = await readdir(join(channelDir, "data", id)).catch( () => [] as string[], ); if (!names.includes("audio.mp3")) continue; await mkdir(join(target, id), { recursive: true }); await rename( join(channelDir, "data", id, "audio.mp3"), join(target, id, "audio.mp3"), ); await symlink( join("..", "..", "media", id, "audio.mp3"), join(channelDir, "data", id, "audio.mp3"), ); } await symlink(target, join(channelDir, "media")); const config = await readJson>( `test-transcripts/channels/${slug}/config.json`, ); await writeChannelConfig(slug, { ...config, mediaDir: target }); await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); return target; } test("the Location and Size columns say which disk and how much", async ({ page, }, testInfo) => { test.setTimeout(120_000); await resetData("one-youtube-channel-with-data"); const root = testInfo.outputPath("platter"); await mkdir(root, { recursive: true }); await writeSettings({ adminTitle: "Test Admin", minFreeDiskGB: 0, storage: { locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }], defaultLocationId: "cold", }, }); await giveAudio(SLUG, VIDEO); await generateReport(page, SLUG); await page.goto("/channels"); // IN PLACE READS "Internal", not blank. A channel whose media has never moved // is on the corpus volume, and that is a fact about it, not an absence. const location = page.getByLabel(`media location for ${SLUG}`); await expect(location).toContainText("Internal"); // The fixture's audio is a small file, so the figure is not zero and not // "—": the report measured it. const size = page.getByLabel(`media size for ${SLUG}`); await expect(size).not.toHaveText("—"); await expect(size).toContainText(/B|KB|MB/); // --- moved, and the column follows it ------------------------------------ await relocateOnDisk(SLUG, root); await page.goto("/channels"); await expect(page.getByLabel(`media location for ${SLUG}`)).toContainText( "Cold", ); // --- the volume bar, once per volume ------------------------------------- const volumes = page.getByLabel("storage volumes"); await expect(volumes).toBeVisible(); await expect(volumes.getByLabel("volume cold")).toContainText("1 ch"); await expect(volumes.getByLabel("volume cold")).toContainText("free"); // FREE SPACE IS NOT A COLUMN. It is a fact about a disk; one read-out per // volume, not one per row. // (Matched on TEXT, not on the accessible name: each header's name comes from // its sort button's aria-label, as channels-sort.spec.ts discovered.) await expect( page.getByRole("columnheader").filter({ hasText: /free/i }), ).toHaveCount(0); }); test("a channel with no report reads as unmeasured, never as empty", async ({ page, }) => { await resetData("one-youtube-channel-with-data"); await writeSettings({ adminTitle: "Test Admin", minFreeDiskGB: 0 }); // Deliberately NO generateReport. A zero here would rank a 400 GB channel // bottom of the very list the operator opened to find it. await page.goto("/channels"); await expect(page.getByLabel(`media size for ${SLUG}`)).toHaveText("—"); }); test("filter by volume, sort by size, and free up N GB", async ({ page, }, testInfo) => { test.setTimeout(180_000); await resetData("one-youtube-channel-with-data"); const root = testInfo.outputPath("platter"); await mkdir(root, { recursive: true }); // A second channel, so a filter has something to exclude and a sort has // something to order. const SECOND = "second-mover"; await writeChannelConfig(SECOND, { handling: "youtube", name: "Second Mover", url: "https://www.youtube.com/@second", }); await mkdir( resolvePath(`test-transcripts/channels/${SECOND}/data/20240102_second0001`), { recursive: true }, ); await giveAudio(SLUG, VIDEO); await giveAudio(SECOND, "20240102_second0001", 1024); await writeSettings({ adminTitle: "Test Admin", minFreeDiskGB: 0, storage: { locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }], defaultLocationId: "cold", }, }); await generateReport(page, SLUG); await generateReport(page, SECOND); await relocateOnDisk(SECOND, root); // --- ?location= ---------------------------------------------------------- await page.goto("/channels?location=internal"); await expect(page.getByLabel(`select ${SLUG}`)).toBeVisible(); await expect(page.getByLabel(`select ${SECOND}`)).toHaveCount(0); await page.goto("/channels?location=cold"); await expect(page.getByLabel(`select ${SECOND}`)).toBeVisible(); await expect(page.getByLabel(`select ${SLUG}`)).toHaveCount(0); // The chip is the filter, and it carries `aria-current` when it is the one // in force — so the operator can see which list they are looking at. await expect( page.getByLabel("storage volumes").getByLabel("volume cold"), ).toHaveAttribute("aria-current", "true"); // --- ?sort=size ---------------------------------------------------------- // /storage links here with it, so the "largest first" list it promised is the // list that renders. await page.goto("/channels?sort=size"); const sizeHeader = page .getByRole("columnheader") .filter({ hasText: /^Size/ }); await expect(sizeHeader).toHaveAttribute("aria-sort", "descending"); // And clicking the header flips it, like every other sortable column. await expect .poll( async () => { await page .getByRole("button", { name: "sort by Size" }) .click({ timeout: 5_000 }) .catch(() => {}); return sizeHeader.getAttribute("aria-sort"); }, { timeout: 30_000, intervals: [250, 500, 1_000, 1_000, 2_000] }, ) .toBe("ascending"); // --- free up N GB -------------------------------------------------------- await page.goto("/channels"); // Nothing ticked, so the deck is not there — which is why the control lives // in the volume bar and not on the deck. await expect(page.getByLabel("channel priority bulk")).toHaveCount(0); // CLICK UNTIL IT TOOK. Every control here is a React handler attached after // the server HTML arrives; Playwright's actionability check is satisfied by // the BUTTON, not by the listener, so a click landing in that window is // silently dropped. The action is idempotent — the same selection is computed // from the same rows — so retrying until the consequence appears is both safe // and the honest way to wait for a listener the DOM cannot advertise. const target = page.getByLabel("free up target GB"); const selectLargest = page.getByLabel("select largest channels to free up"); await expect .poll( async () => { await target.fill("1").catch(() => {}); await selectLargest.click({ timeout: 5_000 }).catch(() => {}); return page.getByLabel(`select ${SLUG}`).isChecked(); }, { timeout: 30_000, intervals: [250, 500, 1_000, 1_000, 2_000] }, ) .toBe(true); // It ticked the in-place channel and NOT the one already on the platter — // moving that frees nothing on the disk being emptied. await expect(page.getByLabel(`select ${SLUG}`)).toBeChecked(); await expect(page.getByLabel(`select ${SECOND}`)).not.toBeChecked(); // The deck is up, with the note beside the Move button that will act on it. const deck = page.getByLabel("channel priority bulk"); await expect(deck).toBeVisible(); await expect(deck.getByLabel("free up selection note")).toContainText( "selected", ); // 1 GB is more than the fixture holds, so it says so rather than pretending. await expect(deck.getByLabel("free up selection note")).toContainText( "short of the target", ); });