98 lines
3.4 KiB
TypeScript
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;
|
|
}
|