@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.
- package/.claude/skills/live-tokens-check-compliance/SKILL.md +2 -0
- 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 +53 -0
- package/bin/check-page.mjs +131 -9
- package/bin/cli.mjs +25 -3
- package/bin/contractRunner.mjs +343 -29
- 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 +3 -3
- package/src/editor/skill-atlas/trees/check-compliance.ts +18 -18
- 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-7TI7Z6Y6.js → chunk-GNIUPIU2.js} +2 -2
- 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 +3 -3
- package/src/testing-js/component-render.contract.js +3 -3
- package/src/testing-js/index.d.ts +8 -2
- package/src/testing-js/index.js +19 -1
- 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 +16 -9
- package/template/package.json +1 -1
- package/template/src/pages/Home.svelte +13 -0
- package/src/testing-js/chunk-ZMSX6CXR.js.map +0 -1
- /package/src/testing-js/{chunk-7TI7Z6Y6.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`
|
|
@@ -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
|
|
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
|
}
|