Archilyzer · Source

archilyzer

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

commit cb273103f2a0e386f1cb9ef817441e83b44dc637
parent 3aef0f277756c926a2ddc1172a6608d98b2eed5c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 17 Sep 2026 20:58:24 -0400

e2e: a destination is picked by name, and an interrupted move is finished

Two new cases. The first configures two locations with the DEFAULT NOT the one it moves to, picks the other by name, and asserts the preview names the target the server resolved from the id — a one-location fixture could not tell "the id was sent" from "the default was used". It ends on the badge: "on Cold", with the full sentence still in the accessible name every other assertion here matches on.

The second builds the killed-rsync state on disk — a marker at phase "copy" beside a partial copy at the target — and presses Resume move. What it asserts is the controller's own summary line, "(resumed an interrupted move)": the root came from the marker's target and from no form on the page.

The bulk case grows a second location for the same reason and loses its typed root entirely; "bulk media root" is now the free-text escape hatch, and the deck's normal path is a select plus the root shown beside it.

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

Diffstat:
Meditor/e2e/channel-storage.spec.ts | 246++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
1 file changed, 218 insertions(+), 28 deletions(-)

diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts @@ -1,4 +1,11 @@ -import { lstat, mkdir, readdir, symlink, writeFile } from "node:fs/promises"; +import { + copyFile, + lstat, + mkdir, + readdir, + symlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { test, expect, type Page } from "@playwright/test"; import { baseUrl } from "./baseUrl"; @@ -114,9 +121,11 @@ test("relocate a channel's media to another root, and move it back", async ({ // by the job, on success, after the copy verified. expect((await lstat(dataDir())).isSymbolicLink()).toBe(true); expect( - (await readJson<{ dataDir?: string }>( - `test-transcripts/channels/${SLUG}/config.json`, - )).dataDir, + ( + await readJson<{ dataDir?: string }>( + `test-transcripts/channels/${SLUG}/config.json`, + ) + ).dataDir, ).toBe(target); // The source was reclaimed: no parked copy left holding a second copy of the // channel on the volume the move exists to free, and no marker. @@ -164,9 +173,11 @@ test("relocate a channel's media to another root, and move it back", async ({ // A real directory again, the config field gone, and the target reclaimed. expect((await lstat(dataDir())).isDirectory()).toBe(true); expect( - (await readJson<{ dataDir?: string }>( - `test-transcripts/channels/${SLUG}/config.json`, - )).dataDir, + ( + await readJson<{ dataDir?: string }>( + `test-transcripts/channels/${SLUG}/config.json`, + ) + ).dataDir, ).toBe(undefined); const back = await page.request.get( `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/transcript.en.vtt`, @@ -212,7 +223,9 @@ test("the /channels bulk move queues one job per channel and skips the rest", as test.setTimeout(120_000); await resetData("one-youtube-channel-with-data"); const root = testInfo.outputPath("bulk-root"); + const warm = testInfo.outputPath("warm-root"); await mkdir(root, { recursive: true }); + await mkdir(warm, { recursive: true }); // THE DEFAULT ROOT, set the way the operator does. The bulk bar's box is // seeded from it, so this is also what asserts the settings value reaches the // client. The other four keys mirror fixtures/test-settings.default.json, @@ -223,13 +236,16 @@ test("the /channels bulk move queues one job per channel and skips the rest", as minFreeDiskGB: 0, verifyAvailabilityBeforeClean: false, syncScheduler: { fullSweepIntervalMinutes: 0 }, - // ONE LOCATION, and it is the default — the shape the old single - // `mediaRoot` string migrates into. `defaultLocationRoot` is what the - // Storage panel and the bulk bar read, so this is still what asserts the - // settings value reaches the client. + // TWO LOCATIONS, and the default is NOT the one this spec moves to. The + // deck opens on `warm` and the spec picks `cold` by name, which is what + // asserts the select's value reaches the action — the old single-location + // fixture could not tell "the id was sent" from "the default was used". storage: { - locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }], - defaultLocationId: "cold", + locations: [ + { id: "warm", label: "Warm", root: warm, autoRepoint: false }, + { id: "cold", label: "Cold", root, autoRepoint: false }, + ], + defaultLocationId: "warm", }, }); @@ -241,21 +257,30 @@ test("the /channels bulk move queues one job per channel and skips the rest", as "WEBVTT\n\n00:00.000 --> 00:01.000\nhello\n", ); await writeChannelConfig(PRE, { dataDir: preTarget }); - await symlink(preTarget, resolvePath(`test-transcripts/channels/${PRE}/data`)); + await symlink( + preTarget, + resolvePath(`test-transcripts/channels/${PRE}/data`), + ); await page.goto("/channels"); // One badge before the move: the channel that is already on the root. const badges = page.getByLabel(/^media location: Media relocated/); await expect(badges).toHaveCount(1); - // The bar only exists with a selection — it is a selection bar, and a root box - // with nothing to apply it to is a control that cannot do anything. - await expect(page.getByLabel("bulk media root")).toHaveCount(0); + // The deck only exists with a selection — it is a selection deck, and a + // destination with nothing to apply it to is a control that cannot do + // anything. + const destination = page.getByLabel("bulk media location"); + await expect(destination).toHaveCount(0); await page.getByLabel("select all channels").check(); await expect(page.getByLabel(`select ${SLUG}`)).toBeChecked(); await expect(page.getByLabel(`select ${PRE}`)).toBeChecked(); - // And it carries the configured root with no typing. - await expect(page.getByLabel("bulk media root")).toHaveValue(root); + // It opens on the DEFAULT location, and names every configured one. No root + // is typed anywhere in this test: the deck sends an id. + await expect(destination).toHaveValue("warm"); + await expect(page.getByLabel("bulk media destination")).toHaveText(warm); + await destination.selectOption("cold"); + await expect(page.getByLabel("bulk media destination")).toHaveText(root); await page.getByLabel("move media for selected channels").click(); const result = page.getByLabel("bulk media move result"); @@ -279,13 +304,21 @@ test("the /channels bulk move queues one job per channel and skips the rest", as { timeout: 90_000 }, ) .toBe(2); + // AND THE BADGE NAMES THE PLACE. Both channels are under the `cold` root, so + // the row says the operator's own word for that drive rather than a 60-column + // absolute path nobody reads. The accessible name still carries the full + // sentence, which is what the locator above matches on. + await expect(badges.first()).toHaveText("on Cold"); + await expect(badges.nth(1)).toHaveText("on Cold"); // The moved channel: config records the target under the tmp root, `data/` is // a link, and the videos list still lists the video through it. expect( - (await readJson<{ dataDir?: string }>( - `test-transcripts/channels/${SLUG}/config.json`, - )).dataDir, + ( + await readJson<{ dataDir?: string }>( + `test-transcripts/channels/${SLUG}/config.json`, + ) + ).dataDir, ).toBe(join(root, SLUG, "data")); expect((await lstat(dataDir())).isSymbolicLink()).toBe(true); await page.goto(channelVideos(SLUG)); @@ -293,13 +326,16 @@ test("the /channels bulk move queues one job per channel and skips the rest", as // The skipped channel was not touched: same target, still a link, no marker. expect( - (await readJson<{ dataDir?: string }>( - `test-transcripts/channels/${PRE}/config.json`, - )).dataDir, + ( + await readJson<{ dataDir?: string }>( + `test-transcripts/channels/${PRE}/config.json`, + ) + ).dataDir, ).toBe(preTarget); expect( - (await lstat(resolvePath(`test-transcripts/channels/${PRE}/data`))) - .isSymbolicLink(), + ( + await lstat(resolvePath(`test-transcripts/channels/${PRE}/data`)) + ).isSymbolicLink(), ).toBe(true); expect( await pathExists(`test-transcripts/channels/${PRE}/.relocating.json`), @@ -346,7 +382,9 @@ test("a bulk move puts every job on one queue and skips a channel with nothing t url: "https://www.youtube.com/@second/videos", }); await mkdir( - resolvePath(`test-transcripts/channels/${SECOND}/data/20240103_second12345`), + resolvePath( + `test-transcripts/channels/${SECOND}/data/20240103_second12345`, + ), { recursive: true }, ); await writeFile( @@ -401,3 +439,155 @@ test("a bulk move puts every job on one queue and skips a channel with nothing t await expect(rows.nth(i)).not.toContainText("channel:"); } }); + +// THE DESTINATION IS A NAME NOW, not a path retyped per channel. +// +// Two locations are configured and the DEFAULT IS NOT THE ONE THIS SPEC MOVES +// TO, which is the whole point: the panel opens on `warm`, the spec picks +// `cold` by name, and what reaches the server is the ID — the root is looked up +// there, from the same settings.json /storage writes. A fixture with one +// location could not tell "the id was sent" from "the default was used". +// +// The free-text box is GONE while a location is picked. It is one option away +// ("Another root…") and it is the whole control on a corpus with no locations +// configured, which is what the first test in this file exercises. +test("the Storage panel moves to a location picked by name", async ({ + page, +}, testInfo) => { + test.setTimeout(90_000); + await resetData("one-youtube-channel-with-data"); + const warm = testInfo.outputPath("warm-root"); + const cold = testInfo.outputPath("cold-root"); + await mkdir(warm, { recursive: true }); + await mkdir(cold, { recursive: true }); + await writeSettings({ + adminTitle: "Test Admin", + minFreeDiskGB: 0, + storage: { + locations: [ + { id: "warm", label: "Warm", root: warm, autoRepoint: false }, + { id: "cold", label: "Cold", root: cold, autoRepoint: false }, + ], + defaultLocationId: "warm", + }, + }); + await generateReport(page, SLUG); + await quiet(page); + + await page.goto(channelStage(SLUG, "storage")); + const destination = page.getByLabel("destination location"); + await expect(destination).toHaveValue("warm"); + // No root box while a location is picked — there is nothing to type. + await expect(page.getByLabel("destination root")).toHaveCount(0); + // Both names are offered, with each drive's state from the server-side probe. + await expect(destination).toContainText("Warm"); + await expect(destination).toContainText("Cold"); + + await destination.selectOption("cold"); + const target = join(cold, SLUG, "data"); + await page.getByRole("button", { name: "Preview" }).click(); + const preview = page.getByLabel("relocation preview"); + await expect(preview).toBeVisible({ timeout: 15_000 }); + // The preview names the target the SERVER resolved from the id. + await expect(preview).toContainText(target); + + const moveButton = page.getByRole("button", { name: "Move media" }); + await expect(moveButton).toBeEnabled(); + await moveButton.click(); + await expect(page.getByLabel("Move media output")).toContainText("Moved", { + timeout: 60_000, + }); + expect( + ( + await readJson<{ dataDir?: string }>( + `test-transcripts/channels/${SLUG}/config.json`, + ) + ).dataDir, + ).toBe(target); + + // AND THE BADGE READS THE NAME. `on Cold`, not sixty columns of absolute + // path — while the accessible name keeps the full sentence, which is what + // every other assertion in this file matches on. + await page.goto("/channels"); + const badge = page.getByLabel(/^media location: Media relocated/); + await expect(badge).toHaveText("on Cold"); + await expect(badge).toHaveAttribute("aria-label", new RegExp(`on Cold$`)); +}); + +// FINISHING AN INTERRUPTED MOVE, rather than throwing away what already copied. +// +// `relocateChannelMedia` has always resumed a same-direction marker; nothing in +// the editor could reach it, so the only offered way out of a killed copy was +// Clear marker and a full re-copy — for the omnimirror incident, 131 GB already +// correctly on the far side. +// +// The state is BUILT ON DISK rather than produced by killing a real run: a +// marker at phase "copy" plus a partial copy at the target is exactly what a +// killed rsync leaves, and it is three filesystem calls instead of a job that +// has to be interrupted at the right instant. What is under test is the resume, +// not the interruption. +test("Resume move finishes an interrupted move and clears its marker", async ({ + page, +}, testInfo) => { + test.setTimeout(90_000); + await resetData("one-youtube-channel-with-data"); + const root = testInfo.outputPath("resume-root"); + const target = join(root, SLUG, "data"); + // The partial copy: the video dir is there with ONE of its two files. + await mkdir(join(target, VIDEO), { recursive: true }); + await copyFile( + resolvePath( + `test-transcripts/channels/${SLUG}/data/${VIDEO}/transcript.en.vtt`, + ), + join(target, VIDEO, "transcript.en.vtt"), + ); + await writeFile( + resolvePath(`test-transcripts/channels/${SLUG}/.relocating.json`), + JSON.stringify( + { + target, + direction: "out", + startedAt: new Date().toISOString(), + phase: "copy", + }, + null, + 2, + ), + ); + + await page.goto(channelStage(SLUG, "storage")); + // In transition, with both escapes offered and neither move available. + await expect(page.getByLabel(/^media location: Media moving/)).toBeVisible(); + await expect( + page.getByRole("button", { name: "Clear marker" }), + ).toBeVisible(); + const resume = page.getByRole("button", { name: "Resume move" }); + await expect(resume).toBeEnabled(); + await resume.click(); + + // THE CONTROLLER SAYS SO ITSELF. The root came from the marker's target, not + // from any form on the page, and the job's summary line is where "this was a + // continuation" is stated. + await expect(page.getByLabel("Resume move output")).toContainText( + "resumed an interrupted move", + { timeout: 60_000 }, + ); + + // Finished like any other move: link, config, no marker. + expect( + await pathExists(`test-transcripts/channels/${SLUG}/.relocating.json`), + ).toBe(false); + expect((await lstat(dataDir())).isSymbolicLink()).toBe(true); + expect( + ( + await readJson<{ dataDir?: string }>( + `test-transcripts/channels/${SLUG}/config.json`, + ) + ).dataDir, + ).toBe(target); + // And the bytes are readable through the same URL as ever. + const file = await page.request.get( + `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/transcript.en.vtt`, + ); + expect(file.status()).toBe(200); +});