@motujs/cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/bundled/github/agents/archipelago-build.agent.md +298 -0
  3. package/bundled/github/agents/island-create.agent.md +261 -0
  4. package/bundled/github/agents/island-extract.agent.md +360 -0
  5. package/bundled/github/agents/island-locate.agent.md +208 -0
  6. package/bundled/github/agents/motu-checks.agent.md +158 -0
  7. package/bundled/github/agents/motu-evidence.agent.md +267 -0
  8. package/bundled/github/agents/motu-regions.agent.md +304 -0
  9. package/bundled/github/host-rules.md +43 -0
  10. package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
  11. package/bundled/github/prompts/island-create.prompt.md +37 -0
  12. package/bundled/github/prompts/island-extract.prompt.md +59 -0
  13. package/bundled/github/prompts/island-locate.prompt.md +43 -0
  14. package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
  15. package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
  16. package/bundled/packages/adapters/next/vite.mjs +121 -0
  17. package/bundled/packages/adapters/vite/vite.mjs +179 -0
  18. package/package.json +64 -3
  19. package/src/alias-loader.mjs +63 -0
  20. package/src/alias-register.mjs +28 -0
  21. package/src/asset-stub.cjs +3 -0
  22. package/src/asset-stub.mjs +5 -0
  23. package/src/cli.mjs +78 -0
  24. package/src/commands/adopt-root.mjs +194 -0
  25. package/src/commands/archipelago-init.mjs +314 -0
  26. package/src/commands/archipelago.mjs +138 -0
  27. package/src/commands/assurance.mjs +40 -0
  28. package/src/commands/check.mjs +346 -0
  29. package/src/commands/codegen.mjs +29 -0
  30. package/src/commands/coverage.mjs +358 -0
  31. package/src/commands/create.mjs +277 -0
  32. package/src/commands/defaults.mjs +129 -0
  33. package/src/commands/diff.mjs +595 -0
  34. package/src/commands/fixtures.mjs +153 -0
  35. package/src/commands/init.mjs +473 -0
  36. package/src/commands/integrate.mjs +208 -0
  37. package/src/commands/integration.mjs +1349 -0
  38. package/src/commands/lagoon-try.mjs +272 -0
  39. package/src/commands/lagoon.mjs +1419 -0
  40. package/src/commands/link.mjs +110 -0
  41. package/src/commands/region-coverage.mjs +604 -0
  42. package/src/commands/removal-check.mjs +878 -0
  43. package/src/commands/skills.mjs +156 -0
  44. package/src/commands/snapshot.mjs +456 -0
  45. package/src/commands/sync.mjs +38 -0
  46. package/src/commands/verify/island-runtime.mjs +581 -0
  47. package/src/commands/verify/island-static.mjs +586 -0
  48. package/src/commands/verify/island.mjs +135 -0
  49. package/src/commands/verify/reach.mjs +426 -0
  50. package/src/commands/verify/region-composition.mjs +391 -0
  51. package/src/commands/verify/region-config.mjs +330 -0
  52. package/src/commands/verify/region-flows.mjs +760 -0
  53. package/src/commands/verify/region-sources.mjs +516 -0
  54. package/src/commands/verify/region.mjs +271 -0
  55. package/src/commands/verify/report.mjs +338 -0
  56. package/src/commands/verify/screens-runtime.mjs +123 -0
  57. package/src/commands/verify.mjs +18 -0
  58. package/src/lagoon-build.mjs +25 -0
  59. package/src/lagoon-dev.mjs +32 -0
  60. package/src/lib/alias-core.mjs +49 -0
  61. package/src/lib/archipelagos.mjs +111 -0
  62. package/src/lib/assurance.mjs +144 -0
  63. package/src/lib/baselines.mjs +107 -0
  64. package/src/lib/bundlability.mjs +123 -0
  65. package/src/lib/changed.mjs +157 -0
  66. package/src/lib/component-props.mjs +145 -0
  67. package/src/lib/config.mjs +451 -0
  68. package/src/lib/contracts.mjs +244 -0
  69. package/src/lib/data-flow.mjs +47 -0
  70. package/src/lib/declared-sources.mjs +101 -0
  71. package/src/lib/dock-inject.mjs +47 -0
  72. package/src/lib/effects.mjs +97 -0
  73. package/src/lib/eject.mjs +715 -0
  74. package/src/lib/evidence-json.mjs +74 -0
  75. package/src/lib/expect-calls.mjs +53 -0
  76. package/src/lib/finding-memory.mjs +0 -0
  77. package/src/lib/flow-calls.mjs +53 -0
  78. package/src/lib/framework.mjs +98 -0
  79. package/src/lib/host-rules.mjs +55 -0
  80. package/src/lib/host-sources.mjs +121 -0
  81. package/src/lib/island-placement.mjs +64 -0
  82. package/src/lib/island-render.mjs +105 -0
  83. package/src/lib/islands.mjs +112 -0
  84. package/src/lib/lagoon-bundle.mjs +138 -0
  85. package/src/lib/lagoon-declares.mjs +553 -0
  86. package/src/lib/lagoon-materialize.mjs +217 -0
  87. package/src/lib/lagoon-vite.mjs +788 -0
  88. package/src/lib/live-url.mjs +53 -0
  89. package/src/lib/node-aliases.mjs +73 -0
  90. package/src/lib/operations.mjs +145 -0
  91. package/src/lib/provenance-plugin.mjs +74 -0
  92. package/src/lib/region-refs.mjs +40 -0
  93. package/src/lib/remote.mjs +171 -0
  94. package/src/lib/scaffold.mjs +606 -0
  95. package/src/lib/screens.mjs +266 -0
  96. package/src/lib/snapshots.mjs +85 -0
  97. package/src/lib/stubs.mjs +114 -0
  98. package/src/lib/ts-project.mjs +42 -0
  99. package/src/lib/util.mjs +626 -0
  100. package/src/lib/worlds.mjs +236 -0
  101. package/src/lib/write-if-changed.mjs +35 -0
  102. package/src/lib/writes-coverage.mjs +32 -0
  103. package/src/playwright-lagoon.mjs +2305 -0
  104. package/src/run.mjs +385 -0
  105. package/src/runtime-harness.mjs +225 -0
  106. 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
+ }