@motion-proto/live-tokens 0.76.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.
- package/.claude/skills/live-tokens-check-compliance/SKILL.md +2 -0
- package/.claude/skills/live-tokens-create-component/SKILL.md +3 -3
- package/.claude/skills/live-tokens-create-component/references/contract-tests.md +99 -3
- package/.claude/skills/live-tokens-create-page/SKILL.md +5 -4
- package/.claude/skills/live-tokens-fix-findings/SKILL.md +7 -0
- package/CHANGELOG.md +90 -0
- package/bin/check-component.mjs +2 -0
- package/bin/check-page.mjs +131 -9
- package/bin/cli.mjs +25 -3
- package/bin/contractRunner.mjs +349 -30
- package/bin/lib/pageRoutes.mjs +309 -0
- package/package.json +5 -3
- package/src/editor/overlay/ColumnsOverlay.svelte +1 -1
- package/src/editor/overlay/LiveEditorOverlay.svelte +1 -0
- package/src/editor/overlay/LiveTokensRouter.svelte +1 -0
- package/src/editor/skill-atlas/skillSources.generated.ts +5 -5
- package/src/editor/skill-atlas/trees/check-compliance.ts +18 -18
- package/src/editor/skill-atlas/trees/create-component.ts +8 -3
- package/src/editor/skill-atlas/trees/create-page.ts +26 -26
- package/src/editor/skill-atlas/trees/fix-findings.ts +46 -11
- package/src/testing-js/{chunk-LXR3MN6N.js → chunk-4JQX6WWL.js} +1 -1
- package/src/testing-js/chunk-4JQX6WWL.js.map +1 -0
- package/src/testing-js/{chunk-AO7EZHYV.js → chunk-GNIUPIU2.js} +3 -3
- package/src/testing-js/{chunk-ZMSX6CXR.js → chunk-NB3NZRBM.js} +13 -2
- package/src/testing-js/chunk-NB3NZRBM.js.map +1 -0
- package/src/testing-js/{chunk-L73N4NSO.js → chunk-WIZ6W7UT.js} +2 -2
- package/src/testing-js/component-alias.contract.js +2 -2
- package/src/testing-js/component-editor.contract.js +21 -5
- package/src/testing-js/component-editor.contract.js.map +1 -1
- package/src/testing-js/component-render.contract.js +4 -4
- package/src/testing-js/index.d.ts +9 -3
- package/src/testing-js/index.js +20 -12
- package/src/testing-js/index.js.map +1 -1
- package/src/testing-js/page-compliance.contract.js +829 -0
- package/src/testing-js/page-compliance.contract.js.map +1 -0
- package/src/testing-js/{vitest-BE6uGF31.d.ts → vitest-C-wNWcoA.d.ts} +21 -1
- package/src/testing-js/vitest.d.ts +1 -1
- package/src/testing-js/vitest.js +1 -1
- package/template/README.md +17 -5
- package/template/live-tokens.testing.ts +6 -0
- package/template/package.json +5 -2
- package/template/src/main.ts +1 -0
- package/template/src/pages/Home.svelte +13 -0
- package/template/src/registerComponents.ts +4 -0
- package/template/tests/contracts.ts +3 -0
- package/template/tsconfig.json +1 -1
- package/src/testing-js/chunk-LXR3MN6N.js.map +0 -1
- package/src/testing-js/chunk-ZMSX6CXR.js.map +0 -1
- /package/src/testing-js/{chunk-AO7EZHYV.js.map → chunk-GNIUPIU2.js.map} +0 -0
- /package/src/testing-js/{chunk-L73N4NSO.js.map → chunk-WIZ6W7UT.js.map} +0 -0
package/bin/contractRunner.mjs
CHANGED
|
@@ -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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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`
|
|
@@ -552,10 +628,15 @@ export function readPlaywrightTests(report) {
|
|
|
552
628
|
* the shipped `.contract.ts` suite (absent from the tarball, and Playwright
|
|
553
629
|
* reports no assertion location for it once compiled — see the plan's Wave
|
|
554
630
|
* 3b/4 notes). `contract-alias`/`persist`/`theme` name the shipped config;
|
|
555
|
-
* `contract-listed` names the editor; render/preview/sketch name the runtime
|
|
631
|
+
* `contract-listed` names the editor; render/preview/sketch name the runtime;
|
|
632
|
+
* `contract-missing` names the settings file the contract module is declared in.
|
|
556
633
|
*/
|
|
557
634
|
export function artifactForContractRule(root, sourceDataDir, rule, componentId, token) {
|
|
558
635
|
if (!componentId) return { file: 'package.json', line: 1 };
|
|
636
|
+
if (rule === 'contract-missing') {
|
|
637
|
+
const settings = settingsFilePath(root);
|
|
638
|
+
return { file: settings ? relative(root, settings) : 'package.json', line: 1 };
|
|
639
|
+
}
|
|
559
640
|
const paths = resolveComponentPaths(componentId, root);
|
|
560
641
|
if (rule === 'contract-alias' || rule === 'contract-persist' || rule === 'contract-theme') {
|
|
561
642
|
const target = componentConfigPath(sourceDataDir, componentId);
|
|
@@ -707,6 +788,136 @@ export function mapPlaywrightResults(report, { root, sourceDataDir, knownIds })
|
|
|
707
788
|
return { findings, coverage };
|
|
708
789
|
}
|
|
709
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
|
+
|
|
710
921
|
// ─── result mapping: Vitest (registry contract) ─────────────────────────────
|
|
711
922
|
|
|
712
923
|
/** `checkRegistryEntry`'s violation strings, read back out of Vitest's
|
|
@@ -893,7 +1104,8 @@ export async function runContractTests(id, { root = process.cwd(), dataDir: expl
|
|
|
893
1104
|
const cleanup = withCleanup([dataDir, configDir]);
|
|
894
1105
|
|
|
895
1106
|
try {
|
|
896
|
-
const
|
|
1107
|
+
const timeoutMs = testsTimeoutMs();
|
|
1108
|
+
const { playwrightConfigPath, vitestConfigPath } = writeGeneratedConfigs({ configDir, root, timeoutMs });
|
|
897
1109
|
process.env[TEST_DATA_DIR_ENV] = dataDir;
|
|
898
1110
|
process.env[DATA_DIR_ENV] = dataDir;
|
|
899
1111
|
if (id) process.env[COMPONENT_ENV] = id;
|
|
@@ -905,8 +1117,8 @@ export async function runContractTests(id, { root = process.cwd(), dataDir: expl
|
|
|
905
1117
|
// Sequential: both share the one isolated data directory, and the
|
|
906
1118
|
// registry run's own Vite instance and the Playwright run's dev server
|
|
907
1119
|
// would otherwise regenerate the same derived files concurrently.
|
|
908
|
-
const registryOutcome = await runRegistrySuite({ root, configDir, vitestConfigPath });
|
|
909
|
-
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 });
|
|
910
1122
|
|
|
911
1123
|
const registryMapped = registryOutcome.setupFinding
|
|
912
1124
|
? { findings: [registryOutcome.setupFinding], coverage: {}, explained: true }
|
|
@@ -940,6 +1152,113 @@ export async function runContractTests(id, { root = process.cwd(), dataDir: expl
|
|
|
940
1152
|
}
|
|
941
1153
|
}
|
|
942
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
|
+
|
|
943
1262
|
export function hasHardFailure(findings) {
|
|
944
1263
|
return findings.some((f) => HARD_FAILURE_RULES.has(f.rule));
|
|
945
1264
|
}
|