- The scheduler keeps one registry of the runs this process has open, for the interval jobs and the ad-hoc runs alike. A row the table still says is `running` but no run here has open (its outcome failed to write) reads as interrupted, as the next start's sweep will close it: no pulsing dot beside "No run recorded yet". - Migration 0109's downgrade sums a still-running run's quota events into its spend before the link to them is dropped, as the startup sweep would. - A test holds that a dialog closing onto the same address leaves an open log line open.
376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The activity page's server-log tab, rendered through the page (which owns the shared filters and
|
|
// the pivots). A request or run id narrows the list to that request or run (and says so, in the
|
|
// filter row and the URL), and Back returns to the view the pivot was made from; the account in use
|
|
// 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),
|
|
adminUsers: () => Promise.resolve([{ id: 1, email: "admin@example.com" }]),
|
|
},
|
|
};
|
|
});
|
|
|
|
const copied: string[] = [];
|
|
vi.mock("../lib/clipboard", () => ({
|
|
copyText: (text: string) => {
|
|
copied.push(text);
|
|
return Promise.resolve(true);
|
|
},
|
|
}));
|
|
|
|
import Activity from "./Activity";
|
|
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"));
|
|
|
|
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,
|
|
run_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}>
|
|
<PageToolbarSlot.Provider value={slot}>
|
|
<Activity />
|
|
</PageToolbarSlot.Provider>
|
|
</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=activity&at=logs");
|
|
facetsMock.mockResolvedValue(FACETS);
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
cleanup();
|
|
logsMock.mockReset();
|
|
facetsMock.mockReset();
|
|
});
|
|
|
|
describe("AdminLogs", () => {
|
|
it("a dialog closing onto the same address leaves the open line open", async () => {
|
|
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
|
|
renderConsole();
|
|
await waitFor(() => expect(lines()).toHaveLength(1));
|
|
fireEvent.click(lines()[0]!);
|
|
expect(lines()[0]!.getAttribute("aria-expanded")).toBe("true");
|
|
// A closing dialog steps back over its own history entry: a popstate onto the same address.
|
|
act(() => {
|
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
});
|
|
expect(lines()[0]!.getAttribute("aria-expanded")).toBe("true");
|
|
});
|
|
|
|
it("a request id narrows the list to that request, with every other filter off", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity&at=logs&lgrp=error&aq=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("aq")]).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: "activity" },
|
|
"",
|
|
"/?page=activity&at=logs&lgrp=error&aq=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("aq")]).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("a run id narrows the list to that run's lines", async () => {
|
|
logsMock.mockResolvedValue({
|
|
items: [row(1, { run_id: 77 })],
|
|
accounts: {},
|
|
next_cursor: null,
|
|
});
|
|
renderConsole();
|
|
await waitFor(() => expect(lines()).toHaveLength(1));
|
|
fireEvent.click(lines()[0]!);
|
|
fireEvent.click(screen.getByRole("button", { name: "77" }));
|
|
|
|
await waitFor(() =>
|
|
expect(logsMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ run_id: 77, request_id: undefined }),
|
|
null,
|
|
),
|
|
);
|
|
expect(screen.getByText("adminLogs.pivotRun:77")).toBeTruthy();
|
|
expect(new URLSearchParams(window.location.search).get("lrun")).toBe("77");
|
|
});
|
|
|
|
it("keeps the account in use offered when the account list does not have it", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity&at=logs&au=7");
|
|
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
|
|
renderConsole();
|
|
await waitFor(() =>
|
|
expect(logsMock).toHaveBeenCalledWith(expect.objectContaining({ user: "7" }), null),
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId("activity-user"));
|
|
|
|
expect(await screen.findByTestId("activity-user-7")).toBeTruthy();
|
|
expect(screen.getByTestId("activity-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("facets that failed say the filter menus may be short, in the other tabs' words", async () => {
|
|
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
|
|
facetsMock.mockRejectedValue(new Error("500"));
|
|
renderConsole();
|
|
await waitFor(() => expect(lines()).toHaveLength(1));
|
|
expect((await screen.findByRole("alert")).textContent).toContain("activity.filtersFailed");
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|