Files
siftlode/frontend/src/components/AdminLogs.dom.test.tsx
T
peter 2d5bd275fa fix(diagnostics): a pivot or Back applies the search text at once
The query read the typing-debounced search, so for 300 ms after a jump it mixed the new view's
filters with the old view's text and fetched rows neither view shows. useDebounced takes a settle key
whose change applies the value immediately.
2026-09-19 05:47:16 +02:00

318 lines
12 KiB
TypeScript

// @vitest-environment jsdom
//
// E22.6: the admin log console. A request id narrows the list to that request (and says so, in the
// filter row and the URL); a filter value the facets no longer count stays pickable; "Live" watches
// the newest page only, and stops on a failed read instead of re-raising it; a failed read is an
// error, not an empty log; Copy takes every loaded line, oldest first.
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 { LogFacets, LogPage, LogQuery, ServerLogRow } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars?.id != null ? `${key}:${String(vars.id)}` : key,
i18n: { language: "en" },
}),
};
});
const logsMock = vi.fn<(query: LogQuery, before: string | null) => Promise<LogPage>>();
const facetsMock = vi.fn<(query: LogQuery) => Promise<LogFacets>>();
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
adminLogs: (query: LogQuery, before: string | null) => logsMock(query, before),
adminLogFacets: (query: LogQuery) => facetsMock(query),
},
};
});
const copied: string[] = [];
vi.mock("../lib/clipboard", () => ({
copyText: (text: string) => {
copied.push(text);
return Promise.resolve(true);
},
}));
import AdminLogs from "./AdminLogs";
function row(id: number, extra: Partial<ServerLogRow> = {}): ServerLogRow {
return {
id,
ts: new Date(Date.UTC(2026, 8, 19, 12, 0, id)).toISOString(),
level: "INFO",
source: "api",
logger: "siftlode.http",
event: "http.request",
msg: `GET /api/thing/${id} 200`,
attrs: null,
exc: null,
request_id: `req-${String(id).padStart(8, "0")}`,
user_id: 1,
job_id: null,
debug_session_id: null,
version: "0.90.0",
...extra,
};
}
const FACETS: LogFacets = {
levels: [{ value: "INFO", count: 3 }],
sources: [{ value: "api", count: 3 }],
events: [{ value: "http.request", count: 3 }],
users: [{ value: "1", count: 3 }],
accounts: { "1": "admin@example.com" },
total: 3,
};
function renderConsole() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<AdminLogs />
</QueryClientProvider>,
);
}
// react-query hands its updates to React on a (real) timer tick, after the promise that caused them.
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 20)));
const lines = () =>
within(screen.getByTestId("admin-logs-lines"))
.queryAllByRole("button")
.filter((b) => b.hasAttribute("aria-expanded"));
beforeEach(() => {
window.history.replaceState(null, "", "/?page=logs");
facetsMock.mockResolvedValue(FACETS);
});
afterEach(() => {
vi.useRealTimers();
cleanup();
logsMock.mockReset();
facetsMock.mockReset();
});
describe("AdminLogs", () => {
it("a request id narrows the list to that request, with every other filter off", async () => {
window.history.replaceState(null, "", "/?page=logs&lgrp=error&lq=boom");
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
fireEvent.click(lines()[0]!);
fireEvent.click(screen.getByRole("button", { name: "req-00000001" }));
await waitFor(() =>
expect(logsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ request_id: "req-00000001", levels: undefined, q: undefined }),
null,
),
);
expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy();
const url = new URLSearchParams(window.location.search);
expect([url.get("lreq"), url.get("lgrp"), url.get("lq")]).toEqual(["req-00000001", null, null]);
});
it("Back from a request's lines returns to the view the pivot was made from", async () => {
window.history.replaceState({ sfPage: "logs" }, "", "/?page=logs&lgrp=error&lq=boom");
const depth = window.history.length;
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
fireEvent.click(lines()[0]!);
fireEvent.click(screen.getByRole("button", { name: "req-00000001" }));
await waitFor(() =>
expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy(),
);
expect(window.history.length).toBe(depth + 1);
await act(async () => {
const landed = new Promise((resolve) =>
window.addEventListener("popstate", resolve, { once: true }),
);
window.history.back();
await landed;
});
await waitFor(() =>
expect(logsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
request_id: undefined,
levels: ["ERROR", "CRITICAL"],
q: "boom",
}),
null,
),
);
expect(screen.queryByText("adminLogs.pivotRequest:req-00000001")).toBeNull();
const url = new URLSearchParams(window.location.search);
expect([url.get("lreq"), url.get("lgrp"), url.get("lq")]).toEqual([null, "error", "boom"]);
// Neither jump waited out the typing debounce: no read ever mixed one view's filters with the
// other's search text (the request with "boom", or the restored view without it).
const mixed = logsMock.mock.calls.filter(([q]) => (q.request_id != null) === (q.q === "boom"));
expect(mixed).toEqual([]);
});
it("keeps the account in use offered when the facets no longer count it", async () => {
window.history.replaceState(null, "", "/?page=logs&lu=7");
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() => expect(facetsMock).toHaveBeenCalled());
fireEvent.click(screen.getByTestId("logs-user"));
expect(await screen.findByTestId("logs-user-7")).toBeTruthy();
expect(screen.getByTestId("logs-user-1")).toBeTruthy();
});
it("Live watches the newest page only", async () => {
logsMock.mockImplementation(async (_q, before) =>
before === null
? { items: [row(3), row(2)], accounts: {}, next_cursor: "c1" }
: { items: [row(1)], accounts: {}, next_cursor: null },
);
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(2));
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
await waitFor(() => expect(lines()).toHaveLength(3));
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
await waitFor(() => expect(lines()).toHaveLength(2));
expect(
screen.getByRole("button", { name: "adminLogs.live" }).getAttribute("aria-pressed"),
).toBe("true");
});
it("a page that lands after Live went on is dropped at the next tick", async () => {
let landOlder!: (page: LogPage) => void;
logsMock.mockImplementation((_q, before) =>
before === null
? Promise.resolve({ items: [row(3), row(2)], accounts: {}, next_cursor: "c1" })
: new Promise<LogPage>((resolve) => (landOlder = resolve)),
);
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(2));
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
await waitFor(() => expect(landOlder).toBeTypeOf("function"));
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
await act(async () => landOlder({ items: [row(1)], accounts: {}, next_cursor: null }));
await settle();
expect(lines()).toHaveLength(3);
const olderReads = logsMock.mock.calls.filter(([, before]) => before !== null).length;
await act(async () => vi.advanceTimersByTimeAsync(4000));
await settle();
expect(lines()).toHaveLength(2);
expect(logsMock.mock.calls.filter(([, before]) => before !== null)).toHaveLength(olderReads);
});
it("a failed live read stops the polling and says the list may be old", async () => {
logsMock.mockResolvedValueOnce({ items: [row(1)], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
logsMock.mockRejectedValue(new Error("500"));
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
await act(async () => vi.advanceTimersByTimeAsync(4000));
await settle();
expect(screen.getByRole("alert").textContent).toContain("adminLogs.stale");
expect(lines()).toHaveLength(1);
const reads = logsMock.mock.calls.length;
await act(async () => vi.advanceTimersByTimeAsync(12000));
expect(logsMock.mock.calls.length).toBe(reads);
// The button says polling stopped, and pressing it reads again.
const liveButton = screen.getByRole("button", { name: "adminLogs.live" });
expect(liveButton.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(liveButton);
await settle();
expect(logsMock.mock.calls.length).toBe(reads + 1);
});
it("a failed older page says so, not that the list is out of date", async () => {
logsMock.mockImplementation(async (_q, before) => {
if (before !== null) throw new Error("500");
return { items: [row(2)], accounts: {}, next_cursor: "c1" };
});
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
expect((await screen.findByRole("alert")).textContent).toContain("adminLogs.olderFailed");
});
it("offers no older page while a filter change loads", async () => {
let landErrors!: (page: LogPage) => void;
logsMock.mockImplementation((q) =>
q.levels
? new Promise<LogPage>((resolve) => (landErrors = resolve))
: Promise.resolve({ items: [row(2)], accounts: {}, next_cursor: "c1" }),
);
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
expect(screen.queryByRole("button", { name: "adminLogs.older" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.error/ }));
await waitFor(() => expect(landErrors).toBeTypeOf("function"));
// The rows on screen are the previous filter's: their cursor is not this list's.
expect(screen.queryByRole("button", { name: "adminLogs.older" })).toBeNull();
await act(async () => landErrors({ items: [row(1)], accounts: {}, next_cursor: null }));
});
it("copies every loaded line, oldest first", async () => {
logsMock.mockImplementation(async (_q, before) =>
before === null
? { items: [row(3), row(2)], accounts: {}, next_cursor: "c1" }
: { items: [row(1)], accounts: {}, next_cursor: null },
);
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(2));
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
await waitFor(() => expect(lines()).toHaveLength(3));
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "adminLogs.copy" }));
});
const ids = copied[copied.length - 1]!.split("\n").map((l) => JSON.parse(l).id);
expect(ids).toEqual([1, 2, 3]);
});
it("the keyboard can walk the lines and open one (NavGrid items)", async () => {
logsMock.mockResolvedValue({ items: [row(2), row(1)], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(2));
const items = lines().map((b) => b.parentElement!);
expect(items.map((el) => el.getAttribute("data-nav-index"))).toEqual(["0", "1"]);
expect(lines().every((b) => b.hasAttribute("data-nav-open"))).toBe(true);
expect(screen.getByTestId("admin-logs-lines").querySelector("[data-nav-list]")).toBeTruthy();
});
it("a failed read is an error with a retry, not an empty log", async () => {
logsMock.mockRejectedValue(new Error("502"));
renderConsole();
expect(await screen.findByRole("alert")).toBeTruthy();
expect(screen.queryByText("adminLogs.empty")).toBeNull();
});
});