import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { ClientName, type ClientNames } from "./clientNames"; import { BASE, CLIENTS, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures"; /** The text of the elements an input points at with `aria-describedby`. */ function describedText(input: HTMLElement): string { const ids = input.getAttribute("aria-describedby"); if (ids === null) throw new Error("input has no aria-describedby"); return ids .split(/\s+/) .map((id) => { const node = document.getElementById(id); if (node === null) throw new Error(`aria-describedby names missing element ${id}`); return node.textContent ?? ""; }) .join(" "); } function clientRow(ip: string): HTMLElement { const row = screen.getByText(ip).closest("tr"); if (row === null) throw new Error(`no client row for ${ip}`); return row; } function assignmentsSection(): HTMLElement { const heading = screen.getByRole("heading", { name: /Network assignments/ }); const section = heading.closest("section"); if (section === null) throw new Error("no network assignments section"); return section; } /** * Editing a client writes configuration, so the affordance is absent until the * status query says the database owns it — never disabled, never assumed. */ async function unlockedEdit(index: number): Promise { const buttons = await screen.findAllByRole("button", { name: "Edit" }); return buttons[index] as HTMLButtonElement; } afterEach(() => { vi.unstubAllGlobals(); }); test("renders the identity-first table: address, name, group, first and last seen", async () => { await renderClientsPage(); const headers = screen.getAllByRole("columnheader").map((cell) => cell.textContent); expect(headers).toEqual(["Address", "Name", "Group", "First seen", "Last seen", "Actions"]); expect(screen.getByText("192.168.1.10")).toBeTruthy(); expect(screen.getByText("192.168.1.11")).toBeTruthy(); expect(screen.getByText("laptop")).toBeTruthy(); expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1); }); test("a named row shows the typed name and hides the learned one", async () => { await renderClientsPage(); expect(screen.getByText("laptop")).toBeTruthy(); expect(screen.queryByText("laptop-1.lan")).toBeNull(); }); test("an unnamed row shows the learned name with the learned marker", async () => { await renderClientsPage(); // The cell holds the learned name followed by the marker, so the match is on // the containing span rather than on a bare text node. const learned = screen.getByText( (content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"), ); // The marker is text, not colour, so a screen reader announces it too. expect(within(learned).getByText("learned")).toBeTruthy(); }); test("the learned marker is a Clients-page affordance: query tables render the same name without it", () => { const names: ClientNames = new Map([["192.168.1.11", { name: "", learned_name: "kids-tablet.lan" }]]); render(); expect(screen.getByText("kids-tablet.lan")).toBeTruthy(); expect(screen.queryByText("learned")).toBeNull(); }); test("the address links to the client's detail page", async () => { await renderClientsPage(); const link = within(clientRow("192.168.1.10")).getByRole("link", { name: "192.168.1.10" }); expect(link.getAttribute("href")).toBe("/clients/1"); }); test("delete asks first, naming the row, and the confirmation carries out the delete", async () => { const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} }); fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" })); const dialog = await screen.findByRole("alertdialog", { name: "Delete client" }); // The operator has to be able to tell from the dialog alone which row this is. expect(within(dialog).getByText(/kids-tablet\.lan \(192\.168\.1\.11\)/)).toBeTruthy(); expect(within(dialog).getByText(/re-materialize on their next DNS query/)).toBeTruthy(); // Asking is not deleting. expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]); fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); await waitFor(() => expect( fetchMock.mock.calls.filter(([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2")), ).toHaveLength(1), ); await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); }); test("cancelling the confirmation keeps the client", async () => { const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} }); fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" })); const dialog = await screen.findByRole("alertdialog", { name: "Delete client" }); fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); expect(screen.getByText("192.168.1.11")).toBeTruthy(); expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]); }); test("shows the DNS-activity empty state when there are no clients", async () => { await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } }); expect(screen.getByText(/rows appear automatically as devices on the network make dns queries/i)).toBeTruthy(); expect(screen.queryByRole("table")).toBeNull(); }); test("edit opens a dialog seeded with the client's name and group", async () => { await renderClientsPage(); fireEvent.click(await unlockedEdit(0)); // The dialog portals out of the table, so every field query is scoped to it. const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" })); expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop"); // The RAC Select names its trigger with the value and then the label. expect(dialog.getByRole("button", { name: /Group$/ }).textContent).toContain("default"); }); test("the group picker offers every group and reports the choice", async () => { await renderClientsPage(); fireEvent.click(await unlockedEdit(0)); const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" })); fireEvent.click(dialog.getByRole("button", { name: /Group$/ })); const options = await screen.findAllByRole("option"); expect(options.map((option) => option.textContent)).toEqual(["default", "kids"]); fireEvent.click(screen.getByRole("option", { name: "kids" })); expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids"); }); test("network assignments start clean and dirty on add", async () => { await renderClientsPage(); const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement; expect(range.value).toBe("192.168.1.0/24"); const save = screen.getByRole("button", { name: "Save assignments" }) as HTMLButtonElement; expect(save.disabled).toBe(true); fireEvent.click(screen.getByRole("button", { name: "Add range" })); expect(save.disabled).toBe(false); expect((screen.getByLabelText("Range 2") as HTMLInputElement).value).toBe(""); }); test("?group= filters the list to that group", async () => { const { router } = await renderClientsPage(); await router.navigate({ to: "/clients", search: { group: 2 } }); await waitFor(() => expect(screen.queryByText("192.168.1.10")).toBeNull()); expect(screen.getByText("192.168.1.11")).toBeTruthy(); expect(screen.getByText("Showing clients in kids.")).toBeTruthy(); }); test("an unknown group id filters to nothing and offers a way out", async () => { const { router } = await renderClientsPage(); await router.navigate({ to: "/clients", search: { group: 999 } }); await screen.findByText("No group with id 999 exists."); expect(screen.getByText("No clients match this filter.")).toBeTruthy(); expect(screen.queryByRole("table")).toBeNull(); fireEvent.click(screen.getByRole("link", { name: "Clear filter" })); await waitFor(() => expect(screen.getByText("192.168.1.10")).toBeTruthy()); expect(router.state.location.search).toEqual({}); }); // Both statuses lock the declared delete, but only file authority proves the // file declares the row; the anchors keep the two sentences apart. const DECLARED_NOTE = /^This client is declared in the configuration file/; const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/; test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => { await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE }); // The settled sentence, not the tag: "Locked" is already on screen while // authority is pending, so waiting on it would not wait for this status. await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/); expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]); const declared = clientRow("192.168.1.10"); const observed = clientRow("192.168.1.11"); expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true); expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false); // Why the locked Delete will not answer, in visible text and exactly once: // per row it would repeat down the whole page, and on the button it was a // title that a keyboard and a touch screen never reached. expect(screen.getAllByText(DECLARED_NOTE, { selector: "p" })).toHaveLength(1); expect(within(declared).queryByText(DECLARED_NOTE, { selector: "p" })).toBeNull(); // The description stays on the button itself too, for a reader on that control. const locked = within(declared).getByRole("button", { name: "Delete" }); expect(document.getElementById(locked.getAttribute("aria-describedby") ?? "")?.textContent).toMatch(DECLARED_NOTE); }); test("an all-observed page still says why Edit is gone, with no delete note to carry it", async () => { // Every row observed, so no Delete is locked. The edit lock is still real, and // "Locked" appearing with nothing to explain it is the failure this guards. const observedOnly = { clients: [CLIENTS.clients[1]] }; await renderClientsPage({ ...BASE, "GET /api/clients": observedOnly, "GET /api/config/status": MANAGED_FILE, }); // The settled sentence is both the anchor and the assertion: it is the whole // explanation for the missing Edit action. await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/); expect(await screen.findAllByText("Locked")).not.toHaveLength(0); // The delete sentence belongs only to a row that has one. expect(screen.queryByText(DECLARED_NOTE)).toBeNull(); expect((screen.getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false); }); test("file mode renders network assignments with no mutation control at all (R2-4)", async () => { await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE }); const section = await waitFor(() => { const found = assignmentsSection(); if (found.querySelector("table") === null) throw new Error("still editing"); return found; }); expect(within(section).getByText("192.168.1.0/24")).toBeTruthy(); expect( section.querySelectorAll( "input, textarea, select, [role='combobox'], [role='checkbox'], [contenteditable], button", ), ).toHaveLength(0); }); test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => { await renderClientsPage({ ...BASE, "GET /api/config/status": undefined }); await screen.findAllByText(/^Configuration status unavailable/); expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]); expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull(); expect(within(assignmentsSection()).getByText(/Configuration status unavailable/)).toBeTruthy(); const observed = clientRow("192.168.1.11"); expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false); const declared = clientRow("192.168.1.10"); expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true); }); // The status query polls, so authority can turn while a dialog or a delete // confirmation is already open. `undefined` is the failed status: the fetch stub // answers 404 for a key it does not hold. // describe.each([ ["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE], ["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE], ])("authority turning to %s under an open affordance", (_label, status, lockNote, otherNote) => { test("locks the open edit dialog's save path and leaves cancel working", async () => { const map = { ...BASE }; const { queryClient } = await renderClientsPage(map); fireEvent.click(await unlockedEdit(0)); expect( within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" })).getByRole("button", { name: "Save", }), ).toBeTruthy(); await setConfigStatus(map, queryClient, status); // The draft survives; only the save path goes, and it says why. const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" }); expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull(); expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop"); expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy(); expect(within(dialog).getByText("Locked")).toBeTruthy(); fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); }); test("locks an open declared-client delete confirmation in place and leaves cancel working", async () => { const map = { ...BASE }; const { queryClient, fetchMock } = await renderClientsPage(map); // The Edit affordance appearing is the proof authority resolved to // database; the transition under test starts from there. await unlockedEdit(0); const declared = clientRow("192.168.1.10"); fireEvent.click(within(declared).getByRole("button", { name: "Delete" })); const dialog = await screen.findByRole("alertdialog", { name: "Delete client" }); await setConfigStatus(map, queryClient, status); // The dialog stays put. Closing it would throw focus at the Delete button // the same turn disabled, and the reason would survive only as a title // attribute; here the reason is the dialog's own message. await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull()); expect(within(dialog).getByText(lockNote)).toBeTruthy(); expect(within(dialog).queryByText(otherNote)).toBeNull(); expect(within(dialog).getByText("Locked")).toBeTruthy(); fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]); }); test("a cancelled confirmation stays closed when authority comes back", async () => { const map = { ...BASE }; const { queryClient } = await renderClientsPage(map); await unlockedEdit(0); fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })); const dialog = await screen.findByRole("alertdialog", { name: "Delete client" }); fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); // A poll that fails and then recovers must not raise a destructive // question the operator already answered. await setConfigStatus(map, queryClient, status); await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]); await waitFor(() => expect(screen.getAllByRole("button", { name: "Edit" }).length).toBeGreaterThan(0)); expect(screen.queryByRole("alertdialog")).toBeNull(); }); test("the confirm action comes back when authority does, without a second prompt", async () => { const map = { ...BASE, "DELETE /api/clients/1": {} }; const { queryClient, fetchMock } = await renderClientsPage(map); await unlockedEdit(0); fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })); const dialog = await screen.findByRole("alertdialog", { name: "Delete client" }); await setConfigStatus(map, queryClient, status); await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull()); await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]); // The question was never withdrawn, so the answer returns to the same // dialog rather than asking the operator to start again. const confirm = await within(dialog).findByRole("button", { name: "Delete" }); fireEvent.click(confirm); await waitFor(() => expect( fetchMock.mock.calls.filter( ([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1"), ), ).toHaveLength(1), ); }); test("keeps an open observed-client delete confirmation live (R3-4)", async () => { const map = { ...BASE, "DELETE /api/clients/2": {} }; const { queryClient, fetchMock } = await renderClientsPage(map); // The Edit affordance appearing is the proof authority resolved to // database; the transition under test starts from there. await unlockedEdit(0); const observed = clientRow("192.168.1.11"); fireEvent.click(within(observed).getByRole("button", { name: "Delete" })); const dialog = await screen.findByRole("alertdialog", { name: "Delete client" }); await setConfigStatus(map, queryClient, status); fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); await waitFor(() => expect( fetchMock.mock.calls.filter( ([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2"), ), ).toHaveLength(1), ); }); }); test("a pending config status holds the same line as a failed one (R3-4)", async () => { // The status request never settles, so authority stays pending for the whole // test: nothing configuration owns may be offered on that guess. await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER }); await screen.findAllByText(/^Checking which configuration source/); expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]); expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull(); expect(within(assignmentsSection()).getByText(/Checking which configuration source/)).toBeTruthy(); const observed = clientRow("192.168.1.11"); expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false); }); test("a save-time problem marks the input it is about and describes it", async () => { await renderClientsPage(); const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement; fireEvent.change(range, { target: { value: "" } }); fireEvent.click(screen.getByRole("button", { name: "Save assignments" })); expect(range.getAttribute("aria-invalid")).toBe("true"); expect(describedText(range)).toBe("Row 1: prefix is required."); // Only the offending input is marked; the priority beside it is untouched. const priority = screen.getByLabelText("Priority for range 1"); expect(priority.getAttribute("aria-invalid")).toBeNull(); expect(priority.getAttribute("aria-describedby")).toBeNull(); fireEvent.change(range, { target: { value: "10.0.0.0/8" } }); fireEvent.change(screen.getByLabelText("Priority for range 1"), { target: { value: "abc" } }); fireEvent.click(screen.getByRole("button", { name: "Save assignments" })); expect(range.getAttribute("aria-invalid")).toBeNull(); expect(describedText(screen.getByLabelText("Priority for range 1"))).toBe( "Row 1: priority must be a whole number.", ); }); test("removing a row drops the message rather than moving it to another input", async () => { await renderClientsPage(); // Row 1 is the offending one, so removing it is what would slide the stale // index onto row 2 — an input that validated cleanly. const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement; fireEvent.change(range, { target: { value: "" } }); fireEvent.click(screen.getByRole("button", { name: "Add range" })); fireEvent.change(screen.getByLabelText("Range 2"), { target: { value: "10.0.0.0/8" } }); fireEvent.click(screen.getByRole("button", { name: "Save assignments" })); expect(range.getAttribute("aria-invalid")).toBe("true"); expect(describedText(range)).toBe("Row 1: prefix is required."); const section = assignmentsSection(); fireEvent.click(within(section).getAllByRole("button", { name: "Remove" })[0] as HTMLButtonElement); const survivor = screen.getByLabelText("Range 1") as HTMLInputElement; expect(survivor.value).toBe("10.0.0.0/8"); expect(survivor.getAttribute("aria-invalid")).toBeNull(); expect(survivor.getAttribute("aria-describedby")).toBeNull(); expect(within(section).queryByRole("alert")).toBeNull(); expect(screen.queryByLabelText("Range 2")).toBeNull(); }); test("editing a row clears the message it was about", async () => { await renderClientsPage(); const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement; fireEvent.change(range, { target: { value: "" } }); fireEvent.click(screen.getByRole("button", { name: "Save assignments" })); expect(range.getAttribute("aria-invalid")).toBe("true"); fireEvent.change(range, { target: { value: "10.0.0.0/8" } }); expect(range.getAttribute("aria-invalid")).toBeNull(); expect(within(assignmentsSection()).queryByRole("alert")).toBeNull(); });