// The monitor widget (/widget) and its builder (/widget/builder). The widget is // meant to embed in a small pinned window or iframe: the app shell (sidebar, // command palette) is stripped on exactly /widget. It is read-only by default // (aside from an in-place settings gear), but `controls=1` opts into interactive // Pause/Resume + Drain + Retry buttons, `act=1` shows a "needs work" channel // list, `cleanlist=1` shows a "needs cleaning" one, and each combines with // `controls=1` into per-channel Download/Transcribe / Clean audio actions. The // builder lives inside the normal shell and composes links. import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { test, expect } from "@playwright/test"; import { readJson, resetData, resolvePath, writeSettings, writeChannelConfig, generateReport, } from "./helpers"; import { baseUrl } from "./baseUrl"; const CLEAN_SLUG = "test-transcribe"; // Seed the transcribed-with-audio cleanup bucket for a channel — the same // pattern cleanup-actionable.spec.ts uses. The default 2,621,440 bytes → "2.5 MB". async function seedCleanableChannel( slug: string = CLEAN_SLUG, bytes: number = 2_621_440, ): Promise { const full = resolvePath(`test-transcripts/channels/${slug}/snapshot.json`); await mkdir(dirname(full), { recursive: true }); await writeFile( full, JSON.stringify( { generatedAt: "2026-05-27T12:00:00.000Z", totals: { videos: 3, transcribed: 2, downloaded: 3 }, buckets: { noTranscript: [], downloadedNoTranscript: [], untranscoded: [], multipleAudioFormats: [], transcribedWithAudio: ["vidA", "vidB"], untranscribable: [], noMetadata: [], failedListed: [], missingFromArchive: [], duplicateDirs: [], partialDownloads: [], }, undownloadedIds: [], cleanupBytes: { transcribedWithAudio: bytes }, }, null, 2, ) + "\n", ); await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); } // Settings with a backfill KIND registered (diarization), so the widget's // backfill strip has something to report and its pause has something to hold. // Same shape as backfill.spec.ts's — `anyKind` is what gates both. const BACKFILL_SETTINGS = { diarization: { enabled: true, inlineAfterTranscribe: false, threshold: 0.5, threads: 1, python: "python3", segModel: "/dev/null", embModel: "/dev/null", concurrency: 1, }, backfill: { concurrency: 1, allowRedownload: false, }, // THE LANE'S GATE, SPELLED — the lane's pause has to start OPEN for the // widget's control to hold it. It used to be spelled by `backfill.enabled: // true` above (the inverted retired field, deleted by S0-pause), and the // gate now defaults shut. autoQueue: { backfill: { held: false } }, }; const TWO_WORKERS = { // NAMED, not inherited. Several cases below reach for the widget's "Disk // space" cell, which the layout only draws while the gate is CONFIGURED, and // the e2e fixture switches the gate OFF (minFreeDiskGB 0) as every other spec // wants. This spec used to get it armed by accident — writeSettings replaced // the fixture wholesale and the product default filled the gap. It merges // now, so the one spec that wants the gate on says so. A positive floor // because the cell has to exist, not because the number matters: every // assertion here is about position, and the cell renders whether or not the // floor is met. minFreeDiskGB: 5, workers: [ { id: "gpu", name: "GPU", kind: "local", enabled: true, priority: 0, appId: "whisper-cpp", config: {} }, { id: "cpu", name: "CPU", kind: "local", enabled: true, priority: 1, appId: "whisper-cpp", config: {} }, ], }; test.beforeEach(async () => { await resetData("empty"); await writeSettings(TWO_WORKERS); }); test("the bare widget has no sidebar and stays read-only", async ({ page }) => { await page.goto("/widget"); // Chrome is stripped: no sidebar nav. await expect(page.locator("aside")).toHaveCount(0); // Both sections render with the configured workers visible. await expect(page.getByRole("heading", { name: "Workers" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Active" })).toBeVisible(); await expect(page.getByLabel("worker GPU idle")).toBeVisible(); await expect(page.getByLabel("worker CPU idle")).toBeVisible(); // The settings gear ships on by default, but nothing mutates worker/job state: // no Pause/Resume/Drain/Retry controls unless controls=1. await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); await expect( page.getByRole("button", { name: "Pause Transcriptions" }), ).toHaveCount(0); await expect(page.getByRole("button", { name: "Drain all" })).toHaveCount(0); }); test("gear=0 hides the settings gear", async ({ page }) => { await page.goto("/widget?gear=0"); await expect(page.getByRole("heading", { name: "Workers" })).toBeVisible(); // The gear is gone, so a locked-down link exposes no settings affordance. // (Scoped to the gear rather than all buttons — `next dev` injects its own // Dev Tools button, absent from a production build.) await expect(page.getByRole("button", { name: "Settings" })).toHaveCount(0); }); test("the settings gear opens an in-place overlay that edits the widget live", async ({ page, }) => { await page.goto("/widget"); await page.getByRole("button", { name: "Settings" }).click(); const dialog = page.getByRole("dialog", { name: "Widget settings" }); await expect(dialog).toBeVisible(); // Toggling a section in the overlay mirrors into the address bar immediately // (history.replaceState) so a reload preserves it and the link stays copyable. await dialog.getByRole("checkbox", { name: "Workers" }).uncheck(); await expect(page).toHaveURL(/[?&]workers=0(&|$)/); // Close the overlay and confirm the change is applied to the live widget. await page.getByRole("button", { name: "Close settings" }).click(); await expect(dialog).toHaveCount(0); await expect(page.getByRole("heading", { name: "Workers" })).toHaveCount(0); await expect(page.getByRole("heading", { name: "Active" })).toBeVisible(); }); test("controls=1 adds interactive Pause/Resume, Drain, and Retry buttons", async ({ page, }) => { await page.goto("/widget?controls=1"); // The opt-in controls render even though the bare widget is read-only. const pause = page.getByRole("button", { name: "Pause Transcriptions" }); await expect(pause).toBeVisible(); await expect(page.getByRole("button", { name: "Drain all" })).toBeVisible(); await expect( page.getByRole("button", { name: "Retry all failed" }), ).toBeVisible(); // Pausing flips the toggle to Resume (the widget refetches workers on change). await pause.click(); await expect( page.getByRole("button", { name: "Resume Transcriptions" }), ).toBeVisible({ timeout: 10_000 }); // The Workers strip also reflects the paused state. await expect(page.getByText("paused", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Resume Transcriptions" }).click(); await expect( page.getByRole("button", { name: "Pause Transcriptions" }), ).toBeVisible({ timeout: 10_000 }); }); test("sync=1 shows a channel-aware Sync button", async ({ page }) => { // No channel pinned → sweeps all channels. await page.goto("/widget?sync=1"); const syncAll = page.getByRole("button", { name: "sync all channels" }); await expect(syncAll).toBeVisible(); await expect(syncAll).toHaveText("Sync all"); // Empty corpus → the sweep queues nothing and reports its counts. await syncAll.click(); await expect(page.getByLabel("sync all result")).toContainText( "Queued 0 · skipped 0", { timeout: 10_000 }, ); // Pinned to one channel → the button targets just that channel. await page.goto("/widget?sync=1&channel=test-youtube"); const syncOne = page.getByRole("button", { name: "sync test-youtube" }); await expect(syncOne).toBeVisible(); await expect(syncOne).toHaveText("Sync"); }); test("lastsync=1 shows the last-sync readout", async ({ page }) => { await page.goto("/widget?lastsync=1"); const strip = page.getByRole("region", { name: "Last sync" }); await expect(strip).toBeVisible({ timeout: 10_000 }); // Empty corpus with no sweep recorded → never. await expect(strip).toContainText("Last full sync: never"); }); test("sched=1 shows the auto-sync scheduler strip", async ({ page }) => { await page.goto("/widget?sched=1"); const strip = page.getByRole("region", { name: "Auto-sync scheduler" }); await expect(strip).toBeVisible({ timeout: 10_000 }); await expect(strip).toContainText(/Auto-sync (on|off)/); }); test("section params gate what renders", async ({ page }) => { await page.goto("/widget?jobs=0"); await expect(page.getByRole("heading", { name: "Workers" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Active" })).toHaveCount(0); await page.goto("/widget?workers=0"); await expect(page.getByRole("heading", { name: "Active" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Workers" })).toHaveCount(0); }); test("act=1 shows a compact needs-work list of channels", async ({ page }) => { await resetData("youtube-with-playlist"); // Visiting the channel detail page auto-generates the snapshot the list reads. await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); await page.goto("/widget?act=1"); const list = page.getByRole("region", { name: "Needs work" }); await expect(list).toBeVisible({ timeout: 10_000 }); // youtube-with-playlist leaves 4 videos undownloaded (↓), none awaiting // transcription — the strip echoes the task-bar download verb. await expect(list).toContainText("test-youtube"); await expect(list).toContainText("↓ 4"); }); test("act=1&controls=1 gives the list per-channel Download actions", async ({ page, }) => { await resetData("youtube-with-playlist"); await generateReport(page, "test-youtube"); await page.goto("/channels/test-youtube"); // auto-generate snapshot await page.goto("/widget?act=1&controls=1"); const download = page.getByRole("button", { name: "download missing test-youtube", }); await expect(download).toBeVisible({ timeout: 10_000 }); // Reuses the shared inline action: clicking queues a download-missing job. await download.click(); await expect( page.getByLabel("download missing test-youtube job"), ).toBeVisible({ timeout: 10_000 }); }); test("cleanlist=1 shows a needs-cleaning list of channels", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await seedCleanableChannel(); await page.goto("/widget?cleanlist=1"); const list = page.getByRole("region", { name: "Needs cleaning" }); // The section arrives on the first poll, not with the SSR'd markup. await expect(list).toBeVisible({ timeout: 10_000 }); await expect(list).toContainText(CLEAN_SLUG); // 2,621,440 bytes of reclaimable audio → 2.5 MB. await expect(list).toContainText("2.5 MB"); }); test("the needs-cleaning list's +N more expands and collapses in place", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); // The list caps at 6 rows; 8 channels puts 2 behind the overflow control. for (let i = 0; i < 8; i += 1) { const slug = `bulk-clean-${i}`; await writeChannelConfig(slug); await seedCleanableChannel(slug, 1_048_576 * (i + 1)); } await page.goto("/widget?cleanlist=1"); const list = page.getByRole("region", { name: "Needs cleaning" }); await expect(list).toBeVisible({ timeout: 10_000 }); // Only the row
  • s carry `needs cleaning `, so this count ignores the // overflow
  • holding the control. const rows = list .getByRole("listitem") .and(page.locator("[aria-label^='needs cleaning ']")); await expect(rows).toHaveCount(6, { timeout: 10_000 }); const more = list.getByRole("button", { name: "+2 more channels" }); await expect(more).toBeVisible(); // Expanding shows the rest without leaving the widget. await more.click(); await expect(rows).toHaveCount(8); const fewer = list.getByRole("button", { name: "Show fewer" }); await expect(fewer).toBeVisible(); // ...and it collapses back, surviving the ~1s poll in between. await fewer.click(); await expect(rows).toHaveCount(6); await expect(list.getByRole("button", { name: "+2 more channels" })).toBeVisible(); }); test("cleanlist=1&controls=1 gives the list per-channel Clean audio actions", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); await seedCleanableChannel(); await page.goto("/widget?cleanlist=1&controls=1"); const clean = page.getByRole("button", { name: `clean audio ${CLEAN_SLUG}`, }); await expect(clean).toBeVisible({ timeout: 10_000 }); // Reuses the shared inline action, confirm dialog included: accepting // queues a clean-audio-transcribed job. page.on("dialog", (d) => d.accept()); await clean.click(); await expect(page.getByLabel(`clean audio ${CLEAN_SLUG} job`)).toBeVisible({ timeout: 10_000, }); }); test("the builder keeps the shell and composes a link", async ({ page }) => { await page.goto("/widget/builder"); // Builder lives inside the normal app shell. await expect(page.locator("aside")).toBeVisible(); const url = page.getByLabel("widget URL"); await expect(url).toHaveValue(/\/widget$/); const preview = page.locator('iframe[title="widget preview"]'); await expect(preview).toHaveAttribute("src", "/widget"); // Toggling a section updates both the link and the preview src. await page.getByRole("checkbox", { name: "Workers" }).uncheck(); await expect(url).toHaveValue(/\/widget\?workers=0$/); await expect(preview).toHaveAttribute("src", "/widget?workers=0"); await expect(page.getByRole("button", { name: /copy/i })).toBeVisible(); // "Open popup" launches a chromeless window pointed at the composed widget URL. const popupPromise = page.waitForEvent("popup"); await page.getByRole("button", { name: "Open popup" }).click(); const popup = await popupPromise; await expect(popup).toHaveURL(/\/widget\?workers=0(&|$)/); await popup.close(); }); test("display toggles serialize into the link and preview", async ({ page }) => { await page.goto("/widget/builder"); const url = page.getByLabel("widget URL"); const preview = page.locator('iframe[title="widget preview"]'); await expect(url).toHaveValue(/\/widget$/); // Per-section display options now live on the section's own card, folded away // behind its options disclosure — so each group needs expanding first. await page.getByRole("button", { name: "Options for Active jobs" }).click(); await page.getByRole("checkbox", { name: "Show batch progress bar" }).uncheck(); await expect(url).toHaveValue(/[?&]jobbar=0(&|$)/); await expect(preview).toHaveAttribute("src", /[?&]jobbar=0(&|$)/); await page .getByRole("checkbox", { name: "Show progress in job heading" }) .check(); await expect(url).toHaveValue(/[?&]headtext=1(&|$)/); await page.getByRole("checkbox", { name: "Show time estimates (ETA)" }).uncheck(); await expect(url).toHaveValue(/[?&]eta=0(&|$)/); await page.getByRole("button", { name: "Options for Workers" }).click(); await page.getByRole("checkbox", { name: "Show worker names" }).uncheck(); await expect(url).toHaveValue(/[?&]wnames=0(&|$)/); await expect(preview).toHaveAttribute("src", /[?&]wnames=0(&|$)/); // A section is switched off by unchecking its card, which sends it to the // tray — where the same checkbox, now unchecked, brings it back. await page.getByRole("checkbox", { name: "Disk space" }).uncheck(); await expect(url).toHaveValue(/[?&]disk=0(&|$)/); await page.getByRole("checkbox", { name: "Needs work" }).check(); await expect(url).toHaveValue(/[?&]act=1(&|$)/); await expect(preview).toHaveAttribute("src", /[?&]act=1(&|$)/); await page.getByRole("checkbox", { name: "Needs cleaning" }).check(); await expect(url).toHaveValue(/[?&]cleanlist=1(&|$)/); await expect(preview).toHaveAttribute("src", /[?&]cleanlist=1(&|$)/); // Flags that belong to the widget as a whole stay in the global row. await page.getByRole("checkbox", { name: "Show settings gear" }).uncheck(); await expect(url).toHaveValue(/[?&]gear=0(&|$)/); // The controls card owns both interactive capabilities: adding it turns on // the buttons, and the Sync flags are its own options. await page.getByRole("checkbox", { name: "Controls & sync" }).check(); await expect(url).toHaveValue(/[?&]controls=1(&|$)/); await expect(preview).toHaveAttribute("src", /[?&]controls=1(&|$)/); await page.getByRole("button", { name: "Options for Controls & sync" }).click(); await page.getByRole("checkbox", { name: "Sync button" }).check(); await expect(url).toHaveValue(/[?&]sync=1(&|$)/); await expect(preview).toHaveAttribute("src", /[?&]sync=1(&|$)/); await page.getByRole("checkbox", { name: "Confirm before Sync all" }).check(); await expect(url).toHaveValue(/[?&]syncask=1(&|$)/); await page.getByRole("checkbox", { name: "Last sync" }).check(); await expect(url).toHaveValue(/[?&]lastsync=1(&|$)/); await page.getByRole("checkbox", { name: "Scheduler" }).check(); await expect(url).toHaveValue(/[?&]sched=1(&|$)/); await page.getByRole("checkbox", { name: "Absolute timestamps" }).check(); await expect(url).toHaveValue(/[?&]abstime=1(&|$)/); }); test("reordering a section on the board writes a layout into the link", async ({ page, }) => { await page.goto("/widget/builder"); const url = page.getByLabel("widget URL"); const preview = page.locator('iframe[title="widget preview"]'); // The default arrangement is the widget's original render order, so it needs // no layout param at all. await expect(url).toHaveValue(/\/widget$/); // Disk, Workers, Active jobs — moving Active jobs above Workers is the whole // point of the board, and it shows up as `l=`. await page.getByRole("button", { name: "Move Active jobs up" }).click(); await expect(url).toHaveValue(/[?&]l=disk\.jobs\.wk(&|$)/); await expect(preview).toHaveAttribute("src", /[?&]l=disk\.jobs\.wk(&|$)/); // Moving it back restores the default, and the param disappears again rather // than being baked into every link from then on. await page.getByRole("button", { name: "Move Active jobs down" }).click(); await expect(url).toHaveValue(/\/widget$/); }); test("dragging a card to a new slot rewrites the layout", async ({ page }) => { await page.goto("/widget/builder"); const url = page.getByLabel("widget URL"); await expect(url).toHaveValue(/\/widget$/); const workers = page.locator('[data-section-card="workers"]'); const jobs = page.locator('[data-section-card="jobs"]'); const column = page.locator('[data-section-card="workers"]').locator("xpath=.."); await expect(workers).toBeVisible(); // Native HTML5 drag isn't driveable with mouse moves, so drive the three // events the board actually listens to. Dropping below the Active jobs card's // midpoint is what puts Workers last. const box = (await jobs.boundingBox())!; const clientY = box.y + box.height * 0.9; const dataTransfer = await page.evaluateHandle(() => new DataTransfer()); await workers.dispatchEvent("dragstart", { dataTransfer }); await column.dispatchEvent("dragover", { dataTransfer, clientY }); await column.dispatchEvent("drop", { dataTransfer, clientY }); await expect(url).toHaveValue(/[?&]l=disk\.jobs\.wk(&|$)/); }); test("a removed section comes back at the end of the board", async ({ page }) => { await page.goto("/widget/builder"); const url = page.getByLabel("widget URL"); // Off → the flag serializes and the layout is still the default one. await page.getByRole("checkbox", { name: "Disk space" }).uncheck(); await expect(url).toHaveValue(/\/widget\?disk=0$/); // Back on from the tray → it appends rather than returning to its old slot // (no parking memory, deliberately), a layout the default order can't express. await page.getByRole("checkbox", { name: "Disk space" }).check(); await expect(url).toHaveValue(/[?&]l=wk\.jobs\.disk(&|$)/); await expect(url).not.toHaveValue(/[?&]disk=0(&|$)/); }); test("the column picker splits the board and the link carries it", async ({ page, }) => { await page.goto("/widget/builder"); const url = page.getByLabel("widget URL"); await page.getByRole("button", { name: "2 columns" }).click(); // A second, empty column: everything is still in the first one. await expect(url).toHaveValue(/[?&]l=disk\.wk\.jobs-(&|$)/); await page.getByRole("button", { name: "Move Workers right" }).click(); await expect(url).toHaveValue(/[?&]l=disk\.jobs-wk(&|$)/); // Stacking is opt-in: columns are otherwise honored at every width. await page.getByRole("checkbox", { name: "Stack columns when narrow" }).check(); await expect(url).toHaveValue(/[?&]stack=1(&|$)/); }); test("a two-column layout renders side by side, and stacks only when asked", async ({ page, }) => { await page.setViewportSize({ width: 900, height: 600 }); await page.goto("/widget?l=wk-jobs"); const workers = page.getByRole("region", { name: "Workers" }); const jobs = page.getByRole("region", { name: "Active jobs" }); await expect(workers).toBeVisible(); const wide = { workers: await workers.boundingBox(), jobs: await jobs.boundingBox(), }; // Side by side: the second column starts to the right of the first, and the // two share vertical space. expect(wide.jobs!.x).toBeGreaterThan(wide.workers!.x); // Narrow, without opting in: the columns are still honored. await page.setViewportSize({ width: 340, height: 600 }); await expect(jobs).toBeVisible(); expect((await jobs.boundingBox())!.x).toBeGreaterThan( (await workers.boundingBox())!.x, ); // stack=1 collapses them below the container-query threshold. The @container // is on the widget root, so this measures the widget's own box. await page.goto("/widget?l=wk-jobs&stack=1"); await expect(workers).toBeVisible(); expect((await jobs.boundingBox())!.x).toBe((await workers.boundingBox())!.x); }); test("a link written before layouts existed renders in the original order", async ({ page, }) => { await page.goto("/widget?clean=1&cleanlist=1&act=1"); // The cleanable strips arrive on the first poll, not with the SSR'd markup. await expect(page.getByRole("region", { name: "Cleanable data" })).toBeVisible({ timeout: 10_000, }); // No `l=` means the default layout, which is the order the widget hardcoded // before the board existed — cleanable above workers, not below it. const order = await page .locator("section[aria-label]") .evaluateAll((els) => els.map((e) => e.getAttribute("aria-label"))); expect( order.filter((l) => l !== "Disk space" && l !== "Controls"), ).toEqual([ "Cleanable data", "Needs cleaning", "Workers", "Active jobs", "Needs work", ]); }); // --------------------------------------------------------------------------- // Rows, spans, fill and the dense rail // --------------------------------------------------------------------------- // // The layout model went from N columns of stacked sections to ROWS OF CELLS — // which is what makes a full-width section, a rail of totals and a scrolling // section possible at all. `l=` stayed an input format and is still what gets // emitted for anything plain columns can express, so every assertion above this // line is the back-compatibility proof and none of it changed. test("a g= layout renders as a grid: rows, and a section spanning both tracks", async ({ page, }) => { await page.setViewportSize({ width: 900, height: 700 }); // The Cockpit arrangement, reached by name — which also covers ?preset= // resolving server-side. await page.goto("/widget?preset=cockpit"); const controls = page.getByRole("region", { name: "Controls" }); const sched = page.getByRole("region", { name: "Auto-sync scheduler" }); const work = page.getByRole("region", { name: "Needs work" }); await expect(work).toBeVisible({ timeout: 10_000 }); const cb = (await controls.boundingBox())!; const sb = (await sched.boundingBox())!; const wb = (await work.boundingBox())!; // Controls spans BOTH tracks: wider than either of the cells below it. expect(cb.width).toBeGreaterThan(sb.width * 1.5); // Three rows, top to bottom... expect(sb.y).toBeGreaterThan(cb.y); // ...and the last row's two cells sit side by side. expect(wb.x).toBeGreaterThan(sb.x); }); test("a row-flow cell puts the totals on one line inside one rail", async ({ page, }) => { await page.setViewportSize({ width: 900, height: 400 }); await page.goto( "/widget?jobs=0&workers=0&clean=1&lastsync=1&sched=1&g=lsync.sched.disk.cln*r", ); const sync = page.getByRole("region", { name: "Last sync" }); const sched = page.getByRole("region", { name: "Auto-sync scheduler" }); const disk = page.getByRole("region", { name: "Disk space" }); const clean = page.getByRole("region", { name: "Cleanable data" }); await expect(clean).toBeVisible({ timeout: 10_000 }); // One line: every total shares a baseline. const boxes = await Promise.all( [sync, sched, disk, clean].map((l) => l.boundingBox()), ); for (const b of boxes) expect(Math.abs(b!.y - boxes[0]!.y)).toBeLessThan(4); // ...in order, left to right. expect(boxes[3]!.x).toBeGreaterThan(boxes[0]!.x); // ONE container: the rail carries the border, the strips inside carry none. expect( await sync.evaluate((el) => getComputedStyle(el).borderTopWidth), ).toBe("0px"); // And the copy shortens rather than wrapping. await expect(sync).toContainText("synced"); await expect(sync).not.toContainText("Last full sync"); }); test("a fill row scrolls its sections under their own headings, uncapped", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); // Twelve channels: twice the six-row cap the non-scrolling list uses, and // more rows than the short frame below can hold — so there is something to // scroll rather than a box that merely could. for (let i = 0; i < 12; i += 1) { const slug = `bulk-clean-${i}`; await writeChannelConfig(slug); await seedCleanableChannel(slug, 1_048_576 * (i + 1)); } await page.setViewportSize({ width: 900, height: 300 }); await page.goto("/widget?jobs=0&workers=0&cleanlist=1&g=clnl*f"); const list = page.getByRole("region", { name: "Needs cleaning" }); await expect(list).toBeVisible({ timeout: 10_000 }); // Given the height to scroll in, the cap is lifted: all eight rows are there // and there is no "+N more" control left to defeat the point. const rows = list .getByRole("listitem") .and(page.locator("[aria-label^='needs cleaning ']")); await expect(rows).toHaveCount(12, { timeout: 10_000 }); await expect(list.getByRole("button", { name: /more channels/ })).toHaveCount(0); // The heading is pinned: scrolling the list does not move it. const heading = list.getByRole("heading", { name: "Needs cleaning" }); const before = (await heading.boundingBox())!; const scrolled = await list.evaluate((el) => { const box = el.firstElementChild as HTMLElement; box.scrollTop = 300; return box.scrollTop; }); expect(scrolled).toBeGreaterThan(0); const after = (await heading.boundingBox())!; expect(Math.abs(after.y - before.y)).toBeLessThan(2); await expect(heading).toBeInViewport(); }); test("the board writes rows, fill and spans into the link, and falls back to l=", async ({ page, }) => { await page.goto("/widget/builder"); const url = page.getByLabel("widget URL"); await expect(url).toHaveValue(/\/widget$/); await page.getByRole("button", { name: "2 columns" }).click(); await expect(url).toHaveValue(/[?&]l=disk\.wk\.jobs-(&|$)/); // A second row, and something in it. await page.getByRole("button", { name: "Add a row" }).click(); await page .getByRole("button", { name: "Move Active jobs to the row below" }) .click(); await expect(url).toHaveValue(/[?&]g=disk\.wk-_jobs-(&|$)/); // The left rail: give the second row the leftover height. await page.getByRole("button", { name: "Row 2 height" }).click(); await expect(url).toHaveValue(/[?&]g=disk\.wk-_jobs\*f-(&|$)/); // Widening swallows the empty cell beside it. await page.getByRole("button", { name: "Widen row 1 cell 1" }).click(); await expect(url).toHaveValue(/[?&]g=disk\.wk\*2_jobs\*f-(&|$)/); // ...and undoing all three falls back to a plain columns link, which is what // keeps a link shareable with anything that only ever understood `l=`. await page.getByRole("button", { name: "Narrow row 1 cell 1" }).click(); await page.getByRole("button", { name: "Row 2 height" }).click(); await page.getByRole("button", { name: "Remove the last row" }).click(); await expect(url).toHaveValue(/[?&]l=disk\.wk-jobs(&|$)/); await expect(url).not.toHaveValue(/[?&]g=/); }); // --------------------------------------------------------------------------- // Presets // --------------------------------------------------------------------------- test("a preset saves, loads and deletes, in the builder and in the widget", async ({ page, }) => { await page.goto("/widget/builder"); const url = page.getByLabel("widget URL"); // Arrange something worth keeping. await page.getByRole("checkbox", { name: "Workers" }).uncheck(); await expect(url).toHaveValue(/\/widget\?workers=0$/); page.once("dialog", (d) => d.accept("Corner pin")); await page.getByRole("button", { name: "Save as…" }).click(); const select = page.getByRole("combobox", { name: "preset" }); await expect(select.locator("option", { hasText: "Corner pin" })).toHaveCount( 1, { timeout: 10_000 }, ); // A built-in loads as a whole link, layout included. await select.selectOption({ label: "Glance" }); await expect(url).toHaveValue(/[?&]g=lsync\.disk\.cln\.bf\.wk\*r(&|$)/); // ...and the saved one comes back exactly as it was saved. await select.selectOption({ label: "Corner pin" }); await expect(url).toHaveValue(/\/widget\?workers=0$/); // The same preset is there inside the widget's own menu — one store, two // surfaces — and loading it there rewrites the widget's address bar. await page.goto("/widget"); await page.getByRole("button", { name: "Settings" }).click(); const dialog = page.getByRole("dialog", { name: "Widget settings" }); const inWidget = dialog.getByRole("combobox", { name: "preset" }); await expect(inWidget.locator("option", { hasText: "Corner pin" })).toHaveCount( 1, { timeout: 10_000 }, ); await inWidget.selectOption({ label: "Corner pin" }); await expect(page).toHaveURL(/[?&]workers=0(&|$)/); page.once("dialog", (d) => d.accept()); await dialog.getByRole("button", { name: "Delete", exact: true }).click(); await expect(inWidget.locator("option", { hasText: "Corner pin" })).toHaveCount( 0, { timeout: 10_000 }, ); }); test("the in-widget menu is the same board, and its moves reach the URL", async ({ page, }) => { await page.goto("/widget"); await page.getByRole("button", { name: "Settings" }).click(); const dialog = page.getByRole("dialog", { name: "Widget settings" }); // The board, not the list form that used to live here: the same move buttons // the builder page has, writing the same param. await dialog.getByRole("button", { name: "Move Active jobs up" }).click(); await expect(page).toHaveURL(/[?&]l=disk\.jobs\.wk(&|$)/); // ...and the row rail too, which is what a second, narrower form could never // have offered. await dialog.getByRole("button", { name: "Row 1 height" }).click(); await expect(page).toHaveURL(/[?&]g=disk\.jobs\.wk\*f(&|$)/); }); // --------------------------------------------------------------------------- // Backfill in the widget // --------------------------------------------------------------------------- test("backfill=1 alone renders the Backfill strip", async ({ page }) => { // REGRESSION: the sync poll used to be gated on lastSync || scheduler only, // so a widget asking for just the backfill strip fetched nothing and rendered // nothing — with no clue that it was the gate rather than the corpus. await writeSettings({ ...TWO_WORKERS, ...BACKFILL_SETTINGS }); await page.goto("/widget?backfill=1"); await expect(page.getByRole("region", { name: "Backfill" })).toBeVisible({ timeout: 15_000, }); }); test("the widget's controls hold and release the backfill lane", async ({ page, }) => { await writeSettings({ ...TWO_WORKERS, ...BACKFILL_SETTINGS }); // Assert the KEY, not just the label: the button writes the same // `autoQueue.backfill.held` the "Run the backfill lane" checkbox does, and // the point of that choice is that the two cannot drift. (It was // `backfill.enabled` until slice 1.4 moved the gate onto the lane and // S0-pause deleted the field; the polarity is no longer inverted.) const laneHeld = async () => ( await readJson<{ autoQueue?: { backfill?: { held?: boolean } } }>( "test-settings.json", ).catch(() => ({}) as { autoQueue?: { backfill?: { held?: boolean } } }) ).autoQueue?.backfill?.held ?? false; await page.goto("/widget?controls=1&backfill=1"); const pause = page.getByRole("button", { name: "pause backfill" }); await expect(pause).toBeEnabled({ timeout: 30_000 }); await pause.click(); await expect.poll(laneHeld, { timeout: 30_000 }).toBe(true); // The label flips immediately, because the click refetches the payload it // reads rather than waiting out the 15s poll floor. const resume = page.getByRole("button", { name: "resume backfill" }); await expect(resume).toBeVisible({ timeout: 15_000 }); await resume.click(); await expect.poll(laneHeld, { timeout: 30_000 }).toBe(false); });