import { test, expect } from "@playwright/test"; import { readJson, resetData, writeSettings, writeSite } from "./helpers"; // THE /channels PRIORITY CONTROLS, asserted against what lands on disk. // // Every one of these gestures goes through `saveChannelPriorityAction`, the one // writer of `settings.channelPriority` — so what a row shows and what // settings.json says cannot drift, and each test checks BOTH: the persisted // document, and the row after a reload (which remounts the control and re-seeds // it from the server). // // The compiled trees are checked too, because the recompile is half of what the // writer is for. A focus is a `prio-focus` group at the head of every lane's // strict root; ending the focus removes it. Nothing else in dispatch is touched. type Settings = { channelPriority?: { focus: { kind: string; siteId?: string; slugs?: string[] }; channels: Record< string, { tier: string; rank?: number; overrides?: Record; } >; }; autoQueue?: Record< string, { root: { children: { id: string; children?: { id: string }[] }[] } } >; }; const settings = () => readJson("test-settings.json"); // Every channel in this fixture, and the pool view: with exactly one site // configured /channels scopes to it, and the focus-site test needs a site whose // membership is a strict subset of the pool. const ALL = "/channels?site=__all__"; test("a row's tier select writes the priority document and survives a reload", async ({ page, }) => { await resetData("two-slow-channels"); await page.goto(ALL); const tierA = page.getByLabel("tier for slow-a", { exact: true }); await expect(tierA).toHaveValue("normal"); await tierA.selectOption("low"); await expect .poll(async () => (await settings()).channelPriority?.channels) .toMatchObject({ "slow-a": { tier: "low" } }); // Only the channel that was touched appears: the document is a list of // exceptions, and an entry equal to the default is dropped by the sanitizer. { const s = await settings(); expect(Object.keys(s.channelPriority?.channels ?? {})).toEqual(["slow-a"]); } await page.reload(); await expect(page.getByLabel("tier for slow-a", { exact: true })).toHaveValue( "low", ); await expect(page.getByLabel("tier for slow-b", { exact: true })).toHaveValue( "normal", ); // The compiled tree followed it in the same save: slow-a is in the low group, // slow-b in the normal group, and the catch-all is last. const root = (await settings()).autoQueue?.download.root; expect(root?.children.map((c) => c.id)).toEqual([ "prio-normal", "prio-low", "prio-all", ]); expect( root?.children.find((c) => c.id === "prio-low")?.children?.map((c) => c.id), ).toEqual(["prio-low-slow-a"]); }); test("Advanced pins one operation, and the pin is what differs from the base", async ({ page, }) => { await resetData("two-slow-channels"); await page.goto(ALL); await page.getByLabel("advanced priority for slow-a").click(); await page .getByLabel("download override for slow-a") .selectOption("paused"); await expect .poll(async () => (await settings()).channelPriority?.channels["slow-a"]) .toEqual({ tier: "normal", overrides: { download: "paused" } }); await page.reload(); await page.getByLabel("advanced priority for slow-a").click(); await expect(page.getByLabel("download override for slow-a")).toHaveValue( "paused", ); // The base tier is unmoved — a pin is a per-operation fact, not a row one. await expect(page.getByLabel("tier for slow-a", { exact: true })).toHaveValue( "normal", ); // ONE lane's tree moved, and only one: slow-a is gone from download and still // in normal everywhere else. const s = await settings(); const normalOf = (lane: string) => s.autoQueue?.[lane].root.children .find((c) => c.id === "prio-normal") ?.children?.map((c) => c.id); expect(normalOf("download")).toEqual(["prio-normal-slow-b"]); expect(normalOf("transcription")).toEqual([ "prio-normal-slow-a", "prio-normal-slow-b", ]); }); test("the Sync only preset is paused everywhere with sync pinned back", async ({ page, }) => { await resetData("two-slow-channels"); await page.goto(ALL); await page.getByLabel("advanced priority for slow-a").click(); await page.getByLabel("sync only for slow-a").click(); await expect .poll(async () => (await settings()).channelPriority?.channels["slow-a"]) .toEqual({ tier: "paused", overrides: { sync: "normal" } }); await page.reload(); await expect(page.getByLabel("tier for slow-a", { exact: true })).toHaveValue( "paused", ); // A paused channel has no leaf on any lane — paused is the one thing the tree // cannot express, so it is removed from the compiled membership entirely. const s = await settings(); for (const lane of ["download", "transcription", "digest", "backfill"]) { const ids = JSON.stringify(s.autoQueue?.[lane].root); expect(ids).not.toContain("slow-a"); } }); // THE ADVANCED MENU HAS TO ESCAPE ITS OWN ROW. // // A row is dimmed when it is excluded from the build or its base tier is // Paused, and the dim used to sit on the `` as `opacity-60`. Opacity below // 1 makes the row a stacking context painted as one group, so the panel's // `absolute z-30` no longer lifts it out of the row: the NEXT dimmed row is a // later group at the same level and paints straight over the menu (operator // report 2026-09-17, on a corpus where paused rows sit next to each other). // Two paused rows in a row is the smallest shape that reproduces it; one alone // does not, because an opacity group still paints above the plain rows below. test("a paused row's Advanced menu draws above the paused row below it", async ({ page, }) => { await resetData("two-slow-channels"); await page.goto(ALL); // slow-a is the FIRST row and slow-b the one its panel hangs over. The // assertion is meaningless on the last row of the table, which has nothing // after it to be covered by. const slugs = await page .locator('[aria-label^="select slow-"]') .evaluateAll((els) => els.map((el) => el.getAttribute("aria-label")!.replace("select ", "")), ); expect(slugs).toEqual(["slow-a", "slow-b"]); await page.getByLabel("select all channels").check(); await page.getByLabel("bulk tier").selectOption("paused"); await page .getByLabel("channel priority bulk") .getByRole("button", { name: "Apply tier" }) .click(); await expect .poll(async () => (await settings()).channelPriority?.channels) .toEqual({ "slow-a": { tier: "paused" }, "slow-b": { tier: "paused" } }); // Reloaded, so both rows render dimmed from the server's own tiers rather // than from the controls' optimistic state. await page.reload(); await page.getByLabel("advanced priority for slow-a").click(); // `sync` is the first control in the panel, the part that hangs over slow-b. const override = page.getByLabel("sync override for slow-a"); await expect(override).toBeInViewport(); // THE ASSERTION. `selectOption` never hit-tests, so it passes straight // through a row painted on top of the panel; this asks the page directly who // owns the pixel. With `opacity-60` back on the `` the answer is a cell // of the paused slow-b row below. const owner = await override.evaluate((el) => { const r = el.getBoundingClientRect(); const top = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); return top === el || el.contains(top) ? "the override" : `${top?.tagName ?? "nothing"} ${top?.getAttribute("aria-label") ?? top?.textContent?.trim().slice(0, 40) ?? ""}`; }); expect(owner).toBe("the override"); await override.selectOption("normal"); await expect .poll(async () => (await settings()).channelPriority?.channels["slow-a"]) .toEqual({ tier: "paused", overrides: { sync: "normal" } }); }); // MOVED HERE FROM channel-sync-toggle.spec.ts, which this slice deletes with the // control it drove. The assertion is the same one — a pool sweep skips the // excluded channel and names it in the tooltip — restated against the tier the // flag became. `syncAllChannelsAction` asks the document for the `sync` // operation, so the manual sweep and the group Sync buttons agree. test("Sync all skips a channel paused for sync and says which", async ({ page, }) => { await resetData("two-slow-channels"); await page.goto(ALL); await page.getByLabel("tier for slow-a", { exact: true }).selectOption("paused"); await expect .poll(async () => (await settings()).channelPriority?.channels["slow-a"]) .toEqual({ tier: "paused" }); await page.getByRole("button", { name: "sync every channel" }).click(); const result = page.getByLabel("sync all result"); await expect(result).toContainText(/Queued 1 . skipped 1/, { timeout: 10_000, }); await expect(result).toHaveAttribute("title", /slow-a: paused for sync/); }); test("Focus site holds the rest, and End focus releases them", async ({ page, }) => { await resetData("two-slow-channels"); // A site whose membership is slow-a alone, so "focus this site" has something // to resolve and something to hold. await writeSite("focusable", { siteTitle: "Focusable", channels: [{ slug: "slow-a", groupId: "default" }], }); await page.goto(ALL); const bar = page.getByLabel("channel focus"); await expect(bar).toContainText("No focus"); await bar.getByLabel("focus site").selectOption("focusable"); await bar.getByRole("button", { name: "Focus site" }).click(); await expect .poll(async () => (await settings()).channelPriority?.focus) .toEqual({ kind: "site", siteId: "focusable" }); await page.reload(); await expect(page.getByLabel("channel focus")).toContainText( "Focus: site Focusable", ); // The focused row says so; the one it holds says why. await expect(page.getByTestId("focused-slow-a")).toBeVisible(); await expect(page.getByLabel("held reason for slow-b")).toContainText( "Held — focus: site Focusable", ); await expect(page.getByLabel("held reason for slow-a")).toHaveCount(0); // The focus group is FIRST in every lane's strict root, which is what makes // it hold: `pick()` descends into the first child that has work. { const s = await settings(); for (const lane of ["download", "transcription", "digest", "backfill"]) { const children = s.autoQueue?.[lane].root.children.map((c) => c.id); expect(children).toEqual(["prio-focus", "prio-normal", "prio-all"]); expect( s.autoQueue?.[lane].root.children[0].children?.map((c) => c.id), ).toEqual(["prio-focus-slow-a"]); } } await page .getByLabel("channel focus") .getByRole("button", { name: "End focus" }) .click(); await expect .poll(async () => (await settings()).channelPriority?.focus) .toEqual({ kind: "none" }); await page.reload(); await expect(page.getByLabel("channel focus")).toContainText("No focus"); await expect(page.getByLabel("held reason for slow-b")).toHaveCount(0); { const children = ( await settings() ).autoQueue?.download.root.children.map((c) => c.id); expect(children).toEqual(["prio-normal", "prio-all"]); } }); test("a row selection focuses those channels and bulk-sets their tier", async ({ page, }) => { await resetData("two-slow-channels"); await page.goto(ALL); // No selection, no bulk bar. await expect(page.getByLabel("channel priority bulk")).toHaveCount(0); await page.getByLabel("select slow-a").check(); const bulk = page.getByLabel("channel priority bulk"); await expect(bulk).toContainText("1 selected"); await bulk.getByRole("button", { name: "Focus these" }).click(); await expect .poll(async () => (await settings()).channelPriority?.focus) .toEqual({ kind: "channels", slugs: ["slow-a"] }); // Applying a tier to both rows at once writes both entries in one save. // // The focus save above triggers a refresh of the rows; a tick that lands while // that re-render is in flight is dropped (seen once under a loaded full suite: // "Clicking the checkbox did not change its state"). Selecting all is // idempotent, so retry until the checkbox reports it. const selectAll = page.getByLabel("select all channels"); await expect .poll( async () => { await selectAll.check({ timeout: 5_000 }).catch(() => {}); return selectAll.isChecked(); }, { timeout: 30_000, intervals: [250, 500, 1_000, 1_000, 2_000] }, ) .toBe(true); await page.getByLabel("bulk tier").selectOption("low"); await page .getByLabel("channel priority bulk") .getByRole("button", { name: "Apply tier" }) .click(); await expect .poll(async () => (await settings()).channelPriority?.channels) .toEqual({ "slow-a": { tier: "low" }, "slow-b": { tier: "low" } }); await page.reload(); await expect(page.getByLabel("tier for slow-a", { exact: true })).toHaveValue( "low", ); await expect(page.getByLabel("tier for slow-b", { exact: true })).toHaveValue( "low", ); // A focused channel is still focused whatever its stored tier says: focus is // a compiled POSITION, and it wins over the tier. const children = (await settings()).autoQueue?.download.root.children.map( (c) => c.id, ); expect(children).toEqual(["prio-focus", "prio-low", "prio-all"]); }); // --- THE FIRST CLICK ON A CORPUS THAT HAS NEVER SET A PRIORITY -------------- // // The hazard the seed exists for (the S2/S3 review, findings 3 and 8). The // dispatched tree is all-or-nothing on the document: the moment it says // ANYTHING, the stored trees stop being dispatched from and compiled ones take // over. A hand-made lane order lives ONLY in those stored trees, so the first // click of a tier — on a corpus whose operator never ran the migration — // would compile a tree in which nothing has a rank, and the order would be // gone with the trees it lived in. // // So the writer seeds from `channelPriorityFromLegacy` when the stored // document says nothing AND the stored trees are not already compiled. The // fixture below is the live shape in miniature: a hand-made order that // DISAGREES with alphabetical, which is the only way to tell a preserved order // from a re-derived one. const LEGACY_ROOT = (lane: string) => ({ id: `${lane}-root`, mode: "strict", children: [ { id: `${lane}-1`, match: { type: "channel", value: "slow-b" } }, { id: `${lane}-2`, match: { type: "channel", value: "slow-a" } }, { id: `${lane}-all`, match: { type: "all" } }, ], }); test("the first tier click seeds from the legacy trees and keeps their order", async ({ page, }) => { await resetData("two-slow-channels"); // A hand-made order, slow-b ahead of slow-a, in both ranked lanes — and no // channelPriority document at all, which is every corpus before the // migration script runs. await writeSettings({ adminTitle: "Test Admin", minFreeDiskGB: 0, syncScheduler: { fullSweepIntervalMinutes: 0 }, autoQueue: { transcription: { root: LEGACY_ROOT("transcription") }, download: { root: LEGACY_ROOT("download") }, }, }); await page.goto(ALL); expect((await settings()).channelPriority?.channels ?? {}).toEqual({}); // One click, on one channel. Everything else about the corpus is untouched. await page.getByLabel("tier for slow-a", { exact: true }).selectOption("low"); await expect .poll(async () => (await settings()).channelPriority?.channels) .toEqual({ // THE ORDER SURVIVED. slow-b was first in the hand-made lists and is // rank 0; slow-a is rank 1. Re-derived from nothing — or read back out // of a compiled tree — both channels would be unranked and the compiler // would fall through to slug order, putting slow-a first. "slow-b": { tier: "normal", rank: 0 }, "slow-a": { tier: "low", rank: 1 }, }); // And the click itself still landed: slow-a is in the low group. { const root = (await settings()).autoQueue?.download.root; expect(root?.children.map((c) => c.id)).toEqual([ "prio-normal", "prio-low", "prio-all", ]); expect( root?.children .find((c) => c.id === "prio-low") ?.children?.map((c) => c.id), ).toEqual(["prio-low-slow-a"]); } // THE SEED IS ONCE. The trees are compiled now, so a second edit must not // re-read them as legacy — which would rank the channels by their position // in the compiled tree rather than by the order that produced it. await page.reload(); await page.getByLabel("tier for slow-a", { exact: true }).selectOption("normal"); await expect .poll(async () => (await settings()).channelPriority?.channels) .toEqual({ "slow-b": { tier: "normal", rank: 0 }, "slow-a": { tier: "normal", rank: 1 }, }); { const normal = (await settings()).autoQueue?.download.root.children.find( (c) => c.id === "prio-normal", ); // Rank order, not slug order. expect(normal?.children?.map((c) => c.id)).toEqual([ "prio-normal-slow-b", "prio-normal-slow-a", ]); } }); // --- A RENAME MOVES THE DOCUMENT WITH THE CHANNEL -------------------------- // // The model keys everything by slug, so a rename that does not pass through // the one writer loses the channel's tier, rank and per-operation overrides // under a slug that no longer exists — silently, because nothing downstream // can tell a stale entry from a deliberate one — and leaves a // `prio-*-` leaf in every compiled root, matching nothing. test("renaming a channel carries its tier and leaves no leaf behind", async ({ page, }) => { await resetData("two-slow-channels"); await page.goto(ALL); await page.getByLabel("tier for slow-a", { exact: true }).selectOption("low"); await expect .poll(async () => (await settings()).channelPriority?.channels) .toMatchObject({ "slow-a": { tier: "low" } }); // Deliberately no generateReport: renaming refuses while a job for the // channel is active, and the danger zone renders without a report. await page.goto("/channels/slow-a?stage=danger"); await page.getByLabel("new slug").fill("slow-a-renamed"); await page.getByLabel("confirm current slug").fill("slow-a"); await page.getByRole("button", { name: "Rename channel" }).click(); await expect(page).toHaveURL(/\/channels\/slow-a-renamed/); await expect .poll(async () => (await settings()).channelPriority?.channels) .toEqual({ "slow-a-renamed": { tier: "low" } }); // And the compiled trees followed in the same write: the low group names the // new slug, and nothing anywhere still names the old one. { const s = await settings(); const raw = JSON.stringify(s.autoQueue); expect(raw.includes("prio-low-slow-a-renamed")).toBe(true); expect(raw.includes("prio-low-slow-a\"")).toBe(false); expect(raw.includes("prio-normal-slow-a\"")).toBe(false); const root = s.autoQueue?.download.root; expect(root?.children.map((c) => c.id)).toEqual([ "prio-normal", "prio-low", "prio-all", ]); expect( root?.children.find((c) => c.id === "prio-low")?.children?.map((c) => c.id), ).toEqual(["prio-low-slow-a-renamed"]); expect( root?.children .find((c) => c.id === "prio-normal") ?.children?.map((c) => c.id), ).toEqual(["prio-normal-slow-b"]); } // The row on /channels shows the carried tier under the new slug. await page.goto(ALL); await expect( page.getByLabel("tier for slow-a-renamed", { exact: true }), ).toHaveValue("low"); });