Files
HiveGrid-Wireframes/hivegrid-cloud/packages/ui-app/test-setup/axe.ts
T

98 lines
3.4 KiB
TypeScript

/**
* Shared axe-core test helper for `@toolctrl/ui-app` and `@toolctrl/ui-subframe`.
*
* Wraps `vitest-axe` with a project-wide ruleset tuned to WCAG-2.2-AA. Tests
* import {@link runAxe} (programmatic) or use {@link expectNoAxeViolations}
* which renders a React element and asserts the resulting DOM is violation
* free.
*
* Spec: docs/improvement-plan/10-compliance-polish/wcag-internal.md
*
* Why a wrapper instead of importing `vitest-axe` directly:
* - Single place to keep rule customisation (e.g. disabled rules that have
* known noise in jsdom, like `color-contrast` which jsdom cannot compute).
* - Single place to enable the right WCAG tag set across packages.
* - Cheap dynamic-import so that packages that don't yet ship axe still
* type-check.
*/
import type { ReactElement } from "react";
export interface AxeRunOptions {
/**
* Override or extend the default tag set used by the axe run. The default
* matches the WCAG-2.2-AA conformance level plus best-practice rules.
*/
readonly tags?: readonly string[];
/**
* Per-rule overrides. Use sparingly — prefer fixing the underlying
* accessibility issue. The default config disables `color-contrast`
* because jsdom cannot compute computed-style colours, so axe always
* reports false-positives in unit tests.
*/
readonly rules?: Record<string, { enabled: boolean }>;
}
const DEFAULT_TAGS = [
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"wcag22aa",
"best-practice",
] as const;
const DEFAULT_RULE_OVERRIDES: Record<string, { enabled: boolean }> = {
// jsdom cannot resolve computed CSS colours; color-contrast must be
// verified by scripts/verify-color-contrast.mjs and Storybook addon-a11y
// instead.
"color-contrast": { enabled: false },
// `region` requires the entire-page landmark structure which is not
// present when unit-testing an isolated component.
region: { enabled: false },
};
async function loadAxe() {
const mod = await import("vitest-axe");
const configureAxe = (mod as unknown as { configureAxe: typeof import("vitest-axe").configureAxe }).configureAxe;
return { configureAxe };
}
/**
* Run axe-core against a DOM container and return the raw axe result.
*/
export async function runAxe(container: Element, options: AxeRunOptions = {}) {
const { configureAxe } = await loadAxe();
const tags = options.tags ?? DEFAULT_TAGS;
const rules = { ...DEFAULT_RULE_OVERRIDES, ...(options.rules ?? {}) };
const axe = configureAxe({
runOnly: { type: "tag", values: [...tags] },
rules,
});
return axe(container);
}
/**
* Render `ui` with `@testing-library/react` and assert that the resulting
* DOM has zero axe-core violations under the project's WCAG-2.2-AA preset.
*
* Throws with a formatted message listing every violation. The thrown
* `Error.cause` contains the raw axe result for advanced inspection.
*/
export async function expectNoAxeViolations(
ui: ReactElement,
options: AxeRunOptions = {},
): Promise<void> {
const { render } = await import("@testing-library/react");
const { container } = render(ui);
const result = await runAxe(container, options);
if (result.violations.length === 0) return;
const summary = result.violations
.map((v, idx) => ` ${idx + 1}. [${v.id}] ${v.help} — ${v.helpUrl}`)
.join("\n");
const error = new Error(
`axe-core found ${result.violations.length} violation(s):\n${summary}`,
);
(error as { cause?: unknown }).cause = result;
throw error;
}