/** * Overview through the real router: Pi-hole's layout over our data. * * The behaviours of the superseded three-section build are accounted for here or * declared dead. The status rows and the issues list moved to the Diagnostics * page's health strip and its Active section; the Pause control moved to the * sidebar; the protection indicator is gone. What stays here is the period, the * window and the panels. */ import { 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 { clientKey, qtypeKey, seriesColor } from "./seriesColors"; import { health } from "@/lib/healthFixture"; import type { Health, Overview } from "@/lib/types"; const SINCE = Date.UTC(2026, 0, 1, 0, 0) / 1000; const UNTIL = Date.UTC(2026, 0, 2, 0, 0) / 1000; const COVERAGE = { complete: true, available_since: SINCE }; const OVERVIEW: Overview = { period: "24h", since: SINCE, until: UNTIL, bucket_seconds: 1800, totals: { queries: 1000, blocked: 250, clients: 7, avg_response_time_us: 2345 }, buckets: [ { ts: SINCE, queries: 60, blocked: 20, cached: 10 }, { ts: SINCE + 1800, queries: 40, blocked: 0, cached: 0 }, ], clients: [ { client: "192.0.2.30", buckets: [40, 20] }, { client: "192.0.2.31", buckets: [20, 20] }, ], other: [0, 0], types: [ { qtype: 1, count: 600 }, { qtype: 28, count: 300 }, { qtype: null, count: 100 }, ], routes: [ { route: "upstream", source: "https://dns.example/dns-query", count: 500 }, { route: "blocked", source: null, count: 250 }, { route: "cache", source: null, count: 150 }, { route: "upstream", source: null, count: 100 }, ], coverage: COVERAGE, }; /** The same shape an hour wide and empty, so a period change is observable. */ const HOUR: Overview = { ...OVERVIEW, period: "1h", since: UNTIL - 3600, bucket_seconds: 60, totals: { queries: 12, blocked: 3, clients: 2, avg_response_time_us: 2345 }, buckets: [], clients: [], other: [], types: [], routes: [], }; let healthBody: Health; let failing: boolean; /** The registered clients, as `/api/clients` answers them. */ let registered: { ip: string; name: string; learned_name: string }[]; let coverageComplete: boolean; /** Held in flight, so a test can look at the page while the request is pending. */ let delayed: Promise | null; function json(payload: unknown, status = 200): Response { return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } }); } function withCoverage(body: T): T { return { ...body, coverage: { ...body.coverage, complete: coverageComplete } }; } beforeEach(() => { healthBody = health(); failing = false; registered = []; coverageComplete = true; delayed = null; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.startsWith("/api/overview")) { if (failing) return json({ error: "endpoint unavailable" }, 400); if (delayed !== null) await delayed; return json(withCoverage(url.includes("period=1h") ? HOUR : OVERVIEW)); } if (url === "/api/clients") { return json({ clients: registered.map((client, index) => ({ id: index + 1, ip: client.ip, name: client.name, learned_name: client.learned_name, group_id: 1, group: "default", hand_edited: client.name !== "", first_seen: SINCE, last_seen: UNTIL, })), }); } if (url === "/api/health") return json(healthBody); if (url === "/api/version") return json({ version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }); if (url.startsWith("/api/diagnostics")) { return json({ events: [], next_before: null, active: { warnings: 0, errors: 0 } }); } return json({ error: "not stubbed" }, 404); }), ); }); afterEach(() => vi.unstubAllGlobals()); function renderApp(path = "/overview") { const queryClient = createQueryClient(); const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient); render( , ); return router; } function panel(name: string): HTMLElement { const heading = screen.getByRole("heading", { name }); const section = heading.closest("section"); if (section === null) throw new Error(`no panel for ${name}`); return section; } test("the root path lands on Overview rather than aliasing it", async () => { const router = renderApp("/"); await screen.findByRole("heading", { name: "Overview", level: 1 }); expect(router.state.location.pathname).toBe("/overview"); }); test("every donut arc is outlined, so two slices of one hue still read as two", async () => { // Colour is a pure function of identity and so cannot rule out two slices of // one panel sharing a hue. The stroke is what stops neighbours from merging // into one shape, which makes it part of the contract rather than decoration. renderApp(); await screen.findByText("1,000"); await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2)); const arcs = Array.from(panel("Query types").querySelectorAll("svg path")); expect(arcs).toHaveLength(3); for (const arc of arcs) { expect(arc.getAttribute("stroke-width")).toBe("1"); // The panel's own surface colour, as a token reference. expect(arc.getAttribute("stroke")).toMatch(/^var\(--/); } }); test("the page builds a donut slice's colour from the entry's identity", async () => { // `Donut` renders the colour it is handed and never recomputes one, so the // mapping from identity to hue is the page's job and is pinned here. renderApp(); await screen.findByText("1,000"); await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2)); const item = within(panel("Query types")).getAllByText("A")[0].closest("li") as HTMLElement; const swatch = item.querySelector("span[aria-hidden]") as HTMLElement; expect(swatch.getAttribute("style")).toContain(seriesColor(qtypeKey(1))); }); test("a request in flight leaves the heading and the picker usable behind one loading surface", async () => { // Through the real route, which is the point: the loader starts the request // and awaits it nowhere. If it awaited, the router would hold the whole page — // heading and period picker included — until the response landed. let release = () => {}; delayed = new Promise((resolve) => (release = resolve)); renderApp(); await screen.findByRole("heading", { name: "Overview", level: 1 }); expect(screen.getByRole("button", { name: "1h" })).toBeTruthy(); // One loading state for the whole page, not one per panel. const loading = await screen.findByText("Loading…"); expect(loading.getAttribute("role")).toBe("status"); expect(screen.getAllByText("Loading…")).toHaveLength(1); expect(screen.queryByRole("heading", { name: "Query types" })).toBeNull(); release(); delayed = null; await screen.findByText("1,000"); expect(screen.queryByText("Loading…")).toBeNull(); }); test("a registered client is named in the chart, an unregistered one keeps its address", async () => { // The fixture's two clients: one registered with a typed name, one the clients // list has never seen. registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }]; renderApp(); const chart = await waitFor(() => panel("Client activity over time")); // Legend and the hidden table both, since the table is what a screen reader // gets instead of the graphic and the two must not name one client differently. await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2)); expect(within(chart).queryByText("192.0.2.30")).toBeNull(); expect(within(chart).getAllByText("192.0.2.31")).toHaveLength(2); }); test("a client named only by reverse DNS is named by it too", async () => { registered = [{ ip: "192.0.2.31", name: "", learned_name: "laptop.lan" }]; renderApp(); const chart = await waitFor(() => panel("Client activity over time")); await waitFor(() => expect(within(chart).getAllByText("laptop.lan")).toHaveLength(2)); }); test("naming a client does not recolour its series", async () => { // The rename the palette must not notice: the swatch beside "kitchen-pi" is // the colour of the address it was drawn under, not of the label on screen. registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }]; renderApp(); const chart = await waitFor(() => panel("Client activity over time")); await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2)); const item = within(chart).getAllByText("kitchen-pi")[0].closest("li") as HTMLElement; const swatch = item.querySelector("span[aria-hidden]") as HTMLElement; expect(swatch.getAttribute("style")).toContain(seriesColor(clientKey("192.0.2.30"))); }); test("the client chart drops Other in a period where it counted nothing", async () => { // The fixture's other series is all zeroes. An aggregation bucket that // aggregated nothing is a legend entry and a table column that say only that // they are empty; the named clients stay, because a quiet client is a fact. renderApp(); await screen.findByRole("heading", { name: "Client activity over time" }); const chart = screen.getByRole("heading", { name: "Client activity over time" }).closest("section"); expect(chart).toBeTruthy(); // Twice each: the legend swatch and the column header of the table a screen // reader gets instead of the graphic. await waitFor(() => expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2)); expect(within(chart as HTMLElement).queryAllByText("Other")).toHaveLength(0); }); test("the page is four tiles, two charts and two donuts — no status or issues sections", async () => { renderApp(); await screen.findByRole("heading", { name: "Overview", level: 1 }); await screen.findByText("1,000"); for (const name of ["Queries over time", "Client activity over time", "Query types", "Upstream servers"]) { expect(screen.getByRole("heading", { name })).toBeTruthy(); } // The sections the layout ruling removed, and the widgets the Dashboard lost. expect(screen.queryByRole("heading", { name: "Current status" })).toBeNull(); expect(screen.queryByRole("heading", { name: "Active issues" })).toBeNull(); expect(screen.queryByRole("heading", { name: "Activity over a period" })).toBeNull(); expect(screen.queryByText("Storage now")).toBeNull(); expect(screen.queryByRole("columnheader", { name: "Upstream" })).toBeNull(); }); test("the four tiles report the window, and each links where its number leads", async () => { renderApp(); const tiles = within((await screen.findByText("1,000")).closest("dl") as HTMLElement); expect(tiles.getByText("250")).toBeTruthy(); expect(tiles.getByText("25.0%")).toBeTruthy(); expect(tiles.getByText("7")).toBeTruthy(); expect(tiles.getByText("2.3 ms")).toBeTruthy(); // The bounds are the ones the stats response returned, not ones computed here. const queries = new URLSearchParams( screen.getByRole("link", { name: "Open in Activity" }).getAttribute("href")?.split("?")[1] ?? "", ); expect(queries.get("mode")).toBe("history"); expect(queries.get("since")).toBe(String(SINCE)); expect(queries.get("until")).toBe(String(UNTIL)); expect(queries.get("blocked")).toBeNull(); const blocked = new URLSearchParams( screen.getByRole("link", { name: "Open blocked queries" }).getAttribute("href")?.split("?")[1] ?? "", ); expect(blocked.get("blocked")).toBe("true"); expect(blocked.get("since")).toBe(String(SINCE)); expect(screen.getByRole("link", { name: "Manage clients" }).getAttribute("href")).toBe("/clients"); // Average response time has no rows behind it to open. expect(screen.queryByRole("link", { name: /average/i })).toBeNull(); }); test("both donuts name every entry, nulls included, and disambiguate a nameless source", async () => { renderApp(); await screen.findByText("1,000"); const types = within(panel("Query types")); expect(types.getByRole("rowheader", { name: "A" })).toBeTruthy(); expect(types.getByRole("rowheader", { name: "AAAA" })).toBeTruthy(); // A query whose type was never recorded is its own entry, not a dropped row. expect(types.getByRole("rowheader", { name: "Unknown" })).toBeTruthy(); const routes = within(panel("Upstream servers")); expect(routes.getByRole("rowheader", { name: "https://dns.example/dns-query (Upstream)" })).toBeTruthy(); expect(routes.getByRole("rowheader", { name: "Blocked" })).toBeTruthy(); expect(routes.getByRole("rowheader", { name: "Cache" })).toBeTruthy(); // An upstream row with no recorded resolver reads as Unknown, qualified by its kind. expect(routes.getByRole("rowheader", { name: "Unknown (Upstream)" })).toBeTruthy(); }); test("the donut ring is decoration; the legend and the hidden table are the accessible surface", async () => { renderApp(); await screen.findByText("1,000"); const svg = panel("Query types").querySelector("svg"); expect(svg?.getAttribute("aria-hidden")).toBe("true"); expect(svg?.getAttribute("focusable")).toBe("false"); expect(within(panel("Query types")).getByRole("table")).toBeTruthy(); }); test("an empty window says so in every panel instead of drawing nothing", async () => { renderApp("/overview?period=1h"); await screen.findByText("12"); // The two donuts and the client chart; the query-volume chart says it too. expect(screen.getAllByText("No queries in this period.").length).toBe(4); }); test("a deep link opens on the period it names", async () => { renderApp("/overview?period=1h"); await screen.findByText("12"); expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true"); expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false"); }); test("a period the API does not have falls back to the default without carrying it in the url", async () => { const router = renderApp("/overview?period=90d"); await screen.findByText("1,000"); expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("true"); expect(router.state.location.search).toEqual({}); }); test("the picker rescopes every panel and writes the period into the url", async () => { const router = renderApp(); await screen.findByText("1,000"); fireEvent.click(screen.getByRole("button", { name: "1h" })); await screen.findByText("12"); await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" })); // No panel is left describing the period the reader left. expect(screen.queryByText("1,000")).toBeNull(); }); test("a failed request is one error for the whole page, stated once and retryable", async () => { failing = true; renderApp(); await screen.findByText("endpoint unavailable"); // One statement of the failure, not one per panel: there is a single request // behind every panel, so a second copy would only repeat this sentence. expect(screen.getAllByText("endpoint unavailable")).toHaveLength(1); expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1); // The heading and the picker survive it, so the reader can rescope or retry. expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy(); expect(screen.getByRole("button", { name: "1h" })).toBeTruthy(); expect(screen.queryByText("Something went wrong")).toBeNull(); failing = false; fireEvent.click(screen.getByRole("button", { name: "Retry" })); await screen.findByText("1,000"); expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy(); }); test("an incomplete window states its watermark once for the whole page", async () => { coverageComplete = false; renderApp(); await screen.findByText("1,000"); expect(screen.getAllByText(/Query history is available from/)).toHaveLength(1); });