@motion-proto/live-tokens 0.77.0 → 0.78.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 (37) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +2 -0
  2. package/.claude/skills/live-tokens-create-page/SKILL.md +5 -4
  3. package/.claude/skills/live-tokens-fix-findings/SKILL.md +7 -0
  4. package/CHANGELOG.md +53 -0
  5. package/bin/check-page.mjs +131 -9
  6. package/bin/cli.mjs +25 -3
  7. package/bin/contractRunner.mjs +343 -29
  8. package/bin/lib/pageRoutes.mjs +309 -0
  9. package/package.json +5 -3
  10. package/src/editor/overlay/ColumnsOverlay.svelte +1 -1
  11. package/src/editor/overlay/LiveEditorOverlay.svelte +1 -0
  12. package/src/editor/overlay/LiveTokensRouter.svelte +1 -0
  13. package/src/editor/skill-atlas/skillSources.generated.ts +3 -3
  14. package/src/editor/skill-atlas/trees/check-compliance.ts +18 -18
  15. package/src/editor/skill-atlas/trees/create-page.ts +26 -26
  16. package/src/editor/skill-atlas/trees/fix-findings.ts +46 -11
  17. package/src/testing-js/{chunk-7TI7Z6Y6.js → chunk-GNIUPIU2.js} +2 -2
  18. package/src/testing-js/{chunk-ZMSX6CXR.js → chunk-NB3NZRBM.js} +13 -2
  19. package/src/testing-js/chunk-NB3NZRBM.js.map +1 -0
  20. package/src/testing-js/{chunk-L73N4NSO.js → chunk-WIZ6W7UT.js} +2 -2
  21. package/src/testing-js/component-alias.contract.js +2 -2
  22. package/src/testing-js/component-editor.contract.js +3 -3
  23. package/src/testing-js/component-render.contract.js +3 -3
  24. package/src/testing-js/index.d.ts +8 -2
  25. package/src/testing-js/index.js +19 -1
  26. package/src/testing-js/index.js.map +1 -1
  27. package/src/testing-js/page-compliance.contract.js +829 -0
  28. package/src/testing-js/page-compliance.contract.js.map +1 -0
  29. package/src/testing-js/{vitest-BE6uGF31.d.ts → vitest-C-wNWcoA.d.ts} +21 -1
  30. package/src/testing-js/vitest.d.ts +1 -1
  31. package/src/testing-js/vitest.js +1 -1
  32. package/template/README.md +16 -9
  33. package/template/package.json +1 -1
  34. package/template/src/pages/Home.svelte +13 -0
  35. package/src/testing-js/chunk-ZMSX6CXR.js.map +0 -1
  36. /package/src/testing-js/{chunk-7TI7Z6Y6.js.map → chunk-GNIUPIU2.js.map} +0 -0
  37. /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).
@@ -23,12 +29,29 @@ 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';
25
31
  import { lineOf } from './lib/findings.mjs';
32
+ import { settingsPageViewports } from './lib/pageRoutes.mjs';
26
33
 
27
34
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
28
35
 
29
36
  const TEST_DATA_DIR_ENV = 'LIVE_TOKENS_TEST_DATA_DIR';
30
37
  const DATA_DIR_ENV = 'LIVE_TOKENS_DATA_DIR';
31
38
  const COMPONENT_ENV = 'LIVE_TOKENS_COMPONENT';
39
+ // Mirrors src/testing/config.ts's own PAGES_ENV: the page targets a run opens,
40
+ // as JSON. Duplicated rather than imported for the same reason as
41
+ // SESSION_FILES below.
42
+ const PAGES_ENV = 'LIVE_TOKENS_PAGES';
43
+
44
+ /** Milliseconds a spawned tool gets before this file sends it SIGINT, then
45
+ * SIGKILL five seconds later. A hung dev server or worker can no longer hold
46
+ * a caller open past this. */
47
+ const DEFAULT_TESTS_TIMEOUT_MS = 15 * 60_000;
48
+ const TESTS_TIMEOUT_ENV = 'LIVE_TOKENS_TESTS_TIMEOUT';
49
+
50
+ function testsTimeoutMs() {
51
+ const raw = process.env[TESTS_TIMEOUT_ENV];
52
+ const ms = raw ? Number(raw) : NaN;
53
+ return Number.isFinite(ms) && ms > 0 ? ms : DEFAULT_TESTS_TIMEOUT_MS;
54
+ }
32
55
 
33
56
  // Mirrors src/testing/isolation.ts's own list. Duplicated rather than shared:
34
57
  // that module compiles into src/testing-js, which does not exist until
@@ -332,27 +355,41 @@ function withCleanup(paths) {
332
355
 
333
356
  // ─── generated tool configs ─────────────────────────────────────────────────
334
357
 
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".
358
+ // `configDir` sits under the OS temp directory, which has no ancestor
359
+ // `package.json`. Without one naming `"type": "module"` here, Node treats
360
+ // these configs as CommonJS by default, and `createPlaywrightConfig`'s
361
+ // `import.meta.url` throws "Cannot use 'import.meta' outside a module".
362
+ export function writeConfigPackageJson(configDir) {
340
363
  writeFileSync(join(configDir, 'package.json'), JSON.stringify({ type: 'module' }));
364
+ }
341
365
 
366
+ /** A source fragment for the generated files below, not an actual settings
367
+ * object: this module never imports the consumer's settings file itself (see
368
+ * the static-import note above), so it only ever sees this as text.
369
+ * `settingsModule` is already the file's default export (a default import
370
+ * unwraps it), not a module namespace object — it has no `.default` of its
371
+ * own. */
372
+ function settingsFragments(root) {
342
373
  const settingsPath = settingsFilePath(root);
343
- const testingIndex = resolveTestingEntry('index');
344
- const testingVitest = resolveTestingEntry('vitest');
345
- const viteConfigPath = guessViteConfigPath(root, settingsPath);
346
-
347
374
  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
375
  const settingsExpr = settingsPath ? 'settingsModule' : '{}';
376
+ return { settingsPath, settingsImport, settingsExpr };
377
+ }
355
378
 
379
+ /**
380
+ * The `page` project alone, config `globalTimeout` set to `timeoutMs`: a
381
+ * `check-page --tests` run must never also carry the whole `contract` project
382
+ * (every component's editor cycle, against no `LIVE_TOKENS_COMPONENT`), and a
383
+ * page suite that hangs reports `timedOut` tests in a real JSON report before
384
+ * this file's own SIGINT/SIGKILL watchdog (`runCli`) ever has to act.
385
+ * `globalTimeout` is `createPlaywrightConfig`'s own option (`src/testing/
386
+ * playwright.ts`), passed through rather than patched onto the resolved
387
+ * config afterward, so the bound holds for every caller of that factory, not
388
+ * only the CLI's generated configs.
389
+ */
390
+ export function writePlaywrightConfig({ configDir, root, timeoutMs = DEFAULT_TESTS_TIMEOUT_MS } = {}) {
391
+ const { settingsImport, settingsExpr } = settingsFragments(root);
392
+ const testingIndex = resolveTestingEntry('index');
356
393
  const playwrightConfigPath = join(configDir, 'playwright.config.ts');
357
394
  writeFileSync(
358
395
  playwrightConfigPath,
@@ -361,9 +398,20 @@ export function writeGeneratedConfigs({ configDir, root }) {
361
398
  export default createPlaywrightConfig({
362
399
  ...${settingsExpr},
363
400
  root: ${JSON.stringify(root)},
401
+ globalTimeout: ${JSON.stringify(timeoutMs)},
364
402
  });
365
403
  `,
366
404
  );
405
+ return playwrightConfigPath;
406
+ }
407
+
408
+ export function writeGeneratedConfigs({ configDir, root, timeoutMs = DEFAULT_TESTS_TIMEOUT_MS } = {}) {
409
+ writeConfigPackageJson(configDir);
410
+ const playwrightConfigPath = writePlaywrightConfig({ configDir, root, timeoutMs });
411
+
412
+ const { settingsPath, settingsImport, settingsExpr } = settingsFragments(root);
413
+ const testingVitest = resolveTestingEntry('vitest');
414
+ const viteConfigPath = guessViteConfigPath(root, settingsPath);
367
415
 
368
416
  const vitestConfigPath = join(configDir, 'vitest.config.ts');
369
417
  writeFileSync(
@@ -384,17 +432,30 @@ export default createVitestConfig(viteConfigModule.default ?? viteConfigModule,
384
432
 
385
433
  // ─── subprocess execution ───────────────────────────────────────────────────
386
434
 
387
- function runCli(command, args, { cwd, env }) {
435
+ /** Spawns `command`, and at `timeoutMs` sends the child SIGINT (Playwright's
436
+ * own graceful-stop signal, per `withCleanup`'s own note above), then
437
+ * SIGKILL five seconds later if it has not exited. `result.timedOut` tells
438
+ * the caller the run never finished on its own, whatever the child's own
439
+ * exit code or report ends up saying. */
440
+ function runCli(command, args, { cwd, env, timeoutMs = DEFAULT_TESTS_TIMEOUT_MS } = {}) {
388
441
  return new Promise((resolveRun) => {
389
442
  const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
390
443
  activeChild = child;
391
444
  let stdout = '';
392
445
  let stderr = '';
446
+ let timedOut = false;
393
447
  child.stdout.on('data', (chunk) => (stdout += chunk));
394
448
  child.stderr.on('data', (chunk) => (stderr += chunk));
449
+ const deadline = setTimeout(() => {
450
+ timedOut = true;
451
+ const killer = setTimeout(() => child.kill('SIGKILL'), 5_000);
452
+ child.once('exit', () => clearTimeout(killer));
453
+ child.kill('SIGINT');
454
+ }, timeoutMs);
395
455
  const finish = (result) => {
456
+ clearTimeout(deadline);
396
457
  if (activeChild === child) activeChild = null;
397
- resolveRun(result);
458
+ resolveRun({ ...result, timedOut });
398
459
  };
399
460
  child.on('close', (code) => finish({ code, stdout, stderr }));
400
461
  child.on('error', (error) => finish({ code: -1, stdout, stderr: `${stderr}\n${error.message}` }));
@@ -410,6 +471,17 @@ function setupFinding(tool, code, stdout, stderr) {
410
471
  };
411
472
  }
412
473
 
474
+ /** `tests-incomplete`, not `tests-setup`: the tool itself never got the
475
+ * chance to explain the failure, this file's own watchdog cut it off. */
476
+ export function timeoutFinding(tool, timeoutMs) {
477
+ return {
478
+ rule: 'tests-incomplete',
479
+ file: 'package.json',
480
+ line: 1,
481
+ 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.`,
482
+ };
483
+ }
484
+
413
485
  /** Reads a JSON report or explains, as a `tests-setup` finding, why there
414
486
  * isn't one. Split from the process-spawning around it so a malformed or
415
487
  * absent report is testable without a subprocess. */
@@ -422,25 +494,29 @@ export function readReportOrSetupFinding(tool, reportPath, code, stdout, stderr)
422
494
  }
423
495
  }
424
496
 
425
- export async function runPlaywrightSuite({ root, configDir, playwrightConfigPath }) {
497
+ export async function runPlaywrightSuite({ root, configDir, playwrightConfigPath, project, timeoutMs = DEFAULT_TESTS_TIMEOUT_MS }) {
426
498
  const reportPath = join(configDir, 'playwright-report.json');
427
499
  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
- );
500
+ const args = [bin, 'test', '-c', playwrightConfigPath, '--reporter=json'];
501
+ if (project) args.push('--project', project);
502
+ const { code, stdout, stderr, timedOut } = await runCli(process.execPath, args, {
503
+ cwd: root,
504
+ env: { ...process.env, PLAYWRIGHT_JSON_OUTPUT_FILE: reportPath },
505
+ timeoutMs,
506
+ });
507
+ if (timedOut) return { setupFinding: timeoutFinding('Playwright', timeoutMs) };
433
508
  return readReportOrSetupFinding('Playwright', reportPath, code, stdout, stderr);
434
509
  }
435
510
 
436
- export async function runRegistrySuite({ root, configDir, vitestConfigPath }) {
511
+ export async function runRegistrySuite({ root, configDir, vitestConfigPath, timeoutMs = DEFAULT_TESTS_TIMEOUT_MS }) {
437
512
  const reportPath = join(configDir, 'vitest-report.json');
438
513
  const bin = peerBin(root, 'vitest', 'vitest.mjs');
439
- const { code, stdout, stderr } = await runCli(
514
+ const { code, stdout, stderr, timedOut } = await runCli(
440
515
  process.execPath,
441
516
  [bin, 'run', '--config', vitestConfigPath, '--reporter=json', '--outputFile', reportPath],
442
- { cwd: root, env: process.env },
517
+ { cwd: root, env: process.env, timeoutMs },
443
518
  );
519
+ if (timedOut) return { setupFinding: timeoutFinding('Vitest', timeoutMs) };
444
520
  const outcome = readReportOrSetupFinding('Vitest', reportPath, code, stdout, stderr);
445
521
  // Kept alongside a successfully-parsed report too: a file that failed to
446
522
  // collect a single test still carries a report, and `mapVitestResults`
@@ -712,6 +788,136 @@ export function mapPlaywrightResults(report, { root, sourceDataDir, knownIds })
712
788
  return { findings, coverage };
713
789
  }
714
790
 
791
+ // ─── result mapping: page rules ─────────────────────────────────────────────
792
+
793
+ /** The plan's Runtime rule ids, fixed rather than read off a page's own
794
+ * results: `reconcileCoverage`'s expected set, same reasoning as
795
+ * `ALL_CONTRACT_RULES` above. */
796
+ const PAGE_RUNTIME_RULES = ['page-component-paint', 'page-text-style', 'page-contrast', 'page-grid', 'page-overflow'];
797
+
798
+ /** `page-compliance.contract.ts` titles each test `${rule} | ${source} |
799
+ * ${width}x${height}`, so the rule, the page, and the viewport are read off
800
+ * the title rather than off describe-block position (there is none — every
801
+ * page test is a sibling at the suite's top level). */
802
+ const PAGE_TEST_TITLE_RE = /^(page-[a-z-]+) \| (.+) \| (\d+x\d+)$/;
803
+
804
+ /** A `PageViolation`'s own `[rule] source:line: message`, read back out of
805
+ * Playwright's `Error.prototype.toString()` (`${name}: ${message}`), the
806
+ * same convention `VIOLATION_RE` above reads `ContractViolation` through. */
807
+ const PAGE_VIOLATION_RE = /^PageViolation:\s*\[([a-z-]+)\]\s+(.+):(\d+):\s*([\s\S]*)$/;
808
+
809
+ /**
810
+ * `PageViolation` to a finding at the page file and line; a timeout or an
811
+ * interruption to `tests-incomplete`; a `test.skip(...)` call — the mechanism
812
+ * `PageHarness.assert*` uses to report a rule inapplicable — to `inapplicable`
813
+ * coverage carrying the skip's own description, never a finding. Structurally
814
+ * the Playwright half of `mapPlaywrightResults` above, with no component,
815
+ * describe-block, or `ContractViolation` concept to lean on: every triple's
816
+ * identity comes off the test's own title.
817
+ */
818
+ export function mapPageResults(report) {
819
+ const zeroCollected = (report.suites ?? []).length === 0;
820
+ if (zeroCollected || (report.errors ?? []).length > 0) {
821
+ const detail = (report.errors ?? []).map((e) => e.message).join('\n') || 'the run collected no tests';
822
+ return {
823
+ findings: [{ rule: 'tests-incomplete', file: 'package.json', line: 1, message: `Playwright collected nothing to check: ${detail}` }],
824
+ coverage: {},
825
+ explained: true,
826
+ };
827
+ }
828
+
829
+ const tests = readPlaywrightTests(report);
830
+
831
+ for (const t of tests) {
832
+ if (t.status !== 'unexpected') continue;
833
+ const infra = classifyInfrastructureError(t.lastResult?.errors?.[0]?.message);
834
+ if (infra?.rule === 'tests-not-installed') {
835
+ return { findings: [{ rule: infra.rule, file: 'package.json', line: 1, message: infra.message }], coverage: {}, explained: true };
836
+ }
837
+ }
838
+
839
+ const findings = [];
840
+ const coverage = {};
841
+ // The outer key carries the page and the viewport, the inner key the bare
842
+ // rule id — never the reverse: `applyCoverageSeverity` and `--off` (`bin/
843
+ // lib/findings.mjs`) resolve a coverage entry's severity by looking up its
844
+ // inner key straight in `PAGE_RULES`, so a composite `rule@viewport` inner
845
+ // key would never match a plain rule id and `--off`/`checks.rules` would
846
+ // silently stop reaching page coverage.
847
+ const markCoverage = (pageAtViewport, rule, status, reason) => {
848
+ if (!pageAtViewport || !rule) return;
849
+ coverage[pageAtViewport] ??= {};
850
+ const existing = coverage[pageAtViewport][rule];
851
+ if (!existing || COVERAGE_PRIORITY[status] >= COVERAGE_PRIORITY[existing.status]) {
852
+ coverage[pageAtViewport][rule] = reason ? { status, reason } : { status };
853
+ }
854
+ };
855
+
856
+ for (const t of tests) {
857
+ const [, titleRule, titleSource, titleViewport] = PAGE_TEST_TITLE_RE.exec(t.specTitle) ?? [];
858
+ const pageAtViewport = titleSource && titleViewport ? `${titleSource}@${titleViewport}` : null;
859
+
860
+ if (t.status === 'expected' || t.status === 'flaky') {
861
+ markCoverage(pageAtViewport, titleRule, t.status === 'flaky' ? 'flaky' : 'passed');
862
+ continue;
863
+ }
864
+ if (t.status === 'skipped') {
865
+ // Playwright's own `skip` annotation, from `test.skip(condition,
866
+ // description)` inside the test body — decision 10's "inapplicable is a
867
+ // status with a reason", never a silent pass.
868
+ const reason = t.annotations.find((a) => a.type === 'skip')?.description || 'inapplicable';
869
+ markCoverage(pageAtViewport, titleRule, 'inapplicable', reason);
870
+ continue;
871
+ }
872
+
873
+ const last = t.lastResult;
874
+ if (last?.status === 'timedOut' || last?.status === 'interrupted') {
875
+ findings.push({
876
+ rule: 'tests-incomplete',
877
+ file: 'package.json',
878
+ line: 1,
879
+ message: `"${t.specTitle}" did not finish (${last.status})`,
880
+ context: { suite: 'playwright', title: t.specTitle, suiteFile: t.specFile, suiteLine: t.specLine },
881
+ });
882
+ markCoverage(pageAtViewport, titleRule, 'failed');
883
+ continue;
884
+ }
885
+
886
+ const rawErrors = last?.errors?.length ? last.errors : [{ message: `${t.status}: ${t.specTitle}` }];
887
+ for (const error of rawErrors) {
888
+ const infra = classifyInfrastructureError(error.message);
889
+ if (infra) {
890
+ findings.push({
891
+ rule: infra.rule,
892
+ file: 'package.json',
893
+ line: 1,
894
+ message: infra.message,
895
+ context: { suite: 'playwright', title: t.specTitle },
896
+ });
897
+ markCoverage(pageAtViewport, titleRule, 'failed');
898
+ continue;
899
+ }
900
+ const block = messageBlock(error.message);
901
+ const violation = PAGE_VIOLATION_RE.exec(block);
902
+ findings.push({
903
+ rule: violation?.[1] ?? titleRule ?? 'tests-setup',
904
+ file: violation?.[2] ?? titleSource ?? 'package.json',
905
+ line: violation ? Number(violation[3]) : 1,
906
+ message: violation?.[4] ?? block,
907
+ context: {
908
+ suite: 'playwright',
909
+ title: t.specTitle,
910
+ suiteFile: t.specFile,
911
+ suiteLine: t.specLine,
912
+ attachments: (last?.attachments ?? []).map((a) => a.path).filter(Boolean),
913
+ },
914
+ });
915
+ markCoverage(pageAtViewport, titleRule, 'failed');
916
+ }
917
+ }
918
+ return { findings, coverage };
919
+ }
920
+
715
921
  // ─── result mapping: Vitest (registry contract) ─────────────────────────────
716
922
 
717
923
  /** `checkRegistryEntry`'s violation strings, read back out of Vitest's
@@ -898,7 +1104,8 @@ export async function runContractTests(id, { root = process.cwd(), dataDir: expl
898
1104
  const cleanup = withCleanup([dataDir, configDir]);
899
1105
 
900
1106
  try {
901
- const { playwrightConfigPath, vitestConfigPath } = writeGeneratedConfigs({ configDir, root });
1107
+ const timeoutMs = testsTimeoutMs();
1108
+ const { playwrightConfigPath, vitestConfigPath } = writeGeneratedConfigs({ configDir, root, timeoutMs });
902
1109
  process.env[TEST_DATA_DIR_ENV] = dataDir;
903
1110
  process.env[DATA_DIR_ENV] = dataDir;
904
1111
  if (id) process.env[COMPONENT_ENV] = id;
@@ -910,8 +1117,8 @@ export async function runContractTests(id, { root = process.cwd(), dataDir: expl
910
1117
  // Sequential: both share the one isolated data directory, and the
911
1118
  // registry run's own Vite instance and the Playwright run's dev server
912
1119
  // would otherwise regenerate the same derived files concurrently.
913
- const registryOutcome = await runRegistrySuite({ root, configDir, vitestConfigPath });
914
- const playwrightOutcome = await runPlaywrightSuite({ root, configDir, playwrightConfigPath });
1120
+ const registryOutcome = await runRegistrySuite({ root, configDir, vitestConfigPath, timeoutMs });
1121
+ const playwrightOutcome = await runPlaywrightSuite({ root, configDir, playwrightConfigPath, project: 'contract', timeoutMs });
915
1122
 
916
1123
  const registryMapped = registryOutcome.setupFinding
917
1124
  ? { findings: [registryOutcome.setupFinding], coverage: {}, explained: true }
@@ -945,6 +1152,113 @@ export async function runContractTests(id, { root = process.cwd(), dataDir: expl
945
1152
  }
946
1153
  }
947
1154
 
1155
+ /** One expected id per (page, viewport) pair — `mapPageResults`'s own outer
1156
+ * coverage key. Reads the viewport list off the project's own settings
1157
+ * (`settingsPageViewports`, decision 4's "the settings file may replace the
1158
+ * list") rather than a shipped default, so a page checked at a replaced
1159
+ * viewport reconciles against the sizes the generated Playwright config
1160
+ * actually resolved for that same run, not a stale expectation. */
1161
+ export function pageExpectedIds(routed, root) {
1162
+ const viewports = settingsPageViewports(root);
1163
+ return routed.flatMap((t) => viewports.map((v) => `${t.source}@${v.width}x${v.height}`));
1164
+ }
1165
+
1166
+ /**
1167
+ * `check-page --tests`'s own entry point: the Playwright half of
1168
+ * `runContractTests` above, minus the registry (Vitest) suite pages have no
1169
+ * equivalent of (decision 11) and minus the `contract` project (`project:
1170
+ * 'page'` below, so a page run never opens every component's editor cycle).
1171
+ *
1172
+ * `targets` is `resolvePageTargets`'s own return shape (`bin/lib/
1173
+ * pageRoutes.mjs`): a page with no route carries `route: null` and a
1174
+ * `reason`, reported as its own `tests-setup` finding rather than passed to
1175
+ * the suite, which cannot open a page it has no URL for (decision 3).
1176
+ */
1177
+ export async function runPageTests(targets, { root = process.cwd(), dataDir: explicitDataDir } = {}) {
1178
+ const toolFindings = missingToolFindings(root);
1179
+ if (toolFindings.length > 0) return { findings: toolFindings, coverage: {} };
1180
+
1181
+ if (targets.length === 0) {
1182
+ return {
1183
+ findings: [
1184
+ {
1185
+ rule: 'tests-setup',
1186
+ file: 'package.json',
1187
+ line: 1,
1188
+ message: 'no page renders through a route yet; nothing for --tests to run',
1189
+ },
1190
+ ],
1191
+ coverage: {},
1192
+ };
1193
+ }
1194
+
1195
+ const unrouted = targets.filter((t) => !t.route);
1196
+ const routed = targets.filter((t) => t.route);
1197
+ const unroutedFindings = unrouted.map((t) => ({
1198
+ rule: 'tests-setup',
1199
+ file: t.source,
1200
+ line: 1,
1201
+ message: t.reason ?? 'no route renders this page',
1202
+ }));
1203
+
1204
+ if (routed.length === 0) {
1205
+ return { findings: unroutedFindings, coverage: {} };
1206
+ }
1207
+
1208
+ let sourceDataDir;
1209
+ let dataDir;
1210
+ let configDir;
1211
+ try {
1212
+ sourceDataDir = explicitDataDir ?? resolveSourceDataDir(root, settingsFilePath(root));
1213
+ dataDir = copyIsolatedDataDir(sourceDataDir);
1214
+ configDir = mkdtempSync(join(tmpdir(), 'live-tokens-check-cfg-'));
1215
+ } catch (error) {
1216
+ if (dataDir) rmSync(dataDir, { recursive: true, force: true });
1217
+ if (configDir) rmSync(configDir, { recursive: true, force: true });
1218
+ return {
1219
+ findings: [...unroutedFindings, { rule: 'tests-setup', file: 'package.json', line: 1, message: `could not prepare an isolated run: ${error.message}` }],
1220
+ coverage: {},
1221
+ };
1222
+ }
1223
+
1224
+ const cleanup = withCleanup([dataDir, configDir]);
1225
+
1226
+ try {
1227
+ const timeoutMs = testsTimeoutMs();
1228
+ writeConfigPackageJson(configDir);
1229
+ const playwrightConfigPath = writePlaywrightConfig({ configDir, root, timeoutMs });
1230
+ process.env[TEST_DATA_DIR_ENV] = dataDir;
1231
+ process.env[DATA_DIR_ENV] = dataDir;
1232
+ process.env[PAGES_ENV] = JSON.stringify(routed);
1233
+ delete process.env[COMPONENT_ENV];
1234
+
1235
+ const playwrightOutcome = await runPlaywrightSuite({ root, configDir, playwrightConfigPath, project: 'page', timeoutMs });
1236
+ const mapped = playwrightOutcome.setupFinding
1237
+ ? { findings: [playwrightOutcome.setupFinding], coverage: {}, explained: true }
1238
+ : mapPageResults(playwrightOutcome.report);
1239
+
1240
+ const explainedGlobally =
1241
+ mapped.explained || mapped.findings.some((f) => f.rule === 'tests-setup' || f.rule === 'tests-not-installed');
1242
+ const reconciled = reconcileCoverage(mapped.coverage, pageExpectedIds(routed, root), {
1243
+ expectedRules: PAGE_RUNTIME_RULES,
1244
+ explainedGlobally,
1245
+ });
1246
+
1247
+ return {
1248
+ findings: [...unroutedFindings, ...mapped.findings, ...reconciled.findings],
1249
+ coverage: reconciled.coverage,
1250
+ };
1251
+ } catch (error) {
1252
+ return {
1253
+ findings: [...unroutedFindings, { rule: 'tests-setup', file: 'package.json', line: 1, message: `check-page --tests crashed: ${error.message}` }],
1254
+ coverage: {},
1255
+ };
1256
+ } finally {
1257
+ cleanup();
1258
+ delete process.env[PAGES_ENV];
1259
+ }
1260
+ }
1261
+
948
1262
  export function hasHardFailure(findings) {
949
1263
  return findings.some((f) => HARD_FAILURE_RULES.has(f.rule));
950
1264
  }