milestone 27: diagnostics — operational failures land in one curated log, resolved history purgeable
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
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 } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import { DIAGNOSTIC_CODES, type DiagnosticEvent } from "@/lib/types";
|
||||
import { EVENT_COPY } from "./eventCopy";
|
||||
|
||||
const NOW_S = Math.floor(Date.now() / 1000);
|
||||
|
||||
function event(overrides: Partial<DiagnosticEvent> = {}): DiagnosticEvent {
|
||||
return {
|
||||
id: 42,
|
||||
code: "blocklist.refresh",
|
||||
component: "blocklist",
|
||||
subject: "StevenBlack",
|
||||
severity: "warning",
|
||||
first_seen: NOW_S - 7200,
|
||||
last_seen: NOW_S - 600,
|
||||
occurrences: 4,
|
||||
resolved_at: null,
|
||||
detail: "download failed: ConnectionTimedOut",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A 204: what `DELETE /api/diagnostics/{id}` answers on a purge. */
|
||||
const NO_CONTENT = Symbol("204");
|
||||
|
||||
let responses: Record<string, unknown>;
|
||||
let requested: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
requested = [];
|
||||
responses = {
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => {
|
||||
const url = String(input);
|
||||
const method = init?.method ?? "GET";
|
||||
const key = method === "GET" ? url : `${method} ${url}`;
|
||||
requested.push(key);
|
||||
const payload = responses[key];
|
||||
if (payload === undefined)
|
||||
return new Response(JSON.stringify({ error: "no such event" }), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
if (payload === NO_CONTENT) return new Response(null, { status: 204 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/**
|
||||
* `retry` is off in the failure test: the shared client backs 5xx off for
|
||||
* seconds, which the render assertions would sit through for nothing.
|
||||
*/
|
||||
function renderDetail(id: number, { retry = true } = {}) {
|
||||
const queryClient = createQueryClient();
|
||||
if (!retry) {
|
||||
const defaults = queryClient.getDefaultOptions();
|
||||
queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
|
||||
}
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [`/diagnostics/${id}`] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
test("an open episode shows its facts, its copy and the error the server sent", async () => {
|
||||
responses["/api/diagnostics/42"] = event();
|
||||
renderDetail(42);
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||
expect(screen.getByText("Warning")).toBeTruthy();
|
||||
expect(screen.getByText("StevenBlack")).toBeTruthy();
|
||||
expect(screen.getByText("Active for 2h")).toBeTruthy();
|
||||
expect(screen.getByText("Not yet — still failing")).toBeTruthy();
|
||||
expect(screen.getByText("4")).toBeTruthy();
|
||||
expect(screen.getByText("blocklist.refresh")).toBeTruthy();
|
||||
expect(screen.getByText(EVENT_COPY["blocklist.refresh"].impact)).toBeTruthy();
|
||||
expect(screen.getByText(EVENT_COPY["blocklist.refresh"].remediation)).toBeTruthy();
|
||||
expect(screen.getByText("download failed: ConnectionTimedOut")).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Go to Blocklists" }).getAttribute("href")).toBe("/blocklists");
|
||||
});
|
||||
|
||||
test("a resolved episode states how long it lasted, not how long it has run", async () => {
|
||||
responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 });
|
||||
renderDetail(7);
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||
expect(screen.getByText("Resolved after 1h")).toBeTruthy();
|
||||
expect(screen.queryByText("Not yet — still failing")).toBeNull();
|
||||
});
|
||||
|
||||
test("an open episode offers no purge", async () => {
|
||||
responses["/api/diagnostics/42"] = event();
|
||||
renderDetail(42);
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||
expect(screen.queryByRole("button", { name: "Purge" })).toBeNull();
|
||||
});
|
||||
|
||||
test("purging a resolved episode asks first, then returns to the list", async () => {
|
||||
responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 });
|
||||
responses["DELETE /api/diagnostics/7"] = NO_CONTENT;
|
||||
responses["/api/diagnostics?state=active"] = { events: [], next_before: null, active: { warnings: 0, errors: 0 } };
|
||||
responses["/api/diagnostics?state=resolved"] = {
|
||||
events: [],
|
||||
next_before: null,
|
||||
active: { warnings: 0, errors: 0 },
|
||||
};
|
||||
const router = renderDetail(7);
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Purge" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog.textContent).toContain("Purge this resolved event? Its history is gone for good.");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
expect(requested).not.toContain("DELETE /api/diagnostics/7");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Purge" }));
|
||||
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
|
||||
|
||||
await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics/7"));
|
||||
// The row it was showing no longer exists, so the page it navigates to is
|
||||
// the list rather than a 404 of its own.
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/diagnostics"));
|
||||
});
|
||||
|
||||
test("a refused purge stays on the event and shows why", async () => {
|
||||
responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => {
|
||||
if (init?.method === "DELETE")
|
||||
return new Response(JSON.stringify({ error: "the event is still active" }), {
|
||||
status: 409,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
return new Response(JSON.stringify(responses[String(input)] ?? {}), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
const router = renderDetail(7, { retry: false });
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Purge" }));
|
||||
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("the event is still active");
|
||||
expect(router.state.location.pathname).toBe("/diagnostics/7");
|
||||
});
|
||||
|
||||
test("every code renders its own title, impact and remediation", async () => {
|
||||
for (const [index, code] of DIAGNOSTIC_CODES.entries()) {
|
||||
const id = 100 + index;
|
||||
responses[`/api/diagnostics/${id}`] = event({ id, code, component: code.slice(0, code.indexOf(".")) });
|
||||
renderDetail(id);
|
||||
|
||||
const copy = EVENT_COPY[code];
|
||||
await screen.findByRole("heading", { name: copy.title });
|
||||
expect(screen.getByText(copy.impact), code).toBeTruthy();
|
||||
expect(screen.getByText(copy.remediation), code).toBeTruthy();
|
||||
screen.getByText(code);
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an event retention has removed shows the server's message, not an empty page", async () => {
|
||||
renderDetail(999);
|
||||
await screen.findByText("no such event");
|
||||
expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an unavailable store reports the failure instead of loading forever", async () => {
|
||||
responses["/api/diagnostics/42"] = event();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input).startsWith("/api/diagnostics/")
|
||||
? new Response(JSON.stringify({ error: "store unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
: new Response(JSON.stringify(responses[String(input)] ?? {}), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderDetail(42, { retry: false });
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("The server is starting or degraded.");
|
||||
expect(screen.queryByText("Loading event…")).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy();
|
||||
});
|
||||
Reference in New Issue
Block a user