@hublo/sentinel 1.4.0-alpha.9 → 1.4.0
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/README.md +1 -0
- package/dist/bin/sentinel.js +45 -101
- package/dist/{chunk-7PUVK4YM.js → chunk-5VNYQIFD.js} +224 -13
- package/dist/chunk-5VNYQIFD.js.map +1 -0
- package/dist/chunk-BXXNV6NP.js +6411 -0
- package/dist/chunk-ELZHIN6E.js +16 -0
- package/dist/chunk-ELZHIN6E.js.map +1 -0
- package/dist/chunk-H3GSGAX2.js +11 -0
- package/dist/chunk-H3GSGAX2.js.map +1 -0
- package/dist/{chunk-WLFE5RUU.js → chunk-KMKQDGI6.js} +6 -8
- package/dist/chunk-KMKQDGI6.js.map +1 -0
- package/dist/{chunk-PWV3BMDA.js → chunk-MMKBDO5V.js} +11 -2
- package/dist/chunk-MMKBDO5V.js.map +1 -0
- package/dist/{chunk-L7WS36XV.js → chunk-MWNOFSYR.js} +221 -4427
- package/dist/chunk-NUOQXAYR.js +14 -0
- package/dist/chunk-NUOQXAYR.js.map +1 -0
- package/dist/chunk-O7REVMOC.js +38 -0
- package/dist/chunk-O7REVMOC.js.map +1 -0
- package/dist/chunk-QXFCZON7.js +16 -0
- package/dist/chunk-QXFCZON7.js.map +1 -0
- package/dist/{chunk-3TDUIKVQ.js → chunk-SWWQ7X7B.js} +11 -8
- package/dist/{chunk-3TDUIKVQ.js.map → chunk-SWWQ7X7B.js.map} +1 -1
- package/dist/{chunk-CPCUPK4J.js → chunk-Z7L4FGKP.js} +5 -12
- package/dist/chunk-Z7L4FGKP.js.map +1 -0
- package/dist/index.d.ts +28 -5
- package/dist/index.js +7 -3
- package/dist/roles/build/nest/toolchain.js +11 -33
- package/dist/roles/build/nest/toolchain.js.map +1 -1
- package/dist/roles/test/nest/toolchain.js +3 -2
- package/dist/roles/test/nest/toolchain.js.map +1 -1
- package/dist/roles/test/react/toolchain.js +3 -2
- package/dist/roles/test/react/toolchain.js.map +1 -1
- 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/file-boundary-close.d.ts +2 -0
- package/dist/roles/test/setup/file-boundary-close.js +8 -0
- package/dist/roles/test/setup/file-boundary-close.js.map +1 -0
- package/dist/roles/test/setup/file-boundary.d.ts +2 -0
- package/dist/roles/test/setup/file-boundary.js +62 -0
- package/dist/roles/test/setup/file-boundary.js.map +1 -0
- package/dist/roles/test/setup/jest-parity.js +19 -2
- package/dist/roles/test/setup/jest-parity.js.map +1 -1
- package/dist/roles/test/setup/msw-lifecycle.js +2 -1
- package/dist/roles/test/setup/msw-lifecycle.js.map +1 -1
- package/dist/roles/test/setup/msw-server.js +2 -1
- package/dist/roles/test/setup/msw-server.js.map +1 -1
- 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/roles/test/setup/workspace-entry.js +3 -2
- package/dist/roles/test/setup/workspace-entry.js.map +1 -1
- package/dist/roles/test/shared-test-config.d.ts +13 -0
- package/dist/roles/test/shared-test-config.js +3 -2
- package/dist/validate-BKD2ICT7.js +170 -0
- package/docs/test-adoption.md +282 -5
- package/docs/using-sentinel.md +17 -1
- package/docs/validating-a-change.md +35 -2
- package/package.json +25 -1
- package/types/jest-global.d.ts +85 -0
- package/types/mock-extended.d.ts +52 -0
- package/dist/chunk-7PUVK4YM.js.map +0 -1
- package/dist/chunk-CPCUPK4J.js.map +0 -1
- package/dist/chunk-PWV3BMDA.js.map +0 -1
- package/dist/chunk-WLFE5RUU.js.map +0 -1
package/docs/test-adoption.md
CHANGED
|
@@ -10,6 +10,7 @@ that decided a design choice say which choice.
|
|
|
10
10
|
- [The one line that makes a per-module migration possible](#the-one-line-that-makes-a-per-module-migration-possible)
|
|
11
11
|
- [What `--init --test` rewrites, and what it refuses](#what---init---test-rewrites-and-what-it-refuses)
|
|
12
12
|
- [Nest services](#nest-services)
|
|
13
|
+
- [The two DOM matchers, which a react module gets without asking](#the-two-dom-matchers-which-a-react-module-gets-without-asking)
|
|
13
14
|
- [The check that decides whether you are done](#the-check-that-decides-whether-you-are-done)
|
|
14
15
|
|
|
15
16
|
## What the ground actually looks like
|
|
@@ -131,19 +132,294 @@ import SIDE EFFECT, a decorator writing into a catalog when its module loads, so
|
|
|
131
132
|
would make results depend on the order files ran in. That is the same fact the build role records
|
|
132
133
|
next to `treeshake: { moduleSideEffects: true }`.
|
|
133
134
|
|
|
135
|
+
## The two DOM matchers, which a react module gets without asking
|
|
136
|
+
|
|
137
|
+
Adopting the test role on a **react** module also registers two matchers. Nothing changes until a
|
|
138
|
+
test calls one: registering a matcher does not make anything fail.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
const { container } = render(<SaveButton />)
|
|
142
|
+
|
|
143
|
+
await expect(container).toBeAccessible() // axe-core
|
|
144
|
+
await expect(container).toBeValidHtml() // html-validate
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
A **nest** module gets neither, because there is no DOM to assert on.
|
|
148
|
+
|
|
149
|
+
⚠️ The generated config imports these by their package subpath, so they only resolve once
|
|
150
|
+
`pnpm install` has run. Skip that step and Vitest reports `Cannot find module
|
|
151
|
+
'<module>/@hublo/sentinel/test/setup/a11y'`, which names a path inside your module and reads like
|
|
152
|
+
a missing file rather than a missing install.
|
|
153
|
+
|
|
154
|
+
### What they found on the first module that ran them
|
|
155
|
+
|
|
156
|
+
`libs/front/components`, the design system, on the first run after migrating. The `Checkbox` given
|
|
157
|
+
a `label` had no accessible name at all, and both engines said so independently:
|
|
158
|
+
|
|
159
|
+
| engine | message |
|
|
160
|
+
| --------------- | ----------------------------------------------------------- |
|
|
161
|
+
| `axe-core` | `aria-prohibited-attr`: aria-label cannot be used on a span |
|
|
162
|
+
| `axe-core` | `label`: form elements must have labels |
|
|
163
|
+
| `html-validate` | `aria-label-misuse` |
|
|
164
|
+
| `html-validate` | `input-missing-label` |
|
|
165
|
+
|
|
166
|
+
One line caused it: `aria-label` was passed to the component, so MUI put it on the root `<span>`
|
|
167
|
+
rather than on the `<input>`. What makes it worth repeating here is why nothing had caught it. The
|
|
168
|
+
component is used across an app of several hundred screens, its tests were green, and
|
|
169
|
+
`getByLabelText` FOUND it: the query returned the wrapper span, so a test could locate the
|
|
170
|
+
checkbox, click it, and never notice it was announced with no name. The name was invisible to
|
|
171
|
+
everything except a screen reader and these two engines.
|
|
172
|
+
|
|
173
|
+
### What `toBeAccessible()` does and does not say
|
|
174
|
+
|
|
175
|
+
It runs axe on what you hand it and fails on any violation, naming the rule, the elements and the
|
|
176
|
+
page that explains the fix.
|
|
177
|
+
|
|
178
|
+
⚠️ It never fails on a check axe calls INCOMPLETE. Under jsdom those are undecidable by
|
|
179
|
+
construction, colour contrast against a computed background being the usual one, and failing on
|
|
180
|
+
them teaches everyone to switch the matcher off. So a pass means _no violation axe could decide
|
|
181
|
+
here_, never _this is accessible_, and the message says so with the count.
|
|
182
|
+
|
|
183
|
+
Two refusals worth recognising:
|
|
184
|
+
|
|
185
|
+
| What it says | What to do |
|
|
186
|
+
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
187
|
+
| `needs the "jsdom" environment` | axe does not support happy-dom; set `environment: 'jsdom'`, or `// @vitest-environment jsdom` for one file |
|
|
188
|
+
| `needs a node that is IN the document` | axe analyses the page, so a detached node has nothing to analyse; pass `container`, not `asFragment()` |
|
|
189
|
+
|
|
190
|
+
### What `toBeValidHtml()` does
|
|
191
|
+
|
|
192
|
+
It puts your markup inside a minimal valid page and validates the page. A component renders a
|
|
193
|
+
fragment, and a fragment has no doctype, no `lang`, no `<title>`; switching those rules off would
|
|
194
|
+
lose exactly the rules that catch what a component breaks inside a real page. Measured, the
|
|
195
|
+
wrapper buys `heading-level`, a skipped heading rank no fragment-level check can see, and costs no
|
|
196
|
+
false positive. A subject that already IS a document is left alone.
|
|
197
|
+
|
|
198
|
+
The engine is an implementation detail on purpose. The matcher is the contract, so `html-validate`
|
|
199
|
+
can be replaced by the W3C's own `vnu` the day this repo's CI carries a Java runtime, without
|
|
200
|
+
touching a single test.
|
|
201
|
+
|
|
202
|
+
### The cost, since it is on by default
|
|
203
|
+
|
|
204
|
+
Both engines load on FIRST USE. Imported eagerly they cost 103ms per test FILE under Vitest's
|
|
205
|
+
isolation, which on `host-admin` and its 1460 files would add 150 seconds to every run for a
|
|
206
|
+
matcher almost no file calls. Lazily, a suite that never asserts on either pays 0.5ms.
|
|
207
|
+
|
|
208
|
+
## After the migration: the four things you will actually hit
|
|
209
|
+
|
|
210
|
+
None of these is a defect, and the tool names each one in its own output. They are listed here
|
|
211
|
+
because they arrive after the rewrite, when the diff already looks finished, and each one costs an
|
|
212
|
+
hour the first time.
|
|
213
|
+
|
|
214
|
+
### 1. `import/order`, which no formatter fixes
|
|
215
|
+
|
|
216
|
+
The migration formats what it writes with your module's own formatter, and a formatter does not
|
|
217
|
+
ORDER imports. So a module whose lint enforces `import/order` comes out lint-red on lines the
|
|
218
|
+
migration touched, and only those.
|
|
219
|
+
|
|
220
|
+
Measured: 95 errors on `institution`, 298 across the three `cloud` modules, every one that rule and
|
|
221
|
+
every one cleared by the module's own fixer.
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
pnpm exec eslint <module> -c <module>/eslint.config.js --fix # or: nx run <module>:lint --fix
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Run it, then re-run the suite. Nothing else is expected to change.
|
|
228
|
+
|
|
229
|
+
### 2. Your config files are named `vitest.*` now
|
|
230
|
+
|
|
231
|
+
An ignore written by NAME no longer covers them. This repo's root eslint config ignores
|
|
232
|
+
`**/jest.*.{ts,js,cjs,mjs}`, so `jest.config.ts` and `jest.setup.js` were never linted; their
|
|
233
|
+
replacements are and, across the front family, that is 136 files seen by the linter for the first
|
|
234
|
+
time. Nothing there is wrong, but it is a batch of findings that arrives with a migration that did
|
|
235
|
+
not cause them.
|
|
236
|
+
|
|
237
|
+
The same is true of the secret scanner. A `vitest.config.*` is a NEW file carrying the env values
|
|
238
|
+
`jest.config.*` already had, so `gitleaks` sees them for the first time and refuses the commit:
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
Finding: HERMES_API_KEY: 'HERMESKEY'
|
|
242
|
+
File: libs/cloud/events-notifications/vitest.config.mts:24
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The values are not new, only the file is. This repo's `.gitleaks.toml` now exempts a test runner's
|
|
246
|
+
config, which its `.test.`/`.spec.` siblings already were.
|
|
247
|
+
|
|
248
|
+
### 3. `legacyFakeTimers` is REMOVED, not translated
|
|
249
|
+
|
|
250
|
+
`useFakeTimers({ legacyFakeTimers: true })` selects jest's pre-27 clock. Vitest installs sinon's and
|
|
251
|
+
has nothing else, so no spelling keeps it. The option is dropped and the run SAYS so, because a
|
|
252
|
+
choice that loses something is no longer a transposition. The call then uses the modern clock, which
|
|
253
|
+
is what jest itself defaults to. Re-read any test that depended on the old one.
|
|
254
|
+
|
|
255
|
+
### 4. The typecheck, and the modules where nothing declares one
|
|
256
|
+
|
|
257
|
+
`--run --test` passing says nothing about types, and the migration rewrites types as well as calls.
|
|
258
|
+
Run your module's `typecheck`.
|
|
259
|
+
|
|
260
|
+
⚠️ And if your module declares none, run `tsc -b <module>/tsconfig.json` by hand at least once. Two
|
|
261
|
+
of the three `cloud` modules declare `test` and `lint` and no typecheck target, and the repo's
|
|
262
|
+
commit hook was the only thing that caught 21 type errors between them. Both were defects in this
|
|
263
|
+
package, both are fixed, and neither would have been visible to a declared target or to CI.
|
|
264
|
+
|
|
265
|
+
## What the package ships so your untouched helpers keep compiling
|
|
266
|
+
|
|
267
|
+
A migration rewrites the module's test files and deliberately leaves shared helpers alone, including
|
|
268
|
+
helpers in OTHER projects that your specs import. Two declarations, named in `tsconfig.spec.json`,
|
|
269
|
+
keep those compiling:
|
|
270
|
+
|
|
271
|
+
| | what it answers |
|
|
272
|
+
| ------------------------ | ---------------------------------------------------------------------------------------------------- |
|
|
273
|
+
| `declare const jest` | `jest.fn()` at module scope in a helper, because the setup puts `vi` on `globalThis` under that name |
|
|
274
|
+
| `declare namespace jest` | `jest.Mock` and its seven siblings in TYPE position, which a const cannot answer |
|
|
275
|
+
|
|
276
|
+
The namespace keeps JEST's generic order on `Mock` and `SpyInstance`, where jest's first generic is
|
|
277
|
+
the return type and Vitest's is the whole signature. It serves files still written for jest, so it
|
|
278
|
+
has to read jest's spelling.
|
|
279
|
+
|
|
280
|
+
## One rewrite worth recognising in the diff
|
|
281
|
+
|
|
282
|
+
const provider = require('../providers/x')
|
|
283
|
+
const provider = (await import('../providers/x')) as unknown as Record<string, Mock>
|
|
284
|
+
|
|
285
|
+
`require()` returns `any`, so `provider.getThing.mockResolvedValue({ id: 1 })` compiled. A dynamic
|
|
286
|
+
import returns the module's real namespace, where that method does not exist, and typing the binding
|
|
287
|
+
as `Mocked<typeof import(...)>` is worse: it demands the real return type and turns 20 errors into
|
|
288
|
+
21, because those fixtures were always partial and `any` was hiding it.
|
|
289
|
+
|
|
290
|
+
`Record<string, Mock>` describes what the factory on the line above actually built. It gives up the
|
|
291
|
+
member NAMES, which `any` gave up too. Applied only when the SAME file mocks that specifier.
|
|
292
|
+
|
|
293
|
+
## Going faster than jest ever was, if your module can
|
|
294
|
+
|
|
295
|
+
Vitest gives every test file its own module registry and its own environment, so nothing one file
|
|
296
|
+
does can reach the next. That is what makes a run expensive: the whole import graph is re-evaluated
|
|
297
|
+
once per file. Measured on `libs/front/components`, 230 files and 1418 tests, same machine and
|
|
298
|
+
session:
|
|
299
|
+
|
|
300
|
+
| | wall |
|
|
301
|
+
| ---------------------------------- | ---------- |
|
|
302
|
+
| jest, files in parallel | 15.9 s |
|
|
303
|
+
| vitest, isolated (the default) | 44.6 s |
|
|
304
|
+
| **vitest, files sharing a worker** | **13.0 s** |
|
|
305
|
+
|
|
306
|
+
On a real CI agent, same module: jest 158.7 s, isolated vitest 352.5 s, shared worker **92.7 s**.
|
|
307
|
+
|
|
308
|
+
You ask for it with one line:
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
reactTestConfig({
|
|
312
|
+
root: import.meta.dirname,
|
|
313
|
+
workspaceRoot: path.resolve(import.meta.dirname, '../../..'),
|
|
314
|
+
isolate: false,
|
|
315
|
+
overrides: { ... },
|
|
316
|
+
})
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### What sentinel does so that this is safe
|
|
320
|
+
|
|
321
|
+
Two things travel with the option, and neither is optional.
|
|
322
|
+
|
|
323
|
+
**A file boundary.** A setup file is re-executed for every test file even when the registry is
|
|
324
|
+
shared, so `afterAll` in a setup file IS the end of a file. Sentinel puts one there and gives back,
|
|
325
|
+
per file, what isolation used to reset for free: Testing Library's cleanup (it registers itself at
|
|
326
|
+
IMPORT time, so without this it arms for one file and 342 of 1418 tests fail with
|
|
327
|
+
`Found multiple elements`), an empty `document`, every spy, real timers, and the file's own mock
|
|
328
|
+
register.
|
|
329
|
+
|
|
330
|
+
That last one is worth knowing about. Measured on plain vitest, `vi.resetAllMocks()` DESTROYS a
|
|
331
|
+
mock's implementation while `clearAllMocks` and `restoreAllMocks` keep it. Under jest that could not
|
|
332
|
+
outlive the file. Sharing a worker it reaches every later file, so one `vi.resetAllMocks()` in one
|
|
333
|
+
hook leaves a shared fixture returning `undefined` for the rest of the run. Sentinel scopes those
|
|
334
|
+
three calls back to the file, and never touches what a setup file built.
|
|
335
|
+
|
|
336
|
+
**A split.** `vi.mock` has to be in place before the module it replaces is imported, and a module
|
|
337
|
+
already evaluated cannot be un-evaluated. So the files that declare a module mock keep their
|
|
338
|
+
isolation, in a second vitest project, and the rest share. The list is read from your sources on
|
|
339
|
+
every run, so adding a test never puts it in the wrong project. On `libs/front/components` that is
|
|
340
|
+
13 files of 230.
|
|
341
|
+
|
|
342
|
+
### What you may have to change
|
|
343
|
+
|
|
344
|
+
**One line, if your suite closes msw itself.** `server.close()` disposes the interceptors msw
|
|
345
|
+
installed and leaves a dead registry behind, so the next `listen()` finds it and interception
|
|
346
|
+
silently stops. Its boundary is the worker, not the file. If your setup has
|
|
347
|
+
`afterAll(() => server.close())`, remove it; if you use `@hublo/sentinel/test/setup/msw-lifecycle`,
|
|
348
|
+
this is already handled.
|
|
349
|
+
|
|
350
|
+
**Anything else `--validate` names.** A mutable singleton at module scope in product code, a test
|
|
351
|
+
writing to `globalThis` without cleaning up: these were forgiven by isolation and are not any more.
|
|
352
|
+
|
|
353
|
+
### Nest does not get this, and the refusal says why
|
|
354
|
+
|
|
355
|
+
Measured on `apps/nest/microservices/agency`: 43 s to 40 s, seven percent, where a jsdom module
|
|
356
|
+
goes from 44.6 s to 13 s. A Nest suite's cost is its test bodies; a React suite's is re-evaluating
|
|
357
|
+
its import graph. Against that seven percent, Nest registers its metadata as an import SIDE EFFECT,
|
|
358
|
+
so a shared registry lets one suite see what another registered. The flavour refuses the option
|
|
359
|
+
rather than ignoring it.
|
|
360
|
+
|
|
361
|
+
### The order is the proof, and `--validate` takes it
|
|
362
|
+
|
|
363
|
+
Isolation's whole product is that the order of your files cannot change the result. So when your
|
|
364
|
+
config says `isolate: false`, `--validate` runs the suite a SECOND time in a random file order and
|
|
365
|
+
compares that run against the same reference. One call, both claims: nothing was lost, and nothing
|
|
366
|
+
depends on the order.
|
|
367
|
+
|
|
368
|
+
This is not ceremony. Three runs of an identical configuration once gave three different failure
|
|
369
|
+
lists, because vitest's default sequencer puts previously-failed files first, and each new order
|
|
370
|
+
exposed a different leak. A single green run says very little.
|
|
371
|
+
|
|
372
|
+
```console
|
|
373
|
+
✗ front-components (main) — this suite shares a worker, and it depends on the ORDER of its files:
|
|
374
|
+
in a random order (seed 418293) it no longer matches. ... Reproduce with
|
|
375
|
+
`--sequence.shuffle.files --sequence.seed=418293`. Something a file does outlives it; until that
|
|
376
|
+
is found, the suite is not safe without isolation.
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### Whether it is worth it for your module
|
|
380
|
+
|
|
381
|
+
Ask how many of your test files declare a module mock, since those keep their isolation:
|
|
382
|
+
|
|
383
|
+
```console
|
|
384
|
+
$ rg -l 'vi\.mock\(|vi\.doMock\(' src --glob '*.{test,spec}.{ts,tsx}' | wc -l
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`libs/front/components` has 13 of 230 and gains 3.4x. `apps/front/host-admin` has 603 of 1463, so
|
|
388
|
+
most of its files would keep isolating and most of the cost would stay.
|
|
389
|
+
|
|
134
390
|
## The check that decides whether you are done
|
|
135
391
|
|
|
136
392
|
Not "the suite is green". **The suite runs the same tests as before**. You do not have to arrange
|
|
137
|
-
that comparison;
|
|
393
|
+
that comparison; `--validate` does it, on either side of the migration:
|
|
138
394
|
|
|
139
395
|
```console
|
|
140
|
-
$ sentinel --
|
|
141
|
-
$
|
|
142
|
-
$
|
|
396
|
+
$ sentinel --validate --test # on the JEST state: records every passing test name
|
|
397
|
+
$ sentinel --init --test # migrates
|
|
398
|
+
$ pnpm install # the module's dependencies changed
|
|
399
|
+
$ sentinel --validate --test # runs vitest, compares against that recording, then spends it
|
|
143
400
|
```
|
|
144
401
|
|
|
145
402
|
A codemod touching thousands of files cannot be reviewed by hand. This comparison is the review.
|
|
146
403
|
|
|
404
|
+
⚠️ **The first command comes first, and the tool enforces it.** Start with `--init` and the jest
|
|
405
|
+
config is already gone, so there is nothing left to record and nothing to compare against:
|
|
406
|
+
|
|
407
|
+
```console
|
|
408
|
+
✗ recruitment — already migrated, and no reference was recorded before it was, so there is
|
|
409
|
+
nothing to compare against. A proof has to be started BEFORE the migration:
|
|
410
|
+
`sentinel --validate --test` on the jest state, then `--init --test`, then this again.
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### Why the proof is its own verb
|
|
414
|
+
|
|
415
|
+
Because of what SURVIVES a run. `--init` and `--migrate` write state that stays, which is their
|
|
416
|
+
whole purpose and why they are strict about what they touch. `--validate` may instrument the module
|
|
417
|
+
it is proving, provided nothing it writes remains. Those are different contracts, so they are
|
|
418
|
+
different verbs, and the proof can grow without touching a migration that is already proven.
|
|
419
|
+
|
|
420
|
+
It answers every type, too: `sentinel --validate --lint` is a well-formed command, so it says "not
|
|
421
|
+
available yet" rather than calling your sentence wrong.
|
|
422
|
+
|
|
147
423
|
### Why it cannot be one command
|
|
148
424
|
|
|
149
425
|
The reference only exists BEFORE the migration, and the thing that produces it, the jest config, is
|
|
@@ -163,7 +439,7 @@ shorter.
|
|
|
163
439
|
So the gate refuses a run that lost a name or invented one, and says which:
|
|
164
440
|
|
|
165
441
|
```console
|
|
166
|
-
$ sentinel --
|
|
442
|
+
$ sentinel --validate --test
|
|
167
443
|
1 test(s) no longer exist: useHublerNetworkProfilesQuery builds the URL with employment
|
|
168
444
|
statuses only. (reference: jest.config.ts, 2026-09-23T21:10:00.000Z) The reference is KEPT
|
|
169
445
|
until a run passes it, so this does not go green by running again. Fix the suite and re-run,
|
|
@@ -198,6 +474,7 @@ have read that improvement as a regression.
|
|
|
198
474
|
```console
|
|
199
475
|
$ nx run <module>:typecheck # BEFORE, and write that down too
|
|
200
476
|
$ sentinel --init --test
|
|
477
|
+
$ pnpm install
|
|
201
478
|
$ nx run <module>:typecheck # after
|
|
202
479
|
```
|
|
203
480
|
|
package/docs/using-sentinel.md
CHANGED
|
@@ -29,11 +29,27 @@ everywhere (`--run --json` and `--inspect` answer them), and `--migrate` is refu
|
|
|
29
29
|
| `--typescript` | writes/extends the tsconfig + `typecheck` script | typechecks | resolved options, what is deferred, drift |
|
|
30
30
|
| `--build` | points the module's Vite config at sentinel's toolchain + scripts + nx metadata | builds | runner, config file, whose Vite, how many overrides, adoption |
|
|
31
31
|
| `--dev` | adopts the BUILD too: one plan writes both `build` and `serve` | serves; long-running, so never swept by an unqualified `--run` | refuses, and sends you to `--inspect --build`: it is that config |
|
|
32
|
-
| `--test` |
|
|
32
|
+
| `--test` | the Vitest config + scripts + nx metadata, and migrates every spec file | runs Vitest | runner, which configs were found, adoption state, drift |
|
|
33
33
|
|
|
34
34
|
`--json` works for every verb, and stdout carries **only** the envelope, so `| jq` always
|
|
35
35
|
parses. `--dry-run` applies to `--init` and writes nothing.
|
|
36
36
|
|
|
37
|
+
**`--validate` is a verb of its own**, and the proof of a migration lives there rather than on
|
|
38
|
+
`--init` or `--run`. Run before a migration it records what the module does today; run after, it
|
|
39
|
+
compares by test NAME and refuses a run that lost one or invented one. What separates it from
|
|
40
|
+
`--init` is what SURVIVES the run: `--init` and `--migrate` write state that remains, `--validate`
|
|
41
|
+
may instrument the module it is proving provided nothing it writes stays.
|
|
42
|
+
|
|
43
|
+
```console
|
|
44
|
+
$ sentinel --validate --test # on the jest state: records the reference
|
|
45
|
+
$ sentinel --init --test # migrates
|
|
46
|
+
$ pnpm install
|
|
47
|
+
$ sentinel --validate --test # compares, then spends the reference
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
It composes with every type, and answers "not available yet" for the ones with no proof rather
|
|
51
|
+
than calling a valid command wrong.
|
|
52
|
+
|
|
37
53
|
`--init` is the one verb that targets exactly **one** module. Adopting everything at once
|
|
38
54
|
would be a big-bang; migration is meant to be gradual and per-team.
|
|
39
55
|
|
|
@@ -7,6 +7,7 @@ Everything here was learned the expensive way. None of it is theory.
|
|
|
7
7
|
- [The rule everything follows](#the-rule-everything-follows)
|
|
8
8
|
- [Phase 0: prove the premise](#phase-0-prove-the-premise)
|
|
9
9
|
- [Phase 1: measure the output](#phase-1-measure-the-output)
|
|
10
|
+
- [Measuring a migration, where both sides must be the same program](#measuring-a-migration-where-both-sides-must-be-the-same-program)
|
|
10
11
|
- [Traps that report success](#traps-that-report-success)
|
|
11
12
|
- [What CI does not cover](#what-ci-does-not-cover)
|
|
12
13
|
|
|
@@ -69,6 +70,34 @@ rm -rf apps/front/<x>/.output && nx run <x>:build # cache hit: same count re
|
|
|
69
70
|
`console` once restored **0 of 464** files from a green cache hit. Its image runs `pnpm` rather
|
|
70
71
|
than nx, so the one caller that would have failed never used the cache.
|
|
71
72
|
|
|
73
|
+
## Measuring a migration, where both sides must be the same program
|
|
74
|
+
|
|
75
|
+
A migration is proved by comparing a suite BEFORE against the same suite AFTER. That comparison is
|
|
76
|
+
only worth something if the two runs differ in the one variable being changed. Three ways they
|
|
77
|
+
silently differed here, each one costing hours of attributing a real-looking red to the wrong
|
|
78
|
+
cause.
|
|
79
|
+
|
|
80
|
+
**Run the module's OWN installed binary.** Invoking a local build by path (`node
|
|
81
|
+
/path/to/sentinel/dist/bin/sentinel.js`) resolves the runner through SENTINEL's manifest, so the
|
|
82
|
+
suite runs against the sentinel repo's physical install of vitest rather than the monorepo's. Two
|
|
83
|
+
red modules in one campaign were that and nothing else. A campaign runs `npx
|
|
84
|
+
@hublo/sentinel@<version>` or the module's own script, never a path.
|
|
85
|
+
|
|
86
|
+
**Run from the workspace ROOT.** The adapter relocates vitest to the root with `--root <module>`,
|
|
87
|
+
because a module's config resolves workspace aliases from there. Running the same command from
|
|
88
|
+
inside the module changes what resolves, and the failure looks like the migration's fault.
|
|
89
|
+
|
|
90
|
+
**Let the module's environment in, deliberately.** nx loads the repo's dotenv files into a task:
|
|
91
|
+
314 environment names under nx against 49 direct. One of them, set in a module's own `.env.local`,
|
|
92
|
+
made a single test fail, and the reference recorded without it reported a name that "disappeared".
|
|
93
|
+
This is the one place where the usual instinct, isolate, is WRONG: the two sides of a comparison
|
|
94
|
+
have to be the same program, so the proof loads the same files the module's own target loads.
|
|
95
|
+
|
|
96
|
+
**A name can leave both lists without failing.** Vitest reports the tests of a suite whose hook
|
|
97
|
+
threw as `skipped`, where jest counted them FAILED. The count moves, no test is red, and the
|
|
98
|
+
comparison must say "missing because skipped" rather than "lost", or it accuses the migration of
|
|
99
|
+
something the runner did.
|
|
100
|
+
|
|
72
101
|
## Traps that report success
|
|
73
102
|
|
|
74
103
|
| Trap | What it looks like | What closes it |
|
|
@@ -79,9 +108,13 @@ than nx, so the one caller that would have failed never used the cache.
|
|
|
79
108
|
| A clock reading vs an mtime | passes locally, fails in CI | stamp from the same filesystem |
|
|
80
109
|
| A stale cache entry | a hit that restores nothing | `nx reset`; entries predating a module's `outputs` restore nothing forever |
|
|
81
110
|
| `"$var:generate-x"` in zsh | a task name that does not exist | brace it: `"${var}:generate-x"` — `:g` is a zsh modifier |
|
|
111
|
+
| `rc=$?` after a pipeline | the status of `tail`, not yours | capture before piping, or `set -o pipefail` |
|
|
112
|
+
| `timeout` on macOS | an empty run read as green | it does not exist there; the command never ran |
|
|
113
|
+
| A local build by path | a red that is not the module's | resolves the runner through SENTINEL's install |
|
|
82
114
|
|
|
83
|
-
|
|
84
|
-
|
|
115
|
+
`rc=$?` after a pipeline caught us three times in one day, and once produced a claim we had
|
|
116
|
+
already announced. The zsh one is the only entry that lies toward FAILURE, which makes it visible
|
|
117
|
+
but sends you hunting for causes elsewhere.
|
|
85
118
|
|
|
86
119
|
## What CI does not cover
|
|
87
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hublo/sentinel",
|
|
3
|
-
"version": "1.4.0
|
|
3
|
+
"version": "1.4.0",
|
|
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",
|
|
@@ -58,6 +58,14 @@
|
|
|
58
58
|
"types": "./dist/roles/test/setup/jest-parity.d.ts",
|
|
59
59
|
"import": "./dist/roles/test/setup/jest-parity.js"
|
|
60
60
|
},
|
|
61
|
+
"./test/setup/file-boundary": {
|
|
62
|
+
"types": "./dist/roles/test/setup/file-boundary.d.ts",
|
|
63
|
+
"import": "./dist/roles/test/setup/file-boundary.js"
|
|
64
|
+
},
|
|
65
|
+
"./test/setup/file-boundary-close": {
|
|
66
|
+
"types": "./dist/roles/test/setup/file-boundary-close.d.ts",
|
|
67
|
+
"import": "./dist/roles/test/setup/file-boundary-close.js"
|
|
68
|
+
},
|
|
61
69
|
"./test/msw": {
|
|
62
70
|
"types": "./dist/roles/test/setup/msw-server.d.ts",
|
|
63
71
|
"import": "./dist/roles/test/setup/msw-server.js"
|
|
@@ -66,6 +74,10 @@
|
|
|
66
74
|
"types": "./dist/roles/test/tools/msw.d.ts",
|
|
67
75
|
"import": "./dist/roles/test/tools/msw.js"
|
|
68
76
|
},
|
|
77
|
+
"./test/tools/mock-extended": {
|
|
78
|
+
"types": "./dist/roles/test/setup/mock-extended.d.ts",
|
|
79
|
+
"import": "./dist/roles/test/setup/mock-extended.js"
|
|
80
|
+
},
|
|
69
81
|
"./test/setup/msw-lifecycle": {
|
|
70
82
|
"types": "./dist/roles/test/setup/msw-lifecycle.d.ts",
|
|
71
83
|
"import": "./dist/roles/test/setup/msw-lifecycle.js"
|
|
@@ -73,10 +85,19 @@
|
|
|
73
85
|
"./test/setup/workspace": {
|
|
74
86
|
"types": "./dist/roles/test/setup/workspace-entry.d.ts",
|
|
75
87
|
"import": "./dist/roles/test/setup/workspace-entry.js"
|
|
88
|
+
},
|
|
89
|
+
"./test/setup/a11y": {
|
|
90
|
+
"types": "./dist/roles/test/setup/a11y.d.ts",
|
|
91
|
+
"import": "./dist/roles/test/setup/a11y.js"
|
|
92
|
+
},
|
|
93
|
+
"./test/setup/w3c": {
|
|
94
|
+
"types": "./dist/roles/test/setup/w3c.d.ts",
|
|
95
|
+
"import": "./dist/roles/test/setup/w3c.js"
|
|
76
96
|
}
|
|
77
97
|
},
|
|
78
98
|
"files": [
|
|
79
99
|
"dist",
|
|
100
|
+
"types",
|
|
80
101
|
"docs",
|
|
81
102
|
"lint",
|
|
82
103
|
"oxlint",
|
|
@@ -87,6 +108,7 @@
|
|
|
87
108
|
"@tailwindcss/vite": "4.1.18",
|
|
88
109
|
"@vitejs/plugin-react": "6.0.1",
|
|
89
110
|
"@vitest/coverage-v8": "4.1.4",
|
|
111
|
+
"axe-core": "^4.13.0",
|
|
90
112
|
"commander": "^13.0.0",
|
|
91
113
|
"dotenv-flow": "4.1.0",
|
|
92
114
|
"eslint-plugin-jest-dom": "5.5.0",
|
|
@@ -94,6 +116,7 @@
|
|
|
94
116
|
"eslint-plugin-react-refresh": "0.4.19",
|
|
95
117
|
"eslint-plugin-storybook": "10.3.5",
|
|
96
118
|
"eslint-plugin-styled-components-a11y": "2.2.0",
|
|
119
|
+
"html-validate": "^11.16.0",
|
|
97
120
|
"jsdom": "^24.1.3",
|
|
98
121
|
"jsonc-parser": "^3.3.1",
|
|
99
122
|
"msw": "1.3.3",
|
|
@@ -102,6 +125,7 @@
|
|
|
102
125
|
"oxfmt": "0.63.0",
|
|
103
126
|
"oxlint": "1.77.0",
|
|
104
127
|
"oxlint-tsgolint": "7.0.2001",
|
|
128
|
+
"tinyglobby": "0.2.17",
|
|
105
129
|
"typescript": "5.9.3",
|
|
106
130
|
"vite": "8.2.2",
|
|
107
131
|
"vite-plugin-svgr": "5.2.0",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The type-level half of the `jest` global the setup installs.
|
|
3
|
+
*
|
|
4
|
+
* ## What it pairs with
|
|
5
|
+
*
|
|
6
|
+
* The migration rewrites SPEC files and deliberately leaves helpers alone, because the setup puts
|
|
7
|
+
* Vitest's `vi` on `globalThis` under the name `jest`:
|
|
8
|
+
*
|
|
9
|
+
* // setup/jest-global.ts
|
|
10
|
+
* ;(globalThis as Record<string, unknown>).jest = vi
|
|
11
|
+
*
|
|
12
|
+
* That is what lets a module migrate on its own while a shared helper still calls `jest.fn()`, and
|
|
13
|
+
* it is why the suite of a migrated module is green with untouched helpers.
|
|
14
|
+
*
|
|
15
|
+
* The shim answers the RUNTIME. It says nothing to TypeScript, and the migration also replaces
|
|
16
|
+
* `"jest"` with `"vitest/globals"` in the module's `tsconfig.spec.json`, so the name stops being a
|
|
17
|
+
* value at compile time while it still is one at run time.
|
|
18
|
+
*
|
|
19
|
+
* Measured on `apps/nest/microservices/institution` with alpha.14, a module whose typecheck was
|
|
20
|
+
* green before it migrated and whose 2958 tests all pass after:
|
|
21
|
+
*
|
|
22
|
+
* src/app/institution.test-wrapper.ts(83,22)
|
|
23
|
+
* error TS2708: Cannot use namespace 'jest' as a value.
|
|
24
|
+
* tests/support/institution-prisma.test-wrapper.ts(58,22)
|
|
25
|
+
* error TS2708: Cannot use namespace 'jest' as a value.
|
|
26
|
+
*
|
|
27
|
+
* Both are helpers, neither is a spec, and both run correctly. This file states, for the type
|
|
28
|
+
* checker, exactly what the setup states for the runtime.
|
|
29
|
+
*
|
|
30
|
+
* ## Why `typeof vi` and not a hand-written subset
|
|
31
|
+
*
|
|
32
|
+
* The shim assigns the whole `vi`, so anything narrower would describe something the module does
|
|
33
|
+
* not run. Writing the truth costs one line and cannot drift.
|
|
34
|
+
*
|
|
35
|
+
* ## In a program that still carries `@types/jest`
|
|
36
|
+
*
|
|
37
|
+
* No conflict, and this one wins: measured on a fixture holding both, `jest.hoisted(() => 1)`
|
|
38
|
+
* compiles, and `hoisted` exists only on `vi`. So a module part-way through its migration gets the
|
|
39
|
+
* type of what actually executes rather than the type of the runner it is leaving.
|
|
40
|
+
*/
|
|
41
|
+
declare const jest: (typeof import('vitest'))['vi']
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The same answer for TYPE positions, which the const above does not reach.
|
|
45
|
+
*
|
|
46
|
+
* `declare const jest` makes `jest.fn()` compile and says nothing about `jest.Mock`, because a
|
|
47
|
+
* const has no type members. A helper that annotates with one gets:
|
|
48
|
+
*
|
|
49
|
+
* libs/front/tests/src/mocks/libs/analytics.mock.ts(2,22)
|
|
50
|
+
* error TS2694: Namespace 'jest' has no exported member 'Mock'.
|
|
51
|
+
*
|
|
52
|
+
* Measured on `libs/front/components`, whose typecheck was green before it migrated: 8 of these,
|
|
53
|
+
* every one of them in `libs/front/tests`, a project this module reads and does not own. Repo-wide
|
|
54
|
+
* the whole vocabulary is eight names, and here is each one's Vitest counterpart.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ `Mock` and `SpyInstance` take JEST's generics here, not Vitest's, and the difference is not
|
|
57
|
+
* cosmetic: jest's first generic is the RETURN type, Vitest's is the whole SIGNATURE. These
|
|
58
|
+
* declarations serve files that are still written for jest, so they must read jest's spelling;
|
|
59
|
+
* `codemod/spy-instance-type.ts` is the other half, and it converts the spelling in the files the
|
|
60
|
+
* migration does rewrite.
|
|
61
|
+
*
|
|
62
|
+
* A type-only namespace declares no value, which is why it merges with the const above instead of
|
|
63
|
+
* colliding with it.
|
|
64
|
+
*
|
|
65
|
+
* ## Alongside a program that still carries `@types/jest`
|
|
66
|
+
*
|
|
67
|
+
* No collision either, and it was measured rather than assumed: a fixture with
|
|
68
|
+
* `types: ["jest", "vitest/globals"]` plus this file compiles clean, and the same fixture WITHOUT
|
|
69
|
+
* this file also compiles, which is what proves `@types/jest` was really loaded. Declaration
|
|
70
|
+
* merging applies to the namespace the way it already applies to the const, so a module part-way
|
|
71
|
+
* through its migration is not broken by carrying both.
|
|
72
|
+
*/
|
|
73
|
+
declare namespace jest {
|
|
74
|
+
type Mock<T = any, Y extends any[] = any[]> = import('vitest').Mock<(...args: Y) => T>
|
|
75
|
+
type Mocked<T> = import('vitest').Mocked<T>
|
|
76
|
+
type MockedObject<T> = import('vitest').MockedObject<T>
|
|
77
|
+
type MockedClass<T extends abstract new (...args: any[]) => any> = import('vitest').MockedClass<T>
|
|
78
|
+
type MockedFunction<T extends (...args: any[]) => any> = import('vitest').MockedFunction<T>
|
|
79
|
+
/** jest's own alias for `MockedFunction`. */
|
|
80
|
+
type MockedFn<T extends (...args: any[]) => any> = import('vitest').MockedFunction<T>
|
|
81
|
+
type SpyInstance<T = any, Y extends any[] = any[]> = import('vitest').MockInstance<
|
|
82
|
+
(...args: Y) => T
|
|
83
|
+
>
|
|
84
|
+
type SpiedFunction<T extends (...args: any[]) => any> = import('vitest').MockInstance<T>
|
|
85
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The type-level half of the `jest-mock-extended` alias.
|
|
3
|
+
*
|
|
4
|
+
* The generated Vitest config aliases `jest-mock-extended` to the fork sentinel ships, so at RUN
|
|
5
|
+
* time a migrated module has ONE copy. TypeScript does not read Vite's aliases, so it keeps
|
|
6
|
+
* resolving the jest package and a migrated spec ends up compiling two worlds at once:
|
|
7
|
+
*
|
|
8
|
+
* the value typeof mockDeep<Db>() -> CalledWithMock<…> extends jest.Mock
|
|
9
|
+
* the spec let spy: MockInstance -> vitest's own type
|
|
10
|
+
* TS2322 not assignable
|
|
11
|
+
*
|
|
12
|
+
* Measured on `apps/nest/microservices/agency`: one error, on a module whose typecheck was clean
|
|
13
|
+
* before it migrated. 34 files under `libs/` export a type built this way.
|
|
14
|
+
*
|
|
15
|
+
* ## Why it re-exports a SUBPATH of sentinel rather than `vitest-mock-extended`
|
|
16
|
+
*
|
|
17
|
+
* Because a bare `vitest-mock-extended` does not resolve from here, which was measured with
|
|
18
|
+
* `--traceResolution` after the first attempt emptied the module out:
|
|
19
|
+
*
|
|
20
|
+
* Resolving module 'vitest-mock-extended' from '…/agency/node_modules/@hublo/sentinel/types/…'
|
|
21
|
+
* Module name 'vitest-mock-extended' was not resolved.
|
|
22
|
+
*
|
|
23
|
+
* The module's `tsconfig.spec.json` names this file by PATH, so TypeScript keeps the symlink path
|
|
24
|
+
* pnpm installed and walks up from there: `…/sentinel/node_modules`, then the module's, then the
|
|
25
|
+
* workspace's. Sentinel's own dependency sits in the store beside its real directory, and none of
|
|
26
|
+
* those three is it. A file reached by RESOLUTION is realpathed into the store instead, and its
|
|
27
|
+
* dependencies are found: same trace, `@hublo/sentinel/test/tools/msw` → `msw` resolved.
|
|
28
|
+
*
|
|
29
|
+
* `skipLibCheck` is on in every module here, so the failed `export *` was silent and what surfaced
|
|
30
|
+
* was an empty module: 18 × `Module '"jest-mock-extended"' has no exported member 'mockDeep'`.
|
|
31
|
+
*
|
|
32
|
+
* ## Why THIS subpath
|
|
33
|
+
*
|
|
34
|
+
* `@hublo/sentinel/test/tools/mock-extended` is the very module the runtime alias points at, so the
|
|
35
|
+
* types a spec compiles against are the ones it runs against, down to the lifecycle helpers the
|
|
36
|
+
* fork adds. Re-exporting `vitest-mock-extended` directly would describe something adjacent to what
|
|
37
|
+
* executes.
|
|
38
|
+
*
|
|
39
|
+
* ## Why it does not couple modules to each other
|
|
40
|
+
*
|
|
41
|
+
* An ambient declaration applies to the PROGRAM that includes it, and nothing else. A module that
|
|
42
|
+
* includes it compiles against the Vitest types; a module still on jest, consuming the very same
|
|
43
|
+
* shared helper, keeps compiling against the jest ones. Proved on an isolated fixture: both
|
|
44
|
+
* programs green, over one unmodified shared lib.
|
|
45
|
+
*
|
|
46
|
+
* That is the whole point. Héla, 2026-09-24, on tying a module's migration to a library's:
|
|
47
|
+
* "ça crée des couplages et bloquent les migrations". lint, format and typescript each migrate one
|
|
48
|
+
* module at a time; this keeps the test role the same.
|
|
49
|
+
*/
|
|
50
|
+
declare module 'jest-mock-extended' {
|
|
51
|
+
export * from '@hublo/sentinel/test/tools/mock-extended'
|
|
52
|
+
}
|