// @vitest-environment jsdom // // The scheduler's two number editors — a job's interval and the maintenance batch size. Both used to // hold a MIRROR of the server value that an effect re-synced; both now show the server value and keep // a draft only while the field is being edited. That matters here more than elsewhere // because this page polls: every few seconds a fresh status arrives, and a mirror is exactly the // thing a poll can overwrite at the wrong moment. import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { render, screen, fireEvent, cleanup, act, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }) }; }); import Scheduler from "./Scheduler"; import { NavigationActionsContext } from "./navigationContext"; import { api, type RunBrief, type SchedulerAdHoc, type SchedulerStatus } from "../lib/api"; import { qk } from "../lib/queryKeys"; const status = (over: Partial = {}): SchedulerStatus => ({ running_here: true, enabled: true, paused: false, jobs: [ { id: "sync", interval_minutes: 30, next_run: null, running: false, status: "ok", last_started: null, last_finished: null, last_result: null, result: null, last_error: null, last_trigger: null, last_run_id: null, quota_units: null, recent: [], progress: null, }, ], ad_hoc: [], queue: { channels_recent_pending: 0, channels_deep_pending: 0, deep_eta_seconds: 0, videos_pending_enrich: 0, videos_pending_shorts: 0, videos_live_refresh: 0, }, quota: { used_today: 0, remaining_today: 100, daily_budget: 100, backfill_reserve: 0 }, maintenance: { revalidate_batch: 5000, revalidate_batch_default: 5000, min: 1000, max: 50000 }, ...over, }); let qc: QueryClient; let current: SchedulerStatus; async function mount(initial = status()) { current = initial; qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( , ); await screen.findByText("scheduler.maintenance.batchLabel"); } /** A poll landing, driven through the query the page actually makes. */ async function poll(next: SchedulerStatus) { current = next; await act(() => qc.refetchQueries({ queryKey: qk.scheduler() })); } beforeEach(() => { vi.spyOn(api, "schedulerStatus").mockImplementation(() => Promise.resolve(current)); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); const intervalBox = () => screen.queryByRole("spinbutton", { name: "" }); const batchBox = () => screen.getByLabelText("scheduler.maintenance.batchLabel"); // The FIRST FOUR tests below are CHARACTERIZATION, not regression guards for the draft sweep: the old // JobRow's re-sync effect was already guarded by `if (!editing)` and the mirror only rendered while // editing, so each of those four passes against the pre-sweep code too. They are here because the // editor had no tests at all. The two after them — the range refusal and the focus return — ARE // regression guards, for the silent-discard fix, and they fail against the code this file shipped // with. The editor that genuinely lost the user's typing to a poll is the maintenance batch box // below: its old effect had no guard, and that one has its own test. describe("Scheduler · the job interval editor", () => { it("opens on the current interval and commits a new one", async () => { const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never); await mount(); fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); const box = intervalBox()!; expect(box).toHaveProperty("value", "30"); fireEvent.change(box, { target: { value: "45" } }); fireEvent.keyDown(box, { key: "Enter" }); expect(intervalBox()).toBeNull(); // committing closes the editor at once await waitFor(() => expect(update).toHaveBeenCalledWith("sync", 45)); }); it("drops what you typed on Escape and shows the server's value again", async () => { const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never); await mount(); fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); fireEvent.change(intervalBox()!, { target: { value: "999" } }); fireEvent.keyDown(intervalBox()!, { key: "Escape" }); expect(update).not.toHaveBeenCalled(); expect(intervalBox()).toBeNull(); fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); expect(intervalBox()).toHaveProperty("value", "30"); }); it("keeps what you are typing when a poll lands underneath", async () => { await mount(); fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); fireEvent.change(intervalBox()!, { target: { value: "45" } }); await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 30, running: true }] })); expect(intervalBox()).toHaveProperty("value", "45"); }); it("follows the server while the editor is closed", async () => { await mount(); await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }] })); await waitFor(() => expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(), ); fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); expect(intervalBox()).toHaveProperty("value", "90"); }); it("refuses a value outside the range instead of silently dropping it", async () => { const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never); await mount(); fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); fireEvent.change(intervalBox()!, { target: { value: "99999" } }); fireEvent.keyDown(intervalBox()!, { key: "Enter" }); expect(update).not.toHaveBeenCalled(); // Still open, still showing what was typed, and saying why. expect(intervalBox()).toHaveProperty("value", "99999"); expect(intervalBox()!.getAttribute("aria-invalid")).toBe("true"); expect(screen.getByRole("alert").textContent).toBe("scheduler.range"); }); it("hands focus back to the trigger when the editor closes", async () => { await mount(); const trigger = screen.getByRole("button", { name: /scheduler.everyMin/ }); fireEvent.click(trigger); expect(document.activeElement).toBe(intervalBox()); fireEvent.keyDown(intervalBox()!, { key: "Escape" }); await waitFor(() => expect(document.activeElement).toBe( screen.getByRole("button", { name: /scheduler.everyMin/ }), ), ); }); // A real Enter re-opened the editor — the keystroke's own activation landed on the trigger // the focus had just returned to. The closing keys cancel their default action. it("closing by a key does not press the trigger again", async () => { await mount(); for (const key of ["Enter", "Escape"]) { fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); expect(fireEvent.keyDown(intervalBox()!, { key })).toBe(false); } }); }); describe("Scheduler · the maintenance batch editor", () => { it("keeps what you are typing when a poll changes the batch underneath", async () => { // THE regression this editor needed: its old effect re-synced on every // `revalidate_batch` change with no editing guard, so a poll landing mid-keystroke replaced // what the admin had typed. await mount(); fireEvent.change(batchBox(), { target: { value: "9000" } }); // The poll carries a new interval TOO, and the interval label follows the server unconditionally // — so waiting for it is how this test knows the new status has actually been rendered. Without // that anchor the assertion below passes on a page that simply has not polled yet. await poll( status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }], maintenance: { ...status().maintenance, revalidate_batch: 12000 }, }), ); await waitFor(() => expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(), ); expect(batchBox()).toHaveProperty("value", "9000"); }); it("refuses a value outside min..max instead of silently dropping it", async () => { const save = vi.spyOn(api, "updateMaintenanceBatch").mockResolvedValue({ revalidate_batch: 5000, }); await mount(); fireEvent.change(batchBox(), { target: { value: "10" } }); // below m.min = 1000 fireEvent.keyDown(batchBox(), { key: "Enter" }); expect(save).not.toHaveBeenCalled(); expect(batchBox()).toHaveProperty("value", "10"); expect(batchBox().getAttribute("aria-invalid")).toBe("true"); expect(screen.getByRole("alert").textContent).toBe("scheduler.range"); expect(screen.getByRole("button", { name: "common.save" })).toHaveProperty("disabled", true); }); it("is not dirty until edited, and stops being dirty once saved", async () => { const save = vi .spyOn(api, "updateMaintenanceBatch") .mockResolvedValue({ revalidate_batch: 9000 }); await mount(); const saveBtn = () => screen.getByRole("button", { name: "common.save" }); expect(saveBtn()).toHaveProperty("disabled", true); fireEvent.change(batchBox(), { target: { value: "9000" } }); expect(saveBtn()).toHaveProperty("disabled", false); fireEvent.keyDown(batchBox(), { key: "Enter" }); expect(saveBtn()).toHaveProperty("disabled", true); // the draft is dropped at once await waitFor(() => expect(save).toHaveBeenCalledWith(9000)); }); it("follows a poll that changes the batch while nothing is being typed", async () => { await mount(); expect(batchBox()).toHaveProperty("value", "5000"); await poll(status({ maintenance: { ...status().maintenance, revalidate_batch: 12000 } })); await waitFor(() => expect(batchBox()).toHaveProperty("value", "12000")); }); }); describe("Scheduler while paused", () => { // Unavailable WITH a reason ("resume first"): the runs stay in the tab order, so a keyboard user // reaches the hint, and they are inert. Natively disabled, the reason was mouse-only. it("keeps Run all and a job's Run focusable and inert", async () => { const runAll = vi.spyOn(api, "runAllSchedulerJobs").mockResolvedValue({ started: [] }); const runOne = vi.spyOn(api, "runSchedulerJob").mockResolvedValue({ started: true } as never); await mount(status({ paused: true })); for (const name of ["scheduler.runAll", "scheduler.runNow"]) { const btn = screen.getByRole("button", { name }); expect(btn.hasAttribute("disabled")).toBe(false); expect(btn.getAttribute("aria-disabled")).toBe("true"); fireEvent.click(btn); } expect(runAll).not.toHaveBeenCalled(); expect(runOne).not.toHaveBeenCalled(); }); }); describe("a job's last result", () => { it("is named from its data, not the server's English one-liner", async () => { const base = status(); await mount({ ...base, jobs: [ { ...base.jobs[0]!, last_finished: new Date().toISOString(), last_result: "new=3, failed=0", result: { new: 3, failed: 0 }, }, ], }); // Key-echo i18n: the keys named, the counts shown. expect( await screen.findByText(/scheduler\.result\.new: 3 · scheduler\.result\.failed: 0/), ).toBeTruthy(); expect(screen.queryByText(/new=3/)).toBeNull(); }); }); describe("a row's run history", () => { const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString(); const run = (id: number, status: RunBrief["status"], minutesAgo: number): RunBrief => ({ id, status, trigger: "scheduled", started_at: at(minutesAgo), finished_at: status === "running" ? null : at(minutesAgo), }); const dots = (root: HTMLElement = document.body) => within(root) .getAllByRole("img") .map((d) => d.getAttribute("data-dot")); // An interrupted run fell through to "idle" — a grey dot that said no run was ever recorded. it("shows the last runs newest first, an interrupted one as such", async () => { const base = status(); await mount({ ...base, jobs: [ { ...base.jobs[0]!, status: "interrupted", last_finished: at(2), recent: [run(3, "interrupted", 2), run(2, "error", 12), run(1, "ok", 22)], }, ], }); expect(dots()).toEqual(["interrupted", "error", "ok"]); expect(screen.getByText(/scheduler\.interrupted/)).toBeTruthy(); expect(screen.getByText("scheduler.dot.interrupted")).toBeTruthy(); // the legend names it }); it("shows one idle dot for a job that never ran", async () => { await mount(); expect(dots()).toEqual(["idle"]); }); it("gives a run the job has claimed, but not yet recorded, the newest dot", async () => { const base = status(); await mount({ ...base, jobs: [ { ...base.jobs[0]!, running: true, recent: [run(3, "ok", 10), run(2, "ok", 20), run(1, "error", 30)], }, ], }); expect(dots()).toEqual(["running", "ok", "ok"]); }); // The one-channel full-history backfill ran unseen: a background task only the log heard of. it("lists a user's ad-hoc run beside the jobs, with its channel and no run button", async () => { const base = status(); const adHoc: SchedulerAdHoc = { ...base.jobs[0]!, id: "channel_backfill", running: false, target: "UC_bbc", target_title: "BBC Earth", last_finished: at(60), result: { videos_added: 69 }, recent: [run(9, "ok", 60), run(8, "ok", 120)], }; await mount({ ...base, ad_hoc: [adHoc] }); expect(screen.getByText("scheduler.adHocTitle")).toBeTruthy(); const row = screen.getByText("scheduler.jobs.channel_backfill").closest(".p-3")!; expect(dots(row)).toEqual(["ok", "ok"]); expect(within(row).getByText(/BBC Earth/)).toBeTruthy(); expect(within(row).getByText(/scheduler\.result\.videos_added: 69/)).toBeTruthy(); expect(within(row).getByText("scheduler.adHocStarted")).toBeTruthy(); expect(within(row).queryByRole("button", { name: "scheduler.runNow" })).toBeNull(); }); }); describe("a row's history", () => { // The dots show three runs; the rest is the activity page's run list, narrowed to the row's job. it("opens the activity page's runs of that job", async () => { const setPage = vi.fn((_page: string, then?: () => void) => then?.()); window.history.replaceState(null, "", "/?page=scheduler"); current = status(); qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( {}, closeChannel: () => {}, enterYtSearch: () => {}, exitYtSearch: () => {}, }} > , ); await screen.findByText("scheduler.maintenance.batchLabel"); const job = current.jobs[0]!.id; const row = screen.getByText(`scheduler.jobs.${job}`).closest(".p-3")!; fireEvent.click(within(row).getByRole("button", { name: "scheduler.history" })); expect(setPage).toHaveBeenCalledWith("activity", expect.any(Function)); const url = new URLSearchParams(window.location.search); expect([url.get("page"), url.get("rjob")]).toEqual(["activity", job]); }); });