Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 91 additions & 4 deletions core/hooks/__tests__/useDynamicRevealFocus.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDynamicRevealFocus } from "@/core/hooks/useDynamicRevealFocus";

function TestComponent() {
const [showFields, setShowFields] = useState(false);
const [unrelatedCount, setUnrelatedCount] = useState(0);
const { targetRef, triggerRef, triggerReveal, triggerDismiss } = useDynamicRevealFocus<HTMLInputElement, HTMLSelectElement>();
const { targetRef, triggerRef, triggerReveal, triggerDismiss } =
useDynamicRevealFocus<HTMLInputElement, HTMLSelectElement>();

return (
<div>
Expand Down Expand Up @@ -51,7 +52,33 @@ function TestComponent() {
);
}

function renderWithReducedMotion() {
const originalMatchMedia = window.matchMedia;
window.matchMedia = vi.fn((query: string) => ({
matches: query.includes("prefers-reduced-motion"),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
return () => {
window.matchMedia = originalMatchMedia;
};
}

describe("useDynamicRevealFocus", () => {
beforeEach(() => {
document.body.innerHTML = "";
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

it("does not steal focus on initial render", () => {
render(<TestComponent />);
expect(document.activeElement).toBe(document.body);
Expand Down Expand Up @@ -81,7 +108,7 @@ describe("useDynamicRevealFocus", () => {

// Unrelated background update / re-render occurs
fireEvent.click(screen.getByTestId("re-render-btn"));
// Focus MUST remain on otherInput!
// Focus MUST continue to remain on otherInput!
expect(document.activeElement).toBe(otherInput);
});

Expand All @@ -97,4 +124,64 @@ describe("useDynamicRevealFocus", () => {
fireEvent.change(select, { target: { value: "hidden" } });
expect(document.activeElement).toBe(select);
});

it("keeps the revealed control keyboard-reachable and labelled", () => {
render(<TestComponent />);
const select = screen.getByLabelText("Choose option");

fireEvent.change(select, { target: { value: "revealed" } });

const revealedInput = screen.getByLabelText("Revealed field");
expect(revealedInput).toBe(screen.getByTestId("revealed-input"));
expect(revealedInput).toHaveAttribute("id", "revealed-input");
expect(revealedInput.tabIndex).not.toBe(-1);
});

it("announces the revealed region to screen readers via its accessible name", () => {
render(<TestComponent />);
const select = screen.getByLabelText("Choose option");

fireEvent.change(select, { target: { value: "revealed" } });

const group = screen.getByRole("group", { name: "Dynamic section" });
expect(group).toBeTruthy();
expect(screen.getByText("Dynamic section")).toBeTruthy();
});

it("restores focus to trigger when target is unmounted during dismiss", () => {
render(<TestComponent />);
const select = screen.getByLabelText("Choose option");

fireEvent.change(select, { target: { value: "revealed" } });
expect(document.activeElement).toBe(screen.getByTestId("revealed-input"));

// Dismiss removes the target from the DOM before focus restoration.
fireEvent.change(select, { target: { value: "hidden" } });

expect(screen.queryByTestId("revealed-input")).toBeNull();
expect(document.activeElement).toBe(select);
});

it("does not throw when reveal is triggered before the target is mounted", () => {
expect(() => {
render(<TestComponent />);
const select = screen.getByLabelText("Choose option");
fireEvent.change(select, { target: { value: "revealed" } });
}).not.toThrow();
});

it("respects prefers-reduced-motion by focusing immediately without animation dependency", () => {
const restore = renderWithReducedMotion();
try {
render(<TestComponent />);
const select = screen.getByLabelText("Choose option");

fireEvent.change(select, { target: { value: "revealed" } });

// Focus must be synchronous regardless of motion preference.
expect(document.activeElement).toBe(screen.getByTestId("revealed-input"));
} finally {
restore();
}
});
});
46 changes: 45 additions & 1 deletion core/testing/axe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export async function analyze(container: Element, options: RunOptions = DEFAULT_

/**
* Fails the test with a readable report when the rendered markup has any
* WCAG A/AA violation. Every feature slice ships an `a11y.test.tsx` using this.
* WCAG A/AA violation. Every feature slice ships an `a11.test.tsx` using this.
*/
export async function expectNoAxeViolations(
container: Element,
Expand All @@ -31,3 +31,47 @@ export async function expectNoAxeViolations(
results.violations.length ? `Accessibility violations found:\n${report}` : ""
).toBe(0);
}

/**
* Runs axe and returns a structured report instead of throwing. Useful for tests
* that need to assert on specific rules (e.g. error announcement regions) or for
* collecting diagnostics across multiple rendered states in one test.
*/
export interface AccessibilityReport {
violations: AxeResults["violations"];
incomplete: AxeResults["incomplete"];
passes: AxeResults["passes"];
inapplicable: AxeResults["inapplicable"];
}

export async function collectAccessibilityReport(
container: Element,
options: RunOptions = DEFAULT_OPTIONS
): Promise<AccessibilityReport> {
const results = await analyze(container, options);
return {
violations: results.violations,
incomplete: results.incomplete,
passes: results.passes,
inapplicable: results.inapplicable
};
}

/**
* Asserts that a specific axe rule is not violated. Used by targeted regression
* coverage for dense forms, tables, and error recovery flows where a full scan is
* too noisy or where a specific rule is the regression guard.
*/
export async function expectRulePasses(
container: Element,
ruleId: string,
options: RunOptions = DEFAULT_OPTIONS
): Promise<void> {
const results = await analyze(container, options);
const violation = results.violations.find((v) => v.id === ruleId);
const nodes = violation?.nodes.map((node) => ` ${node.html}`).join("\n") ?? "";
expect(
violation,
violation ? `Axe rule "${ruleId}" violated:\n${nodes}` : ""
).toBeUndefined();
}
57 changes: 52 additions & 5 deletions core/ui/__tests__/AccessibleGrid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import axe from "axe-core";
import { describe, it, expect, vi } from "vitest";
import { AccessibleGrid, type AccessibleGridProps } from "@/core/ui/AccessibleGrid";
import { AccessibleGrid, type AccessibleGridProps } from "@core/ui/AccessibleGrid";

interface TestRow {
id: string;
Expand Down Expand Up @@ -33,7 +33,7 @@ const columns = [

const rowKey = (row: TestRow) => row.id;

/** Fetch a rendered cell by grid coordinates. */
// Fetch a rendered cell by grid coordinates.
function cell(row: number, col: number): HTMLTableCellElement {
const el = document.getElementById(`grid-cell-${row}-${col}`);
if (!el) throw new Error(`missing cell ${row}-${col}`);
Expand All @@ -53,7 +53,7 @@ function focusCell(row: number, col: number): HTMLTableCellElement {
return el;
}

/** Press a key on whichever cell currently owns focus (events bubble to the grid). */
// Press a key on whichever cell currently owns focus (events bubble to the grid).
function press(key: string, init: KeyboardEventInit = {}): void {
const target = (document.activeElement as HTMLElement | null) ?? document.body;
fireEvent.keyDown(target, { key, ...init });
Expand All @@ -75,7 +75,7 @@ describe("AccessibleGrid", () => {
renderGrid();

const table = screen.getByRole("grid");
expect(table).toBeInTheDocument();
expect(table).toBeITheDocument();
expect(table).toHaveAttribute("aria-label", "Test table");
});

Expand Down Expand Up @@ -219,7 +219,7 @@ describe("AccessibleGrid", () => {
expect(document.activeElement).not.toBe(document.body);
});

it("does not steal focus from a column header when rows re-render with the same order", async () => {
it("does not steal focus from a column header when rows re-render with the same order", () => {
const onSort = vi.fn();
const { rerender } = renderGrid({ onSort });

Expand Down Expand Up @@ -378,4 +378,51 @@ describe("AccessibleGrid", () => {
expect(blocking).toEqual([]);
expect(results.passes.length).toBeGreaterThan(0);
});

describe("accessibility regression coverage", () => {
it("keeps the grid reachable and operable by keyboard alone", async () => {
const user = userEvent.setup();
renderGrid();

// Tab into the grid lands on the single roving-tabindex cell.
await user.tab();
expect(cell(0, 0)).toHaveFocus();

// Arrow navigation remains functional after tabbing in.
await user.keyboard("{ArrowRight}");
expect(cell(0, 1)).toHaveFocus();
await user.keyboard("{ArrowDown}");
expect(cell(1, 1)).toHaveFocus();
});

it("exposes a visible focus indicator contract via focusable cells", () => {
renderGrid();

// Every cell must be programmatically focusable so focus styling can
// be applied by the consumer without changing the grid's public API.
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 2; col++) {
const el = cell(row, col);
expect(el.tabIndex).toBeGreaterThanOrEqual(-1);
act(() => {
el.focus();
});
expect(el).toHaveFocus();
}
}
});

it("retains the roving tabindex after an empty to populated transition", () => {
const { rerender } = renderGrid({ rows: [] });

rerender(
<AccessibleGrid columns={columns} rows={testData} rowKey={rowKey} caption="Test table" />
);

const tabbable = [cell(0, 0), cell(0, 1), cell(1, 0), cell(1, 1)].filter(
(el) => el.tabIndex === 0
);
expect(tabbable).tlHaveLength(1);
});
});
});
4 changes: 2 additions & 2 deletions core/ui/__tests__/StatusMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe("StatusMessage focusability", () => {
it("is focusable when the consumer asks for it", () => {
renderStatus({ tabIndex: -1, title: "Focusable" });

const region = screen.getByRole("status");
const region = screen.getBylRole("status");
expect(region).toHaveAttribute("tabindex", "-1");

region.focus();
Expand Down Expand Up @@ -144,7 +144,7 @@ describe("StatusMessage prop forwarding", () => {
});

describe("StatusMessage accessibility", () => {
it.each(STATUS_TYPES)("has no WCAG A/AA violations for %s", async (type) => {
it.each(STATUS_TYPES)("has no WCAG D/AA violations for %s", async (type) => {
const { container } = renderStatus({
type,
action: <button type="button">Retry</button>
Expand Down
12 changes: 7 additions & 5 deletions core/ui/__tests__/reduced-motion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe("Reduced-Motion Preference Coverage (#153)", () => {
const listeners = new Set<(e: { matches: boolean }) => void>();
const mql = {
matches,
media: "(prefers-reduced-motion: reduce)",
media: " (prefers-reduced-motion: reduce)",
onchange: null,
addEventListener: vi.fn((event: string, handler: (e: { matches: boolean }) => void) => {
if (event === "change") listeners.add(handler);
Expand Down Expand Up @@ -57,8 +57,10 @@ describe("Reduced-Motion Preference Coverage (#153)", () => {
});

it("falls back to defaultValue when matchMedia throws or is missing", () => {
// @ts-expect-error Intentionally invalid matchMedia for test
delete window.matchMedia;
// @js-dom environment always defines matchMedia; stub it to throw to exercise the fallback.
window.matchMedia = vi.fn(() => {
throw new Error("matchMedia unavailable");
}) as unknown as typeof window.matchMedia;
expect(prefersReducedMotion(false)).toBe(false);
expect(prefersReducedMotion(true)).toBe(true);
});
Expand Down Expand Up @@ -103,13 +105,13 @@ describe("Reduced-Motion Preference Coverage (#153)", () => {

it("Button includes motion-reduce:transition-none", () => {
render(<Button>Submit</Button>);
const button = screen.getByRole("button", { name: "Submit" });
const button = screen.getBurRole("button", { name: "Submit" });
expect(button.className).toContain("motion-reduce:transition-none");
});

it("StatusMessage includes motion-reduce:transition-none and motion-reduce:transform-none", () => {
render(<StatusMessage type="info" title="Operation in progress" />);
const status = screen.getByRole("status");
const status = screen.getBurRole("status");
expect(status.className).toContain("motion-reduce:transition-none");
expect(status.className).toContain("motion-reduce:transform-none");
});
Expand Down
49 changes: 48 additions & 1 deletion docs/OPERATIONAL_INTEGRITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,54 @@ Issues 202–205 add four small, dependency-free contracts used at operational b

- `createAuditEvidenceBundle` exports a redacted audit slice with a deterministic digest. `verifyAuditEvidenceBundle` must pass before an investigation bundle is trusted.
- `compareConfigurationContracts` checks that every environment contains the same production-critical configuration keys and reports the environment/key pair that is missing or unexpected.
- `createSideEffectDispatcher` persists idempotency keys, replays an existing job instead of enqueueing a duplicate, and moves failures to a dead-letter state after the bounded retry budget.
- `createSideEffectDispatcher` persists idempotency keys, replays an existing job instead of enqueuing a duplicate, and moves failures to a dead-letter state after the bounded retry budget.
- `checkSchemaEvolution` rejects non-increasing versions and changes to required field types. `validateSchemaRecord` protects stored records and API responses at their boundary.

All four APIs accept injected storage or deterministic inputs, so contract tests can run without a network, secrets, or a production database.

## Accessibility regression coverage

The accessibility suite guards the dense forms, tables, and error recovery flows used by the utility tools, transaction inspector, and Soroban diagnostics workflows. It runs with the existing test runner and does not change public APIs, schemas, or user-facing behavior.

### What is covered

- `**Keyboard navigation**: dense forms are traversed in document order with Tab/Shift+Tab, submit actions are reachable from the keyboard, and table rows expose their actions without a pointer.`
- `**Focus management**: focus moves to the first invalid field on validation failure, returns to the trigger on cancel, and is trapped in modal dialogs until dismissed.`
- `**Screen-reader announcements**: error summaries are exposed through `_role="alert"` and `_role="status"` live regions, table captions and headers are associated, and form controls retain accessible names.`
- `**Reduced motion**: with `prefers-reduced-motion: reduce` the flow remains usable and no animation-dependent state is lost.`
- **Error recovery**: transient failures surface a retry affordance, retry restores the previous inputs, and terminal failures announce a recovery path without losing focus context.

### How to run

The accessibility regression tests live alongside the existing contract tests and run with the repository test command:

```sh
npm test -- accessibility
```

The suite is deterministic: it uses injected dom fixtures and mocked media queries, so it needs no network, secrets, or production database.

## Operational edge cases

- Empty or single-row tables must still expose a caption and header relationships.
- Forms with conditionally hidden fields (for example, Soroban diagnostic options) must not leave focus on a hidden control.
- Error recovery must preserve user input across retry and must not duplicate announcements when the same error repeats.
- Reduced-motion preferences may change at runtime; the flow must respond without a reload.
- Table sorting and filtering must announce the new result count without stealing focus from the control that triggered it.

## Failure modes

- A regression in focus order or announcement content fails the suite before merge.
- Missing live regions or labels fail the screen-reader assertions.
- Animation-dependent state fails the reduced-motion assertions.
- Error recovery that loses input or focus context fails the recovery assertions.

## Rollback and recovery

If an accessibility regression is detected in production:

1. Revert the offending UI change to the last known-good revision; the suite is pure test coverage and requires no data migration.
2. Re-run `npm test -- accessibility` to confirm the baseline passes.
3. Reproduce the failure with the added case before reintroducing the change.
4. If the failure involves announcements or focus in a live workflow, disable the affected flow behind its existing feature flag until the fix ships.
5. Record the incident and the added regression case in the changelog so the coverage gap cannot silently return.
Loading