Archilyzer · Source

archilyzer

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

commit 0828507f6da65f21d831c1bf07e78977d76d72ea
parent 93d82150217ecd05d8c597ad9fba8113a37c10b5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 20 Sep 2026 18:28:58 -0400

e2e: the internal row, the store's move, and the columns operators asked for

storage-locations gains three cases — the corpus volume as a first row with
nothing to configure and a link to its own list, a location whose channels have
no reports reading as unmeasured rather than empty, and the saved-video store
moving out, back, and resuming from a marker a killed copy left.

channels-storage-columns is new: Location and Size, free space once per volume
and never as a column, ?location= filtering, ?sort=size arriving from /storage,
and "free up N GB" ticking the in-place channel while leaving the one already on
the platter alone — moving that frees nothing on the disk being emptied.

channel-storage asserts the decile line the progress parse produces. The
fixture copy is over in one frame, so there is no honest way to catch a bar
mid-flight from a browser; the frame-by-frame behaviour is the unit tests'.

Every settings write here names minFreeDiskGB: 0 — a spec that does not inherit
the 5 GB floor and fails on a nearly full disk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Meditor/e2e/channel-storage.spec.ts | 12++++++++++++
Aeditor/e2e/channels-storage-columns.spec.ts | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/storage-locations.spec.ts | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 401 insertions(+), 0 deletions(-)

diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts @@ -116,6 +116,18 @@ test("relocate a channel's media to another root, and move it back", async ({ await expect(page.getByLabel("Move media output")).toContainText("Moved", { timeout: 60_000, }); + // THE COPY SAYS HOW FAR IT GOT. rsync's `--info=progress2` frames used to go + // into the log as several thousand carriage-return redraws of one line; the + // controller parses them and writes one line per decile instead. The fixture + // is two small files, so the copy is over in one frame and the assertion is + // on the DECILE LINE that frame produces — a percentage-and-rate line that + // could only have come from a parsed frame. (The live task bar on /jobs is + // fed by the same parse; there is no honest way to catch a 3 ms copy + // mid-flight from a browser, which is what the unit tests in + // jobs/progressParsers.test.ts are for.) + await expect(page.getByLabel("Move media output")).toContainText( + /Copying… .* · \d+ % · /, + ); // data/ is a symlink now, and config.json records the target — written only // by the job, on success, after the copy verified. diff --git a/editor/e2e/channels-storage-columns.spec.ts b/editor/e2e/channels-storage-columns.spec.ts @@ -0,0 +1,194 @@ +import { mkdir, rename, symlink } 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"; + +// Put a channel's media on `root` the way a finished relocation leaves it: the +// real directory on the "drive", an absolute symlink at channels/<slug>/data, +// and config.dataDir 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<string> { + const channelDir = resolvePath(`test-transcripts/channels/${slug}`); + const target = join(root, slug, "data"); + await mkdir(join(root, slug), { recursive: true }); + await rename(join(channelDir, "data"), target); + await symlink(target, join(channelDir, "data")); + const config = await readJson<Record<string, unknown>>( + `test-transcripts/channels/${slug}/config.json`, + ); + await writeChannelConfig(slug, { ...config, dataDir: 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 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 is two small files, 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. + await expect(page.getByRole("columnheader", { name: /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 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"); + await expect( + page.getByRole("columnheader", { name: /^Size/ }), + ).toHaveAttribute("aria-sort", "descending"); + // And clicking the header flips it, like every other sortable column. + await page.getByRole("button", { name: "sort by Size" }).click(); + await expect( + page.getByRole("columnheader", { name: /^Size/ }), + ).toHaveAttribute("aria-sort", "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); + await page.getByLabel("free up target GB").fill("1"); + await page + .getByLabel("select largest channels to free up") + .click(); + + // 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", + ); +}); diff --git a/editor/e2e/storage-locations.spec.ts b/editor/e2e/storage-locations.spec.ts @@ -3,6 +3,8 @@ import { join } from "node:path"; import { test, expect, type Page } from "@playwright/test"; import { baseUrl } from "./baseUrl"; import { + generateReport, + pathExists, readJson, resetData, resolvePath, @@ -285,3 +287,196 @@ test("a volume that came up somewhere else is re-pointed in one click", async ({ "1 ok / 0 unreachable / 0 moving", ); }); + +// --- THE CORPUS VOLUME, AND THE SAVED-VIDEO STORE --------------------------- + +test("the corpus volume is a row of its own, with nothing to configure", 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", + }, + }); + // The sizes come off the reports, so the row can only be honest after one. + await generateReport(page, SLUG); + + await page.goto("/storage"); + const internal = row(page, "internal"); + await expect(internal).toBeVisible(); + // IT IS FIRST. 523 GB on a disk with 67 left is not a footnote under the + // locations that were added to fix it. + const ids = await page + .getByLabel(/^storage location: /) + .evaluateAll((els) => + els.map((e) => e.getAttribute("aria-label")?.replace("storage location: ", "")), + ); + expect(ids[0]).toBe("internal"); + + await expect(internal.getByLabel("location status")).toHaveText("Available"); + await expect(internal.getByLabel("location channels")).toContainText("1 ok"); + await expect(internal.getByLabel("location media bytes")).not.toHaveText("—"); + await expect(internal.getByLabel("location media bytes")).not.toContainText( + "size unknown", + ); + // It links to its own list, largest first. + await expect(internal.getByLabel("list channels on internal")).toHaveAttribute( + "href", + "/channels?location=internal&sort=size", + ); + // NOTHING TO REFRESH, RE-POINT, MOUNT, EDIT OR DELETE — and the sentence is + // said once rather than as five greyed buttons. + await expect(internal.getByLabel("refresh internal")).toHaveCount(0); + await expect(internal.getByLabel("delete internal")).toHaveCount(0); + await expect(internal.getByLabel("internal note")).toContainText( + "not a configured location", + ); + + // A LOCATION WITH CHANNELS BUT NO REPORTS SAYS SO. `0 B` on a 2 TB drive is a + // claim nobody measured. + const cold = row(page, "cold"); + await expect(cold.getByLabel("location media bytes")).toHaveText("0 B"); +}); + +test("the saved-video store moves onto a location, and back", async ({ + page, +}, testInfo) => { + test.setTimeout(180_000); + await resetData("one-youtube-channel-with-data"); + const root = testInfo.outputPath("platter"); + await mkdir(root, { recursive: true }); + // Something to move. The store is <root>/<slug>/<videoId>/<container>. + const storeDir = resolvePath("test-transcripts/saved-videos"); + await mkdir(join(storeDir, SLUG, VIDEO), { recursive: true }); + await writeFile( + join(storeDir, SLUG, VIDEO, "source-media.mp4"), + "not really an mp4", + ); + await writeSettings({ + adminTitle: "Test Admin", + minFreeDiskGB: 0, + storage: { + locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }], + defaultLocationId: "cold", + }, + }); + + await page.goto("/storage"); + const card = page.getByLabel("saved video store"); + await expect(card).toBeVisible(); + await expect(card.getByLabel("saved videos status")).toHaveText("In place"); + await expect(card.getByLabel("saved videos location")).toHaveText( + "Internal (in place)", + ); + await expect(card.getByLabel("saved videos bytes")).toContainText("1 file(s)"); + + // --- out ---------------------------------------------------------------- + // NO selectOption: the only destination a store in place can go to is the one + // configured location, and the select opens on it. An option that would be a + // no-op is never offered (see savedVideosView), which is what makes the + // default correct in both directions here. + await expect(card.getByLabel("saved videos destination")).toHaveValue("cold"); + await page.getByRole("button", { name: "Move the store" }).click(); + await expect(page.getByLabel("Move the store output")).toContainText("Moved", { + timeout: 60_000, + }); + + // A symlink where the store was, the bytes on the "drive", and the record + // written only on success. + const target = join(root, "saved-videos"); + expect(await readlink(storeDir)).toBe(target); + const after = await readJson<{ + storage: { savedVideosLocationId?: string }; + }>("test-settings.json"); + expect(after.storage.savedVideosLocationId).toBe("cold"); + + // EVERY READER KEEPS WORKING, through the link, unchanged. + await page.goto("/saved-videos"); + await expect(page.locator("body")).toContainText(SLUG); + + // --- back --------------------------------------------------------------- + await page.goto("/storage"); + const card2 = page.getByLabel("saved video store"); + await expect(card2.getByLabel("saved videos status")).toHaveText( + "Relocated · reachable", + ); + await expect(card2.getByLabel("saved videos location")).toHaveText("Cold"); + // And on the way back the only destination is the corpus volume, for the same + // reason: a location-to-location move of the store is not offered. + await expect(card2.getByLabel("saved videos destination")).toHaveValue(""); + await page.getByRole("button", { name: "Move the store" }).click(); + await expect(page.getByLabel("Move the store output")).toContainText( + "Moved back", + { timeout: 60_000 }, + ); + const back = await readJson<{ + storage: { savedVideosLocationId?: string }; + }>("test-settings.json"); + expect(back.storage.savedVideosLocationId).toBe(undefined); + // The target was reclaimed, and the store is a real directory again. + expect(await pathExists("test-transcripts/saved-videos/" + SLUG)).toBe(true); +}); + +// A KILLED COPY LEAVES A MARKER NOTHING WILL EVER CLEAR, and without Resume the +// only fix is deleting a dotfile over SSH. +test("an interrupted store move is resumable from the page", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + await resetData("one-youtube-channel-with-data"); + const root = testInfo.outputPath("platter"); + await mkdir(root, { recursive: true }); + const storeDir = resolvePath("test-transcripts/saved-videos"); + await mkdir(join(storeDir, SLUG, VIDEO), { recursive: true }); + await writeFile( + join(storeDir, SLUG, VIDEO, "source-media.mp4"), + "not really an mp4", + ); + await writeSettings({ + adminTitle: "Test Admin", + minFreeDiskGB: 0, + storage: { + locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }], + defaultLocationId: "cold", + }, + }); + // What a killed copy leaves behind: a partial target and a phase-copy marker. + const target = join(root, "saved-videos"); + await mkdir(join(target, SLUG, VIDEO), { recursive: true }); + await writeFile( + resolvePath("test-transcripts/.relocating-saved-videos.json"), + JSON.stringify( + { + target, + direction: "out", + startedAt: new Date().toISOString(), + phase: "copy", + }, + null, + 2, + ), + ); + await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); + + await page.goto("/storage"); + const card = page.getByLabel("saved video store"); + await expect(card.getByLabel("saved videos status")).toHaveText( + "Move in flight", + ); + await page.getByRole("button", { name: "Resume move" }).click(); + await expect(page.getByLabel("Resume move output")).toContainText( + "resumed an interrupted move", + { timeout: 60_000 }, + ); + expect(await readlink(storeDir)).toBe(target); + expect( + await pathExists("test-transcripts/.relocating-saved-videos.json"), + ).toBe(false); +});