rename web/ to admin/, along with the web-named build and cli identifiers

This commit is contained in:
2026-08-16 00:17:58 +02:00
parent 5b3d1cd65c
commit 1e97c80f6b
136 changed files with 196 additions and 196 deletions
@@ -0,0 +1,194 @@
import { act } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import PauseWidget, { formatRemaining } from "@/features/pause/PauseWidget";
import { createQueryClient } from "@/lib/queryClient";
import { queryKeys } from "@/lib/queries";
import type { PausePost, PauseState } from "@/lib/types";
let getState: PauseState;
let postBodies: PausePost[];
let postResponse: (body: PausePost) => PauseState;
let postFailure: (() => Response) | null;
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
beforeEach(() => {
postBodies = [];
postFailure = null;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url !== "/api/pause") return jsonResponse({ error: "not stubbed" });
if (init?.method === "POST") {
const body = JSON.parse(String(init.body)) as PausePost;
postBodies.push(body);
if (postFailure !== null) return postFailure();
return jsonResponse(postResponse(body));
}
return jsonResponse(getState);
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
function renderWidget(client?: QueryClient) {
render(
<QueryClientProvider client={client ?? createQueryClient()}>
<PauseWidget />
</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 () => {
getState = { paused: false, until: null };
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
renderWidget();
const trigger = await findPauseTrigger();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
expect(screen.getByRole("button", { name: label })).toBeTruthy();
}
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
await screen.findByRole("button", { name: "Resume" });
expect(screen.getByText(/^Paused \d+:\d{2}$/)).toBeTruthy();
});
test("indefinite pause sends no duration_seconds and renders without a countdown", async () => {
getState = { paused: false, until: null };
postResponse = () => ({ paused: true, until: null });
renderWidget();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
await screen.findByRole("button", { name: "Resume" });
expect(screen.getByText("Paused")).toBeTruthy();
});
test("resume posts paused false and returns to the Pause button", async () => {
getState = { paused: true, until: null };
postResponse = () => ({ paused: false, until: null });
renderWidget();
fireEvent.click(await screen.findByRole("button", { name: "Resume" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: false }]));
await screen.findByRole("button", { name: "Pause" });
});
test("timed pause counts down live", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const nowSec = Math.floor(Date.now() / 1000);
const client = createQueryClient();
client.setQueryData(queryKeys.pause, { paused: true, until: nowSec + 90 });
renderWidget(client);
expect(screen.getByText("Paused 1:30")).toBeTruthy();
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.getByText("Paused 1:28")).toBeTruthy();
});
test("escape closes the duration menu", async () => {
getState = { paused: false, until: null };
postResponse = () => getState;
renderWidget();
const trigger = await findPauseTrigger();
fireEvent.click(trigger);
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
fireEvent.keyDown(trigger, { key: "Escape" });
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
});
test("failed pause with 429 shows a ticking retry countdown", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
getState = { paused: false, until: null };
postFailure = () =>
new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "30" },
});
renderWidget();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { 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 () => {
getState = { paused: false, until: null };
postFailure = () =>
new Response(JSON.stringify({ error: "unavailable" }), {
status: 503,
headers: { "content-type": "application/json" },
});
renderWidget();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { 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 () => {
getState = { paused: false, until: null };
postFailure = () =>
new Response(JSON.stringify({ error: "unavailable" }), {
status: 503,
headers: { "content-type": "application/json" },
});
renderWidget();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await screen.findByRole("alert");
postFailure = null;
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await screen.findByRole("button", { name: "Resume" });
expect(screen.queryByRole("alert")).toBeNull();
});
test("formatRemaining renders m:ss and h:mm:ss and clamps at zero", () => {
expect(formatRemaining(0)).toBe("0:00");
expect(formatRemaining(-5)).toBe("0:00");
expect(formatRemaining(59)).toBe("0:59");
expect(formatRemaining(90)).toBe("1:30");
expect(formatRemaining(3661)).toBe("1:01:01");
});
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { pauseMutation, pauseQuery } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DURATIONS = [
{ label: "60 seconds", seconds: 60 },
{ label: "5 minutes", seconds: 300 },
{ label: "30 minutes", seconds: 1800 },
{ label: "Indefinitely", seconds: null },
] as const;
const styles = stylex.create({
/**
* Disabled text darkens in light scheme and lightens in dark, the opposite
* direction from `textMuted`, so the token cannot express it.
*/
trigger: {
color: {
default: null,
":disabled": "oklch(70.5% 0.015 286.067)",
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
},
},
pausedRow: {
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
},
pausedControls: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
/**
* Amber as standalone text on the app ground, not inside a warning banner, so
* the `warn*` tokens — tuned against `warnSurface` — do not apply here.
*/
pausedLabel: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: {
default: "oklch(55.5% 0.163 48.998)",
"@media (prefers-color-scheme: dark)": "oklch(82.8% 0.189 84.429)",
},
},
anchor: {
position: "relative",
},
menu: {
position: "absolute",
right: 0,
top: "100%",
zIndex: 10,
marginTop: "0.25rem",
display: "flex",
width: "9rem",
flexDirection: "column",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingBlock: "0.25rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
menuItem: {
borderStyle: "none",
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: "inherit",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
export function formatRemaining(totalSeconds: number): string {
const clamped = Math.max(0, totalSeconds);
const hours = Math.floor(clamped / 3600);
const minutes = Math.floor((clamped % 3600) / 60);
const seconds = clamped % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function useNowSeconds(active: boolean): number {
const [now, setNow] = useState(nowSeconds);
useEffect(() => {
if (!active) return;
setNow(nowSeconds());
const id = setInterval(() => setNow(nowSeconds()), 1000);
return () => clearInterval(id);
}, [active]);
return now;
}
export default function PauseWidget() {
const queryClient = useQueryClient();
const { data } = useQuery({
...pauseQuery(),
refetchInterval: (query) => (query.state.data?.paused === true ? 5000 : false),
});
const mutation = useMutation(pauseMutation(queryClient));
const [menuOpen, setMenuOpen] = useState(false);
const now = useNowSeconds(data?.paused === true && data.until !== null);
const paused = data?.paused === true;
const { reset } = mutation;
useEffect(() => reset(), [paused, reset]);
if (data === undefined) {
return (
<button type="button" disabled {...stylex.props(shared.button, styles.trigger, shared.focusRing)}>
Pause
</button>
);
}
if (data.paused) {
return (
<div {...stylex.props(styles.pausedRow)}>
<div {...stylex.props(styles.pausedControls)}>
<span {...stylex.props(styles.pausedLabel)}>
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
</span>
<button
type="button"
onClick={() => mutation.mutate({ paused: false })}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Resume
</button>
</div>
<InlineError error={mutation.error} />
</div>
);
}
return (
<div
{...stylex.props(styles.anchor)}
onKeyDown={(e) => {
if (e.key === "Escape") setMenuOpen(false);
}}
>
<button
type="button"
aria-expanded={menuOpen}
aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Pause
</button>
{menuOpen && (
<div id="pause-menu" {...stylex.props(styles.menu)}>
{DURATIONS.map(({ label, seconds }) => (
<button
key={label}
type="button"
onClick={() => {
setMenuOpen(false);
mutation.mutate(
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
);
}}
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
>
{label}
</button>
))}
</div>
)}
<InlineError error={mutation.error} />
</div>
);
}