admin: associate inline field errors with their inputs

network assignment rows, activity filters, and the settings password pair now mark the offending input with aria-invalid and point it at the error text with aria-describedby. the prefix validator returns the row and field it is about, and any row mutation clears a message that named a position. settings numeric fields carry aria-invalid on an unparseable value.
This commit is contained in:
2026-08-29 12:08:19 +02:00
parent 317d5dd4f8
commit 59d6be98f8
8 changed files with 295 additions and 27 deletions
@@ -0,0 +1,80 @@
/**
* The filter row's error association.
*
* The timezone is fixed because the only invalid bound reachable in jsdom is a
* wall-clock time inside a spring-forward gap: jsdom applies the
* `datetime-local` value sanitization algorithm, so text that is merely
* incomplete never reaches the component at all — the input hands it back as
* the empty string, which is a bound the operator cleared.
*/
import { afterAll, expect, test, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import ActivityFilters, { NO_FILTERS } from "./ActivityFilters";
vi.stubEnv("TZ", "Europe/Paris");
afterAll(() => vi.unstubAllEnvs());
/** 02:30 does not exist on this date in Paris; the clock jumps 02:00 to 03:00. */
const GAP_WALL_TIME = "2026-03-29T02:30:00";
/** 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 renderFilters() {
const applied: { current: unknown } = { current: null };
render(
<ActivityFilters
applied={NO_FILTERS}
isDisabled={false}
onApply={(filters) => (applied.current = filters)}
onClear={() => (applied.current = null)}
/>,
);
return applied;
}
test("a rejected bound marks its own input and describes it", () => {
const applied = renderFilters();
const since = screen.getByLabelText("Since") as HTMLInputElement;
const until = screen.getByLabelText("Until") as HTMLInputElement;
fireEvent.change(since, { target: { value: GAP_WALL_TIME } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
expect(since.getAttribute("aria-invalid")).toBe("true");
expect(describedText(since)).toContain("daylight saving");
// Refused means refused, and only the offending bound carries the mark.
expect(applied.current).toBeNull();
expect(until.getAttribute("aria-invalid")).toBeNull();
expect(until.getAttribute("aria-describedby")).toBeNull();
});
test("the mark moves to the other bound, and clears once both parse", () => {
renderFilters();
const since = screen.getByLabelText("Since") as HTMLInputElement;
const until = screen.getByLabelText("Until") as HTMLInputElement;
fireEvent.change(until, { target: { value: GAP_WALL_TIME } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
expect(since.getAttribute("aria-invalid")).toBeNull();
expect(describedText(until)).toContain("daylight saving");
fireEvent.change(until, { target: { value: "2026-03-29T04:30:00" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
expect(until.getAttribute("aria-invalid")).toBeNull();
expect(until.getAttribute("aria-describedby")).toBeNull();
});
@@ -96,10 +96,24 @@ function optionBlocked(value: string): boolean | undefined {
return value === "allowed" ? false : undefined; return value === "allowed" ? false : undefined;
} }
function boundError(label: string, reason: "unparseable" | "nonexistent"): string { /** The message plus the bound it belongs to, so that input can point at it. */
return reason === "unparseable" interface BoundError {
field: "since" | "until";
message: string;
}
/** The editor is rendered once per page, so the message can hold a fixed id. */
const ERROR_ID = "activity-filter-error";
function boundError(field: "since" | "until", reason: "unparseable" | "nonexistent"): BoundError {
const label = field === "since" ? "Since" : "Until";
return {
field,
message:
reason === "unparseable"
? `${label} is not a complete date and time.` ? `${label} is not a complete date and time.`
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`; : `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`,
};
} }
interface Props { interface Props {
@@ -115,18 +129,22 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
const [blocked, setBlocked] = useState(blockedOption(applied.blocked)); const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since)); const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until)); const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<BoundError | null>(null);
/** True for the one bound the current message is about; nothing else is marked. */
const invalid = (field: BoundError["field"]): true | undefined =>
error !== null && error.field === field ? true : undefined;
function submit(event: FormEvent) { function submit(event: FormEvent) {
event.preventDefault(); event.preventDefault();
const sinceValue = resolveDatetimeField(since); const sinceValue = resolveDatetimeField(since);
if (!sinceValue.ok) { if (!sinceValue.ok) {
setError(boundError("Since", sinceValue.reason)); setError(boundError("since", sinceValue.reason));
return; return;
} }
const untilValue = resolveDatetimeField(until); const untilValue = resolveDatetimeField(until);
if (!untilValue.ok) { if (!untilValue.ok) {
setError(boundError("Until", untilValue.reason)); setError(boundError("until", untilValue.reason));
return; return;
} }
setError(null); setError(null);
@@ -186,6 +204,8 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
type="datetime-local" type="datetime-local"
step={1} step={1}
value={since.text} value={since.text}
aria-invalid={invalid("since")}
aria-describedby={invalid("since") && ERROR_ID}
disabled={isDisabled} disabled={isDisabled}
onChange={(event) => setSince(editDatetimeField(since, event.target.value))} onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)} {...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
@@ -197,6 +217,8 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
type="datetime-local" type="datetime-local"
step={1} step={1}
value={until.text} value={until.text}
aria-invalid={invalid("until")}
aria-describedby={invalid("until") && ERROR_ID}
disabled={isDisabled} disabled={isDisabled}
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))} onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)} {...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
@@ -221,8 +243,8 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
</div> </div>
</form> </form>
{error !== null && ( {error !== null && (
<p role="alert" {...stylex.props(styles.error)}> <p id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
{error} {error.message}
</p> </p>
)} )}
</> </>
@@ -2,6 +2,20 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
import { ClientName, type ClientNames } from "./clientNames"; import { ClientName, type ClientNames } from "./clientNames";
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures"; import { BASE, 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 { function clientRow(ip: string): HTMLElement {
const row = screen.getByText(ip).closest("tr"); const row = screen.getByText(ip).closest("tr");
if (row === null) throw new Error(`no client row for ${ip}`); if (row === null) throw new Error(`no client row for ${ip}`);
@@ -315,7 +329,9 @@ describe.each([
fireEvent.click(confirm); fireEvent.click(confirm);
await waitFor(() => await waitFor(() =>
expect( expect(
fetchMock.mock.calls.filter(([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1")), fetchMock.mock.calls.filter(
([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1"),
),
).toHaveLength(1), ).toHaveLength(1),
); );
}); });
@@ -357,3 +373,66 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
const observed = clientRow("192.168.1.11"); const observed = clientRow("192.168.1.11");
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false); 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();
});
@@ -4,7 +4,15 @@ import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries"; import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types"; import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup"; import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor"; import {
firstProblem,
initPrefixEditor,
isDirty,
prefixEditorReducer,
toInputs,
type PrefixEditorAction,
type PrefixProblem,
} from "./prefixEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import AuthorityGate from "@/features/configuration/AuthorityGate"; import AuthorityGate from "@/features/configuration/AuthorityGate";
import Select from "@/ui/Select"; import Select from "@/ui/Select";
@@ -16,6 +24,9 @@ interface Props {
groups: Group[]; groups: Group[];
} }
/** The editor is rendered once per page, so the message can hold a fixed id. */
const VALIDATION_ID = "network-assignments-validation";
const styles = stylex.create({ const styles = stylex.create({
section: { section: {
marginTop: "2.5rem", marginTop: "2.5rem",
@@ -162,12 +173,24 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
function AssignmentsEditor({ prefixes, groups }: Props) { function AssignmentsEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient)); const mutation = useMutation(clientPrefixesPutMutation(queryClient));
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor); const [state, apply] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<string | null>(null); const [validation, setValidation] = useState<PrefixProblem | null>(null);
const dirty = isDirty(state); const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups); const fallbackGroupId = defaultGroupId(groups);
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name })); const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
// A problem names a row by position, and every action here can move, add or
// delete a position. The message describes the rows Save read, so it dies
// with them rather than drifting onto whatever row inherits the index.
const dispatch = (action: PrefixEditorAction) => {
setValidation(null);
apply(action);
};
/** True for the one input the current message is about; nothing else is marked. */
const invalid = (index: number, field: PrefixProblem["field"]): true | undefined =>
validation !== null && validation.index === index && validation.field === field ? true : undefined;
const save = () => { const save = () => {
const problem = firstProblem(state.rows); const problem = firstProblem(state.rows);
setValidation(problem); setValidation(problem);
@@ -188,6 +211,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
<input <input
type="text" type="text"
aria-label={`Range ${index + 1}`} aria-label={`Range ${index + 1}`}
aria-invalid={invalid(index, "prefix")}
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
placeholder="192.168.1.0/24" placeholder="192.168.1.0/24"
value={row.prefix} value={row.prefix}
onChange={(event) => onChange={(event) =>
@@ -208,6 +233,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
type="text" type="text"
inputMode="numeric" inputMode="numeric"
aria-label={`Priority for range ${index + 1}`} aria-label={`Priority for range ${index + 1}`}
aria-invalid={invalid(index, "priority")}
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
placeholder="100" placeholder="100"
value={row.priority} value={row.priority}
onChange={(event) => onChange={(event) =>
@@ -227,8 +254,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
</ul> </ul>
)} )}
{validation !== null && ( {validation !== null && (
<p role="alert" {...stylex.props(styles.validation)}> <p id={VALIDATION_ID} role="alert" {...stylex.props(styles.validation)}>
{validation} {validation.message}
</p> </p>
)} )}
<InlineError error={mutation.error} /> <InlineError error={mutation.error} />
@@ -251,10 +278,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
{dirty && ( {dirty && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => dispatch({ type: "reset", prefixes })}
setValidation(null);
dispatch({ type: "reset", prefixes });
}}
{...stylex.props(shared.button, shared.focusRing)} {...stylex.props(shared.button, shared.focusRing)}
> >
Discard changes Discard changes
@@ -76,11 +76,15 @@ test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
test("firstProblem flags empty prefixes and non-integer priorities", () => { test("firstProblem flags empty prefixes and non-integer priorities", () => {
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull(); expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required."); expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toEqual({
index: 0,
field: "prefix",
message: "Row 1: prefix is required.",
});
expect( expect(
firstProblem([ firstProblem([
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" }, { prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" }, { prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
]), ]),
).toBe("Row 2: priority must be a whole number."); ).toEqual({ index: 1, field: "priority", message: "Row 2: priority must be a whole number." });
}); });
+13 -4
View File
@@ -55,11 +55,20 @@ export function isDirty(state: PrefixEditorState): boolean {
}); });
} }
export function firstProblem(rows: PrefixRow[]): string | null { /** Which input the message is about, so the editor can point that input at it. */
for (const [i, row] of rows.entries()) { export interface PrefixProblem {
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`; index: number;
field: "prefix" | "priority";
message: string;
}
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
for (const [index, row] of rows.entries()) {
if (row.prefix.trim() === "")
return { index, field: "prefix", message: `Row ${index + 1}: prefix is required.` };
const priority = row.priority.trim(); const priority = row.priority.trim();
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`; if (priority !== "" && !/^\d+$/.test(priority))
return { index, field: "priority", message: `Row ${index + 1}: priority must be a whole number.` };
} }
return null; return null;
} }
@@ -13,6 +13,9 @@ import { styles as config } from "./styles";
const DARK = "@media (prefers-color-scheme: dark)"; const DARK = "@media (prefers-color-scheme: dark)";
/** The form is rendered once per page, so the message can hold a fixed id. */
const MISMATCH_ID = "web.password_mismatch";
const styles = stylex.create({ const styles = stylex.create({
form: { form: {
marginTop: "1rem", marginTop: "1rem",
@@ -175,12 +178,15 @@ function FieldRow({
} }
if (def.kind === "number") { if (def.kind === "number") {
const numeric = value as number; const numeric = value as number;
// An empty or unparseable number reads back as NaN. The form already refuses
// to submit on it; this is what says so to a screen reader.
return ( return (
<div {...stylex.props(styles.field)}> <div {...stylex.props(styles.field)}>
<FieldLabel id={id} text={def.key} restart={restart} /> <FieldLabel id={id} text={def.key} restart={restart} />
<input <input
id={id} id={id}
type="number" type="number"
aria-invalid={Number.isNaN(numeric) || undefined}
value={Number.isNaN(numeric) ? "" : numeric} value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)} onChange={(e) => onChange(e.target.valueAsNumber)}
{...stylex.props(styles.fieldInput, shared.focusRing)} {...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -286,6 +292,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
id="web.password" id="web.password"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
aria-invalid={passwordsMismatch || undefined}
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)} {...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -299,6 +307,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
id="web.password_confirm" id="web.password_confirm"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
aria-invalid={passwordsMismatch || undefined}
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)} {...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -311,7 +321,7 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
</p> </p>
)} )}
{passwordsMismatch && ( {passwordsMismatch && (
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}> <p id={MISMATCH_ID} {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
Passwords do not match. Passwords do not match.
</p> </p>
)} )}
@@ -176,12 +176,52 @@ test("enum and boolean fields diff as their own types", async () => {
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } }); expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
}); });
/** 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(" ");
}
test("clearing a number field disables Save instead of sending NaN", async () => { test("clearing a number field disables Save instead of sending NaN", async () => {
await openSystem(); await openSystem();
const cache = screen.getByRole("group", { name: "Cache" }); const cache = screen.getByRole("group", { name: "Cache" });
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } }); const size = within(cache).getByLabelText("size");
fireEvent.change(size, { target: { value: "" } });
expect(saveButton().disabled).toBe(true); expect(saveButton().disabled).toBe(true);
// The refusal is on the field itself, not only on the Save button.
expect(size.getAttribute("aria-invalid")).toBe("true");
fireEvent.change(size, { target: { value: "512" } });
expect(size.getAttribute("aria-invalid")).toBeNull();
});
test("the mismatch message is attached to both password inputs", async () => {
await openSystem();
const web = screen.getByRole("group", { name: "Web" });
const passwordInput = within(web).getByLabelText("password");
const confirmInput = within(web).getByLabelText("confirm password");
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
for (const input of [passwordInput, confirmInput]) {
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(describedText(input)).toBe("Passwords do not match.");
}
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
for (const input of [passwordInput, confirmInput]) {
expect(input.getAttribute("aria-invalid")).toBeNull();
expect(input.getAttribute("aria-describedby")).toBeNull();
}
}); });
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => { test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {