// @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(); return { ...actual, useTranslation: () => ({ t: (key: string, vars?: Record) => vars?.id != null ? `${key}:${String(vars.id)}` : key, i18n: { language: "en" }, }), }; }); const logsMock = vi.fn<(query: LogQuery, before: string | null) => Promise>(); const facetsMock = vi.fn<(query: LogQuery) => Promise>(); vi.mock("../lib/api", async (importOriginal) => { const actual = await importOriginal(); 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 { 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( , ); } // 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((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((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(); }); });