milestone 30: overview as a dashboard, explicit health contract, period aggregations
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
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, resetAuthProbeForTests } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import { formatClock } from "@/lib/format";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import type { Health } from "@/lib/types";
|
||||
|
||||
const NAV_LABELS = [
|
||||
"Dashboard",
|
||||
"Overview",
|
||||
"Activity",
|
||||
"Clients",
|
||||
"Groups",
|
||||
@@ -25,7 +28,6 @@ const RESPONSES: Record<string, unknown> = {
|
||||
until: 86400,
|
||||
queries: 0,
|
||||
blocked: 0,
|
||||
cached: 0,
|
||||
clients: 0,
|
||||
avg_response_time_us: null,
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
@@ -38,27 +40,50 @@ const RESPONSES: Record<string, unknown> = {
|
||||
buckets: [],
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
},
|
||||
"/api/health": {
|
||||
status: "ok",
|
||||
disk: { state: "ok", free_bytes: 0, db_bytes: 0, log_bytes: 0, sample_failures: 0 },
|
||||
upstreams: { available: 1, total: 1 },
|
||||
queries_dropped: 0,
|
||||
writer_failed: false,
|
||||
refreshes_gated: 0,
|
||||
snapshot_generation: null,
|
||||
diagnostics: { state: "recording", active_warnings: 0, active_errors: 0 },
|
||||
"/api/stats/clients?period=24h": {
|
||||
period: "24h",
|
||||
since: 0,
|
||||
until: 86400,
|
||||
bucket_seconds: 1800,
|
||||
clients: [],
|
||||
other: [],
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
},
|
||||
"/api/upstream/health": { upstreams: [], available: 1, total: 1 },
|
||||
"/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 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
resetAuthProbeForTests();
|
||||
/** Null makes the health poll fail, which the nav badge has to treat as unknown. */
|
||||
let healthBody: Health | 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/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), {
|
||||
@@ -67,13 +92,9 @@ beforeEach(() => {
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("shell renders the dashboard route with all nav links", async () => {
|
||||
function renderShell() {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
|
||||
render(
|
||||
@@ -83,8 +104,30 @@ test("shell renders the dashboard route with all nav links", async () => {
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
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();
|
||||
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();
|
||||
@@ -94,41 +137,105 @@ test("shell renders the dashboard route with all nav links", async () => {
|
||||
});
|
||||
|
||||
test("mount probe reveals the logout button and a failed logout surfaces inline", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
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" },
|
||||
});
|
||||
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,
|
||||
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;
|
||||
});
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
renderShell();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
|
||||
|
||||
await screen.findByText("Rate limited. Try again in 7s.");
|
||||
expect(screen.getByRole("heading", { name: "Dashboard" })).toBeTruthy();
|
||||
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" });
|
||||
for (const gone of [/^Protection/, /^Paused/]) {
|
||||
expect(screen.queryByRole("link", { name: 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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user