import { test, expect } from "@playwright/test"; import { writeFile } from "node:fs/promises"; import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; import { readJson, resetData, resolvePath, writeSettings } from "./helpers"; // End-to-end coverage for the cron-driven sync scheduler. Uses the two-slow // channels (their mock sync hangs long enough to stay "running"), drives the // real /api/scheduler/tick endpoint, and asserts which channels get queued. // // Covers the core rules of common/jobs/syncScheduler.selectDueChannels via the // live server: elapsed-interval due selection, the not-yet-due gate, and the // already-running dedup — plus the sync operation's console at // /operations/sync (the page that was /scheduler before sync was catalogued). const SLOW_A_CONFIG = "test-transcripts/channels/slow-a/config.json"; const SLOW_B_CONFIG = "test-transcripts/channels/slow-b/config.json"; async function writeChannelConfig(rel: string, config: ChannelConfig) { await writeFile(resolvePath(rel), JSON.stringify(config, null, 2)); } type TickResult = { ok: boolean; queued: string[]; skipped: { slug: string; reason: string }[]; reason?: string; }; test("scheduler queues due channels, skips not-due, and dedups running ones", async ({ page, }) => { await resetData("two-slow-channels"); // slow-a: inherits the default interval and has never synced -> due now. await writeChannelConfig(SLOW_A_CONFIG, { handling: "youtube", name: "Slow A", url: "https://www.youtube.com/@slow-a/videos", ytdlpExtraArgs: ["--test-slow"], }); // slow-b: synced just now -> NOT due under the 60-minute default. await writeChannelConfig(SLOW_B_CONFIG, { handling: "youtube", name: "Slow B", url: "https://www.youtube.com/@slow-b/videos", ytdlpExtraArgs: ["--test-slow"], lastSyncedAt: new Date().toISOString(), }); await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, syncScheduler: { enabled: true, defaultIntervalMinutes: 60, maxConcurrentSyncs: 2, quietHoursStart: null, quietHoursEnd: null, backoffBaseMinutes: 30, backoffMaxMinutes: 1440, // Keep these specs on the cheap paged walk: a never-swept channel is // due for a full sweep the moment the default cadence is non-zero, and // writeSettings REPLACES the file, so the e2e default's 0 is gone here. fullSweepIntervalMinutes: 0, }, }); // First tick: slow-a is due and gets queued; slow-b is not due. // // ASSERTED WITH THE TICK'S OWN EXPLANATION ALONGSIDE IT, deliberately. This // read `expect(r1.queued).toEqual(["slow-a"])` on its own, and a tick has // FOUR ways to queue nothing: it never ran (`reason: "tick already // running"`), the scheduler is off (`reason: "scheduler disabled"`), the // channel was held back by the selector, or the launch was refused and its // error recorded — `skipped.push({ slug, reason: result.error })` in // runTick.ts, which is where a low-disk preflight, a rate-limit cooldown and // the unreachable-media guard all land. All four print the same // `Received: []`, so a red run names none of them; this one names whichever // it was. const tick1 = await page.request.post("/api/scheduler/tick"); expect(tick1.ok()).toBeTruthy(); const r1 = (await tick1.json()) as TickResult; expect({ queued: r1.queued, reason: r1.reason, // slow-b's "not due" line belongs in skipped and is not a fault, so only // slow-a's own skip is part of the claim. refusedSlowA: r1.skipped.filter((s) => s.slug === "slow-a"), }).toEqual({ queued: ["slow-a"], reason: undefined, refusedSlowA: [] }); expect(r1.queued).not.toContain("slow-b"); // Second tick (no time passed): slow-a's sync is still running, so it's // skipped as already-running; slow-b is still not due. Nothing new queues. const tick2 = await page.request.post("/api/scheduler/tick"); expect(tick2.ok()).toBeTruthy(); const r2 = (await tick2.json()) as TickResult; expect(r2.queued).toEqual([]); expect(r2.skipped).toContainEqual({ slug: "slow-a", reason: "already running", }); // The /operations/sync console reflects the enabled scheduler and lists the // channels. The page is the sync OPERATION's, so it carries the operation's // own heading and its row on the rail above. await page.goto("/operations/sync"); await expect( page.getByRole("heading", { name: "Sync", level: 1 }), ).toBeVisible(); await expect(page.locator('li[data-operation="sync"]')).toContainText( "channels on a cadence", ); await expect(page.getByText("Scheduler enabled")).toBeVisible(); await expect(page.getByRole("link", { name: "Slow A" })).toBeVisible(); await expect( page.getByRole("button", { name: "Run scheduler now" }), ).toBeVisible(); }); test("internal heartbeat cadence is configurable and surfaced", async ({ page, }) => { // Exercises the config plumbing for the in-process heartbeat (settings -> // sanitize -> /api/scheduler/status -> the /operations/sync header). The // live timer is armed only at server startup (editor/instrumentation.ts), so // changing the setting at runtime is intentionally inert here — no real ticks // fire. await resetData("two-slow-channels"); const baseSettings = { adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, syncScheduler: { enabled: false, defaultIntervalMinutes: 60, maxConcurrentSyncs: 2, quietHoursStart: null, quietHoursEnd: null, backoffBaseMinutes: 30, backoffMaxMinutes: 1440, // Keep these specs on the cheap paged walk: a never-swept channel is // due for a full sweep the moment the default cadence is non-zero, and // writeSettings REPLACES the file, so the e2e default's 0 is gone here. fullSweepIntervalMinutes: 0, }, }; // A sub-minimum positive cadence clamps up to the 15s floor. await writeSettings({ ...baseSettings, syncScheduler: { ...baseSettings.syncScheduler, heartbeatSeconds: 5 }, }); const status1 = (await ( await page.request.get("/api/scheduler/status") ).json()) as { heartbeatSeconds: number }; expect(status1.heartbeatSeconds).toBe(15); await page.goto("/operations/sync"); await expect(page.getByText("internal heartbeat every 15s")).toBeVisible(); // 0 = off -> the panel reports it relies on an external cron heartbeat. await writeSettings({ ...baseSettings, syncScheduler: { ...baseSettings.syncScheduler, heartbeatSeconds: 0 }, }); const status2 = (await ( await page.request.get("/api/scheduler/status") ).json()) as { heartbeatSeconds: number }; expect(status2.heartbeatSeconds).toBe(0); await page.reload(); await expect(page.getByText("external heartbeat (cron)")).toBeVisible(); }); // --- Inline per-channel schedule editing on /operations/sync ---------------- // Seed two channels so the editable status table has rows to act on. async function seedTwoChannels(opts?: { slowAInterval?: number }) { await resetData("two-slow-channels"); await writeChannelConfig(SLOW_A_CONFIG, { handling: "youtube", name: "Slow A", url: "https://www.youtube.com/@slow-a/videos", ytdlpExtraArgs: ["--test-slow"], ...(opts?.slowAInterval != null ? { syncIntervalMinutes: opts.slowAInterval } : {}), }); await writeChannelConfig(SLOW_B_CONFIG, { handling: "youtube", name: "Slow B", url: "https://www.youtube.com/@slow-b/videos", ytdlpExtraArgs: ["--test-slow"], }); // A baseline settings file with the scheduler off (editing intervals doesn't // require it to be on). await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, syncScheduler: { enabled: false, defaultIntervalMinutes: 60, maxConcurrentSyncs: 2, quietHoursStart: null, quietHoursEnd: null, backoffBaseMinutes: 30, backoffMaxMinutes: 1440, // Keep these specs on the cheap paged walk: a never-swept channel is // due for a full sweep the moment the default cadence is non-zero, and // writeSettings REPLACES the file, so the e2e default's 0 is gone here. fullSweepIntervalMinutes: 0, }, }); } test("schedule page saves a preset interval to the channel config", async ({ page, }) => { await seedTwoChannels(); await page.goto("/operations/sync"); const rowA = page.getByRole("row", { name: /Slow A/ }); // The cadence cell is collapsed to chips until you click into it. await rowA.getByRole("button", { name: "Edit cadence for slow-a" }).click(); await rowA.getByLabel("Auto-sync interval for slow-a").selectOption("60"); await rowA.getByRole("button", { name: "Save" }).click(); await expect(rowA.getByText("Saved")).toBeVisible(); const cfg = await readJson(SLOW_A_CONFIG); expect(cfg.syncIntervalMinutes).toBe(60); }); test("schedule page saves a custom minute count", async ({ page }) => { await seedTwoChannels(); await page.goto("/operations/sync"); const rowA = page.getByRole("row", { name: /Slow A/ }); await rowA.getByRole("button", { name: "Edit cadence for slow-a" }).click(); await rowA.getByLabel("Auto-sync interval for slow-a").selectOption("custom"); await rowA .getByLabel("Auto-sync interval for slow-a unit") .selectOption("minutes"); await rowA.getByLabel("Auto-sync interval for slow-a amount").fill("45"); await rowA.getByRole("button", { name: "Save" }).click(); await expect(rowA.getByText("Saved")).toBeVisible(); const cfg = await readJson(SLOW_A_CONFIG); expect(cfg.syncIntervalMinutes).toBe(45); }); test("schedule page clears an interval back to the global default", async ({ page, }) => { // Start with an explicit per-channel interval, then pick "Default". await seedTwoChannels({ slowAInterval: 30 }); await page.goto("/operations/sync"); const rowA = page.getByRole("row", { name: /Slow A/ }); await rowA.getByRole("button", { name: "Edit cadence for slow-a" }).click(); // The editor seeds to the "Every 30 minutes" preset; switch to Default, // which submits "" so the key is deleted rather than stored as a number. await rowA.getByLabel("Auto-sync interval for slow-a").selectOption("inherit"); await rowA.getByRole("button", { name: "Save" }).click(); await expect(rowA.getByText("Saved")).toBeVisible(); const cfg = await readJson(SLOW_A_CONFIG); expect("syncIntervalMinutes" in cfg).toBe(false); }); test("an in-progress custom edit survives the status poll", async ({ page }) => { // The 5s status poll re-renders the table; the per-row editor must not reset a // value the user is mid-typing. await seedTwoChannels(); await page.goto("/operations/sync"); const rowA = page.getByRole("row", { name: /Slow A/ }); await rowA.getByRole("button", { name: "Edit cadence for slow-a" }).click(); await rowA.getByLabel("Auto-sync interval for slow-a").selectOption("custom"); const customInput = rowA.getByLabel("Auto-sync interval for slow-a amount"); await customInput.fill("123"); // Wait past one poll interval (5s) without saving. await page.waitForTimeout(5500); await expect(customInput).toHaveValue("123"); }); test("schedule page edits the global controls", async ({ page }) => { await seedTwoChannels(); await page.goto("/operations/sync"); await page .getByRole("checkbox", { name: /Enable scheduled auto-sync/ }) .check(); await page.getByLabel("Default interval unit").selectOption("minutes"); await page.getByLabel("Default interval amount").fill("120"); await page.getByRole("button", { name: "Save controls" }).click(); await expect(page.getByText("Saved.")).toBeVisible(); const settings = await readJson<{ syncScheduler: { enabled: boolean; defaultIntervalMinutes: number }; }>("test-settings.json"); expect(settings.syncScheduler.enabled).toBe(true); expect(settings.syncScheduler.defaultIntervalMinutes).toBe(120); // The status badge reflects the now-enabled scheduler. await expect(page.getByText("Scheduler enabled")).toBeVisible(); }); test("a disabled scheduler queues nothing", async ({ page }) => { await resetData("two-slow-channels"); // Default test settings leave syncScheduler.enabled false. const tick = await page.request.post("/api/scheduler/tick"); expect(tick.ok()).toBeTruthy(); const r = (await tick.json()) as TickResult; expect(r.queued).toEqual([]); expect(r.reason).toBe("scheduler disabled"); });