Files
nxdns/admin/src/features/activity/LiveActivity.test.tsx
T

591 lines
24 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 { activityCell } from "./activityCell";
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" } }));
});
await screen.findAllByText("Kitchen Pi");
const named = within(activityCell("named.example", "Client")).getByText("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 = within(activityCell("learned.example", "Client")).getByText("laptop.lan");
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
expect(within(activityCell("nameless.example", "Client")).getByText("192.0.2.12").getAttribute("title")).toBeNull();
expect(within(activityCell("stranger.example", "Client")).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(within(activityCell("named.example", "Client")).getByText("192.0.2.10")).toBeTruthy();
expect(screen.queryByText("Kitchen Pi")).toBeNull();
releaseClients();
await screen.findAllByText("Kitchen Pi");
expect(within(activityCell("named.example", "Client")).getByText("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();
});