Files
nxdns/admin/src/features/activity/LiveActivity.test.tsx
T
mokhtar 22abcd9b7b release: nix flake with tag-pinned hashes, reproducible tarballs (milestone 40)
flake.nix fetches the release tarballs and carries their SRI hashes in a generated block. The cut tool builds the release locally with the toolchain gates.yml pins, in a normalized nine-variable environment, writes the hashes into flake.nix, and commits it with build.zig.zon as the single bump commit. The package job verifies the pins on the bump commit and the publish job verifies them again on the tag, before anything is uploaded.

The tarballs are written by dist_stage (std.tar.Writer, flate gzip) instead of the runner's tar and gzip, and -ffile-prefix-map keeps checkout paths out of the C objects; two checkouts at different absolute paths produce byte-identical archives. nxdns version, /api/version and the admin footer report the version only: the bump commit cannot know its own sha.
2026-09-08 21:45:22 +02:00

588 lines
23 KiB
TypeScript

/**
* Activity in live mode, through the real router.
*
* The EventSource is a global here rather than an injected factory: whether the
* connection exists at all is the thing under test, and that is decided by
* which subtree the URL mounts, not by a prop a caller could pass.
*/
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterContextProvider, RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import type { Client } from "@/lib/types";
import { provenance, queryRow } from "@/features/provenance/provenanceFixture";
import { health } from "@/lib/healthFixture";
import { FakeEventSource } from "./fakeEventSource";
import LiveActivity from "./LiveActivity";
import type { ActivitySearch } from "./search";
const LIVE_ORIGIN: ActivitySearch = {
mode: "live",
since: undefined,
until: undefined,
domain: undefined,
client: undefined,
blocked: undefined,
};
function client(ip: string, name: string, learnedName: string): Client {
return {
id: Number(ip.split(".").pop()),
ip,
name,
learned_name: learnedName,
group_id: 1,
group: "default",
hand_edited: name !== "",
first_seen: 1_700_000_000,
last_seen: 1_700_000_100,
};
}
const CLIENTS: Client[] = [
client("192.0.2.10", "Kitchen Pi", "pi.lan"),
client("192.0.2.11", "", "laptop.lan"),
client("192.0.2.12", "", ""),
];
const VERSION = { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 };
let sources: FakeEventSource[];
let fetchMock: ReturnType<typeof vi.fn>;
function json(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
/**
* The empty history page. Live mode asks for no query pages, but switching back
* to History does, and a page-shaped response is the only honest answer there.
*/
const EMPTY_PAGE = { queries: [], next_before: null, coverage: { complete: true, available_since: 0 } };
function defaultHandler(url: string): Response {
return json(url === "/api/queries" || url.startsWith("/api/queries?") ? EMPTY_PAGE : {});
}
function stubFetch(handler: (url: string) => Response | Promise<Response> = defaultHandler) {
fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
if (url === "/api/clients") return Promise.resolve(json({ clients: CLIENTS }));
// The shell reads health on every route for the Diagnostics nav badge.
if (url === "/api/health") return Promise.resolve(json(health()));
return Promise.resolve(handler(url));
});
vi.stubGlobal("fetch", fetchMock);
}
beforeEach(() => {
sources = [];
vi.stubGlobal(
"EventSource",
class {
constructor(url: string) {
const source = new FakeEventSource(url);
sources.push(source);
return source as unknown as EventSource;
}
},
);
stubFetch();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function frame(ts: number, domain: string, sections: Parameters<typeof provenance>[0] = {}): { data: string } {
return {
data: JSON.stringify(
provenance({
...sections,
request: { time: ts, domain, ...sections.request },
route: { kind: "cache", upstream: "", ...sections.route },
}),
),
};
}
function renderPage(path = "/activity?mode=live") {
const queryClient = createQueryClient();
const history = createMemoryHistory({ initialEntries: [path] });
const router = createAppRouter(history, queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return { history };
}
async function openLive(path?: string) {
const rendered = renderPage(path);
await screen.findByRole("button", { name: "Freeze" });
act(() => sources[0]!.emit("open"));
return rendered;
}
function queryCalls(): string[] {
return fetchMock.mock.calls
.map((call) => String(call[0]))
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
}
test("streams rows, flags blocked ones, and freezes the display", async () => {
await openLive();
expect(screen.getByRole("status", { name: "Live" })).toBeTruthy();
expect(screen.getByText("Waiting for queries…")).toBeTruthy();
act(() => {
sources[0]!.emit("query", frame(1000, "ok.example"));
sources[0]!.emit(
"query",
frame(1001, "ads.example", {
request: { qtype: 28 },
policy: { action: "block", reason: "blocklist_wildcard" },
route: { kind: "blocked" },
}),
);
});
expect(screen.getByText("ok.example")).toBeTruthy();
expect(screen.getByText("AAAA")).toBeTruthy();
const blockedRow = screen.getByText("ads.example").closest("tr")!;
expect(within(blockedRow).getAllByText("Blocked")).toHaveLength(2);
// StyleX compiles to opaque class names, so the check is structural: a blocked
// row carries every class a plain row does, plus the ones the flag adds.
const plainRow = screen.getByText("ok.example").closest("tr")!;
const blockedClasses = new Set(blockedRow.className.split(" "));
const plainClasses = plainRow.className.split(" ");
expect(plainClasses.every((name) => blockedClasses.has(name))).toBe(true);
expect(blockedClasses.size).toBeGreaterThan(plainClasses.length);
const freeze = screen.getByRole("button", { name: "Freeze" });
fireEvent.click(freeze);
expect(freeze.getAttribute("aria-pressed")).toBe("true");
act(() => sources[0]!.emit("query", frame(1002, "later.example")));
expect(screen.queryByText("later.example")).toBeNull();
expect(screen.getByText(/3 in buffer/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
expect(screen.getByText("later.example")).toBeTruthy();
});
test("resolves each row's client to its display name, reading the IP out with it", async () => {
await openLive();
act(() => {
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
sources[0]!.emit("query", frame(1001, "learned.example", { request: { client: "192.0.2.11" } }));
sources[0]!.emit("query", frame(1002, "nameless.example", { request: { client: "192.0.2.12" } }));
sources[0]!.emit("query", frame(1003, "stranger.example", { request: { client: "192.0.2.99" } }));
});
const named = await screen.findByText("Kitchen Pi");
// The address reads out with the name it replaced, rather than sitting in a
// title only a mouse can reach.
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
expect(named.getAttribute("title")).toBeNull();
expect(screen.queryByText("pi.lan")).toBeNull();
const learned = screen.getByText("laptop.lan");
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
});
test("rows stream in as bare IPs while the client list is still loading", async () => {
let releaseClients: () => void = () => {};
fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
// The shell reads health on every route for the Diagnostics nav badge.
if (url === "/api/health") return Promise.resolve(json(health()));
return new Promise<Response>((resolve) => {
if (url !== "/api/clients") {
resolve(json({}));
return;
}
releaseClients = () => resolve(json({ clients: CLIENTS }));
});
});
vi.stubGlobal("fetch", fetchMock);
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } })));
expect(screen.getByText("192.0.2.10")).toBeTruthy();
expect(screen.queryByText("Kitchen Pi")).toBeNull();
releaseClients();
expect(await screen.findByText("Kitchen Pi")).toBeTruthy();
});
test("repeated connection failures show the viewer-cap state with a retry button", async () => {
renderPage();
await screen.findByRole("button", { name: "Freeze" });
act(() => {
sources[0]!.emit("error");
sources[0]!.emit("error");
sources[0]!.emit("error");
});
expect(screen.getByRole("alert").textContent).toContain("too many live viewers");
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(sources).toHaveLength(2);
expect(screen.getByText("Connecting…")).toBeTruthy();
});
test("a recovered row links to its stored detail; a streamed one opens in place instead", async () => {
stubFetch((url) => {
if (url.startsWith("/api/queries?")) {
return json({
queries: [queryRow(88, { ts: 1001, domain: "recovered.example" })],
next_before: null,
coverage: { complete: true, available_since: 0 },
});
}
return json({});
});
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
act(() => sources[0]!.emit("error"));
act(() => sources[0]!.emit("open"));
const recovered = await screen.findByRole("link", { name: "recovered.example" });
expect(recovered.getAttribute("href")).toContain("/activity/queries/88");
// The streamed frame precedes its own insert, so it has no row to link to —
// but it does carry its own provenance, so it still has a detail.
expect(screen.queryByRole("link", { name: "streamed.example" })).toBeNull();
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
});
/** The open detail. Named by its heading, so the query proves the name is visible. */
function detailDialog(): HTMLElement {
return screen.getByRole("dialog", { name: "Streamed query" });
}
/** React Aria's ModalOverlay, two levels out from the dialog it wraps. */
function backdrop(): HTMLElement {
return detailDialog().parentElement!.parentElement!;
}
/**
* Activate a control the way a keyboard does. jsdom runs no default action for
* Enter on a button, so the click a browser would then dispatch is issued here;
* `detail: 0` is what marks it as keyboard-driven rather than pointer-driven,
* and is the flag React Aria itself reads.
*/
function pressWithKeyboard(control: HTMLElement) {
act(() => control.focus());
fireEvent.keyDown(control, { key: "Enter" });
fireEvent.click(control, { detail: 0 });
fireEvent.keyUp(control, { key: "Enter" });
}
/** Activate a control the way a mouse does, through the full pointer sequence. */
function pressWithMouse(control: HTMLElement) {
fireEvent.pointerDown(control, { pointerType: "mouse", button: 0 });
fireEvent.pointerUp(control, { pointerType: "mouse", button: 0 });
fireEvent.click(control, { detail: 1 });
}
test.each([
["the pointer", pressWithMouse],
["the keyboard", pressWithKeyboard],
])("a streamed row opens its provenance in a named dialog, from %s", async (_label, press) => {
await openLive();
act(() =>
sources[0]!.emit(
"query",
frame(1000, "streamed.example", {
policy: { action: "block", reason: "blocklist_domain", matched: "streamed.example" },
route: { kind: "blocked", upstream: "" },
}),
),
);
const trigger = screen.getByRole("button", { name: "streamed.example" });
// A real <button> is in the tab order and activates on Enter and Space; the
// only way to lose that is to opt out of it, which nothing here may do.
expect(trigger.tagName).toBe("BUTTON");
expect(trigger.getAttribute("tabindex")).toBeNull();
// The row opens a dialog, so it says so; what it no longer claims is to
// expand a region that stays in the page.
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
expect(trigger.getAttribute("aria-expanded")).toBeNull();
expect(trigger.getAttribute("aria-controls")).toBeNull();
press(trigger);
const dialog = detailDialog();
expect(within(dialog).getByRole("heading", { level: 1, name: "streamed.example" })).toBeTruthy();
expect(within(dialog).getByText("Blocked locally")).toBeTruthy();
expect(dialog.textContent).toContain("the query log may not have written it yet");
// React Aria may defer the move by a frame, depending on the modality it read
// from the activation, so the wait is the assertion rather than a workaround.
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
expect(within(dialog).getByRole("button", { name: "Close" })).toBeTruthy();
});
test("tabbing forward and backward stays inside the open dialog", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
const dialog = detailDialog();
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
// The related links land asynchronously; tabbing before them would walk a
// shorter dialog than the reader ever sees.
await waitFor(() => expect(within(dialog).getAllByRole("link").length).toBeGreaterThan(1));
const visited = new Set<Element>();
for (const shiftKey of [false, false, false, false, false, false, true, true, true, true]) {
fireEvent.keyDown(document.activeElement!, { key: "Tab", shiftKey });
fireEvent.keyUp(document.activeElement!, { key: "Tab", shiftKey });
expect(dialog.contains(document.activeElement)).toBe(true);
visited.add(document.activeElement!);
}
// Containment that never moved focus would satisfy the check above without
// trapping anything, so the walk has to have actually walked.
expect(visited.size).toBeGreaterThan(1);
});
test.each([
["the Close button", () => fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }))],
["Escape", () => fireEvent.keyDown(detailDialog(), { key: "Escape" })],
[
"a click on the backdrop",
() => {
const overlay = backdrop();
fireEvent.pointerDown(overlay, { pointerType: "mouse", button: 0 });
fireEvent.pointerUp(overlay, { pointerType: "mouse", button: 0 });
fireEvent.click(overlay, { detail: 1 });
},
],
])("%s closes the dialog and returns focus to the row that opened it", async (_label, dismiss) => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
const trigger = screen.getByRole("button", { name: "streamed.example" });
act(() => trigger.focus());
fireEvent.click(trigger);
expect(detailDialog()).toBeTruthy();
dismiss();
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(document.activeElement).toBe(screen.getByRole("button", { name: "streamed.example" }));
});
test("the stream runs on behind the open dialog, and its rows land in the table on close", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
const snapshot = detailDialog().textContent;
act(() => sources[0]!.emit("query", frame(1001, "arrived-while-open.example")));
// The connection is untouched: no close, no second EventSource.
expect(sources).toHaveLength(1);
expect(sources[0]!.closed).toBe(false);
// And the snapshot is a snapshot: nothing that arrives rewrites it.
expect(detailDialog().textContent).toBe(snapshot);
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.getByRole("button", { name: "arrived-while-open.example" })).toBeTruthy();
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
});
test("the rows and toolbar behind the dialog are out of reach while it is open", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
// React Aria hides everything outside the modal from assistive technology
// and from the pointer alike, so the row and the toolbar are unreachable by
// role: nothing behind the dialog can be operated while it is open.
expect(screen.queryByRole("button", { name: "streamed.example" })).toBeNull();
expect(screen.queryByRole("button", { name: "Freeze" })).toBeNull();
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.getByRole("button", { name: "Freeze" })).toBeTruthy();
});
/**
* The one render that bypasses the route, because the ring capacity is a
* parameter of the component and the route deliberately never passes it:
* evicting a row at the real 500 means pushing 500 frames through React state,
* which proves nothing the fifth frame does not. The real route tree still
* backs the links inside the detail.
*/
function renderLiveWithCapacity(capacity: number) {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/activity?mode=live"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterContextProvider router={router}>
<LiveActivity origin={LIVE_ORIGIN} capacity={capacity} />
</RouterContextProvider>
</QueryClientProvider>
</AuthProvider>,
);
}
/** Push one ringful of filler through a 5-row ring, evicting whatever was there. */
function evictWithFiller() {
act(() => {
for (let index = 0; index < 5; index += 1) {
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
}
});
}
test("an open dialog's snapshot survives its row being evicted from the ring buffer", async () => {
renderLiveWithCapacity(5);
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
const snapshot = detailDialog().textContent;
expect(within(detailDialog()).getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
// One ringful more: the ring keeps the newest 5, so the selected row is gone.
// The dialog holds the frame itself, not a lookup into the ring, so it neither
// blanks out nor closes.
evictWithFiller();
expect(detailDialog().textContent).toBe(snapshot);
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.getAllByRole("row")).toHaveLength(6);
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
});
test("closing after the source row is evicted anchors focus in the results region", async () => {
renderLiveWithCapacity(5);
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
const trigger = screen.getByRole("button", { name: "evicted.example" });
act(() => trigger.focus());
fireEvent.click(trigger);
evictWithFiller();
expect(trigger.isConnected).toBe(false);
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
// Never the body, never whichever row happens to sit where the old one did,
// and never the Freeze button: a stable anchor in the region the reader was
// reading. React Aria's own deferred restore runs after this and, finding
// focus already placed, leaves it alone.
const region = screen.getByRole("region", { name: "Live queries" });
expect(document.activeElement).toBe(region);
await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))));
expect(document.activeElement).toBe(region);
});
test("the route renders the live ring at its production capacity", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "pinned.example")));
expect(screen.getByText(/last 500 kept/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
expect(screen.getByText(/newest 500 kept/)).toBeTruthy();
});
test("an open streamed detail survives Freeze and Resume", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
const snapshot = detailDialog().textContent;
// Freeze is behind the dialog, so it is reached the way the code reaches it
// rather than by role, which the modal deliberately hides.
const freeze = () => screen.getByText("Freeze") as HTMLButtonElement;
act(() => freeze().click());
expect(detailDialog().textContent).toBe(snapshot);
act(() => (screen.getByText("Resume") as HTMLButtonElement).click());
expect(detailDialog().textContent).toBe(snapshot);
});
test("live renders no filter toolbar, not even a disabled one", async () => {
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
// The stream is unfiltered — the server sends every query — so a row of
// controls here would promise filtering that is not happening. The filters
// are not lost: they are in the URL, and History applies them on the way back.
expect(screen.queryByLabelText("Filter domains")).toBeNull();
expect(screen.queryByLabelText("Client IP (exact match)")).toBeNull();
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
expect(screen.queryByText("Clear")).toBeNull();
expect(screen.getByText(/the History filters apply to history only/)).toBeTruthy();
});
test("live mode asks for no query pages, whatever filters the url retained", async () => {
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true&since=1700000000&bogus=1");
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
expect(queryCalls()).toEqual([]);
});
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
await openLive("/activity?mode=live&domain=ads");
fireEvent.click(screen.getByRole("tab", { name: "History" }));
await screen.findByLabelText("Filter domains");
expect(sources).toHaveLength(1);
expect(sources[0]!.closed).toBe(true);
// The filters came along, which is the point of switching rather than
// navigating: the reader keeps the question they were asking.
expect((screen.getByLabelText("Filter domains") as HTMLInputElement).value).toBe("ads");
fireEvent.click(screen.getByRole("tab", { name: "Live" }));
await screen.findByRole("button", { name: "Freeze" });
expect(sources).toHaveLength(2);
expect(sources[1]!.closed).toBe(false);
});
/** The related-actions region of a query detail. */
function related(): HTMLElement {
return screen.getByRole("region", { name: "Related" });
}
/**
* The streamed detail carries the same Related as the persisted one: four links
* and no control. Pause is resolver-wide and lives in the sidebar alone.
*/
test("a streamed blocked row's Related carries links only", async () => {
await openLive();
act(() =>
sources[0]!.emit(
"query",
frame(1000, "streamed.example", {
policy: { action: "block", reason: "blocklist_domain", matched: "streamed.example" },
route: { kind: "blocked", upstream: "" },
}),
),
);
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
expect(within(related()).getAllByRole("link")).toHaveLength(4);
expect(within(related()).queryByRole("button")).toBeNull();
});