milestone 23 s2: react aria primitives and their cluster

This commit is contained in:
2026-08-12 22:12:18 +02:00
parent 994bbf922c
commit 01e455c8af
32 changed files with 2316 additions and 532 deletions
+60 -13
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
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";
@@ -33,16 +33,26 @@ const RESPONSES: Record<string, unknown> = {
// The API orders groups by name, so the id-1 default is not always first.
let groups: { id: number; name: string; safe_search: boolean }[];
let deleted: string[];
function deleteCalls(): string[] {
return deleted;
}
beforeEach(() => {
groups = [
{ id: 1, name: "Default", safe_search: false },
{ id: 2, name: "Kids", safe_search: true },
];
deleted = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/rules" && init?.method === "POST") {
return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
@@ -75,6 +85,25 @@ function renderRulesRoute() {
);
}
/**
* A RAC Select names its trigger with the current value and then the label, so
* the label alone is a suffix match. Opening it is the only way to read the
* options: there is no `<select>` carrying them any more.
*/
function trigger(label: string): HTMLElement {
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
}
async function optionsOf(label: string): Promise<(string | null)[]> {
fireEvent.click(trigger(label));
const options = await screen.findAllByRole("option");
const labels = options.map((option) => option.textContent);
// Re-picking the current value closes the listbox and changes nothing.
fireEvent.click(options.find((option) => option.getAttribute("aria-selected") === "true") ?? options[0]!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
return labels;
}
test("renders the rule table and the create form with contract enums", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
@@ -87,14 +116,9 @@ test("renders the rule table and the create form with contract enums", async ()
expect(table.getByText("Kids")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
const kindSelect = screen.getByLabelText("Kind") as HTMLSelectElement;
expect(Array.from(kindSelect.options).map((o) => o.value)).toEqual(["exact", "wildcard"]);
const actionSelect = screen.getByLabelText("Action") as HTMLSelectElement;
expect(Array.from(actionSelect.options).map((o) => o.value)).toEqual(["allow", "block"]);
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Default", "Kids"]);
expect(await optionsOf("Kind")).toEqual(["exact", "wildcard"]);
expect(await optionsOf("Action")).toEqual(["allow", "block"]);
expect(await optionsOf("Group")).toEqual(["Default", "Kids"]);
});
test("rule create shows a countdown when rate limited with Retry-After", async () => {
@@ -116,9 +140,8 @@ test("the group select preselects the id-1 default, not the alphabetically first
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Attic", "Default"]);
expect(groupSelect.value).toBe("1");
expect(await optionsOf("Group")).toEqual(["Attic", "Default"]);
expect(trigger("Group").textContent).toContain("Default");
});
test("the group select falls back to the first group when the default is absent", async () => {
@@ -129,5 +152,29 @@ test("the group select falls back to the first group when the default is absent"
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("5");
expect(trigger("Group").textContent).toContain("Attic");
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete the block rule for "ads.example.com"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleteCalls()).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that rule", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleteCalls()).toEqual(["/api/rules/2"]));
});