@hublo/sentinel 1.4.1 → 1.4.3
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 +3 -3
- package/dist/{chunk-GRK2KRFI.js → chunk-DYK6UEMO.js} +14 -11
- package/dist/{chunk-DYJ6J43A.js → chunk-TDNPPNGK.js} +114 -7
- package/dist/index.js +2 -2
- package/dist/{validate-57H7GK5B.js → validate-3QMCX2HW.js} +1 -1
- package/docs/test-adoption.md +148 -0
- package/docs/typescript-traces.md +553 -174
- package/package.json +2 -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-DYK6UEMO.js";
|
|
19
19
|
import {
|
|
20
20
|
resolveContext
|
|
21
|
-
} from "../chunk-
|
|
21
|
+
} from "../chunk-TDNPPNGK.js";
|
|
22
22
|
import "../chunk-O7REVMOC.js";
|
|
23
23
|
import "../chunk-QXFCZON7.js";
|
|
24
24
|
import {
|
|
@@ -145,7 +145,7 @@ async function runValidate(ctx) {
|
|
|
145
145
|
);
|
|
146
146
|
}
|
|
147
147
|
if (!ctx.targets.includes("test")) return 1;
|
|
148
|
-
const { validateModuleTests } = await import("../validate-
|
|
148
|
+
const { validateModuleTests } = await import("../validate-3QMCX2HW.js");
|
|
149
149
|
const outcomes = modules.flatMap(
|
|
150
150
|
(module) => validateModuleTests(module.name, module.root)
|
|
151
151
|
);
|
|
@@ -53,7 +53,7 @@ import {
|
|
|
53
53
|
selfCommand,
|
|
54
54
|
splitToolArgs,
|
|
55
55
|
walk
|
|
56
|
-
} from "./chunk-
|
|
56
|
+
} from "./chunk-TDNPPNGK.js";
|
|
57
57
|
import {
|
|
58
58
|
parseJsonc
|
|
59
59
|
} from "./chunk-QXFCZON7.js";
|
|
@@ -5691,26 +5691,28 @@ function dependencyNames(cwd) {
|
|
|
5691
5691
|
return [];
|
|
5692
5692
|
}
|
|
5693
5693
|
}
|
|
5694
|
-
|
|
5694
|
+
var REACT_TYPESCRIPT_PRESET = "@hublo/sentinel/typescript/react";
|
|
5695
|
+
function reactTsconfigEvidence(cwd) {
|
|
5695
5696
|
let entries;
|
|
5696
5697
|
try {
|
|
5697
5698
|
entries = readdirSync3(cwd).filter(
|
|
5698
5699
|
(name) => name.startsWith("tsconfig") && name.endsWith(".json")
|
|
5699
5700
|
);
|
|
5700
5701
|
} catch {
|
|
5701
|
-
return
|
|
5702
|
+
return void 0;
|
|
5702
5703
|
}
|
|
5703
5704
|
for (const name of entries) {
|
|
5704
5705
|
try {
|
|
5705
|
-
const config = parseJsonc(
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
)
|
|
5709
|
-
|
|
5706
|
+
const config = parseJsonc(readFileSync17(join19(cwd, name), "utf8"), name);
|
|
5707
|
+
if (config.compilerOptions?.jsx !== void 0) return 'its tsconfig sets "jsx"';
|
|
5708
|
+
const extended = Array.isArray(config.extends) ? config.extends : config.extends === void 0 ? [] : [config.extends];
|
|
5709
|
+
if (extended.some((entry) => entry === REACT_TYPESCRIPT_PRESET)) {
|
|
5710
|
+
return `its tsconfig extends "${REACT_TYPESCRIPT_PRESET}"`;
|
|
5711
|
+
}
|
|
5710
5712
|
} catch {
|
|
5711
5713
|
}
|
|
5712
5714
|
}
|
|
5713
|
-
return
|
|
5715
|
+
return void 0;
|
|
5714
5716
|
}
|
|
5715
5717
|
function presetSignals(cwd) {
|
|
5716
5718
|
const signals = [];
|
|
@@ -5719,8 +5721,9 @@ function presetSignals(cwd) {
|
|
|
5719
5721
|
const hit = dependencies.find((name) => pattern.test(name));
|
|
5720
5722
|
if (hit) signals.push({ preset, evidence: `it depends on "${hit}"` });
|
|
5721
5723
|
}
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
+
const reactTsconfig = reactTsconfigEvidence(cwd);
|
|
5725
|
+
if (reactTsconfig !== void 0 && !signals.some((signal) => signal.preset === "react")) {
|
|
5726
|
+
signals.push({ preset: "react", evidence: reactTsconfig });
|
|
5724
5727
|
}
|
|
5725
5728
|
const location = cwd.replaceAll("\\", "/");
|
|
5726
5729
|
for (const { preset, pattern, evidence } of PATH_SIGNALS) {
|
|
@@ -5572,6 +5572,100 @@ function planJestMigration(context, adoption) {
|
|
|
5572
5572
|
return { operations, notes };
|
|
5573
5573
|
}
|
|
5574
5574
|
|
|
5575
|
+
// src/roles/test/react/add-matcher-setups.ts
|
|
5576
|
+
import { parseSync as parseSync11 } from "oxc-parser";
|
|
5577
|
+
var MATCHER_SETUPS = [
|
|
5578
|
+
"@hublo/sentinel/test/setup/a11y",
|
|
5579
|
+
"@hublo/sentinel/test/setup/w3c"
|
|
5580
|
+
];
|
|
5581
|
+
function findSetupFilesValues(root) {
|
|
5582
|
+
const found = [];
|
|
5583
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
5584
|
+
const walk2 = (node) => {
|
|
5585
|
+
if (node === null || typeof node !== "object") return;
|
|
5586
|
+
if (seen.has(node)) return;
|
|
5587
|
+
seen.add(node);
|
|
5588
|
+
if (Array.isArray(node)) {
|
|
5589
|
+
for (const child of node) walk2(child);
|
|
5590
|
+
return;
|
|
5591
|
+
}
|
|
5592
|
+
const record = node;
|
|
5593
|
+
if (record.type === "Property") {
|
|
5594
|
+
const key = record.key;
|
|
5595
|
+
const name = key?.type === "Identifier" ? key.name : key?.value;
|
|
5596
|
+
if (name === "setupFiles") found.push(record.value);
|
|
5597
|
+
}
|
|
5598
|
+
for (const value of Object.values(record)) walk2(value);
|
|
5599
|
+
};
|
|
5600
|
+
walk2(root);
|
|
5601
|
+
return found;
|
|
5602
|
+
}
|
|
5603
|
+
function indentAt(source, offset) {
|
|
5604
|
+
const lineStart = source.lastIndexOf("\n", offset - 1) + 1;
|
|
5605
|
+
return /^\s*/.exec(source.slice(lineStart, offset))?.[0] ?? "";
|
|
5606
|
+
}
|
|
5607
|
+
function quoteOf(source, value) {
|
|
5608
|
+
const first = value.elements[0];
|
|
5609
|
+
if (first === void 0) return "'";
|
|
5610
|
+
return source.slice(first.start, first.end).startsWith('"') ? '"' : "'";
|
|
5611
|
+
}
|
|
5612
|
+
function addMatcherSetups(source, fileName) {
|
|
5613
|
+
const { program, errors } = parseSync11(fileName, source, { sourceType: "module" });
|
|
5614
|
+
if (errors.length > 0) {
|
|
5615
|
+
return { kind: "skipped", why: `${fileName} does not parse` };
|
|
5616
|
+
}
|
|
5617
|
+
const values = findSetupFilesValues(program);
|
|
5618
|
+
if (values.length === 0) {
|
|
5619
|
+
return {
|
|
5620
|
+
kind: "skipped",
|
|
5621
|
+
why: `${fileName} declares no \`setupFiles\`, so there is no list to add them to`
|
|
5622
|
+
};
|
|
5623
|
+
}
|
|
5624
|
+
if (values.length > 1) {
|
|
5625
|
+
return {
|
|
5626
|
+
kind: "skipped",
|
|
5627
|
+
why: `${fileName} declares \`setupFiles\` ${values.length} times, so which list owns the matchers is ambiguous`
|
|
5628
|
+
};
|
|
5629
|
+
}
|
|
5630
|
+
const value = values[0];
|
|
5631
|
+
if (value === void 0 || value.type !== "ArrayExpression") {
|
|
5632
|
+
return {
|
|
5633
|
+
kind: "skipped",
|
|
5634
|
+
why: `\`setupFiles\` in ${fileName} is not an array literal, so its entries cannot be read where they are written`
|
|
5635
|
+
};
|
|
5636
|
+
}
|
|
5637
|
+
const declared = new Set(
|
|
5638
|
+
value.elements.filter((element) => element.type === "Literal" && typeof element.value === "string").map((element) => element.value)
|
|
5639
|
+
);
|
|
5640
|
+
const missing = MATCHER_SETUPS.filter((entry) => !declared.has(entry));
|
|
5641
|
+
if (missing.length === 0) {
|
|
5642
|
+
return { kind: "unchanged", why: "this config already registers both matchers" };
|
|
5643
|
+
}
|
|
5644
|
+
const quote = quoteOf(source, value);
|
|
5645
|
+
const first = value.elements[0];
|
|
5646
|
+
const base = indentAt(source, value.start);
|
|
5647
|
+
if (first !== void 0 && source.slice(value.start, first.start).includes("\n")) {
|
|
5648
|
+
const indent = indentAt(source, first.start);
|
|
5649
|
+
const inserted = missing.map((entry) => `${quote}${entry}${quote},
|
|
5650
|
+
${indent}`).join("");
|
|
5651
|
+
return {
|
|
5652
|
+
kind: "added",
|
|
5653
|
+
source: source.slice(0, first.start) + inserted + source.slice(first.start),
|
|
5654
|
+
added: missing
|
|
5655
|
+
};
|
|
5656
|
+
}
|
|
5657
|
+
const existing = source.slice(value.start + 1, value.end - 1).split(",").map((part) => part.trim()).filter((part) => part.length > 0);
|
|
5658
|
+
const entries = [...missing.map((entry) => `${quote}${entry}${quote}`), ...existing];
|
|
5659
|
+
const rendered = `[
|
|
5660
|
+
${entries.map((entry) => `${base} ${entry},
|
|
5661
|
+
`).join("")}${base}]`;
|
|
5662
|
+
return {
|
|
5663
|
+
kind: "added",
|
|
5664
|
+
source: source.slice(0, value.start) + rendered + source.slice(value.end),
|
|
5665
|
+
added: missing
|
|
5666
|
+
};
|
|
5667
|
+
}
|
|
5668
|
+
|
|
5575
5669
|
// src/roles/test/plan.ts
|
|
5576
5670
|
function ownedTestDependencies(cwd, declared) {
|
|
5577
5671
|
return declared.filter((entry) => SENTINEL_OWNED_TEST_PACKAGES.includes(entry.name)).flatMap((entry) => [
|
|
@@ -5634,21 +5728,34 @@ function plan(context) {
|
|
|
5634
5728
|
}
|
|
5635
5729
|
const operations = [];
|
|
5636
5730
|
const notes = [];
|
|
5637
|
-
const
|
|
5638
|
-
|
|
5639
|
-
readTestConfigSource(context.cwd, configFile),
|
|
5640
|
-
configFile
|
|
5641
|
-
);
|
|
5731
|
+
const configSource = readTestConfigSource(context.cwd, configFile);
|
|
5732
|
+
const rewrite = rewriteToolchainImports(testToolchain(context.preset), configSource, configFile);
|
|
5642
5733
|
if (rewrite.kind === "blocked") {
|
|
5643
5734
|
return {
|
|
5644
5735
|
operations: [],
|
|
5645
5736
|
skipped: `${configFile} could not be pointed at sentinel, so nothing was written: ${rewrite.why}. Nothing here is broken; this needs a look before the role applies.`
|
|
5646
5737
|
};
|
|
5647
5738
|
}
|
|
5648
|
-
|
|
5739
|
+
const base = rewrite.kind === "rewritten" ? rewrite.source : configSource;
|
|
5740
|
+
const matchers = context.preset === "react" ? addMatcherSetups(base, configFile) : { kind: "unchanged", why: "this preset has no DOM matchers" };
|
|
5741
|
+
if (matchers.kind === "added") {
|
|
5742
|
+
operations.push({ kind: "write", path: configFile, contents: matchers.source });
|
|
5743
|
+
} else if (rewrite.kind === "rewritten") {
|
|
5649
5744
|
operations.push({ kind: "write", path: configFile, contents: rewrite.source });
|
|
5745
|
+
}
|
|
5746
|
+
if (rewrite.kind === "rewritten") {
|
|
5747
|
+
notes.push(
|
|
5748
|
+
`${configFile} now imports ${rewrite.moved.join(", ")} from sentinel. Beyond its import lines, every alias, timeout and coverage path is untouched, so this suite runs what it ran before.`
|
|
5749
|
+
);
|
|
5750
|
+
}
|
|
5751
|
+
if (matchers.kind === "added") {
|
|
5752
|
+
notes.push(
|
|
5753
|
+
`${configFile} now registers ${matchers.added.join(" and ")} in its own \`setupFiles\`, which is what makes \`toBeAccessible()\` and \`toBeValidHtml()\` exist here. They are written where you can read them rather than merged in behind the config, and both engines load on FIRST USE, so a file calling neither pays only the import.`
|
|
5754
|
+
);
|
|
5755
|
+
}
|
|
5756
|
+
if (matchers.kind === "skipped") {
|
|
5650
5757
|
notes.push(
|
|
5651
|
-
|
|
5758
|
+
`the two DOM matchers were NOT registered: ${matchers.why}. Add ${MATCHER_SETUPS.map((entry) => `\`${entry}\``).join(" and ")} to this module's \`setupFiles\` to get \`toBeAccessible()\` and \`toBeValidHtml()\`.`
|
|
5652
5759
|
);
|
|
5653
5760
|
}
|
|
5654
5761
|
const existing = projectTargets(context.cwd)?.[TEST_SCRIPT_NAME] ?? manifestTargets(context.cwd)[TEST_SCRIPT_NAME];
|
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-DYK6UEMO.js";
|
|
10
10
|
import {
|
|
11
11
|
BaseAdapter
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-TDNPPNGK.js";
|
|
13
13
|
import "./chunk-O7REVMOC.js";
|
|
14
14
|
import "./chunk-QXFCZON7.js";
|
|
15
15
|
import "./chunk-KMKQDGI6.js";
|
package/docs/test-adoption.md
CHANGED
|
@@ -6,6 +6,7 @@ drop Jest before Nx v24 removes `@nx/jest:jest`.
|
|
|
6
6
|
This page is written from measurement. Every number in it was taken on this monorepo, and the ones
|
|
7
7
|
that decided a design choice say which choice.
|
|
8
8
|
|
|
9
|
+
- [How to run it](#how-to-run-it)
|
|
9
10
|
- [What the ground actually looks like](#what-the-ground-actually-looks-like)
|
|
10
11
|
- [The one line that makes a per-module migration possible](#the-one-line-that-makes-a-per-module-migration-possible)
|
|
11
12
|
- [What `--init --test` rewrites, and what it refuses](#what---init---test-rewrites-and-what-it-refuses)
|
|
@@ -13,6 +14,21 @@ that decided a design choice say which choice.
|
|
|
13
14
|
- [The two DOM matchers, which a react module gets without asking](#the-two-dom-matchers-which-a-react-module-gets-without-asking)
|
|
14
15
|
- [The check that decides whether you are done](#the-check-that-decides-whether-you-are-done)
|
|
15
16
|
|
|
17
|
+
## How to run it
|
|
18
|
+
|
|
19
|
+
Every command on this page is written plainly as `sentinel --init --test`, because once a module
|
|
20
|
+
has adopted, sentinel is one of its devDependencies and the module's own scripts call it. Before
|
|
21
|
+
that, nothing is installed, so reach the tool with `npx`:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx --yes @hublo/sentinel@<exact-version> --inspect --test
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The two invocations that do not work there: `pnpm exec sentinel` answers `Command "sentinel" not
|
|
28
|
+
found`, since the binary is not in the module's `node_modules` yet, and `pnpm dlx` aborts before
|
|
29
|
+
sentinel runs at all under pnpm 12. The [README](../README.md) carries the measurements behind
|
|
30
|
+
both.
|
|
31
|
+
|
|
16
32
|
## What the ground actually looks like
|
|
17
33
|
|
|
18
34
|
| | |
|
|
@@ -146,11 +162,143 @@ await expect(container).toBeValidHtml() // html-validate
|
|
|
146
162
|
|
|
147
163
|
A **nest** module gets neither, because there is no DOM to assert on.
|
|
148
164
|
|
|
165
|
+
How they get there depends on where your config comes from, and both paths end in the same list. A
|
|
166
|
+
module migrated from jest receives a config that names them. A module that already had its own
|
|
167
|
+
Vitest config keeps it, and `--init` adds the two entries to the `setupFiles` it already declares,
|
|
168
|
+
sentinel's first:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
setupFiles: [
|
|
172
|
+
'@hublo/sentinel/test/setup/a11y',
|
|
173
|
+
'@hublo/sentinel/test/setup/w3c',
|
|
174
|
+
'./src/test/setup.ts',
|
|
175
|
+
],
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
⚠️ If that list cannot be located, because the config declares none, declares several, or computes
|
|
179
|
+
it, nothing is guessed: the adoption proceeds and the run says which two entries to add by hand.
|
|
180
|
+
Losing a matcher costs a capability; refusing the adoption over it would cost a migration.
|
|
181
|
+
|
|
149
182
|
⚠️ The generated config imports these by their package subpath, so they only resolve once
|
|
150
183
|
`pnpm install` has run. Skip that step and Vitest reports `Cannot find module
|
|
151
184
|
'<module>/@hublo/sentinel/test/setup/a11y'`, which names a path inside your module and reads like
|
|
152
185
|
a missing file rather than a missing install.
|
|
153
186
|
|
|
187
|
+
### Four cases, in React, with what they print
|
|
188
|
+
|
|
189
|
+
Run on `libs/front/components` with `1.4.1`, after `--init --test` and `pnpm install`. Nothing was
|
|
190
|
+
configured for them: `--init` wrote the wiring, what follows is only the assertions. Every subject
|
|
191
|
+
is `container`, the element `render()` puts in the document.
|
|
192
|
+
|
|
193
|
+
**An image with no alternative text.**
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
const Avatar = () => <img src="/avatar.png" width={40} height={40} />
|
|
197
|
+
|
|
198
|
+
const { container } = render(<Avatar />)
|
|
199
|
+
await expect(container).toBeAccessible()
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
Error: 1 accessibility violation(s):
|
|
204
|
+
|
|
205
|
+
image-alt (critical): Images must have alternative text
|
|
206
|
+
https://dequeuniversity.com/rules/axe/4.13/image-alt?application=axeAPI
|
|
207
|
+
img
|
|
208
|
+
Fix any of the following:
|
|
209
|
+
Element does not have an alt attribute
|
|
210
|
+
aria-label attribute does not exist or is empty
|
|
211
|
+
aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty
|
|
212
|
+
Element has no title attribute
|
|
213
|
+
Element's default semantics were not overridden with role="none" or role="presentation"
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**A button that is only an icon**, which is the same defect one layer deeper: the element has a
|
|
217
|
+
role and no name.
|
|
218
|
+
|
|
219
|
+
```tsx
|
|
220
|
+
const CloseButton = () => (
|
|
221
|
+
<button type="button">
|
|
222
|
+
<svg width="16" height="16" aria-hidden="true" />
|
|
223
|
+
</button>
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
const { container } = render(<CloseButton />)
|
|
227
|
+
await expect(container).toBeAccessible()
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
Error: 1 accessibility violation(s):
|
|
232
|
+
|
|
233
|
+
button-name (critical): Buttons must have discernible text
|
|
234
|
+
https://dequeuniversity.com/rules/axe/4.13/button-name?application=axeAPI
|
|
235
|
+
button
|
|
236
|
+
Fix any of the following:
|
|
237
|
+
Element does not have inner text that is visible to screen readers
|
|
238
|
+
aria-label attribute does not exist or is empty
|
|
239
|
+
...
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
In both, the message names the rule, its severity, the element, and every way of fixing it. The
|
|
243
|
+
last point matters more than it looks: `aria-hidden` on the `<svg>` is correct and is not what is
|
|
244
|
+
missing, so a list of the accepted fixes is what tells you to add the name on the button rather
|
|
245
|
+
than to undo the `aria-hidden`.
|
|
246
|
+
|
|
247
|
+
**A `<p>` inside a `<span>`**, which React renders happily and which no browser will keep.
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
const Hint = () => (
|
|
251
|
+
<span>
|
|
252
|
+
<p>Ce champ est obligatoire</p>
|
|
253
|
+
</span>
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
const { container } = render(<Hint />)
|
|
257
|
+
await expect(container).toBeValidHtml()
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```
|
|
261
|
+
Error: 1 HTML problem(s):
|
|
262
|
+
|
|
263
|
+
1:8 <p> element is not permitted as content under <span> (element-permitted-content)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**A heading level that skips one.**
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
const Panel = () => (
|
|
270
|
+
<section>
|
|
271
|
+
<h1>Mon compte</h1>
|
|
272
|
+
<h3>Coordonnées</h3>
|
|
273
|
+
</section>
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
const { container } = render(<Panel />)
|
|
277
|
+
await expect(container).toBeValidHtml()
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
Error: 1 HTML problem(s):
|
|
282
|
+
|
|
283
|
+
1:30 Heading level can only increase by one, expected <h2> but got <h3> (heading-level)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
This one is why the matcher wraps your markup in a document rather than validating the fragment
|
|
287
|
+
alone: a heading rank is only wrong relative to the ranks around it, and a fragment has nothing to
|
|
288
|
+
compare against.
|
|
289
|
+
|
|
290
|
+
**And one case that neither of them catches**, worth knowing so you do not assume a green means
|
|
291
|
+
more than it does. A `<button>` with no `type` defaults to `submit` and will post a form by
|
|
292
|
+
surprise, yet:
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
const Save = () => <button>Enregistrer</button>
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
passes `toBeAccessible()`, because axe has no rule for it, and passes `toBeValidHtml()`, because
|
|
299
|
+
`no-implicit-button-type` is not in the preset we ship. Both were run; both are green. That one
|
|
300
|
+
belongs to lint, not here.
|
|
301
|
+
|
|
154
302
|
### What they found on the first module that ran them
|
|
155
303
|
|
|
156
304
|
`libs/front/components`, the design system, on the first run after migrating. The `Checkbox` given
|