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.
318 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|