@motujs/cli 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/bundled/github/agents/archipelago-build.agent.md +298 -0
- package/bundled/github/agents/island-create.agent.md +261 -0
- package/bundled/github/agents/island-extract.agent.md +360 -0
- package/bundled/github/agents/island-locate.agent.md +208 -0
- package/bundled/github/agents/motu-checks.agent.md +158 -0
- package/bundled/github/agents/motu-evidence.agent.md +267 -0
- package/bundled/github/agents/motu-regions.agent.md +304 -0
- package/bundled/github/host-rules.md +43 -0
- package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
- package/bundled/github/prompts/island-create.prompt.md +37 -0
- package/bundled/github/prompts/island-extract.prompt.md +59 -0
- package/bundled/github/prompts/island-locate.prompt.md +43 -0
- package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
- package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
- package/bundled/packages/adapters/next/vite.mjs +121 -0
- package/bundled/packages/adapters/vite/vite.mjs +179 -0
- package/package.json +64 -3
- package/src/alias-loader.mjs +63 -0
- package/src/alias-register.mjs +28 -0
- package/src/asset-stub.cjs +3 -0
- package/src/asset-stub.mjs +5 -0
- package/src/cli.mjs +78 -0
- package/src/commands/adopt-root.mjs +194 -0
- package/src/commands/archipelago-init.mjs +314 -0
- package/src/commands/archipelago.mjs +138 -0
- package/src/commands/assurance.mjs +40 -0
- package/src/commands/check.mjs +346 -0
- package/src/commands/codegen.mjs +29 -0
- package/src/commands/coverage.mjs +358 -0
- package/src/commands/create.mjs +277 -0
- package/src/commands/defaults.mjs +129 -0
- package/src/commands/diff.mjs +595 -0
- package/src/commands/fixtures.mjs +153 -0
- package/src/commands/init.mjs +473 -0
- package/src/commands/integrate.mjs +208 -0
- package/src/commands/integration.mjs +1349 -0
- package/src/commands/lagoon-try.mjs +272 -0
- package/src/commands/lagoon.mjs +1419 -0
- package/src/commands/link.mjs +110 -0
- package/src/commands/region-coverage.mjs +604 -0
- package/src/commands/removal-check.mjs +878 -0
- package/src/commands/skills.mjs +156 -0
- package/src/commands/snapshot.mjs +456 -0
- package/src/commands/sync.mjs +38 -0
- package/src/commands/verify/island-runtime.mjs +581 -0
- package/src/commands/verify/island-static.mjs +586 -0
- package/src/commands/verify/island.mjs +135 -0
- package/src/commands/verify/reach.mjs +426 -0
- package/src/commands/verify/region-composition.mjs +391 -0
- package/src/commands/verify/region-config.mjs +330 -0
- package/src/commands/verify/region-flows.mjs +760 -0
- package/src/commands/verify/region-sources.mjs +516 -0
- package/src/commands/verify/region.mjs +271 -0
- package/src/commands/verify/report.mjs +338 -0
- package/src/commands/verify/screens-runtime.mjs +123 -0
- package/src/commands/verify.mjs +18 -0
- package/src/lagoon-build.mjs +25 -0
- package/src/lagoon-dev.mjs +32 -0
- package/src/lib/alias-core.mjs +49 -0
- package/src/lib/archipelagos.mjs +111 -0
- package/src/lib/assurance.mjs +144 -0
- package/src/lib/baselines.mjs +107 -0
- package/src/lib/bundlability.mjs +123 -0
- package/src/lib/changed.mjs +157 -0
- package/src/lib/component-props.mjs +145 -0
- package/src/lib/config.mjs +451 -0
- package/src/lib/contracts.mjs +244 -0
- package/src/lib/data-flow.mjs +47 -0
- package/src/lib/declared-sources.mjs +101 -0
- package/src/lib/dock-inject.mjs +47 -0
- package/src/lib/effects.mjs +97 -0
- package/src/lib/eject.mjs +715 -0
- package/src/lib/evidence-json.mjs +74 -0
- package/src/lib/expect-calls.mjs +53 -0
- package/src/lib/finding-memory.mjs +0 -0
- package/src/lib/flow-calls.mjs +53 -0
- package/src/lib/framework.mjs +98 -0
- package/src/lib/host-rules.mjs +55 -0
- package/src/lib/host-sources.mjs +121 -0
- package/src/lib/island-placement.mjs +64 -0
- package/src/lib/island-render.mjs +105 -0
- package/src/lib/islands.mjs +112 -0
- package/src/lib/lagoon-bundle.mjs +138 -0
- package/src/lib/lagoon-declares.mjs +553 -0
- package/src/lib/lagoon-materialize.mjs +217 -0
- package/src/lib/lagoon-vite.mjs +788 -0
- package/src/lib/live-url.mjs +53 -0
- package/src/lib/node-aliases.mjs +73 -0
- package/src/lib/operations.mjs +145 -0
- package/src/lib/provenance-plugin.mjs +74 -0
- package/src/lib/region-refs.mjs +40 -0
- package/src/lib/remote.mjs +171 -0
- package/src/lib/scaffold.mjs +606 -0
- package/src/lib/screens.mjs +266 -0
- package/src/lib/snapshots.mjs +85 -0
- package/src/lib/stubs.mjs +114 -0
- package/src/lib/ts-project.mjs +42 -0
- package/src/lib/util.mjs +626 -0
- package/src/lib/worlds.mjs +236 -0
- package/src/lib/write-if-changed.mjs +35 -0
- package/src/lib/writes-coverage.mjs +32 -0
- package/src/playwright-lagoon.mjs +2305 -0
- package/src/run.mjs +385 -0
- package/src/runtime-harness.mjs +225 -0
- package/README.md +0 -3
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
// An island's runtime checks: the harness, the browser lane, scenarios, viewports, axe.
|
|
2
|
+
// Split out of commands/verify.mjs; verify.mjs re-exports the public surface.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
+
import { SyntaxKind } from 'ts-morph';
|
|
8
|
+
import { reachableAppSources } from '../../lib/bundlability.mjs';
|
|
9
|
+
import { scenarioInputs } from '../../lib/data-flow.mjs';
|
|
10
|
+
import { readEffectEntries } from '../../lib/effects.mjs';
|
|
11
|
+
import { lostMarker, parseEvidence } from '../../lib/evidence-json.mjs';
|
|
12
|
+
import { lagoonEnv, nodeAliasEnv } from '../../lib/node-aliases.mjs';
|
|
13
|
+
import { sourceFileAt } from '../../lib/ts-project.mjs';
|
|
14
|
+
import { HOST, MOTU_CHECKOUT, REPO_ROOT, ensureNoInstallLinks, lagoonA11y, lagoonAliases, lagoonViewports, paths } from '../../lib/util.mjs';
|
|
15
|
+
import { axeLagoon, differentiateLagoon, responsiveLagoon, runLagoon } from '../../playwright-lagoon.mjs';
|
|
16
|
+
import { declaredReach, portalNote, reportRuntimeDiagnostics } from './reach.mjs';
|
|
17
|
+
import { environmentalCause, lagoonFailure, step } from './report.mjs';
|
|
18
|
+
import { CHROMIUM_INSTALL } from '../../lib/framework.mjs';
|
|
19
|
+
|
|
20
|
+
export const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), '../../runtime-harness.mjs');
|
|
21
|
+
|
|
22
|
+
// The CLI package root — used as cwd for the harness so `--import tsx` and the @motujs/* workspace
|
|
23
|
+
// symlinks resolve from packages/cli/node_modules (tsx is not hoisted to the repo root).
|
|
24
|
+
export const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every harness call, spawned the same way.
|
|
28
|
+
*
|
|
29
|
+
* The mount path got the alias loader, the project root and the lagoon env; the differentiation and
|
|
30
|
+
* scenario-reading calls did not, so on any project whose islands import through an alias they failed
|
|
31
|
+
* — and `runtimeDifferentiationCheck` returned SILENTLY on a non-zero exit, so `data-flow` simply
|
|
32
|
+
* never appeared in the report. A check that vanishes when it breaks is worse than one that fails.
|
|
33
|
+
*/
|
|
34
|
+
export async function spawnHarness(args) {
|
|
35
|
+
ensureNoInstallLinks(REPO_ROOT, MOTU_CHECKOUT);
|
|
36
|
+
const ALIAS_REGISTER = resolve(dirname(fileURLToPath(import.meta.url)), '../../alias-register.mjs');
|
|
37
|
+
return spawnSync(process.execPath, ['--import', 'tsx', '--import', pathToFileURL(ALIAS_REGISTER).href, ...args], {
|
|
38
|
+
encoding: 'utf8',
|
|
39
|
+
cwd: CLI_PKG,
|
|
40
|
+
env: {
|
|
41
|
+
...lagoonEnv(),
|
|
42
|
+
...process.env,
|
|
43
|
+
MOTU_PROJECT_ROOT: REPO_ROOT,
|
|
44
|
+
MOTU_NODE_ALIASES: await nodeAliasEnv(),
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Fast in-process lagoon mount under happy-dom for one fit (used with --fast). */
|
|
50
|
+
export async function runtimeCheckFast(report, tag, fixturesPath, fit) {
|
|
51
|
+
// The alias hook goes AFTER tsx so tsx compiles what it resolves.
|
|
52
|
+
const ALIAS_LOADER = resolve(dirname(fileURLToPath(import.meta.url)), '../../alias-register.mjs');
|
|
53
|
+
const args = ['--import', 'tsx', '--import', pathToFileURL(ALIAS_LOADER).href, HARNESS, tag, fixturesPath || '', fit];
|
|
54
|
+
// Node has to be able to resolve @motujs/* from the project's own files before it can load them.
|
|
55
|
+
ensureNoInstallLinks(REPO_ROOT, MOTU_CHECKOUT);
|
|
56
|
+
const res = await step('mount(fast)', async () =>
|
|
57
|
+
spawnSync(process.execPath, args, {
|
|
58
|
+
encoding: 'utf8',
|
|
59
|
+
cwd: CLI_PKG,
|
|
60
|
+
// cwd is the CLI package so `--import tsx` resolves; MOTU_PROJECT_ROOT says which project to load.
|
|
61
|
+
env: { ...lagoonEnv(), ...process.env, MOTU_PROJECT_ROOT: REPO_ROOT, MOTU_NODE_ALIASES: await nodeAliasEnv() },
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
if (res.status !== 0) {
|
|
65
|
+
// The LAST stderr line is a stack frame; the useful one is what the harness said it died of.
|
|
66
|
+
const lines = (res.stderr || '').trim().split('\n').filter(Boolean);
|
|
67
|
+
const why = lines.find((l) => /harness-fatal|^Error|^[A-Za-z]*Error:/.test(l)) ?? lines.pop() ?? 'no output';
|
|
68
|
+
|
|
69
|
+
// TWO COPIES OF REACT is this path's own failure, not the island's.
|
|
70
|
+
//
|
|
71
|
+
// `--fast` is plain node: the project resolves `react` from its own node_modules and the framework
|
|
72
|
+
// packages resolve theirs, so a component that calls a hook meets a null dispatcher. The browser
|
|
73
|
+
// lagoon does not have this problem — vite dedupes — and the island that fails here passes there,
|
|
74
|
+
// which is exactly what happened to `promo-box` the first time this ran. Reporting it as a defect
|
|
75
|
+
// would send an agent to rewrite a component that is fine.
|
|
76
|
+
if (/Invalid hook call|Cannot read properties of null \(reading 'use[A-Z]/.test(res.stderr || '')) {
|
|
77
|
+
report.inconclusive(
|
|
78
|
+
'lagoon-render',
|
|
79
|
+
`--fast cannot mount this island: two copies of React (the project's and the framework's) meet ` +
|
|
80
|
+
`in one tree, so hooks see a null dispatcher. Re-run without --fast; the browser lagoon dedupes`,
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
report.error('lagoon-render', `lagoon mount failed (fit=${fit}) — ${why.trim()}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const jsonLine = (res.stdout || '').trim().split('\n').filter(Boolean).pop();
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(jsonLine);
|
|
91
|
+
} catch {
|
|
92
|
+
report.error('lagoon-render', `lagoon produced no result (fit=${fit})`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (parsed.ok) report.ok('lagoon-render', `renders in the lagoon (happy-dom, fit=${fit})`);
|
|
96
|
+
else if (!parsed.mounted) report.error('lagoon-render', `island tag <${tag}> did not upgrade (fit=${fit})`);
|
|
97
|
+
else report.warn('lagoon-render', `mounts but renders nothing from default props (fit=${fit}) — confirm this island's empty state is intentional (e.g. a pure projection)`);
|
|
98
|
+
reportRuntimeDiagnostics(report, parsed, `fit=${fit}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Data-flow (differentiation) check. Opt-in: only runs when the island's fixtures export two or more
|
|
103
|
+
* `scenarios` (distinct store seeds). Drives each seed across the archipelago boundary and asserts the
|
|
104
|
+
* rendered output DIFFERS — proving inputs actually flow across the seam (criteria -> contract ->
|
|
105
|
+
* render), not merely that the wiring type-checks. Uses the real-browser lagoon by default (so Vite
|
|
106
|
+
* resolves the island's assets); `--fast` uses the in-process happy-dom harness. Silent when fewer than
|
|
107
|
+
* two scenarios are declared.
|
|
108
|
+
*/
|
|
109
|
+
export function readScenarios(fixturesPath) {
|
|
110
|
+
const args = ['--import', 'tsx', HARNESS, '', fixturesPath, 'native', 'scenarios'];
|
|
111
|
+
// Node has to be able to resolve @motujs/* from the project's own files before it can load them.
|
|
112
|
+
ensureNoInstallLinks(REPO_ROOT, MOTU_CHECKOUT);
|
|
113
|
+
const res = spawnSync(process.execPath, args, {
|
|
114
|
+
encoding: 'utf8',
|
|
115
|
+
cwd: CLI_PKG,
|
|
116
|
+
// cwd is the CLI package so `--import tsx` resolves; MOTU_PROJECT_ROOT says which project to load.
|
|
117
|
+
env: { ...process.env, MOTU_PROJECT_ROOT: REPO_ROOT },
|
|
118
|
+
});
|
|
119
|
+
if (res.status !== 0) return [];
|
|
120
|
+
const jsonLine = (res.stdout || '').trim().split('\n').filter(Boolean).pop();
|
|
121
|
+
// parseEvidence, not JSON.parse: the harness marks Dates on the way out so they arrive as Dates
|
|
122
|
+
// rather than ISO strings. Before that, any island formatting a date threw on mount and every
|
|
123
|
+
// seeded scenario reported "rendered NOTHING" — see lib/evidence-json.mjs.
|
|
124
|
+
return parseEvidence(jsonLine)?.scenarios ?? [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function runtimeDifferentiationCheck(report, tag, fixturesPath, port, fast) {
|
|
128
|
+
if (!fixturesPath) return;
|
|
129
|
+
const scenarios = readScenarios(fixturesPath);
|
|
130
|
+
if (scenarios.length < 2) return; // the check is opt-in
|
|
131
|
+
|
|
132
|
+
if (fast) {
|
|
133
|
+
// In-process happy-dom: mount once per seed and diff (works when the registry has no vite-only imports).
|
|
134
|
+
const res = await spawnHarness([HARNESS, tag, fixturesPath, 'native', 'differentiate']);
|
|
135
|
+
if (res.status !== 0) {
|
|
136
|
+
const why = (res.stderr || '').trim().split('\n').find((l) => /harness-fatal|Error/.test(l)) ?? 'no output';
|
|
137
|
+
report.inconclusive('data-flow', `could not compare scenarios: ${why.trim()}`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const jsonLine = (res.stdout || '').trim().split('\n').filter(Boolean).pop();
|
|
141
|
+
let parsed;
|
|
142
|
+
try {
|
|
143
|
+
parsed = JSON.parse(jsonLine);
|
|
144
|
+
} catch {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
reportDifferentiation(report, parsed);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const r = await step('data-flow', () => differentiateLagoon({ tag, port, scenarios }));
|
|
153
|
+
reportDifferentiation(report, r);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
const msg = String(err?.message || err).split('\n')[0];
|
|
156
|
+
if (!/Executable doesn't exist|playwright install/i.test(msg)) {
|
|
157
|
+
report.warn('data-flow', `differentiation check could not run: ${msg}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A scenario's scripted interactions must have DONE something — the guard that keeps
|
|
164
|
+
* `Scenario.interactions` from becoming the untyped browser-test suite the region-flow model
|
|
165
|
+
* deliberately refuses (see `RegionStep`'s doc comment in `@motujs/runtime/mock`).
|
|
166
|
+
*
|
|
167
|
+
* Sibling of `flow-mutation`'s "a step that cannot fail is not a check". The rule here is NOT "did
|
|
168
|
+
* the render change?" — the properties interactions exist for often assert that it did NOT (a panel
|
|
169
|
+
* keeping its last-good data through a failed refetch renders identically on purpose). Load-bearing
|
|
170
|
+
* means the render moved OR the interaction caused work. Neither means somebody clicked a journey
|
|
171
|
+
* whose end state a plain seed could have described, and it should be a seed.
|
|
172
|
+
*
|
|
173
|
+
* A WARNING, not an error: with no fake-fetch in play there is no request signal, so an interaction
|
|
174
|
+
* that legitimately only moves state the capture cannot see is indistinguishable from a decorative
|
|
175
|
+
* one, and the report says which case it could not tell apart.
|
|
176
|
+
*/
|
|
177
|
+
export function reportInteractions(report, r) {
|
|
178
|
+
const inert = r.inertInteractions;
|
|
179
|
+
if (!inert) return; // the `--fast` lane runs no interactions — nothing to judge
|
|
180
|
+
if (inert.length === 0) {
|
|
181
|
+
report.ok('interaction-effective', 'every scripted interaction moved the render or caused work');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
for (const { name, requestsObservable } of inert) {
|
|
185
|
+
report.warn(
|
|
186
|
+
'interaction-effective',
|
|
187
|
+
`"${name}" scripted an interaction that changed nothing observable — ` +
|
|
188
|
+
(requestsObservable
|
|
189
|
+
? 'no render change and no request. A seed describing the same end state is the honest form'
|
|
190
|
+
: 'no render change, and this project has no fake-fetch request signal to check against. ' +
|
|
191
|
+
'Either it is decorative, or what it moved is not captured — assert on what it produced'),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function reportDifferentiation(report, r) {
|
|
197
|
+
if (!r) return;
|
|
198
|
+
// Never silent: a comparison that did not happen is a skip with its reason, not an absent line —
|
|
199
|
+
// an absent line reads exactly like a check this island does not have.
|
|
200
|
+
if (r.differentiates == null) {
|
|
201
|
+
report.skip(
|
|
202
|
+
'data-flow',
|
|
203
|
+
r.mounted === false
|
|
204
|
+
? 'the island never mounted in the lagoon, so no scenario was driven — see lagoon-render'
|
|
205
|
+
: 'no comparison was made, so distinct output is not proved',
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
reportInteractions(report, r);
|
|
210
|
+
if (r.differentiates) {
|
|
211
|
+
// Duplicates are not a failure — two seeds may legitimately normalise to one output — but they are
|
|
212
|
+
// not evidence either, and the ok line used to cover for them.
|
|
213
|
+
if (r.distinctOutputs != null && r.distinctOutputs < (r.comparedCount ?? r.scenarioCount)) {
|
|
214
|
+
report.warn(
|
|
215
|
+
'data-flow',
|
|
216
|
+
`${r.scenarioCount} scenarios produced only ${r.distinctOutputs} distinct render(s) — ` +
|
|
217
|
+
`${(r.comparedCount ?? r.scenarioCount) - r.distinctOutputs} of them show nothing the others do not`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
// A pass that rests on what the island PORTALED (a dialog on <body>) says so — otherwise it reads
|
|
221
|
+
// like the island's own subtree differed, and the next person cannot tell which it was.
|
|
222
|
+
const withPortals = (r.portals ?? []).filter((n) => n > 0);
|
|
223
|
+
const portaled = withPortals.length
|
|
224
|
+
? ` · ${portalNote(withPortals.reduce((a, b) => a + b, 0))} in ${withPortals.length} of ${r.scenarioCount} scenario(s)`
|
|
225
|
+
: '';
|
|
226
|
+
report.ok(
|
|
227
|
+
'data-flow',
|
|
228
|
+
`distinct inputs produce distinct output (${r.scenarioCount} scenarios) — data flows past the seam`,
|
|
229
|
+
{ n: r.distinctOutputs ?? r.scenarioCount, of: `distinct render(s)${portaled}` },
|
|
230
|
+
);
|
|
231
|
+
} else if (r.blankButRendered?.length) {
|
|
232
|
+
// A DECLARATION THAT IS NO LONGER TRUE. `blank` says "renders nothing, on purpose"; an island that
|
|
233
|
+
// now renders something there either grew a state its author did not expect or the seed stopped
|
|
234
|
+
// meaning what it said. Either way the comparison would be built on a claim that is false.
|
|
235
|
+
report.error(
|
|
236
|
+
'data-flow',
|
|
237
|
+
`${r.blankButRendered.length} scenario(s) declared \`blank\` rendered something — ${r.blankButRendered.join(', ')}. ` +
|
|
238
|
+
`Remove \`blank\` if the render is right, or fix what it renders`,
|
|
239
|
+
);
|
|
240
|
+
} else if (r.empty?.length) {
|
|
241
|
+
// AN EMPTY SCENARIO IS NOT AN IDENTICAL ONE. The comparison needs every scenario to have rendered
|
|
242
|
+
// something, so one that renders nothing sinks the check — and saying "rendered identically" then
|
|
243
|
+
// points at the wrong thing entirely. Name the scenarios, because an island whose empty state IS
|
|
244
|
+
// its correct answer (a summary of nothing, a bar with nothing to act on) cannot take part in this
|
|
245
|
+
// check at all, and the author needs to know that rather than hunt a bind gap.
|
|
246
|
+
report.error(
|
|
247
|
+
'data-flow',
|
|
248
|
+
`${r.empty.length} of ${r.scenarioCount} scenario(s) rendered NOTHING — ${r.empty.join(', ')}. ` +
|
|
249
|
+
`Distinct output cannot be compared against a blank one. Either the island is not receiving that ` +
|
|
250
|
+
`seed, or its empty state is deliberate — then say so on the scenario: \`blank: '<why it renders nothing>'\``,
|
|
251
|
+
);
|
|
252
|
+
} else {
|
|
253
|
+
report.error(
|
|
254
|
+
'data-flow',
|
|
255
|
+
`${r.scenarioCount} scenarios rendered identically — inputs don't reach the output: the seam is wired but data doesn't flow (bind gap, or fixtures aren't request-keyed with \`match\`)`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Real-browser lagoon mount via Playwright/Chromium for one fit (the default). */
|
|
261
|
+
export async function runtimeCheckBrowser(report, tag, fit, port) {
|
|
262
|
+
try {
|
|
263
|
+
const r = await step('mount', () => runLagoon({ tag, fit, port }));
|
|
264
|
+
if (r.ok) report.ok('lagoon-render', `renders in the real-browser lagoon (fit=${fit})`);
|
|
265
|
+
else if (!r.mounted) report.error('lagoon-render', `island tag <${tag}> did not upgrade (fit=${fit})`);
|
|
266
|
+
else report.warn('lagoon-render', `mounts but renders nothing from default props (fit=${fit}) — confirm this island's empty state is intentional (e.g. a pure projection)`);
|
|
267
|
+
reportRuntimeDiagnostics(report, r, `fit=${fit}`, declaredReach({ tag }));
|
|
268
|
+
} catch (err) {
|
|
269
|
+
const msg = lagoonFailure(err);
|
|
270
|
+
const env = environmentalCause(err);
|
|
271
|
+
if (/Executable doesn't exist|playwright install/i.test(msg)) {
|
|
272
|
+
report.inconclusive('lagoon-render', `Chromium not installed — run ${CHROMIUM_INSTALL}, or use --fast for the happy-dom mount`);
|
|
273
|
+
} else if (env) {
|
|
274
|
+
report.inconclusive('lagoon-render', `could not boot the lagoon (fit=${fit}): ${env} — the environment, not the island`);
|
|
275
|
+
} else {
|
|
276
|
+
report.error('lagoon-render', `lagoon failed (fit=${fit}): ${msg}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Mount the island against a backend that fails every call (500), asserting it survives — renders and
|
|
282
|
+
* leaks no unhandled rejection / console error (i.e. it catches its own fetch failures). */
|
|
283
|
+
export async function runtimeErrorCheck(report, tag, port) {
|
|
284
|
+
try {
|
|
285
|
+
const r = await step('error-resilience', () => runLagoon({ tag, fit: 'native', port, forceError: 500, checkRemount: false }));
|
|
286
|
+
if (!r.mounted) {
|
|
287
|
+
report.warn('error-resilient', 'island did not mount under forced backend errors');
|
|
288
|
+
} else if (r.diagnostics.length === 0) {
|
|
289
|
+
report.ok('error-resilient', 'survives a backend error (renders, no unhandled rejection)');
|
|
290
|
+
} else {
|
|
291
|
+
report.error('error-resilient', `unhandled backend error — the island must catch its own calls: ${r.diagnostics[0]}`);
|
|
292
|
+
}
|
|
293
|
+
} catch (err) {
|
|
294
|
+
const msg = lagoonFailure(err);
|
|
295
|
+
// A missing browser is already reported by the normal lagoon check; don't double-report.
|
|
296
|
+
if (!/Executable doesn't exist|playwright install/i.test(msg)) {
|
|
297
|
+
report.error('error-resilient', `error mount failed: ${msg}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Known adapter packages -> their verify contribution's export specifier. Discovery is by the adapter
|
|
303
|
+
// the island actually imports (read from element.ts), resolved via the package's `./verify` export.
|
|
304
|
+
export const ADAPTER_VERIFY = {
|
|
305
|
+
'@motujs/adapter-angularjs': '@motujs/adapter-angularjs/verify',
|
|
306
|
+
'@motujs/adapter-next': '@motujs/adapter-next/verify',
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// Fallback discovery for hosts whose islands don't import an adapter at the mount point.
|
|
310
|
+
export const HOST_ADAPTER_VERIFY = { next: '@motujs/adapter-next/verify' };
|
|
311
|
+
|
|
312
|
+
/** Extract the structured `contract.coupling` from an island's element.ts (AST, not regex). */
|
|
313
|
+
export function extractCoupling(elementPath) {
|
|
314
|
+
const sf = sourceFileAt(elementPath, { allowJs: true });
|
|
315
|
+
const strAt = (obj, name) => {
|
|
316
|
+
const init = obj.getProperty(name)?.getInitializer();
|
|
317
|
+
return init && init.getKind() === SyntaxKind.StringLiteral ? init.getText().replace(/['"]/g, '') : undefined;
|
|
318
|
+
};
|
|
319
|
+
for (const obj of sf.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
320
|
+
// MECHANISM AND BOUNDARY, READ APART. `mount` says how the element attaches to a legacy scope;
|
|
321
|
+
// the names it RESOLVES from that scope are boundary facts and live in `contract.effects` as
|
|
322
|
+
// `scope:…`, beside the modules and tables. One list for everything an island reaches.
|
|
323
|
+
const contract = obj.getProperty('contract')?.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
324
|
+
const mount = obj.getProperty('mount')?.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
325
|
+
// Through the shared reader, so an authored `{ scope: 'search' }` and the canonical `scope:search`
|
|
326
|
+
// this check wants are the same fact read once. Reading the array's TEXT rather than its AST nodes
|
|
327
|
+
// keeps one implementation for the island file, the source module and the generated contracts.
|
|
328
|
+
const effectsProp = contract?.getProperty('effects');
|
|
329
|
+
const effects = effectsProp ? readEffectEntries(effectsProp.getText()) : undefined;
|
|
330
|
+
const hostScope = effects?.filter((e) => e.startsWith('scope:')).map((e) => e.slice('scope:'.length));
|
|
331
|
+
if (!mount && !hostScope?.length) continue;
|
|
332
|
+
return {
|
|
333
|
+
adopt: mount && strAt(mount, 'adopt'),
|
|
334
|
+
inheritScope: mount && strAt(mount, 'inheritScope'),
|
|
335
|
+
hostScopeKey: mount && strAt(mount, 'hostScopeKey'),
|
|
336
|
+
hostScope: hostScope?.length ? hostScope : undefined,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return {};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Adapter-owned verify layer. Core verify is framework-neutral; the one coupling it cannot judge is an
|
|
344
|
+
* island reaching into the ocean's own framework scope. Discover the adapter the island imports (from
|
|
345
|
+
* element.ts) and hand its verify contribution the STRUCTURED coupling we extract by AST — the CLI owns
|
|
346
|
+
* the parsing (it has ts-morph), the adapter owns the semantics.
|
|
347
|
+
*/
|
|
348
|
+
export async function adapterChecks(report, kebab, componentPath) {
|
|
349
|
+
const elementPath = paths.elementFile(kebab);
|
|
350
|
+
if (!existsSync(elementPath)) return;
|
|
351
|
+
const text = readFileSync(elementPath, 'utf8');
|
|
352
|
+
// Discovery is by the adapter the island IMPORTS — right for AngularJS, where the mount point is
|
|
353
|
+
// adapter-specific. A React host's mount point imports no adapter (the adapter lives at the
|
|
354
|
+
// composition root), so fall back to the configured host: the coupling is a property of where the
|
|
355
|
+
// island is going, not of what element.ts happens to import.
|
|
356
|
+
const pkgs = [...text.matchAll(/from\s+['"](@motujs\/adapter-[\w-]+)['"]/g)].map((m) => m[1]);
|
|
357
|
+
const specifier = pkgs.map((p) => ADAPTER_VERIFY[p]).find(Boolean) ?? HOST_ADAPTER_VERIFY[HOST];
|
|
358
|
+
if (!specifier) return; // no adapter ships a verify contribution for this island/host
|
|
359
|
+
const coupling = extractCoupling(elementPath);
|
|
360
|
+
// Hand over the component source too: some host boundaries (Next's server/client split) live in the
|
|
361
|
+
// component, not in the mount point. The CLI reads; the adapter judges.
|
|
362
|
+
const source = componentPath && existsSync(componentPath) ? readFileSync(componentPath, 'utf8') : undefined;
|
|
363
|
+
const graph = componentPath ? reachableAppSources(componentPath, { aliased: lagoonAliases() ?? [] }) : [];
|
|
364
|
+
try {
|
|
365
|
+
const mod = await import(specifier);
|
|
366
|
+
for (const f of mod.checkCoupling({ coupling, source, elementSource: text, graph })) report[f.level](f.check, f.msg);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
// INCONCLUSIVE, NOT A WARNING. A warning does not affect the exit code, so an adapter that failed
|
|
369
|
+
// to load meant its checks silently did not run and the island still reported PASS — the exact
|
|
370
|
+
// case exit 2 exists for ("a check could not run — retry, do NOT repair"). It is an environment
|
|
371
|
+
// failure, not a finding about the island.
|
|
372
|
+
report.inconclusive('adapter-verify', `could not run ${specifier}: ${String(err?.message || err)} — the ${specifier} checks did not run`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* axe, per declared scenario, scoped to the island's own subtree.
|
|
378
|
+
*
|
|
379
|
+
* Serious violations fail; the rest are reported. The line is `impact`: 'critical' and 'serious' are
|
|
380
|
+
* axe's own words for "a person cannot use this", which is not a matter of taste.
|
|
381
|
+
*/
|
|
382
|
+
export async function a11yCheck(report, tag, kebab, port) {
|
|
383
|
+
let findings;
|
|
384
|
+
try {
|
|
385
|
+
findings = await step('a11y', async () => axeLagoon({ tag, port, scenarios: await islandScenarios(kebab) }));
|
|
386
|
+
} catch (err) {
|
|
387
|
+
report[environmentalCause(err) ? 'inconclusive' : 'warn']('a11y', `could not run axe: ${err.message}`);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const policy = lagoonA11y();
|
|
391
|
+
const kept = findings.filter((f) => !policy.ignore.has(f.id));
|
|
392
|
+
if (!kept.length) {
|
|
393
|
+
report.ok('a11y', `no axe violations in any declared scenario${policy.ignore.size ? ` (${policy.ignore.size} rule(s) ignored by config)` : ''}`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// 'never' (the default) reports everything and fails on nothing; 'serious' and 'critical' promote at
|
|
397
|
+
// that severity and above.
|
|
398
|
+
const rank = { minor: 0, moderate: 1, serious: 2, critical: 3 };
|
|
399
|
+
const bar = policy.fail === 'never' ? Infinity : rank[policy.fail] ?? Infinity;
|
|
400
|
+
const say = (f) => `${f.impact}: ${f.help} — ${f.where || '?'} (${f.nodes} node(s), scenario "${f.scenario}")`;
|
|
401
|
+
for (const f of kept) {
|
|
402
|
+
if ((rank[f.impact] ?? 0) >= bar) report.error('a11y', say(f));
|
|
403
|
+
else report.warn('a11y', say(f));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Scenario seeds have to cross into the browser, and the crossing is JSON.
|
|
409
|
+
*
|
|
410
|
+
* A `Set`, a `Map` or a function in a seed arrives as `{}` — so the island renders against a value of
|
|
411
|
+
* the wrong SHAPE. When that throws (`favoriteIds.has is not a function`), the mount dies and EVERY
|
|
412
|
+
* scenario renders empty, which the differentiation check then reports as "scenarios rendered
|
|
413
|
+
* identically" and the responsive check as "renders nothing" — three misleading findings, none of them
|
|
414
|
+
* naming the cause. Name it here instead, once, before those checks run.
|
|
415
|
+
*
|
|
416
|
+
* The fix is always at the component: take the ITERABLE, not the Set, and rebuild it inside.
|
|
417
|
+
*
|
|
418
|
+
* A `Date` USED TO BE ON THAT LIST and no longer is: the evidence hop marks and revives them
|
|
419
|
+
* (lib/evidence-json.mjs), because a seed describing anything scheduled is made of dates and telling
|
|
420
|
+
* an author to strip them is telling them not to seed their own domain.
|
|
421
|
+
*
|
|
422
|
+
* IT WALKS THE WHOLE SEED. Looking only at top-level values is what let this class through for as long
|
|
423
|
+
* as it did: nobody seeds a bare Set, they seed a ROW that contains one — and what the author sees
|
|
424
|
+
* instead of this message is the three misleading findings above. Found on a seeded session object
|
|
425
|
+
* whose dates were two levels down, in a project whose whole domain is meetings.
|
|
426
|
+
*/
|
|
427
|
+
export async function seedTransportCheck(report, kebab) {
|
|
428
|
+
const scenarios = await islandScenarios(kebab);
|
|
429
|
+
if (!scenarios.length) return;
|
|
430
|
+
// Either the real object (when the evidence was loaded in-process) or the marker the harness left
|
|
431
|
+
// where one used to be (when it came back through JSON). Both, because both loaders are live.
|
|
432
|
+
const kind = (v) =>
|
|
433
|
+
v instanceof Set ? 'Set' : v instanceof Map ? 'Map' : typeof v === 'function' ? 'function' : lostMarker(v);
|
|
434
|
+
/** Every untransportable value inside `value`, as `path (Kind)`. Cycle-safe; arrays keep their index. */
|
|
435
|
+
const walk = (value, path, seen, out) => {
|
|
436
|
+
const k = kind(value);
|
|
437
|
+
if (k) {
|
|
438
|
+
out.push(`${path} (${k})`);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (!value || typeof value !== 'object' || value instanceof Date) return;
|
|
442
|
+
if (seen.has(value)) return;
|
|
443
|
+
seen.add(value);
|
|
444
|
+
if (Array.isArray(value)) {
|
|
445
|
+
value.forEach((v, i) => walk(v, `${path}[${i}]`, seen, out));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
for (const [key, v] of Object.entries(value)) walk(v, `${path}.${key}`, seen, out);
|
|
449
|
+
};
|
|
450
|
+
const bad = [];
|
|
451
|
+
for (const s of scenarios) {
|
|
452
|
+
for (const [key, value] of Object.entries(scenarioInputs(s))) {
|
|
453
|
+
const found = [];
|
|
454
|
+
walk(value, key, new Set(), found);
|
|
455
|
+
for (const f of found) bad.push(`${s.name ?? '(unnamed)'} → ${f}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (bad.length) {
|
|
459
|
+
report.error(
|
|
460
|
+
'seed-transport',
|
|
461
|
+
`seed value(s) that do not survive the trip into the browser: ${bad.join(', ')} — they arrive as {}, ` +
|
|
462
|
+
`which breaks EVERY scenario, not just this one. Take an iterable/plain value in the component and rebuild the Set inside.`,
|
|
463
|
+
);
|
|
464
|
+
} else {
|
|
465
|
+
// The count goes in `seen`, not the message: that is what makes `report.ok`'s "0 examined is a
|
|
466
|
+
// skip" rule apply, so a run with no scenarios reports a SKIP instead of a green tick over nothing.
|
|
467
|
+
report.ok('seed-transport', 'every scenario seed crosses into the browser intact', { n: scenarios.length, of: 'scenario seed(s)' });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The values an island's scenarios never show it.
|
|
473
|
+
*
|
|
474
|
+
* `data-flow` proves the scenarios DIFFER; nothing asks whether they differ in the ways that matter.
|
|
475
|
+
* Two classes, both read from the VALUES the scenarios supply rather than from types, so this needs no
|
|
476
|
+
* type-checker and says nothing about a prop no scenario sets:
|
|
477
|
+
* - a prop always given a non-empty array — the empty case is the one components forget;
|
|
478
|
+
* - a boolean prop that only ever takes one value — the other branch is never rendered.
|
|
479
|
+
*
|
|
480
|
+
* WHAT IT DOES NOT CATCH, stated because the case that prompted it is exactly this. An unguarded array
|
|
481
|
+
* access reachable only with `compactMode: true` AND an empty list survived this check, and rightly:
|
|
482
|
+
* each prop is well covered on its own — the region has an empty-list scenario and both values of the
|
|
483
|
+
* boolean — and the hole is the CROSSING nothing renders. Per-prop coverage cannot see a per-pair gap,
|
|
484
|
+
* and checking every pair over eight inputs would report more combinations than anyone would read.
|
|
485
|
+
* The message says "cross it with the other inputs" because that is the part a human still owns.
|
|
486
|
+
*
|
|
487
|
+
* Deliberately not general property testing. These two are cheap, common and specific; everything
|
|
488
|
+
* beyond them is a research project that would drown the report.
|
|
489
|
+
*/
|
|
490
|
+
export function inputCoverageCheck(report, scenarios) {
|
|
491
|
+
if (!Array.isArray(scenarios) || scenarios.length === 0) return;
|
|
492
|
+
const seen = new Map();
|
|
493
|
+
for (const sc of scenarios) {
|
|
494
|
+
for (const [prop, value] of Object.entries(sc?.seed || sc?.afterMount ? scenarioInputs(sc) : sc?.props ?? {})) {
|
|
495
|
+
if (!seen.has(prop)) seen.set(prop, []);
|
|
496
|
+
seen.get(prop).push(value);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (!seen.size) return;
|
|
500
|
+
|
|
501
|
+
const gaps = [];
|
|
502
|
+
for (const [prop, values] of seen) {
|
|
503
|
+
if (values.every((v) => Array.isArray(v)) && !values.some((v) => v.length === 0)) {
|
|
504
|
+
gaps.push(`${prop} is never seeded empty`);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (values.every((v) => typeof v === 'boolean') && new Set(values).size === 1) {
|
|
508
|
+
gaps.push(`${prop} is only ever seeded ${values[0]}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (!gaps.length) {
|
|
512
|
+
report.ok('input-coverage', 'every seeded input is shown in more than one shape', {
|
|
513
|
+
n: seen.size,
|
|
514
|
+
of: 'seeded input(s)',
|
|
515
|
+
});
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
report.warn(
|
|
519
|
+
'input-coverage',
|
|
520
|
+
`${gaps.length}/${seen.size} seeded input(s) are only ever seeded one way: ${gaps.join(', ')} — the state ` +
|
|
521
|
+
`nothing renders is the one that breaks. Add a scenario for it, and cross it with the other inputs ` +
|
|
522
|
+
`rather than varying one at a time`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** An island's declared scenarios, loaded from its evidence file (node strips the types). */
|
|
527
|
+
export async function islandScenarios(kebab) {
|
|
528
|
+
const file = paths.fixturesFile(kebab);
|
|
529
|
+
if (!existsSync(file)) return [];
|
|
530
|
+
try {
|
|
531
|
+
const mod = await import(`file://${file}?t=${Date.now()}`);
|
|
532
|
+
const scenarios = Array.isArray(mod.scenarios) ? mod.scenarios : [];
|
|
533
|
+
// A plain node import cannot resolve a `.js` specifier that points at a `.ts` sibling — which is
|
|
534
|
+
// the convention every file here uses. It THREW, this returned [], and the island quietly lost
|
|
535
|
+
// every scenario: `data-flow` still saw five (it loads through tsx) while `responsive` reported
|
|
536
|
+
// one. Two loaders for one file, disagreeing in silence. Cross-check against the tsx loader and
|
|
537
|
+
// take the fuller answer, so evidence can import a shared module like any other code.
|
|
538
|
+
const viaTsx = readScenarios(file);
|
|
539
|
+
return viaTsx.length > scenarios.length ? viaTsx : scenarios;
|
|
540
|
+
} catch {
|
|
541
|
+
return readScenarios(file);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Renders at every declared width, and fits.
|
|
547
|
+
*
|
|
548
|
+
* An error, not a warning: a page the member has to scroll sideways is broken, and the project rule
|
|
549
|
+
* ("always implement mobile AND desktop") had no enforcement at all until this.
|
|
550
|
+
*/
|
|
551
|
+
export async function responsiveCheck(report, tag, kebab, port) {
|
|
552
|
+
const viewports = lagoonViewports();
|
|
553
|
+
if (!viewports.length) return;
|
|
554
|
+
let measured;
|
|
555
|
+
try {
|
|
556
|
+
measured = await step('responsive', async () =>
|
|
557
|
+
responsiveLagoon({ tag, port, viewports, scenarios: await islandScenarios(kebab) }));
|
|
558
|
+
} catch (err) {
|
|
559
|
+
report.warn('responsive', `could not measure viewports: ${err.message}`);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const overflowing = measured.filter((m) => m.overflow > 1);
|
|
563
|
+
// Overflow is an error — a page the member has to pan sideways is broken. Rendering nothing is a
|
|
564
|
+
// WARNING: an island's empty state can legitimately be empty, and only the author knows which.
|
|
565
|
+
if (overflowing.length) {
|
|
566
|
+
report.error(
|
|
567
|
+
'responsive',
|
|
568
|
+
`overflows horizontally at ${overflowing
|
|
569
|
+
.map((m) => `${m.name} (${m.width}px, +${m.overflow}px${m.scenario === 'default' ? '' : `, "${m.scenario}"`})`)
|
|
570
|
+
.join(', ')}`,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
if (measured.every((m) => !m.rendered)) {
|
|
574
|
+
report.warn('responsive', 'renders nothing at any declared viewport, in any scenario — is there evidence that shows it?');
|
|
575
|
+
}
|
|
576
|
+
if (!overflowing.length && measured.some((m) => m.rendered)) {
|
|
577
|
+
const widths = [...new Set(measured.map((m) => `${m.name} ${m.width}px`))].join(', ');
|
|
578
|
+
const states = new Set(measured.map((m) => m.scenario)).size;
|
|
579
|
+
report.ok('responsive', `fits every declared viewport (${widths}) in ${states} scenario(s)`);
|
|
580
|
+
}
|
|
581
|
+
}
|