Files
nxdns/admin/src/shell/AppShell.test.tsx
T
mokhtar 209c1c77a9 admin: overview redesign, device scope, one formatting contract (milestone 39)
The Overview page takes the decided visual language (specs/ui-visual-redesign.md): four centred totals with their Activity links, a smoothed area chart of total and blocked queries with point hover and a tooltip beside the point, a stacked client chart in eight distinct hues plus one Other band, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown.

GET /api/overview takes a client parameter; the scoped read uses idx_query_log_ts and the cache keeps scoped slots. The device selector beside the period selector is URL state, so a scoped view is a link, and the tile links carry the scope into Activity.

All numbers go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. A sweep test refuses toFixed, toLocaleString, Intl.NumberFormat and padStart anywhere else.

Chrome: one 4px radius from the metrics constants, shared Card with a prominent title and description, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), and the accent darkened to 5.43:1 on its wash.

Not built: the spec's ranked-list primitive, which has no consumer and no API rows. Codex could not review sessions B to D, its endpoint returned 404 for every call on 2026-09-03; the owner accepted the smoke-server screenshots.
2026-09-03 17:16:24 +02:00

384 lines
16 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider, resetAuthProbeForTests } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { formatClock, formatTime } from "@/lib/format";
import { health } from "@/lib/healthFixture";
import type { ConfigStatus, Health } from "@/lib/types";
const NAV_LABELS = ["Overview", "Activity", "Clients", "Diagnostics"];
const SYSTEM_LABELS = ["Protection", "Resolution", "System", "Diagnostics"];
/** The pages the redesign folded into the three configuration ones. */
const GONE_LABELS = ["Groups", "Blocklists", "Rules", "Local DNS", "Upstreams", "Settings"];
const CONFIG_PATH = "/etc/nxdns/config.zon";
const RECONCILED_AT = 1754899200;
const DATABASE: ConfigStatus = { authority: "database", path: null, reconciled_at: null, restart_pending: false };
const RESPONSES: Record<string, unknown> = {
"/api/overview?period=24h": {
period: "24h",
since: 0,
until: 86400,
bucket_seconds: 1800,
totals: { queries: 0, blocked: 0, clients: 0, avg_response_time_us: null },
buckets: [],
clients: [],
other: [],
types: [],
routes: [],
coverage: { complete: true, available_since: 0 },
},
"/api/diagnostics?state=active": { events: [], next_before: null, active: { warnings: 0, errors: 0 } },
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
/** Null makes the health poll fail, which the nav badge has to treat as unknown. */
let healthBody: Health | null;
/** Null makes the config status poll fail, which the shell has to say out loud. */
let configStatus: ConfigStatus | null;
function stubFetch(extra: (url: string) => Response | null = () => null) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const override = extra(url);
if (override !== null) return override;
if (url === "/api/config/status") {
const failed = configStatus === null;
return new Response(JSON.stringify(failed ? { error: "config status unavailable" } : configStatus), {
status: failed ? 503 : 200,
headers: { "content-type": "application/json" },
});
}
if (url === "/api/health") {
const failed = healthBody === null;
return new Response(JSON.stringify(failed ? { error: "health unavailable" } : healthBody), {
status: failed ? 503 : 200,
headers: { "content-type": "application/json" },
});
}
const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
}
function renderShell() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return router;
}
function diagnosticsBadgeText(): string | null {
const link = screen.getAllByRole("link", { name: /^Diagnostics/ })[0];
const badge = link.querySelector("[aria-label]");
return badge === null ? null : (badge.textContent ?? "");
}
beforeEach(() => {
sessionStorage.clear();
resetAuthProbeForTests();
healthBody = health();
configStatus = DATABASE;
stubFetch();
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("shell renders the overview route with all nav links", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const nav = screen.getByRole("navigation", { name: "Main" });
expect(nav).toBeTruthy();
for (const label of [...NAV_LABELS, ...SYSTEM_LABELS]) {
expect(screen.getByRole("link", { name: label })).toBeTruthy();
}
for (const label of GONE_LABELS) {
expect(screen.queryByRole("link", { name: label })).toBeNull();
}
});
test("main carries the ids the router scrolls and restores", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const main = screen.getByRole("main");
expect(main.id).toBe("main-content");
expect(main.getAttribute("data-scroll-restoration-id")).toBe("main");
});
test("the configuration pages and Diagnostics sit under the System group, after Monitoring", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const group = screen.getByRole("list", { name: "System" });
expect(
within(group)
.getAllByRole("link")
.map((link) => link.textContent),
).toEqual(SYSTEM_LABELS);
// The group is a section of Main, not a nav of its own.
const nav = screen.getByRole("navigation", { name: "Main" });
expect(nav.contains(group)).toBe(true);
const clients = within(nav).getByRole("link", { name: "Clients" });
expect(clients.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test("under file authority the nav states the file and when it was loaded", async () => {
configStatus = {
authority: "managed_file",
path: CONFIG_PATH,
reconciled_at: RECONCILED_AT,
restart_pending: false,
};
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const line = await screen.findByText(/^File-managed ·/);
expect(line.textContent).toBe(`File-managed · ${CONFIG_PATH} · loaded ${formatTime(RECONCILED_AT)}`);
const group = screen.getByRole("list", { name: "System" });
expect(group.parentElement?.contains(line)).toBe(true);
});
test("under database authority there is no authority line to read", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await screen.findByRole("list", { name: "System" });
expect(screen.queryByText(/File-managed/)).toBeNull();
});
test("a pending restart is announced on every page, with no way to dismiss it", async () => {
configStatus = { ...DATABASE, restart_pending: true };
renderShell();
const notice = await screen.findByText(/Saved changes are not running yet\. Restart nxdns to apply them\./);
expect(within(notice).queryByRole("button")).toBeNull();
expect(screen.queryByRole("button", { name: /dismiss/i })).toBeNull();
});
test("the restart notice is server state, so a browser refresh does not clear it", async () => {
configStatus = { ...DATABASE, restart_pending: true };
renderShell();
await screen.findByText(/Saved changes are not running yet/);
// A refresh: everything client-side is thrown away and rebuilt from the API.
cleanup();
renderShell();
await screen.findByText(/Saved changes are not running yet/);
});
test("a failed config status is stated rather than passed off as database authority", async () => {
configStatus = null;
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await screen.findByText(/Configuration status unavailable — file authority and pending restarts cannot be shown\./);
});
test("mount probe reveals the logout button and a failed logout surfaces inline", async () => {
stubFetch((url) => {
if (url === "/api/auth/login")
return new Response(JSON.stringify({ error: "password required" }), {
status: 401,
headers: { "content-type": "application/json" },
});
if (url === "/api/auth/logout")
return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "retry-after": "7" },
});
return null;
});
renderShell();
// Both renderings are mounted; the viewport paints the sidebar one on WIDE
// and the header one below it.
await waitFor(() => expect(screen.getAllByRole("button", { name: "Log out" })).toHaveLength(2));
const aside = document.querySelector("aside") as HTMLElement;
fireEvent.click(within(aside).getByRole("button", { name: "Log out" }));
await within(aside).findByText("Rate limited. Try again in 7s.");
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
});
test("Log out sits in the sidebar on wide, and only in the header below it", async () => {
stubFetch((url) =>
url === "/api/auth/login"
? new Response(JSON.stringify({ error: "password required" }), {
status: 401,
headers: { "content-type": "application/json" },
})
: null,
);
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
const header = document.querySelector("header") as HTMLElement;
await waitFor(() => expect(within(aside).getByRole("button", { name: "Log out" })).toBeTruthy());
expect(within(header).getByRole("button", { name: "Log out" })).toBeTruthy();
// The header is the narrow rendering now: the Menu button is its only nav.
expect(within(header).getByRole("button", { name: "Menu" })).toBeTruthy();
// In the sidebar the control precedes the version label rather than crowding it.
const version = within(aside).getByText(/^nxdns v/);
const logout = within(aside).getByRole("button", { name: "Log out" });
expect(logout.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// The drawer keeps no third copy: the header already carries the narrow one.
fireEvent.click(within(header).getByRole("button", { name: "Menu" }));
const drawer = document.getElementById("mobile-nav") as HTMLElement;
expect(within(drawer).queryByRole("button", { name: "Log out" })).toBeNull();
});
test("the header carries the protection state as a reading, never as a control", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const header = within(document.querySelector("header") as HTMLElement);
await waitFor(() => expect(header.getByText("Protection active")).toBeTruthy());
expect(header.queryByRole("link", { name: /^Protection/ })).toBeNull();
expect(header.queryByRole("button", { name: /^Pause/ })).toBeNull();
});
test("the sidebar's foot states protection and uptime, and queries/min only on Overview", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
const status = within(aside).getByLabelText("Status");
await waitFor(() => expect(within(status).getByText("Active")).toBeTruthy());
// 1 second of uptime from the fixture, aged by however long the test took.
expect(within(status).getByText("Uptime").nextElementSibling?.textContent).toMatch(/^\d+s$/);
// The fixture's overview body: no queries over a 24h window, stated as a rate.
await waitFor(() => expect(within(status).getByText("Queries/min")).toBeTruthy());
expect(within(status).getByText("Queries/min").nextElementSibling?.textContent).toBe("0.0");
});
test("Pause sits at the foot of the sidebar, above the version label", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
const pause = await waitFor(() => within(aside).getByRole("button", { name: "Pause" }));
const version = within(aside).getByText(/^nxdns v/);
// Node order, not styling: the control precedes the version footer.
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test("the mobile drawer carries the same control, not a header one it lost", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(1));
fireEvent.click(screen.getByRole("button", { name: "Menu" }));
// Both renderings are mounted; the viewport decides which is painted.
await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(2));
const drawer = document.getElementById("mobile-nav") as HTMLElement;
const pause = within(drawer).getByRole("button", { name: "Pause" });
const version = within(drawer).getByText(/^nxdns v/);
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test("the Menu button is a disclosure trigger, and it names the panel it opens", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const menu = screen.getByRole("button", { name: "Menu" });
// The trigger states the drawer's state, and points at the drawer itself.
expect(menu.getAttribute("aria-expanded")).toBe("false");
expect(menu.getAttribute("aria-controls")).toBe("mobile-nav");
const drawer = document.getElementById("mobile-nav") as HTMLElement;
expect(drawer.getAttribute("hidden")).not.toBeNull();
fireEvent.click(menu);
expect(menu.getAttribute("aria-expanded")).toBe("true");
expect(drawer.getAttribute("hidden")).toBeNull();
expect(within(drawer).getByRole("navigation", { name: "Main" })).toBeTruthy();
fireEvent.click(menu);
expect(menu.getAttribute("aria-expanded")).toBe("false");
expect(drawer.getAttribute("hidden")).not.toBeNull();
});
test("following a drawer link closes the drawer behind it", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const menu = screen.getByRole("button", { name: "Menu" });
fireEvent.click(menu);
const drawer = document.getElementById("mobile-nav") as HTMLElement;
fireEvent.click(within(drawer).getByRole("link", { name: "Clients" }));
await waitFor(() => expect(menu.getAttribute("aria-expanded")).toBe("false"));
expect(drawer.getAttribute("hidden")).not.toBeNull();
});
test("a paused resolver says so in both renderings, not only on Diagnostics", async () => {
// The trace a pause leaves on every page. With the header indicator and the
// Overview status row both gone, a reader who is not on Diagnostics has only
// this line to tell them filtering is off.
const until = Math.floor(Date.now() / 1000) + 90;
healthBody = health({ protection: { state: "paused", until } });
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
await waitFor(() => expect(within(aside).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Menu" }));
const drawer = document.getElementById("mobile-nav") as HTMLElement;
await waitFor(() => expect(within(drawer).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy());
});
test("nothing open and a healthy rollup leaves the Diagnostics item unbadged", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(document.querySelector("aside")?.textContent).toContain("Diagnostics"));
expect(diagnosticsBadgeText()).toBeNull();
});
test("open episodes are counted on the nav item", async () => {
healthBody = health({ diagnostics: { state: "recording", active_warnings: 1, active_errors: 2 } });
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(diagnosticsBadgeText()).toBe("3"));
expect(screen.getAllByLabelText("3 active diagnostic events").length).toBeGreaterThan(0);
});
test("a degraded rollup with nothing open is still marked, and a failed poll too", async () => {
healthBody = health({ status: "degraded" });
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(diagnosticsBadgeText()).toBe("!"));
expect(screen.getAllByLabelText("Health degraded").length).toBeGreaterThan(0);
});
test("a health poll that failed is marked unknown rather than left looking healthy", async () => {
healthBody = null;
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(diagnosticsBadgeText()).toBe("!"));
expect(screen.getAllByLabelText("Health unavailable").length).toBeGreaterThan(0);
});