@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.
Files changed (65) hide show
  1. package/README.md +1 -0
  2. package/dist/bin/sentinel.js +45 -101
  3. package/dist/{chunk-7PUVK4YM.js → chunk-5VNYQIFD.js} +224 -13
  4. package/dist/chunk-5VNYQIFD.js.map +1 -0
  5. package/dist/chunk-BXXNV6NP.js +6411 -0
  6. package/dist/chunk-ELZHIN6E.js +16 -0
  7. package/dist/chunk-ELZHIN6E.js.map +1 -0
  8. package/dist/chunk-H3GSGAX2.js +11 -0
  9. package/dist/chunk-H3GSGAX2.js.map +1 -0
  10. package/dist/{chunk-WLFE5RUU.js → chunk-KMKQDGI6.js} +6 -8
  11. package/dist/chunk-KMKQDGI6.js.map +1 -0
  12. package/dist/{chunk-PWV3BMDA.js → chunk-MMKBDO5V.js} +11 -2
  13. package/dist/chunk-MMKBDO5V.js.map +1 -0
  14. package/dist/{chunk-L7WS36XV.js → chunk-MWNOFSYR.js} +221 -4427
  15. package/dist/chunk-NUOQXAYR.js +14 -0
  16. package/dist/chunk-NUOQXAYR.js.map +1 -0
  17. package/dist/chunk-O7REVMOC.js +38 -0
  18. package/dist/chunk-O7REVMOC.js.map +1 -0
  19. package/dist/chunk-QXFCZON7.js +16 -0
  20. package/dist/chunk-QXFCZON7.js.map +1 -0
  21. package/dist/{chunk-3TDUIKVQ.js → chunk-SWWQ7X7B.js} +11 -8
  22. package/dist/{chunk-3TDUIKVQ.js.map → chunk-SWWQ7X7B.js.map} +1 -1
  23. package/dist/{chunk-CPCUPK4J.js → chunk-Z7L4FGKP.js} +5 -12
  24. package/dist/chunk-Z7L4FGKP.js.map +1 -0
  25. package/dist/index.d.ts +28 -5
  26. package/dist/index.js +7 -3
  27. package/dist/roles/build/nest/toolchain.js +11 -33
  28. package/dist/roles/build/nest/toolchain.js.map +1 -1
  29. package/dist/roles/test/nest/toolchain.js +3 -2
  30. package/dist/roles/test/nest/toolchain.js.map +1 -1
  31. package/dist/roles/test/react/toolchain.js +3 -2
  32. package/dist/roles/test/react/toolchain.js.map +1 -1
  33. package/dist/roles/test/setup/a11y.d.ts +45 -0
  34. package/dist/roles/test/setup/a11y.js +72 -0
  35. package/dist/roles/test/setup/a11y.js.map +1 -0
  36. package/dist/roles/test/setup/file-boundary-close.d.ts +2 -0
  37. package/dist/roles/test/setup/file-boundary-close.js +8 -0
  38. package/dist/roles/test/setup/file-boundary-close.js.map +1 -0
  39. package/dist/roles/test/setup/file-boundary.d.ts +2 -0
  40. package/dist/roles/test/setup/file-boundary.js +62 -0
  41. package/dist/roles/test/setup/file-boundary.js.map +1 -0
  42. package/dist/roles/test/setup/jest-parity.js +19 -2
  43. package/dist/roles/test/setup/jest-parity.js.map +1 -1
  44. package/dist/roles/test/setup/msw-lifecycle.js +2 -1
  45. package/dist/roles/test/setup/msw-lifecycle.js.map +1 -1
  46. package/dist/roles/test/setup/msw-server.js +2 -1
  47. package/dist/roles/test/setup/msw-server.js.map +1 -1
  48. package/dist/roles/test/setup/w3c.d.ts +75 -0
  49. package/dist/roles/test/setup/w3c.js +49 -0
  50. package/dist/roles/test/setup/w3c.js.map +1 -0
  51. package/dist/roles/test/setup/workspace-entry.js +3 -2
  52. package/dist/roles/test/setup/workspace-entry.js.map +1 -1
  53. package/dist/roles/test/shared-test-config.d.ts +13 -0
  54. package/dist/roles/test/shared-test-config.js +3 -2
  55. package/dist/validate-BKD2ICT7.js +170 -0
  56. package/docs/test-adoption.md +282 -5
  57. package/docs/using-sentinel.md +17 -1
  58. package/docs/validating-a-change.md +35 -2
  59. package/package.json +25 -1
  60. package/types/jest-global.d.ts +85 -0
  61. package/types/mock-extended.d.ts +52 -0
  62. package/dist/chunk-7PUVK4YM.js.map +0 -1
  63. package/dist/chunk-CPCUPK4J.js.map +0 -1
  64. package/dist/chunk-PWV3BMDA.js.map +0 -1
  65. package/dist/chunk-WLFE5RUU.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/roles/test/setup/msw-server.ts"],"sourcesContent":["/**\n * The msw server a migrated suite shares with its setup.\n *\n * ## Why this entry exists at all\n *\n * The setup beside it starts a server and the tests add handlers to one. If those are two different\n * instances, the one that listens has no handlers and every request is unhandled, which under\n * `onUnhandledRequest: 'error'` fails every test that touches HTTP.\n *\n * That is not hypothetical. Migrating `apps/cloud/agency` and running its suite produced exactly\n * it: 22 green tests under jest became 10 failures, with `[MSW] Cannot bypass a request when using\n * the \"error\" strategy`. The setup had been absorbed into sentinel while the 599 files that import\n * the server still pointed at the workspace's own instance.\n *\n * So the server is exported under its own name, and the codemod repoints those imports here. One\n * instance, listened to by the setup and used by the tests.\n */\nexport { server } from './msw.js'\n\n/**\n * msw's own API, re-exported, so a module gets its handlers from the SAME physical copy that\n * intercepts.\n *\n * sentinel ships msw as a dependency on purpose: a module must stand alone, and the workspace root\n * that used to provide it is going away. But a test that keeps `import { rest } from 'msw'` builds\n * its handlers with the WORKSPACE's copy while the server that listens is built with sentinel's.\n * Same version, two physical instances, one interceptor: the handler is never matched.\n *\n * Measured on `libs/cloud/shared`: the msw handler is never called and the test fails on\n * `expected \"vi.fn()\" to be called 1 times, but got 0 times`. Same family as the axios duplicate,\n * where `resolve.dedupe` changed nothing and only pointing at one physical path did.\n *\n * `export *` rather than a list: `rest` covers 669 of the 671 importing files here, but the\n * surface a test may need (`graphql`, `ctx`, the handler types) belongs to msw, not to a list\n * sentinel would have to keep in step. `setupServer` is not part of it: it lives in `msw/node`,\n * and the server is sentinel's to create.\n */\nexport * from 'msw'\n"],"mappings":";;;;;AAqCA,cAAc;","names":[]}
1
+ {"version":3,"sources":["../../../../src/roles/test/setup/msw-server.ts"],"sourcesContent":["/**\n * The msw server a migrated suite shares with its setup.\n *\n * ## Why this entry exists at all\n *\n * The setup beside it starts a server and the tests add handlers to one. If those are two different\n * instances, the one that listens has no handlers and every request is unhandled, which under\n * `onUnhandledRequest: 'error'` fails every test that touches HTTP.\n *\n * That is not hypothetical. Migrating `apps/cloud/agency` and running its suite produced exactly\n * it: 22 green tests under jest became 10 failures, with `[MSW] Cannot bypass a request when using\n * the \"error\" strategy`. The setup had been absorbed into sentinel while the 599 files that import\n * the server still pointed at the workspace's own instance.\n *\n * So the server is exported under its own name, and the codemod repoints those imports here. One\n * instance, listened to by the setup and used by the tests.\n */\nexport { server } from './msw.js'\n\n/**\n * msw's own API, re-exported, so a module gets its handlers from the SAME physical copy that\n * intercepts.\n *\n * sentinel ships msw as a dependency on purpose: a module must stand alone, and the workspace root\n * that used to provide it is going away. But a test that keeps `import { rest } from 'msw'` builds\n * its handlers with the WORKSPACE's copy while the server that listens is built with sentinel's.\n * Same version, two physical instances, one interceptor: the handler is never matched.\n *\n * Measured on `libs/cloud/shared`: the msw handler is never called and the test fails on\n * `expected \"vi.fn()\" to be called 1 times, but got 0 times`. Same family as the axios duplicate,\n * where `resolve.dedupe` changed nothing and only pointing at one physical path did.\n *\n * `export *` rather than a list: `rest` covers 669 of the 671 importing files here, but the\n * surface a test may need (`graphql`, `ctx`, the handler types) belongs to msw, not to a list\n * sentinel would have to keep in step. `setupServer` is not part of it: it lives in `msw/node`,\n * and the server is sentinel's to create.\n */\nexport * from 'msw'\n"],"mappings":";;;;;;AAqCA,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":[]}
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  pinWorkspaceTimezone
3
- } from "../../../chunk-CPCUPK4J.js";
4
- import "../../../chunk-WLFE5RUU.js";
3
+ } from "../../../chunk-Z7L4FGKP.js";
4
+ import "../../../chunk-ELZHIN6E.js";
5
+ import "../../../chunk-KMKQDGI6.js";
5
6
 
6
7
  // src/roles/test/setup/workspace-entry.ts
7
8
  await pinWorkspaceTimezone();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/roles/test/setup/workspace-entry.ts"],"sourcesContent":["/**\n * What the WORKSPACE's shared jest setup did, for the modules that actually loaded it.\n *\n * Separate from `setup/jest-parity` on purpose, and the split is the whole point. `setup/jest-parity` carries the\n * runner shims: `jest.*` semantics restored under Vitest, which every migrated module needs whatever\n * its config said. This entry carries the ZONE that `jest.setup.after.env.js` pinned at the workspace root, which\n * only a module whose jest config NAMED that file ever had:\n *\n * require('dotenv-flow').config(...) the .env cascade\n * Settings.defaultZone = 'utc' luxon's default zone\n *\n * Measured on this repo: 97 modules name the root setup, 7 name a setup of their own instead. For\n * those 7 the pin was never applied, and applying it in the migration changes what the suite does\n * while claiming to move it.\n *\n * `libs/front/logic` is the one that said so out loud. It has its own setup, uses luxon, and its\n * test is literally called \"formats them in the local zone\":\n *\n * expect(formatTimeOfDay(new Date(2024, 2, 1, 8, 5))).toBe('08:05')\n * // pinned to utc: '07:05'\n *\n * One test, and it would have been one silent hour of offset in any suite that did not assert it.\n */\nimport { pinWorkspaceTimezone } from './workspace.js'\n\n/*\n * Awaited at the top level, so the environment is loaded before the first test file is imported.\n * Deferring it to a `beforeAll` would be too late: a module read at import time would already have\n * captured an unset variable.\n */\nawait pinWorkspaceTimezone()\n"],"mappings":";;;;;;AA8BA,MAAM,qBAAqB;","names":[]}
1
+ {"version":3,"sources":["../../../../src/roles/test/setup/workspace-entry.ts"],"sourcesContent":["/**\n * What the WORKSPACE's shared jest setup did, for the modules that actually loaded it.\n *\n * Separate from `setup/jest-parity` on purpose, and the split is the whole point. `setup/jest-parity` carries the\n * runner shims: `jest.*` semantics restored under Vitest, which every migrated module needs whatever\n * its config said. This entry carries the ZONE that `jest.setup.after.env.js` pinned at the workspace root, which\n * only a module whose jest config NAMED that file ever had:\n *\n * require('dotenv-flow').config(...) the .env cascade\n * Settings.defaultZone = 'utc' luxon's default zone\n *\n * Measured on this repo: 97 modules name the root setup, 7 name a setup of their own instead. For\n * those 7 the pin was never applied, and applying it in the migration changes what the suite does\n * while claiming to move it.\n *\n * `libs/front/logic` is the one that said so out loud. It has its own setup, uses luxon, and its\n * test is literally called \"formats them in the local zone\":\n *\n * expect(formatTimeOfDay(new Date(2024, 2, 1, 8, 5))).toBe('08:05')\n * // pinned to utc: '07:05'\n *\n * One test, and it would have been one silent hour of offset in any suite that did not assert it.\n */\nimport { pinWorkspaceTimezone } from './workspace.js'\n\n/*\n * Awaited at the top level, so the environment is loaded before the first test file is imported.\n * Deferring it to a `beforeAll` would be too late: a module read at import time would already have\n * captured an unset variable.\n */\nawait pinWorkspaceTimezone()\n"],"mappings":";;;;;;;AA8BA,MAAM,qBAAqB;","names":[]}
@@ -21,6 +21,19 @@ interface SharedTestOptions {
21
21
  * plugin for all of them took the suite from 98s to 249s.
22
22
  */
23
23
  lowerDecoratorsWithTypeScript?: boolean;
24
+ /**
25
+ * Let the files of this suite share a worker instead of each getting its own registry.
26
+ *
27
+ * Off by default, which is Vitest's own default and jest's semantics. Turning it off buys the
28
+ * whole of `./setup/file-boundary.js` and `./isolation-split.js`: measured on
29
+ * `front-components`, 44.6s isolated against 11.0s split, with all 1418 tests green and the
30
+ * same result across ten shuffled file orders.
31
+ *
32
+ * A module takes it deliberately, and proves it with `--validate`, because the switch is the one
33
+ * thing in this preset that changes what a suite MEANS: a file can see what an earlier file left
34
+ * behind, and only running the suite can say whether any of them does.
35
+ */
36
+ isolate?: boolean;
24
37
  /** Merged over the base. For what a module genuinely needs to differ on, nothing else. */
25
38
  overrides?: ViteUserConfig;
26
39
  }
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  sharedTestConfig
3
- } from "../../chunk-7PUVK4YM.js";
4
- import "../../chunk-3TDUIKVQ.js";
3
+ } from "../../chunk-5VNYQIFD.js";
4
+ import "../../chunk-SWWQ7X7B.js";
5
+ import "../../chunk-QXFCZON7.js";
5
6
  export {
6
7
  sharedTestConfig
7
8
  };
@@ -0,0 +1,170 @@
1
+ import {
2
+ captureReference,
3
+ compareAgainstReference,
4
+ fromWorkspaceRoot,
5
+ hasReference,
6
+ moduleEnvironment,
7
+ readTestAdoption,
8
+ resolveVitest,
9
+ suiteOf
10
+ } from "./chunk-BXXNV6NP.js";
11
+ import "./chunk-O7REVMOC.js";
12
+ import "./chunk-QXFCZON7.js";
13
+ import "./chunk-KMKQDGI6.js";
14
+
15
+ // src/roles/test/validate.ts
16
+ import { execFileSync } from "node:child_process";
17
+ import { existsSync, mkdtempSync, readFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ function runVitest(cwd, config, extra = []) {
21
+ const { bin } = resolveVitest(cwd);
22
+ if (bin === void 0) return void 0;
23
+ const relocated = fromWorkspaceRoot(cwd, ["--config", config], []);
24
+ const out = join(mkdtempSync(join(tmpdir(), "sentinel-validate-")), "vitest.json");
25
+ try {
26
+ execFileSync(
27
+ bin,
28
+ ["run", ...relocated.options, ...extra, "--reporter=json", `--outputFile=${out}`],
29
+ {
30
+ cwd: relocated.cwd,
31
+ // The SAME environment the reference was taken in, or the two sides are two programs.
32
+ // `relocated.env` last: moving the process is part of the run, not part of the module.
33
+ env: { ...moduleEnvironment(cwd).env, ...relocated.env },
34
+ stdio: ["ignore", "ignore", "ignore"],
35
+ maxBuffer: 1 << 28
36
+ }
37
+ );
38
+ } catch {
39
+ }
40
+ if (!existsSync(out)) return void 0;
41
+ try {
42
+ return JSON.parse(readFileSync(out, "utf8"));
43
+ } catch {
44
+ return void 0;
45
+ }
46
+ }
47
+ function vitestConfigFor(cwd, suite) {
48
+ const stems = suite === "main" ? ["vitest.config"] : [`vitest.${suite}.config`, `vitest.${suite}-config`];
49
+ for (const stem of stems) {
50
+ for (const extension of [".mts", ".ts", ".mjs", ".js"]) {
51
+ if (existsSync(join(cwd, `${stem}${extension}`))) return `${stem}${extension}`;
52
+ }
53
+ }
54
+ return void 0;
55
+ }
56
+ function sharesAWorker(cwd, config) {
57
+ try {
58
+ return /\bisolate\s*:\s*false\b/.test(readFileSync(join(cwd, config), "utf8"));
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+ function shuffledPassMatches(root, suite, config) {
64
+ const seed = Math.floor(Math.random() * 1e6);
65
+ const report = runVitest(root, config, ["--sequence.shuffle.files", `--sequence.seed=${seed}`]);
66
+ if (report === void 0) {
67
+ return {
68
+ ok: false,
69
+ message: `the shuffled pass (seed ${seed}) produced no report, so order independence is unproven.`
70
+ };
71
+ }
72
+ const comparison = compareAgainstReference(root, suite, report);
73
+ if (comparison.ok) {
74
+ return { ok: true, message: `and again in a random file order (seed ${seed}).` };
75
+ }
76
+ return {
77
+ ok: false,
78
+ message: `this suite shares a worker, and it depends on the ORDER of its files: in a random order (seed ${seed}) it no longer matches. ${comparison.message} Reproduce with \`--sequence.shuffle.files --sequence.seed=${seed}\`. Something a file does outlives it; until that is found, the suite is not safe without isolation.`
79
+ };
80
+ }
81
+ var KNOWN_SUITES = ["main", "prisma", "integration", "functional"];
82
+ function suitesOf(cwd, adoption) {
83
+ const declared = adoption.state === "jest" ? adoption.jestConfigs.map(suiteOf) : KNOWN_SUITES.filter((suite) => vitestConfigFor(cwd, suite) !== void 0);
84
+ const recorded = KNOWN_SUITES.filter((suite) => hasReference(cwd, suite));
85
+ return [.../* @__PURE__ */ new Set([...declared, ...recorded])];
86
+ }
87
+ function validateModuleTests(name, root) {
88
+ const adoption = readTestAdoption(root);
89
+ if (adoption.state === "no-tests") {
90
+ return [
91
+ { module: name, suite: "main", ok: true, message: `no tests, so there is nothing to prove.` }
92
+ ];
93
+ }
94
+ const suites = suitesOf(root, adoption);
95
+ if (suites.length === 0) {
96
+ return [
97
+ {
98
+ module: name,
99
+ suite: "main",
100
+ ok: false,
101
+ message: `no test config could be read, so there is no suite to prove.`
102
+ }
103
+ ];
104
+ }
105
+ return suites.map((suite) => validateSuite(name, root, adoption, suite));
106
+ }
107
+ function validateSuite(name, root, adoption, suite) {
108
+ if (hasReference(root, suite)) {
109
+ if (adoption.state === "jest") {
110
+ return {
111
+ module: name,
112
+ suite,
113
+ ok: false,
114
+ message: `a reference is recorded, but this module still tests with jest (${adoption.jestConfigs.join(", ")}). Migrate it with \`sentinel --init --test\`, then run this again to compare.`
115
+ };
116
+ }
117
+ const config2 = vitestConfigFor(root, suite);
118
+ if (config2 === void 0) {
119
+ return {
120
+ module: name,
121
+ suite,
122
+ ok: false,
123
+ message: `a reference is recorded for the \`${suite}\` suite, and this module has no Vitest config for it. The migration wrote one config where the jest side had two, so one suite is not running at all.`
124
+ };
125
+ }
126
+ const report = runVitest(root, config2);
127
+ if (report === void 0) {
128
+ return {
129
+ module: name,
130
+ suite,
131
+ ok: false,
132
+ message: `the Vitest run produced no report, so nothing could be compared. Check that the suite starts at all: \`sentinel --run --test\`.`
133
+ };
134
+ }
135
+ const comparison = compareAgainstReference(root, suite, report);
136
+ if (!comparison.ok || !sharesAWorker(root, config2)) {
137
+ return { module: name, suite, ok: comparison.ok, message: comparison.message };
138
+ }
139
+ const shuffled = shuffledPassMatches(root, suite, config2);
140
+ return {
141
+ module: name,
142
+ suite,
143
+ ok: shuffled.ok,
144
+ message: shuffled.ok ? `${comparison.message} ${shuffled.message}` : shuffled.message
145
+ };
146
+ }
147
+ if (adoption.state !== "jest") {
148
+ return {
149
+ module: name,
150
+ suite,
151
+ ok: false,
152
+ message: `already migrated, and no reference was recorded before it was, so there is nothing to compare against. A proof has to be started BEFORE the migration: \`sentinel --validate --test\` on the jest state, then \`--init --test\`, then this again.`
153
+ };
154
+ }
155
+ const config = adoption.jestConfigs.find((candidate) => suiteOf(candidate) === suite);
156
+ if (config === void 0) {
157
+ return {
158
+ module: name,
159
+ suite,
160
+ ok: false,
161
+ message: `this module reads as jest but names no config for the \`${suite}\` suite.`
162
+ };
163
+ }
164
+ const capture = captureReference(root, config);
165
+ return { module: name, suite, ok: capture.captured, message: capture.message };
166
+ }
167
+ export {
168
+ sharesAWorker,
169
+ validateModuleTests
170
+ };