Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s
326 lines
12 KiB
TypeScript
326 lines
12 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 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<string, unknown> = {
|
|
"/api/stats?period=24h": {
|
|
period: "24h",
|
|
since: 0,
|
|
until: 86400,
|
|
queries: 0,
|
|
blocked: 0,
|
|
clients: 0,
|
|
avg_response_time_us: null,
|
|
coverage: { complete: true, available_since: 0 },
|
|
},
|
|
"/api/stats/timeseries?period=24h": {
|
|
period: "24h",
|
|
since: 0,
|
|
until: 86400,
|
|
bucket_seconds: 1800,
|
|
buckets: [],
|
|
coverage: { complete: true, available_since: 0 },
|
|
},
|
|
"/api/stats/clients?period=24h": {
|
|
period: "24h",
|
|
since: 0,
|
|
until: 86400,
|
|
bucket_seconds: 1800,
|
|
clients: [],
|
|
other: [],
|
|
coverage: { complete: true, available_since: 0 },
|
|
},
|
|
"/api/stats/types?period=24h": {
|
|
period: "24h",
|
|
since: 0,
|
|
until: 86400,
|
|
types: [],
|
|
coverage: { complete: true, available_since: 0 },
|
|
},
|
|
"/api/stats/routes?period=24h": {
|
|
period: "24h",
|
|
since: 0,
|
|
until: 86400,
|
|
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, ...CONFIGURATION_LABELS]) {
|
|
expect(screen.getByRole("link", { name: label })).toBeTruthy();
|
|
}
|
|
for (const label of GONE_LABELS) {
|
|
expect(screen.queryByRole("link", { name: label })).toBeNull();
|
|
}
|
|
});
|
|
|
|
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);
|
|
});
|