/** * 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( , ); } async function findPauseTrigger(): Promise { 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(); });