Skip to content

Commit f79d916

Browse files
committed
chore: replace vitest-axe with internal implementation
1 parent ab6fbb3 commit f79d916

File tree

31 files changed

+633
-211
lines changed

31 files changed

+633
-211
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"@resolid/config": "^5.0.2",
2727
"@svitejs/changesets-changelog-github-compact": "^1.2.0",
2828
"@tailwindcss/language-server": "^0.14.29",
29-
"@types/node": "^24.10.13",
29+
"@types/node": "^24.10.14",
3030
"eslint-plugin-react-hooks": "^7.0.1",
3131
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.1",
3232
"lefthook": "^2.1.1",
@@ -38,7 +38,7 @@
3838
"node": "^20.19.0 || ^22.13.0 || >=24",
3939
"pnpm": "10.x"
4040
},
41-
"packageManager": "pnpm@10.30.2",
41+
"packageManager": "pnpm@10.30.3",
4242
"pnpm": {
4343
"onlyBuiltDependencies": [
4444
"esbuild",

packages/react-ui/package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,19 @@
6060
"@testing-library/user-event": "^14.6.1",
6161
"@types/react": "^19.2.14",
6262
"@types/react-dom": "^19.2.3",
63+
"@vitest/pretty-format": "^4.0.18",
64+
"axe-core": "^4.11.1",
6365
"babel-plugin-react-compiler": "^1.0.0",
64-
"jsdom": "^27.4.0",
66+
"jsdom": "^28.1.0",
6567
"react": "^19.2.4",
6668
"react-dom": "^19.2.4",
6769
"tsdown": "^0.20.3",
68-
"vitest": "^4.0.18",
69-
"vitest-axe": "pre"
70+
"vitest": "^4.0.18"
7071
},
7172
"peerDependencies": {
7273
"react": "^19.2.4",
7374
"react-dom": "^19.2.4",
74-
"tailwindcss": "^4.2.0"
75+
"tailwindcss": "^4.2.1"
7576
},
7677
"reactDoctor": {
7778
"ignore": {
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
import { format, plugins } from "@vitest/pretty-format";
2+
import {
3+
type AxeResults,
4+
configure,
5+
getRules,
6+
type ImpactValue,
7+
type Result,
8+
run,
9+
type RunOptions,
10+
type Spec,
11+
} from "axe-core";
12+
import { styleText } from "node:util";
13+
14+
function mount(html: Element | string): [HTMLElement, () => void] {
15+
// noinspection SuspiciousTypeOfGuard
16+
if (!!html && typeof html === "object" && typeof html.tagName === "string") {
17+
if (document.body.contains(html)) {
18+
return [html as HTMLElement, () => undefined];
19+
}
20+
21+
html = html.outerHTML;
22+
}
23+
24+
if (typeof html === "string" && /(<([^>]+)>)/i.test(html)) {
25+
const originalHTML = document.body.innerHTML;
26+
27+
const restore = () => {
28+
document.body.innerHTML = originalHTML;
29+
};
30+
31+
document.body.innerHTML = html;
32+
33+
return [document.body, restore];
34+
}
35+
36+
if (typeof html === "string") {
37+
throw new Error(`html parameter ("${html}") has no elements`);
38+
}
39+
40+
throw new Error(`html parameter should be an HTML string or an HTML element`);
41+
}
42+
43+
const AXE_RULES_COLOR = getRules(["cat.color"]);
44+
45+
function configureAxe(
46+
options: RunOptions & {
47+
globalOptions?: Spec;
48+
impactLevels?: Array<ImpactValue>;
49+
} = {},
50+
): (html: Element | string, additionalOptions?: RunOptions) => Promise<AxeResults> {
51+
const { globalOptions = {}, ...runnerOptions } = options;
52+
53+
const { rules = [], ...otherGlobalOptions } = globalOptions;
54+
55+
const defaultRules = AXE_RULES_COLOR.map(({ ruleId: id }) => ({
56+
id,
57+
enabled: false,
58+
}));
59+
60+
configure({
61+
rules: [...defaultRules, ...rules],
62+
...otherGlobalOptions,
63+
});
64+
65+
return function axe(
66+
html: Element | string,
67+
additionalOptions: RunOptions = {},
68+
): Promise<AxeResults> {
69+
const [element, restore] = mount(html);
70+
71+
const options: RunOptions = { ...runnerOptions, ...additionalOptions };
72+
73+
return new Promise<AxeResults>((resolve) => {
74+
run(element, options, (err, results) => {
75+
restore();
76+
if (err) throw err;
77+
resolve(results);
78+
});
79+
});
80+
};
81+
}
82+
83+
export type NoViolationsMatcherResult = {
84+
actual: Result[];
85+
message(): string;
86+
pass: boolean;
87+
};
88+
89+
export type AxeMatchers = {
90+
toHaveNoViolations(): NoViolationsMatcherResult;
91+
};
92+
93+
const {
94+
AsymmetricMatcher,
95+
DOMCollection,
96+
DOMElement,
97+
Immutable,
98+
ReactElement,
99+
ReactTestComponent,
100+
} = plugins;
101+
102+
const PLUGINS = [
103+
ReactTestComponent,
104+
ReactElement,
105+
DOMElement,
106+
DOMCollection,
107+
Immutable,
108+
AsymmetricMatcher,
109+
];
110+
111+
const SPACE_SYMBOL = "\u{00B7}";
112+
113+
function stringify(object: unknown, maxDepth = 10, maxWidth = 10): string {
114+
const MAX_LENGTH = 10000;
115+
let result: string;
116+
117+
try {
118+
result = format(object, {
119+
maxDepth,
120+
maxWidth,
121+
min: true,
122+
plugins: PLUGINS,
123+
});
124+
} catch {
125+
result = format(object, {
126+
callToJSON: false,
127+
maxDepth,
128+
maxWidth,
129+
min: true,
130+
plugins: PLUGINS,
131+
});
132+
}
133+
134+
if (result.length >= MAX_LENGTH && maxDepth > 1) {
135+
return stringify(object, Math.floor(maxDepth / 2), maxWidth);
136+
}
137+
138+
if (result.length >= MAX_LENGTH && maxWidth > 1) {
139+
return stringify(object, maxDepth, Math.floor(maxWidth / 2));
140+
}
141+
142+
return result;
143+
}
144+
145+
function replaceTrailingSpaces(text: string): string {
146+
return text.replace(/\s+$/gm, (spaces) => SPACE_SYMBOL.repeat(spaces.length));
147+
}
148+
149+
function filterViolations(violations: Result[], impactLevels: Array<ImpactValue>) {
150+
if (impactLevels && impactLevels.length > 0) {
151+
return violations.filter((v) => impactLevels.includes(v.impact!));
152+
}
153+
154+
return violations;
155+
}
156+
157+
type MatcherHintOptions = {
158+
comment?: string;
159+
isDirectExpectCall?: boolean;
160+
isNot?: boolean;
161+
promise?: string;
162+
secondArgument?: string;
163+
};
164+
165+
function matcherHint(
166+
matcherName: string,
167+
received = "received",
168+
expected = "expected",
169+
options: MatcherHintOptions = {},
170+
): string {
171+
const {
172+
comment = "",
173+
isDirectExpectCall = false,
174+
isNot = false,
175+
promise = "",
176+
secondArgument = "",
177+
} = options;
178+
let hint = "";
179+
let dimString = "expect";
180+
181+
if (!isDirectExpectCall && received !== "") {
182+
hint += styleText("dim", `${dimString}(`) + styleText("red", received);
183+
dimString = ")";
184+
}
185+
186+
if (promise !== "") {
187+
hint += styleText("dim", `${dimString}.`) + promise;
188+
dimString = "";
189+
}
190+
191+
if (isNot) {
192+
hint += `${styleText("dim", `${dimString}.`)}not`;
193+
dimString = "";
194+
}
195+
196+
if (matcherName.includes(".")) {
197+
dimString += matcherName;
198+
} else {
199+
hint += styleText("dim", `${dimString}.`) + matcherName;
200+
dimString = "";
201+
}
202+
203+
if (expected === "") {
204+
dimString += "()";
205+
} else {
206+
hint += styleText("dim", `${dimString}(`) + styleText("green", expected);
207+
if (secondArgument) {
208+
hint += styleText("dim", ", ") + styleText("green", secondArgument);
209+
}
210+
dimString = ")";
211+
}
212+
213+
if (comment !== "") {
214+
dimString += ` // ${comment}`;
215+
}
216+
217+
if (dimString !== "") {
218+
hint += styleText("dim", dimString);
219+
}
220+
221+
return hint;
222+
}
223+
224+
function toHaveNoViolations(results: AxeResults): NoViolationsMatcherResult {
225+
if (typeof results.violations === "undefined") {
226+
throw new Error(
227+
"Unexpected aXe results object. No violations property found.\nDid you change the `reporter` in your aXe configuration?",
228+
);
229+
}
230+
231+
const violations = filterViolations(
232+
results.violations,
233+
// oxlint-disable-next-line typescript/ban-ts-comment
234+
// @ts-expect-error
235+
results.toolOptions?.impactLevels ?? [],
236+
);
237+
238+
function reporter(violations: Result[]) {
239+
if (violations.length === 0) {
240+
return [];
241+
}
242+
243+
const lineBreak = "\n\n";
244+
const horizontalLine = "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500";
245+
246+
return violations
247+
.map((violation) => {
248+
return violation.nodes
249+
.map((node) => {
250+
return [
251+
`Expected the HTML found at $('${node.target.join(", ")}') to have no violations:`,
252+
styleText("gray", node.html),
253+
"Received:",
254+
styleText(
255+
"red",
256+
replaceTrailingSpaces(stringify(`${violation.help} (${violation.id})`)),
257+
),
258+
styleText("yellow", node.failureSummary ?? ""),
259+
violation.helpUrl
260+
? `You can find more information on this issue here: \n${styleText(
261+
"blue",
262+
violation.helpUrl,
263+
)}`
264+
: "",
265+
].join(lineBreak);
266+
})
267+
.join(lineBreak);
268+
})
269+
.join(lineBreak + horizontalLine + lineBreak);
270+
}
271+
272+
const formatedViolations = reporter(violations);
273+
const pass = formatedViolations.length === 0;
274+
275+
function message(): string {
276+
if (pass) {
277+
return "";
278+
}
279+
280+
return `${matcherHint(".toHaveNoViolations")}
281+
282+
${formatedViolations}`;
283+
}
284+
285+
return { actual: violations, message, pass };
286+
}
287+
288+
const axe: (html: Element | string, additionalOptions?: RunOptions) => Promise<AxeResults> =
289+
configureAxe();
290+
291+
export { configureAxe, axe, toHaveNoViolations };

packages/react-ui/src/components/accordion/tests/accordion.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { cleanup, render, screen } from "@testing-library/react";
22
import { userEvent } from "@testing-library/user-event";
33
import { afterEach, describe, expect, it, vi } from "vitest";
4-
import { axe } from "vitest-axe";
4+
import { axe } from "../../../../plugins/vitest-axe";
55
import {
66
Accordion,
77
AccordionContent,

packages/react-ui/src/components/alert/tests/alert.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { cleanup, render } from "@testing-library/react";
22
import { afterEach, describe, expect, it } from "vitest";
3-
import { axe } from "vitest-axe";
3+
import { axe } from "../../../../plugins/vitest-axe";
44
import { Alert, AlertContent, AlertDescription, type AlertProps, AlertTitle } from "../alert";
55

66
const ComponentUnderTest = (props: AlertProps) => (

packages/react-ui/src/components/avatar/tests/avatar.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { cleanup, render } from "@testing-library/react";
22
import { afterEach, describe, expect, it } from "vitest";
3-
import { axe } from "vitest-axe";
3+
import { axe } from "../../../../plugins/vitest-axe";
44
import { Avatar, AvatarFallback, AvatarImage, type AvatarProps } from "../avatar";
55

66
const ComponentUnderTest = (props: AvatarProps) => (

packages/react-ui/src/components/badge/tests/badge.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { cleanup, render } from "@testing-library/react";
22
import { afterEach, describe, expect, it } from "vitest";
3-
import { axe } from "vitest-axe";
3+
import { axe } from "../../../../plugins/vitest-axe";
44
import { Badge } from "../badge";
55

66
describe("Badge", () => {

packages/react-ui/src/components/checkbox/tests/checkbox.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re
22
import { userEvent } from "@testing-library/user-event";
33
import { useState } from "react";
44
import { afterEach, describe, expect, it, vi } from "vitest";
5-
import { axe } from "vitest-axe";
5+
import { axe } from "../../../../plugins/vitest-axe";
66
import { Checkbox, type CheckboxProps } from "../checkbox";
77

88
const ComponentUnderTest = (props: CheckboxProps) => <Checkbox {...props}>Checkbox</Checkbox>;

packages/react-ui/src/components/collapsible/tests/collapsible.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { cleanup, render, screen, waitFor } from "@testing-library/react";
22
import { userEvent } from "@testing-library/user-event";
33
import { afterEach, describe, expect, it } from "vitest";
4-
import { axe } from "vitest-axe";
4+
import { axe } from "../../../../plugins/vitest-axe";
55
import {
66
Collapsible,
77
CollapsibleContent,

packages/react-ui/src/components/combobox/tests/combobox.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
22
import { afterEach, describe, expect, it, vi } from "vitest";
3-
import { axe } from "vitest-axe";
43
import type { ListboxItem } from "../../listbox/use-listbox";
54
import type { ComboboxProps } from "../use-combobox";
5+
import { axe } from "../../../../plugins/vitest-axe";
66
import { LocaleProvider } from "../../provider/locale-provider";
77
import {
88
Combobox,

0 commit comments

Comments
 (0)