// @vitest-environment jsdom // // The activity page around its run and audit lists (the server-log tab has its own file): the view // is the URL — a link narrows the runs to one job, a status chip narrows them further; a run opens // to its log lines and an admin action to its request's, each a new history entry that Back undoes; // the account filter reaches the quota tab; a // failed read is an error with a retry, never an empty list. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { AuditQuery, AuditRow, HistoryPage, LogQuery, RunQuery, RunRow } from "../lib/api"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), }; }); const runsMock = vi.fn<(q: RunQuery, before: string | null) => Promise>>(); const auditMock = vi.fn<(q: AuditQuery, before: string | null) => Promise>>(); const logsMock = vi.fn<(q: LogQuery, before: string | null) => Promise>(); const quotaMock = vi.fn<(since: string, user: string | null) => Promise>(); const runFacetsMock = vi.fn<() => Promise>(); const usersMock = vi.fn<() => Promise>(); vi.mock("../lib/api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, api: { ...actual.api, adminRuns: (q: RunQuery, before: string | null) => runsMock(q, before), adminRunFacets: () => runFacetsMock(), adminAudit: (q: AuditQuery, before: string | null) => auditMock(q, before), adminAuditFacets: () => Promise.resolve({ actions: [] }), adminLogs: (q: LogQuery, before: string | null) => logsMock(q, before), adminLogFacets: () => Promise.resolve({ levels: [], sources: [], events: [], users: [], accounts: {}, total: 0 }), adminUsers: () => usersMock(), adminQuota: (since: string, user: string | null) => quotaMock(since, user), clearAudit: () => Promise.resolve({ ok: true, cleared: 1 }), }, }; }); import Activity from "./Activity"; import { ChoiceContext } from "./confirmContext"; import { PageToolbarSlot } from "./ui/pageToolbarSlot"; // The filter bar lives in the toolbar, which portals into the shell's band: give it one. const slot = document.body.appendChild(document.createElement("div")); const RUN: RunRow = { id: 41, job: "backfill", trigger: "manual", actor_id: 1, target: null, target_title: null, status: "error", started_at: "2026-10-18T02:31:04Z", finished_at: "2026-10-18T02:31:45Z", summary: null, result: null, error: "quotaExceeded", failure: null, quota_units: 212, log_lines: 9, }; const ACTION: AuditRow = { id: 5, created_at: "2026-10-18T02:27:00Z", actor_id: 1, action: "scheduler_interval", target_type: "job", target_id: "rss_poll", summary: "rss_poll → 25 min/run", before: null, after: { interval_minutes: 25 }, request_id: "0efdd960c9ad1977", log_lines: 4, }; const page = (items: T[]): HistoryPage => ({ items, accounts: { "1": "admin@example.com" }, next_cursor: null, }); function renderPage() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( {/* Every confirmation is answered yes. */} Promise.resolve("confirm")}> , ); } const url = () => new URLSearchParams(window.location.search); const rowButton = (text: RegExp) => screen .getAllByRole("button") .find((b) => b.hasAttribute("aria-expanded") && text.test(b.textContent ?? ""))!; beforeEach(() => { runsMock.mockResolvedValue(page([RUN])); auditMock.mockResolvedValue(page([ACTION])); logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null }); quotaMock.mockResolvedValue({ rows: [], daily: [] }); runFacetsMock.mockResolvedValue({ jobs: [], statuses: [], total: 0 }); usersMock.mockResolvedValue([{ id: 1, email: "admin@example.com" }]); }); afterEach(() => { cleanup(); vi.clearAllMocks(); }); describe("Activity", () => { it("a link narrows the runs to one job, and a status chip narrows them further", async () => { window.history.replaceState(null, "", "/?page=activity&rjob=backfill"); renderPage(); await waitFor(() => expect(runsMock).toHaveBeenCalledWith( expect.objectContaining({ job: "backfill", status: undefined, since: expect.any(String) }), null, ), ); fireEvent.click(screen.getByRole("button", { name: /activity\.runs\.status\.error/ })); await waitFor(() => expect(runsMock).toHaveBeenLastCalledWith( expect.objectContaining({ job: "backfill", status: "error" }), null, ), ); expect([url().get("rjob"), url().get("rst")]).toEqual(["backfill", "error"]); }); it("Older reads the next page from its cursor, and offers none past the last", async () => { window.history.replaceState(null, "", "/?page=activity"); runsMock.mockImplementation((_q, before) => Promise.resolve( before === null ? { ...page([RUN]), next_cursor: "c1" } : page([{ ...RUN, id: 40, error: "the older one" }]), ), ); renderPage(); fireEvent.click(await screen.findByRole("button", { name: "activity.older" })); await waitFor(() => expect(screen.getByText("the older one")).toBeTruthy()); expect(runsMock).toHaveBeenLastCalledWith(expect.anything(), "c1"); expect(screen.getByText("quotaExceeded")).toBeTruthy(); expect(screen.queryByRole("button", { name: "activity.older" })).toBeNull(); }); it("an older page that fails says so and keeps what was loaded", async () => { window.history.replaceState(null, "", "/?page=activity"); runsMock.mockImplementation((_q, before) => before === null ? Promise.resolve({ ...page([RUN]), next_cursor: "c1" }) : Promise.reject(new Error("500")), ); renderPage(); fireEvent.click(await screen.findByRole("button", { name: "activity.older" })); const alert = await screen.findByRole("alert"); expect(alert.textContent).toContain("activity.olderFailed"); expect(screen.getByText("quotaExceeded")).toBeTruthy(); }); it("offers no older page while a filter change is still loading", async () => { window.history.replaceState(null, "", "/?page=activity"); // The cursor on screen is the previous filter's: it would continue the wrong list. runsMock.mockImplementation((q) => q.status ? new Promise(() => {}) : Promise.resolve({ ...page([RUN]), next_cursor: "c1" }), ); renderPage(); await screen.findByRole("button", { name: "activity.older" }); fireEvent.click(screen.getByRole("button", { name: /activity\.runs\.status\.error/ })); await waitFor(() => expect(screen.queryByRole("button", { name: "activity.older" })).toBeNull(), ); expect(screen.getByText("quotaExceeded")).toBeTruthy(); // the old rows stay, dimmed }); it("a run opens to its log lines, and Back returns to the runs", async () => { window.history.replaceState({ sfPage: "activity" }, "", "/?page=activity&aw=24h&aq=quota"); renderPage(); await waitFor(() => expect(screen.getByText("quotaExceeded")).toBeTruthy()); fireEvent.click(rowButton(/quotaExceeded/)); fireEvent.click(screen.getByRole("button", { name: "activity.runs.logLines" })); await waitFor(() => expect(logsMock).toHaveBeenLastCalledWith( expect.objectContaining({ run_id: 41, q: undefined }), null, ), ); // Every other filter is off, and the range is the log's whole retention. expect([url().get("at"), url().get("lrun"), url().get("aw"), url().get("aq")]).toEqual([ "logs", "41", "90d", null, ]); await act(async () => { const landed = new Promise((resolve) => window.addEventListener("popstate", resolve, { once: true }), ); window.history.back(); await landed; }); await waitFor(() => expect(url().get("at")).toBeNull()); expect([url().get("aw"), url().get("aq"), url().get("lrun")]).toEqual(["24h", "quota", null]); expect(screen.getByText("quotaExceeded")).toBeTruthy(); }); it("a run's outcome is its result in the reader's words, its failure in the danger colour", async () => { runsMock.mockResolvedValue( page([ { ...RUN, id: 1, status: "ok", error: null, result: { new: 3 }, summary: "new=3" }, // Moved over from the audit log before the result rode along as data: the one-liner only. { ...RUN, id: 2, status: "ok", error: null, result: null, summary: "failed=2" }, { ...RUN, id: 3, status: "interrupted", error: null }, { ...RUN, id: 4 }, ]), ); renderPage(); await waitFor(() => expect(screen.getByText("scheduler.result.new: 3")).toBeTruthy()); expect(screen.queryByText("new=3")).toBeNull(); expect(screen.getByText("scheduler.result.failed: 2")).toBeTruthy(); expect(screen.getByText("scheduler.interrupted").className).toContain("text-warn"); expect(screen.getByText("quotaExceeded").className).toContain("text-danger"); }); it("filter choices that failed to load say so beside the filters, with a retry", async () => { runFacetsMock.mockRejectedValueOnce(new Error("500")); usersMock.mockRejectedValueOnce(new Error("500")); renderPage(); await waitFor(() => expect(screen.getAllByRole("alert").map((a) => a.textContent)).toEqual([ expect.stringContaining("activity.accountsFailed"), expect.stringContaining("activity.filtersFailed"), ]), ); for (const retry of screen.getAllByRole("button", { name: "common.retry" })) fireEvent.click(retry); await waitFor(() => expect(screen.queryAllByRole("alert")).toEqual([])); expect(runFacetsMock).toHaveBeenCalledTimes(2); expect(usersMock).toHaveBeenCalledTimes(2); }); it("clearing the admin actions reads the list again", async () => { window.history.replaceState(null, "", "/?page=activity&at=admin"); renderPage(); await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy()); auditMock.mockResolvedValue(page([])); fireEvent.click(screen.getByRole("button", { name: "audit.clear" })); await waitFor(() => expect(screen.getByText("audit.empty")).toBeTruthy()); // An emptied list is announced, as the error and loading states are. expect(screen.getByText("audit.empty").closest('[role="status"]')).toBeTruthy(); }); it("closing a dialog over the server log leaves Live on", async () => { window.history.replaceState(null, "", "/?page=activity&at=logs"); renderPage(); const live = await screen.findByRole("button", { name: "adminLogs.live" }); fireEvent.click(live); expect(live.getAttribute("aria-pressed")).toBe("true"); // A closing dialog steps back over its own history entry: a popstate onto the same address. act(() => { window.dispatchEvent(new PopStateEvent("popstate")); }); const after = screen.getByRole("button", { name: "adminLogs.live" }); expect(after.getAttribute("aria-pressed")).toBe("true"); expect(after).toBe(live); // the same log, not a fresh one }); it("an action whose request's lines are gone says so instead of linking to an empty list", async () => { auditMock.mockResolvedValue(page([{ ...ACTION, log_lines: 0 }])); window.history.replaceState(null, "", "/?page=activity&at=admin"); renderPage(); await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy()); fireEvent.click(rowButton(/scheduler_interval/)); expect(screen.getByText("activity.audit.noRequestLines")).toBeTruthy(); expect(screen.queryByRole("button", { name: "activity.audit.requestLines" })).toBeNull(); }); it("the admin tab lists the actions, and an action opens its request's lines", async () => { window.history.replaceState(null, "", "/?page=activity&at=admin"); renderPage(); await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy()); expect(runsMock).not.toHaveBeenCalled(); fireEvent.click(rowButton(/auditActions\.scheduler_interval/)); fireEvent.click(screen.getByRole("button", { name: "activity.audit.requestLines" })); await waitFor(() => expect(logsMock).toHaveBeenLastCalledWith( expect.objectContaining({ request_id: "0efdd960c9ad1977" }), null, ), ); expect(url().get("lreq")).toBe("0efdd960c9ad1977"); }); it("the account filter reaches the quota tab, over the page's range", async () => { window.history.replaceState(null, "", "/?page=activity&at=quota&au=none&aw=30d"); renderPage(); await waitFor(() => expect(quotaMock).toHaveBeenCalled()); const [since, user] = quotaMock.mock.calls[0]!; expect(user).toBe("none"); const days = (Date.now() - new Date(since).getTime()) / 86_400_000; expect(Math.round(days)).toBe(30); }); it("each day's spend is readable without a pointer", async () => { window.history.replaceState(null, "", "/?page=activity&at=quota"); quotaMock.mockResolvedValue({ rows: [{ user_id: null, email: "System", total: 9, by_action: {} }], daily: [ { day: "2026-10-16", total: 4 }, { day: "2026-10-17", total: 5 }, ], }); renderPage(); const chart = await screen.findByRole("list", { name: "activity.quota.daily" }); expect( within(chart) .getAllByRole("listitem") .map((d) => d.textContent), ).toEqual(["stats.dailyTooltip", "stats.dailyTooltip"]); }); // The runs moved over from the audit log kept one instant for start and end: their length is // unknown, and a "1 sec" would be invented. it("a run whose start and end are one instant has no length", async () => { window.history.replaceState(null, "", "/?page=activity"); runsMock.mockResolvedValue( page([ { ...RUN, id: 1, status: "ok", error: null, finished_at: RUN.started_at, summary: "moved" }, { ...RUN, id: 2, status: "ok", error: null, summary: "timed" }, ]), ); renderPage(); await waitFor(() => expect(screen.getByText("moved")).toBeTruthy()); const cells = (text: string) => [...rowButton(new RegExp(text)).children].map((c) => c.textContent); expect(cells("moved")).toContain("—"); expect(cells("timed")).not.toContain("—"); }); // A bare "212" in a row reads as nothing in particular: the spend carries its unit, and a run that // spent none says nothing rather than "0". it("a run's quota spend reads with its unit, and none spent leaves the cell blank", async () => { window.history.replaceState(null, "", "/?page=activity"); runsMock.mockResolvedValue( page([ { ...RUN, id: 1, status: "ok", error: null, summary: "spent" }, { ...RUN, id: 2, status: "ok", error: null, summary: "free", quota_units: 0 }, ]), ); renderPage(); await waitFor(() => expect(screen.getByText("spent")).toBeTruthy()); const cells = (text: string) => [...rowButton(new RegExp(text)).children].map((c) => c.textContent); expect(cells("spent")).toContain("activity.runs.units"); expect(cells("spent")).not.toContain("212"); expect(cells("free")).not.toContain("0"); expect(cells("free")).not.toContain("activity.runs.units"); }); // Most runs store no line at all: a link there would only lead to an empty list. it("a run that logged nothing says so instead of linking to an empty list", async () => { window.history.replaceState(null, "", "/?page=activity"); runsMock.mockResolvedValue(page([{ ...RUN, log_lines: 0 }])); renderPage(); await waitFor(() => expect(screen.getByText("quotaExceeded")).toBeTruthy()); fireEvent.click(rowButton(/quotaExceeded/)); expect(screen.getByText("activity.runs.noLogLines")).toBeTruthy(); expect(screen.queryByRole("button", { name: "activity.runs.logLines" })).toBeNull(); }); it("a failed read is an error with a retry, not an empty list", async () => { window.history.replaceState(null, "", "/?page=activity"); runsMock.mockRejectedValue(new Error("502")); renderPage(); expect(await screen.findByRole("alert")).toBeTruthy(); expect(screen.queryByText("activity.runs.empty")).toBeNull(); }); });