import { mkdir, writeFile } from "node:fs/promises"; import { test, expect, type Page } from "@playwright/test"; import { baseUrl } from "./baseUrl"; import { channelStage, generateReport, readJson, resetData, resolvePath, writeChannelConfig, writeSite, } from "./helpers"; // Renaming a channel's slug lives in the Danger zone and requires typing the // current slug to confirm (mirroring delete). On success it migrates slug-keyed // state (here: a site membership) and redirects to the new URL; the old URL 404s. const OLD = "test-youtube"; const NEW = "renamed-youtube"; test("rename requires the exact current slug and then moves the channel", async ({ page, }) => { await resetData("one-youtube-channel"); // A site that lists the channel — its membership must follow the rename. await writeSite("mysite", { channels: [{ slug: OLD }] }); // Deliberately NO generateReport here: it queues a refresh-report job, and // renaming refuses while any job for the channel is active. This test doesn't // need a report — the channel page renders fine without one (empty buckets // plus a banner), which is the whole point of not generating inside a GET. await page.goto(channelStage(OLD, "danger")); const newSlug = page.getByLabel("new slug"); const confirm = page.getByLabel("confirm current slug"); const submit = page.getByRole("button", { name: "Rename channel" }); // Wrong confirmation is rejected and stays put. await newSlug.fill(NEW); await confirm.fill("not-the-slug"); await submit.click(); await expect(page.getByText(/Type the channel slug/)).toBeVisible(); // Still on the old channel (a trailing ?site= query may be appended). await expect(page).toHaveURL(new RegExp(`/channels/${OLD}(\\?|$)`)); // Correct confirmation renames and redirects to the new URL. Refill both // fields — React 19 resets the
inputs after the failed submit. await newSlug.fill(NEW); await confirm.fill(OLD); await submit.click(); await expect(page).toHaveURL(new RegExp(`/channels/${NEW}`)); // The old URL now 404s. const resp = await page.goto(`/channels/${OLD}`); expect(resp?.status()).toBe(404); // The site membership was retargeted to the new slug. const site = await readJson<{ channels: { slug: string }[] }>( "test-transcripts/sites/mysite/site.json", ); expect(site.channels.map((c) => c.slug)).toEqual([NEW]); }); // A SOCIAL CHANNEL HAS THE SAME DANGER ZONE. Its page returns before the stage // list a video channel gets, so it used to have no Rename at all — a misspelled // X channel could only be renamed by hand. The zone is collapsed under the posts // panel and opens on the same `?stage=danger` link. test("a social channel renames from its Danger zone and keeps its posts", async ({ page, }) => { await resetData("empty"); const OLD_X = "misspeled-x"; const NEW_X = "misspelled-x"; await writeChannelConfig(OLD_X, { handling: "transcribe", sourceKind: "social", platform: "twitter", postFetcher: "x-gallery-dl", socialHandle: "faketester", name: "Fake Tester (X)", url: "https://x.com/faketester", }); const posts = resolvePath(`test-transcripts/channels/${OLD_X}/posts`); await mkdir(posts, { recursive: true }); await writeFile( `${posts}/2026-05.jsonl`, JSON.stringify({ id: "1001", slug: `${OLD_X}/1001`, channelSlug: OLD_X, author: "faketester", createdAt: "2026-05-01T00:00:00.000Z", uploadDate: "20260501", text: "a post", url: "https://x.com/faketester/status/1001", platform: "x", isReply: false, isRepost: false, links: [], }) + "\n", ); // Collapsed on a plain visit, open on the stage link. await page.goto(`/channels/${OLD_X}`); await expect(page.getByText("Danger zone")).toBeVisible(); await expect(page.getByRole("button", { name: "Rename channel" })).toBeHidden(); await page.goto(channelStage(OLD_X, "danger")); await page.getByLabel("new slug").fill(NEW_X); await page.getByLabel("confirm current slug").fill(OLD_X); await page.getByRole("button", { name: "Rename channel" }).click(); await expect(page).toHaveURL(new RegExp(`/channels/${NEW_X}`)); // Still a social channel, with its one post. await expect(page.locator("[data-social-panel]")).toContainText("Archived posts"); await expect(page.locator("[data-social-panel]")).toContainText("1"); expect((await page.request.get(`${baseUrl}/channels/${OLD_X}`)).status()).toBe(404); }); // RENAMING AND DELETING ARE REFUSED WHILE SOMETHING IS WRITING INTO data/. // // The rename always asked, but it asked the JOB REGISTRY alone — and the // registry is half the truth: the auto-queue lanes run their per-video units // in-process and make no job record (lib/mediaBusy.ts, the omnimirror // incident). Delete asked nothing at all, which is the worse of the two: a // rename that orphans a registry entry is a nuisance, a delete racing a // download for the tree loses bytes. // // Both now ask `channelMediaBusyReason`, the one question the Storage panel and // the bulk move ask, and both say the same sentence with their own verb. // // THE JOB IS THE HALF AN E2E CAN STAGE. A lane unit is in flight for // milliseconds at a time and cannot be held there from a browser; the fake // yt-dlp's `--test-slow` holds a real job for 30 s, and it is the same registry // read either way. const SLOW = "slow-channel"; async function running(page: Page, slug: string): Promise { await expect .poll( async () => { const res = await page.request.get(`${baseUrl}/api/jobs/active`); const body = await res.json(); const jobs: { channelSlug?: string; status: string }[] = Array.isArray( body, ) ? body : (body.jobs ?? []); return jobs.filter( (j) => j.channelSlug === slug && (j.status === "running" || j.status === "queued"), ).length; }, { timeout: 30_000 }, ) .toBeGreaterThan(0); } test("rename and delete refuse while a job is writing into the channel", async ({ page, }) => { test.setTimeout(120_000); await resetData("slow-pipeline-channel"); await generateReport(page, SLOW); // Rendered while the channel is QUIET, so both forms come back live. The // point of the next few lines is that the SERVER refuses — a page that // already knows would prove only that the button can be greyed out. await expect .poll( async () => { const res = await page.request.get(`${baseUrl}/api/jobs/active`); const body = await res.json(); const jobs: { channelSlug?: string; status: string }[] = Array.isArray( body, ) ? body : (body.jobs ?? []); return jobs.filter( (j) => j.channelSlug === SLOW && (j.status === "running" || j.status === "queued"), ).length; }, { timeout: 30_000 }, ) .toBe(0); await page.goto(channelStage(SLOW, "danger")); await expect( page.getByRole("button", { name: "Rename channel" }), ).toBeEnabled(); // Out of band, so this page's forms are the ones rendered before it started. const started = await page.request.post(`${baseUrl}/api/ops/sync`, { headers: { authorization: "Bearer test-worker-token" }, data: { slug: SLOW }, }); expect(started.ok()).toBe(true); await running(page, SLOW); // --- the server refuses, in the sentence every other surface says --------- await page.getByLabel("new slug").fill("renamed-slow"); await page.getByLabel("confirm current slug").fill(SLOW); await page.getByRole("button", { name: "Rename channel" }).click(); // `.first()`: the refusal is rendered by the form's own error paragraph, and // a re-render would put the same sentence in the status above it too. await expect(page.getByText(/before renaming it\./).first()).toBeVisible(); await expect(page).toHaveURL(new RegExp(`/channels/${SLOW}(\\?|$)`)); await page.getByLabel("confirm slug to delete").fill(SLOW); await page.getByRole("button", { name: "Delete channel" }).click(); await expect(page.getByText(/before deleting it\./).first()).toBeVisible(); // Nothing was deleted: the channel page still exists. expect( (await page.request.get(`${baseUrl}/channels/${SLOW}`)).status(), ).toBe(200); // --- and a fresh render says so before either form is filled in ----------- await page.goto(channelStage(SLOW, "danger")); await expect(page.getByLabel("rename blocked")).toContainText( /before renaming it\./, ); await expect(page.getByLabel("delete blocked")).toContainText( /before deleting it\./, ); await expect( page.getByRole("button", { name: "Rename channel" }), ).toBeDisabled(); await expect( page.getByRole("button", { name: "Delete channel" }), ).toBeDisabled(); });