@motion-proto/live-tokens 0.74.0 → 0.76.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 +33 -38
- package/.claude/skills/live-tokens-create-component/SKILL copy.md +196 -0
- package/.claude/skills/live-tokens-create-component/SKILL.md +198 -146
- package/.claude/skills/live-tokens-create-component/references/contract-tests.md +95 -52
- package/.claude/skills/live-tokens-create-component/references/intrinsics.md +2 -2
- package/.claude/skills/live-tokens-create-component/references/linked-siblings.md +2 -2
- package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +12 -12
- package/.claude/skills/live-tokens-create-component/references/token-naming.md +2 -1
- package/.claude/skills/live-tokens-create-page/SKILL.md +187 -0
- package/.claude/skills/live-tokens-create-page/references/interaction-sources.md +66 -0
- package/.claude/skills/live-tokens-create-page/references/layout-sources.md +87 -0
- package/.claude/skills/live-tokens-create-theme/SKILL.md +52 -48
- package/.claude/skills/live-tokens-create-theme/references/design-directions.md +1 -1
- package/.claude/skills/live-tokens-fix-findings/SKILL.md +69 -60
- package/.claude/skills/live-tokens-pick-component/SKILL.md +64 -79
- package/.claude/skills/live-tokens-set-colors/SKILL.md +47 -38
- package/.claude/skills/live-tokens-set-geometry/SKILL.md +58 -35
- package/.claude/skills/live-tokens-set-geometry/references/geometry-anchors.md +5 -3
- package/.claude/skills/live-tokens-set-type/SKILL.md +30 -30
- package/CHANGELOG.md +138 -0
- package/README.md +17 -7
- package/bin/check-component.mjs +91 -19
- package/bin/check-page.mjs +102 -21
- package/bin/cli.mjs +87 -113
- package/bin/contractRunner.mjs +945 -0
- package/bin/create.mjs +1 -1
- package/bin/lib/catalogue.mjs +37 -30
- package/bin/lib/findings.mjs +46 -15
- package/bin/lib/report.mjs +3 -3
- package/bin/lib/tokenVocabulary.mjs +4 -4
- package/bin/migrate-routes.mjs +5 -5
- package/bin/migrate.mjs +4 -4
- package/bin/save-theme.mjs +8 -9
- package/bin/set-colors.mjs +9 -11
- package/bin/set-geometry.mjs +7 -7
- package/bin/set-type.mjs +5 -7
- package/bin/setup-claude.mjs +110 -0
- package/dist-plugin/{chunk-W6Y4BWFB.js → chunk-6WGFOJXO.js} +22 -0
- package/dist-plugin/{chunk-7VRTBGJT.js → chunk-PDNL4NC5.js} +9 -2
- package/dist-plugin/{chunk-V3YF6CGT.js → chunk-SWXRVZKT.js} +43 -1
- package/dist-plugin/{dataPaths-BhWzd5cL.d.cts → dataPaths-BpIK_Obx.d.cts} +1 -0
- package/dist-plugin/{dataPaths-BhWzd5cL.d.ts → dataPaths-BpIK_Obx.d.ts} +1 -0
- package/dist-plugin/index.cjs +310 -147
- package/dist-plugin/index.d.cts +1 -1
- package/dist-plugin/index.d.ts +1 -1
- package/dist-plugin/index.js +218 -122
- package/dist-plugin/migrateData/index.cjs +66 -2
- package/dist-plugin/migrateData/index.d.cts +1 -1
- package/dist-plugin/migrateData/index.d.ts +1 -1
- package/dist-plugin/migrateData/index.js +3 -3
- package/dist-plugin/setColors/index.cjs +61 -4
- package/dist-plugin/setColors/index.d.cts +1 -1
- package/dist-plugin/setColors/index.d.ts +1 -1
- package/dist-plugin/setColors/index.js +5 -5
- package/dist-plugin/setGeometry/index.cjs +107 -44
- package/dist-plugin/setGeometry/index.d.cts +5 -5
- package/dist-plugin/setGeometry/index.d.ts +5 -5
- package/dist-plugin/setGeometry/index.js +51 -45
- package/dist-plugin/setType/index.cjs +15 -0
- package/dist-plugin/setType/index.d.cts +1 -1
- package/dist-plugin/setType/index.d.ts +1 -1
- package/dist-plugin/setType/index.js +1 -1
- package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
- package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
- package/dist-plugin/tokensCssMigrations/index.js +1 -1
- package/package.json +35 -8
- package/src/app/site.css +19 -9
- package/src/editor/bootstrap.ts +2 -0
- package/src/editor/component-editor/CollapsibleSectionEditor.svelte +4 -4
- package/src/editor/component-editor/DialogEditor.svelte +4 -4
- package/src/editor/component-editor/MenuSelectEditor.svelte +4 -1
- package/src/editor/component-editor/SegmentedControlEditor.svelte +6 -1
- package/src/editor/component-editor/TabBarEditor.svelte +1 -1
- package/src/editor/component-editor/TableEditor.svelte +2 -2
- package/src/editor/component-editor/scaffolding/TokenLayout.svelte +6 -3
- package/src/editor/component-editor/scaffolding/VariantGroup.svelte +41 -20
- package/src/editor/core/components/adjustAliases.ts +59 -45
- package/src/editor/core/components/aliasKinds.ts +9 -5
- package/src/editor/core/preview/themePreview.ts +9 -2
- package/src/editor/core/sketch/sketchLayer.ts +22 -0
- package/src/editor/core/store/editorStore.ts +10 -1
- package/src/editor/core/themes/buildColors.ts +3 -3
- package/src/editor/core/themes/liveStateStream.ts +26 -0
- package/src/editor/core/themes/migrations/2026-09-07-stroke-role-renames.ts +52 -0
- package/src/editor/core/themes/migrations/index.ts +2 -0
- package/src/editor/core/themes/themeDocumentSync.ts +22 -11
- package/src/editor/core/themes/themeService.ts +9 -2
- package/src/editor/pages/ComponentEditorPage.svelte +17 -1
- package/src/editor/pages/liveTokensEditorHandle.ts +23 -0
- package/src/editor/skill-atlas/SkillAtlas.svelte +75 -573
- package/src/editor/skill-atlas/TreeCanvas.svelte +263 -0
- package/src/editor/skill-atlas/TreeNodeCard.svelte +136 -53
- package/src/editor/skill-atlas/edges.ts +31 -0
- package/src/editor/skill-atlas/skillSources.generated.ts +20 -19
- package/src/editor/skill-atlas/skillTrees.ts +19 -3840
- package/src/editor/skill-atlas/trees/check-compliance.ts +183 -0
- package/src/editor/skill-atlas/trees/create-component.ts +275 -0
- package/src/editor/skill-atlas/trees/create-page.ts +320 -0
- package/src/editor/skill-atlas/trees/create-theme.ts +255 -0
- package/src/editor/skill-atlas/trees/fix-findings.ts +469 -0
- package/src/editor/skill-atlas/trees/pick-component.ts +300 -0
- package/src/editor/skill-atlas/trees/set-colors.ts +148 -0
- package/src/editor/skill-atlas/trees/set-geometry.ts +136 -0
- package/src/editor/skill-atlas/trees/set-type.ts +142 -0
- package/src/editor/skill-atlas/types.ts +3 -4
- package/src/editor/skill-atlas/wireLayout.ts +287 -0
- package/src/live-tokens/data/themes/autumn.json +15 -15
- package/src/live-tokens/data/themes/halloween.json +15 -15
- package/src/live-tokens/data/themes/midnight-study.json +15 -15
- package/src/live-tokens/data/themes/ocean.json +15 -15
- package/src/live-tokens/data/themes/royal-velvet.json +15 -15
- package/src/live-tokens/data/themes/sketchy.json +15 -15
- package/src/live-tokens/data/themes/spring-meadow.json +15 -15
- package/src/live-tokens/data/themes/sunset.json +15 -15
- package/src/system/components/Badge.svelte +7 -0
- package/src/system/components/Button.svelte +7 -0
- package/src/system/components/Callout.svelte +10 -6
- package/src/system/components/Card.svelte +23 -4
- package/src/system/components/CodeSnippet.svelte +4 -3
- package/src/system/components/CollapsibleSection.svelte +23 -8
- package/src/system/components/CornerBadge.svelte +6 -0
- package/src/system/components/Dialog.svelte +13 -6
- package/src/system/components/IconButton.svelte +9 -0
- package/src/system/components/Image.svelte +8 -0
- package/src/system/components/ImageLightbox.svelte +6 -0
- package/src/system/components/InlineEditActions.svelte +7 -0
- package/src/system/components/Input.svelte +7 -0
- package/src/system/components/MenuSelect.svelte +7 -0
- package/src/system/components/Notification.svelte +7 -0
- package/src/system/components/Panel.svelte +6 -0
- package/src/system/components/ProgressBar.svelte +5 -0
- package/src/system/components/RadioButton.svelte +11 -5
- package/src/system/components/SectionDivider.svelte +8 -0
- package/src/system/components/SegmentedControl.svelte +6 -0
- package/src/system/components/SideNavigation.svelte +6 -0
- package/src/system/components/Slider.svelte +7 -4
- package/src/system/components/TabBar.svelte +15 -9
- package/src/system/components/Table.svelte +8 -3
- package/src/system/components/Toggle.svelte +4 -4
- package/src/system/components/Tooltip.svelte +6 -0
- package/src/testing-js/chunk-AO7EZHYV.js +776 -0
- package/src/testing-js/chunk-AO7EZHYV.js.map +1 -0
- package/src/testing-js/chunk-FAFOAWYL.js +39 -0
- package/src/testing-js/chunk-FAFOAWYL.js.map +1 -0
- package/src/testing-js/chunk-L73N4NSO.js +23 -0
- package/src/testing-js/chunk-L73N4NSO.js.map +1 -0
- package/src/testing-js/chunk-LXR3MN6N.js +3063 -0
- package/src/testing-js/chunk-LXR3MN6N.js.map +1 -0
- package/src/testing-js/chunk-ZMSX6CXR.js +53 -0
- package/src/testing-js/chunk-ZMSX6CXR.js.map +1 -0
- package/src/testing-js/component-alias.contract.js +81 -0
- package/src/testing-js/component-alias.contract.js.map +1 -0
- package/src/testing-js/component-editor.contract.js +62 -0
- package/src/testing-js/component-editor.contract.js.map +1 -0
- package/src/testing-js/component-render.contract.js +568 -0
- package/src/testing-js/component-render.contract.js.map +1 -0
- package/src/testing-js/index.d.ts +293 -0
- package/src/testing-js/index.js +222 -0
- package/src/testing-js/index.js.map +1 -0
- package/src/testing-js/registry.contract.js +39 -0
- package/src/testing-js/registry.contract.js.map +1 -0
- package/src/testing-js/vitest-BE6uGF31.d.ts +73 -0
- package/src/testing-js/vitest.d.ts +3 -0
- package/src/testing-js/vitest.js +13 -0
- package/src/testing-js/vitest.js.map +1 -0
- package/template/README.md +13 -0
- package/template/_gitignore +6 -0
- package/template/package.json +3 -1
- package/template/src/pages/Home.svelte +4 -18
- package/.claude/skills/live-tokens-build-page/SKILL.md +0 -103
- package/.claude/skills/live-tokens-build-page/references/layout-sources.md +0 -48
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
// `check-component --tests`: runs the registry contract under Vitest and the
|
|
2
|
+
// component contract suites under Playwright for one component or every
|
|
3
|
+
// authored one, and maps their results onto findings by rule.
|
|
4
|
+
//
|
|
5
|
+
// Spawns the two tools as child processes rather than importing their APIs, so
|
|
6
|
+
// this module needs neither `@playwright/test` nor `vitest` at its own module
|
|
7
|
+
// top — it is loaded lazily, only when `--tests` is passed (see cli.mjs).
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import {
|
|
11
|
+
cpSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
realpathSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
statSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { discoverComponents, resolveComponentPaths } from './check-component.mjs';
|
|
25
|
+
import { lineOf } from './lib/findings.mjs';
|
|
26
|
+
|
|
27
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
|
|
29
|
+
const TEST_DATA_DIR_ENV = 'LIVE_TOKENS_TEST_DATA_DIR';
|
|
30
|
+
const DATA_DIR_ENV = 'LIVE_TOKENS_DATA_DIR';
|
|
31
|
+
const COMPONENT_ENV = 'LIVE_TOKENS_COMPONENT';
|
|
32
|
+
|
|
33
|
+
// Mirrors src/testing/isolation.ts's own list. Duplicated rather than shared:
|
|
34
|
+
// that module compiles into src/testing-js, which does not exist until
|
|
35
|
+
// `build:testing` runs, and this one ships unbuilt.
|
|
36
|
+
const SESSION_FILES = new Set(['_working.json', '_active.json', '_production.json']);
|
|
37
|
+
|
|
38
|
+
const REQUIRED_TOOLS = [
|
|
39
|
+
{
|
|
40
|
+
name: '@playwright/test',
|
|
41
|
+
install: 'npm install -D @playwright/test',
|
|
42
|
+
extra: 'Then run `npx playwright install chromium`.',
|
|
43
|
+
},
|
|
44
|
+
{ name: 'vitest', install: 'npm install -D vitest' },
|
|
45
|
+
{ name: 'happy-dom', install: 'npm install -D happy-dom' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const HARD_FAILURE_RULES = new Set(['tests-not-installed', 'tests-setup', 'tests-incomplete']);
|
|
49
|
+
|
|
50
|
+
/** Design decision 8's fixed rule ids, the full set reconciliation checks
|
|
51
|
+
* coverage against. */
|
|
52
|
+
const ALL_CONTRACT_RULES = [
|
|
53
|
+
'contract-registry',
|
|
54
|
+
'contract-listed',
|
|
55
|
+
'contract-alias',
|
|
56
|
+
'contract-states',
|
|
57
|
+
'contract-interaction',
|
|
58
|
+
'contract-persist',
|
|
59
|
+
'contract-theme',
|
|
60
|
+
'contract-sketch',
|
|
61
|
+
'contract-render',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `component-editor.contract.ts` runs each component through the same eight
|
|
66
|
+
* `test()` calls in the same order (`describe.serial` preserves declaration
|
|
67
|
+
* order in the reporter). Position, not title text, is what identifies the
|
|
68
|
+
* obligation: two positions read "resolves every alias" and "answers the
|
|
69
|
+
* pointer", but rewording either string must not silently stop this from
|
|
70
|
+
* recognising it — only reordering or adding/removing a `test()` call would,
|
|
71
|
+
* and that is a deliberate change to the suite itself.
|
|
72
|
+
*/
|
|
73
|
+
export const EDITOR_SUITE_POSITIONAL_RULES = [
|
|
74
|
+
'contract-listed',
|
|
75
|
+
'contract-alias', // declares every part and every shipped alias (assertInventory)
|
|
76
|
+
'contract-alias', // resolves every alias it paints with
|
|
77
|
+
'contract-states', // previews the state being edited
|
|
78
|
+
'contract-interaction', // answers the pointer and the keyboard
|
|
79
|
+
'contract-persist',
|
|
80
|
+
'contract-theme',
|
|
81
|
+
'contract-sketch',
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// ─── tool + path resolution ─────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/** Node's own node_modules resolution, walked by hand: a monorepo hoists a
|
|
87
|
+
* peer to an ancestor, so checking only `root/node_modules` under-reports. */
|
|
88
|
+
function findPeerRoot(root, name) {
|
|
89
|
+
let dir = resolve(root);
|
|
90
|
+
for (;;) {
|
|
91
|
+
const candidate = join(dir, 'node_modules', ...name.split('/'));
|
|
92
|
+
if (existsSync(candidate)) return candidate;
|
|
93
|
+
const parent = dirname(dir);
|
|
94
|
+
if (parent === dir) return null;
|
|
95
|
+
dir = parent;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function missingToolFindings(root) {
|
|
100
|
+
return REQUIRED_TOOLS.filter((tool) => !findPeerRoot(root, tool.name)).map((tool) => ({
|
|
101
|
+
rule: 'tests-not-installed',
|
|
102
|
+
file: 'package.json',
|
|
103
|
+
line: 1,
|
|
104
|
+
message: `${tool.name} is not installed. Run \`${tool.install}\`.${tool.extra ? ` ${tool.extra}` : ''}`,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function peerBin(root, name, relBin) {
|
|
109
|
+
const peerRoot = findPeerRoot(root, name);
|
|
110
|
+
if (!peerRoot) throw new Error(`${name} is not installed under ${root}`);
|
|
111
|
+
return join(peerRoot, relBin);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The newest mtime among the source tree's own `.ts` files. `tsup`'s
|
|
115
|
+
* `splitting: true` spreads one entry across several output chunks, so an
|
|
116
|
+
* edit that matters can land in a file `name` never names directly (e.g.
|
|
117
|
+
* `support/contractHarness.ts` feeding `component-editor.contract.js`), so
|
|
118
|
+
* the whole source tree is the unit of staleness. */
|
|
119
|
+
function newestSourceMtime(dir) {
|
|
120
|
+
let newest = 0;
|
|
121
|
+
for (const entry of readdirSync(dir, { recursive: true })) {
|
|
122
|
+
if (!entry.endsWith('.ts')) continue;
|
|
123
|
+
const mtime = statSync(join(dir, entry)).mtimeMs;
|
|
124
|
+
if (mtime > newest) newest = mtime;
|
|
125
|
+
}
|
|
126
|
+
return newest;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The shipped testing module: compiled once `build:testing` has run,
|
|
130
|
+
* source in this repo's own dev loop otherwise. Resolved to an absolute path
|
|
131
|
+
* so the generated configs below need no bare-specifier or extensionless
|
|
132
|
+
* resolution of their own.
|
|
133
|
+
*
|
|
134
|
+
* A tarball install ships only the compiled copy (`src/testing` is not in
|
|
135
|
+
* `package.json`'s `files`), so `sourceDir` never exists there and this
|
|
136
|
+
* always returns `compiled` unconditionally, same as before. In this repo's
|
|
137
|
+
* own dev loop both exist, and an edit under `src/testing` used to leave the
|
|
138
|
+
* compiled copy silently stale: measured by editing `contractHarness.ts` and
|
|
139
|
+
* confirming the compiled `component-editor.contract.js` in `src/testing-js`
|
|
140
|
+
* still ran the old assertion. Preferring the newer one makes the dev loop
|
|
141
|
+
* self-healing. */
|
|
142
|
+
function resolveTestingEntry(name) {
|
|
143
|
+
const compiled = join(PKG_ROOT, 'src/testing-js', `${name}.js`);
|
|
144
|
+
const sourceDir = join(PKG_ROOT, 'src/testing');
|
|
145
|
+
const source = join(sourceDir, `${name}.ts`);
|
|
146
|
+
const compiledExists = existsSync(compiled);
|
|
147
|
+
const sourceExists = existsSync(source);
|
|
148
|
+
if (compiledExists && sourceExists && newestSourceMtime(sourceDir) > statSync(compiled).mtimeMs) {
|
|
149
|
+
console.warn(
|
|
150
|
+
`[live-tokens] src/testing/ has changes newer than src/testing-js/${name}.js. ` +
|
|
151
|
+
`Using the source directly. Run \`npm run build:testing\` to refresh the compiled copy.`,
|
|
152
|
+
);
|
|
153
|
+
return source;
|
|
154
|
+
}
|
|
155
|
+
if (compiledExists) return compiled;
|
|
156
|
+
if (sourceExists) return source;
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Cannot find the shipped testing module "${name}" under ${PKG_ROOT}. ` +
|
|
159
|
+
'Run `npm run build:testing` (development) or reinstall the package.',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
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
|
+
function guessViteConfigPath(root, settingsPath) {
|
|
228
|
+
return resolve(root, scrapeSettingsField(settingsPath, 'viteConfig') ?? 'vite.config.ts');
|
|
229
|
+
}
|
|
230
|
+
|
|
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
|
+
// ─── data isolation ─────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
function copyIsolatedDataDir(sourceDataDir) {
|
|
260
|
+
if (!existsSync(sourceDataDir)) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`No data directory at ${sourceDataDir}. Set "dataDir" in live-tokens.config.json ` +
|
|
263
|
+
'when the project keeps its live-tokens data somewhere else.',
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
// realpath: macOS resolves os.tmpdir() through a symlink, and the plugin
|
|
267
|
+
// compares resolved paths when it decides whether a write is in scope.
|
|
268
|
+
const dataDir = realpathSync(mkdtempSync(join(tmpdir(), 'live-tokens-check-')));
|
|
269
|
+
cpSync(sourceDataDir, dataDir, { recursive: true });
|
|
270
|
+
for (const entry of readdirSync(dataDir, { recursive: true })) {
|
|
271
|
+
if (SESSION_FILES.has(basename(entry))) rmSync(join(dataDir, entry), { force: true });
|
|
272
|
+
}
|
|
273
|
+
return dataDir;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The one child process a `withCleanup` signal handler needs to stop before
|
|
277
|
+
* it is safe to remove the directories that process is still writing into.
|
|
278
|
+
* Module-scoped because exactly one `runContractTests` call is ever active
|
|
279
|
+
* in a given process. */
|
|
280
|
+
let activeChild = null;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Removes `paths` on completion, on an uncaught exception, and on SIGINT or
|
|
284
|
+
* SIGTERM. On a signal, it first asks the active child (Playwright or Vitest)
|
|
285
|
+
* to stop and waits, briefly, for it to exit — measured against a live
|
|
286
|
+
* `webServer`: Playwright only tears its dev server down on SIGINT, never
|
|
287
|
+
* SIGTERM, so this always sends SIGINT to the child regardless of which
|
|
288
|
+
* signal this process received, and falls back to SIGKILL if the child
|
|
289
|
+
* ignores it. Only then does it remove the directories, so a slow child
|
|
290
|
+
* cannot recreate a report or a trace file in a directory that is already
|
|
291
|
+
* gone (also measured: without the wait, Playwright wrote into `test-results/`
|
|
292
|
+
* after cleanup had already removed it).
|
|
293
|
+
*
|
|
294
|
+
* The handler removes its own listener and re-sends the original signal to
|
|
295
|
+
* this process rather than just cleaning up and returning: any listener at
|
|
296
|
+
* all cancels Node's default terminate-on-signal behaviour, so a handler that
|
|
297
|
+
* only cleans up would swallow the first Ctrl+C in a process that installed
|
|
298
|
+
* no other SIGINT listener.
|
|
299
|
+
*/
|
|
300
|
+
function withCleanup(paths) {
|
|
301
|
+
let done = false;
|
|
302
|
+
const cleanup = () => {
|
|
303
|
+
if (done) return;
|
|
304
|
+
done = true;
|
|
305
|
+
for (const path of paths) rmSync(path, { recursive: true, force: true });
|
|
306
|
+
};
|
|
307
|
+
const stopActiveChild = () =>
|
|
308
|
+
new Promise((resolveStop) => {
|
|
309
|
+
if (!activeChild || activeChild.exitCode !== null || activeChild.signalCode !== null) {
|
|
310
|
+
resolveStop();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const child = activeChild;
|
|
314
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 5_000);
|
|
315
|
+
child.once('exit', () => {
|
|
316
|
+
clearTimeout(timer);
|
|
317
|
+
resolveStop();
|
|
318
|
+
});
|
|
319
|
+
child.kill('SIGINT');
|
|
320
|
+
});
|
|
321
|
+
const onSignal = async (signal) => {
|
|
322
|
+
await stopActiveChild();
|
|
323
|
+
cleanup();
|
|
324
|
+
process.removeListener(signal, onSignal);
|
|
325
|
+
process.kill(process.pid, signal);
|
|
326
|
+
};
|
|
327
|
+
process.once('SIGINT', onSignal);
|
|
328
|
+
process.once('SIGTERM', onSignal);
|
|
329
|
+
process.once('exit', cleanup);
|
|
330
|
+
return cleanup;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── generated tool configs ─────────────────────────────────────────────────
|
|
334
|
+
|
|
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".
|
|
340
|
+
writeFileSync(join(configDir, 'package.json'), JSON.stringify({ type: 'module' }));
|
|
341
|
+
|
|
342
|
+
const settingsPath = settingsFilePath(root);
|
|
343
|
+
const testingIndex = resolveTestingEntry('index');
|
|
344
|
+
const testingVitest = resolveTestingEntry('vitest');
|
|
345
|
+
const viteConfigPath = guessViteConfigPath(root, settingsPath);
|
|
346
|
+
|
|
347
|
+
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
|
+
const settingsExpr = settingsPath ? 'settingsModule' : '{}';
|
|
355
|
+
|
|
356
|
+
const playwrightConfigPath = join(configDir, 'playwright.config.ts');
|
|
357
|
+
writeFileSync(
|
|
358
|
+
playwrightConfigPath,
|
|
359
|
+
`${settingsImport}import { createPlaywrightConfig } from ${JSON.stringify(testingIndex)};
|
|
360
|
+
|
|
361
|
+
export default createPlaywrightConfig({
|
|
362
|
+
...${settingsExpr},
|
|
363
|
+
root: ${JSON.stringify(root)},
|
|
364
|
+
});
|
|
365
|
+
`,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const vitestConfigPath = join(configDir, 'vitest.config.ts');
|
|
369
|
+
writeFileSync(
|
|
370
|
+
vitestConfigPath,
|
|
371
|
+
`${settingsImport}import viteConfigModule from ${JSON.stringify(viteConfigPath)};
|
|
372
|
+
import { createVitestConfig, resolveTestingConfig } from ${JSON.stringify(testingVitest)};
|
|
373
|
+
|
|
374
|
+
const settings = resolveTestingConfig(${settingsExpr}, ${JSON.stringify(root)});
|
|
375
|
+
|
|
376
|
+
export default createVitestConfig(viteConfigModule.default ?? viteConfigModule, {
|
|
377
|
+
registrySetup: settings.registrySetup,
|
|
378
|
+
});
|
|
379
|
+
`,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
return { playwrightConfigPath, vitestConfigPath };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ─── subprocess execution ───────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
function runCli(command, args, { cwd, env }) {
|
|
388
|
+
return new Promise((resolveRun) => {
|
|
389
|
+
const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
390
|
+
activeChild = child;
|
|
391
|
+
let stdout = '';
|
|
392
|
+
let stderr = '';
|
|
393
|
+
child.stdout.on('data', (chunk) => (stdout += chunk));
|
|
394
|
+
child.stderr.on('data', (chunk) => (stderr += chunk));
|
|
395
|
+
const finish = (result) => {
|
|
396
|
+
if (activeChild === child) activeChild = null;
|
|
397
|
+
resolveRun(result);
|
|
398
|
+
};
|
|
399
|
+
child.on('close', (code) => finish({ code, stdout, stderr }));
|
|
400
|
+
child.on('error', (error) => finish({ code: -1, stdout, stderr: `${stderr}\n${error.message}` }));
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function setupFinding(tool, code, stdout, stderr) {
|
|
405
|
+
return {
|
|
406
|
+
rule: 'tests-setup',
|
|
407
|
+
file: 'package.json',
|
|
408
|
+
line: 1,
|
|
409
|
+
message: `${tool} exited with code ${code} before producing a report.\n${`${stdout}\n${stderr}`.trim().slice(-2000)}`,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Reads a JSON report or explains, as a `tests-setup` finding, why there
|
|
414
|
+
* isn't one. Split from the process-spawning around it so a malformed or
|
|
415
|
+
* absent report is testable without a subprocess. */
|
|
416
|
+
export function readReportOrSetupFinding(tool, reportPath, code, stdout, stderr) {
|
|
417
|
+
if (!existsSync(reportPath)) return { setupFinding: setupFinding(tool, code, stdout, stderr) };
|
|
418
|
+
try {
|
|
419
|
+
return { report: JSON.parse(readFileSync(reportPath, 'utf8')) };
|
|
420
|
+
} catch (error) {
|
|
421
|
+
return { setupFinding: setupFinding(tool, code, stdout, `report at ${reportPath} did not parse: ${error.message}`) };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export async function runPlaywrightSuite({ root, configDir, playwrightConfigPath }) {
|
|
426
|
+
const reportPath = join(configDir, 'playwright-report.json');
|
|
427
|
+
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
|
+
);
|
|
433
|
+
return readReportOrSetupFinding('Playwright', reportPath, code, stdout, stderr);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export async function runRegistrySuite({ root, configDir, vitestConfigPath }) {
|
|
437
|
+
const reportPath = join(configDir, 'vitest-report.json');
|
|
438
|
+
const bin = peerBin(root, 'vitest', 'vitest.mjs');
|
|
439
|
+
const { code, stdout, stderr } = await runCli(
|
|
440
|
+
process.execPath,
|
|
441
|
+
[bin, 'run', '--config', vitestConfigPath, '--reporter=json', '--outputFile', reportPath],
|
|
442
|
+
{ cwd: root, env: process.env },
|
|
443
|
+
);
|
|
444
|
+
const outcome = readReportOrSetupFinding('Vitest', reportPath, code, stdout, stderr);
|
|
445
|
+
// Kept alongside a successfully-parsed report too: a file that failed to
|
|
446
|
+
// collect a single test still carries a report, and `mapVitestResults`
|
|
447
|
+
// falls back to this when that file names no message of its own.
|
|
448
|
+
return outcome.setupFinding ? outcome : { ...outcome, stderr };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ─── result mapping: shared ─────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
const TOKEN_RE = /(--[a-z0-9-]+)/;
|
|
454
|
+
|
|
455
|
+
export function extractToken(message) {
|
|
456
|
+
return TOKEN_RE.exec(message ?? '')?.[1] ?? null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The real line a token sits on, found by literal search with a trailing
|
|
460
|
+
* boundary so a shorter token cannot match inside a longer one that starts
|
|
461
|
+
* the same way (`--card-default-body` inside `--card-default-body-padding`,
|
|
462
|
+
* measured against every shipped alias: 34 pairs resolved to the wrong line
|
|
463
|
+
* without this boundary). Falls back to line 1 (decision 8's documented
|
|
464
|
+
* fallback) when the token is absent from the message or the file. */
|
|
465
|
+
export function findTokenLine(filePath, token) {
|
|
466
|
+
if (!token || !existsSync(filePath)) return 1;
|
|
467
|
+
const text = readFileSync(filePath, 'utf8');
|
|
468
|
+
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
469
|
+
const match = new RegExp(`${escaped}(?![a-z0-9-])`).exec(text);
|
|
470
|
+
return match ? lineOf(text, match.index) : 1;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function componentConfigPath(sourceDataDir, id) {
|
|
474
|
+
return join(sourceDataDir, 'component-configs', id, 'default.json');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** A message block ends at the first blank line: `ContractViolation`'s own
|
|
478
|
+
* multi-line messages (e.g. a list of aliases, one per line) never contain
|
|
479
|
+
* one, while Playwright always separates the message from the ANSI code
|
|
480
|
+
* frame it appends with one. Splitting on the first single `\n` instead
|
|
481
|
+
* (as an earlier version of this file did) silently dropped every line
|
|
482
|
+
* after the first. */
|
|
483
|
+
function messageBlock(rawMessage) {
|
|
484
|
+
return String(rawMessage ?? '').split(/\n\n/)[0].trim();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Signatures Playwright's own errors carry that are not a component contract
|
|
488
|
+
* obligation failing — a missing browser or a crashed worker — checked
|
|
489
|
+
* before any rule/component-shaped parsing runs. Measured: with
|
|
490
|
+
* `PLAYWRIGHT_BROWSERS_PATH` pointed at an empty directory, every obligation
|
|
491
|
+
* failed with `browserType.launch: Executable doesn't exist` and, before
|
|
492
|
+
* this check existed, each one produced an unrelated contract finding. */
|
|
493
|
+
export function classifyInfrastructureError(message) {
|
|
494
|
+
const text = String(message ?? '');
|
|
495
|
+
if (/Executable doesn't exist|browserType\.launch:/.test(text)) {
|
|
496
|
+
return {
|
|
497
|
+
rule: 'tests-not-installed',
|
|
498
|
+
message: 'Chromium is not installed for Playwright. Run `npx playwright install chromium`.',
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if (/Worker process exited unexpectedly|Test process crashed/.test(text)) {
|
|
502
|
+
return { rule: 'tests-setup', message: `Playwright's worker process crashed: ${text.split('\n')[0]}` };
|
|
503
|
+
}
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ─── result mapping: Playwright ─────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
/** `component-editor.contract.ts` names each component in a
|
|
510
|
+
* `describe.serial(id, ...)`, its immediate parent; the other two suites
|
|
511
|
+
* name it as the leading word of the spec title instead. Either way, the
|
|
512
|
+
* candidate is only trusted against `knownIds` — a leading word like
|
|
513
|
+
* "every" or "component" is not a component id. */
|
|
514
|
+
export function identifyComponent(describeTitles, specTitle, knownIds) {
|
|
515
|
+
const parent = describeTitles[describeTitles.length - 1];
|
|
516
|
+
if (parent && knownIds.has(parent)) return parent;
|
|
517
|
+
const prefix = specTitle.split(' ')[0];
|
|
518
|
+
return knownIds.has(prefix) ? prefix : null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** One entry per *test*, not per attempt: `test.status` is Playwright's own
|
|
522
|
+
* reconciliation of every retry ('expected' | 'unexpected' | 'flaky' |
|
|
523
|
+
* 'skipped'), and `test.annotations` — not `result.annotations`, which is
|
|
524
|
+
* always empty — is where an inapplicable reason actually lands (measured
|
|
525
|
+
* against a real sketch-inapplicable run). Reading `test.results` per entry
|
|
526
|
+
* instead double-reported a flaky test's failed attempt as an error finding
|
|
527
|
+
* alongside its final `passed` coverage. */
|
|
528
|
+
export function readPlaywrightTests(report) {
|
|
529
|
+
const out = [];
|
|
530
|
+
const walk = (suite, titles) => {
|
|
531
|
+
for (const child of suite.suites ?? []) walk(child, [...titles, child.title]);
|
|
532
|
+
for (const spec of suite.specs ?? []) {
|
|
533
|
+
for (const test of spec.tests ?? []) {
|
|
534
|
+
out.push({
|
|
535
|
+
describeTitles: titles,
|
|
536
|
+
specTitle: spec.title,
|
|
537
|
+
specFile: spec.file,
|
|
538
|
+
specLine: spec.line,
|
|
539
|
+
status: test.status,
|
|
540
|
+
annotations: test.annotations ?? [],
|
|
541
|
+
lastResult: test.results?.[test.results.length - 1] ?? null,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
for (const suite of report.suites ?? []) walk(suite, []);
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Where a contract rule's finding points: the consumer's own artifact, never
|
|
552
|
+
* the shipped `.contract.ts` suite (absent from the tarball, and Playwright
|
|
553
|
+
* reports no assertion location for it once compiled — see the plan's Wave
|
|
554
|
+
* 3b/4 notes). `contract-alias`/`persist`/`theme` name the shipped config;
|
|
555
|
+
* `contract-listed` names the editor; render/preview/sketch name the runtime.
|
|
556
|
+
*/
|
|
557
|
+
export function artifactForContractRule(root, sourceDataDir, rule, componentId, token) {
|
|
558
|
+
if (!componentId) return { file: 'package.json', line: 1 };
|
|
559
|
+
const paths = resolveComponentPaths(componentId, root);
|
|
560
|
+
if (rule === 'contract-alias' || rule === 'contract-persist' || rule === 'contract-theme') {
|
|
561
|
+
const target = componentConfigPath(sourceDataDir, componentId);
|
|
562
|
+
return { file: relative(root, target), line: findTokenLine(target, token) };
|
|
563
|
+
}
|
|
564
|
+
if (rule === 'contract-listed') {
|
|
565
|
+
return { file: relative(root, paths.editorPath), line: 1 };
|
|
566
|
+
}
|
|
567
|
+
return { file: relative(root, paths.runtimePath), line: findTokenLine(paths.runtimePath, token) };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const VIOLATION_RE = /^ContractViolation:\s*\[([a-z-]+)\]\s+([a-z0-9-]+):\s*([\s\S]*)$/;
|
|
571
|
+
|
|
572
|
+
/** The rule a passing or failing test exercises, derived from *where* the
|
|
573
|
+
* test lives rather than its title text: position within a component's
|
|
574
|
+
* `describe.serial` group for the eight editor-suite obligations (see
|
|
575
|
+
* `EDITOR_SUITE_POSITIONAL_RULES`), the suite file for the other two. A
|
|
576
|
+
* `ContractViolation`'s own `[rule]` prefix, when present, is more
|
|
577
|
+
* authoritative than either and wins in the caller. */
|
|
578
|
+
function structuralRule(entry, componentId, positionCounters) {
|
|
579
|
+
if (componentId && entry.describeTitles[entry.describeTitles.length - 1] === componentId) {
|
|
580
|
+
const index = positionCounters.get(componentId) ?? 0;
|
|
581
|
+
positionCounters.set(componentId, index + 1);
|
|
582
|
+
return EDITOR_SUITE_POSITIONAL_RULES[index] ?? null;
|
|
583
|
+
}
|
|
584
|
+
const file = basename(entry.specFile ?? '');
|
|
585
|
+
if (file.startsWith('component-render.contract')) return 'contract-render';
|
|
586
|
+
if (file.startsWith('component-alias.contract')) return 'contract-alias';
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** failed beats flaky beats passed beats inapplicable, so a component whose
|
|
591
|
+
* interaction is genuinely inapplicable but whose states obligation is a
|
|
592
|
+
* real pass reads as passed, and
|
|
593
|
+
* either reading as failed always wins. `flaky` outranks `passed` so a
|
|
594
|
+
* retried obligation stays visible rather than being overwritten by a
|
|
595
|
+
* sibling that passed clean the first time. */
|
|
596
|
+
const COVERAGE_PRIORITY = { failed: 4, flaky: 3, passed: 2, inapplicable: 1 };
|
|
597
|
+
|
|
598
|
+
export function mapPlaywrightResults(report, { root, sourceDataDir, knownIds }) {
|
|
599
|
+
const zeroCollected = (report.suites ?? []).length === 0;
|
|
600
|
+
if (zeroCollected || (report.errors ?? []).length > 0) {
|
|
601
|
+
const detail = (report.errors ?? []).map((e) => e.message).join('\n') || 'the run collected no tests';
|
|
602
|
+
return {
|
|
603
|
+
findings: [{ rule: 'tests-incomplete', file: 'package.json', line: 1, message: `Playwright collected nothing to check: ${detail}` }],
|
|
604
|
+
coverage: {},
|
|
605
|
+
// Distinct from a per-test `tests-incomplete` (a single timed-out
|
|
606
|
+
// obligation, say): this one finding already explains every missing
|
|
607
|
+
// (component, rule) pair, so reconciliation must not also flag each of
|
|
608
|
+
// them — that would multiply one collection failure into dozens of
|
|
609
|
+
// findings that all say the same thing.
|
|
610
|
+
explained: true,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const tests = readPlaywrightTests(report);
|
|
615
|
+
|
|
616
|
+
for (const t of tests) {
|
|
617
|
+
if (t.status !== 'unexpected') continue;
|
|
618
|
+
const infra = classifyInfrastructureError(t.lastResult?.errors?.[0]?.message);
|
|
619
|
+
if (infra?.rule === 'tests-not-installed') {
|
|
620
|
+
return { findings: [{ rule: infra.rule, file: 'package.json', line: 1, message: infra.message }], coverage: {}, explained: true };
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const findings = [];
|
|
625
|
+
const coverage = {};
|
|
626
|
+
const positionCounters = new Map();
|
|
627
|
+
const markCoverage = (id, rule, status, reason) => {
|
|
628
|
+
if (!id || !rule) return;
|
|
629
|
+
coverage[id] ??= {};
|
|
630
|
+
const existing = coverage[id][rule];
|
|
631
|
+
if (!existing || COVERAGE_PRIORITY[status] >= COVERAGE_PRIORITY[existing.status]) {
|
|
632
|
+
coverage[id][rule] = reason ? { status, reason } : { status };
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
for (const t of tests) {
|
|
637
|
+
const componentId = identifyComponent(t.describeTitles, t.specTitle, knownIds);
|
|
638
|
+
const rule = structuralRule(t, componentId, positionCounters);
|
|
639
|
+
const inapplicable = t.annotations.find((a) => a.type === 'inapplicable')?.description;
|
|
640
|
+
|
|
641
|
+
if (t.status === 'expected' || t.status === 'flaky') {
|
|
642
|
+
// Flaky reaches a passing final attempt, same as expected, but a retry
|
|
643
|
+
// was needed to get there: worth a distinct coverage status so it stays
|
|
644
|
+
// visible rather than reading identically to a clean pass. It carries no
|
|
645
|
+
// finding and never flips exit status. CI's retry budget exists to
|
|
646
|
+
// absorb a shared dev server wobbling under CI load; failing the run on
|
|
647
|
+
// the very condition retries exist to tolerate would defeat the point.
|
|
648
|
+
const status = t.status === 'flaky' ? 'flaky' : inapplicable ? 'inapplicable' : 'passed';
|
|
649
|
+
markCoverage(componentId, rule, status, inapplicable);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
// `describe.serial` skips the rest of a component's obligations after its
|
|
653
|
+
// first failure; reconciliation (in runContractTests) accounts for these,
|
|
654
|
+
// so a bare skip here is not, by itself, a second thing to report.
|
|
655
|
+
if (t.status === 'skipped') continue;
|
|
656
|
+
|
|
657
|
+
const last = t.lastResult;
|
|
658
|
+
if (last?.status === 'timedOut' || last?.status === 'interrupted') {
|
|
659
|
+
findings.push({
|
|
660
|
+
rule: 'tests-incomplete',
|
|
661
|
+
file: 'package.json',
|
|
662
|
+
line: 1,
|
|
663
|
+
message: `${componentId ? `${componentId}: ` : ''}"${t.specTitle}" did not finish (${last.status})`,
|
|
664
|
+
context: { suite: 'playwright', title: [...t.describeTitles, t.specTitle].join(' > '), suiteFile: t.specFile, suiteLine: t.specLine },
|
|
665
|
+
});
|
|
666
|
+
markCoverage(componentId, rule, 'failed');
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const rawErrors = last?.errors?.length ? last.errors : [{ message: `${t.status}: ${t.specTitle}` }];
|
|
671
|
+
for (const error of rawErrors) {
|
|
672
|
+
const infra = classifyInfrastructureError(error.message);
|
|
673
|
+
if (infra) {
|
|
674
|
+
findings.push({
|
|
675
|
+
rule: infra.rule,
|
|
676
|
+
file: 'package.json',
|
|
677
|
+
line: 1,
|
|
678
|
+
message: infra.message,
|
|
679
|
+
context: { suite: 'playwright', title: [...t.describeTitles, t.specTitle].join(' > ') },
|
|
680
|
+
});
|
|
681
|
+
markCoverage(componentId, rule, 'failed');
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const block = messageBlock(error.message);
|
|
685
|
+
const violation = VIOLATION_RE.exec(block);
|
|
686
|
+
const finalRule = violation?.[1] ?? rule ?? 'tests-setup';
|
|
687
|
+
const id = violation?.[2] ?? componentId;
|
|
688
|
+
const message = violation?.[3] ?? block;
|
|
689
|
+
const token = extractToken(message);
|
|
690
|
+
const { file, line } = artifactForContractRule(root, sourceDataDir, finalRule, id, token);
|
|
691
|
+
findings.push({
|
|
692
|
+
rule: finalRule,
|
|
693
|
+
file,
|
|
694
|
+
line,
|
|
695
|
+
message: id ? `${id}: ${message}` : message,
|
|
696
|
+
context: {
|
|
697
|
+
suite: 'playwright',
|
|
698
|
+
title: [...t.describeTitles, t.specTitle].join(' > '),
|
|
699
|
+
suiteFile: t.specFile,
|
|
700
|
+
suiteLine: t.specLine,
|
|
701
|
+
attachments: (last?.attachments ?? []).map((a) => a.path).filter(Boolean),
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
markCoverage(id, finalRule, 'failed');
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return { findings, coverage };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ─── result mapping: Vitest (registry contract) ─────────────────────────────
|
|
711
|
+
|
|
712
|
+
/** `checkRegistryEntry`'s violation strings, read back out of Vitest's
|
|
713
|
+
* `expected [ ... ] to deeply equal []` failure text. `createVitestConfig`
|
|
714
|
+
* sets `chaiConfig.truncateThreshold: 0` so this array is never summarized
|
|
715
|
+
* away before it gets here. */
|
|
716
|
+
export function extractViolationArray(text) {
|
|
717
|
+
const m = /expected \[([\s\S]*?)\] to deeply equal \[\]/.exec(text ?? '');
|
|
718
|
+
if (!m) return [];
|
|
719
|
+
return [...m[1].matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((x) => x[1]);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** `checkRegistryEntry` prefixes every violation with its category
|
|
723
|
+
* (`registration:`, `uniqueness:`, `runtime:`, `default config:`,
|
|
724
|
+
* `opacity floor:`, `round-trip:`); the two config-shaped ones point at the
|
|
725
|
+
* shipped default, everything else at the runtime the schema describes. */
|
|
726
|
+
export function mapRegistryViolation(root, sourceDataDir, componentId, text) {
|
|
727
|
+
const category = text.split(':')[0];
|
|
728
|
+
const token = extractToken(text);
|
|
729
|
+
const paths = resolveComponentPaths(componentId, root);
|
|
730
|
+
const target =
|
|
731
|
+
category === 'default config' || category === 'opacity floor'
|
|
732
|
+
? componentConfigPath(sourceDataDir, componentId)
|
|
733
|
+
: paths.runtimePath;
|
|
734
|
+
return { file: relative(root, target), line: findTokenLine(target, token) };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Mirrors the Playwright side's zero-collection check: a file that failed
|
|
738
|
+
* before it ran a single assertion (`assertionResults` empty, `status`
|
|
739
|
+
* 'failed') carries its own `message` — measured against a bad
|
|
740
|
+
* `registrySetup` path, "Cannot find module '.../does-not-exist.ts'". Left
|
|
741
|
+
* unhandled, this fell through with empty coverage and no finding of its
|
|
742
|
+
* own, relying entirely on reconciliation's generic "did not run" message,
|
|
743
|
+
* which never names what actually failed to load. */
|
|
744
|
+
export function mapVitestResults(report, { root, sourceDataDir, stderr } = {}) {
|
|
745
|
+
const files = report.testResults ?? [];
|
|
746
|
+
if (files.length === 0) {
|
|
747
|
+
return {
|
|
748
|
+
findings: [{ rule: 'tests-incomplete', file: 'package.json', line: 1, message: `Vitest collected nothing to check: ${stderr?.trim() || 'the run collected no tests'}` }],
|
|
749
|
+
coverage: {},
|
|
750
|
+
explained: true,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const collectionFailures = files.filter((f) => f.status === 'failed' && (f.assertionResults ?? []).length === 0);
|
|
755
|
+
const findings = collectionFailures.map((f) => ({
|
|
756
|
+
rule: 'tests-incomplete',
|
|
757
|
+
file: 'package.json',
|
|
758
|
+
line: 1,
|
|
759
|
+
message: `Vitest collected nothing from ${f.name ?? 'a test file'}: ${f.message || stderr?.trim() || 'no message reported'}`,
|
|
760
|
+
}));
|
|
761
|
+
// Only when every file failed to load does this already account for every
|
|
762
|
+
// missing (component, rule) pair; one crashed file beside others that ran
|
|
763
|
+
// fine still leaves real gaps reconciliation has to name on its own.
|
|
764
|
+
if (collectionFailures.length === files.length) {
|
|
765
|
+
return { findings, coverage: {}, explained: true };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const coverage = {};
|
|
769
|
+
for (const file of files) {
|
|
770
|
+
if (collectionFailures.includes(file)) continue;
|
|
771
|
+
for (const assertion of file.assertionResults ?? []) {
|
|
772
|
+
const componentId = assertion.ancestorTitles.length >= 2 ? assertion.ancestorTitles[1] : null;
|
|
773
|
+
if (assertion.status !== 'failed') {
|
|
774
|
+
if (assertion.status === 'passed' && componentId) {
|
|
775
|
+
coverage[componentId] ??= {};
|
|
776
|
+
coverage[componentId]['contract-registry'] = { status: 'passed' };
|
|
777
|
+
}
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
const text = (assertion.failureMessages ?? []).join('\n');
|
|
781
|
+
const violations = componentId ? extractViolationArray(text) : [];
|
|
782
|
+
if (violations.length > 0) {
|
|
783
|
+
for (const violation of violations) {
|
|
784
|
+
const { file: f, line } = mapRegistryViolation(root, sourceDataDir, componentId, violation);
|
|
785
|
+
findings.push({
|
|
786
|
+
rule: 'contract-registry',
|
|
787
|
+
file: f,
|
|
788
|
+
line,
|
|
789
|
+
message: `${componentId}: ${violation}`,
|
|
790
|
+
context: { suite: 'vitest', title: assertion.fullName },
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
} else {
|
|
794
|
+
findings.push({
|
|
795
|
+
rule: componentId ? 'contract-registry' : 'tests-setup',
|
|
796
|
+
file: componentId ? relative(root, resolveComponentPaths(componentId, root).editorPath) : 'package.json',
|
|
797
|
+
line: 1,
|
|
798
|
+
message: text ? `${assertion.fullName}: ${text.split('\n')[0]}` : assertion.fullName,
|
|
799
|
+
context: { suite: 'vitest', title: assertion.fullName },
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
if (componentId) {
|
|
803
|
+
coverage[componentId] ??= {};
|
|
804
|
+
coverage[componentId]['contract-registry'] = { status: 'failed' };
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return { findings, coverage };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function mergeCoverage(a, b) {
|
|
812
|
+
const out = {};
|
|
813
|
+
for (const [id, rules] of [...Object.entries(a), ...Object.entries(b)]) {
|
|
814
|
+
out[id] = { ...out[id], ...rules };
|
|
815
|
+
}
|
|
816
|
+
return out;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// ─── reconciliation ─────────────────────────────────────────────────────────
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Every expected (component, rule) pair coverage does not already carry a
|
|
823
|
+
* verdict for becomes `incomplete`. A component with a `failed` rule already
|
|
824
|
+
* explains its own gaps (`describe.serial` stops after the first failure);
|
|
825
|
+
* `explainedGlobally` does the same for a setup-level finding that stopped
|
|
826
|
+
* the whole run. Anything left over is a genuinely unexpected skip, which
|
|
827
|
+
* gets its own `tests-incomplete` finding — the plan requires those to fail
|
|
828
|
+
* the run, not read as a clean, partial pass.
|
|
829
|
+
*/
|
|
830
|
+
export function reconcileCoverage(coverage, expectedIds, { expectedRules = ALL_CONTRACT_RULES, explainedGlobally = false } = {}) {
|
|
831
|
+
const out = {};
|
|
832
|
+
for (const [id, rules] of Object.entries(coverage)) out[id] = { ...rules };
|
|
833
|
+
const findings = [];
|
|
834
|
+
for (const id of expectedIds) {
|
|
835
|
+
const rules = (out[id] ??= {});
|
|
836
|
+
const explained = explainedGlobally || Object.values(rules).some((r) => r.status === 'failed');
|
|
837
|
+
for (const rule of expectedRules) {
|
|
838
|
+
if (rules[rule]) continue;
|
|
839
|
+
rules[rule] = { status: 'incomplete' };
|
|
840
|
+
if (!explained) {
|
|
841
|
+
findings.push({
|
|
842
|
+
rule: 'tests-incomplete',
|
|
843
|
+
file: 'package.json',
|
|
844
|
+
line: 1,
|
|
845
|
+
message: `${id}: ${rule} did not run, and nothing else for ${id} failed to explain why`,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return { coverage: out, findings };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ─── entry point ────────────────────────────────────────────────────────────
|
|
854
|
+
|
|
855
|
+
export async function runContractTests(id, { root = process.cwd(), dataDir: explicitDataDir } = {}) {
|
|
856
|
+
const toolFindings = missingToolFindings(root);
|
|
857
|
+
if (toolFindings.length > 0) return { findings: toolFindings, coverage: {} };
|
|
858
|
+
|
|
859
|
+
const ids = id ? [id] : discoverComponents(root);
|
|
860
|
+
if (ids.length === 0) {
|
|
861
|
+
return {
|
|
862
|
+
findings: [
|
|
863
|
+
{
|
|
864
|
+
rule: 'tests-setup',
|
|
865
|
+
file: 'package.json',
|
|
866
|
+
line: 1,
|
|
867
|
+
message: 'no component authored under src/system/components yet; nothing for --tests to run',
|
|
868
|
+
},
|
|
869
|
+
],
|
|
870
|
+
coverage: {},
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
let sourceDataDir;
|
|
875
|
+
let dataDir;
|
|
876
|
+
let configDir;
|
|
877
|
+
try {
|
|
878
|
+
// `dataDir` bypasses resolution entirely: the seam a caller (a test, or a
|
|
879
|
+
// future batch/CI runner) uses to point an isolated copy at a source tree
|
|
880
|
+
// other than the project's own, without ever touching the real one.
|
|
881
|
+
sourceDataDir = explicitDataDir ?? resolveSourceDataDir(root, settingsFilePath(root));
|
|
882
|
+
dataDir = copyIsolatedDataDir(sourceDataDir);
|
|
883
|
+
configDir = mkdtempSync(join(tmpdir(), 'live-tokens-check-cfg-'));
|
|
884
|
+
} catch (error) {
|
|
885
|
+
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
|
|
886
|
+
if (configDir) rmSync(configDir, { recursive: true, force: true });
|
|
887
|
+
return {
|
|
888
|
+
findings: [{ rule: 'tests-setup', file: 'package.json', line: 1, message: `could not prepare an isolated run: ${error.message}` }],
|
|
889
|
+
coverage: {},
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const cleanup = withCleanup([dataDir, configDir]);
|
|
894
|
+
|
|
895
|
+
try {
|
|
896
|
+
const { playwrightConfigPath, vitestConfigPath } = writeGeneratedConfigs({ configDir, root });
|
|
897
|
+
process.env[TEST_DATA_DIR_ENV] = dataDir;
|
|
898
|
+
process.env[DATA_DIR_ENV] = dataDir;
|
|
899
|
+
if (id) process.env[COMPONENT_ENV] = id;
|
|
900
|
+
else delete process.env[COMPONENT_ENV];
|
|
901
|
+
|
|
902
|
+
const knownIds = new Set([...discoverComponents(root), ...discoverComponents(PKG_ROOT)]);
|
|
903
|
+
const expectedIds = id ? [id] : [...knownIds];
|
|
904
|
+
|
|
905
|
+
// Sequential: both share the one isolated data directory, and the
|
|
906
|
+
// registry run's own Vite instance and the Playwright run's dev server
|
|
907
|
+
// would otherwise regenerate the same derived files concurrently.
|
|
908
|
+
const registryOutcome = await runRegistrySuite({ root, configDir, vitestConfigPath });
|
|
909
|
+
const playwrightOutcome = await runPlaywrightSuite({ root, configDir, playwrightConfigPath });
|
|
910
|
+
|
|
911
|
+
const registryMapped = registryOutcome.setupFinding
|
|
912
|
+
? { findings: [registryOutcome.setupFinding], coverage: {}, explained: true }
|
|
913
|
+
: mapVitestResults(registryOutcome.report, { root, sourceDataDir, stderr: registryOutcome.stderr });
|
|
914
|
+
const playwrightMapped = playwrightOutcome.setupFinding
|
|
915
|
+
? { findings: [playwrightOutcome.setupFinding], coverage: {}, explained: true }
|
|
916
|
+
: mapPlaywrightResults(playwrightOutcome.report, { root, sourceDataDir, knownIds });
|
|
917
|
+
|
|
918
|
+
const mergedFindings = [...registryMapped.findings, ...playwrightMapped.findings];
|
|
919
|
+
// A finding from either suite's own setup path, or an explicit `explained`
|
|
920
|
+
// flag from a whole-run short-circuit (zero collected, missing browser),
|
|
921
|
+
// already accounts for every missing (component, rule) pair — without
|
|
922
|
+
// this, one such failure would multiply into one reconciliation finding
|
|
923
|
+
// per expected id on top of it.
|
|
924
|
+
const explainedGlobally =
|
|
925
|
+
registryMapped.explained ||
|
|
926
|
+
playwrightMapped.explained ||
|
|
927
|
+
mergedFindings.some((f) => f.rule === 'tests-setup' || f.rule === 'tests-not-installed');
|
|
928
|
+
const reconciled = reconcileCoverage(mergeCoverage(registryMapped.coverage, playwrightMapped.coverage), expectedIds, {
|
|
929
|
+
explainedGlobally,
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
return { findings: [...mergedFindings, ...reconciled.findings], coverage: reconciled.coverage };
|
|
933
|
+
} catch (error) {
|
|
934
|
+
return {
|
|
935
|
+
findings: [{ rule: 'tests-setup', file: 'package.json', line: 1, message: `check-component --tests crashed: ${error.message}` }],
|
|
936
|
+
coverage: {},
|
|
937
|
+
};
|
|
938
|
+
} finally {
|
|
939
|
+
cleanup();
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
export function hasHardFailure(findings) {
|
|
944
|
+
return findings.some((f) => HARD_FAILURE_RULES.has(f.rule));
|
|
945
|
+
}
|