import { test, expect } from "@playwright/test"; import { channelStage, generateReport, readJson, resetData, writeChannelConfig, writeSettings, } from "./helpers"; // The cadence UI: every interval in the app is now set as an amount plus a unit // (DurationField) instead of a raw minute count, the full-sweep cadence is // settable globally and per channel, and the scheduler page can retune many // channels at once. // // The controls are deliberately a native , so // .selectOption()/.fill() keep working the way the rest of this suite expects. // Each field posts ONE hidden input in its base unit, which is why no server // parser had to change — these specs assert on the stored integers. const CHANNEL = "cadence-test"; const CONFIG = `test-transcripts/channels/${CHANNEL}/config.json`; type CadenceConfig = { syncIntervalMinutes?: number; fullSweepIntervalMinutes?: number; }; async function seedChannel(config: Record = {}) { await resetData(null); await writeChannelConfig(CHANNEL, { handling: "youtube", name: "Cadence Test", url: "https://www.youtube.com/@cadence-test/videos", ...config, }); } // The channel form auto-resets after a React 19
submit, so // assertions read the file rather than the inputs. async function saveChannelForm(page: import("@playwright/test").Page) { await page.getByRole("button", { name: /save changes/i }).click(); } test("channel form saves a full-sweep preset as a minute count", async ({ page, }) => { await seedChannel(); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "configure")); await page.getByLabel("Full sweep", { exact: true }).selectOption("1440"); // The echo line restates the raw stored value, so nothing is hidden from // someone who also reads config.json. await expect(page.getByText("Every 1,440 minutes ·")).toBeVisible(); await page .getByLabel("Auto-sync (newest)", { exact: true }) .selectOption("30"); await saveChannelForm(page); await expect .poll(async () => readJson(CONFIG)) .toMatchObject({ fullSweepIntervalMinutes: 1440, syncIntervalMinutes: 30 }); }); test("a hand-set non-preset interval round-trips unchanged", async ({ page, }) => { // 137 minutes divides by neither hours nor days, so the field must show it as // "137 minutes" and save it back untouched rather than rounding to 2 hours. await seedChannel({ fullSweepIntervalMinutes: 137 }); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "configure")); await expect(page.getByLabel("Full sweep amount")).toHaveValue("137"); await expect(page.getByLabel("Full sweep unit")).toHaveValue("minutes"); await saveChannelForm(page); await expect .poll(async () => readJson(CONFIG)) .toMatchObject({ fullSweepIntervalMinutes: 137 }); }); test("clearing a cadence to Default removes the key from config", async ({ page, }) => { // The CHANNEL_FORM_FIELDS clearing contract: "Default (use global)" must // actually delete the stored value, not re-layer the previous one. await seedChannel({ fullSweepIntervalMinutes: 720 }); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "configure")); await page.getByLabel("Full sweep", { exact: true }).selectOption("inherit"); await saveChannelForm(page); await expect .poll(async () => { const cfg = await readJson>(CONFIG); return "fullSweepIntervalMinutes" in cfg; }) .toBe(false); }); test("the sync page round-trips the full-sweep cadence and confirm cap", async ({ page, }) => { await resetData(null); // THE WHOLE BLOCK IS ON THE SYNC OPERATION'S PAGE NOW. These three fields // were a /settings fieldset; the labels did not change, the page did. await page.goto("/operations/sync"); const form = page.locator('form[data-settings-block="syncScheduler"]'); // Preset: every 3 days. await form .getByLabel("Default full sweep interval", { exact: true }) .selectOption("4320"); await form.getByLabel(/full-sweep auto-confirm cap/i).fill("5"); // The keep-latest check interval had no input at all before this — and the // action read it with a coercion that reset it to the default on every save. await form .getByLabel("Keep-latest check interval unit") .selectOption("hours"); await form.getByLabel("Keep-latest check interval amount").fill("8"); await form.getByRole("button", { name: "Save controls" }).click(); await expect(form.getByRole("status")).toHaveText("Saved."); await expect .poll(async () => readJson<{ syncScheduler: { fullSweepIntervalMinutes: number; fullSweepConfirmMaxSuspects: number; keepLatestCheckIntervalMinutes: number; }; }>("test-settings.json").then((s) => s.syncScheduler), ) .toMatchObject({ fullSweepIntervalMinutes: 4320, fullSweepConfirmMaxSuspects: 5, keepLatestCheckIntervalMinutes: 480, }); }); // UNCHANGED, and it passes for a different reason now: /settings no longer // parses the sync scheduler at all, so the interval survives by PRESERVATION // rather than by being re-read off a field this form still renders. That is // the stronger property — it is the contract every settings writer here is // held to. test("an unrelated settings save no longer resets the keep-latest interval", async ({ page, }) => { await resetData(null); await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, verifyAvailabilityBeforeClean: false, syncScheduler: { fullSweepIntervalMinutes: 0, keepLatestCheckIntervalMinutes: 4321, }, }); await page.goto("/settings"); await page.getByLabel(/admin title/i).fill("Renamed Admin"); await page.getByRole("button", { name: /save settings/i }).click(); await expect .poll(async () => readJson<{ adminTitle: string; syncScheduler: { keepLatestCheckIntervalMinutes: number }; }>("test-settings.json"), ) .toMatchObject({ adminTitle: "Renamed Admin", syncScheduler: { keepLatestCheckIntervalMinutes: 4321 }, }); }); test("the sync page's save writes its block and nothing else", async ({ page, }) => { // ONE WRITER PER BLOCK, proved from a browser: the sync form is the only // writer of settings.syncScheduler, and saving it must leave every other // block on disk exactly as it was. await resetData(null); await writeSettings({ adminTitle: "Keep Me", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, verifyAvailabilityBeforeClean: false, }); await page.goto("/operations/sync"); const form = page.locator('form[data-settings-block="syncScheduler"]'); await form.getByLabel("Max concurrent syncs").fill("3"); await form.getByRole("button", { name: "Save controls" }).click(); await expect(form.getByRole("status")).toHaveText("Saved."); await expect .poll(async () => readJson<{ adminTitle: string; syncScheduler: { maxConcurrentSyncs: number }; }>("test-settings.json"), ) .toMatchObject({ adminTitle: "Keep Me", syncScheduler: { maxConcurrentSyncs: 3 }, }); }); test("bulk apply retunes selected channels and leaves the rest alone", async ({ page, }) => { await resetData(null); for (const slug of ["bulk-a", "bulk-b", "bulk-c"]) { await writeChannelConfig(slug, { handling: "youtube", name: `Bulk ${slug.slice(-1).toUpperCase()}`, url: `https://www.youtube.com/@${slug}/videos`, syncIntervalMinutes: 30, }); } await page.goto("/scheduler"); await page.getByLabel("select bulk-a").check(); await page.getByLabel("select bulk-b").check(); // Only the sweep is set; auto-sync stays on "Leave unchanged", which is the // whole point of the bar — retune sweeps without disturbing anyone's sync. await page .getByLabel("Bulk full sweep interval", { exact: true }) .selectOption("10080"); await page.getByRole("button", { name: "Apply" }).click(); await expect(page.getByText("Applied.")).toBeVisible(); await expect .poll(async () => readJson("test-transcripts/channels/bulk-a/config.json"), ) .toMatchObject({ fullSweepIntervalMinutes: 10080, syncIntervalMinutes: 30 }); await expect .poll(async () => readJson("test-transcripts/channels/bulk-b/config.json"), ) .toMatchObject({ fullSweepIntervalMinutes: 10080, syncIntervalMinutes: 30 }); const c = await readJson>( "test-transcripts/channels/bulk-c/config.json", ); expect("fullSweepIntervalMinutes" in c).toBe(false); }); test("the Full sweep button forces a sweep the cadence would have skipped", async ({ page, }) => { // Sweeps are OFF in the e2e default settings, so this proves the button // passes fullSweep through rather than relying on the cadence gate. await resetData("availability-baseline"); const slug = "availability-test"; const configRel = `test-transcripts/channels/${slug}/config.json`; await generateReport(page, slug); await page.goto(channelStage(slug, "playlist")); const button = page.getByRole("button", { name: "Full sweep", exact: true }); // Retried: a click landing before React hydrates fires nothing at all. for (let attempt = 0; attempt < 5; attempt++) { await button.click({ timeout: 5_000 }).catch(() => {}); let swept = false; for (let i = 0; i < 80; i++) { const cfg = await readJson<{ lastFullSweepAt?: string }>(configRel).catch( () => null, ); if (cfg?.lastFullSweepAt) { swept = true; break; } await new Promise((r) => setTimeout(r, 250)); } if (swept) break; } const cfg = await readJson<{ lastFullSweepAt?: string }>(configRel); expect(cfg.lastFullSweepAt).toBeTruthy(); // The run announces itself, so the two flavours are distinguishable in a log. await expect( page.getByText(/Full sweep: re-reading the whole channel listing/), ).toBeVisible(); });