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 CONFIGURATION_LABELS = ["Protection", "Resolution", "System"]; /** 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 = { "/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( , ); 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, ...CONFIGURATION_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 three configuration pages sit under a labelled group, after the rest", async () => { renderShell(); await screen.findByRole("heading", { name: "Overview" }); const group = screen.getByRole("list", { name: "Configuration" }); expect( within(group) .getAllByRole("link") .map((link) => link.textContent), ).toEqual(CONFIGURATION_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: "Configuration" }); 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: "Configuration" }); 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(); fireEvent.click(await screen.findByRole("button", { name: "Log out" })); await screen.findByText("Rate limited. Try again in 7s."); expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy(); }); test("the header carries no protection display at all any more", async () => { renderShell(); await screen.findByRole("heading", { name: "Overview" }); // Scoped to the header: "Protection" is a nav destination now, and that is // not the status pill this test buried. const header = within(document.querySelector("header") as HTMLElement); for (const gone of [/^Protection/, /^Paused/]) { expect(header.queryByRole("link", { name: gone })).toBeNull(); expect(header.queryByText(gone)).toBeNull(); } }); 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("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); });