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

824 lines
31 KiB
TypeScript

/**
* Activity in history mode, through the real router: the URL is the applied
* state, so nothing here can be checked by rendering the page on its own.
*/
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
import { queryRow } from "@/features/provenance/provenanceFixture";
import { activityCell } from "./activityCell";
import { FakeEventSource } from "./fakeEventSource";
function client(id: number, ip: string, name: string, learnedName: string): Client {
return {
id,
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(1, "192.0.2.10", "Kitchen Pi", "pi.lan"),
client(2, "192.0.2.11", "", "laptop.lan"),
client(3, "192.0.2.12", "", ""),
];
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
return queryRow(id, { ts: 1_700_000_000 + id, domain, upstream: "udp://9.9.9.9:53", ...overrides });
}
const COMPLETE: Coverage = { complete: true, available_since: 1_600_000_000 };
/** The blocked row every page fixture reuses. */
const BLOCKED = {
blocked: true,
policy_action: "block",
policy_reason: "blocklist_wildcard",
route_kind: "blocked",
upstream: "",
} as const satisfies Partial<QueryRow>;
const PAGES: Record<string, QueriesPage> = {
"/api/queries": {
queries: [
row(20, "first.example", { qtype: 65, cache_hit: true, route_kind: "cache" }),
row(19, "ads.example", { ...BLOCKED, response_time_us: null, cache_hit: null }),
],
next_before: 19,
coverage: COMPLETE,
},
"/api/queries?before=19": {
queries: [row(5, "older.example")],
next_before: null,
coverage: COMPLETE,
},
"/api/queries?domain=ads": {
queries: [row(19, "ads.example", BLOCKED)],
next_before: null,
coverage: COMPLETE,
},
"/api/queries?domain=ads&blocked=true": {
queries: [row(19, "ads.example", BLOCKED)],
next_before: null,
coverage: COMPLETE,
},
"/api/queries?since=1700000000": {
queries: [row(20, "first.example")],
next_before: null,
coverage: COMPLETE,
},
};
let fetchMock: ReturnType<typeof vi.fn>;
function json(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
const VERSION = { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 };
/** The shell's own requests, which every test serves the same way. */
function stubFetch(handler: (url: string) => Response | Promise<Response>) {
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 Promise.resolve(handler(url));
});
vi.stubGlobal("fetch", fetchMock);
}
function fromPages(url: string): Response {
if (url === "/api/clients") return json({ clients: CLIENTS });
const payload = PAGES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json(payload);
}
beforeEach(() => {
stubFetch(fromPages);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderPage(path = "/activity") {
const queryClient = createQueryClient();
const history = createMemoryHistory({ initialEntries: [path] });
const router = createAppRouter(history, queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return { queryClient, history };
}
/** Every `/api/queries` URL the run asked for, list pages only. */
function queryCalls(): string[] {
return fetchMock.mock.calls
.map((call) => String(call[0]))
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
}
/** The toolbar's search field, which is how a domain filter is entered now. */
function domainInput(): HTMLInputElement {
return screen.getByLabelText("Filter domains") as HTMLInputElement;
}
/** Enter in a text field: the debounce's escape hatch, and the fast path here. */
function submitFilters() {
fireEvent.submit(domainInput().closest("form")!);
}
/**
* The footer's count line, matched on the whole sentence.
*
* The number sits in a span of its own so it can carry tabular digits, so the
* line is several text nodes and the default string matcher — which reads one
* node at a time — cannot see it whole.
*/
function countMatcher(pattern: RegExp) {
return (_: string, element: Element | null): boolean =>
element?.tagName === "P" && pattern.test(element.textContent ?? "");
}
function countLine(pattern: RegExp): HTMLElement {
return screen.getByText(countMatcher(pattern));
}
function findCountLine(pattern: RegExp): Promise<HTMLElement> {
return screen.findByText(countMatcher(pattern));
}
/** The custom range lives behind the Time menu; the two bounds only exist there. */
function openCustomRange() {
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
}
test("renders the first page with the seven columns filled in", async () => {
renderPage();
await screen.findByText("first.example");
expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([
"Time",
"Domain",
"Client",
"Type",
"Result",
"Route",
"Duration",
]);
expect(activityCell("first.example", "Type").textContent).toBe("HTTPS");
expect(activityCell("first.example", "Result").textContent).toBe("NOERROR");
expect(activityCell("first.example", "Route").textContent).toBe("Cache");
expect(activityCell("first.example", "Duration").textContent).toBe("1.2 ms");
// The Result cell says Blocked even though the client saw NOERROR, and the
// Route cell says how: this is the pair the old Status column could not show.
expect(activityCell("ads.example", "Result").textContent).toBe("Blocked");
expect(activityCell("ads.example", "Route").textContent).toBe("Blocked");
expect(activityCell("ads.example", "Duration").textContent).toBe("—");
expect(countLine(/Showing 2 queries/)).toBeTruthy();
});
test("resolves each row's client to its display name, reading the IP out with it", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json({
queries: [
row(20, "named.example", { client_ip: "192.0.2.10" }),
row(19, "learned.example", { client_ip: "192.0.2.11" }),
row(18, "nameless.example", { client_ip: "192.0.2.12" }),
row(17, "stranger.example", { client_ip: "192.0.2.99" }),
],
next_before: null,
coverage: COMPLETE,
} satisfies QueriesPage);
});
renderPage();
await screen.findAllByText("Kitchen Pi");
// A hand-typed name wins outright; the learned name never surfaces for it.
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();
// A learned name reads muted and nothing more here: the "learned" tag would
// repeat on every row of the table, so the Clients page carries it instead.
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();
// A known client with neither name, and a client the loaded list has never
// seen, both fall back to the bare address with nothing standing in for it.
expect(within(activityCell("nameless.example", "Client")).getByText("192.0.2.12").textContent).toBe("192.0.2.12");
expect(within(activityCell("stranger.example", "Client")).getByText("192.0.2.99").textContent).toBe("192.0.2.99");
});
test("the Domain cell carries the hidden columns' facts for a table too narrow for them", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json({
queries: [row(20, "named.example", { client_ip: "192.0.2.10" })],
next_before: null,
coverage: COMPLETE,
} satisfies QueriesPage);
});
renderPage();
await screen.findAllByText("Kitchen Pi");
const domain = activityCell("named.example", "Domain");
expect(within(domain).getByText("Kitchen Pi").textContent).toBe("Kitchen Pi (192.0.2.10)");
const facts = [
activityCell("named.example", "Time").textContent,
`Type ${activityCell("named.example", "Type").textContent}`,
activityCell("named.example", "Route").textContent,
activityCell("named.example", "Duration").textContent,
].join(" · ");
expect(domain.textContent).toContain(facts);
});
test("load more appends the next page and stops at the end of the log", async () => {
renderPage();
await screen.findByText("first.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("older.example");
expect(screen.getByText("first.example")).toBeTruthy();
expect(countLine(/Showing 3 queries — end of log/)).toBeTruthy();
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
});
test("applying a filter puts it in the url, refetches, and resets the accumulated list", async () => {
const { history } = renderPage();
await screen.findByText("first.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("older.example");
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
await findCountLine(/Showing 1 query /);
expect(history.location.search).toContain("domain=ads");
expect(screen.getByText("ads.example")).toBeTruthy();
expect(screen.queryByText("first.example")).toBeNull();
expect(screen.queryByText("older.example")).toBeNull();
});
test("a load-more that resolves after a filter change is discarded", async () => {
let releaseLoadMore: () => void = () => {};
stubFetch((url) => {
if (url === "/api/queries?before=19") {
return new Promise<Response>((resolve) => {
releaseLoadMore = () => resolve(json(PAGES["/api/queries?before=19"]));
});
}
return fromPages(url);
});
renderPage();
await screen.findByText("first.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
await findCountLine(/Showing 1 query /);
releaseLoadMore();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(screen.queryByText("older.example")).toBeNull();
expect(countLine(/Showing 1 query /)).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
test("load more is disabled while a filter change shows placeholder data, then uses the fresh cursor", async () => {
let releaseFiltered: () => void = () => {};
const filteredPage: QueriesPage = {
queries: [row(19, "ads.example", BLOCKED)],
next_before: 7,
coverage: COMPLETE,
};
const filteredOlderPage: QueriesPage = {
queries: [row(3, "ads.older.example")],
next_before: null,
coverage: COMPLETE,
};
stubFetch((url) => {
if (url === "/api/queries?domain=ads") {
return new Promise<Response>((resolve) => {
releaseFiltered = () => resolve(json(filteredPage));
});
}
if (url === "/api/queries?domain=ads&before=7") return json(filteredOlderPage);
return fromPages(url);
});
renderPage();
await screen.findByText("first.example");
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
const staleButton = await screen.findByRole("button", { name: "Load more" });
expect(staleButton).toHaveProperty("disabled", true);
fireEvent.click(staleButton);
expect(queryCalls()).not.toContain("/api/queries?domain=ads&before=19");
releaseFiltered();
await waitFor(() => {
expect(screen.queryByText("first.example")).toBeNull();
});
const freshButton = screen.getByRole("button", { name: "Load more" });
expect(freshButton).toHaveProperty("disabled", false);
fireEvent.click(freshButton);
await screen.findByText("ads.older.example");
expect(queryCalls()).toContain("/api/queries?domain=ads&before=7");
expect(countLine(/Showing 2 queries — end of log/)).toBeTruthy();
});
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
// The newest-100 window moves up while the reader has a second page open.
// Refetching only the first page would drop n20 and n19 out of the middle
// of the table; the second page must be replayed from the fresh cursor.
const before: Record<string, QueriesPage> = {
"/api/queries": {
queries: [row(20, "n20.example"), row(19, "n19.example")],
next_before: 19,
coverage: COMPLETE,
},
"/api/queries?before=19": {
queries: [row(18, "n18.example"), row(17, "n17.example")],
next_before: null,
coverage: COMPLETE,
},
};
const after: Record<string, QueriesPage> = {
"/api/queries": {
queries: [row(22, "n22.example"), row(21, "n21.example")],
next_before: 21,
coverage: COMPLETE,
},
"/api/queries?before=21": {
queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")],
next_before: null,
coverage: COMPLETE,
},
};
let live = before;
stubFetch((url) => {
const payload = live[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json(payload);
});
const { queryClient } = renderPage();
await screen.findByText("n20.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("n17.example");
live = after;
await act(async () => {
await queryClient.invalidateQueries({ queryKey: ["queries"] });
});
await screen.findByText("n22.example");
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
expect(countLine(/Showing 6 queries — end of log/)).toBeTruthy();
});
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
const assign = vi.fn();
vi.stubGlobal("location", { pathname: "/activity", search: "", assign });
stubFetch((url) => {
if (url === "/api/queries?before=19") {
return new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
return fromPages(url);
});
renderPage();
await screen.findByText("first.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await waitFor(() => {
expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/activity")}`);
});
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByText(/Failed to load more/)).toBeNull();
});
test("each row links into its own detail page, carrying the investigation with it", async () => {
renderPage("/activity?mode=history&since=1700000000");
await screen.findByText("first.example");
const link = screen.getByRole("link", { name: "first.example" });
expect(link.getAttribute("href")).toContain("/activity/queries/20");
expect(link.getAttribute("href")).toContain("since=1700000000");
// An <a href> is in the tab order by default; nothing here may opt it out.
expect(link.getAttribute("tabindex")).toBeNull();
});
test("a pruned window tells the reader when history starts", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json({
queries: [row(20, "kept.example")],
next_before: null,
coverage: { complete: false, available_since: 1_700_000_000 },
} satisfies QueriesPage);
});
renderPage();
await screen.findByText("kept.example");
expect(screen.getByText(/Query history is available from/)).toBeTruthy();
});
test("a complete window shows no coverage notice", async () => {
renderPage();
await screen.findByText("first.example");
expect(screen.queryByText(/Query history is available from/)).toBeNull();
});
test("a ?domain= link seeds the filter form and fetches that domain on arrival", async () => {
renderPage("/activity?domain=ads");
await screen.findByText("ads.example");
expect(domainInput()).toHaveProperty("value", "ads");
expect(screen.queryByText("first.example")).toBeNull();
});
test("history forwards exactly the six normalized filter fields and nothing else", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
renderPage(
"/activity?mode=history&domain=%20ads%20&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600&bogus=1&limit=9999",
);
await screen.findByText("No queries match the current filters.");
expect(queryCalls()).toEqual([
"/api/queries?domain=ads&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600",
]);
});
test("a rejected search parameter is dropped rather than guessed at", async () => {
renderPage("/activity?since=1.5&blocked=%22true%22&domain=%20%20");
await screen.findByText("first.example");
// Nothing survived validation, so the request is the unfiltered one.
expect(queryCalls()).toEqual(["/api/queries"]);
expect(domainInput()).toHaveProperty("value", "");
});
test("the form draft follows the url back and forward, seconds included", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
// A bound with non-zero seconds: the round trip has to keep them, and the
// untouched field has to carry the original number rather than re-parse.
const seeded = 1_700_000_017;
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
await screen.findByLabelText("Filter domains");
expect(domainInput()).toHaveProperty("value", "first");
// A seeded custom range opens its row, so the link's bounds are visible.
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
expect(sinceInput.value).toContain(":37");
fireEvent.change(domainInput(), { target: { value: "second" } });
submitFilters();
await waitFor(() => expect(history.location.search).toContain("domain=second"));
// The untouched Since bound applied as the exact second it was seeded with.
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);
// A pasted link, then the buttons over it: the draft is derived from the URL,
// so whichever way the browser moves it the field has to move with it.
act(() => history.push(`/activity?mode=history&domain=third&since=${seeded}`));
await waitFor(() => {
expect(domainInput()).toHaveProperty("value", "third");
});
act(() => history.back());
await waitFor(() => {
expect(domainInput()).toHaveProperty("value", "second");
});
act(() => history.forward());
await waitFor(() => {
expect(domainInput()).toHaveProperty("value", "third");
});
});
/** 2 a.m. on the EU spring-forward date: an hour that exists in some zones and not others. */
const DST_WALL_TIME = "2026-03-29T02:30:00";
/**
* Whether that wall time names an instant in the timezone the suite runs in.
* `new Date` slides a spring-forward gap silently forward, so an hour or minute
* that comes back different from the one written *is* the gap.
*/
function wallTimeExists(text: string): boolean {
const written = /T(\d{2}):(\d{2})/.exec(text)!;
const parsed = new Date(text);
return parsed.getHours() === Number(written[1]) && parsed.getMinutes() === Number(written[2]);
}
test("a wall-clock time the daylight-saving jump skips is refused, not silently moved", async () => {
// One expected outcome per timezone, decided here rather than accepted from
// the page: in a zone with the jump the bound must be refused outright, and
// in a zone without it the same text is an ordinary instant that applies.
const inGap = !wallTimeExists(DST_WALL_TIME);
const unix = Math.floor(new Date(DST_WALL_TIME).getTime() / 1000);
stubFetch((url) => {
if (url === `/api/queries?since=${unix}`) {
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
}
return fromPages(url);
});
const { history } = renderPage();
await screen.findByText("first.example");
const callsBefore = queryCalls().length;
const searchBefore = history.location.search;
openCustomRange();
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
if (inGap) {
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
// Refused means refused: no navigation, and no request for the hour the
// operator did not ask for.
expect(history.location.search).toBe(searchBefore);
expect(queryCalls()).toHaveLength(callsBefore);
return;
}
await waitFor(() => expect(history.location.search).toContain(`since=${unix}`));
expect(queryCalls()).toContain(`/api/queries?since=${unix}`);
expect(screen.queryByRole("alert")).toBeNull();
});
test("the policy simulation is reachable from the header, with no rows to click through", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url === "/api/groups") return json({ groups: [{ id: 1, name: "default", safe_search: false }] });
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
const { history } = renderPage();
// An empty log is exactly the case a row-borne link cannot serve.
await screen.findByText("No queries logged yet.");
const link = screen.getByRole("link", { name: "Current policy simulation" });
expect(link.getAttribute("href")).toBe("/activity/test");
fireEvent.click(link);
await screen.findByRole("heading", { level: 1, name: "Current policy simulation" });
expect(history.location.pathname).toBe("/activity/test");
});
test("the mode switch is a tab list whose selection is the url, and it keeps the filters", async () => {
// Live opens a stream as soon as its panel mounts, so the switch cannot be
// exercised without one; the fake stands in for the browser's EventSource.
vi.stubGlobal(
"EventSource",
class {
constructor(url: string) {
return new FakeEventSource(url) as unknown as EventSource;
}
},
);
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
await screen.findByText("ads.example");
const tabs = within(screen.getByRole("tablist", { name: "Activity mode" }));
expect(tabs.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["History", "Live"]);
expect(tabs.getByRole("tab", { name: "History", selected: true })).toBeTruthy();
expect(tabs.getByRole("tab", { name: "Live", selected: false })).toBeTruthy();
fireEvent.click(tabs.getByRole("tab", { name: "Live" }));
await waitFor(() => expect(history.location.search).toContain("mode=live"));
// The investigation survives the switch: both filters are still in the URL.
expect(history.location.search).toContain("domain=ads");
expect(history.location.search).toContain("blocked=true");
expect(
within(screen.getByRole("tablist", { name: "Activity mode" })).getByRole("tab", {
name: "Live",
selected: true,
}),
).toBeTruthy();
expect(await screen.findByText(/the History filters apply to history only/)).toBeTruthy();
});
test("Clear empties the url as well as the form", async () => {
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
await screen.findByText("ads.example");
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
await waitFor(() => {
expect(history.location.search).not.toContain("domain");
});
expect(history.location.search).not.toContain("blocked");
expect(domainInput()).toHaveProperty("value", "");
});
test("the empty result offers the clear it names, and it clears the same filters the toolbar does", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url === "/api/queries") return json(PAGES["/api/queries"]);
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
const { history } = renderPage("/activity?mode=history&domain=nothing&blocked=true");
await screen.findByText("No queries match the current filters.");
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
await waitFor(() => {
expect(history.location.search).not.toContain("domain");
});
expect(history.location.search).not.toContain("blocked");
expect(domainInput()).toHaveProperty("value", "");
// The unfiltered log is back, so the button did the toolbar's Clear and not a
// reset of its own that only emptied the form.
expect(await screen.findByText("first.example")).toBeTruthy();
});
test("an empty log offers no clear, because there is no filter to blame for it", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
renderPage();
await screen.findByText("No queries logged yet.");
expect(screen.queryByRole("button", { name: "Clear filters" })).toBeNull();
});
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { history } = renderPage();
await screen.findByText("first.example");
fireEvent.change(domainInput(), { target: { value: "a" } });
fireEvent.change(domainInput(), { target: { value: "ad" } });
fireEvent.change(domainInput(), { target: { value: "ads" } });
// Mid-word the URL has not moved: three keystrokes are one investigation,
// not three, and each one would otherwise be a request and a history entry.
expect(history.location.search).not.toContain("domain");
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(history.location.search).toContain("domain=ads");
// Replaced, not pushed: Back leaves the page, it does not retype the word.
expect(history.length).toBe(1);
fireEvent.change(domainInput(), { target: { value: "first" } });
submitFilters();
await waitFor(() => expect(history.location.search).toContain("domain=first"));
} finally {
vi.useRealTimers();
}
});
test("the field being typed in keeps the focus when the debounce commits", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { history } = renderPage();
await screen.findByText("first.example");
const input = domainInput();
input.focus();
fireEvent.change(input, { target: { value: "ads" } });
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
await waitFor(() => expect(history.location.search).toContain("domain=ads"));
// The same node, still focused, still holding the caret: a toolbar that
// remounted on the URL it just wrote would drop the next keystroke.
expect(domainInput()).toBe(input);
expect(document.activeElement).toBe(input);
} finally {
vi.useRealTimers();
}
});
test("a result segment commits on the click, with no wait and no button", async () => {
const { history } = renderPage("/activity?domain=ads");
await screen.findByText("ads.example");
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
await waitFor(() => expect(history.location.search).toContain("blocked=true"));
expect(history.location.search).toContain("domain=ads");
});
test("a time preset writes the second it resolved to, not a rolling window", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
const { history } = renderPage();
await screen.findByText("No queries logged yet.");
fireEvent.click(screen.getByRole("button", { name: "Time: Any time" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
// Both bounds are concrete, so the link names a closed hour rather than one
// that keeps growing at the top as the log does.
const since = now / 1000 - 3600;
const until = now / 1000;
await waitFor(() => expect(history.location.search).toContain(`since=${since}`));
expect(history.location.search).toContain(`until=${until}`);
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
expect(queryCalls()).toContain(`/api/queries?since=${since}&until=${until}`);
vi.restoreAllMocks();
});
test("Live shows no toolbar at all", async () => {
vi.stubGlobal(
"EventSource",
class {
constructor(url: string) {
return new FakeEventSource(url) as unknown as EventSource;
}
},
);
renderPage("/activity?mode=live&domain=ads");
await screen.findByText(/the History filters apply to history only/);
// The stream is unfiltered, so a control here would promise filtering that is
// not happening; the filters are still in the URL, waiting for History.
expect(screen.queryByLabelText("Filter domains")).toBeNull();
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
});
test("the coverage watermark reads under the results, never over them", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json({
queries: [row(20, "kept.example")],
next_before: null,
coverage: { complete: false, available_since: 1_700_000_000 },
} satisfies QueriesPage);
});
renderPage();
await screen.findByText("kept.example");
const watermark = screen.getByText(/Query history is available from/);
const count = countLine(/Showing 1 query/);
// After the count in document order, which is what "footer" means here.
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});