@motion-proto/live-tokens 0.77.0 → 0.79.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 (177) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +60 -29
  2. package/.claude/skills/live-tokens-create-component/SKILL.md +23 -28
  3. package/.claude/skills/live-tokens-create-component/references/contract-tests.md +61 -0
  4. package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +2 -2
  5. package/.claude/skills/live-tokens-create-component/references/token-naming.md +6 -15
  6. package/.claude/skills/live-tokens-create-page/SKILL.md +9 -6
  7. package/.claude/skills/live-tokens-create-theme/references/design-directions.md +1 -1
  8. package/.claude/skills/live-tokens-pick-component/SKILL.md +3 -3
  9. package/.claude/skills/live-tokens-set-colors/references/color-anchors.md +1 -1
  10. package/.claude/skills/live-tokens-set-geometry/SKILL.md +1 -1
  11. package/.claude/skills/live-tokens-set-geometry/references/geometry-anchors.md +1 -1
  12. package/CHANGELOG.md +284 -0
  13. package/README.md +9 -15
  14. package/bin/check-component.mjs +152 -551
  15. package/bin/check-page.mjs +56 -420
  16. package/bin/cli.mjs +83 -15
  17. package/bin/contractRunner.mjs +380 -120
  18. package/bin/lib/catalogue.mjs +161 -34
  19. package/bin/lib/componentSource.mjs +152 -0
  20. package/bin/lib/cssValues.mjs +9 -0
  21. package/bin/lib/dataDir.mjs +126 -0
  22. package/bin/lib/findings.mjs +103 -12
  23. package/bin/lib/fixers.mjs +64 -0
  24. package/bin/lib/geometry.mjs +92 -0
  25. package/bin/lib/pageRoutes.mjs +309 -0
  26. package/bin/lib/pageSource.mjs +230 -0
  27. package/bin/lib/report.mjs +57 -59
  28. package/bin/lib/tokenVocabulary.mjs +104 -34
  29. package/bin/rules/componentStructure.mjs +344 -0
  30. package/bin/rules/componentUse.mjs +313 -0
  31. package/bin/rules/importsAndRoutes.mjs +136 -0
  32. package/bin/rules/testRuns.mjs +122 -0
  33. package/bin/rules/tokens.mjs +363 -0
  34. package/bin/setup-claude.mjs +1 -2
  35. package/dist-plugin/{chunk-PDNL4NC5.js → chunk-D4WRIKEZ.js} +7 -2
  36. package/dist-plugin/{chunk-SWXRVZKT.js → chunk-REBHE3ZM.js} +414 -1
  37. package/dist-plugin/index.cjs +432 -15
  38. package/dist-plugin/index.js +8 -9
  39. package/dist-plugin/migrateData/index.cjs +422 -4
  40. package/dist-plugin/migrateData/index.js +2 -2
  41. package/dist-plugin/setColors/index.cjs +414 -1
  42. package/dist-plugin/setColors/index.d.cts +1 -1
  43. package/dist-plugin/setColors/index.d.ts +1 -1
  44. package/dist-plugin/setColors/index.js +1 -1
  45. package/dist-plugin/setGeometry/index.cjs +423 -13
  46. package/dist-plugin/setGeometry/index.d.cts +3 -3
  47. package/dist-plugin/setGeometry/index.d.ts +3 -3
  48. package/dist-plugin/setGeometry/index.js +10 -13
  49. package/dist-plugin/setType/index.d.cts +1 -1
  50. package/dist-plugin/setType/index.d.ts +1 -1
  51. package/dist-plugin/{themeTypes-BxRtuN5V.d.cts → themeTypes-B8_Idrp4.d.cts} +2 -2
  52. package/dist-plugin/{themeTypes-BxRtuN5V.d.ts → themeTypes-B8_Idrp4.d.ts} +2 -2
  53. package/package.json +6 -4
  54. package/src/editor/component-editor/CalloutEditor.svelte +2 -2
  55. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +14 -15
  56. package/src/editor/component-editor/CornerBadgeEditor.svelte +14 -14
  57. package/src/editor/component-editor/DialogEditor.svelte +5 -5
  58. package/src/editor/component-editor/InlineEditActionsEditor.svelte +2 -2
  59. package/src/editor/component-editor/RadioButtonEditor.svelte +21 -21
  60. package/src/editor/component-editor/SectionDividerEditor.svelte +7 -7
  61. package/src/editor/component-editor/SegmentedControlEditor.svelte +5 -5
  62. package/src/editor/component-editor/SideNavigationEditor.svelte +25 -25
  63. package/src/editor/component-editor/TabBarEditor.svelte +6 -6
  64. package/src/editor/component-editor/TableEditor.svelte +6 -6
  65. package/src/editor/component-editor/ToggleEditor.svelte +2 -2
  66. package/src/editor/component-editor/index.ts +3 -0
  67. package/src/editor/component-editor/registry.ts +57 -1
  68. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +14 -14
  69. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +3 -0
  70. package/src/editor/component-editor/scaffolding/types.ts +15 -0
  71. package/src/editor/core/components/adjustAliases.ts +4 -4
  72. package/src/editor/core/components/aliasKinds.ts +20 -19
  73. package/src/editor/core/sketch/sketchLayer.ts +3 -3
  74. package/src/editor/core/themes/migrateComponentConfig.ts +14 -6
  75. package/src/editor/core/themes/migrations/2026-09-13-badge-brand.ts +42 -0
  76. package/src/editor/core/themes/migrations/2026-09-13-collapsiblesection-open.ts +33 -0
  77. package/src/editor/core/themes/migrations/2026-09-13-cornerbadge-prefix.ts +70 -0
  78. package/src/editor/core/themes/migrations/2026-09-13-hairline.ts +92 -0
  79. package/src/editor/core/themes/migrations/2026-09-13-indicator.ts +54 -0
  80. package/src/editor/core/themes/migrations/2026-09-13-sectiondivider-surface.ts +36 -0
  81. package/src/editor/core/themes/migrations/2026-09-13-selected-state.ts +124 -0
  82. package/src/editor/core/themes/migrations/2026-09-13-toggle-label.ts +31 -0
  83. package/src/editor/core/themes/migrations/index.ts +16 -0
  84. package/src/editor/core/themes/themeService.ts +3 -3
  85. package/src/editor/core/themes/themeTypes.ts +13 -15
  86. package/src/editor/docs/Docs.svelte +1 -1
  87. package/src/editor/docs/content/light-and-dark.md +3 -3
  88. package/src/editor/docs/content.generated.ts +1 -1
  89. package/src/editor/index.ts +1 -0
  90. package/src/editor/overlay/ColumnsOverlay.svelte +1 -1
  91. package/src/editor/overlay/LiveEditorOverlay.svelte +16 -0
  92. package/src/editor/overlay/LiveTokensRouter.svelte +1 -0
  93. package/src/editor/skill-atlas/SkillAtlas.svelte +4 -4
  94. package/src/editor/skill-atlas/TreeNodeCard.svelte +4 -4
  95. package/src/editor/skill-atlas/skillSources.generated.ts +11 -14
  96. package/src/editor/skill-atlas/skillTrees.ts +1 -3
  97. package/src/editor/skill-atlas/trees/check-compliance.ts +258 -93
  98. package/src/editor/skill-atlas/trees/create-component.ts +73 -62
  99. package/src/editor/skill-atlas/trees/create-page.ts +54 -33
  100. package/src/editor/skill-atlas/trees/pick-component.ts +3 -3
  101. package/src/editor/skill-atlas/trees/set-geometry.ts +1 -1
  102. package/src/editor/ui/UIPaletteSelector.svelte +11 -15
  103. package/src/editor/ui/variantScales.ts +8 -8
  104. package/src/live-tokens/data/themes/autumn.json +188 -188
  105. package/src/live-tokens/data/themes/halloween.json +188 -188
  106. package/src/live-tokens/data/themes/midnight-study.json +245 -245
  107. package/src/live-tokens/data/themes/ocean.json +188 -188
  108. package/src/live-tokens/data/themes/royal-velvet.json +188 -188
  109. package/src/live-tokens/data/themes/sketchy.json +188 -188
  110. package/src/live-tokens/data/themes/spring-meadow.json +188 -188
  111. package/src/live-tokens/data/themes/sunset.json +188 -188
  112. package/src/system/components/Badge.svelte +27 -23
  113. package/src/system/components/Button.svelte +13 -7
  114. package/src/system/components/Callout.svelte +16 -11
  115. package/src/system/components/Card.svelte +22 -15
  116. package/src/system/components/CodeSnippet.svelte +10 -6
  117. package/src/system/components/CollapsibleSection.svelte +68 -63
  118. package/src/system/components/CornerBadge.svelte +78 -72
  119. package/src/system/components/Dialog.svelte +21 -18
  120. package/src/system/components/IconButton.svelte +13 -9
  121. package/src/system/components/Image.svelte +14 -8
  122. package/src/system/components/ImageLightbox.svelte +10 -6
  123. package/src/system/components/InlineEditActions.svelte +17 -14
  124. package/src/system/components/Input.svelte +13 -7
  125. package/src/system/components/MenuSelect.svelte +13 -7
  126. package/src/system/components/Notification.svelte +17 -11
  127. package/src/system/components/Panel.svelte +13 -6
  128. package/src/system/components/ProgressBar.svelte +10 -5
  129. package/src/system/components/RadioButton.svelte +33 -30
  130. package/src/system/components/SectionDivider.svelte +28 -21
  131. package/src/system/components/SegmentedControl.svelte +27 -23
  132. package/src/system/components/SideNavigation.svelte +175 -171
  133. package/src/system/components/Slider.svelte +11 -7
  134. package/src/system/components/TabBar.svelte +53 -49
  135. package/src/system/components/Table.svelte +20 -15
  136. package/src/system/components/Toggle.svelte +14 -10
  137. package/src/system/components/Tooltip.svelte +10 -6
  138. package/src/system/styles/CONVENTIONS.md +3 -4
  139. package/src/testing-js/chunk-3UKGXCDL.js +48 -0
  140. package/src/testing-js/chunk-3UKGXCDL.js.map +1 -0
  141. package/src/testing-js/{chunk-ZMSX6CXR.js → chunk-NB3NZRBM.js} +13 -2
  142. package/src/testing-js/chunk-NB3NZRBM.js.map +1 -0
  143. package/src/testing-js/{chunk-FAFOAWYL.js → chunk-Q3YIAAG3.js} +17 -3
  144. package/src/testing-js/chunk-Q3YIAAG3.js.map +1 -0
  145. package/src/testing-js/{chunk-7TI7Z6Y6.js → chunk-U7OJE5DU.js} +19 -19
  146. package/src/testing-js/chunk-U7OJE5DU.js.map +1 -0
  147. package/src/testing-js/{chunk-L73N4NSO.js → chunk-WIZ6W7UT.js} +2 -2
  148. package/src/testing-js/{chunk-4JQX6WWL.js → chunk-ZMZQZ33J.js} +474 -166
  149. package/src/testing-js/chunk-ZMZQZ33J.js.map +1 -0
  150. package/src/testing-js/component-alias.contract.js +2 -2
  151. package/src/testing-js/component-behavior.contract.js +155 -0
  152. package/src/testing-js/component-behavior.contract.js.map +1 -0
  153. package/src/testing-js/component-editor.contract.js +7 -5
  154. package/src/testing-js/component-editor.contract.js.map +1 -1
  155. package/src/testing-js/component-render.contract.js +15 -11
  156. package/src/testing-js/component-render.contract.js.map +1 -1
  157. package/src/testing-js/index.d.ts +50 -4
  158. package/src/testing-js/index.js +32 -8
  159. package/src/testing-js/index.js.map +1 -1
  160. package/src/testing-js/page-compliance.contract.js +867 -0
  161. package/src/testing-js/page-compliance.contract.js.map +1 -0
  162. package/src/testing-js/registry.contract.js +5 -3
  163. package/src/testing-js/registry.contract.js.map +1 -1
  164. package/src/testing-js/{vitest-BE6uGF31.d.ts → vitest-BMLIbDs2.d.ts} +29 -3
  165. package/src/testing-js/vitest.d.ts +1 -1
  166. package/src/testing-js/vitest.js +3 -2
  167. package/template/README.md +16 -9
  168. package/template/package.json +2 -2
  169. package/template/src/pages/Home.svelte +13 -0
  170. package/.claude/skills/live-tokens-create-component/SKILL copy.md +0 -196
  171. package/.claude/skills/live-tokens-fix-findings/SKILL.md +0 -98
  172. package/src/editor/skill-atlas/trees/fix-findings.ts +0 -469
  173. package/src/testing-js/chunk-4JQX6WWL.js.map +0 -1
  174. package/src/testing-js/chunk-7TI7Z6Y6.js.map +0 -1
  175. package/src/testing-js/chunk-FAFOAWYL.js.map +0 -1
  176. package/src/testing-js/chunk-ZMSX6CXR.js.map +0 -1
  177. /package/src/testing-js/{chunk-L73N4NSO.js.map → chunk-WIZ6W7UT.js.map} +0 -0
@@ -2,6 +2,12 @@
2
2
  // component contract suites under Playwright for one component or every
3
3
  // authored one, and maps their results onto findings by rule.
4
4
  //
5
+ // `check-page --tests` (`runPageTests`, below `runContractTests`) shares this
6
+ // file's tool detection, data isolation, generated Playwright config, spawn
7
+ // deadline, report reading, and infrastructure classification, running the
8
+ // suite's `page` project instead of `contract` and skipping the registry
9
+ // (Vitest) half pages have no equivalent of.
10
+ //
5
11
  // Spawns the two tools as child processes rather than importing their APIs, so
6
12
  // this module needs neither `@playwright/test` nor `vitest` at its own module
7
13
  // top — it is loaded lazily, only when `--tests` is passed (see cli.mjs).
@@ -22,13 +28,31 @@ import { tmpdir } from 'node:os';
22
28
  import { basename, dirname, join, relative, resolve } from 'node:path';
23
29
  import { fileURLToPath } from 'node:url';
24
30
  import { discoverComponents, resolveComponentPaths } from './check-component.mjs';
31
+ import { resolveSourceDataDir, scrapeSettingsField, settingsFilePath } from './lib/dataDir.mjs';
25
32
  import { lineOf } from './lib/findings.mjs';
33
+ import { settingsPageViewports } from './lib/pageRoutes.mjs';
26
34
 
27
35
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
28
36
 
29
37
  const TEST_DATA_DIR_ENV = 'LIVE_TOKENS_TEST_DATA_DIR';
30
38
  const DATA_DIR_ENV = 'LIVE_TOKENS_DATA_DIR';
31
39
  const COMPONENT_ENV = 'LIVE_TOKENS_COMPONENT';
40
+ // Mirrors src/testing/config.ts's own PAGES_ENV: the page targets a run opens,
41
+ // as JSON. Duplicated rather than imported for the same reason as
42
+ // SESSION_FILES below.
43
+ const PAGES_ENV = 'LIVE_TOKENS_PAGES';
44
+
45
+ /** Milliseconds a spawned tool gets before this file sends it SIGINT, then
46
+ * SIGKILL five seconds later. A hung dev server or worker can no longer hold
47
+ * a caller open past this. */
48
+ const DEFAULT_TESTS_TIMEOUT_MS = 15 * 60_000;
49
+ const TESTS_TIMEOUT_ENV = 'LIVE_TOKENS_TESTS_TIMEOUT';
50
+
51
+ function testsTimeoutMs() {
52
+ const raw = process.env[TESTS_TIMEOUT_ENV];
53
+ const ms = raw ? Number(raw) : NaN;
54
+ return Number.isFinite(ms) && ms > 0 ? ms : DEFAULT_TESTS_TIMEOUT_MS;
55
+ }
32
56
 
33
57
  // Mirrors src/testing/isolation.ts's own list. Duplicated rather than shared:
34
58
  // that module compiles into src/testing-js, which does not exist until
@@ -51,6 +75,7 @@ const HARD_FAILURE_RULES = new Set(['tests-not-installed', 'tests-setup', 'tests
51
75
  * coverage against. */
52
76
  const ALL_CONTRACT_RULES = [
53
77
  'contract-registry',
78
+ 'contract-behavior',
54
79
  'contract-listed',
55
80
  'contract-alias',
56
81
  'contract-states',
@@ -160,100 +185,10 @@ function resolveTestingEntry(name) {
160
185
  );
161
186
  }
162
187
 
163
- function settingsFilePath(root) {
164
- for (const name of ['live-tokens.testing.ts', 'live-tokens.testing.mts', 'live-tokens.testing.js', 'live-tokens.testing.mjs']) {
165
- const path = join(root, name);
166
- if (existsSync(path)) return path;
167
- }
168
- return null;
169
- }
170
-
171
- /**
172
- * A generated config's own extensionless relative imports (Vite/Playwright's
173
- * loaders resolve those, matching how this repo's own `vite.config.ts` and
174
- * `live-tokens.testing.ts` are written) only get that treatment for a
175
- * *static* import. A dynamic `import()` of the same path, called after a Vite
176
- * config finishes loading, runs through plain Node resolution instead and
177
- * fails on the same files: verified against `vitest.contract.config.ts` with
178
- * both a `file://` URL and a plain absolute path as the dynamic specifier,
179
- * identical `ERR_MODULE_NOT_FOUND` both times. So neither `settings.viteConfig`
180
- * nor `settings.dataDir` (the resolved values `resolveTestingConfig` computes)
181
- * has a reader here on purpose — reading either would mean importing the
182
- * settings file dynamically first, which reintroduces exactly this failure.
183
- * Regexing the settings file's *source text* for a field, imprecise as that
184
- * is, is what stays inside the static-import constraint: a wrong guess still
185
- * fails loudly (a bad `viteConfig` throws on its own static import in the
186
- * generated file; a bad `dataDir` throws "no data directory at ..." below),
187
- * never as a silent pass. A settings-level `dataDir` this cannot see at all
188
- * is worse than one resolved this imprecisely, since a project that names its
189
- * data directory only in `live-tokens.testing.ts` would otherwise have its
190
- * contracts checked against whatever happens to sit at the default path.
191
- */
192
- function stripComments(text) {
193
- // Block comments first: a `//` inside one (`/* // note */`) must not seed a
194
- // second, overlapping strip.
195
- return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
196
- }
197
-
198
- /** Measured across eleven settings-file shapes: a commented-out `dataDir:`
199
- * above the real one won the regex, because the regex only sees text
200
- * position, never comment syntax, and a comment naming the field with
201
- * nothing else present invented a setting out of prose. Comments are
202
- * stripped before the field ever gets a chance to match. A field present in
203
- * live code as a template literal, a computed value, or an import throws
204
- * instead of falling through to a default silently: the two guesses this
205
- * feeds, `viteConfig` and `dataDir`, can each resolve to a real path that
206
- * simply names the wrong tree, which then reads as a clean run. */
207
- function scrapeSettingsField(settingsPath, fieldName) {
208
- if (!settingsPath) return null;
209
- let text;
210
- try {
211
- text = readFileSync(settingsPath, 'utf8');
212
- } catch {
213
- return null;
214
- }
215
- const live = stripComments(text);
216
- const literal = new RegExp(`\\b${fieldName}\\s*:\\s*['"]([^'"]+)['"]`).exec(live);
217
- if (literal) return literal[1];
218
- if (new RegExp(`\\b${fieldName}\\s*:`).test(live)) {
219
- throw new Error(
220
- `"${fieldName}" in ${settingsPath} is set to something other than a plain string literal, ` +
221
- `so --tests cannot read it statically. Use a literal string, or remove the key to fall back to the default.`,
222
- );
223
- }
224
- return null;
225
- }
226
-
227
188
  function guessViteConfigPath(root, settingsPath) {
228
189
  return resolve(root, scrapeSettingsField(settingsPath, 'viteConfig') ?? 'vite.config.ts');
229
190
  }
230
191
 
231
- /**
232
- * `dataDir` as the plugin resolves it: a `dataDir` field scraped from the
233
- * settings file (see above), else `live-tokens.config.json`'s own key, else
234
- * the default. Mirrors `resolveTestingConfig`'s own `configuredDataDir`
235
- * fallback (`src/testing/config.ts`) rather than importing it: that module
236
- * compiles into `src/testing-js`, which does not exist until `build:testing`
237
- * runs, and importing the *source* `.ts` module to reach it hits the same
238
- * extensionless-import failure documented above — measured with
239
- * `src/testing-js` moved aside, `resolveTestingEntry`'s `.ts` fallback threw
240
- * exactly that trying to load `vitest.ts`. Duplicating this small a resolver
241
- * has precedent in this file already, at `SESSION_FILES`.
242
- */
243
- export function resolveSourceDataDir(root, settingsPath) {
244
- const scraped = scrapeSettingsField(settingsPath, 'dataDir');
245
- if (scraped) return resolve(root, scraped);
246
- try {
247
- const parsed = JSON.parse(readFileSync(join(root, 'live-tokens.config.json'), 'utf8'));
248
- if (parsed && typeof parsed === 'object' && typeof parsed.dataDir === 'string') {
249
- return resolve(root, parsed.dataDir);
250
- }
251
- } catch {
252
- // Missing or unparseable reads as absent, matching the plugin's own resolver.
253
- }
254
- return resolve(root, 'src/live-tokens/data');
255
- }
256
-
257
192
  // ─── data isolation ─────────────────────────────────────────────────────────
258
193
 
259
194
  function copyIsolatedDataDir(sourceDataDir) {
@@ -332,27 +267,41 @@ function withCleanup(paths) {
332
267
 
333
268
  // ─── generated tool configs ─────────────────────────────────────────────────
334
269
 
335
- export function writeGeneratedConfigs({ configDir, root }) {
336
- // `configDir` sits under the OS temp directory, which has no ancestor
337
- // `package.json`. Without one naming `"type": "module"` here, Node treats
338
- // these configs as CommonJS by default, and `createPlaywrightConfig`'s
339
- // `import.meta.url` throws "Cannot use 'import.meta' outside a module".
270
+ // `configDir` sits under the OS temp directory, which has no ancestor
271
+ // `package.json`. Without one naming `"type": "module"` here, Node treats
272
+ // these configs as CommonJS by default, and `createPlaywrightConfig`'s
273
+ // `import.meta.url` throws "Cannot use 'import.meta' outside a module".
274
+ export function writeConfigPackageJson(configDir) {
340
275
  writeFileSync(join(configDir, 'package.json'), JSON.stringify({ type: 'module' }));
276
+ }
341
277
 
278
+ /** A source fragment for the generated files below, not an actual settings
279
+ * object: this module never imports the consumer's settings file itself (see
280
+ * the static-import note above), so it only ever sees this as text.
281
+ * `settingsModule` is already the file's default export (a default import
282
+ * unwraps it), not a module namespace object — it has no `.default` of its
283
+ * own. */
284
+ function settingsFragments(root) {
342
285
  const settingsPath = settingsFilePath(root);
343
- const testingIndex = resolveTestingEntry('index');
344
- const testingVitest = resolveTestingEntry('vitest');
345
- const viteConfigPath = guessViteConfigPath(root, settingsPath);
346
-
347
286
  const settingsImport = settingsPath ? `import settingsModule from ${JSON.stringify(settingsPath)};\n` : '';
348
- // A source fragment for the generated files below, not an actual settings
349
- // object: this module never imports the consumer's settings file itself
350
- // (see the static-import note above), so it only ever sees this as text.
351
- // `settingsModule` is already the file's default export (a default import
352
- // unwraps it), not a module namespace object — it has no `.default` of its
353
- // own.
354
287
  const settingsExpr = settingsPath ? 'settingsModule' : '{}';
288
+ return { settingsPath, settingsImport, settingsExpr };
289
+ }
355
290
 
291
+ /**
292
+ * The `page` project alone, config `globalTimeout` set to `timeoutMs`: a
293
+ * `check-page --tests` run must never also carry the whole `contract` project
294
+ * (every component's editor cycle, against no `LIVE_TOKENS_COMPONENT`), and a
295
+ * page suite that hangs reports `timedOut` tests in a real JSON report before
296
+ * this file's own SIGINT/SIGKILL watchdog (`runCli`) ever has to act.
297
+ * `globalTimeout` is `createPlaywrightConfig`'s own option (`src/testing/
298
+ * playwright.ts`), passed through rather than patched onto the resolved
299
+ * config afterward, so the bound holds for every caller of that factory, not
300
+ * only the CLI's generated configs.
301
+ */
302
+ export function writePlaywrightConfig({ configDir, root, timeoutMs = DEFAULT_TESTS_TIMEOUT_MS } = {}) {
303
+ const { settingsImport, settingsExpr } = settingsFragments(root);
304
+ const testingIndex = resolveTestingEntry('index');
356
305
  const playwrightConfigPath = join(configDir, 'playwright.config.ts');
357
306
  writeFileSync(
358
307
  playwrightConfigPath,
@@ -361,9 +310,20 @@ export function writeGeneratedConfigs({ configDir, root }) {
361
310
  export default createPlaywrightConfig({
362
311
  ...${settingsExpr},
363
312
  root: ${JSON.stringify(root)},
313
+ globalTimeout: ${JSON.stringify(timeoutMs)},
364
314
  });
365
315
  `,
366
316
  );
317
+ return playwrightConfigPath;
318
+ }
319
+
320
+ export function writeGeneratedConfigs({ configDir, root, timeoutMs = DEFAULT_TESTS_TIMEOUT_MS } = {}) {
321
+ writeConfigPackageJson(configDir);
322
+ const playwrightConfigPath = writePlaywrightConfig({ configDir, root, timeoutMs });
323
+
324
+ const { settingsPath, settingsImport, settingsExpr } = settingsFragments(root);
325
+ const testingVitest = resolveTestingEntry('vitest');
326
+ const viteConfigPath = guessViteConfigPath(root, settingsPath);
367
327
 
368
328
  const vitestConfigPath = join(configDir, 'vitest.config.ts');
369
329
  writeFileSync(
@@ -375,6 +335,7 @@ const settings = resolveTestingConfig(${settingsExpr}, ${JSON.stringify(root)});
375
335
 
376
336
  export default createVitestConfig(viteConfigModule.default ?? viteConfigModule, {
377
337
  registrySetup: settings.registrySetup,
338
+ contractsModule: settings.contractsModule,
378
339
  });
379
340
  `,
380
341
  );
@@ -384,17 +345,30 @@ export default createVitestConfig(viteConfigModule.default ?? viteConfigModule,
384
345
 
385
346
  // ─── subprocess execution ───────────────────────────────────────────────────
386
347
 
387
- function runCli(command, args, { cwd, env }) {
348
+ /** Spawns `command`, and at `timeoutMs` sends the child SIGINT (Playwright's
349
+ * own graceful-stop signal, per `withCleanup`'s own note above), then
350
+ * SIGKILL five seconds later if it has not exited. `result.timedOut` tells
351
+ * the caller the run never finished on its own, whatever the child's own
352
+ * exit code or report ends up saying. */
353
+ function runCli(command, args, { cwd, env, timeoutMs = DEFAULT_TESTS_TIMEOUT_MS } = {}) {
388
354
  return new Promise((resolveRun) => {
389
355
  const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
390
356
  activeChild = child;
391
357
  let stdout = '';
392
358
  let stderr = '';
359
+ let timedOut = false;
393
360
  child.stdout.on('data', (chunk) => (stdout += chunk));
394
361
  child.stderr.on('data', (chunk) => (stderr += chunk));
362
+ const deadline = setTimeout(() => {
363
+ timedOut = true;
364
+ const killer = setTimeout(() => child.kill('SIGKILL'), 5_000);
365
+ child.once('exit', () => clearTimeout(killer));
366
+ child.kill('SIGINT');
367
+ }, timeoutMs);
395
368
  const finish = (result) => {
369
+ clearTimeout(deadline);
396
370
  if (activeChild === child) activeChild = null;
397
- resolveRun(result);
371
+ resolveRun({ ...result, timedOut });
398
372
  };
399
373
  child.on('close', (code) => finish({ code, stdout, stderr }));
400
374
  child.on('error', (error) => finish({ code: -1, stdout, stderr: `${stderr}\n${error.message}` }));
@@ -410,6 +384,17 @@ function setupFinding(tool, code, stdout, stderr) {
410
384
  };
411
385
  }
412
386
 
387
+ /** `tests-incomplete`, not `tests-setup`: the tool itself never got the
388
+ * chance to explain the failure, this file's own watchdog cut it off. */
389
+ export function timeoutFinding(tool, timeoutMs) {
390
+ return {
391
+ rule: 'tests-incomplete',
392
+ file: 'package.json',
393
+ line: 1,
394
+ message: `${tool} did not finish within ${Math.round(timeoutMs / 60_000)} minute(s) and was interrupted. Set LIVE_TOKENS_TESTS_TIMEOUT (milliseconds) to change the bound.`,
395
+ };
396
+ }
397
+
413
398
  /** Reads a JSON report or explains, as a `tests-setup` finding, why there
414
399
  * isn't one. Split from the process-spawning around it so a malformed or
415
400
  * absent report is testable without a subprocess. */
@@ -422,25 +407,29 @@ export function readReportOrSetupFinding(tool, reportPath, code, stdout, stderr)
422
407
  }
423
408
  }
424
409
 
425
- export async function runPlaywrightSuite({ root, configDir, playwrightConfigPath }) {
410
+ export async function runPlaywrightSuite({ root, configDir, playwrightConfigPath, project, timeoutMs = DEFAULT_TESTS_TIMEOUT_MS }) {
426
411
  const reportPath = join(configDir, 'playwright-report.json');
427
412
  const bin = peerBin(root, '@playwright/test', 'cli.js');
428
- const { code, stdout, stderr } = await runCli(
429
- process.execPath,
430
- [bin, 'test', '-c', playwrightConfigPath, '--reporter=json'],
431
- { cwd: root, env: { ...process.env, PLAYWRIGHT_JSON_OUTPUT_FILE: reportPath } },
432
- );
413
+ const args = [bin, 'test', '-c', playwrightConfigPath, '--reporter=json'];
414
+ if (project) args.push('--project', project);
415
+ const { code, stdout, stderr, timedOut } = await runCli(process.execPath, args, {
416
+ cwd: root,
417
+ env: { ...process.env, PLAYWRIGHT_JSON_OUTPUT_FILE: reportPath },
418
+ timeoutMs,
419
+ });
420
+ if (timedOut) return { setupFinding: timeoutFinding('Playwright', timeoutMs) };
433
421
  return readReportOrSetupFinding('Playwright', reportPath, code, stdout, stderr);
434
422
  }
435
423
 
436
- export async function runRegistrySuite({ root, configDir, vitestConfigPath }) {
424
+ export async function runRegistrySuite({ root, configDir, vitestConfigPath, timeoutMs = DEFAULT_TESTS_TIMEOUT_MS }) {
437
425
  const reportPath = join(configDir, 'vitest-report.json');
438
426
  const bin = peerBin(root, 'vitest', 'vitest.mjs');
439
- const { code, stdout, stderr } = await runCli(
427
+ const { code, stdout, stderr, timedOut } = await runCli(
440
428
  process.execPath,
441
429
  [bin, 'run', '--config', vitestConfigPath, '--reporter=json', '--outputFile', reportPath],
442
- { cwd: root, env: process.env },
430
+ { cwd: root, env: process.env, timeoutMs },
443
431
  );
432
+ if (timedOut) return { setupFinding: timeoutFinding('Vitest', timeoutMs) };
444
433
  const outcome = readReportOrSetupFinding('Vitest', reportPath, code, stdout, stderr);
445
434
  // Kept alongside a successfully-parsed report too: a file that failed to
446
435
  // collect a single test still carries a report, and `mapVitestResults`
@@ -712,6 +701,136 @@ export function mapPlaywrightResults(report, { root, sourceDataDir, knownIds })
712
701
  return { findings, coverage };
713
702
  }
714
703
 
704
+ // ─── result mapping: page rules ─────────────────────────────────────────────
705
+
706
+ /** The plan's Runtime rule ids, fixed rather than read off a page's own
707
+ * results: `reconcileCoverage`'s expected set, same reasoning as
708
+ * `ALL_CONTRACT_RULES` above. */
709
+ const PAGE_RUNTIME_RULES = ['page-component-paint', 'page-text-style', 'page-contrast', 'page-grid', 'page-overflow'];
710
+
711
+ /** `page-compliance.contract.ts` titles each test `${rule} | ${source} |
712
+ * ${width}x${height}`, so the rule, the page, and the viewport are read off
713
+ * the title rather than off describe-block position (there is none — every
714
+ * page test is a sibling at the suite's top level). */
715
+ const PAGE_TEST_TITLE_RE = /^(page-[a-z-]+) \| (.+) \| (\d+x\d+)$/;
716
+
717
+ /** A `PageViolation`'s own `[rule] source:line: message`, read back out of
718
+ * Playwright's `Error.prototype.toString()` (`${name}: ${message}`), the
719
+ * same convention `VIOLATION_RE` above reads `ContractViolation` through. */
720
+ const PAGE_VIOLATION_RE = /^PageViolation:\s*\[([a-z-]+)\]\s+(.+):(\d+):\s*([\s\S]*)$/;
721
+
722
+ /**
723
+ * `PageViolation` to a finding at the page file and line; a timeout or an
724
+ * interruption to `tests-incomplete`; a `test.skip(...)` call — the mechanism
725
+ * `PageHarness.assert*` uses to report a rule inapplicable — to `inapplicable`
726
+ * coverage carrying the skip's own description, never a finding. Structurally
727
+ * the Playwright half of `mapPlaywrightResults` above, with no component,
728
+ * describe-block, or `ContractViolation` concept to lean on: every triple's
729
+ * identity comes off the test's own title.
730
+ */
731
+ export function mapPageResults(report) {
732
+ const zeroCollected = (report.suites ?? []).length === 0;
733
+ if (zeroCollected || (report.errors ?? []).length > 0) {
734
+ const detail = (report.errors ?? []).map((e) => e.message).join('\n') || 'the run collected no tests';
735
+ return {
736
+ findings: [{ rule: 'tests-incomplete', file: 'package.json', line: 1, message: `Playwright collected nothing to check: ${detail}` }],
737
+ coverage: {},
738
+ explained: true,
739
+ };
740
+ }
741
+
742
+ const tests = readPlaywrightTests(report);
743
+
744
+ for (const t of tests) {
745
+ if (t.status !== 'unexpected') continue;
746
+ const infra = classifyInfrastructureError(t.lastResult?.errors?.[0]?.message);
747
+ if (infra?.rule === 'tests-not-installed') {
748
+ return { findings: [{ rule: infra.rule, file: 'package.json', line: 1, message: infra.message }], coverage: {}, explained: true };
749
+ }
750
+ }
751
+
752
+ const findings = [];
753
+ const coverage = {};
754
+ // The outer key carries the page and the viewport, the inner key the bare
755
+ // rule id — never the reverse: `applyCoverageSeverity` and `--off` (`bin/
756
+ // lib/findings.mjs`) resolve a coverage entry's severity by looking up its
757
+ // inner key straight in `PAGE_RULES`, so a composite `rule@viewport` inner
758
+ // key would never match a plain rule id and `--off`/`checks.rules` would
759
+ // silently stop reaching page coverage.
760
+ const markCoverage = (pageAtViewport, rule, status, reason) => {
761
+ if (!pageAtViewport || !rule) return;
762
+ coverage[pageAtViewport] ??= {};
763
+ const existing = coverage[pageAtViewport][rule];
764
+ if (!existing || COVERAGE_PRIORITY[status] >= COVERAGE_PRIORITY[existing.status]) {
765
+ coverage[pageAtViewport][rule] = reason ? { status, reason } : { status };
766
+ }
767
+ };
768
+
769
+ for (const t of tests) {
770
+ const [, titleRule, titleSource, titleViewport] = PAGE_TEST_TITLE_RE.exec(t.specTitle) ?? [];
771
+ const pageAtViewport = titleSource && titleViewport ? `${titleSource}@${titleViewport}` : null;
772
+
773
+ if (t.status === 'expected' || t.status === 'flaky') {
774
+ markCoverage(pageAtViewport, titleRule, t.status === 'flaky' ? 'flaky' : 'passed');
775
+ continue;
776
+ }
777
+ if (t.status === 'skipped') {
778
+ // Playwright's own `skip` annotation, from `test.skip(condition,
779
+ // description)` inside the test body — decision 10's "inapplicable is a
780
+ // status with a reason", never a silent pass.
781
+ const reason = t.annotations.find((a) => a.type === 'skip')?.description || 'inapplicable';
782
+ markCoverage(pageAtViewport, titleRule, 'inapplicable', reason);
783
+ continue;
784
+ }
785
+
786
+ const last = t.lastResult;
787
+ if (last?.status === 'timedOut' || last?.status === 'interrupted') {
788
+ findings.push({
789
+ rule: 'tests-incomplete',
790
+ file: 'package.json',
791
+ line: 1,
792
+ message: `"${t.specTitle}" did not finish (${last.status})`,
793
+ context: { suite: 'playwright', title: t.specTitle, suiteFile: t.specFile, suiteLine: t.specLine },
794
+ });
795
+ markCoverage(pageAtViewport, titleRule, 'failed');
796
+ continue;
797
+ }
798
+
799
+ const rawErrors = last?.errors?.length ? last.errors : [{ message: `${t.status}: ${t.specTitle}` }];
800
+ for (const error of rawErrors) {
801
+ const infra = classifyInfrastructureError(error.message);
802
+ if (infra) {
803
+ findings.push({
804
+ rule: infra.rule,
805
+ file: 'package.json',
806
+ line: 1,
807
+ message: infra.message,
808
+ context: { suite: 'playwright', title: t.specTitle },
809
+ });
810
+ markCoverage(pageAtViewport, titleRule, 'failed');
811
+ continue;
812
+ }
813
+ const block = messageBlock(error.message);
814
+ const violation = PAGE_VIOLATION_RE.exec(block);
815
+ findings.push({
816
+ rule: violation?.[1] ?? titleRule ?? 'tests-setup',
817
+ file: violation?.[2] ?? titleSource ?? 'package.json',
818
+ line: violation ? Number(violation[3]) : 1,
819
+ message: violation?.[4] ?? block,
820
+ context: {
821
+ suite: 'playwright',
822
+ title: t.specTitle,
823
+ suiteFile: t.specFile,
824
+ suiteLine: t.specLine,
825
+ attachments: (last?.attachments ?? []).map((a) => a.path).filter(Boolean),
826
+ },
827
+ });
828
+ markCoverage(pageAtViewport, titleRule, 'failed');
829
+ }
830
+ }
831
+ return { findings, coverage };
832
+ }
833
+
715
834
  // ─── result mapping: Vitest (registry contract) ─────────────────────────────
716
835
 
717
836
  /** `checkRegistryEntry`'s violation strings, read back out of Vitest's
@@ -739,6 +858,12 @@ export function mapRegistryViolation(root, sourceDataDir, componentId, text) {
739
858
  return { file: relative(root, target), line: findTokenLine(target, token) };
740
859
  }
741
860
 
861
+ /** `component-behavior.contract.ts` shares the same `describe(id) > it(case)`
862
+ * shape `registry.contract.ts` uses (see `structuralRule`'s Playwright
863
+ * analogue), so the file name — not the describe titles — is what tells the
864
+ * two suites' assertions apart. */
865
+ const BEHAVIOR_FILE_PREFIX = 'component-behavior.contract';
866
+
742
867
  /** Mirrors the Playwright side's zero-collection check: a file that failed
743
868
  * before it ran a single assertion (`assertionResults` empty, `status`
744
869
  * 'failed') carries its own `message` — measured against a bad
@@ -773,16 +898,43 @@ export function mapVitestResults(report, { root, sourceDataDir, stderr } = {}) {
773
898
  const coverage = {};
774
899
  for (const file of files) {
775
900
  if (collectionFailures.includes(file)) continue;
901
+ const isBehaviorFile = basename(file.name ?? '').startsWith(BEHAVIOR_FILE_PREFIX);
902
+ const passRule = isBehaviorFile ? 'contract-behavior' : 'contract-registry';
776
903
  for (const assertion of file.assertionResults ?? []) {
777
904
  const componentId = assertion.ancestorTitles.length >= 2 ? assertion.ancestorTitles[1] : null;
778
905
  if (assertion.status !== 'failed') {
779
906
  if (assertion.status === 'passed' && componentId) {
780
907
  coverage[componentId] ??= {};
781
- coverage[componentId]['contract-registry'] = { status: 'passed' };
908
+ coverage[componentId][passRule] = { status: 'passed' };
782
909
  }
783
910
  continue;
784
911
  }
785
912
  const text = (assertion.failureMessages ?? []).join('\n');
913
+ // `component-behavior.contract.ts` throws `ContractViolation` directly
914
+ // rather than a registry-style violation array, and Vitest's own stack
915
+ // frames follow the message on their own line with no blank line
916
+ // between (measured; unlike Playwright's ANSI code frame), so only the
917
+ // first line is the message `messageBlock`'s blank-line split expects.
918
+ if (isBehaviorFile) {
919
+ const violation = VIOLATION_RE.exec(text.split('\n')[0].trim());
920
+ const finalRule = violation?.[1] ?? 'contract-behavior';
921
+ const id = violation?.[2] ?? componentId;
922
+ const message = violation?.[3] ?? text.split('\n')[0].trim();
923
+ const token = extractToken(message);
924
+ const { file: f, line } = artifactForContractRule(root, sourceDataDir, finalRule, id, token);
925
+ findings.push({
926
+ rule: finalRule,
927
+ file: f,
928
+ line,
929
+ message: id ? `${id}: ${message}` : message,
930
+ context: { suite: 'vitest', title: assertion.fullName },
931
+ });
932
+ if (id) {
933
+ coverage[id] ??= {};
934
+ coverage[id][finalRule] = { status: 'failed' };
935
+ }
936
+ continue;
937
+ }
786
938
  const violations = componentId ? extractViolationArray(text) : [];
787
939
  if (violations.length > 0) {
788
940
  for (const violation of violations) {
@@ -898,7 +1050,8 @@ export async function runContractTests(id, { root = process.cwd(), dataDir: expl
898
1050
  const cleanup = withCleanup([dataDir, configDir]);
899
1051
 
900
1052
  try {
901
- const { playwrightConfigPath, vitestConfigPath } = writeGeneratedConfigs({ configDir, root });
1053
+ const timeoutMs = testsTimeoutMs();
1054
+ const { playwrightConfigPath, vitestConfigPath } = writeGeneratedConfigs({ configDir, root, timeoutMs });
902
1055
  process.env[TEST_DATA_DIR_ENV] = dataDir;
903
1056
  process.env[DATA_DIR_ENV] = dataDir;
904
1057
  if (id) process.env[COMPONENT_ENV] = id;
@@ -910,8 +1063,8 @@ export async function runContractTests(id, { root = process.cwd(), dataDir: expl
910
1063
  // Sequential: both share the one isolated data directory, and the
911
1064
  // registry run's own Vite instance and the Playwright run's dev server
912
1065
  // would otherwise regenerate the same derived files concurrently.
913
- const registryOutcome = await runRegistrySuite({ root, configDir, vitestConfigPath });
914
- const playwrightOutcome = await runPlaywrightSuite({ root, configDir, playwrightConfigPath });
1066
+ const registryOutcome = await runRegistrySuite({ root, configDir, vitestConfigPath, timeoutMs });
1067
+ const playwrightOutcome = await runPlaywrightSuite({ root, configDir, playwrightConfigPath, project: 'contract', timeoutMs });
915
1068
 
916
1069
  const registryMapped = registryOutcome.setupFinding
917
1070
  ? { findings: [registryOutcome.setupFinding], coverage: {}, explained: true }
@@ -945,6 +1098,113 @@ export async function runContractTests(id, { root = process.cwd(), dataDir: expl
945
1098
  }
946
1099
  }
947
1100
 
1101
+ /** One expected id per (page, viewport) pair — `mapPageResults`'s own outer
1102
+ * coverage key. Reads the viewport list off the project's own settings
1103
+ * (`settingsPageViewports`, decision 4's "the settings file may replace the
1104
+ * list") rather than a shipped default, so a page checked at a replaced
1105
+ * viewport reconciles against the sizes the generated Playwright config
1106
+ * actually resolved for that same run, not a stale expectation. */
1107
+ export function pageExpectedIds(routed, root) {
1108
+ const viewports = settingsPageViewports(root);
1109
+ return routed.flatMap((t) => viewports.map((v) => `${t.source}@${v.width}x${v.height}`));
1110
+ }
1111
+
1112
+ /**
1113
+ * `check-page --tests`'s own entry point: the Playwright half of
1114
+ * `runContractTests` above, minus the registry (Vitest) suite pages have no
1115
+ * equivalent of (decision 11) and minus the `contract` project (`project:
1116
+ * 'page'` below, so a page run never opens every component's editor cycle).
1117
+ *
1118
+ * `targets` is `resolvePageTargets`'s own return shape (`bin/lib/
1119
+ * pageRoutes.mjs`): a page with no route carries `route: null` and a
1120
+ * `reason`, reported as its own `tests-setup` finding rather than passed to
1121
+ * the suite, which cannot open a page it has no URL for (decision 3).
1122
+ */
1123
+ export async function runPageTests(targets, { root = process.cwd(), dataDir: explicitDataDir } = {}) {
1124
+ const toolFindings = missingToolFindings(root);
1125
+ if (toolFindings.length > 0) return { findings: toolFindings, coverage: {} };
1126
+
1127
+ if (targets.length === 0) {
1128
+ return {
1129
+ findings: [
1130
+ {
1131
+ rule: 'tests-setup',
1132
+ file: 'package.json',
1133
+ line: 1,
1134
+ message: 'no page renders through a route yet; nothing for --tests to run',
1135
+ },
1136
+ ],
1137
+ coverage: {},
1138
+ };
1139
+ }
1140
+
1141
+ const unrouted = targets.filter((t) => !t.route);
1142
+ const routed = targets.filter((t) => t.route);
1143
+ const unroutedFindings = unrouted.map((t) => ({
1144
+ rule: 'tests-setup',
1145
+ file: t.source,
1146
+ line: 1,
1147
+ message: t.reason ?? 'no route renders this page',
1148
+ }));
1149
+
1150
+ if (routed.length === 0) {
1151
+ return { findings: unroutedFindings, coverage: {} };
1152
+ }
1153
+
1154
+ let sourceDataDir;
1155
+ let dataDir;
1156
+ let configDir;
1157
+ try {
1158
+ sourceDataDir = explicitDataDir ?? resolveSourceDataDir(root, settingsFilePath(root));
1159
+ dataDir = copyIsolatedDataDir(sourceDataDir);
1160
+ configDir = mkdtempSync(join(tmpdir(), 'live-tokens-check-cfg-'));
1161
+ } catch (error) {
1162
+ if (dataDir) rmSync(dataDir, { recursive: true, force: true });
1163
+ if (configDir) rmSync(configDir, { recursive: true, force: true });
1164
+ return {
1165
+ findings: [...unroutedFindings, { rule: 'tests-setup', file: 'package.json', line: 1, message: `could not prepare an isolated run: ${error.message}` }],
1166
+ coverage: {},
1167
+ };
1168
+ }
1169
+
1170
+ const cleanup = withCleanup([dataDir, configDir]);
1171
+
1172
+ try {
1173
+ const timeoutMs = testsTimeoutMs();
1174
+ writeConfigPackageJson(configDir);
1175
+ const playwrightConfigPath = writePlaywrightConfig({ configDir, root, timeoutMs });
1176
+ process.env[TEST_DATA_DIR_ENV] = dataDir;
1177
+ process.env[DATA_DIR_ENV] = dataDir;
1178
+ process.env[PAGES_ENV] = JSON.stringify(routed);
1179
+ delete process.env[COMPONENT_ENV];
1180
+
1181
+ const playwrightOutcome = await runPlaywrightSuite({ root, configDir, playwrightConfigPath, project: 'page', timeoutMs });
1182
+ const mapped = playwrightOutcome.setupFinding
1183
+ ? { findings: [playwrightOutcome.setupFinding], coverage: {}, explained: true }
1184
+ : mapPageResults(playwrightOutcome.report);
1185
+
1186
+ const explainedGlobally =
1187
+ mapped.explained || mapped.findings.some((f) => f.rule === 'tests-setup' || f.rule === 'tests-not-installed');
1188
+ const reconciled = reconcileCoverage(mapped.coverage, pageExpectedIds(routed, root), {
1189
+ expectedRules: PAGE_RUNTIME_RULES,
1190
+ explainedGlobally,
1191
+ });
1192
+
1193
+ return {
1194
+ findings: [...unroutedFindings, ...mapped.findings, ...reconciled.findings],
1195
+ coverage: reconciled.coverage,
1196
+ };
1197
+ } catch (error) {
1198
+ return {
1199
+ findings: [...unroutedFindings, { rule: 'tests-setup', file: 'package.json', line: 1, message: `check-page --tests crashed: ${error.message}` }],
1200
+ coverage: {},
1201
+ };
1202
+ } finally {
1203
+ cleanup();
1204
+ delete process.env[PAGES_ENV];
1205
+ }
1206
+ }
1207
+
948
1208
  export function hasHardFailure(findings) {
949
1209
  return findings.some((f) => HARD_FAILURE_RULES.has(f.rule));
950
1210
  }