the header row survives only on narrow screens; on desktop its lone occupant, log out, joins pause in the sidebar footer, both full width. pause leaves the query detail page's related actions, where a global control had no business, and its hand-rolled duration dropdown becomes a react-aria menu with real keyboard navigation, dismissal and positioning.
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
/**
|
|
* The Pause control, migrated from the header PauseWidget it replaces. Every
|
|
* behaviour that widget pinned is pinned here, now driven by `Health.protection`
|
|
* rather than by a second poll of `/api/pause`.
|
|
*/
|
|
|
|
import { act } from "react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import PauseControl from "@/features/pause/PauseControl";
|
|
import { formatClock } from "@/lib/format";
|
|
import { createQueryClient } from "@/lib/queryClient";
|
|
import { health } from "@/lib/healthFixture";
|
|
import { queryKeys } from "@/lib/queries";
|
|
import type { Health, PausePost, PauseState } from "@/lib/types";
|
|
|
|
let protection: Health["protection"];
|
|
let postBodies: PausePost[];
|
|
let postFailure: (() => Response) | null;
|
|
let healthFails: boolean;
|
|
|
|
function jsonResponse(payload: unknown): Response {
|
|
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
|
}
|
|
|
|
beforeEach(() => {
|
|
protection = { state: "active", until: null };
|
|
postBodies = [];
|
|
postFailure = null;
|
|
healthFails = false;
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = String(input);
|
|
if (url === "/api/health") {
|
|
if (healthFails) {
|
|
return new Response(JSON.stringify({ error: "nope" }), {
|
|
status: 400,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
return jsonResponse(health({ protection }));
|
|
}
|
|
if (url === "/api/pause" && init?.method === "POST") {
|
|
const body = JSON.parse(String(init.body)) as PausePost;
|
|
postBodies.push(body);
|
|
if (postFailure !== null) return postFailure();
|
|
protection = body.paused
|
|
? {
|
|
state: "paused",
|
|
until:
|
|
body.duration_seconds == null
|
|
? null
|
|
: Math.floor(Date.now() / 1000) + body.duration_seconds,
|
|
}
|
|
: { state: "active", until: null };
|
|
const echo: PauseState = { paused: body.paused, until: protection.until };
|
|
return jsonResponse(echo);
|
|
}
|
|
return jsonResponse({ error: "not stubbed" });
|
|
}),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
function renderControl(client?: QueryClient) {
|
|
render(
|
|
<QueryClientProvider client={client ?? createQueryClient()}>
|
|
<PauseControl />
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
async function findPauseTrigger(): Promise<HTMLButtonElement> {
|
|
await waitFor(() => {
|
|
const button = screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
|
expect(button.disabled).toBe(false);
|
|
});
|
|
return screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
|
}
|
|
|
|
test("unpaused: duration menu pauses with the picked duration_seconds", async () => {
|
|
renderControl();
|
|
|
|
const trigger = await findPauseTrigger();
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
|
|
fireEvent.click(trigger);
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
|
|
"60 seconds",
|
|
"5 minutes",
|
|
"30 minutes",
|
|
"Indefinitely",
|
|
]);
|
|
|
|
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
|
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
|
|
|
|
await screen.findByRole("button", { name: "Resume" });
|
|
});
|
|
|
|
test("indefinite pause sends no duration_seconds", async () => {
|
|
renderControl();
|
|
|
|
fireEvent.click(await findPauseTrigger());
|
|
fireEvent.click(screen.getByRole("menuitem", { name: "Indefinitely" }));
|
|
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
|
|
|
|
await screen.findByRole("button", { name: "Resume" });
|
|
});
|
|
|
|
test("resume posts paused false and returns to the Pause button", async () => {
|
|
protection = { state: "paused", until: null };
|
|
renderControl();
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: "Resume" }));
|
|
await waitFor(() => expect(postBodies).toEqual([{ paused: false }]));
|
|
await screen.findByRole("button", { name: "Pause" });
|
|
});
|
|
|
|
test("a timed pause says until when, beside the control that would end it", () => {
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
const client = createQueryClient();
|
|
client.setQueryData(queryKeys.health, health({ protection: { state: "paused", until: nowSec + 90 } }));
|
|
renderControl(client);
|
|
|
|
// The same clock format the Diagnostics health strip writes, off the same
|
|
// reading, so the two cannot say different things about one pause.
|
|
expect(screen.getByText(`Paused until ${formatClock(nowSec + 90)}`)).toBeTruthy();
|
|
expect(screen.getByRole("button", { name: "Resume" })).toBeTruthy();
|
|
});
|
|
|
|
test("a pause with no end says so without inventing a time", () => {
|
|
const client = createQueryClient();
|
|
client.setQueryData(queryKeys.health, health({ protection: { state: "paused", until: null } }));
|
|
renderControl(client);
|
|
|
|
expect(screen.getByText("Paused")).toBeTruthy();
|
|
expect(screen.queryByText(/until/)).toBeNull();
|
|
});
|
|
|
|
test("an active resolver states nothing: the button already says Pause", async () => {
|
|
renderControl();
|
|
|
|
expect(await findPauseTrigger()).toBeTruthy();
|
|
expect(screen.queryByText(/^Paused/)).toBeNull();
|
|
});
|
|
|
|
test("escape closes the duration menu", async () => {
|
|
renderControl();
|
|
|
|
fireEvent.click(await findPauseTrigger());
|
|
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
|
|
|
// Opening the menu moves focus into it, so Escape is pressed where the reader
|
|
// is, not on the trigger they left.
|
|
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
|
|
|
|
await waitFor(() => expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull());
|
|
expect(screen.getByRole("button", { name: "Pause" }).getAttribute("aria-expanded")).toBe("false");
|
|
});
|
|
|
|
test("failed pause with 429 shows a ticking retry countdown", async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
postFailure = () =>
|
|
new Response(JSON.stringify({ error: "rate limited" }), {
|
|
status: 429,
|
|
headers: { "content-type": "application/json", "Retry-After": "30" },
|
|
});
|
|
renderControl();
|
|
|
|
fireEvent.click(await findPauseTrigger());
|
|
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
|
|
|
const alert = await screen.findByRole("alert");
|
|
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
|
|
act(() => {
|
|
vi.advanceTimersByTime(1000);
|
|
});
|
|
expect(alert.textContent).toBe("Rate limited. Try again in 29s.");
|
|
});
|
|
|
|
test("failed pause with 503 shows the degraded message", async () => {
|
|
postFailure = () =>
|
|
new Response(JSON.stringify({ error: "unavailable" }), {
|
|
status: 503,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
renderControl();
|
|
|
|
fireEvent.click(await findPauseTrigger());
|
|
fireEvent.click(screen.getByRole("menuitem", { name: "60 seconds" }));
|
|
|
|
const alert = await screen.findByRole("alert");
|
|
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
|
|
});
|
|
|
|
test("a successful pause clears the previous mutation error", async () => {
|
|
postFailure = () =>
|
|
new Response(JSON.stringify({ error: "unavailable" }), {
|
|
status: 503,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
renderControl();
|
|
|
|
fireEvent.click(await findPauseTrigger());
|
|
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
|
await screen.findByRole("alert");
|
|
|
|
postFailure = null;
|
|
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
|
|
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
|
|
|
await screen.findByRole("button", { name: "Resume" });
|
|
expect(screen.queryByRole("alert")).toBeNull();
|
|
});
|
|
|
|
test("no control at all while protection is unavailable", async () => {
|
|
protection = { state: "unavailable", until: null };
|
|
const client = createQueryClient();
|
|
renderControl(client);
|
|
|
|
await waitFor(() => expect(client.getQueryData(queryKeys.health)).toBeDefined());
|
|
expect(screen.queryByRole("button")).toBeNull();
|
|
});
|
|
|
|
test("a failed poll after a good one withdraws the control rather than acting on a stale state", async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
renderControl();
|
|
await vi.waitFor(() =>
|
|
expect((screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement).disabled).toBe(false),
|
|
);
|
|
|
|
healthFails = true;
|
|
await vi.advanceTimersByTimeAsync(11_000);
|
|
|
|
await vi.waitFor(() => expect(screen.queryByRole("button")).toBeNull());
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test("a menu left open when the control withdraws does not come back open", async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
renderControl();
|
|
fireEvent.click(await findPauseTrigger());
|
|
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
|
|
|
protection = { state: "unavailable", until: null };
|
|
await vi.advanceTimersByTimeAsync(11_000);
|
|
await vi.waitFor(() => expect(screen.queryByRole("button")).toBeNull());
|
|
|
|
protection = { state: "active", until: null };
|
|
await vi.advanceTimersByTimeAsync(11_000);
|
|
|
|
// Protection is back, and so is the trigger — but the menu is a thing the
|
|
// reader opened, and nobody opened this one.
|
|
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test("a menu open when someone else pauses does not reopen when that pause ends", async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
renderControl();
|
|
fireEvent.click(await findPauseTrigger());
|
|
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
|
|
|
// Filtering is paused from somewhere else, and this browser learns it from
|
|
// the poll. The Resume rendering has no menu.
|
|
protection = { state: "paused", until: null };
|
|
await vi.advanceTimersByTimeAsync(11_000);
|
|
await vi.waitFor(() => expect(screen.getByRole("button", { name: "Resume" })).toBeTruthy());
|
|
|
|
protection = { state: "active", until: null };
|
|
await vi.advanceTimersByTimeAsync(11_000);
|
|
|
|
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test("protection unknown offers no control at all, by the same rule as unavailable", () => {
|
|
// Health has not answered. Which of Pause and Resume applies is exactly what
|
|
// it has not said, so the control names neither.
|
|
renderControl();
|
|
|
|
expect(screen.queryByRole("button")).toBeNull();
|
|
});
|