Files
nxdns/admin/src/features/diagnostics/HealthStrip.test.tsx
T
mokhtar 7e0df5fd94
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
milestone 32: task-shaped configuration, file mode as a rendering, config status api
2026-08-22 22:42:50 +02:00

199 lines
7.9 KiB
TypeScript

/**
* The health strip on the Diagnostics page, through the real router.
*
* Its load contract is migrated whole from the deleted Overview status section:
* a visible loading state before the first reading, an error row with Retry when
* the first read fails, and a refetch failure that marks the conditions on
* screen as the last reading rather than the current state. What is new is that
* a condition explained on this page narrows this page instead of navigating.
*/
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 { health } from "@/lib/healthFixture";
import type { Health } from "@/lib/types";
let healthBody: Health;
let healthFails: boolean;
let requested: string[];
/** Held open to keep a health request in flight while a test looks at the strip. */
let pendingHealth: Promise<void> | null;
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
beforeEach(() => {
healthBody = health();
healthFails = false;
requested = [];
pendingHealth = null;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
requested.push(url);
if (url === "/api/health") {
if (pendingHealth !== null) await pendingHealth;
return healthFails ? json({ error: "health unavailable" }, 400) : 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();
vi.useRealTimers();
});
function renderDiagnostics(path = "/diagnostics") {
const queryClient = createQueryClient();
const defaults = queryClient.getDefaultOptions();
queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return { router, queryClient };
}
function strip(): HTMLElement {
return screen.getByRole("list", { name: "Current status" });
}
function fact(label: string): HTMLElement {
// First match, not only match: a condition's label and the link it offers can
// be the same word — Upstreams links to Upstreams — and the label comes first.
const cell = within(strip()).getAllByText(label)[0];
const item = cell.closest("li");
if (item === null) throw new Error(`no health fact for ${label}`);
return item;
}
test("the five conditions are stated in words, healthy ones without a way out", async () => {
renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
for (const [label, value] of [
["Protection", "Active"],
["Upstreams", "Available"],
["Query history", "Recording"],
["Diagnostics", "Recording"],
["Storage", "OK"],
] as const) {
expect(within(fact(label)).getByText(value)).toBeTruthy();
}
expect(within(strip()).queryByRole("link")).toBeNull();
});
test("protection unavailable sends the reader to the sources, upstreams to the pool", async () => {
healthBody = health({
protection: { state: "unavailable", until: null },
upstreams: { state: "unavailable", available: 0, total: 2 },
});
renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
expect(within(fact("Protection")).getByRole("link", { name: "Blocklist sources" }).getAttribute("href")).toBe(
"/configuration/protection?tab=sources",
);
expect(within(fact("Upstreams")).getByRole("link", { name: "Upstreams" }).getAttribute("href")).toBe(
"/configuration/resolution?tab=upstreams",
);
});
test("a losing query log narrows this page to the disk, a failed writer to the query log", async () => {
healthBody = health({ query_history: { state: "losing", dropped_total: 4, last_drop_s: null } });
const { router } = renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
expect(within(fact("Query history")).getByText("4 queries dropped")).toBeTruthy();
fireEvent.click(within(fact("Query history")).getByRole("link", { name: "Disk diagnostics" }));
await waitFor(() => expect(router.state.location.search).toEqual({ component: "disk" }));
await waitFor(() => expect(requested.some((url) => url.includes("component=disk"))).toBe(true));
});
test("a filter link drops a time window that would hide the episodes it points at", async () => {
healthBody = health({ disk: { state: "critical", free_bytes: 0 } });
const { router } = renderDiagnostics("/diagnostics?since=1000&until=2000&severity=error&state=resolved");
await waitFor(() => expect(strip()).toBeTruthy());
fireEvent.click(within(fact("Storage")).getByRole("link", { name: "Disk diagnostics" }));
// Everything that could hide the episode goes with the bounds: `state=resolved`
// would exclude the active disk episode this link exists to show, and an
// `error` severity would exclude it whenever it is a warning.
await waitFor(() => expect(router.state.location.search).toEqual({ component: "disk" }));
});
test("an unavailable diagnostics store explains itself and offers no link into itself", async () => {
healthBody = health({ diagnostics: { state: "unavailable", active_warnings: 0, active_errors: 0 } });
renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
const row = fact("Diagnostics");
expect(within(row).getByText(/not being recorded/)).toBeTruthy();
expect(within(row).queryByRole("link")).toBeNull();
});
test("the strip says it is loading before the first reading, never empty conditions", async () => {
// Through the route, which is the path that matters: the loader starts the
// health request without waiting for it, so the page paints while the reading
// is still in flight and the strip has to say so.
let release = () => {};
pendingHealth = new Promise<void>((resolve) => {
release = resolve;
});
renderDiagnostics();
expect(await screen.findByText("Loading status…")).toBeTruthy();
// Scoped to the page: "Protection" is also a nav destination now.
expect(within(document.querySelector("main") as HTMLElement).queryByText("Protection")).toBeNull();
release();
await waitFor(() => expect(strip()).toBeTruthy());
expect(screen.queryByText("Loading status…")).toBeNull();
});
test("a failed first health read is an error row with Retry, not a healthy strip", async () => {
healthFails = true;
renderDiagnostics();
await screen.findByText("health unavailable");
expect(screen.queryByRole("list", { name: "Current status" })).toBeNull();
});
test("a reading that has gone stale says so rather than passing for current", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
renderDiagnostics();
await vi.waitFor(() => expect(strip()).toBeTruthy());
expect(within(fact("Storage")).getByText("OK")).toBeTruthy();
// The next poll fails. The conditions on screen are the last that arrived and
// must not keep passing for the current state.
healthFails = true;
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.getByText(/last reading that arrived/)).toBeTruthy());
expect(within(fact("Storage")).getByText("OK")).toBeTruthy();
// Recovery clears the caption rather than leaving the page permanently unsure.
healthFails = false;
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.queryByText(/last reading that arrived/)).toBeNull());
});