@hublo/sentinel 1.4.0-alpha.29 → 1.4.0-alpha.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sentinel.js +9 -7
- package/dist/{chunk-2S7HPRKK.js → chunk-GCZKG4B2.js} +4 -0
- package/dist/{chunk-KM4A6G2H.js → chunk-Y2EKU6OE.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/roles/test/setup/a11y.d.ts +45 -0
- package/dist/roles/test/setup/a11y.js +72 -0
- package/dist/roles/test/setup/a11y.js.map +1 -0
- package/dist/roles/test/setup/w3c.d.ts +75 -0
- package/dist/roles/test/setup/w3c.js +49 -0
- package/dist/roles/test/setup/w3c.js.map +1 -0
- package/dist/{validate-OYMXIGHU.js → validate-ONAQPYJB.js} +1 -1
- package/docs/test-adoption.md +49 -0
- package/package.json +11 -1
package/dist/bin/sentinel.js
CHANGED
|
@@ -15,10 +15,10 @@ import {
|
|
|
15
15
|
palette,
|
|
16
16
|
registerAdapters,
|
|
17
17
|
resolve
|
|
18
|
-
} from "../chunk-
|
|
18
|
+
} from "../chunk-Y2EKU6OE.js";
|
|
19
19
|
import {
|
|
20
20
|
resolveContext
|
|
21
|
-
} from "../chunk-
|
|
21
|
+
} from "../chunk-GCZKG4B2.js";
|
|
22
22
|
import {
|
|
23
23
|
ensureWorkspacePrep,
|
|
24
24
|
findWorkspaceRoot,
|
|
@@ -135,13 +135,15 @@ sentinel (${type}): ${asMessage(error)}
|
|
|
135
135
|
async function runValidate(ctx) {
|
|
136
136
|
const { modules } = resolveContext(ctx.cwd, { module: ctx.module, ci: ctx.ci });
|
|
137
137
|
const unsupported = ctx.targets.filter((target) => target !== "test");
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
if (unsupported.length > 0) {
|
|
139
|
+
const named = unsupported.map((target) => `--${target}`).join(", ");
|
|
140
|
+
process.stdout.write(
|
|
141
|
+
` \xB7 ${named}: not available yet. \`--validate\` answers \`--test\` today, by recording a suite before a migration and comparing it after. Nothing was checked for ${named}.
|
|
142
|
+
`
|
|
141
143
|
);
|
|
142
|
-
return 1;
|
|
143
144
|
}
|
|
144
|
-
|
|
145
|
+
if (!ctx.targets.includes("test")) return 1;
|
|
146
|
+
const { validateModuleTests } = await import("../validate-ONAQPYJB.js");
|
|
145
147
|
const outcomes = modules.flatMap(
|
|
146
148
|
(module) => validateModuleTests(module.name, module.root)
|
|
147
149
|
);
|
|
@@ -3390,6 +3390,8 @@ function flavourOf(reading) {
|
|
|
3390
3390
|
var SENTINEL_TEST_SETUP = "@hublo/sentinel/test/setup/jest-parity";
|
|
3391
3391
|
var SENTINEL_MSW_SETUP = "@hublo/sentinel/test/setup/msw-lifecycle";
|
|
3392
3392
|
var SENTINEL_WORKSPACE_SETUP = "@hublo/sentinel/test/setup/workspace";
|
|
3393
|
+
var SENTINEL_A11Y_SETUP = "@hublo/sentinel/test/setup/a11y";
|
|
3394
|
+
var SENTINEL_W3C_SETUP = "@hublo/sentinel/test/setup/w3c";
|
|
3393
3395
|
function hopsToRoot(cwd, workspaceRoot) {
|
|
3394
3396
|
const up = relative4(cwd, workspaceRoot).replaceAll("\\", "/");
|
|
3395
3397
|
return up === "" ? "." : up;
|
|
@@ -3460,11 +3462,13 @@ function ownsMswLifecycle2(cwd, reading) {
|
|
|
3460
3462
|
return ownsMswLifecycle(cwd, [...reading.setupFiles ?? [], ...reading.setupFilesAfterEnv ?? []]);
|
|
3461
3463
|
}
|
|
3462
3464
|
function setupBlock(cwd, reading, msw) {
|
|
3465
|
+
const dom = flavourOf(reading) === "react";
|
|
3463
3466
|
const declared = [...reading.setupFiles ?? [], ...reading.setupFilesAfterEnv ?? []];
|
|
3464
3467
|
const namedTheRootSetup = declared.some((file) => file.endsWith("jest.setup.after.env.js"));
|
|
3465
3468
|
const own = declared.filter((file) => !file.endsWith("jest.setup.after.env.js")).map((file) => `'${vitestSupportName(withoutRootDir(file))}'`);
|
|
3466
3469
|
const sentinel = [
|
|
3467
3470
|
`'${SENTINEL_TEST_SETUP}'`,
|
|
3471
|
+
...dom ? [`'${SENTINEL_A11Y_SETUP}'`, `'${SENTINEL_W3C_SETUP}'`] : [],
|
|
3468
3472
|
...namedTheRootSetup ? [`'${SENTINEL_WORKSPACE_SETUP}'`] : [],
|
|
3469
3473
|
...msw && namedTheRootSetup && !ownsMswLifecycle2(cwd, reading) ? [`'${SENTINEL_MSW_SETUP}'`] : []
|
|
3470
3474
|
];
|
package/dist/index.js
CHANGED
|
@@ -6,10 +6,10 @@ import {
|
|
|
6
6
|
registerAdapters,
|
|
7
7
|
resolve,
|
|
8
8
|
setDefaultRunner
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-Y2EKU6OE.js";
|
|
10
10
|
import {
|
|
11
11
|
BaseAdapter
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-GCZKG4B2.js";
|
|
13
13
|
import "./chunk-NW6UHNYX.js";
|
|
14
14
|
export {
|
|
15
15
|
BaseAdapter,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { RunOptions } from 'axe-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `expect(element).toBeAccessible()`, run by axe-core directly.
|
|
5
|
+
*
|
|
6
|
+
* Opt in from a module's config:
|
|
7
|
+
*
|
|
8
|
+
* setupFiles: ['@hublo/sentinel/test/setup/a11y']
|
|
9
|
+
*
|
|
10
|
+
* ## Why axe-core DIRECTLY, and not jest-axe or vitest-axe
|
|
11
|
+
*
|
|
12
|
+
* Both are thin wrappers whose job is to turn axe's result into a matcher, which is the twenty
|
|
13
|
+
* lines below. What they add on top is a dependency that lags axe itself, its own idea of which
|
|
14
|
+
* rules to run, and a second place where a violation can be filtered out. Measured while choosing:
|
|
15
|
+
* the RULE SET decides the outcome far more than the runner does, 12 messages against 70 on the
|
|
16
|
+
* same markup depending only on configuration. So the configuration is the thing worth owning, and
|
|
17
|
+
* a wrapper that owns it for us is a wrapper that decides for the modules.
|
|
18
|
+
*
|
|
19
|
+
* axe-core is also the engine those wrappers call, so this is not a reimplementation, it is the
|
|
20
|
+
* same engine with one fewer layer between the module and its rules.
|
|
21
|
+
*
|
|
22
|
+
* ## What it asserts, and what it deliberately does not
|
|
23
|
+
*
|
|
24
|
+
* It runs axe on the element it is given and fails on any VIOLATION. It says nothing about
|
|
25
|
+
* "incomplete" results, which axe reports when it cannot decide without a real browser: colour
|
|
26
|
+
* contrast against a computed background, for instance. Under jsdom those are not inconclusive by
|
|
27
|
+
* accident, they are inconclusive by construction, and failing on them would teach everyone to
|
|
28
|
+
* disable the matcher.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ So a green `toBeAccessible()` means "axe found no violation it could decide here", never "this
|
|
31
|
+
* is accessible". The message says so on failure, and the count of incomplete checks is reported
|
|
32
|
+
* alongside, so nobody reads silence as coverage.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
declare module 'vitest' {
|
|
36
|
+
interface Matchers<T = any> {
|
|
37
|
+
/**
|
|
38
|
+
* Run axe-core on this element and fail on any violation.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ A pass means axe found no violation it could DECIDE under jsdom. Checks axe reports as
|
|
41
|
+
* incomplete, such as colour contrast, are counted in the message and never failed.
|
|
42
|
+
*/
|
|
43
|
+
toBeAccessible: (options?: RunOptions) => Promise<T>;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/roles/test/setup/a11y.ts
|
|
2
|
+
import { expect } from "vitest";
|
|
3
|
+
function describeViolation(violation) {
|
|
4
|
+
const nodes = violation.nodes.slice(0, 3).map((node) => ` ${node.target.join(" ")}
|
|
5
|
+
${node.failureSummary ?? ""}`).join("\n");
|
|
6
|
+
const more = violation.nodes.length > 3 ? `
|
|
7
|
+
\u2026and ${violation.nodes.length - 3} more element(s)` : "";
|
|
8
|
+
return ` ${violation.id} (${violation.impact ?? "unknown impact"}): ${violation.help}
|
|
9
|
+
${violation.helpUrl}
|
|
10
|
+
${nodes}${more}`;
|
|
11
|
+
}
|
|
12
|
+
var engine;
|
|
13
|
+
var NODE = { ELEMENT: 1, DOCUMENT: 9, FRAGMENT: 11 };
|
|
14
|
+
function isDomNode(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && "nodeType" in value;
|
|
16
|
+
}
|
|
17
|
+
function nodeTypeOf(value) {
|
|
18
|
+
return isDomNode(value) ? value.nodeType : void 0;
|
|
19
|
+
}
|
|
20
|
+
function toAxeContext(target) {
|
|
21
|
+
if (nodeTypeOf(target) !== NODE.FRAGMENT) return target;
|
|
22
|
+
const elements = Array.from(target.childNodes).filter(
|
|
23
|
+
(node) => node.nodeType === NODE.ELEMENT
|
|
24
|
+
);
|
|
25
|
+
return elements.length > 0 ? { include: elements } : { include: [target] };
|
|
26
|
+
}
|
|
27
|
+
function assertSupportedDom() {
|
|
28
|
+
const agent = typeof navigator === "undefined" ? "" : (navigator.userAgent ?? "").toLowerCase();
|
|
29
|
+
if (agent.includes("happy-dom")) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`toBeAccessible() needs the "jsdom" environment: axe-core does not support happy-dom. Set \`environment: 'jsdom'\` for this suite, or for this file with \`// @vitest-environment jsdom\`.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function run(target, options) {
|
|
36
|
+
engine ??= import("axe-core");
|
|
37
|
+
const loaded = await engine;
|
|
38
|
+
const axe = loaded.default ?? loaded;
|
|
39
|
+
return axe.run(toAxeContext(target), {
|
|
40
|
+
elementRef: false,
|
|
41
|
+
...options
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
expect.extend({
|
|
45
|
+
async toBeAccessible(received, options) {
|
|
46
|
+
assertSupportedDom();
|
|
47
|
+
if (isDomNode(received) && received.isConnected === false) {
|
|
48
|
+
return {
|
|
49
|
+
pass: false,
|
|
50
|
+
message: () => `toBeAccessible() needs a node that is IN the document: axe analyses the page, and this one is detached. Testing Library's \`asFragment()\` returns a detached fragment, so pass \`container\` instead: expect(render(<X />).container).toBeAccessible().`
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const type = nodeTypeOf(received);
|
|
54
|
+
if (type !== NODE.ELEMENT && type !== NODE.DOCUMENT && type !== NODE.FRAGMENT) {
|
|
55
|
+
return {
|
|
56
|
+
pass: false,
|
|
57
|
+
message: () => `toBeAccessible() needs an Element, a Document or a DocumentFragment, and received ${typeof received}. From Testing Library, pass the container: expect(render(<X />).container).toBeAccessible(), or asFragment().`
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const results = await run(received, options);
|
|
61
|
+
const incomplete = results.incomplete.length === 0 ? "" : `
|
|
62
|
+
|
|
63
|
+
${results.incomplete.length} check(s) were INCOMPLETE and are not failures: axe cannot decide them without a real browser (contrast against a computed background, for instance). A pass here means no decidable violation, never "this is accessible".`;
|
|
64
|
+
return {
|
|
65
|
+
pass: results.violations.length === 0,
|
|
66
|
+
message: () => results.violations.length === 0 ? `expected accessibility violations, and axe found none.${incomplete}` : `${results.violations.length} accessibility violation(s):
|
|
67
|
+
|
|
68
|
+
${results.violations.map(describeViolation).join("\n\n")}${incomplete}`
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
//# sourceMappingURL=a11y.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../src/roles/test/setup/a11y.ts"],"sourcesContent":["/**\n * `expect(element).toBeAccessible()`, run by axe-core directly.\n *\n * Opt in from a module's config:\n *\n * setupFiles: ['@hublo/sentinel/test/setup/a11y']\n *\n * ## Why axe-core DIRECTLY, and not jest-axe or vitest-axe\n *\n * Both are thin wrappers whose job is to turn axe's result into a matcher, which is the twenty\n * lines below. What they add on top is a dependency that lags axe itself, its own idea of which\n * rules to run, and a second place where a violation can be filtered out. Measured while choosing:\n * the RULE SET decides the outcome far more than the runner does, 12 messages against 70 on the\n * same markup depending only on configuration. So the configuration is the thing worth owning, and\n * a wrapper that owns it for us is a wrapper that decides for the modules.\n *\n * axe-core is also the engine those wrappers call, so this is not a reimplementation, it is the\n * same engine with one fewer layer between the module and its rules.\n *\n * ## What it asserts, and what it deliberately does not\n *\n * It runs axe on the element it is given and fails on any VIOLATION. It says nothing about\n * \"incomplete\" results, which axe reports when it cannot decide without a real browser: colour\n * contrast against a computed background, for instance. Under jsdom those are not inconclusive by\n * accident, they are inconclusive by construction, and failing on them would teach everyone to\n * disable the matcher.\n *\n * ⚠️ So a green `toBeAccessible()` means \"axe found no violation it could decide here\", never \"this\n * is accessible\". The message says so on failure, and the count of incomplete checks is reported\n * alongside, so nobody reads silence as coverage.\n */\n/// <reference lib=\"dom\" />\nimport type { AxeResults, Result, RunOptions } from 'axe-core'\nimport { expect } from 'vitest'\n\n/** One violation, rendered the way a developer can act on it. */\nfunction describeViolation(violation: Result): string {\n const nodes = violation.nodes\n .slice(0, 3)\n .map((node) => ` ${node.target.join(' ')}\\n ${node.failureSummary ?? ''}`)\n .join('\\n')\n const more =\n violation.nodes.length > 3 ? `\\n …and ${violation.nodes.length - 3} more element(s)` : ''\n return (\n ` ${violation.id} (${violation.impact ?? 'unknown impact'}): ${violation.help}\\n` +\n ` ${violation.helpUrl}\\n${nodes}${more}`\n )\n}\n\n/**\n * ⚠️ The engine is loaded on FIRST USE, not when this file is read.\n *\n * `setupFiles` runs per test FILE under Vitest's isolation, and axe costs 28 ms to import. On\n * `host-admin`, 1460 files, that is 41 seconds added to every run for a matcher almost no file\n * calls. Lazily, a suite that never asserts on accessibility pays nothing at all, and one that\n * does pays once per file that does.\n *\n * The type import above is erased at build time, so it costs nothing either.\n */\nlet engine: Promise<typeof import('axe-core')> | undefined\n\n/** `nodeType`, because that is the one thing every DOM implementation agrees on. */\nconst NODE = { ELEMENT: 1, DOCUMENT: 9, FRAGMENT: 11 } as const\n\n/**\n * ⚠️ `nodeType`, NOT `instanceof Element`, and the difference is not style.\n *\n * `instanceof` compares against the constructor of THIS realm. A DOM built in another one, which\n * is what happens with jsdom inside a Vitest worker, fails the check while being a perfectly good\n * element, and the matcher then refuses the container it was handed. Taken from the OVH manager\n * kit, which hit it first.\n */\nfunction isDomNode(value: unknown): value is Element | Document | DocumentFragment {\n return typeof value === 'object' && value !== null && 'nodeType' in value\n}\n\nfunction nodeTypeOf(value: unknown): number | undefined {\n return isDomNode(value) ? (value as { nodeType: number }).nodeType : undefined\n}\n\n/**\n * The context axe accepts, from whatever Testing Library handed over.\n *\n * ⚠️ `asFragment()` returns a DocumentFragment, which axe cannot take directly: it is turned into\n * the list of its element children, which is the shape axe documents for a partial context.\n */\nfunction toAxeContext(target: Element | Document | DocumentFragment): unknown {\n if (nodeTypeOf(target) !== NODE.FRAGMENT) return target\n const elements = Array.from((target as DocumentFragment).childNodes).filter(\n (node) => (node as { nodeType: number }).nodeType === NODE.ELEMENT,\n )\n return elements.length > 0 ? { include: elements } : { include: [target] }\n}\n\n/**\n * ⚠️ axe does not work under happy-dom, and says nothing useful when it does not.\n *\n * Refused here with a sentence that names the fix, rather than leaving someone to read an\n * incomprehensible axe error. This repo has an open question about moving to happy-dom, which is\n * exactly why the guard is worth having before that question is answered. Taken from the OVH\n * manager kit.\n */\nfunction assertSupportedDom(): void {\n const agent = typeof navigator === 'undefined' ? '' : (navigator.userAgent ?? '').toLowerCase()\n if (agent.includes('happy-dom')) {\n throw new Error(\n `toBeAccessible() needs the \"jsdom\" environment: axe-core does not support happy-dom. ` +\n `Set \\`environment: 'jsdom'\\` for this suite, or for this file with ` +\n `\\`// @vitest-environment jsdom\\`.`,\n )\n }\n}\n\nasync function run(\n target: Element | Document | DocumentFragment,\n options?: RunOptions,\n): Promise<AxeResults> {\n engine ??= import('axe-core')\n const loaded = await engine\n // The package is CJS with a default export under ESM interop, and a namespace otherwise.\n const axe = (loaded as { default?: typeof loaded }).default ?? loaded\n /*\n * `elementRef: false` because the handle axe would attach is useless once the test has torn the\n * DOM down, and it keeps the result serialisable, which is what lets a reporter print it.\n */\n return axe.run(toAxeContext(target) as Parameters<typeof axe.run>[0], {\n elementRef: false,\n ...options,\n })\n}\n\nexpect.extend({\n async toBeAccessible(received: unknown, options?: RunOptions) {\n assertSupportedDom()\n\n /*\n * ⚠️ axe analyses the PAGE, so a node that is not in it has nothing to analyse, and what axe\n * says about that is `No elements found for include in page Context`, which names neither the\n * cause nor the fix. Measured on a detached `DocumentFragment`, which is exactly what\n * Testing Library's `asFragment()` returns.\n */\n if (isDomNode(received) && (received as { isConnected?: boolean }).isConnected === false) {\n return {\n pass: false,\n message: () =>\n `toBeAccessible() needs a node that is IN the document: axe analyses the page, and this ` +\n `one is detached. Testing Library's \\`asFragment()\\` returns a detached fragment, so ` +\n `pass \\`container\\` instead: expect(render(<X />).container).toBeAccessible().`,\n }\n }\n\n const type = nodeTypeOf(received)\n if (type !== NODE.ELEMENT && type !== NODE.DOCUMENT && type !== NODE.FRAGMENT) {\n return {\n pass: false,\n message: () =>\n `toBeAccessible() needs an Element, a Document or a DocumentFragment, and received ` +\n `${typeof received}. From Testing Library, pass the container: ` +\n `expect(render(<X />).container).toBeAccessible(), or asFragment().`,\n }\n }\n\n const results = await run(received as Element | Document | DocumentFragment, options)\n const incomplete =\n results.incomplete.length === 0\n ? ''\n : `\\n\\n${results.incomplete.length} check(s) were INCOMPLETE and are not failures: axe ` +\n `cannot decide them without a real browser (contrast against a computed background, for ` +\n `instance). A pass here means no decidable violation, never \"this is accessible\".`\n\n return {\n pass: results.violations.length === 0,\n message: () =>\n results.violations.length === 0\n ? `expected accessibility violations, and axe found none.${incomplete}`\n : `${results.violations.length} accessibility violation(s):\\n\\n` +\n `${results.violations.map(describeViolation).join('\\n\\n')}${incomplete}`,\n }\n },\n})\n\ndeclare module 'vitest' {\n // `T = any` to match Vitest's own declaration: TypeScript refuses to merge an interface whose\n // type parameters differ, and the message it gives points at this line rather than at the cause.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n interface Matchers<T = any> {\n /**\n * Run axe-core on this element and fail on any violation.\n *\n * ⚠️ A pass means axe found no violation it could DECIDE under jsdom. Checks axe reports as\n * incomplete, such as colour contrast, are counted in the message and never failed.\n */\n toBeAccessible: (options?: RunOptions) => Promise<T>\n }\n}\n"],"mappings":";AAiCA,SAAS,cAAc;AAGvB,SAAS,kBAAkB,WAA2B;AACpD,QAAM,QAAQ,UAAU,MACrB,MAAM,GAAG,CAAC,EACV,IAAI,CAAC,SAAS,SAAS,KAAK,OAAO,KAAK,GAAG,CAAC;AAAA,UAAa,KAAK,kBAAkB,EAAE,EAAE,EACpF,KAAK,IAAI;AACZ,QAAM,OACJ,UAAU,MAAM,SAAS,IAAI;AAAA,kBAAgB,UAAU,MAAM,SAAS,CAAC,qBAAqB;AAC9F,SACE,KAAK,UAAU,EAAE,KAAK,UAAU,UAAU,gBAAgB,MAAM,UAAU,IAAI;AAAA,MACvE,UAAU,OAAO;AAAA,EAAK,KAAK,GAAG,IAAI;AAE7C;AAYA,IAAI;AAGJ,IAAM,OAAO,EAAE,SAAS,GAAG,UAAU,GAAG,UAAU,GAAG;AAUrD,SAAS,UAAU,OAAgE;AACjF,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,cAAc;AACtE;AAEA,SAAS,WAAW,OAAoC;AACtD,SAAO,UAAU,KAAK,IAAK,MAA+B,WAAW;AACvE;AAQA,SAAS,aAAa,QAAwD;AAC5E,MAAI,WAAW,MAAM,MAAM,KAAK,SAAU,QAAO;AACjD,QAAM,WAAW,MAAM,KAAM,OAA4B,UAAU,EAAE;AAAA,IACnE,CAAC,SAAU,KAA8B,aAAa,KAAK;AAAA,EAC7D;AACA,SAAO,SAAS,SAAS,IAAI,EAAE,SAAS,SAAS,IAAI,EAAE,SAAS,CAAC,MAAM,EAAE;AAC3E;AAUA,SAAS,qBAA2B;AAClC,QAAM,QAAQ,OAAO,cAAc,cAAc,MAAM,UAAU,aAAa,IAAI,YAAY;AAC9F,MAAI,MAAM,SAAS,WAAW,GAAG;AAC/B,UAAM,IAAI;AAAA,MACR;AAAA,IAGF;AAAA,EACF;AACF;AAEA,eAAe,IACb,QACA,SACqB;AACrB,aAAW,OAAO,UAAU;AAC5B,QAAM,SAAS,MAAM;AAErB,QAAM,MAAO,OAAuC,WAAW;AAK/D,SAAO,IAAI,IAAI,aAAa,MAAM,GAAoC;AAAA,IACpE,YAAY;AAAA,IACZ,GAAG;AAAA,EACL,CAAC;AACH;AAEA,OAAO,OAAO;AAAA,EACZ,MAAM,eAAe,UAAmB,SAAsB;AAC5D,uBAAmB;AAQnB,QAAI,UAAU,QAAQ,KAAM,SAAuC,gBAAgB,OAAO;AACxF,aAAO;AAAA,QACL,MAAM;AAAA,QACN,SAAS,MACP;AAAA,MAGJ;AAAA,IACF;AAEA,UAAM,OAAO,WAAW,QAAQ;AAChC,QAAI,SAAS,KAAK,WAAW,SAAS,KAAK,YAAY,SAAS,KAAK,UAAU;AAC7E,aAAO;AAAA,QACL,MAAM;AAAA,QACN,SAAS,MACP,qFACG,OAAO,QAAQ;AAAA,MAEtB;AAAA,IACF;AAEA,UAAM,UAAU,MAAM,IAAI,UAAmD,OAAO;AACpF,UAAM,aACJ,QAAQ,WAAW,WAAW,IAC1B,KACA;AAAA;AAAA,EAAO,QAAQ,WAAW,MAAM;AAItC,WAAO;AAAA,MACL,MAAM,QAAQ,WAAW,WAAW;AAAA,MACpC,SAAS,MACP,QAAQ,WAAW,WAAW,IAC1B,yDAAyD,UAAU,KACnE,GAAG,QAAQ,WAAW,MAAM;AAAA;AAAA,EACzB,QAAQ,WAAW,IAAI,iBAAiB,EAAE,KAAK,MAAM,CAAC,GAAG,UAAU;AAAA,IAC9E;AAAA,EACF;AACF,CAAC;","names":[]}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { ConfigData } from 'html-validate';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `expect(element).toBeValidHtml()`, run by html-validate.
|
|
5
|
+
*
|
|
6
|
+
* Opt in from a module's config:
|
|
7
|
+
*
|
|
8
|
+
* setupFiles: ['@hublo/sentinel/test/setup/w3c']
|
|
9
|
+
*
|
|
10
|
+
* ## Why html-validate and not vnu
|
|
11
|
+
*
|
|
12
|
+
* vnu is the W3C's own checker and the W3C's own documentation advises against running it as a
|
|
13
|
+
* service in a loop, which is what a test suite does. It also needs a JVM, but that was never the
|
|
14
|
+
* deciding argument: measured on the same markup, the two agree on almost everything that matters
|
|
15
|
+
* to a component test. What differs is the configuration, and configuration is the thing worth
|
|
16
|
+
* owning.
|
|
17
|
+
*
|
|
18
|
+
* ## The fragment is WRAPPED in a page before it is validated
|
|
19
|
+
*
|
|
20
|
+
* A component renders a fragment, and a fragment cannot be held to document-level rules: it has no
|
|
21
|
+
* doctype, no `<html lang>`, no `<title>`. The obvious answer is to switch those rules off, and it
|
|
22
|
+
* is the wrong one, because they are the rules that catch what a component silently breaks inside
|
|
23
|
+
* a real page.
|
|
24
|
+
*
|
|
25
|
+
* So the fragment is put INTO a minimal valid page and the page is validated. Héla brought this
|
|
26
|
+
* from an OVH project that did the same with vnu, and measured here it is strictly better:
|
|
27
|
+
*
|
|
28
|
+
* case standard + 3 rules wrapped, standard + document + 3
|
|
29
|
+
* <img src="a.png"> wcag/h37 wcag/h37
|
|
30
|
+
* <div><p>x</div> no-implicit-close no-implicit-close
|
|
31
|
+
* <input type="text"> input-missing-label input-missing-label
|
|
32
|
+
* <div id="a" id="b"> no-dup-attr no-dup-attr
|
|
33
|
+
* <div id="a"> x2 no-dup-id no-dup-id
|
|
34
|
+
* <h1>a</h1><h3>b</h3> nothing heading-level
|
|
35
|
+
* correct markup nothing nothing
|
|
36
|
+
*
|
|
37
|
+
* The last two lines are the point: wrapping buys `heading-level`, a skipped heading rank, which
|
|
38
|
+
* no fragment-level check can see, and it costs no false positive.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ `html-validate:document` is NOT a superset of `standard`, which is easy to assume and wrong:
|
|
41
|
+
* measured on its own it caught `input-missing-label` and missed the other four. Both are extended,
|
|
42
|
+
* plus the three rules neither of them carries.
|
|
43
|
+
*
|
|
44
|
+
* The wrapper is written on ONE line and its width is subtracted from every reported column, so a
|
|
45
|
+
* position still points into the component's own markup rather than into our envelope.
|
|
46
|
+
*
|
|
47
|
+
* ## What a pass means
|
|
48
|
+
*
|
|
49
|
+
* That the markup this component rendered is well formed and carries the attributes those rules
|
|
50
|
+
* require. It says nothing about the rest of the page it will live in, and nothing about anything
|
|
51
|
+
* only a browser can decide. `toBeAccessible()` is the other half, and the two overlap on purpose:
|
|
52
|
+
* `wcag/h37` and axe's `image-alt` are the same requirement seen by a parser and by a DOM.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The configuration, which is the thing that decides the outcome.
|
|
57
|
+
*
|
|
58
|
+
* Exported so a module can read what it is being held to, and so a test can assert on it rather
|
|
59
|
+
* than on a behaviour that happens to follow from it.
|
|
60
|
+
*/
|
|
61
|
+
declare const W3C_CONFIG: ConfigData;
|
|
62
|
+
declare module 'vitest' {
|
|
63
|
+
interface Matchers<T = any> {
|
|
64
|
+
/**
|
|
65
|
+
* Validate this element's markup with html-validate.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ The fragment is put INTO a minimal page before it is validated, so the document-level
|
|
68
|
+
* rules apply to what the component will actually sit in, without holding the component
|
|
69
|
+
* itself to a doctype it does not own.
|
|
70
|
+
*/
|
|
71
|
+
toBeValidHtml: (config?: ConfigData) => Promise<T>;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { W3C_CONFIG };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/roles/test/setup/w3c.ts
|
|
2
|
+
import { expect } from "vitest";
|
|
3
|
+
var W3C_CONFIG = {
|
|
4
|
+
extends: ["html-validate:standard", "html-validate:document"],
|
|
5
|
+
rules: {
|
|
6
|
+
"wcag/h37": "error",
|
|
7
|
+
"no-implicit-close": "error",
|
|
8
|
+
"input-missing-label": "error"
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
var PREFIX = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>component under test</title></head><body>';
|
|
12
|
+
var SUFFIX = "</body></html>";
|
|
13
|
+
function asDocument(html) {
|
|
14
|
+
const looksLikeADocument = /<!doctype\s+html/i.test(html) || /<html[\s>]/i.test(html);
|
|
15
|
+
return looksLikeADocument ? { html, wrapped: false } : { html: `${PREFIX}${html}${SUFFIX}`, wrapped: true };
|
|
16
|
+
}
|
|
17
|
+
var engine;
|
|
18
|
+
function describe(message, wrapped) {
|
|
19
|
+
const shift = wrapped && message.line === 1 ? PREFIX.length : 0;
|
|
20
|
+
const column = Math.max(message.column - shift, 1);
|
|
21
|
+
return ` ${message.line}:${column} ${message.message} (${message.ruleId})`;
|
|
22
|
+
}
|
|
23
|
+
expect.extend({
|
|
24
|
+
async toBeValidHtml(received, config) {
|
|
25
|
+
const html = typeof received === "string" ? received : received instanceof Element ? received.innerHTML : void 0;
|
|
26
|
+
if (html === void 0) {
|
|
27
|
+
return {
|
|
28
|
+
pass: false,
|
|
29
|
+
message: () => `toBeValidHtml() needs an Element or an HTML string, and received ${typeof received}. From Testing Library, pass the container: expect(render(<X />).container).toBeValidHtml().`
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
engine ??= import("html-validate");
|
|
33
|
+
const { HtmlValidate } = await engine;
|
|
34
|
+
const validator = new HtmlValidate(config ?? W3C_CONFIG);
|
|
35
|
+
const document = asDocument(html);
|
|
36
|
+
const report = await validator.validateString(document.html);
|
|
37
|
+
const messages = report.results[0]?.messages ?? [];
|
|
38
|
+
return {
|
|
39
|
+
pass: messages.length === 0,
|
|
40
|
+
message: () => messages.length === 0 ? `expected invalid HTML, and html-validate found nothing to report.` : `${messages.length} HTML problem(s):
|
|
41
|
+
|
|
42
|
+
${messages.map((message) => describe(message, document.wrapped)).join("\n")}`
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
export {
|
|
47
|
+
W3C_CONFIG
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=w3c.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../src/roles/test/setup/w3c.ts"],"sourcesContent":["/**\n * `expect(element).toBeValidHtml()`, run by html-validate.\n *\n * Opt in from a module's config:\n *\n * setupFiles: ['@hublo/sentinel/test/setup/w3c']\n *\n * ## Why html-validate and not vnu\n *\n * vnu is the W3C's own checker and the W3C's own documentation advises against running it as a\n * service in a loop, which is what a test suite does. It also needs a JVM, but that was never the\n * deciding argument: measured on the same markup, the two agree on almost everything that matters\n * to a component test. What differs is the configuration, and configuration is the thing worth\n * owning.\n *\n * ## The fragment is WRAPPED in a page before it is validated\n *\n * A component renders a fragment, and a fragment cannot be held to document-level rules: it has no\n * doctype, no `<html lang>`, no `<title>`. The obvious answer is to switch those rules off, and it\n * is the wrong one, because they are the rules that catch what a component silently breaks inside\n * a real page.\n *\n * So the fragment is put INTO a minimal valid page and the page is validated. Héla brought this\n * from an OVH project that did the same with vnu, and measured here it is strictly better:\n *\n * case standard + 3 rules wrapped, standard + document + 3\n * <img src=\"a.png\"> wcag/h37 wcag/h37\n * <div><p>x</div> no-implicit-close no-implicit-close\n * <input type=\"text\"> input-missing-label input-missing-label\n * <div id=\"a\" id=\"b\"> no-dup-attr no-dup-attr\n * <div id=\"a\"> x2 no-dup-id no-dup-id\n * <h1>a</h1><h3>b</h3> nothing heading-level\n * correct markup nothing nothing\n *\n * The last two lines are the point: wrapping buys `heading-level`, a skipped heading rank, which\n * no fragment-level check can see, and it costs no false positive.\n *\n * ⚠️ `html-validate:document` is NOT a superset of `standard`, which is easy to assume and wrong:\n * measured on its own it caught `input-missing-label` and missed the other four. Both are extended,\n * plus the three rules neither of them carries.\n *\n * The wrapper is written on ONE line and its width is subtracted from every reported column, so a\n * position still points into the component's own markup rather than into our envelope.\n *\n * ## What a pass means\n *\n * That the markup this component rendered is well formed and carries the attributes those rules\n * require. It says nothing about the rest of the page it will live in, and nothing about anything\n * only a browser can decide. `toBeAccessible()` is the other half, and the two overlap on purpose:\n * `wcag/h37` and axe's `image-alt` are the same requirement seen by a parser and by a DOM.\n */\n/// <reference lib=\"dom\" />\nimport type { ConfigData, Message } from 'html-validate'\nimport { expect } from 'vitest'\n\n/**\n * The configuration, which is the thing that decides the outcome.\n *\n * Exported so a module can read what it is being held to, and so a test can assert on it rather\n * than on a behaviour that happens to follow from it.\n */\nexport const W3C_CONFIG: ConfigData = {\n extends: ['html-validate:standard', 'html-validate:document'],\n rules: {\n 'wcag/h37': 'error',\n 'no-implicit-close': 'error',\n 'input-missing-label': 'error',\n },\n}\n\n/**\n * The page a fragment is put into, on ONE line so a reported position stays readable.\n *\n * `lang` and `<title>` are there because the document rules require them, and requiring them of\n * the COMPONENT would be wrong: it does not own the page it renders into.\n */\nconst PREFIX =\n '<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>component under test</title></head><body>'\nconst SUFFIX = '</body></html>'\n\n/**\n * The fragment, inside a page, unless it already IS one.\n *\n * ⚠️ Conditional, taken from the OVH manager kit: a test that hands over a whole document, which\n * happens as soon as someone validates a server-rendered page, would otherwise get a second\n * doctype and a nested `<html>`, and every message after that would be about our wrapper.\n */\nfunction asDocument(html: string): { html: string; wrapped: boolean } {\n const looksLikeADocument = /<!doctype\\s+html/i.test(html) || /<html[\\s>]/i.test(html)\n return looksLikeADocument\n ? { html, wrapped: false }\n : { html: `${PREFIX}${html}${SUFFIX}`, wrapped: true }\n}\n\n/** The engine, kept once a suite has actually needed it. See the note at its first use. */\nlet engine: Promise<typeof import('html-validate')> | undefined\n\n/**\n * One message, with a position that points into the COMPONENT's markup.\n *\n * Everything the component rendered sits on the wrapper's single first line, so only a message on\n * line 1 needs its column moved back; a message on a later line is already at the component's own\n * coordinates.\n */\nfunction describe(message: Message, wrapped: boolean): string {\n const shift = wrapped && message.line === 1 ? PREFIX.length : 0\n const column = Math.max(message.column - shift, 1)\n return ` ${message.line}:${column} ${message.message} (${message.ruleId})`\n}\n\nexpect.extend({\n async toBeValidHtml(received: unknown, config?: ConfigData) {\n const html =\n typeof received === 'string'\n ? received\n : received instanceof Element\n ? received.innerHTML\n : undefined\n\n if (html === undefined) {\n return {\n pass: false,\n message: () =>\n `toBeValidHtml() needs an Element or an HTML string, and received ${typeof received}. ` +\n `From Testing Library, pass the container: expect(render(<X />).container).toBeValidHtml().`,\n }\n }\n\n /*\n * ⚠️ Loaded on FIRST USE. `setupFiles` runs per test FILE under Vitest's isolation, and\n * html-validate costs 75 ms to import: on `host-admin`, 1460 files, that is 110 seconds added\n * to every run for a matcher almost no file calls.\n */\n engine ??= import('html-validate')\n const { HtmlValidate } = await engine\n const validator = new HtmlValidate(config ?? W3C_CONFIG)\n const document = asDocument(html)\n const report = await validator.validateString(document.html)\n const messages = report.results[0]?.messages ?? []\n\n return {\n pass: messages.length === 0,\n message: () =>\n messages.length === 0\n ? `expected invalid HTML, and html-validate found nothing to report.`\n : `${messages.length} HTML problem(s):\\n\\n` +\n `${messages.map((message) => describe(message, document.wrapped)).join('\\n')}`,\n }\n },\n})\n\ndeclare module 'vitest' {\n // `T = any` to match Vitest's own declaration; see the same note in `a11y.ts`.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n interface Matchers<T = any> {\n /**\n * Validate this element's markup with html-validate.\n *\n * ⚠️ The fragment is put INTO a minimal page before it is validated, so the document-level\n * rules apply to what the component will actually sit in, without holding the component\n * itself to a doctype it does not own.\n */\n toBeValidHtml: (config?: ConfigData) => Promise<T>\n }\n}\n"],"mappings":";AAqDA,SAAS,cAAc;AAQhB,IAAM,aAAyB;AAAA,EACpC,SAAS,CAAC,0BAA0B,wBAAwB;AAAA,EAC5D,OAAO;AAAA,IACL,YAAY;AAAA,IACZ,qBAAqB;AAAA,IACrB,uBAAuB;AAAA,EACzB;AACF;AAQA,IAAM,SACJ;AACF,IAAM,SAAS;AASf,SAAS,WAAW,MAAkD;AACpE,QAAM,qBAAqB,oBAAoB,KAAK,IAAI,KAAK,cAAc,KAAK,IAAI;AACpF,SAAO,qBACH,EAAE,MAAM,SAAS,MAAM,IACvB,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,MAAM,IAAI,SAAS,KAAK;AACzD;AAGA,IAAI;AASJ,SAAS,SAAS,SAAkB,SAA0B;AAC5D,QAAM,QAAQ,WAAW,QAAQ,SAAS,IAAI,OAAO,SAAS;AAC9D,QAAM,SAAS,KAAK,IAAI,QAAQ,SAAS,OAAO,CAAC;AACjD,SAAO,KAAK,QAAQ,IAAI,IAAI,MAAM,KAAK,QAAQ,OAAO,MAAM,QAAQ,MAAM;AAC5E;AAEA,OAAO,OAAO;AAAA,EACZ,MAAM,cAAc,UAAmB,QAAqB;AAC1D,UAAM,OACJ,OAAO,aAAa,WAChB,WACA,oBAAoB,UAClB,SAAS,YACT;AAER,QAAI,SAAS,QAAW;AACtB,aAAO;AAAA,QACL,MAAM;AAAA,QACN,SAAS,MACP,oEAAoE,OAAO,QAAQ;AAAA,MAEvF;AAAA,IACF;AAOA,eAAW,OAAO,eAAe;AACjC,UAAM,EAAE,aAAa,IAAI,MAAM;AAC/B,UAAM,YAAY,IAAI,aAAa,UAAU,UAAU;AACvD,UAAM,WAAW,WAAW,IAAI;AAChC,UAAM,SAAS,MAAM,UAAU,eAAe,SAAS,IAAI;AAC3D,UAAM,WAAW,OAAO,QAAQ,CAAC,GAAG,YAAY,CAAC;AAEjD,WAAO;AAAA,MACL,MAAM,SAAS,WAAW;AAAA,MAC1B,SAAS,MACP,SAAS,WAAW,IAChB,sEACA,GAAG,SAAS,MAAM;AAAA;AAAA,EACf,SAAS,IAAI,CAAC,YAAY,SAAS,SAAS,SAAS,OAAO,CAAC,EAAE,KAAK,IAAI,CAAC;AAAA,IACpF;AAAA,EACF;AACF,CAAC;","names":[]}
|
package/docs/test-adoption.md
CHANGED
|
@@ -131,6 +131,55 @@ import SIDE EFFECT, a decorator writing into a catalog when its module loads, so
|
|
|
131
131
|
would make results depend on the order files ran in. That is the same fact the build role records
|
|
132
132
|
next to `treeshake: { moduleSideEffects: true }`.
|
|
133
133
|
|
|
134
|
+
## The two DOM matchers, which a react module gets without asking
|
|
135
|
+
|
|
136
|
+
Adopting the test role on a **react** module also registers two matchers. Nothing changes until a
|
|
137
|
+
test calls one: registering a matcher does not make anything fail.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const { container } = render(<SaveButton />)
|
|
141
|
+
|
|
142
|
+
await expect(container).toBeAccessible() // axe-core
|
|
143
|
+
await expect(container).toBeValidHtml() // html-validate
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
A **nest** module gets neither, because there is no DOM to assert on.
|
|
147
|
+
|
|
148
|
+
### What `toBeAccessible()` does and does not say
|
|
149
|
+
|
|
150
|
+
It runs axe on what you hand it and fails on any violation, naming the rule, the elements and the
|
|
151
|
+
page that explains the fix.
|
|
152
|
+
|
|
153
|
+
⚠️ It never fails on a check axe calls INCOMPLETE. Under jsdom those are undecidable by
|
|
154
|
+
construction, colour contrast against a computed background being the usual one, and failing on
|
|
155
|
+
them teaches everyone to switch the matcher off. So a pass means _no violation axe could decide
|
|
156
|
+
here_, never _this is accessible_, and the message says so with the count.
|
|
157
|
+
|
|
158
|
+
Two refusals worth recognising:
|
|
159
|
+
|
|
160
|
+
| What it says | What to do |
|
|
161
|
+
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
162
|
+
| `needs the "jsdom" environment` | axe does not support happy-dom; set `environment: 'jsdom'`, or `// @vitest-environment jsdom` for one file |
|
|
163
|
+
| `needs a node that is IN the document` | axe analyses the page, so a detached node has nothing to analyse; pass `container`, not `asFragment()` |
|
|
164
|
+
|
|
165
|
+
### What `toBeValidHtml()` does
|
|
166
|
+
|
|
167
|
+
It puts your markup inside a minimal valid page and validates the page. A component renders a
|
|
168
|
+
fragment, and a fragment has no doctype, no `lang`, no `<title>`; switching those rules off would
|
|
169
|
+
lose exactly the rules that catch what a component breaks inside a real page. Measured, the
|
|
170
|
+
wrapper buys `heading-level`, a skipped heading rank no fragment-level check can see, and costs no
|
|
171
|
+
false positive. A subject that already IS a document is left alone.
|
|
172
|
+
|
|
173
|
+
The engine is an implementation detail on purpose. The matcher is the contract, so `html-validate`
|
|
174
|
+
can be replaced by the W3C's own `vnu` the day this repo's CI carries a Java runtime, without
|
|
175
|
+
touching a single test.
|
|
176
|
+
|
|
177
|
+
### The cost, since it is on by default
|
|
178
|
+
|
|
179
|
+
Both engines load on FIRST USE. Imported eagerly they cost 103ms per test FILE under Vitest's
|
|
180
|
+
isolation, which on `host-admin` and its 1460 files would add 150 seconds to every run for a
|
|
181
|
+
matcher almost no file calls. Lazily, a suite that never asserts on either pays 0.5ms.
|
|
182
|
+
|
|
134
183
|
## After the migration: the four things you will actually hit
|
|
135
184
|
|
|
136
185
|
None of these is a defect, and the tool names each one in its own output. They are listed here
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hublo/sentinel",
|
|
3
|
-
"version": "1.4.0-alpha.
|
|
3
|
+
"version": "1.4.0-alpha.30",
|
|
4
4
|
"description": "One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -77,6 +77,14 @@
|
|
|
77
77
|
"./test/setup/workspace": {
|
|
78
78
|
"types": "./dist/roles/test/setup/workspace-entry.d.ts",
|
|
79
79
|
"import": "./dist/roles/test/setup/workspace-entry.js"
|
|
80
|
+
},
|
|
81
|
+
"./test/setup/a11y": {
|
|
82
|
+
"types": "./dist/roles/test/setup/a11y.d.ts",
|
|
83
|
+
"import": "./dist/roles/test/setup/a11y.js"
|
|
84
|
+
},
|
|
85
|
+
"./test/setup/w3c": {
|
|
86
|
+
"types": "./dist/roles/test/setup/w3c.d.ts",
|
|
87
|
+
"import": "./dist/roles/test/setup/w3c.js"
|
|
80
88
|
}
|
|
81
89
|
},
|
|
82
90
|
"files": [
|
|
@@ -92,6 +100,7 @@
|
|
|
92
100
|
"@tailwindcss/vite": "4.1.18",
|
|
93
101
|
"@vitejs/plugin-react": "6.0.1",
|
|
94
102
|
"@vitest/coverage-v8": "4.1.4",
|
|
103
|
+
"axe-core": "^4.13.0",
|
|
95
104
|
"commander": "^13.0.0",
|
|
96
105
|
"dotenv-flow": "4.1.0",
|
|
97
106
|
"eslint-plugin-jest-dom": "5.5.0",
|
|
@@ -99,6 +108,7 @@
|
|
|
99
108
|
"eslint-plugin-react-refresh": "0.4.19",
|
|
100
109
|
"eslint-plugin-storybook": "10.3.5",
|
|
101
110
|
"eslint-plugin-styled-components-a11y": "2.2.0",
|
|
111
|
+
"html-validate": "^11.16.0",
|
|
102
112
|
"jsdom": "^24.1.3",
|
|
103
113
|
"jsonc-parser": "^3.3.1",
|
|
104
114
|
"msw": "1.3.3",
|