@hublo/sentinel 1.4.0 → 1.4.2

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.
@@ -15,10 +15,10 @@ import {
15
15
  palette,
16
16
  registerAdapters,
17
17
  resolve
18
- } from "../chunk-MWNOFSYR.js";
18
+ } from "../chunk-BGS5DL44.js";
19
19
  import {
20
20
  resolveContext
21
- } from "../chunk-BXXNV6NP.js";
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-BKD2ICT7.js");
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-BXXNV6NP.js";
56
+ } from "./chunk-TDNPPNGK.js";
57
57
  import {
58
58
  parseJsonc
59
59
  } from "./chunk-QXFCZON7.js";
@@ -1117,6 +1117,9 @@ var RUNTIME_RENAMES = /* @__PURE__ */ new Set([
1117
1117
  "useFakeTimers",
1118
1118
  "useRealTimers"
1119
1119
  ]);
1120
+ var MEMBER_RENAMES = {
1121
+ dontMock: "doUnmock"
1122
+ };
1120
1123
  var TYPE_RENAMES = {
1121
1124
  Mock: "Mock",
1122
1125
  MockedClass: "MockedClass",
@@ -1149,6 +1152,11 @@ function valueEdit(node, source, outcome) {
1149
1152
  outcome.rewritten++;
1150
1153
  return { start: object.start, end: object.end, text: "vi" };
1151
1154
  }
1155
+ const renamed = MEMBER_RENAMES[member];
1156
+ if (renamed !== void 0) {
1157
+ outcome.rewritten++;
1158
+ return { start: node.start, end: node.end, text: `vi.${renamed}` };
1159
+ }
1152
1160
  if (OWNED_BY_A_LATER_PASS.has(member)) return void 0;
1153
1161
  outcome.unhandled.push({
1154
1162
  member: `jest.${member}`,
@@ -5564,6 +5572,100 @@ function planJestMigration(context, adoption) {
5564
5572
  return { operations, notes };
5565
5573
  }
5566
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
+
5567
5669
  // src/roles/test/plan.ts
5568
5670
  function ownedTestDependencies(cwd, declared) {
5569
5671
  return declared.filter((entry) => SENTINEL_OWNED_TEST_PACKAGES.includes(entry.name)).flatMap((entry) => [
@@ -5626,21 +5728,34 @@ function plan(context) {
5626
5728
  }
5627
5729
  const operations = [];
5628
5730
  const notes = [];
5629
- const rewrite = rewriteToolchainImports(
5630
- testToolchain(context.preset),
5631
- readTestConfigSource(context.cwd, configFile),
5632
- configFile
5633
- );
5731
+ const configSource = readTestConfigSource(context.cwd, configFile);
5732
+ const rewrite = rewriteToolchainImports(testToolchain(context.preset), configSource, configFile);
5634
5733
  if (rewrite.kind === "blocked") {
5635
5734
  return {
5636
5735
  operations: [],
5637
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.`
5638
5737
  };
5639
5738
  }
5640
- if (rewrite.kind === "rewritten") {
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") {
5641
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") {
5642
5757
  notes.push(
5643
- `${configFile} now imports ${rewrite.moved.join(", ")} from sentinel. Only the import lines changed: every alias, setup file, timeout and coverage path is untouched, so this suite runs what it ran before.`
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()\`.`
5644
5759
  );
5645
5760
  }
5646
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-MWNOFSYR.js";
9
+ } from "./chunk-BGS5DL44.js";
10
10
  import {
11
11
  BaseAdapter
12
- } from "./chunk-BXXNV6NP.js";
12
+ } from "./chunk-TDNPPNGK.js";
13
13
  import "./chunk-O7REVMOC.js";
14
14
  import "./chunk-QXFCZON7.js";
15
15
  import "./chunk-KMKQDGI6.js";
@@ -7,7 +7,7 @@ import {
7
7
  readTestAdoption,
8
8
  resolveVitest,
9
9
  suiteOf
10
- } from "./chunk-BXXNV6NP.js";
10
+ } from "./chunk-TDNPPNGK.js";
11
11
  import "./chunk-O7REVMOC.js";
12
12
  import "./chunk-QXFCZON7.js";
13
13
  import "./chunk-KMKQDGI6.js";
@@ -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