Files
siftlode/frontend/src/components/Activity.dom.test.tsx
T
peter fad656988a fix(activity): runs keep their true outcome and spend, the page reaches every kept row, a closing dialog leaves the server log alone
Backend
- A run's outcome write sits outside the job's own try: a database blip after a run that
  succeeded no longer records it as failed (the row waits for the next start's sweep).
- An interrupted run keeps what it spent (the sweep sums its quota rows, as finish does).
- The Scheduler's ad-hoc row says running while any backfill runs, not only the newest.
- recent_runs reads each job's last runs off its index instead of ranking the whole table.
- An interval change is stored with its audit row in one transaction, then rescheduled.
- One API instance per database is the stated contract (.env.example, jobruns).
- Turning full history on for a channel already complete records no run.
- Migration 0109 round-trips interrupted and ad-hoc runs; the run words' DB CHECKs are
  test-held to the enums; the account filter is one helper for the four activity reads.
- An audit row says how many of its request's log lines are still kept.

Frontend
- An "All" period reaches every row still kept (runs and admin actions outlive 90 days).
- Back/Forward is followed in place by the server log: a closing dialog no longer turns
  Live off; a pivot still opens a fresh log.
- Runs moved over from the audit log read their one-liner in the reader's language.
- Filter choices or accounts that failed to load say so beside the filters, with a retry.
- An old server-log link keeps its account, text and range; a remembered retired page
  opens its tab.
- A request whose lines are gone says so instead of linking to an empty list.
- The log lines and the history rows share one row frame, and the server log one list
  frame with the other tabs; an empty list is announced; each day's quota is readable.
2026-10-18 15:38:32 +02:00

402 lines
17 KiB
TypeScript

// @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<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
const runsMock = vi.fn<(q: RunQuery, before: string | null) => Promise<HistoryPage<RunRow>>>();
const auditMock = vi.fn<(q: AuditQuery, before: string | null) => Promise<HistoryPage<AuditRow>>>();
const logsMock = vi.fn<(q: LogQuery, before: string | null) => Promise<unknown>>();
const quotaMock = vi.fn<(since: string, user: string | null) => Promise<unknown>>();
const runFacetsMock = vi.fn<() => Promise<unknown>>();
const usersMock = vi.fn<() => Promise<unknown>>();
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
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 = <T,>(items: T[]): HistoryPage<T> => ({
items,
accounts: { "1": "admin@example.com" },
next_cursor: null,
});
function renderPage() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
{/* Every confirmation is answered yes. */}
<ChoiceContext.Provider value={() => Promise.resolve("confirm")}>
<PageToolbarSlot.Provider value={slot}>
<Activity />
</PageToolbarSlot.Provider>
</ChoiceContext.Provider>
</QueryClientProvider>,
);
}
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<RunRow>([
{ ...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<AuditRow>([]));
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();
});
});