@motujs/cli 0.0.0-stage → 0.1.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/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,2305 @@
|
|
|
1
|
+
// Real-browser lagoon check. Boots the focused lagoon (the configured lagoon root via Vite,
|
|
2
|
+
// MOTU_TARGET=island:<tag>) and drives it with Playwright/Chromium, so verification exercises real
|
|
3
|
+
// layout, CSS and paint — not just an in-process DOM. Exposes runLagoon() for `motu island verify`;
|
|
4
|
+
// also runnable directly for a screenshot: `node --import tsx playwright-lagoon.mjs <tag> <fit> [screenshotPath]`.
|
|
5
|
+
import { judgeRenders, scenarioInputs } from './lib/data-flow.mjs';
|
|
6
|
+
import { expectCallsMismatches } from './lib/expect-calls.mjs';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { createConnection } from 'node:net';
|
|
10
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
11
|
+
import { resolve } from 'node:path';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { REPO_ROOT, paths, color } from './lib/util.mjs';
|
|
15
|
+
import { installIslandRender } from './lib/island-render.mjs';
|
|
16
|
+
|
|
17
|
+
/** The framework's own lagoon dev-server runner (see lagoon-dev.mjs). */
|
|
18
|
+
const LAGOON_DEV = fileURLToPath(new URL('./lagoon-dev.mjs', import.meta.url));
|
|
19
|
+
|
|
20
|
+
const LAGOON_DIR = paths.lagoonDir;
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The lagoon is the loop: without it `verify` has static checks and nothing else. Both of its
|
|
25
|
+
* preconditions used to fail as a bare spawn ENOENT (an unhandled 'error' event that killed the
|
|
26
|
+
* process with a stack trace), which reads as a motu bug rather than a missing composition root.
|
|
27
|
+
* Diagnose them up front instead.
|
|
28
|
+
*/
|
|
29
|
+
function assertLagoonBootable() {
|
|
30
|
+
if (!existsSync(LAGOON_DIR)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`no lagoon root at ${paths.rel(LAGOON_DIR)} — the lagoon is where verify mounts the island, so ` +
|
|
33
|
+
`there is nothing to verify against. Run \`motu init --host <angularjs|next|none>\` to scaffold ` +
|
|
34
|
+
`one, or point "lagoon" in motu.config.json at an existing composition root.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function waitForPort(port, timeoutMs = 30000) {
|
|
41
|
+
const deadline = Date.now() + timeoutMs;
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const tryOnce = () => {
|
|
44
|
+
const sock = createConnection({ port, host: 'localhost' });
|
|
45
|
+
sock.once('connect', () => {
|
|
46
|
+
sock.destroy();
|
|
47
|
+
resolve();
|
|
48
|
+
});
|
|
49
|
+
sock.once('error', () => {
|
|
50
|
+
sock.destroy();
|
|
51
|
+
if (Date.now() > deadline) reject(new Error(`vite did not open port ${port} in time`));
|
|
52
|
+
else setTimeout(tryOnce, 200);
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
tryOnce();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// One server, one browser, for the whole run.
|
|
60
|
+
//
|
|
61
|
+
// Every runtime check used to boot its own Vite and its own Chromium: four per island, sixty for a
|
|
62
|
+
// project the size of acme, each paying a cold dep-optimize before it could answer a question that
|
|
63
|
+
// takes 200ms. The target is now read from the URL as well as the env, so the SAME server can serve
|
|
64
|
+
// every island and every region — a check navigates instead of booting. What still forces a separate
|
|
65
|
+
// server is what is baked at build time (fit, isolation, forced error, transport), so the pool is
|
|
66
|
+
// keyed by exactly those.
|
|
67
|
+
const servers = new Map();
|
|
68
|
+
let sharedBrowser = null;
|
|
69
|
+
let poolClosing = false;
|
|
70
|
+
let exitHooked = false;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Kill the pool on the way out.
|
|
74
|
+
*
|
|
75
|
+
* Commands end with `process.exit`, so a `finally` in the caller is not enough — and a leaked vite
|
|
76
|
+
* holds its port, which the next run then connects to and measures a stale build with.
|
|
77
|
+
*/
|
|
78
|
+
function hookExit() {
|
|
79
|
+
if (exitHooked) return;
|
|
80
|
+
exitHooked = true;
|
|
81
|
+
const kill = () => {
|
|
82
|
+
for (const entry of servers.values()) stopLagoonProcess(entry.child);
|
|
83
|
+
servers.clear();
|
|
84
|
+
};
|
|
85
|
+
process.on('exit', kill);
|
|
86
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
87
|
+
process.on(sig, () => {
|
|
88
|
+
kill();
|
|
89
|
+
process.exit(130);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Everything the pool holds, released at the end of a command (see `closeLagoonPool`). */
|
|
95
|
+
export async function closeLagoonPool() {
|
|
96
|
+
poolClosing = true;
|
|
97
|
+
for (const page of pages.values()) await page.close().catch(() => {});
|
|
98
|
+
pages.clear();
|
|
99
|
+
for (const entry of servers.values()) stopLagoonProcess(entry.child);
|
|
100
|
+
servers.clear();
|
|
101
|
+
if (sharedBrowser) {
|
|
102
|
+
await sharedBrowser.close().catch(() => {});
|
|
103
|
+
sharedBrowser = null;
|
|
104
|
+
}
|
|
105
|
+
poolClosing = false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A CHROMIUM THIS MACHINE ALREADY HAS, when it has one motu did not install.
|
|
110
|
+
*
|
|
111
|
+
* `chromium.launch()` insists on the exact build the pinned Playwright expects and ignores a working
|
|
112
|
+
* browser sitting on disk. That is right on a developer machine, where `npx playwright install` is
|
|
113
|
+
* one command — and it makes the whole runtime tier unrunnable everywhere that command cannot reach
|
|
114
|
+
* the CDN: a hardened CI image, a distro- or nix-packaged chromium, an air-gapped build box, a
|
|
115
|
+
* sandbox whose egress policy does not list `cdn.playwright.dev`. All of them HAVE a browser.
|
|
116
|
+
*
|
|
117
|
+
* Opt-in, and never silent — see `browserOverrideNote`. A build motu did not pin can render and
|
|
118
|
+
* behave differently from the one it did, so a green `--runtime` that used one is claiming slightly
|
|
119
|
+
* more than it checked unless it says so.
|
|
120
|
+
*/
|
|
121
|
+
export const chromiumOverride = () => process.env.MOTU_CHROMIUM_PATH || null;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* What to SAY about the browser, or '' when it is the pinned one.
|
|
125
|
+
*
|
|
126
|
+
* Returned rather than printed: this module drives checks, and a check's report is where a caveat
|
|
127
|
+
* belongs — a line on stdout is read by whoever is watching and lost by everyone else.
|
|
128
|
+
*/
|
|
129
|
+
export function browserOverrideNote() {
|
|
130
|
+
const exe = chromiumOverride();
|
|
131
|
+
return exe ? `not the pinned Chromium — launched \`${exe}\` via MOTU_CHROMIUM_PATH` : '';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The one Chromium every check drives. */
|
|
135
|
+
async function getBrowser() {
|
|
136
|
+
const { chromium } = await import('playwright');
|
|
137
|
+
const exe = chromiumOverride();
|
|
138
|
+
if (!sharedBrowser || !sharedBrowser.isConnected()) {
|
|
139
|
+
sharedBrowser = await chromium.launch(exe ? { executablePath: exe } : {});
|
|
140
|
+
}
|
|
141
|
+
return sharedBrowser;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ONE PAGE, RE-AIMED.
|
|
145
|
+
//
|
|
146
|
+
// The lagoon is a single page holding the whole registry, every fixture and a mock transport. Loading
|
|
147
|
+
// it again to look at the next island rebuilt all of that to ask a question that takes milliseconds —
|
|
148
|
+
// which is what made the runtime lane feel like an integration suite. The page is opened once per run
|
|
149
|
+
// and each check asks it to show something else: a different island, a whole region, the same island
|
|
150
|
+
// with a failing backend. Feeding data and firing declared outputs then happen in the page that is
|
|
151
|
+
// already standing, which is the thing a browser-per-spec suite cannot do.
|
|
152
|
+
const pages = new Map();
|
|
153
|
+
/**
|
|
154
|
+
* How long to wait for an island to paint.
|
|
155
|
+
*
|
|
156
|
+
* It used to be fifteen seconds because the page was loading from cold each time — Vite's first-run
|
|
157
|
+
* dep optimization can reload underneath the check. The page is loaded once now, so a re-aimed mount
|
|
158
|
+
* paints in tens of milliseconds, and the only thing the long wait still bought was thirty seconds per
|
|
159
|
+
* island that legitimately renders nothing from its defaults. The first load keeps the long deadline;
|
|
160
|
+
* everything after it gets the short one.
|
|
161
|
+
*/
|
|
162
|
+
const PAINT_TIMEOUT_COLD = 15000;
|
|
163
|
+
const PAINT_TIMEOUT_WARM = 3000;
|
|
164
|
+
let paintTimeout = PAINT_TIMEOUT_COLD;
|
|
165
|
+
/** How long a mounted island may stay empty before its empty default is taken as its answer. */
|
|
166
|
+
const EMPTY_DEFAULT_SETTLE_MS = 2000;
|
|
167
|
+
/** Diagnostics of the CHECK currently running — console/pageerror listeners are per page, not per check. */
|
|
168
|
+
let diagnosticSink = [];
|
|
169
|
+
/**
|
|
170
|
+
* Requests that LEFT the machine during the check currently running — same per-page-listener,
|
|
171
|
+
* per-check-sink shape as `diagnosticSink`.
|
|
172
|
+
*
|
|
173
|
+
* A lagoon is supposed to be self-contained: every backend call answered by a stub or the fake fetch,
|
|
174
|
+
* nothing reaching a real host. Nothing checked that. Worse, the one signal that would have shown it
|
|
175
|
+
* is deliberately silenced — `NOISE` filters `net::ERR` and `Failed to load resource` out of console
|
|
176
|
+
* diagnostics, which is right for a dev server's own chatter and exactly wrong for an island quietly
|
|
177
|
+
* trying to reach the ocean and swallowing the failure in its own catch block. That renders as an
|
|
178
|
+
* empty state and passes every check.
|
|
179
|
+
*/
|
|
180
|
+
let networkSink = [];
|
|
181
|
+
|
|
182
|
+
/** Loopback = the lagoon's own dev server (and its reload poll, which can name a different loopback
|
|
183
|
+
* host than the page). Anything else is the page reaching for the world. */
|
|
184
|
+
function isLoopback(hostname) {
|
|
185
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The page for a build posture, opened on first use. */
|
|
189
|
+
/** The view each pooled page is currently showing, so a check never inherits the previous one's. */
|
|
190
|
+
const pageView = new Map();
|
|
191
|
+
|
|
192
|
+
async function lagoonPage(server, target, view) {
|
|
193
|
+
const existing = pages.get(server.key);
|
|
194
|
+
const wanted = view ?? 'region';
|
|
195
|
+
// A view change is a different render, not a re-aim: the warm path swaps the TARGET in place and
|
|
196
|
+
// keeps whatever view the page already had. Left unhandled it is a silent contaminated result —
|
|
197
|
+
// the region-render check inherited the wiring probe's 'mountpoints' page and reported every
|
|
198
|
+
// declared slot as mounted, including one the arrangement never places.
|
|
199
|
+
if (existing && !existing.isClosed() && pageView.get(server.key) === wanted) {
|
|
200
|
+
// Re-aim it. `false` means this build has no harness (an older lagoon entry) — fall back to a load.
|
|
201
|
+
paintTimeout = PAINT_TIMEOUT_WARM;
|
|
202
|
+
const aimed = await existing
|
|
203
|
+
.evaluate(
|
|
204
|
+
([t, fit, forceError]) => !!window.__motuLagoonHarness?.mount(t, { fit, forceError }),
|
|
205
|
+
[target, server.fit ?? '', server.forceError ?? 0],
|
|
206
|
+
)
|
|
207
|
+
.catch(() => false);
|
|
208
|
+
if (aimed) return existing;
|
|
209
|
+
// A FULL LOAD IS COLD AGAIN, whatever the last check was.
|
|
210
|
+
//
|
|
211
|
+
// `paintTimeout` is latched to WARM by the first re-aim and was never put back, so every full
|
|
212
|
+
// load after it got a fifth of the deadline a first paint is budgeted. Not hypothetical in this
|
|
213
|
+
// lane: a VIEW change cannot be re-aimed, so it opens a fresh page and loads it cold, and the
|
|
214
|
+
// region checks change view three times per region — every one of those after the first re-aim
|
|
215
|
+
// was racing a 3s clock meant for a page that is already booted.
|
|
216
|
+
//
|
|
217
|
+
// Found while chasing something else (the target, below in `lagoon-bootstrap`) and fixed on its
|
|
218
|
+
// own merits: it is a flake waiting on a slow CI runner, and the failure it produces is
|
|
219
|
+
// "nothing mounted under that slot" — which names the region and not the clock.
|
|
220
|
+
paintTimeout = PAINT_TIMEOUT_COLD;
|
|
221
|
+
pageView.set(server.key, view ?? 'region');
|
|
222
|
+
await existing.goto(lagoonUrl(server, target, view), { waitUntil: 'load' });
|
|
223
|
+
return existing;
|
|
224
|
+
}
|
|
225
|
+
// CLOSE THE PAGE THIS ONE REPLACES. A view change lands here with the previous view's page still
|
|
226
|
+
// open, and the pool entry below was simply overwritten — so that page was never closed and kept
|
|
227
|
+
// running its region for the rest of the command. The region checks change view three times per
|
|
228
|
+
// region: on a 23-region project (Peps) `motu check --runtime` held ~45 live pages by the end, ran
|
|
229
|
+
// the machine out of memory, and every region ran slower than the one before it — 30 minutes and
|
|
230
|
+
// a CI timeout for checks that take ~25 alone, and a "hang" for whichever region came last.
|
|
231
|
+
if (existing) await existing.close().catch(() => {});
|
|
232
|
+
const browser = await getBrowser();
|
|
233
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
|
234
|
+
await setupPageDiagnostics(page, null);
|
|
235
|
+
paintTimeout = PAINT_TIMEOUT_COLD;
|
|
236
|
+
pageView.set(server.key, view ?? 'region');
|
|
237
|
+
await page.goto(lagoonUrl(server, target, view), { waitUntil: 'load' });
|
|
238
|
+
pages.set(server.key, page);
|
|
239
|
+
return page;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Run one check against the shared page: re-aim it, collect this check's diagnostics, restore the
|
|
244
|
+
* viewport it was handed.
|
|
245
|
+
*/
|
|
246
|
+
async function onLagoonPage({ target, fit, forceError, transport, port, viewport, diagnostics, view }, fn) {
|
|
247
|
+
const server = await startLagoon({ target, fit, forceError, transport, port });
|
|
248
|
+
const page = await lagoonPage(server, target, view);
|
|
249
|
+
const previousSink = diagnosticSink;
|
|
250
|
+
const previousNetwork = networkSink;
|
|
251
|
+
diagnosticSink = diagnostics ?? [];
|
|
252
|
+
networkSink = [];
|
|
253
|
+
try {
|
|
254
|
+
if (viewport) await page.setViewportSize(viewport);
|
|
255
|
+
return await fn(page, server);
|
|
256
|
+
} finally {
|
|
257
|
+
diagnosticSink = previousSink;
|
|
258
|
+
networkSink = previousNetwork;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** `/lagoon.html` for a target, on a pooled server. */
|
|
263
|
+
function lagoonUrl(server, target, view) {
|
|
264
|
+
const q = new URLSearchParams({ target: target ?? '' });
|
|
265
|
+
if (view) q.set('view', view);
|
|
266
|
+
if (server.fit) q.set('fit', server.fit);
|
|
267
|
+
if (server.forceError) q.set('forceError', String(server.forceError));
|
|
268
|
+
// Verify mounts an island on its OWN defaults; the region's seed is for a human looking at the page.
|
|
269
|
+
if (String(target ?? '').startsWith('island:')) q.set('seed', 'off');
|
|
270
|
+
// NO INFERRED COLOUR WHILE THE CHECKS ARE DRIVING. A published lagoon detects its own primary from
|
|
271
|
+
// its pixels, which depends on what has painted by the time the raster runs — fine for a person
|
|
272
|
+
// opening the page, and a flaky visual baseline for `island snapshot`, which pictures islands
|
|
273
|
+
// through this same boot. The harness always sees motu's declared colour.
|
|
274
|
+
q.set('autoPrimary', 'off');
|
|
275
|
+
// AND NO PAGE-INSET. `responsive` measures the APPLICATION at each declared viewport; the dock is
|
|
276
|
+
// not part of the application, so reserving its strip during that run would measure the app 46px
|
|
277
|
+
// narrower than the viewport it declares and report an overflow the app does not have.
|
|
278
|
+
q.set('dockInset', 'off');
|
|
279
|
+
return `http://localhost:${server.port}/lagoon.html?${q}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Start the lagoon Vite dev server focused on one target ("island:x-…" | "archipelago:id"). */
|
|
283
|
+
async function startLagoonProcess({ target, fit = 'native', port, forceError, transport }) {
|
|
284
|
+
assertLagoonBootable();
|
|
285
|
+
// Spawn vite DIRECTLY (not via `pnpm exec`) in its own process group: a pnpm wrapper would spawn
|
|
286
|
+
// vite as a grandchild that gets orphaned on kill and keeps holding the strict port, so a later run
|
|
287
|
+
// would connect to the stale server. `detached: true` lets us SIGKILL the whole group in stop().
|
|
288
|
+
const child = spawn(
|
|
289
|
+
process.execPath,
|
|
290
|
+
[LAGOON_DEV, '--port', String(port), '--strictPort'],
|
|
291
|
+
{
|
|
292
|
+
// The project root, not the lagoon: the runner resolves motu.config.json by walking up from cwd.
|
|
293
|
+
cwd: REPO_ROOT,
|
|
294
|
+
env: {
|
|
295
|
+
...process.env,
|
|
296
|
+
MOTU_NO_SSL: '1',
|
|
297
|
+
MOTU_TARGET: target,
|
|
298
|
+
MOTU_FIT: fit,
|
|
299
|
+
...(forceError ? { MOTU_FORCE_ERROR: String(forceError) } : {}),
|
|
300
|
+
...(transport ? { MOTU_TRANSPORT: transport } : {}),
|
|
301
|
+
},
|
|
302
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
303
|
+
detached: true,
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
let log = '';
|
|
307
|
+
child.stdout.on('data', (d) => (log += d));
|
|
308
|
+
child.stderr.on('data', (d) => (log += d));
|
|
309
|
+
try {
|
|
310
|
+
await waitForPort(port);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
stopLagoon(child);
|
|
313
|
+
throw new Error(`${err.message}\n--- vite output ---\n${log}`);
|
|
314
|
+
}
|
|
315
|
+
return child;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Take (or start) a pooled server for this build posture. The `target` is passed for the first boot's
|
|
320
|
+
* env — later callers pass theirs in the URL.
|
|
321
|
+
*/
|
|
322
|
+
async function startLagoon(opts) {
|
|
323
|
+
// Only what is BAKED at build time can force another server. Target, fit and the forced error all
|
|
324
|
+
// travel in the URL, so what is left is the transport choice, which vite compiles in.
|
|
325
|
+
const key = String(opts.transport ?? '');
|
|
326
|
+
const existing = servers.get(key);
|
|
327
|
+
if (existing) return { ...existing, target: opts.target, fit: opts.fit, forceError: opts.forceError };
|
|
328
|
+
hookExit();
|
|
329
|
+
const port = opts.port ?? 5200 + Math.floor(Math.random() * 700);
|
|
330
|
+
const child = await startLagoonProcess({ ...opts, port });
|
|
331
|
+
const entry = { key, child, port };
|
|
332
|
+
servers.set(key, entry);
|
|
333
|
+
return { ...entry, target: opts.target, fit: opts.fit, forceError: opts.forceError };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** A pooled server is not stopped by its caller — the pool outlives the check (see closeLagoonPool). */
|
|
337
|
+
function stopLagoon(server) {
|
|
338
|
+
if (poolClosing || !server) return;
|
|
339
|
+
if (servers.has(server.key)) return;
|
|
340
|
+
stopLagoonProcess(server.child ?? server);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Kill the vite process group started by startLagoon. */
|
|
344
|
+
function stopLagoonProcess(child) {
|
|
345
|
+
if (!child || child.killed) return;
|
|
346
|
+
try {
|
|
347
|
+
process.kill(-child.pid, 'SIGKILL'); // negative pid → the whole detached group
|
|
348
|
+
} catch {
|
|
349
|
+
try {
|
|
350
|
+
child.kill('SIGKILL');
|
|
351
|
+
} catch {
|
|
352
|
+
/* already gone */
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Dev-server / framework noise that isn't the island's fault — filtered out of diagnostics.
|
|
358
|
+
const NOISE = /\[vite\]|Download the React DevTools|favicon|net::ERR|Failed to load resource/i;
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Wire a page's diagnostics capture: (1) a resolver that finds an island even when it's nested in a
|
|
362
|
+
* <motu-archipelago> shadow root (the lagoon wraps island targets in a one-island archipelago that
|
|
363
|
+
* owns the shadow boundary; the island renders LIGHT inside it, so it has no own shadowRoot and isn't
|
|
364
|
+
* reachable from document.querySelector); (2) console.error / uncaught / unhandled-rejection collection.
|
|
365
|
+
*/
|
|
366
|
+
async function setupPageDiagnostics(page, diagnostics) {
|
|
367
|
+
// `null` means "the shared sink" — the page outlives any one check, so its listeners write into
|
|
368
|
+
// whichever check is running (see onLagoonPage).
|
|
369
|
+
const sink = diagnostics ?? { push: (line) => diagnosticSink.push(line) };
|
|
370
|
+
await page.addInitScript(() => {
|
|
371
|
+
window.__motuFindIsland = (t) => {
|
|
372
|
+
let el = document.querySelector(t);
|
|
373
|
+
if (!el) {
|
|
374
|
+
for (const arch of document.querySelectorAll('motu-archipelago')) {
|
|
375
|
+
const hit = arch.shadowRoot && arch.shadowRoot.querySelector(t);
|
|
376
|
+
if (hit) {
|
|
377
|
+
el = hit;
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// React mount path: the island renders in the host's own tree, so there is no <x-tag> element.
|
|
383
|
+
// Its wrapper carries the tag instead (display:contents, so it is the same box as the content).
|
|
384
|
+
if (!el) el = document.querySelector('[data-motu-island="' + t + '"]');
|
|
385
|
+
return el;
|
|
386
|
+
};
|
|
387
|
+
// EXPAND printf-style console.error before it leaves the page. React logs its warnings as a
|
|
388
|
+
// format string plus arguments ("Each child in a list should have a unique \"key\" prop.%s%s"),
|
|
389
|
+
// and the text Playwright reports is the bare format — the component that broke the rule was in
|
|
390
|
+
// the arguments and never reached the finding.
|
|
391
|
+
const origError = console.error.bind(console);
|
|
392
|
+
console.error = (...args) => {
|
|
393
|
+
if (typeof args[0] === 'string' && /%[sdifoOc]/.test(args[0])) {
|
|
394
|
+
let rest = args.slice(1);
|
|
395
|
+
const text = args[0].replace(/%[sdifoOc]/g, (m) => (rest.length ? (m === '%c' ? (rest.shift(), '') : String(rest.shift())) : m));
|
|
396
|
+
return origError(text, ...rest);
|
|
397
|
+
}
|
|
398
|
+
return origError(...args);
|
|
399
|
+
};
|
|
400
|
+
window.addEventListener('unhandledrejection', (e) => {
|
|
401
|
+
const r = e && e.reason;
|
|
402
|
+
(window.__motuRejections = window.__motuRejections || []).push(
|
|
403
|
+
'unhandledrejection: ' + String((r && (r.stack || r.message)) || r),
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
// Rendered output = the island's own shadow (standalone) or its light DOM (nested in an
|
|
408
|
+
// archipelago), PLUS what its own mount portaled to <body> (a dialog, a sheet) — attributed by React
|
|
409
|
+
// ancestry, never by position, so lagoon chrome and other mounts stay out. See lib/island-render.mjs.
|
|
410
|
+
await page.addInitScript(installIslandRender);
|
|
411
|
+
page.on('console', (msg) => {
|
|
412
|
+
if (msg.type() === 'error' && !NOISE.test(msg.text())) sink.push(`console.error: ${msg.text()}`);
|
|
413
|
+
});
|
|
414
|
+
page.on('pageerror', (err) => {
|
|
415
|
+
const m = String(err?.message || err);
|
|
416
|
+
if (!NOISE.test(m)) sink.push(`pageerror: ${m}`);
|
|
417
|
+
});
|
|
418
|
+
// Every request the page issues, whether it succeeded or not — a stub gap usually FAILS (the lagoon
|
|
419
|
+
// points the app at an unresolvable host), and a failed request is exactly what `NOISE` hides.
|
|
420
|
+
page.on('request', (req) => {
|
|
421
|
+
const url = req.url();
|
|
422
|
+
if (!/^https?:/i.test(url)) return; // data:, blob:, about: — not the network
|
|
423
|
+
try {
|
|
424
|
+
if (!isLoopback(new URL(url).hostname)) networkSink.push({ method: req.method(), url });
|
|
425
|
+
} catch {
|
|
426
|
+
/* unparseable URL — not something to fail a check over */
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** What has escaped during the check currently running. Read from inside an `onLagoonPage` callback. */
|
|
432
|
+
function currentNetworkEscapes() {
|
|
433
|
+
return networkSink.slice();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Requests the lagoon's fake fetch (`@motujs/runtime/postgrest-fetch`) could not resolve against any
|
|
438
|
+
* declared table/fixture — accumulated in `window.__motuUnscopedRequests` by the fake itself. Read
|
|
439
|
+
* the same way `window.__motuRejections` is: a call that ran against nothing scoped for it is exactly
|
|
440
|
+
* as much a finding as an unhandled rejection, and both are populated by code running IN the page.
|
|
441
|
+
*
|
|
442
|
+
* Paired with the TOTAL request count the fake saw (`window.__motuFakeFetchRequestCount`) — a run
|
|
443
|
+
* that made zero requests has zero unscoped ones too, and that is not the same claim as "every
|
|
444
|
+
* request was covered". `undefined` when the fake never ran at all (a project still on module-alias
|
|
445
|
+
* stubs), so the caller can skip reporting the check rather than fabricate a count of 0.
|
|
446
|
+
*/
|
|
447
|
+
async function readUnscopedRequests(page) {
|
|
448
|
+
return page
|
|
449
|
+
.evaluate(() => {
|
|
450
|
+
// ANY of the three, not just the first two. A fake that CLAIMED nothing never initialises its
|
|
451
|
+
// request counter, so a lagoon whose `appRoutes` miss the path the app actually calls looked
|
|
452
|
+
// identical to one with no fake installed at all — and the `unanswered` list, which is the whole
|
|
453
|
+
// evidence for that case, was discarded on the way out. That is the exact shape of the gap this
|
|
454
|
+
// list was added to close, hiding one layer above it.
|
|
455
|
+
if (
|
|
456
|
+
window.__motuUnscopedRequests === undefined &&
|
|
457
|
+
window.__motuFakeFetchRequestCount === undefined &&
|
|
458
|
+
window.__motuUnansweredRequests === undefined
|
|
459
|
+
) {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
const unscoped = window.__motuUnscopedRequests || [];
|
|
463
|
+
// Delegated-and-failed: the fake never claimed it, and the dev server answered with an error.
|
|
464
|
+
const unanswered = window.__motuUnansweredRequests || [];
|
|
465
|
+
const seen = window.__motuFakeFetchRequestCount || 0;
|
|
466
|
+
const reach = window.__motuDataReach || { tables: {}, rpcs: [], by: {} };
|
|
467
|
+
return { unscoped, unanswered, seen, reach };
|
|
468
|
+
})
|
|
469
|
+
.catch(() => undefined);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Neutralise auto-generated element ids before two renders are compared.
|
|
474
|
+
*
|
|
475
|
+
* React's `useId` hands out ids from a per-root counter, so a second mount legitimately produces
|
|
476
|
+
* `radix-_r_1_` where the first had `radix-_r_0_`. Any component library built on it (Radix, and so
|
|
477
|
+
* every shadcn UI) therefore differs between mounts for a reason that says nothing about the island.
|
|
478
|
+
* Comparing raw HTML made that look like leaked module state, and — in the other direction — could
|
|
479
|
+
* make two identical scenarios look meaningfully different and pass the data-flow check for free.
|
|
480
|
+
* Both comparisons are about CONTENT, so strip the counter from both.
|
|
481
|
+
*/
|
|
482
|
+
function normalizeRender(html) {
|
|
483
|
+
return String(html)
|
|
484
|
+
.replace(/_r_[0-9a-z]+_/gi, '_r_#_') // React 19
|
|
485
|
+
.replace(/:r[0-9a-z]+:/gi, ':r#:') // React 18
|
|
486
|
+
.replace(/\u00abr[0-9a-z]+\u00bb/gi, 'r#'); // React 18, SSR-safe form
|
|
487
|
+
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Wait for the island's rendered output to SETTLE, and return it.
|
|
492
|
+
*
|
|
493
|
+
* "Non-empty" is not the same as "finished". An island that loads through the contract paints an empty
|
|
494
|
+
* shell first and fills in a tick later, so a snapshot taken at first paint is a race: the re-mount
|
|
495
|
+
* check compares an empty first mount against a warm, already-filled second one and reports phantom
|
|
496
|
+
* instability, and the data-flow check compares two shells and calls them identical. Since almost every
|
|
497
|
+
* island that talks to a backend behaves this way, settling has to be the default, not an opt-in.
|
|
498
|
+
*
|
|
499
|
+
* Settled = non-empty and unchanged across consecutive samples. Returns '' if it never rendered.
|
|
500
|
+
*/
|
|
501
|
+
/**
|
|
502
|
+
* `quietSamples × intervalMs` is a floor every mount pays, and a project pays it per island per
|
|
503
|
+
* scenario: 3 × 120ms = 360ms, five or six mounts per island, sixteen islands. Most of a
|
|
504
|
+
* `check --runtime` run was this deliberate waiting.
|
|
505
|
+
*
|
|
506
|
+
* 60ms keeps three confirmations — the shape of the check is unchanged, it is the same "has it stopped
|
|
507
|
+
* moving" question — while halving the floor to 180ms. An island still changing resets the counter and
|
|
508
|
+
* keeps its full deadline, so what this can miss is a render whose only remaining update lands in a
|
|
509
|
+
* gap longer than 180ms and never changes anything again. Measured across three projects (16, 4 and 5
|
|
510
|
+
* islands) nothing regressed.
|
|
511
|
+
*/
|
|
512
|
+
async function waitForStableRender(page, tag, { timeoutMs, quietSamples = 3, intervalMs = 60 } = {}) {
|
|
513
|
+
const started = Date.now();
|
|
514
|
+
const deadline = started + (timeoutMs ?? paintTimeout);
|
|
515
|
+
let last = null;
|
|
516
|
+
let stable = 0;
|
|
517
|
+
while (Date.now() < deadline) {
|
|
518
|
+
let html = '';
|
|
519
|
+
try {
|
|
520
|
+
html = await page.evaluate((t) => window.__motuRendered(window.__motuFindIsland(t)), tag);
|
|
521
|
+
} catch {
|
|
522
|
+
// Execution context destroyed by Vite's dep re-optimization reload — re-poll on the new one.
|
|
523
|
+
last = null;
|
|
524
|
+
stable = 0;
|
|
525
|
+
html = null;
|
|
526
|
+
}
|
|
527
|
+
if (html !== null && html === (last ?? '')) {
|
|
528
|
+
// EMPTY settles too, after a grace period. Only counting non-empty output as settled meant an
|
|
529
|
+
// island that legitimately renders nothing from its defaults (a pure projection with no input)
|
|
530
|
+
// burned the whole timeout on every check — fifteen seconds to observe an emptiness that was
|
|
531
|
+
// decided in the first frame.
|
|
532
|
+
const grace = html === '' ? started + 1000 : started;
|
|
533
|
+
if (++stable >= quietSamples && Date.now() >= grace) return html;
|
|
534
|
+
} else if (html !== null) {
|
|
535
|
+
stable = 0;
|
|
536
|
+
last = html;
|
|
537
|
+
}
|
|
538
|
+
await sleep(intervalMs);
|
|
539
|
+
}
|
|
540
|
+
return last ?? '';
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Mount one island in the real-browser lagoon and report whether it rendered. Optionally screenshots.
|
|
545
|
+
* Returns { ok, mounted, shadowLength, diagnostics, remountIdentical }.
|
|
546
|
+
* diagnostics — console.error lines + uncaught errors + unhandled rejections (empty = clean).
|
|
547
|
+
* remountIdentical — true/false if the re-mount check ran, null if it couldn't (island never rendered).
|
|
548
|
+
*/
|
|
549
|
+
export async function runLagoon({ tag, fit = 'native', port = 5199, screenshotPath, checkRemount = true, forceError }) {
|
|
550
|
+
const { chromium } = await import('playwright');
|
|
551
|
+
const diagnostics = [];
|
|
552
|
+
return onLagoonPage({ target: `island:${tag}`, fit, port, forceError, viewport: { width: 900, height: 720 }, diagnostics }, async (page, server) => {
|
|
553
|
+
|
|
554
|
+
// Poll the DOM until the island upgrades and paints. This is a retry loop rather than a single
|
|
555
|
+
// waitForFunction because Vite's first-run dep re-optimization can trigger a full page reload that
|
|
556
|
+
// destroys the execution context mid-check — we simply re-poll on the new one.
|
|
557
|
+
let result = { mounted: false, shadowLength: 0 };
|
|
558
|
+
const deadline = Date.now() + paintTimeout;
|
|
559
|
+
while (Date.now() < deadline) {
|
|
560
|
+
try {
|
|
561
|
+
result = await page.evaluate((t) => {
|
|
562
|
+
const el = window.__motuFindIsland(t);
|
|
563
|
+
const html = window.__motuRendered(el);
|
|
564
|
+
return { mounted: !!el, shadowLength: html.length };
|
|
565
|
+
}, tag);
|
|
566
|
+
if (result.mounted && result.shadowLength > 0) break;
|
|
567
|
+
} catch {
|
|
568
|
+
// Execution context destroyed by an HMR/full reload — wait and retry on the fresh context.
|
|
569
|
+
}
|
|
570
|
+
await sleep(200);
|
|
571
|
+
}
|
|
572
|
+
// Let an async-loading island finish before anything is measured from it.
|
|
573
|
+
if (result.mounted) {
|
|
574
|
+
const settled = await waitForStableRender(page, tag);
|
|
575
|
+
result.shadowLength = settled.length;
|
|
576
|
+
result.portals = await portalCount(page, tag);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (screenshotPath) await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => {});
|
|
580
|
+
|
|
581
|
+
// Re-mount identical: dispose the element and mount a fresh clone, then diff the rendered output. A
|
|
582
|
+
// difference means the island carries accidental module-level state (islands mount/unmount often).
|
|
583
|
+
let remountIdentical = null;
|
|
584
|
+
if (checkRemount && result.mounted && result.shadowLength > 0) {
|
|
585
|
+
remountIdentical = await remountAndCompare(page, tag);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const rejections = await page.evaluate(() => window.__motuRejections || []).catch(() => []);
|
|
589
|
+
for (const r of rejections) if (!NOISE.test(r)) diagnostics.push(r);
|
|
590
|
+
const fixtureCoverage = await readUnscopedRequests(page);
|
|
591
|
+
const networkEscapes = currentNetworkEscapes();
|
|
592
|
+
|
|
593
|
+
return { ok: result.mounted && result.shadowLength > 0, ...result, diagnostics, remountIdentical, fixtureCoverage, networkEscapes };
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** How many body-level nodes the island's own mount portaled (0 when none, or unreadable). */
|
|
598
|
+
async function portalCount(page, tag) {
|
|
599
|
+
return page
|
|
600
|
+
.evaluate((t) => {
|
|
601
|
+
const el = window.__motuFindIsland(t);
|
|
602
|
+
return el ? window.__motuPortals(el).length : 0;
|
|
603
|
+
}, tag)
|
|
604
|
+
.catch(() => 0);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Snapshot the island's rendered output, swap in a fresh clone (fires disconnect+reconnect), diff. */
|
|
608
|
+
async function remountAndCompare(page, tag) {
|
|
609
|
+
try {
|
|
610
|
+
const before = await page.evaluate((t) => {
|
|
611
|
+
const el = window.__motuFindIsland(t);
|
|
612
|
+
if (!el) return null;
|
|
613
|
+
const html = window.__motuRendered(el);
|
|
614
|
+
// React mount path: swapping the DOM node only detaches React's tree — the "fresh" node would
|
|
615
|
+
// stay empty and every island would look unstable. Tear the tree down and rebuild it instead,
|
|
616
|
+
// which is the same question asked of the same code: does anything survive that should not.
|
|
617
|
+
if (window.__motuLagoon && typeof window.__motuLagoon.remount === 'function') {
|
|
618
|
+
window.__motuLagoon.remount();
|
|
619
|
+
return html;
|
|
620
|
+
}
|
|
621
|
+
const parent = el.parentNode;
|
|
622
|
+
const clone = el.cloneNode(false); // attributes only — a genuinely fresh element, no shadow
|
|
623
|
+
parent.removeChild(el); // disconnectedCallback -> __motuDispose
|
|
624
|
+
parent.appendChild(clone); // connectedCallback -> fresh mount
|
|
625
|
+
return html;
|
|
626
|
+
}, tag);
|
|
627
|
+
if (before == null) return null;
|
|
628
|
+
|
|
629
|
+
const after = await waitForStableRender(page, tag, { timeoutMs: 8000 });
|
|
630
|
+
return normalizeRender(after) === normalizeRender(before);
|
|
631
|
+
} catch {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Run a region's declared FLOWS: seed, fire a declared output, check what the region holds.
|
|
638
|
+
*
|
|
639
|
+
* The executable form of a coupling. `wiring-live` proves a declared write reaches its key; this
|
|
640
|
+
* proves the region ends up in the state the flow says it should — which is the difference between
|
|
641
|
+
* "the wire is connected" and "answering a card raises the banner's number".
|
|
642
|
+
*
|
|
643
|
+
* Returns [{ scenario, step, ok, mismatches: [{ key, expected, actual }] }].
|
|
644
|
+
*/
|
|
645
|
+
/**
|
|
646
|
+
* `onStep(page, { scenario, step })`, when given, runs after each step settles — `motu lagoon try`
|
|
647
|
+
* uses it to picture what a step left on screen. It sees the page; it must not drive it.
|
|
648
|
+
*/
|
|
649
|
+
export async function runRegionFlows({ id, port = 5199, scenarios = [], chartWith = null, trace = false, onStep = null }) {
|
|
650
|
+
const { chromium } = await import('playwright');
|
|
651
|
+
// See probeWiring: the flows are where writes happen, so this is where a store complaint lands.
|
|
652
|
+
const diagnostics = [];
|
|
653
|
+
return onLagoonPage({ target: `archipelago:${id}`, port, viewport: { width: 1280, height: 900 }, diagnostics, view: 'mountpoints' }, async (page, server) => {
|
|
654
|
+
await page.waitForFunction(() => !!window.__motuLagoon, null, { timeout: 15000 }).catch(() => {});
|
|
655
|
+
await sleep(400);
|
|
656
|
+
|
|
657
|
+
// WHAT MOVED WHILE THE REGION MERELY RENDERED — captured HERE, before the first `emit`.
|
|
658
|
+
//
|
|
659
|
+
// Every later read is contaminated for this purpose: a flow's emit goes through the same seam a
|
|
660
|
+
// component's own output does and writes with the same source, so afterwards "the key moved" is
|
|
661
|
+
// true whether the component produced it or the harness did. Before any emit, the only thing that
|
|
662
|
+
// can have moved a key is the region rendering itself.
|
|
663
|
+
//
|
|
664
|
+
// WAIT FOR THE REGION, do not sleep at it — the same rule the scenario loop below learned. Reading
|
|
665
|
+
// after `sleep(400)` alone reported every effect-driven output as never fired on a region where
|
|
666
|
+
// they all fire: the slots had not mounted yet, so the honest answer was "nothing has rendered",
|
|
667
|
+
// and it came out as "your components stopped emitting".
|
|
668
|
+
// RESET, REMOUNT, READ. The lane opens one page for the whole run, so the wiring probe has already
|
|
669
|
+
// fired every declared write on it — a tally read without clearing says everything emitted,
|
|
670
|
+
// including outputs a component only fires from a click. Remounting re-runs the effects, so what
|
|
671
|
+
// lands in the cleared tally is what RENDERING alone produced.
|
|
672
|
+
await page.evaluate(() => window.__motuLagoon?.resetOutputs?.()).catch(() => {});
|
|
673
|
+
await page.evaluate(() => window.__motuLagoon?.remount?.()).catch(() => {});
|
|
674
|
+
await page
|
|
675
|
+
.waitForFunction(() => document.querySelectorAll('[data-motu-slot]').length > 0, null, { timeout: 10000 })
|
|
676
|
+
.catch(() => {});
|
|
677
|
+
await sleep(600);
|
|
678
|
+
const renderOutputs = await page
|
|
679
|
+
.evaluate(() => (typeof window.__motuLagoon?.outputs === 'function' ? window.__motuLagoon.outputs() : []))
|
|
680
|
+
.catch(() => []);
|
|
681
|
+
|
|
682
|
+
const out = [];
|
|
683
|
+
// THE CALL WATERMARK: every landed call carries a monotonic `seq`, so "what did this step cause"
|
|
684
|
+
// is "what landed after the mark". The first step's mark is taken BEFORE the scenario's reset and
|
|
685
|
+
// remount, so calls the seed and the mount make are that step's too.
|
|
686
|
+
const lastSeq = () =>
|
|
687
|
+
page.evaluate(() => Math.max(0, ...((window.__motuLagoon?.calls?.() ?? []).map((c) => c.seq)))).catch(() => 0);
|
|
688
|
+
const callsSince = (seq) =>
|
|
689
|
+
page.evaluate((s) => (window.__motuLagoon?.calls?.() ?? []).filter((c) => c.seq > s), seq).catch(() => []);
|
|
690
|
+
// THE WORLD each flow runs in (`world` on the flow; absent = the default lagoon). Switching makes
|
|
691
|
+
// the focused entry re-render the region with that world's answers — its channels rebuilt with
|
|
692
|
+
// the world's args — so the flow drives what that world hands the app.
|
|
693
|
+
let activeWorld = 'default';
|
|
694
|
+
const switchWorld = async (want) => {
|
|
695
|
+
if (want === activeWorld) return true;
|
|
696
|
+
const ok = await page.evaluate((w) => window.__motuLagoonHarness?.setWorld?.(w) ?? false, want).catch(() => false);
|
|
697
|
+
if (!ok) return false;
|
|
698
|
+
activeWorld = want;
|
|
699
|
+
await page
|
|
700
|
+
.waitForFunction(() => document.querySelectorAll('[data-motu-slot]').length > 0, null, { timeout: 10000 })
|
|
701
|
+
.catch(() => {});
|
|
702
|
+
await sleep(600);
|
|
703
|
+
return true;
|
|
704
|
+
};
|
|
705
|
+
// WHERE THE LANE'S TIME WENT, per scenario. `region-flow` was 73% of a `check --runtime` on Peps and
|
|
706
|
+
// printed as one number; the real flows, the flow-mutation mutants, the world mutants and the
|
|
707
|
+
// null-island mutants all ride this lane, and which of them costs what is the whole question.
|
|
708
|
+
const timings = [];
|
|
709
|
+
const laneStarted = Date.now();
|
|
710
|
+
// ANSWERED GROUPS. A scenario carrying `__killGroup` belongs to a set whose verdict is "did ANY of
|
|
711
|
+
// these fail?" (a null-island slot and mode). The first failure answers it, so the rest of the
|
|
712
|
+
// group is not run — on Peps' `actions` the null-island mutants were 260 of 284 scenarios and 89%
|
|
713
|
+
// of the region's time, nearly all of it replaying flows for groups already answered.
|
|
714
|
+
const killed = new Set();
|
|
715
|
+
const suspects = [];
|
|
716
|
+
const suspectSeen = new Set();
|
|
717
|
+
// Every mutant the lane carries is tagged by the caller; a scenario with any of these is not a flow.
|
|
718
|
+
const isMutantScenario = (sc) => Boolean(sc.__nullIsland || sc.__killGroup || sc.__world || /^µ /.test(String(sc.name ?? '')));
|
|
719
|
+
// HOW LONG EACH REAL STEP TOOK TO HOLD. The real flows run first in the lane, so by the time a
|
|
720
|
+
// null-island replay of a step runs, the region has already shown how long that step needs. The
|
|
721
|
+
// replay's assertion is meant to FAIL — a nulled island renders less — and waiting the full 2s for
|
|
722
|
+
// each failure was most of what null-island still cost after the kill groups (~46s of 70 on
|
|
723
|
+
// Peps' `actions`). A replay whose assertion still holds holds about as fast as the real step did,
|
|
724
|
+
// so it gets three times that plus a margin; a step whose real run never held keeps the full 2s.
|
|
725
|
+
const settledIn = new Map();
|
|
726
|
+
const budgetFor = (of, index) => {
|
|
727
|
+
const real = settledIn.get(`${of}#${index}`);
|
|
728
|
+
return real == null ? 2000 : Math.min(2000, Math.max(400, 3 * real + 250));
|
|
729
|
+
};
|
|
730
|
+
for (const scenario of scenarios) {
|
|
731
|
+
if (scenario.__killGroup && killed.has(scenario.__killGroup)) continue;
|
|
732
|
+
const scenarioStarted = Date.now();
|
|
733
|
+
if (!(await switchWorld(scenario.world ?? 'default'))) {
|
|
734
|
+
out.push({ scenario: scenario.name ?? 'flow', step: 1, ok: false, error: `world "${scenario.world}" is not declared in the lagoon`, mismatches: [], calls: 0, undeclared: [] });
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
// A NULL-ISLAND MUTANT names one slot to render from defaults or not at all; every other flow
|
|
738
|
+
// clears it. Set before the reset and remount below, so the region mounts already nulled.
|
|
739
|
+
const nullable = await page
|
|
740
|
+
.evaluate((n) => {
|
|
741
|
+
const set = window.__motuLagoon?.nullIsland;
|
|
742
|
+
if (!set) return false;
|
|
743
|
+
set(null);
|
|
744
|
+
if (n) set(n.slot, n.mode);
|
|
745
|
+
return true;
|
|
746
|
+
}, scenario.__nullIsland ?? null)
|
|
747
|
+
.catch(() => false);
|
|
748
|
+
// A MUTANT THIS MOUNT PATH CANNOT MAKE IS NOT RUN. The custom-element path (demo-app) has no
|
|
749
|
+
// null-island switch; running the flow anyway replayed the REAL flow, every one held, and every
|
|
750
|
+
// slot of demo-app was reported unconstrained — a verdict about a mutant nobody made.
|
|
751
|
+
if (scenario.__nullIsland && !nullable) {
|
|
752
|
+
out.push({ scenario: scenario.name ?? 'flow', step: 1, ok: false, unsupported: true, error: 'this mount path cannot null an island', mismatches: [], calls: 0, undeclared: [] });
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
let mark = await lastSeq();
|
|
756
|
+
// A fresh REGION per scenario: forget the store, THEN rebuild the tree. One flow CAN leave the
|
|
757
|
+
// region unrenderable — seed an index past the end of a list and the component that reads it
|
|
758
|
+
// throws, React tears the tree down, and every later flow reports "no island mounted under that
|
|
759
|
+
// slot", which is true and completely misleading. Isolation keeps each verdict about its own flow.
|
|
760
|
+
//
|
|
761
|
+
// `remount()` ALONE WAS NOT ISOLATION, and the gap silently broke flow-mutation. The store is
|
|
762
|
+
// registered per archipelago id and deliberately survives a remount, so whatever a scenario put
|
|
763
|
+
// in it was still there when the next one started. Mutants run after the real flows, so a mutant
|
|
764
|
+
// whose effect a REAL scenario had already produced asserted against that leftover state and
|
|
765
|
+
// "still held" — reported as "the assertion does not depend on what the step does" when in truth
|
|
766
|
+
// the step's own effect was already in the store. Measured on a region whose list island asks a
|
|
767
|
+
// source to open a detail: the real scenario selected a candidate, the mutant emitted `null`,
|
|
768
|
+
// nothing happened, and the assertion passed on the previous scenario's selection. That is the
|
|
769
|
+
// worst shape of failure this check can have — it accuses a correct flow of being a tautology.
|
|
770
|
+
//
|
|
771
|
+
// `reset()` is documented "for SCENARIO lanes only" for exactly this call site: it forgets the
|
|
772
|
+
// keys and re-applies the region's own seed. A flow's own STEPS still build on each other, which
|
|
773
|
+
// is why this sits outside the step loop.
|
|
774
|
+
// One `evaluate` each, like the pre-flow reset above: remounting tears down the React tree, and
|
|
775
|
+
// doing it in the same evaluate as the store reset destroyed the execution context mid-call
|
|
776
|
+
// ("Execution context was destroyed"), which took the whole flow lane down with it.
|
|
777
|
+
await page.evaluate(() => window.__motuLagoon?.reset?.()).catch(() => {});
|
|
778
|
+
// LET THE SOURCE LAND BEFORE THE SCENARIO SEEDS. Restarting the channels starts the region's
|
|
779
|
+
// first production again, and that write is ASYNC — so without this pause it arrived after the
|
|
780
|
+
// flow's own seed and overwrote it. Measured across three regions: a Conseil seeded "séance en
|
|
781
|
+
// cours" rendered the default next-séance fixture instead, and the flow failed on a state it had
|
|
782
|
+
// asked for and been given, then silently lost. A scenario's seed is the authoritative starting
|
|
783
|
+
// state; the source's initial answer belongs before it, not on top of it.
|
|
784
|
+
await sleep(400);
|
|
785
|
+
// SEED, THEN MOUNT. The flow's seed used to land in step 1, AFTER this remount — so every flow
|
|
786
|
+
// first mounted the region in its DEFAULT state and only then moved it to the one it asked for.
|
|
787
|
+
// Whatever the mount did under the wrong state stayed done: on shlink the app's own "fetch the
|
|
788
|
+
// remote servers when the store starts empty" fired on every flow, because the store WAS empty
|
|
789
|
+
// for one mount, and a flow declaring "this state asks for nothing" (`expectCalls: []`) could
|
|
790
|
+
// never hold. Step 1 still applies the seed again after the mount (a source the reset restarted
|
|
791
|
+
// may answer late); this makes the mount itself happen in the flow's own state.
|
|
792
|
+
await page
|
|
793
|
+
.evaluate((seed) => {
|
|
794
|
+
const lagoon = window.__motuLagoon;
|
|
795
|
+
for (const [k, v] of Object.entries(seed ?? {})) (lagoon?.seed ?? lagoon?.provide)?.(k, v);
|
|
796
|
+
}, scenario.seed ?? {})
|
|
797
|
+
.catch(() => {});
|
|
798
|
+
// Step 1's calls start HERE: the mount is part of what the flow's first state does (`flow-calls`
|
|
799
|
+
// counts it), the reset to the region's default above is not.
|
|
800
|
+
mark = await lastSeq();
|
|
801
|
+
await page.evaluate(() => window.__motuLagoon?.remount?.()).catch(() => {});
|
|
802
|
+
// WAIT FOR THE REGION, do not sleep at it. 250ms was enough for stand-in islands and is not
|
|
803
|
+
// enough for an app's real component tree (Twenty's widgets mount a dozen providers, read a
|
|
804
|
+
// metadata store and issue queries) — the assertions then ran against a region that had not
|
|
805
|
+
// rendered yet and reported "the region rendered nothing after this step", which is a timing
|
|
806
|
+
// artefact wearing the costume of a real failure.
|
|
807
|
+
await page
|
|
808
|
+
.waitForFunction(() => document.querySelectorAll('[data-motu-slot]').length > 0, null, { timeout: 10000 })
|
|
809
|
+
.catch(() => {});
|
|
810
|
+
for (let [index, step] of (scenario.steps ?? []).entries()) {
|
|
811
|
+
// The first step of a scenario establishes the page's starting state; the rest build on it.
|
|
812
|
+
step = index === 0 ? { ...step, __seedNow: true } : step;
|
|
813
|
+
if (trace) step = { ...step, __trace: true };
|
|
814
|
+
if (scenario.__nullIsland && scenario.__of) step = { ...step, __budgetMs: budgetFor(scenario.__of, index) };
|
|
815
|
+
// WHAT THIS STEP CRASHED, if anything. A mutant's crude value can throw inside an island
|
|
816
|
+
// (`null.toLowerCase()`), the region empties, and the assertion "fails" — which read as the
|
|
817
|
+
// assertion discriminating. `flow-mutation` needs to tell a crash from a kill, so every step
|
|
818
|
+
// records the page errors raised while it ran.
|
|
819
|
+
const crashes = [];
|
|
820
|
+
const onCrash = (err) => crashes.push(String(err?.message || err).split('\n')[0].slice(0, 200));
|
|
821
|
+
page.on('pageerror', onCrash);
|
|
822
|
+
const result = await page.evaluate(
|
|
823
|
+
async ({ seed, step: st }) => {
|
|
824
|
+
const lagoon = window.__motuLagoon;
|
|
825
|
+
if (!lagoon || typeof lagoon.emit !== 'function') return { error: 'no emit seam on this mount path' };
|
|
826
|
+
// A flow's `seed` is a SEED — the page establishing a starting value — even when the key
|
|
827
|
+
// belongs to an island. Applying it as a host write made the harness trip motu's own
|
|
828
|
+
// ownership guard on every produced key a flow starts from.
|
|
829
|
+
//
|
|
830
|
+
// ONCE PER SCENARIO, not per step. Re-seeding before every step silently undid whatever
|
|
831
|
+
// the previous steps had done: a booking flow that picked two seats, then applied a promo,
|
|
832
|
+
// asserted the discounted total against a fare whose seats had been reset to zero. Every
|
|
833
|
+
// flow written before this had a single step, so nothing noticed. A journey is a sequence
|
|
834
|
+
// or it is three unrelated assertions wearing one name.
|
|
835
|
+
if (st.__seedNow) {
|
|
836
|
+
for (const [k, v] of Object.entries(seed ?? {})) (lagoon.seed ?? lagoon.provide)(k, v);
|
|
837
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
838
|
+
}
|
|
839
|
+
// Setup done. From here, a host write is a RESPONSE to what the island did.
|
|
840
|
+
window.__motuSuspects?.reset?.();
|
|
841
|
+
if (st.emit) {
|
|
842
|
+
if (!lagoon.emit(st.emit.slot, st.emit.event, st.emit.detail)) {
|
|
843
|
+
return { error: `no island mounted under slot "${st.emit.slot}"` };
|
|
844
|
+
}
|
|
845
|
+
} else if (st.provide) {
|
|
846
|
+
// The APPLICATION moving its own state, which is how a host-fed key ever changes.
|
|
847
|
+
for (const [k, v] of Object.entries(st.provide)) lagoon.provide(k, v);
|
|
848
|
+
} else if (st.click) {
|
|
849
|
+
// THE USER'S HALF. `emit` enters the region past the component's own handler, so a
|
|
850
|
+
// control whose callback was dropped leaves an emit-only flow green while the screen is
|
|
851
|
+
// dead — measured twice. Clicking the RENDERED control makes the component fire its own
|
|
852
|
+
// declared output, which is the path a person actually takes.
|
|
853
|
+
//
|
|
854
|
+
// Accessible name, never a selector: the same rule island scenarios follow, resolved the
|
|
855
|
+
// same way. Exact match first so a short name is not stolen by a longer control that
|
|
856
|
+
// contains it, then the narrowest containing match; visible elements only; the whole
|
|
857
|
+
// document, so a control inside a portal is reachable.
|
|
858
|
+
const name = String(st.click).trim();
|
|
859
|
+
const CLICKABLE =
|
|
860
|
+
'button, [role="button"], input[type="checkbox"], [role="checkbox"], [role="switch"], [role="radio"], [role="menuitem"], [role="tab"], a[href], label, summary';
|
|
861
|
+
const nameOf = (el) => {
|
|
862
|
+
const aria = el.getAttribute('aria-label') ?? el.getAttribute('title') ?? el.getAttribute('alt');
|
|
863
|
+
return `${aria ?? ''} ${el.innerText ?? el.textContent ?? ''}`.replace(/\s+/g, ' ').trim();
|
|
864
|
+
};
|
|
865
|
+
const candidates = [...document.querySelectorAll(CLICKABLE)].filter(
|
|
866
|
+
(el) => el.offsetParent !== null || el.getClientRects().length > 0,
|
|
867
|
+
);
|
|
868
|
+
const el =
|
|
869
|
+
candidates.find((c) => nameOf(c) === name) ??
|
|
870
|
+
candidates.filter((c) => nameOf(c).includes(name)).sort((a, b) => nameOf(a).length - nameOf(b).length)[0];
|
|
871
|
+
if (!el) {
|
|
872
|
+
// NAME WHAT WAS THERE. "nothing is named X" turns writing a flow into guessing at the
|
|
873
|
+
// accessible names a component happens to produce; the whole point of naming controls
|
|
874
|
+
// this way is that the names are legible, so print them.
|
|
875
|
+
const available = [...new Set(candidates.map(nameOf).filter(Boolean))].slice(0, 12);
|
|
876
|
+
return {
|
|
877
|
+
error:
|
|
878
|
+
`nothing clickable is named "${name}" — clickable here: ` +
|
|
879
|
+
(available.length ? available.map((n) => `"${n}"`).join(', ') : '(nothing clickable is rendered)'),
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
el.click();
|
|
883
|
+
} else if (Object.keys(st.expectRender ?? {}).length) {
|
|
884
|
+
// A COVERAGE step: no stimulus, only "this slot renders its own island". Legitimate and
|
|
885
|
+
// not a data flow — some islands take no input at all (Twenty's tasks and timeline
|
|
886
|
+
// widgets ignore the row they are bound to and render from the record context), so
|
|
887
|
+
// there is nothing to drive and the claim is still worth making: it is what catches a
|
|
888
|
+
// slot wired to another island's data.
|
|
889
|
+
} else {
|
|
890
|
+
return { error: 'a step must `emit`, `provide`, `click`, or assert with `expectRender`' };
|
|
891
|
+
}
|
|
892
|
+
// Settle, then re-check: an assertion may become true a tick or three after the write —
|
|
893
|
+
// a store subscriber, an effect, a real component's render pass. Polling keeps the check
|
|
894
|
+
// exactly as strict (it still fails if the claim never becomes true) while removing the
|
|
895
|
+
// race that a single fixed wait bakes in.
|
|
896
|
+
// A MUTANT IS EXPECTED TO FAIL, so waiting the full budget for it is pure cost: every
|
|
897
|
+
// mutant polled 2s before concluding what it was always going to conclude, and
|
|
898
|
+
// flow-mutation became 30% of a whole `check --runtime` run. A mutant that DOES still hold
|
|
899
|
+
// holds immediately — the prefix steps already settled the region — so a short budget
|
|
900
|
+
// loses nothing. Real steps keep the long one, because that is where a slow effect is
|
|
901
|
+
// genuinely being waited for.
|
|
902
|
+
// `__budgetMs`: a null-island replay is given what its REAL step needed (set by the lane
|
|
903
|
+
// below), not the 2s a real step is allowed for an effect nobody has timed yet.
|
|
904
|
+
const budgetMs = st.__budgetMs ?? (st.__mutant ? 300 : 2000);
|
|
905
|
+
const settleStarted = Date.now();
|
|
906
|
+
const settled = async (check) => {
|
|
907
|
+
const until = Date.now() + budgetMs;
|
|
908
|
+
for (;;) {
|
|
909
|
+
if (check()) return true;
|
|
910
|
+
if (Date.now() >= until) return check();
|
|
911
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
915
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
916
|
+
await settled(() =>
|
|
917
|
+
Object.entries(st.expect ?? {}).every(([key, expected]) => same(expected, lagoon.read(key))),
|
|
918
|
+
);
|
|
919
|
+
const mismatches = Object.entries(st.expect ?? {})
|
|
920
|
+
.map(([key, expected]) => ({ key, expected, actual: lagoon.read(key) }))
|
|
921
|
+
.filter((m) => !same(m.expected, m.actual));
|
|
922
|
+
|
|
923
|
+
// What the islands SHOW. Read off the mountpoints the lagoon already marks, so this needs
|
|
924
|
+
// no selector from the project and cannot drift from where the region actually mounted.
|
|
925
|
+
/**
|
|
926
|
+
* What a person can PERCEIVE in this slot — not merely what is text.
|
|
927
|
+
*
|
|
928
|
+
* `innerText` alone cannot see a text field's value or a control's accessible name, so a
|
|
929
|
+
* booking flow could not assert that the passenger form shows "Ada Lovelace" (an input
|
|
930
|
+
* value) or that the seat map offers "Seat 2A, available" (an aria-label). Both are
|
|
931
|
+
* plainly perceivable — one on screen, one to a screen reader — and an island made of form
|
|
932
|
+
* controls had almost nothing assertable without them.
|
|
933
|
+
*
|
|
934
|
+
* Checkbox and radio state is included as `checked`/`unchecked` for the same reason: it is
|
|
935
|
+
* the control's value, and it is the only thing that distinguishes a ticked box from an
|
|
936
|
+
* empty one.
|
|
937
|
+
*/
|
|
938
|
+
const perceivable = (el) => {
|
|
939
|
+
const parts = [el.innerText ?? el.textContent ?? ''];
|
|
940
|
+
const own = el.getAttribute?.('aria-label');
|
|
941
|
+
if (own) parts.push(own);
|
|
942
|
+
for (const f of el.querySelectorAll('input, textarea, select')) {
|
|
943
|
+
if (f.type === 'checkbox' || f.type === 'radio') parts.push(f.checked ? 'checked' : 'unchecked');
|
|
944
|
+
else if (f.value) parts.push(f.value);
|
|
945
|
+
if (f.placeholder) parts.push(f.placeholder);
|
|
946
|
+
}
|
|
947
|
+
for (const n of el.querySelectorAll('[aria-label], [alt], [title]')) {
|
|
948
|
+
for (const a of ['aria-label', 'alt', 'title']) {
|
|
949
|
+
const v = n.getAttribute(a);
|
|
950
|
+
if (v) parts.push(v);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
// A CONTROL'S STATE, the way a screen reader announces it — "Not yet accepted, pressed".
|
|
954
|
+
// A toggle group's value lives in `aria-pressed`, a tab's in `aria-selected`, a nav
|
|
955
|
+
// link's in `aria-current`; none of it is text, so a flow could not say which choice a
|
|
956
|
+
// filter showed (host-app's `corpus-filter`, found by `null-island`). Each is reported
|
|
957
|
+
// as `<name> pressed|selected|current`, beside the name, as `checked` is for a checkbox.
|
|
958
|
+
for (const n of el.querySelectorAll('[aria-pressed="true"], [aria-selected="true"], [aria-current]:not([aria-current="false"])')) {
|
|
959
|
+
const name = (n.getAttribute('aria-label') || n.innerText || n.textContent || '').replace(/\s+/g, ' ').trim();
|
|
960
|
+
const state = n.getAttribute('aria-pressed') === 'true' ? 'pressed' : n.getAttribute('aria-selected') === 'true' ? 'selected' : 'current';
|
|
961
|
+
if (name) parts.push(`${name} ${state}`);
|
|
962
|
+
}
|
|
963
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
964
|
+
};
|
|
965
|
+
/**
|
|
966
|
+
* THE SLOT, PLUS WHAT IT OPENED THROUGH A PORTAL.
|
|
967
|
+
*
|
|
968
|
+
* `expectRender` is slot-scoped, and a dialog/popover/dropdown/select built on a portal
|
|
969
|
+
* renders as a SIBLING of the slot under `document.body` — so everything a member sees
|
|
970
|
+
* after clicking a control in that slot was invisible to the assertion. A flow could open
|
|
971
|
+
* the challenges catalogue, click an info trigger and watch the warning appear on screen,
|
|
972
|
+
* and still have nothing it was allowed to assert. motu already met the mirror of this on
|
|
973
|
+
* the CLICK side and fixed it there (`runInteractions` searches page-wide "because a
|
|
974
|
+
* dialog/popover/dropdown built on a portal renders as a SIBLING of the island"); this is
|
|
975
|
+
* the same fix on the assertion side.
|
|
976
|
+
*
|
|
977
|
+
* ARIA OWNERSHIP, NOT "everything under body". The relation is read from the accessibility
|
|
978
|
+
* tree — `aria-controls` / `aria-owns` / `aria-describedby`, which is precisely how a
|
|
979
|
+
* portal declares "that content over there is mine" and how a screen reader follows it.
|
|
980
|
+
* Radix (and every shadcn component on it) wires these already: a trigger carries
|
|
981
|
+
* `aria-controls` to its content's id, so nothing in the application has to be annotated
|
|
982
|
+
* for motu. Widening to "any portal" instead would have let ANOTHER island's open dialog
|
|
983
|
+
* satisfy this slot's assertion, which is the coupling `expectRender` exists to catch.
|
|
984
|
+
*
|
|
985
|
+
* TRANSITIVE, because overlays nest: the slot owns the dialog, and a trigger INSIDE the
|
|
986
|
+
* dialog owns the popover. One hop would have stopped at the dialog.
|
|
987
|
+
*
|
|
988
|
+
* Three guards keep it honest: content already inside the slot is not re-collected;
|
|
989
|
+
* content that sits inside ANOTHER slot belongs to that slot and is skipped, so this can
|
|
990
|
+
* never let one island's render answer for another's; and invisible targets are ignored
|
|
991
|
+
* (`forceMount` keeps a closed overlay in the DOM, and a closed overlay is not perceived).
|
|
992
|
+
*/
|
|
993
|
+
const perceivableRoots = (slotEl) => {
|
|
994
|
+
const roots = [slotEl];
|
|
995
|
+
const seen = new Set([slotEl]);
|
|
996
|
+
const visible = (el) => el.getClientRects().length > 0 || el.offsetParent !== null;
|
|
997
|
+
// Never collect content that belongs to ANOTHER slot: one island's overlay must not be
|
|
998
|
+
// able to satisfy another island's assertion, which is the coupling this check exists
|
|
999
|
+
// to catch. And never re-collect what the slot already contains.
|
|
1000
|
+
// Also never collect a node that a root ALREADY collected contains: a dialog's own
|
|
1001
|
+
// `aria-describedby` points at its description, which is inside the dialog, and adding
|
|
1002
|
+
// it again duplicated the whole overlay in the reported surface.
|
|
1003
|
+
const claimable = (el) =>
|
|
1004
|
+
el &&
|
|
1005
|
+
!seen.has(el) &&
|
|
1006
|
+
!slotEl.contains(el) &&
|
|
1007
|
+
!el.closest('[data-motu-slot]') &&
|
|
1008
|
+
visible(el) &&
|
|
1009
|
+
!roots.some((r) => r.contains(el));
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* WHO RENDERED THIS NODE — read from React, which is the only place that still knows.
|
|
1013
|
+
*
|
|
1014
|
+
* A portal moves a node in the DOM and NOT in the React tree: the content is still a
|
|
1015
|
+
* child of the component that rendered it, and the fiber's `return` chain says so. So
|
|
1016
|
+
* walking up from a portalled node until a host fiber lands inside the slot answers
|
|
1017
|
+
* "did this slot's island put this on screen?" exactly, with no annotation in the
|
|
1018
|
+
* application and no guessing from position in the DOM.
|
|
1019
|
+
*
|
|
1020
|
+
* This is what ARIA alone could not do. `aria-controls` works for a Radix trigger, but
|
|
1021
|
+
* a dialog opened from component state (`<ResponsiveDialog open={open}>`) has no
|
|
1022
|
+
* trigger and therefore no ARIA relation to follow at all — measured on peps' own
|
|
1023
|
+
* challenges panel, where the slot contained ZERO aria-owning elements while a dialog
|
|
1024
|
+
* it had plainly rendered sat under `body`. Both paths are kept: fibers are exact but
|
|
1025
|
+
* React-specific, ARIA is portable and covers a host motu cannot read fibers from.
|
|
1026
|
+
*/
|
|
1027
|
+
// `Object.keys`, not `for...in`: React attaches the fiber as an OWN property, and
|
|
1028
|
+
// `for...in` would walk the whole DOM prototype chain to find it.
|
|
1029
|
+
const FIBER = (node) => {
|
|
1030
|
+
for (const k of Object.keys(node)) {
|
|
1031
|
+
if (k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')) return node[k];
|
|
1032
|
+
}
|
|
1033
|
+
return null;
|
|
1034
|
+
};
|
|
1035
|
+
const renderedInsideSlot = (node) => {
|
|
1036
|
+
let f = FIBER(node);
|
|
1037
|
+
// Bounded: a corrupted or cyclic chain must not turn an assertion into a hang.
|
|
1038
|
+
for (let hops = 0; f && hops < 4000; hops++, f = f.return) {
|
|
1039
|
+
const sn = f.stateNode;
|
|
1040
|
+
if (sn && sn.nodeType === 1 && slotEl.contains(sn)) return true;
|
|
1041
|
+
}
|
|
1042
|
+
return false;
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
// A portal's DOM home is a container, normally `document.body`. Scanning its children
|
|
1046
|
+
// (rather than the whole document) keeps this cheap and is where every portal lands.
|
|
1047
|
+
for (const node of Array.from(document.body.children)) {
|
|
1048
|
+
if (!claimable(node)) continue;
|
|
1049
|
+
if (!renderedInsideSlot(node)) continue;
|
|
1050
|
+
seen.add(node);
|
|
1051
|
+
roots.push(node);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// ARIA OWNERSHIP, as the second path — `aria-controls` / `aria-owns` /
|
|
1055
|
+
// `aria-describedby` is how a portal declares that content is its own and how a screen
|
|
1056
|
+
// reader follows it. Transitive, because overlays nest: the slot owns the dialog, and a
|
|
1057
|
+
// trigger INSIDE the dialog owns the popover.
|
|
1058
|
+
for (let i = 0; i < roots.length && roots.length < 64; i++) {
|
|
1059
|
+
const from = roots[i];
|
|
1060
|
+
const holders = [from, ...from.querySelectorAll('[aria-controls], [aria-owns], [aria-describedby]')];
|
|
1061
|
+
for (const holder of holders) {
|
|
1062
|
+
for (const attr of ['aria-controls', 'aria-owns', 'aria-describedby']) {
|
|
1063
|
+
const raw = holder.getAttribute?.(attr);
|
|
1064
|
+
if (!raw) continue;
|
|
1065
|
+
for (const id of raw.split(/\s+/)) {
|
|
1066
|
+
if (!id) continue;
|
|
1067
|
+
let target = null;
|
|
1068
|
+
try {
|
|
1069
|
+
target = document.getElementById(id);
|
|
1070
|
+
} catch {
|
|
1071
|
+
target = null;
|
|
1072
|
+
}
|
|
1073
|
+
if (!claimable(target)) continue;
|
|
1074
|
+
seen.add(target);
|
|
1075
|
+
roots.push(target);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return roots;
|
|
1081
|
+
};
|
|
1082
|
+
const perceivableWithPortals = (slotEl) =>
|
|
1083
|
+
perceivableRoots(slotEl)
|
|
1084
|
+
.map(perceivable)
|
|
1085
|
+
.join(' ')
|
|
1086
|
+
.replace(/\s+/g, ' ')
|
|
1087
|
+
.trim();
|
|
1088
|
+
const renderText = (slot) => {
|
|
1089
|
+
const el = document.querySelector(`[data-motu-slot="${slot}"]`);
|
|
1090
|
+
return el ? perceivableWithPortals(el) : null;
|
|
1091
|
+
};
|
|
1092
|
+
/**
|
|
1093
|
+
* CASE-INSENSITIVE, because `innerText` reports what `text-transform` produced.
|
|
1094
|
+
*
|
|
1095
|
+
* A flow asserts that DATA reached a slot. Whether the slot's stylesheet happens to
|
|
1096
|
+
* uppercase it is a presentational choice that can change without the data flow changing
|
|
1097
|
+
* at all — and an assertion that breaks when a caption's letter-case is restyled is
|
|
1098
|
+
* asserting the wrong thing. Found the day the focused lagoon started applying the
|
|
1099
|
+
* project's stylesheet: every flow asserting "changed" or "week-actions" failed against
|
|
1100
|
+
* "CHANGED" and "WEEK-ACTIONS", having passed for as long as the page rendered naked.
|
|
1101
|
+
*
|
|
1102
|
+
* It does not weaken the check. A wrong value and another island's text both still fail;
|
|
1103
|
+
* only the casing stops mattering.
|
|
1104
|
+
*/
|
|
1105
|
+
const contains = (haystack, needle) => haystack.toLowerCase().includes(String(needle).toLowerCase());
|
|
1106
|
+
await settled(() =>
|
|
1107
|
+
Object.entries(st.expectRender ?? {}).every(([slot, want]) => {
|
|
1108
|
+
const text = renderText(slot);
|
|
1109
|
+
if (text === null) return false;
|
|
1110
|
+
const wants = typeof want === 'string' ? { text: want } : want;
|
|
1111
|
+
return (
|
|
1112
|
+
(wants.text == null || contains(text, wants.text)) &&
|
|
1113
|
+
(wants.notText == null || !contains(text, wants.notText))
|
|
1114
|
+
);
|
|
1115
|
+
}),
|
|
1116
|
+
);
|
|
1117
|
+
const renderMismatches = [];
|
|
1118
|
+
for (const [slot, want] of Object.entries(st.expectRender ?? {})) {
|
|
1119
|
+
const el = document.querySelector(`[data-motu-slot="${slot}"]`);
|
|
1120
|
+
if (!el) {
|
|
1121
|
+
renderMismatches.push({ key: `render:${slot}`, expected: want, actual: 'nothing mounted under that slot' });
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
const text = perceivableWithPortals(el);
|
|
1125
|
+
const wants = typeof want === 'string' ? { text: want } : want;
|
|
1126
|
+
if (wants.text != null && !contains(text, wants.text)) {
|
|
1127
|
+
renderMismatches.push({ key: `render:${slot}`, expected: `text containing ${JSON.stringify(wants.text)}`, actual: text });
|
|
1128
|
+
}
|
|
1129
|
+
if (wants.notText != null && contains(text, wants.notText)) {
|
|
1130
|
+
renderMismatches.push({ key: `render:${slot}`, expected: `text NOT containing ${JSON.stringify(wants.notText)}`, actual: text });
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
if (!Object.keys(st.expect ?? {}).length && !Object.keys(st.expectRender ?? {}).length && st.expectCalls === undefined) {
|
|
1134
|
+
return { error: 'a step must assert something — `expect` a region key, `expectRender` an island, or `expectCalls` the backend' };
|
|
1135
|
+
}
|
|
1136
|
+
// Did the region survive its own flow? A step that leaves nothing on screen has broken
|
|
1137
|
+
// something, even when every key it asserted holds the right value.
|
|
1138
|
+
const alive = document.querySelectorAll('[data-motu-slot]').length > 0;
|
|
1139
|
+
// FOR `motu diff`: what every slot renders, read the way `expectRender` reads it — through
|
|
1140
|
+
// portals — so a dialog an island opened is part of its slot's surface on both sides.
|
|
1141
|
+
let surface = null;
|
|
1142
|
+
if (st.__trace) {
|
|
1143
|
+
surface = {};
|
|
1144
|
+
for (const el of document.querySelectorAll('[data-motu-slot]')) {
|
|
1145
|
+
const slot = el.getAttribute('data-motu-slot');
|
|
1146
|
+
const text = perceivableWithPortals(el);
|
|
1147
|
+
surface[slot] = surface[slot] ? `${surface[slot]} | ${text}` : text;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
// How long the step's assertions took to hold — what a null-island replay of it is allowed.
|
|
1151
|
+
const settleMs = mismatches.length || renderMismatches.length ? null : Date.now() - settleStarted;
|
|
1152
|
+
return { mismatches: [...mismatches, ...renderMismatches], alive, surface, settleMs };
|
|
1153
|
+
},
|
|
1154
|
+
{ seed: scenario.seed ?? {}, step },
|
|
1155
|
+
);
|
|
1156
|
+
// A beat for the calls a step starts to land, then read them against the mark.
|
|
1157
|
+
await sleep(150);
|
|
1158
|
+
page.off('pageerror', onCrash);
|
|
1159
|
+
const landed = await callsSince(mark);
|
|
1160
|
+
// THE SMELL, read per step — the ledger is reset at the start of every step (above), so read
|
|
1161
|
+
// once at the end of the lane it only ever held the LAST step's suspicions, and which step was
|
|
1162
|
+
// last was whichever mutant happened to run last. Real flows only: a mutant's crude emit
|
|
1163
|
+
// provoking a host write says nothing about how the application is written.
|
|
1164
|
+
if (!isMutantScenario(scenario) && !step.__mutant) {
|
|
1165
|
+
for (const sus of await page.evaluate(() => window.__motuSuspects?.list?.() ?? []).catch(() => [])) {
|
|
1166
|
+
const key = JSON.stringify(sus);
|
|
1167
|
+
if (!suspectSeen.has(key)) {
|
|
1168
|
+
suspectSeen.add(key);
|
|
1169
|
+
suspects.push(sus);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
mark = landed.reduce((m, c) => Math.max(m, c.seq), mark);
|
|
1174
|
+
// WHAT THE STEP WAS ALLOWED TO ASK FOR (`expectCalls`), held against what it asked for. Read
|
|
1175
|
+
// after the beat above, with the same `mark` `flow-calls` uses, so step 1 includes the mount.
|
|
1176
|
+
const callMismatches = result.error ? [] : expectCallsMismatches(step.expectCalls, landed);
|
|
1177
|
+
// WHAT THIS STEP LEFT BEHIND, for `motu diff`: every key the region holds, and what each slot
|
|
1178
|
+
// renders (the same perceivable surface `expectRender` reads). Values pass through JSON so
|
|
1179
|
+
// a function or a cycle cannot break the comparison.
|
|
1180
|
+
const traced = !trace
|
|
1181
|
+
? null
|
|
1182
|
+
: await page
|
|
1183
|
+
.evaluate(() => {
|
|
1184
|
+
const safe = (v) => {
|
|
1185
|
+
try {
|
|
1186
|
+
return JSON.parse(JSON.stringify(v ?? null));
|
|
1187
|
+
} catch {
|
|
1188
|
+
return String(v);
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
const keys = safe(window.__motuLagoon?.snapshot?.() ?? {});
|
|
1192
|
+
const surface = null;
|
|
1193
|
+
return { keys, surface };
|
|
1194
|
+
})
|
|
1195
|
+
.catch(() => null);
|
|
1196
|
+
if (onStep) await onStep(page, { scenario: scenario.name ?? 'flow', step: index + 1 }).catch(() => {});
|
|
1197
|
+
out.push({
|
|
1198
|
+
...(traced
|
|
1199
|
+
? { trace: { keys: traced.keys, surface: result.surface ?? {}, calls: landed.map((c) => `${c.method} ${c.label || c.url} ${c.status}`) } }
|
|
1200
|
+
: {}),
|
|
1201
|
+
scenario: scenario.name ?? 'flow',
|
|
1202
|
+
step: index + 1,
|
|
1203
|
+
ok: !result.error && result.mismatches.length === 0 && callMismatches.length === 0 && result.alive !== false,
|
|
1204
|
+
error: result.error ?? (result.alive === false ? 'the region rendered nothing after this step' : null),
|
|
1205
|
+
mismatches: [...(result.mismatches ?? []), ...callMismatches],
|
|
1206
|
+
crashed: crashes[0] ?? null,
|
|
1207
|
+
calls: landed.length,
|
|
1208
|
+
// A FAILURE NOTHING DECLARED. A declared one (a fixture's `status: 500`) is the flow's own
|
|
1209
|
+
// state and never lands here.
|
|
1210
|
+
undeclared: landed.filter((c) => !c.ok && !c.declared),
|
|
1211
|
+
});
|
|
1212
|
+
if (!scenario.__nullIsland && !scenario.__killGroup && !step.__mutant && result.settleMs != null) {
|
|
1213
|
+
settledIn.set(`${scenario.name ?? 'flow'}#${index}`, result.settleMs);
|
|
1214
|
+
}
|
|
1215
|
+
// A failing step answers the group; the steps after it cannot turn "it failed" into "it held".
|
|
1216
|
+
if (scenario.__killGroup && !out[out.length - 1].ok) {
|
|
1217
|
+
killed.add(scenario.__killGroup);
|
|
1218
|
+
break;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
timings.push({ scenario: scenario.name ?? 'flow', ms: Date.now() - scenarioStarted, steps: (scenario.steps ?? []).length });
|
|
1222
|
+
}
|
|
1223
|
+
const scenariosMs = Date.now() - laneStarted;
|
|
1224
|
+
// One beat before reading: a host effect answering an island lands a tick later, and the last
|
|
1225
|
+
// step's assertion returns before it does.
|
|
1226
|
+
await sleep(300);
|
|
1227
|
+
// No null island outlives the run — the page is pooled and the next check re-aims it.
|
|
1228
|
+
await page.evaluate(() => window.__motuLagoon?.nullIsland?.(null)).catch(() => {});
|
|
1229
|
+
// BACK TO THE DEFAULT WORLD before anything else reads this page: it is pooled, and the next check
|
|
1230
|
+
// re-aims it rather than reloading — a world left on would be inherited by a check that never asked.
|
|
1231
|
+
if (activeWorld !== 'default') await switchWorld('default');
|
|
1232
|
+
// `suspects` — the smell: an island emitted, and the HOST answered by writing a key some island
|
|
1233
|
+
// reads. Declared ownership cannot see this — the declaration is consistent, it is just not honest —
|
|
1234
|
+
// so it is reported as a suspicion, never as a violation. Collected per real step, in the loop above.
|
|
1235
|
+
// What the CHANNELS actually produced while the flows ran — the inbound seam's provenance. The
|
|
1236
|
+
// declared `sources` say who feeds each host-fed key; this says who really did.
|
|
1237
|
+
// WHERE THE INPUT CAME FROM, read after the flows have run.
|
|
1238
|
+
const provenance = await page
|
|
1239
|
+
.evaluate(() => (typeof window.__motuLagoon?.hostCalls === 'function' ? window.__motuLagoon.hostCalls() : []))
|
|
1240
|
+
.catch(() => []);
|
|
1241
|
+
// EVERY DOOR, not just the traced one. A region whose islands talk through the contract, or whose
|
|
1242
|
+
// services read through the wire, recorded nothing in `hostCalls` and read as a region that
|
|
1243
|
+
// fetched nothing — accurate about that one ledger and wrong about the question.
|
|
1244
|
+
const outbound = await page
|
|
1245
|
+
.evaluate(() => (typeof window.__motuLagoon?.outbound === 'function' ? window.__motuLagoon.outbound() : []))
|
|
1246
|
+
.catch(() => []);
|
|
1247
|
+
const channels = await page.evaluate(() =>
|
|
1248
|
+
window.__motuLagoon && typeof window.__motuLagoon.channels === 'function' ? window.__motuLagoon.channels() : null,
|
|
1249
|
+
);
|
|
1250
|
+
// Which keys the region ended up HOLDING. A declared source that was seeded instead of installed
|
|
1251
|
+
// leaves its keys full, and one that nothing feeds leaves them empty — the same "no channel wrote
|
|
1252
|
+
// this" reads as either, and only one of the two is a real finding.
|
|
1253
|
+
const held = await page
|
|
1254
|
+
.evaluate(() => (typeof window.__motuLagoon?.held === 'function' ? window.__motuLagoon.held() : []))
|
|
1255
|
+
.catch(() => []);
|
|
1256
|
+
// THE CHART, LAST — after every ledger above has been read, because it replays the flows again
|
|
1257
|
+
// and would add its own calls to them. Same page, no navigation: `chartRegion` is the in-page
|
|
1258
|
+
// function the dock uses, so the check and the dock place every what-if by the same rule.
|
|
1259
|
+
// The focused entry carries no evidence of its own, so the caller hands the chart the region's
|
|
1260
|
+
// real flows (not the mutants) and each island's scenarios.
|
|
1261
|
+
const chart = !chartWith
|
|
1262
|
+
? null
|
|
1263
|
+
: await page
|
|
1264
|
+
.evaluate(
|
|
1265
|
+
({ rid, evidence }) => (typeof window.__motuLagoonChart === 'function' ? window.__motuLagoonChart(rid, evidence) : null),
|
|
1266
|
+
{ rid: id, evidence: { flows: { [id]: chartWith.flows }, scenarios: chartWith.scenarios } },
|
|
1267
|
+
)
|
|
1268
|
+
.catch((err) => ({ error: String(err?.message ?? err) }));
|
|
1269
|
+
return { flows: out, suspects, diagnostics, channels, provenance, outbound, held, renderOutputs, chart, timings, scenariosMs };
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Probe every wire a region DECLARES: fire each `writes` event and see whether its key moved.
|
|
1275
|
+
*
|
|
1276
|
+
* This is the contract test the archipelago can write for itself. Nothing is hand-scripted and nothing
|
|
1277
|
+
* touches the DOM — the harness can only emit what an island declares as an output, so the probe list
|
|
1278
|
+
* IS the declaration. What it catches is the failure types cannot: a wire that resolves, compiles and
|
|
1279
|
+
* never carries anything ("declared but never fired"), which is what a broken change looks like from
|
|
1280
|
+
* the outside.
|
|
1281
|
+
*
|
|
1282
|
+
* Returns [{ slot, event, key, moved }].
|
|
1283
|
+
*/
|
|
1284
|
+
export async function probeWiring({ id, port = 5199, islands = [] }) {
|
|
1285
|
+
const { chromium } = await import('playwright');
|
|
1286
|
+
// Diagnostics belong to the page that WRITES. The console check runs on a page that only mounts the
|
|
1287
|
+
// region, so anything the store says about a bad write — the ownership guard above all — was raised
|
|
1288
|
+
// where nobody was listening. Collect them here and hand them back with the results.
|
|
1289
|
+
const diagnostics = [];
|
|
1290
|
+
return onLagoonPage({ target: `archipelago:${id}`, port, viewport: { width: 1280, height: 900 }, diagnostics, view: 'mountpoints' }, async (page, server) => {
|
|
1291
|
+
await page.waitForFunction(() => !!window.__motuLagoon, null, { timeout: 15000 }).catch(() => {});
|
|
1292
|
+
await sleep(400);
|
|
1293
|
+
|
|
1294
|
+
const results = [];
|
|
1295
|
+
for (const island of islands) {
|
|
1296
|
+
for (const [event, target] of Object.entries(island.writes ?? {})) {
|
|
1297
|
+
const keys = typeof target === 'string' ? [target] : Object.values(target);
|
|
1298
|
+
const fields = typeof target === 'string' ? null : Object.entries(target);
|
|
1299
|
+
const probe = await page.evaluate(
|
|
1300
|
+
({ slot, event: ev, keys: ks, fields: fs }) => {
|
|
1301
|
+
const lagoon = window.__motuLagoon;
|
|
1302
|
+
if (!lagoon || typeof lagoon.emit !== 'function' || typeof lagoon.read !== 'function') {
|
|
1303
|
+
return { kind: 'no-seam' };
|
|
1304
|
+
}
|
|
1305
|
+
// A sentinel that keeps the SHAPE of what is there. The first version wrote a string over
|
|
1306
|
+
// the week's mission array, the island that renders it threw on `.filter`, React unmounted
|
|
1307
|
+
// the tree, and every island probed after that looked unmounted — the probe destroying the
|
|
1308
|
+
// page it was measuring. Different value, same type: enough to see the store move, safe
|
|
1309
|
+
// enough to leave the region standing.
|
|
1310
|
+
const nudge = (v) => {
|
|
1311
|
+
if (typeof v === 'number') return v + 1;
|
|
1312
|
+
if (typeof v === 'boolean') return !v;
|
|
1313
|
+
if (typeof v === 'string') return `${v}\u00b7probe`;
|
|
1314
|
+
if (Array.isArray(v)) return v.length ? v.slice(0, v.length - 1) : ['motu-probe'];
|
|
1315
|
+
if (v && typeof v === 'object') return { ...v };
|
|
1316
|
+
return 'motu-probe';
|
|
1317
|
+
};
|
|
1318
|
+
const read = () => ks.map((k) => lagoon.read(k));
|
|
1319
|
+
const before = read();
|
|
1320
|
+
const detail = fs
|
|
1321
|
+
? Object.fromEntries(fs.map(([field, key]) => [field, nudge(lagoon.read(key))]))
|
|
1322
|
+
: nudge(lagoon.read(ks[0]));
|
|
1323
|
+
if (!lagoon.emit(slot, ev, detail)) return { kind: 'not-mounted' };
|
|
1324
|
+
const after = read();
|
|
1325
|
+
const rows = ks.map((k, i) => ({ key: k, moved: before[i] !== after[i] }));
|
|
1326
|
+
// Put the region back the way it was, so a later probe measures the page and not the wake
|
|
1327
|
+
// of this one.
|
|
1328
|
+
// Put it back as a SEED, not a host write: a rollback must not look like the host
|
|
1329
|
+
// reaching into a key an island owns (that is what the ownership guard is for).
|
|
1330
|
+
ks.forEach((k, i) => (lagoon.seed ?? lagoon.provide)(k, before[i]));
|
|
1331
|
+
return { kind: 'measured', rows };
|
|
1332
|
+
},
|
|
1333
|
+
{ slot: island.slot, event, keys, fields },
|
|
1334
|
+
);
|
|
1335
|
+
if (probe.kind === 'measured') {
|
|
1336
|
+
for (const row of probe.rows) results.push({ slot: island.slot, event, ...row });
|
|
1337
|
+
} else {
|
|
1338
|
+
results.push({ slot: island.slot, event, key: keys.join(', '), moved: null, reason: probe.kind });
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
return { results, diagnostics };
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Run axe against one island, in the lagoon, per scenario.
|
|
1348
|
+
*
|
|
1349
|
+
* Cheap because the browser is already open, and it catches the class of mistake that types cannot see
|
|
1350
|
+
* at all: a clickable div, a control with no accessible name, text that fails contrast. Scoped to the
|
|
1351
|
+
* island's own subtree — the lagoon's chrome is motu's, and an island should not fail for it.
|
|
1352
|
+
*/
|
|
1353
|
+
export async function axeLagoon({ tag, port = 5199, scenarios = [] }) {
|
|
1354
|
+
const { chromium } = await import('playwright');
|
|
1355
|
+
const { createRequire } = await import('node:module');
|
|
1356
|
+
const require = createRequire(import.meta.url);
|
|
1357
|
+
const axeSource = readFileSync(require.resolve('axe-core'), 'utf8');
|
|
1358
|
+
return onLagoonPage({ target: `island:${tag}`, port, viewport: { width: 1280, height: 900 } }, async (page, server) => {
|
|
1359
|
+
const deadline = Date.now() + paintTimeout;
|
|
1360
|
+
while (Date.now() < deadline) {
|
|
1361
|
+
const ready = await page
|
|
1362
|
+
.evaluate((t) => {
|
|
1363
|
+
const el = window.__motuFindIsland(t);
|
|
1364
|
+
return !!el && window.__motuRendered(el).trim().length > 0;
|
|
1365
|
+
}, tag)
|
|
1366
|
+
.catch(() => false);
|
|
1367
|
+
if (ready) break;
|
|
1368
|
+
await sleep(250);
|
|
1369
|
+
}
|
|
1370
|
+
// FREEZE MOTION BEFORE MEASURING, the same way the snapshot lane does and for the same reason.
|
|
1371
|
+
//
|
|
1372
|
+
// axe reads computed colour, and an element mid-fade has an opacity the design never ships. The
|
|
1373
|
+
// kit's lists swim in on a stagger, so re-seeding a scenario restarts the entrance and axe caught
|
|
1374
|
+
// whichever row was still transparent — reporting a "serious" contrast failure on a colour that
|
|
1375
|
+
// measures 5.3:1 at rest, and naming a different nth-child on each run. A check whose findings
|
|
1376
|
+
// move between runs is a check people learn to ignore.
|
|
1377
|
+
await page.addStyleTag({
|
|
1378
|
+
content: '*,*::before,*::after{animation:none!important;transition:none!important}',
|
|
1379
|
+
});
|
|
1380
|
+
await page.addScriptTag({ content: axeSource });
|
|
1381
|
+
|
|
1382
|
+
const findings = [];
|
|
1383
|
+
const list = scenarios.length ? scenarios : [{ name: 'default', seed: {} }];
|
|
1384
|
+
const owned = scenarioKeys(list);
|
|
1385
|
+
for (const scenario of list) {
|
|
1386
|
+
await provideScenario(page, scenario.seed, { afterMount: scenario.afterMount, tag, owned });
|
|
1387
|
+
const violations = await page.evaluate(async (t) => {
|
|
1388
|
+
const el = window.__motuFindIsland(t);
|
|
1389
|
+
if (!el) return [];
|
|
1390
|
+
// A `display: contents` wrapper is not a valid axe context; its painted content is.
|
|
1391
|
+
const target = el.getBoundingClientRect().height ? el : el.firstElementChild;
|
|
1392
|
+
// What the island portaled (a dialog) is its UI too, and the part most likely to lack a name.
|
|
1393
|
+
const include = [target, ...window.__motuPortals(el)].filter(Boolean);
|
|
1394
|
+
if (!include.length) return [];
|
|
1395
|
+
const res = await window.axe.run({ include }, { resultTypes: ['violations'] });
|
|
1396
|
+
return res.violations.map((v) => ({
|
|
1397
|
+
id: v.id,
|
|
1398
|
+
impact: v.impact,
|
|
1399
|
+
help: v.help,
|
|
1400
|
+
nodes: v.nodes.length,
|
|
1401
|
+
// The first offender, so a finding says WHERE — a count alone sends you hunting.
|
|
1402
|
+
where: v.nodes[0]?.target?.join(' ') ?? '',
|
|
1403
|
+
html: (v.nodes[0]?.html ?? '').slice(0, 120),
|
|
1404
|
+
}));
|
|
1405
|
+
}, tag);
|
|
1406
|
+
for (const v of violations) findings.push({ scenario: scenario.name ?? 'default', ...v });
|
|
1407
|
+
}
|
|
1408
|
+
return findings;
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Capture one island's rendered pixels, per scenario × viewport, in one browser.
|
|
1414
|
+
*
|
|
1415
|
+
* Same seams as everything else here: scenarios come from the island's declared evidence and are
|
|
1416
|
+
* driven through `provide()`, viewports from `lagoon.config.json`. Nothing accepts a selector or a
|
|
1417
|
+
* script — a capture can only put the island in a state the island itself declares, which is what
|
|
1418
|
+
* keeps this a harness rather than a second, untyped test suite.
|
|
1419
|
+
*
|
|
1420
|
+
* Returns [{ scenario, viewport, width, png }].
|
|
1421
|
+
*/
|
|
1422
|
+
export async function captureLagoon({ tag, port = 5199, scenarios = [], viewports = [] }) {
|
|
1423
|
+
const { chromium } = await import('playwright');
|
|
1424
|
+
return onLagoonPage({ target: `island:${tag}`, port, viewport: { width: viewports[0]?.width ?? 1280, height: 900 } }, async (page, server) => {
|
|
1425
|
+
|
|
1426
|
+
const deadline = Date.now() + paintTimeout;
|
|
1427
|
+
while (Date.now() < deadline) {
|
|
1428
|
+
const ready = await page
|
|
1429
|
+
.evaluate((t) => {
|
|
1430
|
+
const el = window.__motuFindIsland(t);
|
|
1431
|
+
return !!el && window.__motuRendered(el).trim().length > 0;
|
|
1432
|
+
}, tag)
|
|
1433
|
+
.catch(() => false);
|
|
1434
|
+
if (ready) break;
|
|
1435
|
+
await sleep(250);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// Motion makes a baseline flap. Freeze it rather than sampling and hoping.
|
|
1439
|
+
await page.addStyleTag({
|
|
1440
|
+
content: '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}',
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
const shots = [];
|
|
1444
|
+
const list = scenarios.length ? scenarios : [{ name: 'default', seed: {} }];
|
|
1445
|
+
const owned = scenarioKeys(list);
|
|
1446
|
+
for (const scenario of list) {
|
|
1447
|
+
// NO REMOUNT: this lane measures pixels, and a fresh mount replays every entrance animation the
|
|
1448
|
+
// shot would then capture mid-flight. The reset inside still runs, so each picture is of the
|
|
1449
|
+
// scenario it is named after.
|
|
1450
|
+
await provideScenario(page, scenario.seed, { afterMount: scenario.afterMount, remount: false, owned });
|
|
1451
|
+
for (const vp of viewports) {
|
|
1452
|
+
await page.setViewportSize({ width: vp.width, height: 900 });
|
|
1453
|
+
await sleep(250);
|
|
1454
|
+
const handle = await page.evaluateHandle((t) => window.__motuFindIsland(t), tag);
|
|
1455
|
+
const el = handle.asElement();
|
|
1456
|
+
// An island that PORTALED something (a dialog over the page) is pictured as the viewport it
|
|
1457
|
+
// covers — an element shot of its own box would miss the very thing it shows. The lagoon's
|
|
1458
|
+
// chrome is hidden for that shot, so the picture is the island's and not the furniture's.
|
|
1459
|
+
const portaled = el ? await el.evaluate((n) => window.__motuPortals(n).length) : 0;
|
|
1460
|
+
if (portaled) {
|
|
1461
|
+
await page.addStyleTag({ content: '[data-motu-chrome]{visibility:hidden!important}' });
|
|
1462
|
+
shots.push({
|
|
1463
|
+
scenario: scenario.name ?? 'default',
|
|
1464
|
+
viewport: vp.name,
|
|
1465
|
+
width: vp.width,
|
|
1466
|
+
png: await page.screenshot({ animations: 'disabled' }),
|
|
1467
|
+
portals: portaled,
|
|
1468
|
+
});
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1471
|
+
// A `display: contents` wrapper cannot be screenshotted; its first painted child can.
|
|
1472
|
+
const target = (await el?.evaluateHandle((n) => (n.getBoundingClientRect().height ? n : n.firstElementChild)))?.asElement();
|
|
1473
|
+
const png = target
|
|
1474
|
+
? await target.screenshot({ animations: 'disabled' }).catch(() => null)
|
|
1475
|
+
: await page.screenshot({ animations: 'disabled' });
|
|
1476
|
+
if (png) shots.push({ scenario: scenario.name ?? 'default', viewport: vp.name, width: vp.width, png });
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return shots;
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* Does the COMPOSED page fit, and is it usable — at each declared viewport.
|
|
1485
|
+
*
|
|
1486
|
+
* `responsive` and `a11y` are ISLAND checks: each island is mounted alone and asked whether it fits.
|
|
1487
|
+
* Every island passes that on its own and the page still overflows, because the arrangement is where
|
|
1488
|
+
* a layout breaks — a three-column grid with fixed rails fits nothing at 390px, and no island in it
|
|
1489
|
+
* is at fault. The same blind spot as the frame that renders one island's data in another's slot.
|
|
1490
|
+
*
|
|
1491
|
+
* Measured on the first project to have a region of its own: every island green at every viewport,
|
|
1492
|
+
* and the composed page scrolling sideways on a phone. A human opened the lagoon and saw it in a
|
|
1493
|
+
* second; nothing in the suite could.
|
|
1494
|
+
*
|
|
1495
|
+
* Overflow of the DOCUMENT, not of the region — the same signal the island check uses, for the same
|
|
1496
|
+
* reason: a card that scrolls its own table on purpose is fine, a page the reader has to pan is not.
|
|
1497
|
+
*/
|
|
1498
|
+
export async function auditRegionLagoon({ id, port = 5199, states = [], viewports = [], a11y = true }) {
|
|
1499
|
+
const { chromium } = await import('playwright');
|
|
1500
|
+
const { createRequire } = await import('node:module');
|
|
1501
|
+
const require = createRequire(import.meta.url);
|
|
1502
|
+
const axeSource = a11y ? readFileSync(require.resolve('axe-core'), 'utf8') : null;
|
|
1503
|
+
|
|
1504
|
+
return onLagoonPage(
|
|
1505
|
+
{ target: `archipelago:${id}`, port, viewport: { width: viewports[0]?.width ?? 1280, height: 900 }, view: 'region' },
|
|
1506
|
+
async (page) => {
|
|
1507
|
+
await page.waitForFunction(() => !!window.__motuLagoon, null, { timeout: 15000 }).catch(() => {});
|
|
1508
|
+
await page
|
|
1509
|
+
.waitForFunction(() => document.querySelectorAll('[data-motu-slot]').length > 0, null, { timeout: 15000 })
|
|
1510
|
+
.catch(() => {});
|
|
1511
|
+
|
|
1512
|
+
const out = [];
|
|
1513
|
+
const list = states.length ? states : [{ name: 'default', seed: {} }];
|
|
1514
|
+
for (const state of list) {
|
|
1515
|
+
await page.evaluate(() => window.__motuLagoon?.remount?.()).catch(() => {});
|
|
1516
|
+
await page
|
|
1517
|
+
.waitForFunction(() => document.querySelectorAll('[data-motu-slot]').length > 0, null, { timeout: 10000 })
|
|
1518
|
+
.catch(() => {});
|
|
1519
|
+
await page.evaluate((seed) => {
|
|
1520
|
+
const l = window.__motuLagoon;
|
|
1521
|
+
if (l) for (const [k, v] of Object.entries(seed || {})) l.seed(k, v);
|
|
1522
|
+
}, state.seed ?? {});
|
|
1523
|
+
|
|
1524
|
+
for (const vp of viewports) {
|
|
1525
|
+
await page.setViewportSize({ width: vp.width, height: 900 });
|
|
1526
|
+
// Two beats: one for the resize to land, one for anything that re-measures on it.
|
|
1527
|
+
await sleep(220);
|
|
1528
|
+
const m = await page.evaluate(() => {
|
|
1529
|
+
const doc = document.documentElement;
|
|
1530
|
+
return {
|
|
1531
|
+
overflow: doc.scrollWidth - doc.clientWidth,
|
|
1532
|
+
client: doc.clientWidth,
|
|
1533
|
+
slots: document.querySelectorAll('[data-motu-slot]').length,
|
|
1534
|
+
// What the region view actually put on the page, for when the measurement disagrees
|
|
1535
|
+
// with a human looking at the same URL.
|
|
1536
|
+
// The ARRANGEMENT's own element, by class — `body.firstElementChild` is a mount
|
|
1537
|
+
// container in both views and tells you nothing about which one you are in.
|
|
1538
|
+
frame: !!document.querySelector('.rv, [class*="Frame"], main, aside'),
|
|
1539
|
+
widest: Math.max(0, ...[...document.body.querySelectorAll('*')].map((e) => e.scrollWidth)),
|
|
1540
|
+
styled: !!document.getElementById('motu-island-styles'),
|
|
1541
|
+
cols: getComputedStyle(document.querySelector('.rv-body') ?? document.body).gridTemplateColumns,
|
|
1542
|
+
};
|
|
1543
|
+
});
|
|
1544
|
+
out.push({ scenario: state.name ?? 'default', viewport: vp.name, width: vp.width, ...m });
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
let violations = [];
|
|
1549
|
+
if (axeSource) {
|
|
1550
|
+
// One axe pass, at the widest declared viewport: the composed page's structure — landmarks,
|
|
1551
|
+
// heading order, contrast — does not change with width, and running it per viewport triples
|
|
1552
|
+
// the cost of the slowest check for the same answer.
|
|
1553
|
+
await page.setViewportSize({ width: viewports[viewports.length - 1]?.width ?? 1280, height: 900 });
|
|
1554
|
+
// Same freeze as the island lane: axe reads computed colour, and a region re-lays-out on a
|
|
1555
|
+
// viewport change, so its lists start their entrance again right before this runs.
|
|
1556
|
+
await page.addStyleTag({
|
|
1557
|
+
content: '*,*::before,*::after{animation:none!important;transition:none!important}',
|
|
1558
|
+
});
|
|
1559
|
+
await sleep(200);
|
|
1560
|
+
await page.addScriptTag({ content: axeSource });
|
|
1561
|
+
violations = await page.evaluate(async () => {
|
|
1562
|
+
const res = await window.axe.run(document.body, { resultTypes: ['violations'] });
|
|
1563
|
+
return res.violations.map((v) => ({
|
|
1564
|
+
id: v.id,
|
|
1565
|
+
impact: v.impact,
|
|
1566
|
+
help: v.help,
|
|
1567
|
+
nodes: v.nodes.length,
|
|
1568
|
+
target: v.nodes[0]?.target?.join(' ') ?? '',
|
|
1569
|
+
}));
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
return { measurements: out, violations };
|
|
1573
|
+
},
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Picture the REGION — the composed page, not its parts.
|
|
1579
|
+
*
|
|
1580
|
+
* WHAT THIS CATCHES THAT AN ISLAND SHOT CANNOT: arrangement. The frame is not declared and therefore
|
|
1581
|
+
* not checked, and this project has already shipped the consequence — two agents each extended the
|
|
1582
|
+
* frame's slot lookup, the naive merge made one agent's widget render as the other's, and
|
|
1583
|
+
* `archipelago verify --runtime` was BYTE-IDENTICAL between the correct and broken resolutions. Every
|
|
1584
|
+
* island rendered correctly in isolation. Only the composition was wrong.
|
|
1585
|
+
*
|
|
1586
|
+
* THE REGION VIEW, deliberately, not the mountpoints view: the question is what a person sees, and the
|
|
1587
|
+
* mountpoints view is a diagnostic layout that exists for probing wires.
|
|
1588
|
+
*
|
|
1589
|
+
* STATES COME FROM THE FLOWS. A region has no `scenarios` of its own, and one picture of one state
|
|
1590
|
+
* would miss the case where the screen changes SHAPE — an actions page with an empty week is a
|
|
1591
|
+
* different page, not a different value. Each flow's first step establishes a state someone thought
|
|
1592
|
+
* worth driving, so that is the state list. A region whose flows do not seed a shape worth seeing has
|
|
1593
|
+
* an evidence gap, and the fix is a flow, which is worth having anyway.
|
|
1594
|
+
*/
|
|
1595
|
+
export async function captureRegionLagoon({ id, port = 5199, states = [], viewports = [] }) {
|
|
1596
|
+
const { chromium } = await import('playwright');
|
|
1597
|
+
return onLagoonPage(
|
|
1598
|
+
{ target: `archipelago:${id}`, port, viewport: { width: viewports[0]?.width ?? 1280, height: 900 }, view: 'region' },
|
|
1599
|
+
async (page) => {
|
|
1600
|
+
await page.waitForFunction(() => !!window.__motuLagoon, null, { timeout: 15000 }).catch(() => {});
|
|
1601
|
+
// Wait for the region, do not sleep at it — an app's real component tree takes far longer than a
|
|
1602
|
+
// stand-in, and a picture of a half-mounted page is a baseline that flaps forever.
|
|
1603
|
+
await page
|
|
1604
|
+
.waitForFunction(() => document.querySelectorAll('[data-motu-slot]').length > 0, null, { timeout: 15000 })
|
|
1605
|
+
.catch(() => {});
|
|
1606
|
+
|
|
1607
|
+
// Motion makes a baseline flap. Freeze it rather than sampling and hoping.
|
|
1608
|
+
await page.addStyleTag({
|
|
1609
|
+
content: '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}',
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
const shots = [];
|
|
1613
|
+
const list = states.length ? states : [{ name: 'default', seed: {} }];
|
|
1614
|
+
for (const state of list) {
|
|
1615
|
+
// A fresh mount per state, for the reason the flow runner remounts: one seed can leave the
|
|
1616
|
+
// region unrenderable, and every later picture would then be of the wreckage.
|
|
1617
|
+
await page.evaluate(() => window.__motuLagoon?.remount?.()).catch(() => {});
|
|
1618
|
+
await page
|
|
1619
|
+
.waitForFunction(() => document.querySelectorAll('[data-motu-slot]').length > 0, null, { timeout: 10000 })
|
|
1620
|
+
.catch(() => {});
|
|
1621
|
+
await page.evaluate((seed) => {
|
|
1622
|
+
const l = window.__motuLagoon;
|
|
1623
|
+
if (l) for (const [k, v] of Object.entries(seed || {})) l.seed(k, v);
|
|
1624
|
+
}, state.seed ?? {});
|
|
1625
|
+
for (const vp of viewports) {
|
|
1626
|
+
await page.setViewportSize({ width: vp.width, height: 900 });
|
|
1627
|
+
await sleep(300);
|
|
1628
|
+
const png = await page.screenshot({ animations: 'disabled', fullPage: true }).catch(() => null);
|
|
1629
|
+
if (png) shots.push({ scenario: state.name ?? 'default', viewport: vp.name, width: vp.width, png });
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
return shots;
|
|
1633
|
+
},
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* Render one island at each declared viewport and report what does not fit.
|
|
1639
|
+
*
|
|
1640
|
+
* The check nobody was running: motu knew `native | legacy` (footprint and skin) and nothing about
|
|
1641
|
+
* WIDTH, so "does this work on a phone" was a human dragging a window. One mount, resized — a browser
|
|
1642
|
+
* per width would triple the wall clock for the same answer.
|
|
1643
|
+
*
|
|
1644
|
+
* Horizontal overflow of the DOCUMENT is the signal, not of the island: a card that scrolls its own
|
|
1645
|
+
* table on purpose is fine; a page the member has to pan sideways is the bug this catches.
|
|
1646
|
+
*/
|
|
1647
|
+
export async function responsiveLagoon({ tag, port = 5199, viewports = [], scenarios = [] }) {
|
|
1648
|
+
return onLagoonPage(
|
|
1649
|
+
{ target: `island:${tag}`, port, viewport: { width: viewports[0]?.width ?? 1280, height: 900 } },
|
|
1650
|
+
async (page) => {
|
|
1651
|
+
// Poll for a PAINTED island, not just a mounted one: Vite's first-run dep re-optimization reloads
|
|
1652
|
+
// the page, and an island that fetches its own data paints a frame later.
|
|
1653
|
+
const deadline = Date.now() + paintTimeout;
|
|
1654
|
+
while (Date.now() < deadline) {
|
|
1655
|
+
const ready = await page
|
|
1656
|
+
.evaluate((t) => {
|
|
1657
|
+
const el = window.__motuFindIsland(t);
|
|
1658
|
+
return !!el && window.__motuRendered(el).trim().length > 0;
|
|
1659
|
+
}, tag)
|
|
1660
|
+
.catch(() => false);
|
|
1661
|
+
if (ready) break;
|
|
1662
|
+
await sleep(250);
|
|
1663
|
+
}
|
|
1664
|
+
await sleep(250);
|
|
1665
|
+
|
|
1666
|
+
const results = [];
|
|
1667
|
+
// Per SCENARIO, not just the default state: an island whose empty state is legitimately blank (a
|
|
1668
|
+
// notice with nothing to say) would otherwise be measured empty at every width and reported as
|
|
1669
|
+
// rendering nothing — a false alarm that teaches people to ignore the check.
|
|
1670
|
+
const list = scenarios.length ? scenarios : [{ name: 'default', seed: {} }];
|
|
1671
|
+
const owned = scenarioKeys(list);
|
|
1672
|
+
for (const scenario of list) {
|
|
1673
|
+
// NO REMOUNT, for the same reason the snapshot lane skips it: this one measures layout, and a
|
|
1674
|
+
// fresh mount would have it measuring a tree that is still filling in.
|
|
1675
|
+
await provideScenario(page, scenario.seed, { afterMount: scenario.afterMount, remount: false, owned });
|
|
1676
|
+
for (const vp of viewports) {
|
|
1677
|
+
await page.setViewportSize({ width: vp.width, height: 900 });
|
|
1678
|
+
// Two beats: one for the resize to land, one for anything that re-measures on it.
|
|
1679
|
+
await sleep(200);
|
|
1680
|
+
const measured = await page.evaluate((t) => {
|
|
1681
|
+
// A React-mounted island's wrapper is `display: contents` — no box of its own — so measuring it
|
|
1682
|
+
// directly reports every island as blank. Fall back to what it rendered, the same way the seam
|
|
1683
|
+
// lens does for its outlines.
|
|
1684
|
+
const boxOf = (el) => {
|
|
1685
|
+
if (!el) return null;
|
|
1686
|
+
const own = el.getBoundingClientRect();
|
|
1687
|
+
if (own.width || own.height) return own;
|
|
1688
|
+
let l = Infinity, t2 = Infinity, r = -Infinity, b = -Infinity;
|
|
1689
|
+
for (const child of el.children) {
|
|
1690
|
+
const c = boxOf(child);
|
|
1691
|
+
if (!c || (!c.width && !c.height)) continue;
|
|
1692
|
+
l = Math.min(l, c.left); t2 = Math.min(t2, c.top);
|
|
1693
|
+
r = Math.max(r, c.right); b = Math.max(b, c.bottom);
|
|
1694
|
+
}
|
|
1695
|
+
return l === Infinity ? null : { left: l, top: t2, right: r, bottom: b, width: r - l, height: b - t2 };
|
|
1696
|
+
};
|
|
1697
|
+
const doc = document.documentElement;
|
|
1698
|
+
const island = window.__motuFindIsland(t);
|
|
1699
|
+
// The island's own box, grown by whatever it portaled — a dialog-only island is not "blank".
|
|
1700
|
+
let rect = boxOf(island);
|
|
1701
|
+
for (const p of island ? window.__motuPortals(island) : []) {
|
|
1702
|
+
const b = boxOf(p);
|
|
1703
|
+
if (!b || (!b.width && !b.height)) continue;
|
|
1704
|
+
if (!rect || (!rect.width && !rect.height)) { rect = b; continue; }
|
|
1705
|
+
const l = Math.min(rect.left, b.left), t2 = Math.min(rect.top, b.top);
|
|
1706
|
+
const r = Math.max(rect.right, b.right), bt = Math.max(rect.bottom, b.bottom);
|
|
1707
|
+
rect = { left: l, top: t2, right: r, bottom: bt, width: r - l, height: bt - t2 };
|
|
1708
|
+
}
|
|
1709
|
+
return {
|
|
1710
|
+
overflow: doc.scrollWidth - doc.clientWidth,
|
|
1711
|
+
rendered: !!rect && rect.width > 0 && rect.height > 0,
|
|
1712
|
+
height: rect ? Math.round(rect.height) : 0,
|
|
1713
|
+
};
|
|
1714
|
+
}, tag);
|
|
1715
|
+
results.push({ ...vp, scenario: scenario.name ?? 'default', ...measured });
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
return results;
|
|
1719
|
+
},
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* Boot a whole archipelago in the real-browser lagoon and report every island slot it mounted. Returns
|
|
1725
|
+
* { ok, region, islands: [{ slot, tag, len }], diagnostics }. `ok` is true when the region rendered and
|
|
1726
|
+
* every declared slot MOUNTED its island element. Emptiness of content is NOT a failure — an island may
|
|
1727
|
+
* legitimately render nothing (e.g. filter chips with no active filter); a real mount throw is caught by
|
|
1728
|
+
* the diagnostics (console/pageerror) instead.
|
|
1729
|
+
*/
|
|
1730
|
+
export async function runArchipelagoLagoon({ id, port = 5199 }) {
|
|
1731
|
+
const { chromium } = await import('playwright');
|
|
1732
|
+
const diagnostics = [];
|
|
1733
|
+
return onLagoonPage({ target: `archipelago:${id}`, port, viewport: { width: 1200, height: 900 }, diagnostics }, async (page, server) => {
|
|
1734
|
+
|
|
1735
|
+
let result = { region: false, islands: [] };
|
|
1736
|
+
const deadline = Date.now() + paintTimeout;
|
|
1737
|
+
while (Date.now() < deadline) {
|
|
1738
|
+
try {
|
|
1739
|
+
result = await page.evaluate(() => {
|
|
1740
|
+
const rendered = (el) => (el ? (el.shadowRoot ? el.shadowRoot.innerHTML : el.innerHTML) : '');
|
|
1741
|
+
const arch = document.querySelector('motu-archipelago');
|
|
1742
|
+
if (arch) {
|
|
1743
|
+
const root = arch.shadowRoot || arch;
|
|
1744
|
+
const islands = [...root.querySelectorAll('motu-island')].map((marker) => {
|
|
1745
|
+
const child = [...marker.children].find((c) => c.tagName.includes('-'));
|
|
1746
|
+
return {
|
|
1747
|
+
slot: marker.getAttribute('slot'),
|
|
1748
|
+
tag: child ? child.tagName.toLowerCase() : null,
|
|
1749
|
+
len: rendered(child || marker).length,
|
|
1750
|
+
};
|
|
1751
|
+
});
|
|
1752
|
+
return { region: true, islands };
|
|
1753
|
+
}
|
|
1754
|
+
// React mount path: no region element and no markers — islands render in the host's own
|
|
1755
|
+
// tree, each behind a wrapper carrying its slot and tag.
|
|
1756
|
+
if (window.__motuLagoon) {
|
|
1757
|
+
const islands = [...document.querySelectorAll('[data-motu-island]')].map((el) => ({
|
|
1758
|
+
slot: el.getAttribute('data-motu-slot'),
|
|
1759
|
+
tag: el.getAttribute('data-motu-island'),
|
|
1760
|
+
len: rendered(el).length,
|
|
1761
|
+
}));
|
|
1762
|
+
return { region: true, islands };
|
|
1763
|
+
}
|
|
1764
|
+
return { region: false, islands: [] };
|
|
1765
|
+
});
|
|
1766
|
+
// Ready once the region is up and every declared slot has mounted its island element.
|
|
1767
|
+
if (result.region && result.islands.length > 0 && result.islands.every((i) => i.tag)) break;
|
|
1768
|
+
} catch {
|
|
1769
|
+
// HMR/full reload destroyed the context — re-poll on the fresh one.
|
|
1770
|
+
}
|
|
1771
|
+
await sleep(200);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
const rejections = await page.evaluate(() => window.__motuRejections || []).catch(() => []);
|
|
1775
|
+
for (const r of rejections) if (!NOISE.test(r)) diagnostics.push(r);
|
|
1776
|
+
const fixtureCoverage = await readUnscopedRequests(page);
|
|
1777
|
+
const networkEscapes = currentNetworkEscapes();
|
|
1778
|
+
|
|
1779
|
+
const ok = result.region && result.islands.length > 0 && result.islands.every((i) => i.tag);
|
|
1780
|
+
return { ok, ...result, diagnostics, fixtureCoverage, networkEscapes };
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* Establish ONE scenario's seed in the running lagoon, then RE-MOUNT the island so that seed is an
|
|
1786
|
+
* input rather than a late update.
|
|
1787
|
+
*
|
|
1788
|
+
* The re-mount is not optional, and this is the bug it closes. `provide()` writes the store, which
|
|
1789
|
+
* drives anything BOUND to a prop and drives NOTHING in an island that fetches its own data through a
|
|
1790
|
+
* stubbed host module — that island's effect already ran, against whatever the previous scenario left
|
|
1791
|
+
* behind. The happy-dom lane mounts once per scenario, so the two runtimes disagreed on the same
|
|
1792
|
+
* evidence: acme's club-feed passed `data-flow` under `--fast` with three distinct renders and failed
|
|
1793
|
+
* in the browser with "3 scenarios rendered identically". A stub answering with `seededValue()` only
|
|
1794
|
+
* becomes a per-scenario input if something makes the effect run again.
|
|
1795
|
+
*
|
|
1796
|
+
* The store survives the re-mount (it is module state, not tree state), so the fresh island reads the
|
|
1797
|
+
* seed just provided.
|
|
1798
|
+
*
|
|
1799
|
+
* NOT for region flows: a flow's steps ACCUMULATE by design, and re-mounting between them would throw
|
|
1800
|
+
* away the state the next step builds on.
|
|
1801
|
+
*
|
|
1802
|
+
* NOT for the lanes that MEASURE either. `captureLagoon` and `responsiveLagoon` keep their own inline
|
|
1803
|
+
* provide and are deliberately NOT routed through here: re-mounting made `snapshot --all` fail with
|
|
1804
|
+
* "Execution context was destroyed" (that lane re-aims ONE shared page per island, and the teardown
|
|
1805
|
+
* races the re-aim) and perturbed which element the screenshot found. Whether those two have their own
|
|
1806
|
+
* frozen-state problem is open — it needs the page-pool interaction understood first, and a snapshot
|
|
1807
|
+
* lane that dies is worse than one that might be stale.
|
|
1808
|
+
*/
|
|
1809
|
+
/**
|
|
1810
|
+
* Every key a scenario SET declares — what a reset between scenarios is allowed to forget.
|
|
1811
|
+
*
|
|
1812
|
+
* Scoped to these keys rather than the whole store, because a region's CHANNELS write their keys once,
|
|
1813
|
+
* when they are installed, and nothing re-fires them. Clearing everything took those with it and left
|
|
1814
|
+
* each later scenario looking at a region with no page behind it.
|
|
1815
|
+
*/
|
|
1816
|
+
function scenarioKeys(scenarios) {
|
|
1817
|
+
const keys = new Set();
|
|
1818
|
+
for (const s of scenarios ?? []) for (const k of Object.keys(scenarioInputs(s))) keys.add(k);
|
|
1819
|
+
return [...keys];
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
async function provideScenario(page, seed, { settleMs = 150, tag, remount = true, owned = [], afterMount } = {}) {
|
|
1823
|
+
await page.evaluate(({ s, later, remount, owned }) => {
|
|
1824
|
+
const arch = document.querySelector('motu-archipelago');
|
|
1825
|
+
// Either mount path exposes the same seams: the element on the custom-element path,
|
|
1826
|
+
// window.__motuLagoon on the React one.
|
|
1827
|
+
const on = arch && typeof arch.provide === 'function' ? arch : window.__motuLagoon;
|
|
1828
|
+
const provide = on ? (k, v) => on.provide(k, v) : null;
|
|
1829
|
+
|
|
1830
|
+
// FORGET THE LAST SCENARIO FIRST, and this is the whole reason a scenario means anything.
|
|
1831
|
+
//
|
|
1832
|
+
// Seeds were applied to one long-lived store, in order, so they ACCUMULATED: a scenario that seeds
|
|
1833
|
+
// `{}` provided nothing and rendered whatever its predecessor left behind, under its own name, in
|
|
1834
|
+
// the data-flow comparison and in the visual baseline alike. A scenario is a STATE — it says what
|
|
1835
|
+
// the region holds, and everything it does not mention it does not hold — so each one starts from
|
|
1836
|
+
// the region's own seed and adds only its own keys. (Flows are the opposite and do not come
|
|
1837
|
+
// through here: their steps build on each other by design.)
|
|
1838
|
+
//
|
|
1839
|
+
// ONLY THE KEYS THE SCENARIO SET OWNS. A region's channels write their keys once, at install, and
|
|
1840
|
+
// nothing re-fires them — so forgetting the whole store leaves every later scenario measuring a
|
|
1841
|
+
// region with no page behind it, which reads as an island "rendering nothing from default props"
|
|
1842
|
+
// while the lagoon a human opens shows it rendering perfectly.
|
|
1843
|
+
//
|
|
1844
|
+
// Tolerated when absent: an older host bundle has no `reset`, and the lane should degrade to the
|
|
1845
|
+
// previous behaviour rather than fail with a TypeError from inside a page evaluate.
|
|
1846
|
+
on?.reset?.(owned);
|
|
1847
|
+
// THE SCENARIO'S KEYS ARE ITS OWN: pinned before they are provided, so a source the reset just
|
|
1848
|
+
// restarted cannot replace them when it answers (the React harness exposes `pin`).
|
|
1849
|
+
// `afterMount` keys too: they arrive later, and a source answering in between must not take them.
|
|
1850
|
+
window.__motuLagoon?.pin?.([...Object.keys(s || {}), ...Object.keys(later || {})]);
|
|
1851
|
+
if (provide) for (const [k, v] of Object.entries(s || {})) provide(k, v);
|
|
1852
|
+
|
|
1853
|
+
if (!remount) return;
|
|
1854
|
+
// The React path exposes a real teardown. The custom-element path has none, so re-insert the
|
|
1855
|
+
// marker: disconnectedCallback disposes the child, connectedCallback mounts a fresh one.
|
|
1856
|
+
if (typeof window.__motuLagoon?.remount === 'function') {
|
|
1857
|
+
window.__motuLagoon.remount();
|
|
1858
|
+
} else {
|
|
1859
|
+
for (const el of document.querySelectorAll('motu-island')) {
|
|
1860
|
+
const parent = el.parentNode;
|
|
1861
|
+
const next = el.nextSibling;
|
|
1862
|
+
parent?.removeChild(el);
|
|
1863
|
+
parent?.insertBefore(el, next);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}, { s: seed ?? {}, later: afterMount ?? {}, remount, owned });
|
|
1867
|
+
// A re-mount re-runs the island's fetch, so a lane that MEASURES (screenshot, viewport fit, axe)
|
|
1868
|
+
// would otherwise sample a tree that is still filling in — a fixed sleep was enough when `provide`
|
|
1869
|
+
// only updated props. Wait for the render to go quiet instead, and keep the fixed settle for callers
|
|
1870
|
+
// that wait for stability themselves.
|
|
1871
|
+
if (tag) await waitForStableRender(page, tag, { timeoutMs: 8000 });
|
|
1872
|
+
else await sleep(settleMs);
|
|
1873
|
+
// WHAT ARRIVES AFTER MOUNT (`Scenario.afterMount`): only once the island has mounted and settled, so
|
|
1874
|
+
// the component sees it as news rather than as where it started. Then settle again — the render
|
|
1875
|
+
// that counts is the one after the arrival.
|
|
1876
|
+
if (afterMount && Object.keys(afterMount).length) {
|
|
1877
|
+
await page.evaluate((later) => {
|
|
1878
|
+
const arch = document.querySelector('motu-archipelago');
|
|
1879
|
+
const on = arch && typeof arch.provide === 'function' ? arch : window.__motuLagoon;
|
|
1880
|
+
if (on) for (const [k, v] of Object.entries(later)) on.provide(k, v);
|
|
1881
|
+
}, afterMount);
|
|
1882
|
+
if (tag) await waitForStableRender(page, tag, { timeoutMs: 8000 });
|
|
1883
|
+
else await sleep(settleMs);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/** Interactive roles worth trying, most-specific first — a checkbox's accessible name and a button's
|
|
1888
|
+
* often collide (e.g. a labelled tile), so a narrower role tried first wins over a generic one. */
|
|
1889
|
+
const CLICKABLE_ROLES = ['checkbox', 'switch', 'radio', 'menuitem', 'tab', 'link', 'button'];
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* Run a scenario's scripted interactions against the ALREADY-MOUNTED island — real Playwright
|
|
1893
|
+
* locators, scoped to the island's own element (its custom-element tag, or its React-path wrapper),
|
|
1894
|
+
* matched by ACCESSIBLE NAME rather than a CSS selector into the component's internals. See
|
|
1895
|
+
* `Scenario.interactions`'s doc comment (`@motujs/runtime/mock`) for why this is a different claim
|
|
1896
|
+
* than a region flow's `emit`, and therefore not the same "no synthetic clicks" rule.
|
|
1897
|
+
*
|
|
1898
|
+
* Playwright's locators pierce open shadow roots by default, so this works unchanged whether the
|
|
1899
|
+
* island rendered under its own shadow root (`fit=native`, standalone) or light DOM (nested in an
|
|
1900
|
+
* archipelago / the React mount path).
|
|
1901
|
+
*/
|
|
1902
|
+
async function runInteractions(page, tag, interactions) {
|
|
1903
|
+
// NOT scoped to the island's own element. A dialog/popover/dropdown built on a portal (Radix,
|
|
1904
|
+
// every shadcn component on it) renders as a SIBLING of the island in the DOM, not a descendant —
|
|
1905
|
+
// scoping the search to the island's subtree made every interaction past the one that opens such a
|
|
1906
|
+
// thing unfindable. `differentiateLagoon` mounts exactly one island alone, so page-wide search has
|
|
1907
|
+
// no ambiguity to avoid here; it would need scoping again if this ever drives a multi-island region.
|
|
1908
|
+
for (const step of interactions ?? []) {
|
|
1909
|
+
if (!step.click) continue;
|
|
1910
|
+
let target = null;
|
|
1911
|
+
for (const role of CLICKABLE_ROLES) {
|
|
1912
|
+
const candidate = page.getByRole(role, { name: step.click, exact: false });
|
|
1913
|
+
if ((await candidate.count()) > 0) {
|
|
1914
|
+
target = candidate.first();
|
|
1915
|
+
break;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
if (!target) target = page.getByText(step.click, { exact: false }).first();
|
|
1919
|
+
await target.click({ timeout: 5000 });
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/**
|
|
1924
|
+
* Data-flow differentiation in the real-browser lagoon. Mounts the island once, then drives each
|
|
1925
|
+
* declared scenario's seed into the store via the archipelago's `provide()` seam (the same inbound
|
|
1926
|
+
* boundary the ocean uses) and captures the rendered output after each. Distinct inputs producing
|
|
1927
|
+
* distinct output prove data flows criteria -> contract -> render, not merely that the wiring exists.
|
|
1928
|
+
* Returns { differentiates, scenarioCount, mounted, diagnostics }.
|
|
1929
|
+
*/
|
|
1930
|
+
export async function differentiateLagoon({ tag, fit = 'native', port = 5199, scenarios = [] }) {
|
|
1931
|
+
const { chromium } = await import('playwright');
|
|
1932
|
+
const diagnostics = [];
|
|
1933
|
+
return onLagoonPage({ target: `island:${tag}`, fit, port, viewport: { width: 900, height: 720 }, diagnostics }, async (page, server) => {
|
|
1934
|
+
|
|
1935
|
+
// Wait for the island to upgrade and paint — or to settle EMPTY. An island whose correct answer
|
|
1936
|
+
// with no input is nothing (a card with no member, a list with no rows) never paints from its
|
|
1937
|
+
// defaults, and waiting for paint used to end this check with `differentiates: null`, which the
|
|
1938
|
+
// caller dropped without a word: the scenarios were never driven and nothing said so. Mounted is
|
|
1939
|
+
// the element existing; an empty default is a state, not a failure, so its seeds are driven too.
|
|
1940
|
+
let mounted = false;
|
|
1941
|
+
let presentSince = null;
|
|
1942
|
+
const deadline = Date.now() + paintTimeout;
|
|
1943
|
+
while (Date.now() < deadline) {
|
|
1944
|
+
try {
|
|
1945
|
+
const r = await page.evaluate((t) => {
|
|
1946
|
+
const el = window.__motuFindIsland(t);
|
|
1947
|
+
return { mounted: !!el, len: window.__motuRendered(el).length };
|
|
1948
|
+
}, tag);
|
|
1949
|
+
if (r.mounted && r.len > 0) {
|
|
1950
|
+
mounted = true;
|
|
1951
|
+
break;
|
|
1952
|
+
}
|
|
1953
|
+
if (r.mounted) {
|
|
1954
|
+
presentSince ??= Date.now();
|
|
1955
|
+
if (Date.now() - presentSince >= EMPTY_DEFAULT_SETTLE_MS) {
|
|
1956
|
+
mounted = true;
|
|
1957
|
+
break;
|
|
1958
|
+
}
|
|
1959
|
+
} else {
|
|
1960
|
+
presentSince = null;
|
|
1961
|
+
}
|
|
1962
|
+
} catch {
|
|
1963
|
+
// context replaced by HMR — retry
|
|
1964
|
+
}
|
|
1965
|
+
await sleep(200);
|
|
1966
|
+
}
|
|
1967
|
+
if (!mounted) return { differentiates: null, scenarioCount: scenarios.length, mounted: false, diagnostics };
|
|
1968
|
+
|
|
1969
|
+
// Drive each scenario's seed through the archipelago boundary and capture rendered output.
|
|
1970
|
+
const outputs = [];
|
|
1971
|
+
const owned = scenarioKeys(scenarios);
|
|
1972
|
+
// Scenarios whose interactions changed NOTHING observable — see `inertInteractions` below.
|
|
1973
|
+
const inertInteractions = [];
|
|
1974
|
+
const requestCount = () =>
|
|
1975
|
+
page.evaluate(() => window.__motuFakeFetchRequestCount).catch(() => undefined);
|
|
1976
|
+
|
|
1977
|
+
// How many body-level nodes each scenario's render included from the island's own portals — so
|
|
1978
|
+
// a pass that rests on a dialog says so, rather than reading like the island's own subtree.
|
|
1979
|
+
const portals = [];
|
|
1980
|
+
for (const [i, scenario] of scenarios.entries()) {
|
|
1981
|
+
await provideScenario(page, scenario.seed, { afterMount: scenario.afterMount, owned });
|
|
1982
|
+
if (!scenario.interactions?.length) {
|
|
1983
|
+
outputs.push(normalizeRender(await waitForStableRender(page, tag, { timeoutMs: 8000 })));
|
|
1984
|
+
portals.push(await portalCount(page, tag));
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// AN INTERACTION MUST BE LOAD-BEARING, and "did the render change?" is the WRONG test for it.
|
|
1989
|
+
//
|
|
1990
|
+
// The properties interactions exist for are often exactly the ones where nothing visible moves:
|
|
1991
|
+
// "the panel keeps its last-good data when a later refetch fails" asserts that the render is
|
|
1992
|
+
// UNCHANGED. Comparing before/after alone would flag that scenario as decorative — the one case
|
|
1993
|
+
// the primitive was added for. So an interaction counts as load-bearing if it moved the render
|
|
1994
|
+
// OR caused work (a request through the lagoon's fake fetch). Only when it did neither is it a
|
|
1995
|
+
// journey somebody clicked through whose end state a plain seed could have described.
|
|
1996
|
+
const before = normalizeRender(await waitForStableRender(page, tag, { timeoutMs: 8000 }));
|
|
1997
|
+
const requestsBefore = await requestCount();
|
|
1998
|
+
await runInteractions(page, tag, scenario.interactions);
|
|
1999
|
+
const after = normalizeRender(await waitForStableRender(page, tag, { timeoutMs: 8000 }));
|
|
2000
|
+
const requestsAfter = await requestCount();
|
|
2001
|
+
|
|
2002
|
+
const renderMoved = before !== after;
|
|
2003
|
+
const known = requestsBefore !== undefined && requestsAfter !== undefined;
|
|
2004
|
+
const requestsMoved = known && requestsAfter > requestsBefore;
|
|
2005
|
+
if (!renderMoved && !requestsMoved) {
|
|
2006
|
+
inertInteractions.push({ name: scenario.name ?? `#${i + 1}`, requestsObservable: known });
|
|
2007
|
+
}
|
|
2008
|
+
outputs.push(after);
|
|
2009
|
+
portals.push(await portalCount(page, tag));
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
const rejections = await page.evaluate(() => window.__motuRejections || []).catch(() => []);
|
|
2013
|
+
for (const r of rejections) if (!NOISE.test(r)) diagnostics.push(r);
|
|
2014
|
+
const fixtureCoverage = await readUnscopedRequests(page);
|
|
2015
|
+
const networkEscapes = currentNetworkEscapes();
|
|
2016
|
+
|
|
2017
|
+
// TWO FAILURES, REPORTED AS ONE. `differentiates` folds "every scenario rendered" and "at least two
|
|
2018
|
+
// differ" into a boolean, and the caller could only say "rendered identically" — which is a lie
|
|
2019
|
+
// when the truth is that one scenario rendered NOTHING. That message sent someone looking for a
|
|
2020
|
+
// bind gap when the actual answer was an empty state the island renders on purpose.
|
|
2021
|
+
// A scenario may DECLARE that it renders nothing (`blank: 'why'`) — see lib/data-flow.mjs.
|
|
2022
|
+
const judged = judgeRenders(outputs, scenarios);
|
|
2023
|
+
return { ...judged, scenarioCount: scenarios.length, mounted: true, diagnostics, fixtureCoverage, inertInteractions, networkEscapes, portals };
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/**
|
|
2028
|
+
* Record contract calls in the real-browser lagoon. Starts capturing as early as the runtime exposes
|
|
2029
|
+
* the recorder (before the island's mount self-fetch), drives each scenario's seed through the
|
|
2030
|
+
* archipelago's `provide()` seam, and returns every captured call (request + response). With
|
|
2031
|
+
* `transport: 'http'` this records the REAL backend; default (mock) records the mock's own responses
|
|
2032
|
+
* (a self-consistency check of the pipeline). Returns { calls, mounted, diagnostics }.
|
|
2033
|
+
*/
|
|
2034
|
+
export async function recordLagoon({ tag, fit = 'native', port = 5199, scenarios = [], transport }) {
|
|
2035
|
+
const { chromium } = await import('playwright');
|
|
2036
|
+
const diagnostics = [];
|
|
2037
|
+
return onLagoonPage({ target: `island:${tag}`, fit, port, transport, viewport: { width: 900, height: 720 }, diagnostics }, async (page, server) => {
|
|
2038
|
+
// Turn on recording the instant the runtime module exposes the hook — before the island's mount
|
|
2039
|
+
// self-fetch — so the default (first scenario) request is captured too. Also start the seed
|
|
2040
|
+
// recorder (@motujs/core) to capture host-fed store writes (channels + provide()).
|
|
2041
|
+
await page.addInitScript(() => {
|
|
2042
|
+
const iv = setInterval(() => {
|
|
2043
|
+
if (window.__motuRecorder) {
|
|
2044
|
+
window.__motuRecorder.start();
|
|
2045
|
+
if (window.__motuSeedRecorder) window.__motuSeedRecorder.start();
|
|
2046
|
+
clearInterval(iv);
|
|
2047
|
+
}
|
|
2048
|
+
}, 0);
|
|
2049
|
+
});
|
|
2050
|
+
|
|
2051
|
+
// Wait for the island to upgrade and paint (its mount fetch fires here).
|
|
2052
|
+
let mounted = false;
|
|
2053
|
+
const deadline = Date.now() + paintTimeout;
|
|
2054
|
+
while (Date.now() < deadline) {
|
|
2055
|
+
try {
|
|
2056
|
+
const r = await page.evaluate((t) => {
|
|
2057
|
+
const el = window.__motuFindIsland(t);
|
|
2058
|
+
return { mounted: !!el, len: window.__motuRendered(el).length };
|
|
2059
|
+
}, tag);
|
|
2060
|
+
if (r.mounted && r.len > 0) {
|
|
2061
|
+
mounted = true;
|
|
2062
|
+
break;
|
|
2063
|
+
}
|
|
2064
|
+
} catch {
|
|
2065
|
+
// context replaced by HMR — retry
|
|
2066
|
+
}
|
|
2067
|
+
await sleep(200);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// Drive each scenario so its request is issued and captured. To make it DETERMINISTIC (a scenario
|
|
2071
|
+
// equal to the current store — e.g. the empty default — wouldn't re-fetch on its own), first set
|
|
2072
|
+
// the scenario's keys to a unique sentinel, then to the real seed: the change always re-issues the
|
|
2073
|
+
// request. Sentinel calls are filtered out below.
|
|
2074
|
+
let reset = 0;
|
|
2075
|
+
const provide = (seed) =>
|
|
2076
|
+
page.evaluate((s) => {
|
|
2077
|
+
const arch = document.querySelector('motu-archipelago');
|
|
2078
|
+
if (arch && typeof arch.provide === 'function') {
|
|
2079
|
+
for (const [k, v] of Object.entries(s || {})) arch.provide(k, v);
|
|
2080
|
+
}
|
|
2081
|
+
}, seed);
|
|
2082
|
+
for (const scenario of scenarios) {
|
|
2083
|
+
const seed = scenario.seed ?? {};
|
|
2084
|
+
const sentinel = { __motuRecordReset: ++reset };
|
|
2085
|
+
await provide(Object.fromEntries(Object.keys(seed).map((k) => [k, sentinel])));
|
|
2086
|
+
await sleep(300);
|
|
2087
|
+
await provide(seed);
|
|
2088
|
+
await sleep(500);
|
|
2089
|
+
if (scenario.afterMount) {
|
|
2090
|
+
await provide(scenario.afterMount);
|
|
2091
|
+
await sleep(500);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
await sleep(200);
|
|
2095
|
+
|
|
2096
|
+
const raw = await page.evaluate(() => (window.__motuRecorder ? window.__motuRecorder.stop() : []));
|
|
2097
|
+
// Drop the sentinel-priming calls; keep the real scenario requests.
|
|
2098
|
+
const calls = (raw || []).filter((c) => !JSON.stringify(c.args).includes('__motuRecordReset'));
|
|
2099
|
+
// Host-fed store writes (channel + provide) captured as lagoon seed; drop sentinel-primed values.
|
|
2100
|
+
const rawSeed = await page.evaluate(() => (window.__motuSeedRecorder ? window.__motuSeedRecorder.stop() : []));
|
|
2101
|
+
const seedWrites = (rawSeed || []).filter((w) => !JSON.stringify(w.value).includes('__motuRecordReset'));
|
|
2102
|
+
|
|
2103
|
+
const rejections = await page.evaluate(() => window.__motuRejections || []).catch(() => []);
|
|
2104
|
+
for (const r of rejections) if (!NOISE.test(r)) diagnostics.push(r);
|
|
2105
|
+
const fixtureCoverage = await readUnscopedRequests(page);
|
|
2106
|
+
const networkEscapes = currentNetworkEscapes();
|
|
2107
|
+
|
|
2108
|
+
return { calls, seedWrites, mounted, diagnostics, fixtureCoverage, networkEscapes };
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
// Direct invocation: screenshot helper for a manual dry-run.
|
|
2113
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
2114
|
+
const [, , tag, fit = 'native', screenshotPath] = process.argv;
|
|
2115
|
+
runLagoon({ tag, fit, screenshotPath })
|
|
2116
|
+
.then((r) => {
|
|
2117
|
+
process.stdout.write(JSON.stringify(r) + '\n');
|
|
2118
|
+
process.exit(r.ok ? 0 : 1);
|
|
2119
|
+
})
|
|
2120
|
+
.catch((err) => {
|
|
2121
|
+
process.stderr.write(`playwright-lagoon: ${err?.stack || err}\n`);
|
|
2122
|
+
process.exit(3);
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
/**
|
|
2127
|
+
* DOES THE APPLICATION'S OWN PAGE RENDER, AND DOES IT REACH EVERY SLOT IT DECLARES?
|
|
2128
|
+
*
|
|
2129
|
+
* EXPERIMENTAL. This is the one question every other check here is structurally unable to ask.
|
|
2130
|
+
* `integrate check` reads the host's SOURCE, so it can see `<X.Island slot="y">` and cannot see
|
|
2131
|
+
* whether the branch containing it ever runs — a slot inside `{isOpen && …}`, a ternary or a `.map()`
|
|
2132
|
+
* is reported as conditionally placed, and nothing proves the page REACHES it. The region view cannot
|
|
2133
|
+
* close that either: it renders every declared slot unconditionally, by construction, because motu
|
|
2134
|
+
* supplies the arrangement there. So a region could pass everything while the page that ships it
|
|
2135
|
+
* crashes on load or never renders half of it.
|
|
2136
|
+
*
|
|
2137
|
+
* What it asks, and nothing more:
|
|
2138
|
+
*
|
|
2139
|
+
* 1. the page mounted without throwing (a crash is marked, not a blank screen)
|
|
2140
|
+
* 2. every declared slot is present in the DOM (the page reached the branch that places it)
|
|
2141
|
+
* 3. each of those slots rendered something (the island mounted, not an empty wrapper)
|
|
2142
|
+
*
|
|
2143
|
+
* It does NOT assert content, drive interactions or replay flows — those are the region's flows, and
|
|
2144
|
+
* duplicating them here would make the same claim twice against a slower page.
|
|
2145
|
+
*
|
|
2146
|
+
* SKIPS, LOUDLY, when the region declares no `page` override — `seen: 0` so the report renders it as
|
|
2147
|
+
* a skip rather than a pass, because a check that looked at nothing has not passed.
|
|
2148
|
+
*/
|
|
2149
|
+
export async function pageRenderLagoon({ id, port = 5199 }) {
|
|
2150
|
+
const diagnostics = [];
|
|
2151
|
+
return onLagoonPage(
|
|
2152
|
+
{ target: `archipelago:${id}`, port, view: 'page', viewport: { width: 1200, height: 900 }, diagnostics },
|
|
2153
|
+
async (page) => {
|
|
2154
|
+
let result = { declared: false, crashed: null, slots: [] };
|
|
2155
|
+
const deadline = Date.now() + paintTimeout;
|
|
2156
|
+
while (Date.now() < deadline) {
|
|
2157
|
+
try {
|
|
2158
|
+
result = await page.evaluate(() => {
|
|
2159
|
+
const absent = document.querySelector('[data-motu-page="absent"]');
|
|
2160
|
+
const crashed = document.querySelector('[data-motu-page="crashed"]');
|
|
2161
|
+
const slots = [...document.querySelectorAll('[data-motu-island]')].map((el) => ({
|
|
2162
|
+
slot: el.getAttribute('data-motu-slot'),
|
|
2163
|
+
tag: el.getAttribute('data-motu-island'),
|
|
2164
|
+
len: (el.innerHTML || '').length,
|
|
2165
|
+
}));
|
|
2166
|
+
return {
|
|
2167
|
+
declared: !absent,
|
|
2168
|
+
crashed: crashed ? (crashed.textContent || '').slice(0, 1200) : null,
|
|
2169
|
+
slots,
|
|
2170
|
+
};
|
|
2171
|
+
});
|
|
2172
|
+
// Settled once the page either failed visibly or put something in every slot it reached.
|
|
2173
|
+
if (!result.declared || result.crashed || (result.slots.length > 0 && result.slots.every((s) => s.len > 0)))
|
|
2174
|
+
break;
|
|
2175
|
+
} catch {
|
|
2176
|
+
// HMR or a full reload tore the context down — re-poll on the fresh one.
|
|
2177
|
+
}
|
|
2178
|
+
await sleep(200);
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
const rejections = await page.evaluate(() => window.__motuRejections || []).catch(() => []);
|
|
2182
|
+
for (const r of rejections) if (!NOISE.test(r)) diagnostics.push(r);
|
|
2183
|
+
return { ...result, diagnostics };
|
|
2184
|
+
},
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* Open every declared SCREEN, and replay the screen flows.
|
|
2190
|
+
*
|
|
2191
|
+
* A screen is the application's own layout with a region per slot, and the calls that matter most
|
|
2192
|
+
* come from that layout — Peps' dashboard reads feature grants and two badge counts on every screen,
|
|
2193
|
+
* none of which a region's own flows ever make. So each screen is opened alone, in a fresh page, and
|
|
2194
|
+
* every call it causes is collected; and a screen may declare FLOWS (`src/screens/screens.evidence.ts`)
|
|
2195
|
+
* that click the app's own links, `navigate` to a path, and assert what renders afterwards.
|
|
2196
|
+
*
|
|
2197
|
+
* A fresh page per screen and per flow, not one re-aimed page: a screen's layout stays mounted across
|
|
2198
|
+
* navigation by design, so a re-aimed page would credit one screen's calls to the next.
|
|
2199
|
+
*/
|
|
2200
|
+
export async function runScreens({ port, screens = [], flows = [] }) {
|
|
2201
|
+
const server = await startLagoon({ target: '', port });
|
|
2202
|
+
const browser = await getBrowser();
|
|
2203
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
2204
|
+
const base = `http://localhost:${server.port}/`;
|
|
2205
|
+
const addr = (q) => `${base}?${new URLSearchParams({ ...q, dockInset: 'off', autoPrimary: 'off' })}`;
|
|
2206
|
+
const open = async (page, url) => {
|
|
2207
|
+
await page.goto(url, { waitUntil: 'load' });
|
|
2208
|
+
await page.waitForFunction(() => window.__motuLagoonState !== undefined, null, { timeout: 20000 }).catch(() => {});
|
|
2209
|
+
await sleep(1200);
|
|
2210
|
+
};
|
|
2211
|
+
const calls = (page, after = 0) =>
|
|
2212
|
+
page.evaluate((s) => (window.__motuLagoonControl?.calls?.() ?? []).filter((c) => c.seq > s), after).catch(() => []);
|
|
2213
|
+
const state = (page) => page.evaluate(() => window.__motuLagoonState ?? null).catch(() => null);
|
|
2214
|
+
const failing = (list) => list.filter((c) => !c.ok && !c.declared);
|
|
2215
|
+
|
|
2216
|
+
const opened = [];
|
|
2217
|
+
const ran = [];
|
|
2218
|
+
try {
|
|
2219
|
+
for (const id of screens) {
|
|
2220
|
+
const page = await context.newPage();
|
|
2221
|
+
try {
|
|
2222
|
+
await open(page, addr({ screen: id }));
|
|
2223
|
+
const st = await state(page);
|
|
2224
|
+
const landed = await calls(page);
|
|
2225
|
+
opened.push({ screen: id, ok: !!st?.ok, error: st?.ok ? null : (st?.error ?? 'the screen never reported a state'), calls: landed.length, undeclared: failing(landed) });
|
|
2226
|
+
} finally {
|
|
2227
|
+
await page.close().catch(() => {});
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
for (const flow of flows) {
|
|
2232
|
+
const page = await context.newPage();
|
|
2233
|
+
try {
|
|
2234
|
+
await open(page, addr({ screen: flow.screen, ...(flow.world ? { world: flow.world } : {}) }));
|
|
2235
|
+
let mark = 0;
|
|
2236
|
+
for (const [index, step] of (flow.steps ?? []).entries()) {
|
|
2237
|
+
const result = await page.evaluate(async (st) => {
|
|
2238
|
+
const ctl = window.__motuLagoonControl;
|
|
2239
|
+
if (st.click) {
|
|
2240
|
+
const name = String(st.click).trim();
|
|
2241
|
+
const CLICKABLE = 'button, [role="button"], a[href], [role="menuitem"], [role="tab"], [role="link"], label, summary';
|
|
2242
|
+
const nameOf = (el) =>
|
|
2243
|
+
`${el.getAttribute('aria-label') ?? el.getAttribute('title') ?? ''} ${el.innerText ?? el.textContent ?? ''}`.replace(/\s+/g, ' ').trim();
|
|
2244
|
+
const candidates = [...document.querySelectorAll(CLICKABLE)].filter((el) => el.getClientRects().length > 0);
|
|
2245
|
+
const el =
|
|
2246
|
+
candidates.find((c) => nameOf(c) === name) ??
|
|
2247
|
+
candidates.filter((c) => nameOf(c).includes(name)).sort((a, b) => nameOf(a).length - nameOf(b).length)[0];
|
|
2248
|
+
if (!el) {
|
|
2249
|
+
const available = [...new Set(candidates.map(nameOf).filter(Boolean))].slice(0, 12);
|
|
2250
|
+
return { error: `nothing clickable is named "${name}" — clickable here: ${available.map((n) => `"${n}"`).join(', ') || '(nothing)'}` };
|
|
2251
|
+
}
|
|
2252
|
+
el.click();
|
|
2253
|
+
} else if (st.navigate) {
|
|
2254
|
+
if (typeof ctl?.openPath !== 'function') return { error: 'this lagoon cannot open a path (no openPath on its control)' };
|
|
2255
|
+
ctl.openPath(String(st.navigate));
|
|
2256
|
+
} else if (!st.expectScreen && !Object.keys(st.expectRender ?? {}).length) {
|
|
2257
|
+
return { error: 'a screen step must `click`, `navigate`, or assert with `expectScreen` / `expectRender`' };
|
|
2258
|
+
}
|
|
2259
|
+
if (!st.expectScreen && !Object.keys(st.expectRender ?? {}).length) {
|
|
2260
|
+
return { error: 'a screen step must assert something — `expectScreen` or `expectRender`' };
|
|
2261
|
+
}
|
|
2262
|
+
const text = (el) => (el?.innerText ?? '').replace(/\s+/g, ' ').trim();
|
|
2263
|
+
const mismatches = () => {
|
|
2264
|
+
const out = [];
|
|
2265
|
+
const onScreen = new URL(location.href).searchParams.get('screen');
|
|
2266
|
+
if (st.expectScreen && onScreen !== st.expectScreen) out.push({ key: 'screen', expected: st.expectScreen, actual: onScreen });
|
|
2267
|
+
for (const [slot, want] of Object.entries(st.expectRender ?? {})) {
|
|
2268
|
+
const els = [...document.querySelectorAll(`[data-motu-slot="${slot}"]`)];
|
|
2269
|
+
const seen = els.map(text).join(' | ');
|
|
2270
|
+
if (!els.length) out.push({ key: `render:${slot}`, expected: `slot "${slot}" on screen`, actual: 'no such slot is mounted' });
|
|
2271
|
+
else if (!seen.includes(String(want))) out.push({ key: `render:${slot}`, expected: `text containing ${JSON.stringify(want)}`, actual: seen.slice(0, 300) });
|
|
2272
|
+
}
|
|
2273
|
+
return out;
|
|
2274
|
+
};
|
|
2275
|
+
const until = Date.now() + 3000;
|
|
2276
|
+
let m = mismatches();
|
|
2277
|
+
while (m.length && Date.now() < until) {
|
|
2278
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
2279
|
+
m = mismatches();
|
|
2280
|
+
}
|
|
2281
|
+
return { mismatches: m };
|
|
2282
|
+
}, step);
|
|
2283
|
+
await sleep(150);
|
|
2284
|
+
const landed = await calls(page, mark);
|
|
2285
|
+
mark = landed.reduce((n, c) => Math.max(n, c.seq), mark);
|
|
2286
|
+
ran.push({
|
|
2287
|
+
flow: flow.name ?? 'screen flow',
|
|
2288
|
+
screen: flow.screen,
|
|
2289
|
+
step: index + 1,
|
|
2290
|
+
ok: !result.error && !(result.mismatches ?? []).length,
|
|
2291
|
+
error: result.error ?? null,
|
|
2292
|
+
mismatches: result.mismatches ?? [],
|
|
2293
|
+
calls: landed.length,
|
|
2294
|
+
undeclared: failing(landed),
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
} finally {
|
|
2298
|
+
await page.close().catch(() => {});
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
} finally {
|
|
2302
|
+
await context.close().catch(() => {});
|
|
2303
|
+
}
|
|
2304
|
+
return { screens: opened, flows: ran };
|
|
2305
|
+
}
|