commit 09a21743f1c24b6497ff4ed23a0eeec9510cfb23
parent 784060a03e6d15d4f31f878f0b8edcf055aab153
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 12:18:13 -0400
e2e: two channels selected, one moves, and the other says why it did not
The bulk case, in the spec that already owns the single move. The cold root goes
in through Settings, which is also what asserts the value reaches the client: the
bar's box carries it with nothing typed.
THE SKIPPED CHANNEL IS BUILT ON DISK, not by running a second relocation first —
an absolute `data` symlink plus `config.dataDir`, which is exactly what a
finished move leaves. A real move would cost a second rsync, a second job wait
and a second 60s timeout to assert a skip that is decided before a byte is read.
THE HAPPENS-BEFORE EDGE IS THE BADGE. The action returns when the jobs are
ENQUEUED, so "Queued 1" is not permission to read config.json. The row's badge
comes from inspectChannelMedia on the server and cannot appear before the swap
has committed, so waiting for the second badge is waiting for the job through the
surface an operator actually watches. Then the videos list, which is the claim
the whole design rests on, reached this time by the bulk route. The skipped
channel is asserted untouched — same target, still a link, no marker — because
"skipped" has to mean nothing happened, not "failed quietly".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
1 file changed, 113 insertions(+), 1 deletion(-)
diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts
@@ -1,4 +1,4 @@
-import { lstat, mkdir, readdir } from "node:fs/promises";
+import { 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";
@@ -10,6 +10,8 @@ import {
readJson,
resetData,
resolvePath,
+ writeChannelConfig,
+ writeSettings,
} from "./helpers";
// MOVING A CHANNEL'S MEDIA TO ANOTHER DIRECTORY, AND BACK.
@@ -186,3 +188,113 @@ test("the Configure form shows the media location read-only", async ({
// with a keystroke.
expect(await line.evaluate((el) => el.tagName)).not.toBe("INPUT");
});
+
+// THE BULK MOVE, from /channels, with the cold root coming out of Settings.
+//
+// Two channels are selected and exactly one moves. The other is a channel that
+// is ALREADY relocated, built directly on disk — an absolute `data` symlink plus
+// `config.dataDir`, which is precisely what a finished move leaves behind —
+// rather than by running a second relocation first. A real move here would cost
+// a second rsync, a second job wait and a second 60s timeout to assert a skip
+// that is decided before any byte is read; what is under test is the skip, and
+// the state it keys off is three filesystem calls to produce.
+//
+// THE HAPPENS-BEFORE EDGE IS THE BADGE. The bulk action returns when the jobs
+// are ENQUEUED, not when they finish, so "Queued 1" is not permission to read
+// config.json. The row's badge is rendered from inspectChannelMedia on the
+// server, so it cannot appear before the swap has committed — waiting for it is
+// waiting for the job, through the same surface an operator watches. The videos
+// list afterwards is the claim the whole design rests on, checked once more from
+// a channel that got there by a different route.
+test("the /channels bulk move queues one job per channel and skips the rest", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(120_000);
+ await resetData("one-youtube-channel-with-data");
+ const root = testInfo.outputPath("bulk-root");
+ await mkdir(root, { 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,
+ // which writeSettings replaces wholesale — notably minFreeDiskGB 0, so the
+ // move is not charged the resume margin on a nearly-full disk.
+ await writeSettings({
+ adminTitle: "Test Admin",
+ minFreeDiskGB: 0,
+ verifyAvailabilityBeforeClean: false,
+ syncScheduler: { fullSweepIntervalMinutes: 0 },
+ storage: { mediaRoot: root },
+ });
+
+ const PRE = "pre-moved";
+ const preTarget = join(root, PRE, "data");
+ await mkdir(join(preTarget, "20240102_pre1234567"), { recursive: true });
+ await writeFile(
+ join(preTarget, "20240102_pre1234567", "transcript.en.vtt"),
+ "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 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);
+ 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);
+
+ await page.getByLabel("move media for selected channels").click();
+ const result = page.getByLabel("bulk media move result");
+ await expect(result).toContainText("Queued 1 · skipped 1", {
+ timeout: 30_000,
+ });
+ // The skip names the channel and why — the same title-attribute shape the
+ // "Sync every channel" result uses for its own skips.
+ await expect(result).toHaveAttribute(
+ "title",
+ `${PRE}: already relocated to ${preTarget}`,
+ );
+
+ // Wait for the job through the badge: two relocated channels, not one.
+ await expect
+ .poll(
+ async () => {
+ await page.reload();
+ return badges.count();
+ },
+ { timeout: 90_000 },
+ )
+ .toBe(2);
+
+ // 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,
+ ).toBe(join(root, SLUG, "data"));
+ expect((await lstat(dataDir())).isSymbolicLink()).toBe(true);
+ await page.goto(channelVideos(SLUG));
+ await expect(page.getByText(VIDEO).first()).toBeVisible();
+
+ // 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,
+ ).toBe(preTarget);
+ expect(
+ (await lstat(resolvePath(`test-transcripts/channels/${PRE}/data`)))
+ .isSymbolicLink(),
+ ).toBe(true);
+ expect(
+ await pathExists(`test-transcripts/channels/${PRE}/.relocating.json`),
+ ).toBe(false);
+});