Files
siftlode/frontend/src/components/Scheduler.dom.test.tsx
T
peter 24448e7c6f feat(activity): one Activity page — runs, admin actions, server log and quota under one filter bar
The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.

Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.

Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
2026-10-18 03:45:18 +02:00

401 lines
16 KiB
TypeScript

// @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<typeof import("react-i18next")>();
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> = {}): 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(
<QueryClientProvider client={qc}>
<Scheduler />
</QueryClientProvider>,
);
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<HTMLElement>(".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(
<NavigationActionsContext.Provider
value={{
setPage,
openChannel: () => {},
closeChannel: () => {},
enterYtSearch: () => {},
exitYtSearch: () => {},
}}
>
<QueryClientProvider client={qc}>
<Scheduler />
</QueryClientProvider>
</NavigationActionsContext.Provider>,
);
await screen.findByText("scheduler.maintenance.batchLabel");
const job = current.jobs[0]!.id;
const row = screen.getByText(`scheduler.jobs.${job}`).closest<HTMLElement>(".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]);
});
});