@codeyam-editor/codeyam-editor 0.1.4 → 0.1.6

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 (97) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud-branch-switch.js +1 -1
  3. package/npm/cloud.js +3166 -150
  4. package/npm/codesign-plan.js +68 -0
  5. package/npm/container.js +3 -0
  6. package/npm/disk-pressure.js +78 -0
  7. package/npm/editor-dev.js +96 -15
  8. package/npm/editor.js +85 -37
  9. package/npm/empty-project-dir.js +92 -7
  10. package/npm/fleet-add-failure.js +252 -0
  11. package/npm/fleet-asset-version.js +37 -0
  12. package/npm/fleet-auth-health.js +213 -0
  13. package/npm/fleet-auth.js +6 -1
  14. package/npm/fleet-build-data.js +109 -5
  15. package/npm/fleet-cleanup.js +232 -16
  16. package/npm/fleet-destroy-safety.js +272 -0
  17. package/npm/fleet-early-roster.js +87 -0
  18. package/npm/fleet-effect-poll.js +110 -0
  19. package/npm/fleet-effect-status.js +33 -32
  20. package/npm/fleet-env.js +86 -0
  21. package/npm/fleet-ghcr-secret.js +129 -0
  22. package/npm/fleet-git-maintenance.js +142 -0
  23. package/npm/fleet-host-op-limiter.js +68 -0
  24. package/npm/fleet-host.js +74 -0
  25. package/npm/fleet-iap-token.js +165 -0
  26. package/npm/fleet-job-badge.js +1 -1
  27. package/npm/fleet-launchd.js +171 -0
  28. package/npm/fleet-live-status.js +155 -0
  29. package/npm/fleet-memory.js +245 -9
  30. package/npm/fleet-plans.js +233 -4
  31. package/npm/fleet-pool.js +75 -16
  32. package/npm/fleet-prewarm.js +241 -0
  33. package/npm/fleet-project-drift.js +58 -0
  34. package/npm/fleet-public-url.js +394 -0
  35. package/npm/fleet-queue.js +95 -1
  36. package/npm/fleet-reachability-debounce.js +69 -0
  37. package/npm/fleet-reachability.js +80 -0
  38. package/npm/fleet-reconcile.js +43 -1
  39. package/npm/fleet-reset.js +330 -13
  40. package/npm/fleet-roster.js +6 -8
  41. package/npm/fleet-running-plan.js +85 -0
  42. package/npm/fleet-self-update.js +142 -0
  43. package/npm/fleet-session-token-cache.js +65 -0
  44. package/npm/fleet-setup-steps.js +112 -0
  45. package/npm/fleet-sleep-status.js +33 -0
  46. package/npm/fleet-status-refs.js +96 -0
  47. package/npm/fleet-strand.js +181 -0
  48. package/npm/fleet-stuck-status.js +117 -0
  49. package/npm/fleet-vm-access.js +404 -0
  50. package/npm/fleet-vm-probe-target.js +282 -0
  51. package/npm/fleet-vm-readiness.js +237 -0
  52. package/npm/fleet-vm-slugs.js +116 -0
  53. package/npm/google-ingress.js +226 -0
  54. package/npm/ingress-invoke.js +770 -0
  55. package/npm/launch-config.js +386 -9
  56. package/npm/scenario-check.js +880 -37
  57. package/npm/scenario-handlers.js +154 -30
  58. package/npm/scenario-interactivity.js +145 -6
  59. package/npm/scenario-issues.js +13 -1
  60. package/npm/scenario-metrics.js +116 -1
  61. package/npm/scenario-mocks.js +144 -21
  62. package/npm/scenario-playwright.js +649 -38
  63. package/npm/script-drift.js +152 -0
  64. package/npm/utils.js +365 -52
  65. package/npm/vm-urls.js +69 -8
  66. package/package.json +5 -5
  67. package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
  68. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
  69. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  70. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
  71. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
  72. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
  73. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  74. package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
  75. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
  76. package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
  77. package/ui/dist/assets/index-BygCiJSb.css +1 -0
  78. package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
  79. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  80. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  81. package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
  82. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
  83. package/ui/dist/codeyam-mark.svg +4 -0
  84. package/ui/dist/codeyam-wordmark.svg +5 -0
  85. package/ui/dist/index.html +4 -4
  86. package/ui/dist/open-source/codeyam-counter.png +0 -0
  87. package/ui/dist/open-source/el-carot.png +0 -0
  88. package/ui/dist/open-source/tabcommand.png +0 -0
  89. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  90. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  91. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  92. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  93. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  94. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  95. package/ui/dist/assets/index-BGqairFV.css +0 -1
  96. package/ui/dist/assets/react-CSS0HapR.js +0 -28
  97. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -20,17 +20,88 @@ const { chromium } = require("playwright");
20
20
  const PLAYWRIGHT_MISSING_BROWSER_PATTERN = "Executable doesn't exist";
21
21
  const PLAYWRIGHT_INSTALL_COMMAND = "npx playwright install chromium";
22
22
 
23
- // One-shot self-heal around `chromium.launch()`. If the first launch
24
- // throws the "missing browser" error, run `npx playwright install
25
- // chromium` synchronously (with `stdio: "inherit"` so the user sees
26
- // progress) and retry the launch exactly once. If the install or the
27
- // retry fails, rethrow the ORIGINAL Playwright error so the existing
28
- // `Scenario check failed: <stderr>` path keeps showing the actionable
29
- // message — looping would hide a real ops failure under a slow timeout.
23
+ // Substrings Playwright/Chromium emit when the browser process dies on a
24
+ // transient launch crash. On the cloud VM the base image has no dbus session,
25
+ // so Chromium intermittently aborts at launch with `Received signal 11`
26
+ // (SIGSEGV) — an environmental fault, not a scenario fault. Match either form
27
+ // because the wording varies across Playwright releases and the OS signal
28
+ // reporter.
29
+ const PLAYWRIGHT_LAUNCH_CRASH_PATTERNS = ["SIGSEGV", "Received signal 11"];
30
+ // A transient SIGSEGV launch crash is environmental, so a bounded retry clears
31
+ // it. Keep it small — a genuinely broken environment must still surface loudly
32
+ // rather than spin.
33
+ const CAPTURE_LAUNCH_SIGSEGV_RETRIES = 2;
34
+ // Short backoff between SIGSEGV retries so the transient fault has a moment to
35
+ // clear; small enough not to eat the per-slug recapture budget.
36
+ const CAPTURE_LAUNCH_SIGSEGV_BACKOFF_MS = 250;
37
+
38
+ // Pin the headless capture browser's `localhost` resolution to the IPv4
39
+ // loopback the editor's listeners bind. The browser-facing preview origin is
40
+ // now `localhost` (secure-context apps refuse a bare IP — see
41
+ // `BROWSER_FACING_HOST`), but on a dual-stack host `localhost` can resolve to
42
+ // `::1` first, which nothing answers on — stalling the capture or paying a
43
+ // happy-eyeballs fallback delay. Forcing the map removes that intermittency
44
+ // deterministically for capture. The operator's OWN preview browser is covered
45
+ // separately by the editor binding `::1` alongside `127.0.0.1` (see start.rs).
46
+ const CAPTURE_HOST_RESOLVER_RULES = "MAP localhost 127.0.0.1";
47
+ const CAPTURE_LAUNCH_ARGS = [
48
+ `--host-resolver-rules=${CAPTURE_HOST_RESOLVER_RULES}`,
49
+ // Cloud-VM-no-dbus / containerized-launch hardening. Each flag below is
50
+ // inert on a healthy desktop, so the laptop capture path is unchanged; on
51
+ // the dbus-less cloud base image they stop Chromium from SIGSEGV-ing at
52
+ // launch (signal 11). See plan chromium-capture-dbus-crash-hardening.
53
+ // No setuid sandbox in the container (the cloud VM runs capture without the
54
+ // kernel namespaces the sandbox needs).
55
+ "--no-sandbox",
56
+ // /dev/shm is tiny in many containers; route shared memory to /tmp so the
57
+ // renderer doesn't crash allocating it.
58
+ "--disable-dev-shm-usage",
59
+ // No GPU on the headless cloud VM — avoid the GL init path that aborts.
60
+ "--disable-gpu",
61
+ // Stub out the dbus integration Chromium reaches for at launch; the cloud
62
+ // base image has no dbus session bus, which is the proximate cause of the
63
+ // signal-11 abort.
64
+ "--disable-features=DBus",
65
+ ];
66
+ // Point Chromium's dbus client at a dead address so it never blocks on (or
67
+ // crashes against) a missing session bus. Spread over the real env in the
68
+ // default launcher so the laptop path keeps its normal environment.
69
+ const CAPTURE_LAUNCH_DBUS_ENV = { DBUS_SESSION_BUS_ADDRESS: "/dev/null" };
70
+
71
+ // Does this launch error look like a transient SIGSEGV browser crash?
72
+ // (environmental — e.g. the cloud VM's missing dbus session bus — not a
73
+ // scenario fault, so it is safe to retry).
74
+ function isTransientLaunchCrash(error) {
75
+ return (
76
+ error &&
77
+ typeof error.message === "string" &&
78
+ PLAYWRIGHT_LAUNCH_CRASH_PATTERNS.some((pattern) =>
79
+ error.message.includes(pattern),
80
+ )
81
+ );
82
+ }
83
+
84
+ // Self-heal around `chromium.launch()`. Two recoverable classes:
85
+ //
86
+ // 1. "missing browser" — run `npx playwright install chromium` synchronously
87
+ // (with `stdio: "inherit"` so the user sees progress) and retry once.
88
+ // 2. transient SIGSEGV launch crash — retry the launch up to
89
+ // `CAPTURE_LAUNCH_SIGSEGV_RETRIES` times with a short backoff, since the
90
+ // crash is environmental (dbus-less cloud VM) and clears on a retry.
91
+ //
92
+ // For any unrecognized error, or after exhausting retries, rethrow the
93
+ // ORIGINAL Playwright error so the existing `Scenario check failed: <stderr>`
94
+ // path keeps showing the actionable message — looping would hide a real ops
95
+ // failure under a slow timeout.
30
96
  async function launchChromiumWithSelfHeal({
31
- launch = () => chromium.launch(),
97
+ launch = () =>
98
+ chromium.launch({
99
+ args: CAPTURE_LAUNCH_ARGS,
100
+ env: { ...process.env, ...CAPTURE_LAUNCH_DBUS_ENV },
101
+ }),
32
102
  install = () => execSync(PLAYWRIGHT_INSTALL_COMMAND, { stdio: "inherit" }),
33
103
  stderr = process.stderr,
104
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
34
105
  } = {}) {
35
106
  try {
36
107
  return await launch();
@@ -39,7 +110,24 @@ async function launchChromiumWithSelfHeal({
39
110
  error &&
40
111
  typeof error.message === "string" &&
41
112
  error.message.includes(PLAYWRIGHT_MISSING_BROWSER_PATTERN);
42
- if (!isMissingBrowser) throw error;
113
+ const isTransientCrash = isTransientLaunchCrash(error);
114
+ if (!isMissingBrowser && !isTransientCrash) throw error;
115
+
116
+ if (isTransientCrash) {
117
+ for (let attempt = 1; attempt <= CAPTURE_LAUNCH_SIGSEGV_RETRIES; attempt++) {
118
+ stderr.write(
119
+ `Chromium crashed at launch (transient, likely a dbus-less environment) — retry ${attempt}/${CAPTURE_LAUNCH_SIGSEGV_RETRIES}.\n`,
120
+ );
121
+ await sleep(CAPTURE_LAUNCH_SIGSEGV_BACKOFF_MS);
122
+ try {
123
+ return await launch();
124
+ } catch (retryError) {
125
+ if (!isTransientLaunchCrash(retryError)) throw retryError;
126
+ }
127
+ }
128
+ throw error;
129
+ }
130
+
43
131
  stderr.write(
44
132
  "Playwright's Chromium browser is missing — installing it now (one-time ~150 MB download). Subsequent runs will be instant.\n",
45
133
  );
@@ -57,9 +145,10 @@ async function launchChromiumWithSelfHeal({
57
145
  }
58
146
 
59
147
  const {
60
- findErrorPattern,
61
- buildErrorContextSnippet,
148
+ findScenarioError,
149
+ SCENARIO_ERROR_MARKER,
62
150
  hasRenderableContent,
151
+ buildSettleAdvisory,
63
152
  describeBlankReason,
64
153
  } = require("./scenario-metrics");
65
154
 
@@ -71,19 +160,99 @@ const {
71
160
 
72
161
  const {
73
162
  attachHttpMocks,
163
+ isDeclaredErrorMock,
164
+ unmockedRouteFrom,
74
165
  } = require("./scenario-mocks");
75
166
 
76
167
  const {
77
168
  assertAppPortReachable,
78
169
  loadScenarioInIframe,
79
170
  loadScenarioTopLevel,
171
+ resolveHarnessOrigin,
80
172
  waitForStablePage,
81
173
  createNetworkTracker,
82
174
  waitForNetworkQuiet,
83
175
  collectContentState,
176
+ scrollThroughDocument,
177
+ collectVisibleTextLength,
178
+ forceFinalVisualState,
179
+ centerCaptureWrapper,
84
180
  performInteraction,
181
+ waitForPredicate,
182
+ performInteractionSequence,
85
183
  } = require("./scenario-playwright");
86
184
 
185
+ // Name of the cookie the editor's control-API accepts the session token in —
186
+ // must match `SESSION_COOKIE` in crates/control-api/src/origin_guard.rs. On a
187
+ // non-loopback (cloud) bind the control-API requires this token on the harness
188
+ // route (`/__codeyam_harness`); without it the top-level harness navigation 401s
189
+ // and `#scenario-frame` never attaches, so EVERY capture times out. The browser
190
+ // UI carries this cookie automatically; the headless capturer does not, so we
191
+ // inject it here from the on-box 0600 token file.
192
+ const SESSION_COOKIE = "cy_session";
193
+
194
+ // Build the Playwright cookie array that authenticates the capture context to
195
+ // the editor's token-gated control-API. Reads the on-box `.codeyam/session-token`
196
+ // (0600) and scopes a `cy_session` cookie to `localhost` (localhost cookies are
197
+ // port-agnostic, so the same cookie covers the harness origin on the control
198
+ // port and any `/api` call on the app-proxy port). Returns `[]` — a no-op — when
199
+ // there is no token file (a loopback bind that never persisted one) or no
200
+ // resolvable harness origin, so a laptop capture is completely unaffected.
201
+ // `deps` is injectable so the builder is unit-testable without disk.
202
+ function buildSessionTokenCookies({
203
+ readTokenFile = () => {
204
+ const p = path.join(process.cwd(), ".codeyam", "session-token");
205
+ return fs.existsSync(p) ? fs.readFileSync(p, "utf8").trim() : null;
206
+ },
207
+ harnessOrigin = resolveHarnessOrigin(),
208
+ } = {}) {
209
+ try {
210
+ const token = readTokenFile();
211
+ if (!token || !harnessOrigin) return [];
212
+ return [
213
+ {
214
+ name: SESSION_COOKIE,
215
+ value: token,
216
+ domain: "localhost",
217
+ path: "/",
218
+ httpOnly: true,
219
+ sameSite: "Lax",
220
+ },
221
+ ];
222
+ } catch (_) {
223
+ // A missing/unreadable token file must never break capture on a loopback
224
+ // bind where the token isn't required anyway — degrade to no cookie.
225
+ return [];
226
+ }
227
+ }
228
+
229
+ // True when a non-empty on-box `.codeyam/session-token` exists (the same
230
+ // 0600 file `buildSessionTokenCookies` reads). Used by the capability-skew
231
+ // guard to tell "no token, loopback bind, nothing to inject" (fine) apart from
232
+ // "a token exists but the capture produced no cookie" (a stale capture script
233
+ // heading into a guaranteed 401). Never throws — an unreadable file reports
234
+ // absent, degrading to today's behavior.
235
+ function onBoxSessionTokenExists() {
236
+ try {
237
+ const p = path.join(process.cwd(), ".codeyam", "session-token");
238
+ return fs.existsSync(p) && fs.readFileSync(p, "utf8").trim().length > 0;
239
+ } catch (_) {
240
+ return false;
241
+ }
242
+ }
243
+
244
+ // Pure predicate behind the capability-skew guard: true when the capture WILL
245
+ // use the token-gated harness route (an origin resolved) yet produced NO
246
+ // `cy_session` cookie despite an on-box token existing — the signature of a
247
+ // generated `.codeyam/capture.js` stale relative to the harness route, heading
248
+ // into a guaranteed 401. Separated from the throw so the decision is
249
+ // unit-testable without a browser context. Returns false the moment any leg is
250
+ // absent (loopback bind with no harness origin, no token, or a cookie was
251
+ // produced), so a healthy capture never trips it.
252
+ function captureAuthSkewDetected({ harnessOrigin, cookieCount, tokenExists }) {
253
+ return Boolean(harnessOrigin) && cookieCount === 0 && Boolean(tokenExists);
254
+ }
255
+
87
256
  const {
88
257
  getInitScript,
89
258
  handleConsoleMessage,
@@ -92,7 +261,7 @@ const {
92
261
  } = require("./scenario-handlers");
93
262
 
94
263
  const {
95
- probeInteractivity,
264
+ waitForHydration,
96
265
  } = require("./scenario-interactivity");
97
266
 
98
267
  // Read project-specific loading markers from `.codeyam/stack.json`
@@ -116,6 +285,70 @@ function readStackLoadingMarkers() {
116
285
  }
117
286
  }
118
287
 
288
+ // True when the scenario being captured scripts a `/ws/terminal` transcript or
289
+ // a WebSocket stream. Such captures need the REAL `WebSocket` so the server can
290
+ // replay the scripted agent state into the frame — `getInitScript` keeps its
291
+ // reconnect-silencing stub OFF for them (see the comment there). Read from the
292
+ // scenario file by slug (`config.scenarioId`), the same cwd-relative path
293
+ // `readStackLoadingMarkers` uses. Defaults to `false` (stub stays on, today's
294
+ // behavior) for the scenario-less CLI preview path or any unreadable/parse
295
+ // failure, so a missing file never accidentally un-stubs a live terminal.
296
+ function scenarioScriptsLiveSocket(config) {
297
+ const slug = config && config.scenarioId;
298
+ if (!slug) return false;
299
+ try {
300
+ const raw = fs.readFileSync(
301
+ path.join(".codeyam", "scenarios", `${slug}.json`),
302
+ "utf8",
303
+ );
304
+ const mocks = (JSON.parse(raw) || {}).mocks || {};
305
+ const transcripts = mocks.transcripts || {};
306
+ const streams = mocks.streams || {};
307
+ return Object.keys(transcripts).length > 0 || Object.keys(streams).length > 0;
308
+ } catch (_) {
309
+ return false;
310
+ }
311
+ }
312
+
313
+ // Classify what a driven interaction actually did, from the DOM fingerprint on
314
+ // either side of it plus the page's hydration state. Pure, so the distinction
315
+ // this encodes is unit-tested without a browser.
316
+ //
317
+ // A click that changed nothing has two very different causes, and reporting both
318
+ // as "none" is what let a dead page masquerade as a flaky harness: a page that
319
+ // NEVER HYDRATED could not have responded to ANY interaction (no client JS is
320
+ // running), whereas a hydrated page with an unchanged DOM really does mean the
321
+ // control is inert. Keeping them apart is what tells a reader whether to go look
322
+ // at their component or at the environment.
323
+ //
324
+ // `hydrated` is the three-valued signal from `waitForHydration`: only a PROVEN
325
+ // dead page (`false`) earns "unhydrated". `null` means the wait could not judge
326
+ // (unknown framework, no controls, probe threw) and must keep reporting "none" —
327
+ // never invent a hydration fault we cannot prove.
328
+ function classifyInteractionEffect(beforeFingerprint, afterFingerprint, hydrated) {
329
+ if (beforeFingerprint !== afterFingerprint) return "changed";
330
+ if (hydrated === false) return "unhydrated";
331
+ return "none";
332
+ }
333
+
334
+ async function getDOMFingerprint(frame) {
335
+ try {
336
+ return await frame.evaluate(() => {
337
+ const body = document.body;
338
+ if (!body) return "";
339
+ const html = body.innerHTML;
340
+ let hash = 0;
341
+ for (let i = 0; i < html.length; i++) {
342
+ hash = (hash << 5) - hash + html.charCodeAt(i);
343
+ hash |= 0;
344
+ }
345
+ return `${html.length}-${hash}`;
346
+ });
347
+ } catch (err) {
348
+ return "";
349
+ }
350
+ }
351
+
119
352
  // Cold-start retry pause. waitForStablePage settles as soon as the page is
120
353
  // HTML-stable, which for a lazy/Suspense app is the empty `<div id="root">`
121
354
  // shell — stable for the ~3s the dynamic chunk takes to load (longer when the
@@ -124,6 +357,33 @@ function readStackLoadingMarkers() {
124
357
  // while staying under the test runner's default per-case timeout.
125
358
  const BLANK_RETRY_DELAY_MS = 3000;
126
359
 
360
+ // Pause after scrolling the document to trip scroll-gated reveal observers, so
361
+ // the IntersectionObserver callbacks fire and any opacity/transform entrance
362
+ // transition begins before we measure visible content and screenshot. Driven
363
+ // through `page.waitForTimeout` so it is a real wait in Playwright but instant
364
+ // against the stubbed pages in unit tests.
365
+ const REVEAL_SETTLE_MS = 600;
366
+
367
+ // Measure the frame's visible (painted, non-opacity:0) text and fold it into
368
+ // the content state in place, so the blank gate can distinguish "text rendered
369
+ // but invisible" from a populated frame. Best-effort: a frame/target that
370
+ // cannot be measured (a stubbed test target returns a non-number, an evaluate
371
+ // throws) leaves the state untouched, so `hasRenderableContent` falls back to
372
+ // its DOM-presence behavior rather than treating an un-measurable frame as
373
+ // blank.
374
+ async function mergeVisibleTextLength(contentState, frame) {
375
+ if (!contentState) return;
376
+ let visible;
377
+ try {
378
+ visible = await collectVisibleTextLength(frame);
379
+ } catch (_) {
380
+ return;
381
+ }
382
+ if (typeof visible === "number") {
383
+ contentState.visibleTextLength = visible;
384
+ }
385
+ }
386
+
127
387
  // True when `url` targets a different origin than `appOrigin`. Used to decide
128
388
  // whether the codeyam capture markers must be stripped before the request
129
389
  // leaves (cross-origin) or may ride along (same-origin, the app's own dev
@@ -139,6 +399,21 @@ function isCrossOriginRequest(url, appOrigin) {
139
399
  }
140
400
  }
141
401
 
402
+ // Decide whether a failed request should fail the capture. Only a failure
403
+ // targeting the captured page's OWN origin counts — a first-party route that
404
+ // 500s or a same-origin asset that 404s is a real capture problem. A
405
+ // cross-origin failure (the mocked app's dev port being down, an external
406
+ // CDN/API) is an external resource and is tolerated, so the editor-shell
407
+ // screenshot still succeeds. Mirrors the console handler's tolerance for
408
+ // declared error-mock URLs. When `appOrigin` is unknown (malformed capture
409
+ // URL) the failure stays fatal — the conservative default preserves today's
410
+ // behavior rather than silently swallowing every failure. Pure, so the
411
+ // decision is unit-testable without a live browser.
412
+ function isCaptureFatalRequestFailure(url, appOrigin) {
413
+ if (!appOrigin) return true;
414
+ return !isCrossOriginRequest(url, appOrigin);
415
+ }
416
+
142
417
  // Return a copy of `headers` with every name in `markerNames` removed.
143
418
  // Names are matched case-insensitively against the (lowercased) header keys
144
419
  // Playwright reports. Pure — never mutates its input — so unrelated headers
@@ -235,6 +510,37 @@ async function applyBrowserState(context, config) {
235
510
  },
236
511
  );
237
512
  }
513
+
514
+ // Seed the scenario's `browserState.localStorage` / `.sessionStorage` into
515
+ // the page before any app JS runs. Storage-gated UI (first-run banners,
516
+ // dismissed-prompt flags, persisted view state) is otherwise
517
+ // uncontrollable at capture time. Playwright serializes the function and
518
+ // its arg into the page context, so the function body must not close over
519
+ // outer variables. Only registered when the scenario actually carries
520
+ // storage, preserving the pre-storage capture context for everyone else.
521
+ const localStorageSeed = state.localStorage || {};
522
+ const sessionStorageSeed = state.sessionStorage || {};
523
+ if (
524
+ Object.keys(localStorageSeed).length > 0 ||
525
+ Object.keys(sessionStorageSeed).length > 0
526
+ ) {
527
+ await context.addInitScript(
528
+ (storage) => {
529
+ try {
530
+ for (const [key, value] of Object.entries(storage.local)) {
531
+ window.localStorage.setItem(key, value);
532
+ }
533
+ for (const [key, value] of Object.entries(storage.session)) {
534
+ window.sessionStorage.setItem(key, value);
535
+ }
536
+ } catch (_) {
537
+ // Storage unavailable (sandboxed/opaque origin) — the seed is
538
+ // best-effort; never fail the capture over it.
539
+ }
540
+ },
541
+ { local: localStorageSeed, session: sessionStorageSeed },
542
+ );
543
+ }
238
544
  }
239
545
 
240
546
  // Emit a `redirect-mismatch` issue when the final iframe URL's path
@@ -345,11 +651,207 @@ async function dumpPageState(frame, selector) {
345
651
  }, selector || null);
346
652
  }
347
653
 
654
+ // Landed-state verification: assert the localStorage the capture INJECTED
655
+ // (`config.browserState.localStorage`, which already carries the seed-session
656
+ // overlay the editor merged in) actually reached the capture browser after the
657
+ // page settled. The core promise of a seeded scenario is remote control of the
658
+ // app — a seed that silently doesn't land produces an empty screenshot that
659
+ // looks like a successful capture of an empty app, which is exactly the
660
+ // failure this guards. Returns a loud `seed-not-landed` issue (which fails the
661
+ // capture, since `ok` requires zero issues) when a non-empty injected seed is
662
+ // missing/empty on read-back, or `null` when there was nothing to verify, the
663
+ // seed landed, or storage is unavailable (sandboxed/opaque origin — never fail
664
+ // the capture over the verifier itself).
665
+ async function verifySeededStorageLanded(frame, config) {
666
+ const expected =
667
+ (config && config.browserState && config.browserState.localStorage) || {};
668
+ const expectedKeys = Object.keys(expected);
669
+ if (expectedKeys.length === 0) return null; // nothing was seeded into storage
670
+
671
+ let readback;
672
+ try {
673
+ readback = await frame.evaluate((keys) => {
674
+ try {
675
+ const allKeys = [];
676
+ for (let i = 0; i < window.localStorage.length; i++) {
677
+ const k = window.localStorage.key(i);
678
+ if (k != null) allKeys.push(k);
679
+ }
680
+ const present = {};
681
+ for (const k of keys) {
682
+ const v = window.localStorage.getItem(k);
683
+ present[k] = v != null && v !== "";
684
+ }
685
+ return { present, allKeys };
686
+ } catch (_) {
687
+ // localStorage unavailable (sandboxed/opaque origin) — can't verify.
688
+ return null;
689
+ }
690
+ }, expectedKeys);
691
+ } catch (_) {
692
+ return null; // evaluate failed — never fail a capture over the verifier.
693
+ }
694
+ if (!readback) return null; // storage unavailable — best-effort, don't fail.
695
+
696
+ const missing = expectedKeys.filter((k) => !readback.present[k]);
697
+ if (missing.length === 0) return null; // every seeded key landed — success.
698
+
699
+ const actual = readback.allKeys.length ? readback.allKeys.join(", ") : "<none>";
700
+ return createIssue(
701
+ "seed-not-landed",
702
+ `Seeded localStorage did not reach the capture browser: expected non-empty ` +
703
+ `keys [${expectedKeys.join(", ")}] but [${missing.join(", ")}] are ` +
704
+ `missing/empty after load (browser localStorage holds: [${actual}]). The ` +
705
+ `seed did NOT land, so this screenshot shows DEFAULT/EMPTY state — fix the ` +
706
+ `seed; do not delete the scenario. Likely causes: (1) the seed-session ` +
707
+ `overlay was stale or empty (re-run the seed adapter to refresh it), (2) the ` +
708
+ `adapter's stdout localStorage map failed to parse, or (3) the injection ` +
709
+ `path is down (the seeded origin differs from the captured page's origin).`,
710
+ { url: (frame && frame.url && frame.url()) || (config && config.url) },
711
+ );
712
+ }
713
+
714
+ // Drive an ordered list of flow steps against ONE already-loaded browser
715
+ // session so a scripted multi-step demo (`editor preview-flow`) is captured as
716
+ // the real round-trip — click state and client transients persist across
717
+ // steps, which N independent fresh-load captures could never reproduce. Each
718
+ // step is one of:
719
+ // - navigate: re-load a route (resolved relative to the initial url) using
720
+ // the same loader strategy as the initial load, then re-settle. Returns
721
+ // the new content frame so subsequent steps target the navigated page.
722
+ // - click / fill / press: a `performInteraction` against the current frame,
723
+ // then re-settle.
724
+ // - waitFor: hold until a visible-text / selector predicate (bounded).
725
+ // - capture: write a numbered filmstrip frame to the step's `outputPath`.
726
+ // A failing step THROWS with its 1-based index and action, so the outer catch
727
+ // in `runScenarioCheck` reports exactly which step broke (and, for waitFor,
728
+ // the predicate that never appeared) instead of a silent blank capture.
729
+ // Returns the frame the flow ended on, so the caller's final screenshot and
730
+ // result URL reflect the last navigated route.
731
+ async function runFlowSteps(page, initialFrame, steps, ctx) {
732
+ const {
733
+ url,
734
+ loadingMarkers,
735
+ navigation,
736
+ iframeBackground,
737
+ preflight,
738
+ harnessOrigin,
739
+ hydrationTimeoutMs = 10000,
740
+ settleMs,
741
+ } = ctx;
742
+ // Honor a caller-supplied `settleMs` for the in-flow stability windows,
743
+ // falling back to today's hardcoded defaults (5s for interaction steps, 10s
744
+ // for a navigate). This stops the field from being silently dropped — the
745
+ // exact trap that made a reporter's `settleMs: 8000` a no-op.
746
+ const navigateSettleMs =
747
+ typeof settleMs === "number" && settleMs > 0 ? settleMs : 10000;
748
+ const interactionSettleMs =
749
+ typeof settleMs === "number" && settleMs > 0 ? settleMs : 5000;
750
+ let frame = initialFrame;
751
+
752
+ for (let i = 0; i < steps.length; i++) {
753
+ const step = steps[i] || {};
754
+ const n = i + 1;
755
+ try {
756
+ switch (step.action) {
757
+ case "navigate": {
758
+ const target = new URL(step.path, url).href;
759
+ const loadResult =
760
+ navigation === "topLevel"
761
+ ? await loadScenarioTopLevel(page, target, { preflight })
762
+ : await loadScenarioInIframe(page, target, {
763
+ background: iframeBackground,
764
+ preflight,
765
+ harnessOrigin,
766
+ });
767
+ frame = loadResult.frame;
768
+ await waitForStablePage(page, frame, navigateSettleMs, loadingMarkers);
769
+ // A navigate loads a fresh route that must re-hydrate before the next
770
+ // fill/click, or the interaction lands on inert SSR markup (the same
771
+ // bug the top-level wait fixes, one route deeper). Bounded and
772
+ // stack-gated exactly like the top-level wait.
773
+ await waitForHydration(frame, {
774
+ url: target,
775
+ timeoutMs: hydrationTimeoutMs,
776
+ });
777
+ break;
778
+ }
779
+ case "click":
780
+ case "fill":
781
+ case "press":
782
+ await performInteraction(frame, step);
783
+ await waitForStablePage(page, frame, interactionSettleMs, loadingMarkers);
784
+ break;
785
+ case "waitFor":
786
+ await waitForPredicate(frame, step);
787
+ break;
788
+ case "capture":
789
+ if (step.outputPath) {
790
+ fs.mkdirSync(path.dirname(step.outputPath), { recursive: true });
791
+ await page.screenshot({ path: step.outputPath, fullPage: false });
792
+ }
793
+ break;
794
+ default:
795
+ throw new Error(
796
+ `unknown step action "${step.action}" (expected navigate | click | fill | press | waitFor | capture)`,
797
+ );
798
+ }
799
+ } catch (error) {
800
+ throw new Error(
801
+ `flow step ${n} (${step.action}) failed: ${error.message || String(error)}`,
802
+ );
803
+ }
804
+ }
805
+
806
+ return frame;
807
+ }
808
+
348
809
  // `preflight` is injectable (defaulting to the real app-port reachability
349
810
  // check) so unit tests that mock the browser can stay network-free.
350
- async function runScenarioCheck(config, { preflight = assertAppPortReachable } = {}) {
811
+ // `harnessOrigin` is likewise injectable: it defaults to resolving the editor's
812
+ // harness origin from `.codeyam/server-state.json`, but a test passes an
813
+ // explicit value (e.g. `null` to force the legacy `setContent` harness) so the
814
+ // iframe-load path never silently depends on a server-state file in cwd.
815
+ // Resolved ONCE here and threaded into every iframe load below.
816
+ async function runScenarioCheck(
817
+ config,
818
+ { preflight = assertAppPortReachable, harnessOrigin } = {},
819
+ ) {
820
+ const resolvedHarnessOrigin =
821
+ harnessOrigin !== undefined ? harnessOrigin : resolveHarnessOrigin();
351
822
  const { url, outputPath, width, height, httpMocks = {} } = config;
823
+ // Per-scenario capture-check allowances (see `CaptureAllowances` /
824
+ // `ScenarioDefinition` on the Rust side). Default false so the guards keep
825
+ // their strict behavior for every scenario that does not opt in.
826
+ const allowMinimalRender = !!(config && config.allowMinimalRender);
827
+ const expectedConsoleErrors = !!(config && config.expectedConsoleErrors);
828
+ let interactionEffect = null;
829
+ let interactionRetried = false;
830
+ // Non-fatal warnings raised while resolving interaction/flow targets — e.g. a
831
+ // substring text match that hit more than one element. Surfaced on the result
832
+ // so the agent sees the ambiguity instead of a silently-wrong first-match.
833
+ const interactionWarnings = [];
352
834
  const issues = [];
835
+ // Actionable set for self-healing: same-origin routes that returned 4xx with
836
+ // no scenario mock. A hermetic-proxy/upstream 404 is a *successful* HTTP
837
+ // response with status 404 (not a Playwright `requestfailed`), so the only
838
+ // place with method + path + status together is the `page.on("response")`
839
+ // collector below. De-duped by `"<METHOD> <path>"`; surfaced through
840
+ // `buildResult` so the Rust failure message can name each unmocked route and
841
+ // `stub-unmocked-routes` can add stub mocks for them.
842
+ const unmockedRoutes = [];
843
+ const unmockedRouteKeys = new Set();
844
+ // Origin of the captured page, used to classify failed requests: only a
845
+ // failure on the page's OWN origin should fail the capture (see
846
+ // `isCaptureFatalRequestFailure`). Derived from `config.url` exactly as
847
+ // `applyBrowserState` does; a malformed URL leaves it null, which keeps every
848
+ // request failure fatal (today's behavior).
849
+ let appOrigin = null;
850
+ try {
851
+ appOrigin = new URL(url).origin;
852
+ } catch (_) {
853
+ /* malformed capture URL — the guard no-ops and failures stay fatal */
854
+ }
353
855
  const browser = await launchChromiumWithSelfHeal();
354
856
  const contextOptions = {
355
857
  viewport: { width: width || 1440, height: height || 900 },
@@ -362,8 +864,54 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
362
864
  if (config.timezoneId) contextOptions.timezoneId = config.timezoneId;
363
865
  if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion;
364
866
  if (config.forcedColors) contextOptions.forcedColors = config.forcedColors;
867
+ // When the opt-in HTTPS preview (`proxy.httpsPreview`) is on, the capture
868
+ // origin is `https://localhost:<port>` served by the reverse proxy's
869
+ // self-signed cert. Accept it for capture only — gated on the https origin so
870
+ // plain-HTTP captures keep full TLS-error fidelity.
871
+ if (typeof appOrigin === "string" && appOrigin.startsWith("https://")) {
872
+ contextOptions.ignoreHTTPSErrors = true;
873
+ }
365
874
  const context = await browser.newContext(contextOptions);
366
875
 
876
+ // Authenticate the capture context to the editor's token-gated control-API
877
+ // BEFORE any navigation. On a non-loopback bind the harness route requires the
878
+ // session token; the browser UI carries it as a cookie automatically but the
879
+ // headless capturer does not, so inject it from the on-box token file. Scoped
880
+ // to the SAME resolved harness origin the capture navigates to: when no harness
881
+ // origin resolves (missing server-state, or a test that passes
882
+ // `harnessOrigin: null`) there is nothing to authenticate to, so no cookie is
883
+ // added. The `addCookies` guard keeps a mock context that doesn't implement it
884
+ // from throwing (a real Playwright context always has it).
885
+ const sessionCookies = buildSessionTokenCookies({
886
+ harnessOrigin: resolvedHarnessOrigin,
887
+ });
888
+ // Capability-skew guard. When the capture WILL use the token-gated harness
889
+ // route (a harness origin resolved) AND an on-box session token exists, but no
890
+ // `cy_session` cookie was produced, the token injection is broken — the
891
+ // generated `.codeyam/capture.js` is stale relative to the harness route it
892
+ // must authenticate to. Proceeding would hit a guaranteed 401 and strand every
893
+ // component capture on a `#scenario-frame` timeout. Fail loudly at the source
894
+ // instead, naming the version skew and its fix — closing the gap between the
895
+ // harness route (present) and the capture-side auth (absent).
896
+ if (
897
+ captureAuthSkewDetected({
898
+ harnessOrigin: resolvedHarnessOrigin,
899
+ cookieCount: sessionCookies.length,
900
+ tokenExists: onBoxSessionTokenExists(),
901
+ })
902
+ ) {
903
+ throw new Error(
904
+ `The capture harness origin ${resolvedHarnessOrigin} is token-gated and an ` +
905
+ `on-box \`.codeyam/session-token\` exists, but the capture produced no ` +
906
+ `\`cy_session\` cookie — the generated \`.codeyam/capture.js\` is stale ` +
907
+ `relative to the harness route (its session-token injection is missing or ` +
908
+ `broken). Regenerate the capture script / refresh the editor binary on this VM.`,
909
+ );
910
+ }
911
+ if (sessionCookies.length > 0 && typeof context.addCookies === "function") {
912
+ await context.addCookies(sessionCookies);
913
+ }
914
+
367
915
  // Apply the scenario's merged `browserState` (cookies + request
368
916
  // headers) to the capture context BEFORE the first navigation.
369
917
  // Belt-and-suspenders with the proxy's `Set-Cookie` injection: the
@@ -372,8 +920,11 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
372
920
  // not redirect to `/login` when the proxy is bypassed.
373
921
  await applyBrowserState(context, config);
374
922
 
375
- // Context-level init script runs in ALL frames (including cross-origin iframes)
376
- await context.addInitScript(getInitScript());
923
+ // Context-level init script runs in ALL frames (including cross-origin iframes).
924
+ // Keep the real WebSocket for scenarios that script a `/ws/terminal`
925
+ // transcript / WS stream so the scripted agent state reaches the capture;
926
+ // every other capture gets the reconnect-silencing stub.
927
+ await context.addInitScript(getInitScript(scenarioScriptsLiveSocket(config)));
377
928
 
378
929
  const page = await context.newPage();
379
930
  // Attach the network tracker BEFORE navigation so every request (the document
@@ -389,12 +940,66 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
389
940
 
390
941
  page.on("console", (message) => {
391
942
  const issue = handleConsoleMessage(message);
392
- if (issue) {
393
- pushIssue(issues, issue);
394
- }
943
+ if (!issue) return;
944
+ // Per-scenario allowance: a scenario that provokes console errors by design
945
+ // (a broken-image fallback whose `<img>` is meant to 404) opts in via
946
+ // `expectedConsoleErrors`. Load-bearing: without it the console-error guard
947
+ // fails the capture instead of screenshotting the intended fallback UI. A
948
+ // non-opted-in scenario still fails, so an unexpected console error is never
949
+ // silently tolerated.
950
+ if (expectedConsoleErrors) return;
951
+ // Console errors produced by the scenario's OWN declared error mocks
952
+ // (status >= 400) are the intended behavior of an error-state scenario,
953
+ // not a capture problem — skip them so "History - Load Error"-style
954
+ // scenarios can screenshot the failure UI they exist to demonstrate.
955
+ const sourceUrl = message.location && message.location().url;
956
+ if (sourceUrl && isDeclaredErrorMock(httpMocks, sourceUrl)) return;
957
+ // A console error originating from a cross-origin resource — the "Failed to
958
+ // load resource" Chromium logs alongside a cross-origin requestfailed (the
959
+ // down app dev port, an external CDN) — is an external-resource failure, not
960
+ // an editor-shell problem. Tolerate it for the same reason, and via the same
961
+ // predicate, as the requestfailed handler below.
962
+ if (sourceUrl && !isCaptureFatalRequestFailure(sourceUrl, appOrigin)) return;
963
+ pushIssue(issues, issue);
964
+ });
965
+
966
+ page.on("response", (response) => {
967
+ // Collect same-origin 4xx responses that no scenario mock covers — the set
968
+ // a stub mock would fix. Mirrors the console-guard predicates exactly so the
969
+ // reported set matches the guard's tolerances: a scenario that provokes
970
+ // errors by design (`expectedConsoleErrors`) reports none, and a route the
971
+ // scenario already declares an error mock for is the mock's intended 4xx,
972
+ // not a missing mock.
973
+ if (expectedConsoleErrors) return;
974
+ const req = response.request();
975
+ const reqUrl = req.url();
976
+ // Same-origin only — mirror the console/requestfailed origin gate so a
977
+ // cross-origin sub-resource 4xx is never reported as an unmocked route.
978
+ if (!isCaptureFatalRequestFailure(reqUrl, appOrigin)) return;
979
+ // The status-threshold, declared-mock, and path-extraction decision is the
980
+ // pure `unmockedRouteFrom` helper; the listener keeps only the stateful
981
+ // same-origin gate above and the dedup below.
982
+ const route = unmockedRouteFrom(req.method(), reqUrl, response.status(), httpMocks);
983
+ if (!route) return;
984
+ const key = `${route.method} ${route.path}`;
985
+ if (unmockedRouteKeys.has(key)) return;
986
+ unmockedRouteKeys.add(key);
987
+ unmockedRoutes.push(route);
395
988
  });
396
989
 
397
990
  page.on("requestfailed", (request) => {
991
+ // Per-scenario allowance: the broken-image fallback scenario's `<img>` 404
992
+ // is a SAME-origin request failure it exists to demonstrate, so
993
+ // `expectedConsoleErrors` tolerates it here too (the console guard above
994
+ // relaxes the paired "Failed to load resource" error). Load-bearing for the
995
+ // same reason; a non-opted-in same-origin failure still fails the capture.
996
+ if (expectedConsoleErrors) return;
997
+ // A cross-origin sub-resource failing must not fail an editor-shell
998
+ // screenshot — only the captured page's OWN origin counts. The most common
999
+ // case here is the live preview pane reaching the mocked project's app dev
1000
+ // port (e.g. http://localhost:3000), which is not running in the
1001
+ // self-hosting capture container.
1002
+ if (!isCaptureFatalRequestFailure(request.url(), appOrigin)) return;
398
1003
  const issue = handleRequestFailed(request);
399
1004
  if (issue) {
400
1005
  pushIssue(issues, issue);
@@ -410,13 +1015,18 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
410
1015
  // iframe harness for its background/sizing control. The backend signals
411
1016
  // the choice via `config.navigation` ("topLevel"); absent (the default)
412
1017
  // means the iframe harness, so existing callers are unchanged.
413
- const { frame, response } =
1018
+ const loadResult =
414
1019
  config.navigation === "topLevel"
415
1020
  ? await loadScenarioTopLevel(page, url, { preflight })
416
1021
  : await loadScenarioInIframe(page, url, {
417
1022
  background: config.iframeBackground,
418
1023
  preflight,
1024
+ harnessOrigin: resolvedHarnessOrigin,
419
1025
  });
1026
+ // `frame` is `let` so a `navigate` flow step (below) can re-point it at the
1027
+ // freshly-loaded route's content frame; `response` is the initial load only.
1028
+ let frame = loadResult.frame;
1029
+ const response = loadResult.response;
420
1030
  loaded = true;
421
1031
 
422
1032
  if (response && response.status() >= 400) {
@@ -427,6 +1037,21 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
427
1037
  status: response.status(),
428
1038
  }),
429
1039
  );
1040
+ } else if (!response && config.navigation === "topLevel") {
1041
+ // A real top-level document load always yields a response. A null one
1042
+ // means the status could never be checked — and an unverified status is
1043
+ // not a passing status: a 500 whose error body renders text would
1044
+ // otherwise satisfy `hasContent` and pass as `ok`. Fail loudly instead.
1045
+ // The iframe path stays tolerant: there the app response is matched by
1046
+ // exact URL and is legitimately allowed to miss.
1047
+ pushIssue(
1048
+ issues,
1049
+ createIssue(
1050
+ "navigation",
1051
+ "Navigation response unavailable — could not verify the HTTP status of the top-level document",
1052
+ { url },
1053
+ ),
1054
+ );
430
1055
  }
431
1056
 
432
1057
  pushRedirectMismatchIssue(issues, url, frame, response, config);
@@ -437,14 +1062,43 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
437
1062
  const loadingMarkers = Array.isArray(config.loadingMarkers)
438
1063
  ? config.loadingMarkers
439
1064
  : readStackLoadingMarkers();
440
- await waitForStablePage(page, frame, 10000, loadingMarkers);
1065
+ // Content-stability window. Defaults to 10s; the editor's own WS-driven
1066
+ // terminal/build route under self-hosting passes a longer `stableTimeoutMs`
1067
+ // (its network never goes idle, so the default expired before the WS replay
1068
+ // landed and the capture came back blank — VM1). Every other route omits the
1069
+ // field and keeps the 10s default.
1070
+ const stableTimeoutMs =
1071
+ typeof config.stableTimeoutMs === "number" && config.stableTimeoutMs > 0
1072
+ ? config.stableTimeoutMs
1073
+ : 10000;
1074
+ // Hydration wait cap. Mirrors `stableTimeoutMs`: defaults to 10s, but a slow
1075
+ // Turbopack cold-compile route can extend it via `config.hydrationTimeoutMs`
1076
+ // without a code change. Bounded and near-zero on a healthy page (attaches in
1077
+ // a poll or two); only a genuinely dead page pays the full cap.
1078
+ const hydrationTimeoutMs =
1079
+ typeof config.hydrationTimeoutMs === "number" &&
1080
+ config.hydrationTimeoutMs > 0
1081
+ ? config.hydrationTimeoutMs
1082
+ : 10000;
1083
+ const stableOutcome = await waitForStablePage(
1084
+ page,
1085
+ frame,
1086
+ stableTimeoutMs,
1087
+ loadingMarkers,
1088
+ );
441
1089
 
442
1090
  // DOM-stable does not mean done: a client-side data fetch can still be in
443
1091
  // flight (the loading skeleton cleared but its replacement content hasn't
444
1092
  // landed). Wait for the network to go quiet — bounded, so a streaming /
445
1093
  // long-poll endpoint that never idles caps out and captures anyway rather
446
1094
  // than hanging.
447
- await waitForNetworkQuiet(networkTracker);
1095
+ const networkOutcome = await waitForNetworkQuiet(networkTracker);
1096
+
1097
+ // If the page never settled (a loading marker outlasted the wait) or the
1098
+ // network never went quiet, the screenshot likely caught a client-fetched
1099
+ // page mid-load. Compute the non-blocking advisory now, while both settle
1100
+ // signals are in hand, and surface it on the capture-state report below.
1101
+ const settleAdvisory = buildSettleAdvisory(stableOutcome, networkOutcome);
448
1102
 
449
1103
  const rejectionMessages = await frame.evaluate(
450
1104
  () => window.__codeyamUnhandledRejections || [],
@@ -458,6 +1112,23 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
458
1112
  );
459
1113
  }
460
1114
 
1115
+ // Reveal-suppression fix: scroll-gated entrance animations (content held at
1116
+ // `opacity:0` until an IntersectionObserver fires on scroll) render blank in
1117
+ // isolation, where the app shell that wires those observers is absent.
1118
+ // Emulate reduced motion — so reduced-motion-aware stylesheets drop their
1119
+ // entrance animations and render the final state — and scroll the document
1120
+ // end-to-end to trip every observer, so the content reveals BEFORE we
1121
+ // measure and screenshot it, with no per-project force-reveal script. Both
1122
+ // are best-effort: a page/frame that cannot emulate or scroll (a
1123
+ // backend/static capture, a stubbed test target) is a no-op.
1124
+ if (typeof page.emulateMedia === "function") {
1125
+ await page.emulateMedia({ reducedMotion: "reduce" }).catch(() => {});
1126
+ }
1127
+ await scrollThroughDocument(frame).catch(() => {});
1128
+ if (typeof page.waitForTimeout === "function") {
1129
+ await page.waitForTimeout(REVEAL_SETTLE_MS);
1130
+ }
1131
+
461
1132
  // Cold-start retry: a single re-collect after BLANK_RETRY_DELAY_MS covers
462
1133
  // the React.lazy / Suspense-fallback race where waitForStablePage settles
463
1134
  // on the still-empty `<div id="root">` shell before the dynamic chunk
@@ -465,13 +1136,27 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
465
1136
  // chunk-load window; only a genuinely blank page falls through to the
466
1137
  // blank issue below.
467
1138
  let contentState = await collectContentState(frame);
1139
+ await mergeVisibleTextLength(contentState, frame);
468
1140
  let hasContent = hasRenderableContent(contentState);
469
1141
  if (!hasContent) {
470
1142
  await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS));
471
1143
  contentState = await collectContentState(frame);
1144
+ await mergeVisibleTextLength(contentState, frame);
472
1145
  hasContent = hasRenderableContent(contentState);
473
1146
  }
474
1147
 
1148
+ // Per-scenario allowance: a scenario whose intended UI is intrinsically
1149
+ // minimal (an empty `<textarea>` the blank heuristic can't "see") opts in
1150
+ // via `allowMinimalRender`. Accept the near-blank render as valid content so
1151
+ // BOTH the blank issue below is skipped AND `buildResult`'s `ok` (which
1152
+ // independently requires `hasContent`) can be true — otherwise the capture
1153
+ // would still fail with an empty issue list. The cold-start retry already
1154
+ // ran above, so a non-opted-in blank (a genuinely empty render) still falls
1155
+ // through to the issue below.
1156
+ if (!hasContent && allowMinimalRender) {
1157
+ hasContent = true;
1158
+ }
1159
+
475
1160
  if (!hasContent) {
476
1161
  pushIssue(
477
1162
  issues,
@@ -483,16 +1168,29 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
483
1168
  );
484
1169
  }
485
1170
 
486
- // Check for known error states in the rendered content
487
- const bodyText = await frame.evaluate(() => document.body?.innerText || "");
488
- const matchedPattern = findErrorPattern(bodyText);
489
- if (matchedPattern) {
490
- const contextSnippet = buildErrorContextSnippet(bodyText, matchedPattern);
1171
+ // Check for codeyam's scenario-renderer error fallback. We anchor on the
1172
+ // DOM marker the ScenarioRenderer stamps on its error / seed-error frames
1173
+ // rather than scanning the whole page body for ERROR_PATTERNS — a
1174
+ // legitimately-mocked scenario whose content quotes one of those harness
1175
+ // phrases must not be flagged. Apps with no codeyam ScenarioRenderer never
1176
+ // emit the marker, so their pages are treated as healthy regardless of body
1177
+ // text. The scan is scoped to the marked element's own text.
1178
+ const errorMarker = await frame.evaluate((attr) => {
1179
+ const el = document.querySelector(`[${attr}]`);
1180
+ if (!el) return null;
1181
+ return {
1182
+ reason: el.getAttribute(attr) || "",
1183
+ text: el.innerText || el.textContent || "",
1184
+ };
1185
+ }, SCENARIO_ERROR_MARKER);
1186
+ const scenarioError = findScenarioError(errorMarker);
1187
+ if (scenarioError) {
1188
+ const { matchedPattern, contextSnippet } = scenarioError;
491
1189
  pushIssue(
492
1190
  issues,
493
1191
  createIssue(
494
1192
  "error-state",
495
- `Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? bodyText.slice(0, 200)}`,
1193
+ `Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? ""}`,
496
1194
  {
497
1195
  url: page.url() || url,
498
1196
  matchedPattern,
@@ -508,21 +1206,130 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
508
1206
  // to run before the screenshot. Stack-gated and fail-safe — see
509
1207
  // scenario-interactivity.js — so backend / static / unknown-framework
510
1208
  // captures are an automatic pass.
511
- const hydrationIssue = await probeInteractivity(frame, {
1209
+ // `hydration.hydrated` is also consulted below to classify interactionEffect:
1210
+ // the wait runs BEFORE any interaction, which is exactly the reading we want
1211
+ // — "was this page alive when we found it," not "did our click revive it."
1212
+ //
1213
+ // WAIT for hydration (not just detect it): the one-shot probe read the
1214
+ // attachment state before React had a chance to hydrate SSR markup, so the
1215
+ // subsequent fill/click landed on inert controls and the submit handler
1216
+ // never fired — yet the run reported success. Waiting here gates the
1217
+ // screenshot AND every interaction branch below (steps / interaction /
1218
+ // interactions[] all run after this line) on the client runtime actually
1219
+ // attaching. Stack-gated and fail-safe: a timed-out wait yields
1220
+ // `hydrated: false` (so a truly dead page still classifies `unhydrated`),
1221
+ // and a non-interactive stack returns instantly.
1222
+ const hydration = await waitForHydration(frame, {
512
1223
  url: page.url() || url,
1224
+ timeoutMs: hydrationTimeoutMs,
1225
+ // `undefined` in production → waitForHydration reads .codeyam/stack.json;
1226
+ // a test injects a stack to force the interactive path without a real file.
1227
+ stack: config.stack,
513
1228
  });
514
- if (hydrationIssue) {
515
- pushIssue(issues, hydrationIssue);
1229
+ if (hydration.issue) {
1230
+ pushIssue(issues, hydration.issue);
516
1231
  }
517
1232
 
518
- // Drive the requested interaction (if any) against the settled frame,
519
- // then re-settle, so `preview-interact` captures the RESULT of a click /
520
- // fill / press (expanded accordion, open modal) without editing app
521
- // source. A no-match target throws here and is caught below as a failed
522
- // capture with the candidate-labels hint — never a silent blank shot.
523
- if (config.interaction) {
524
- await performInteraction(frame, config.interaction);
1233
+ // Assert the injected seed actually landed in the capture browser, at rest
1234
+ // and BEFORE any interaction can legitimately mutate storage. A non-empty
1235
+ // seed that didn't reach localStorage means the screenshot will show
1236
+ // default/empty state — fail loudly instead of emitting a misleading frame.
1237
+ const seedNotLandedIssue = await verifySeededStorageLanded(frame, config);
1238
+ if (seedNotLandedIssue) {
1239
+ pushIssue(issues, seedNotLandedIssue);
1240
+ }
1241
+
1242
+ // Scripted multi-step flow (`editor preview-flow`): drive an ordered
1243
+ // sequence of steps in THIS one browser session — so a behavioral
1244
+ // round-trip (click → observe → click) is captured as the real flow, not N
1245
+ // independent fresh-load snapshots. Each `capture` step writes a numbered
1246
+ // filmstrip frame; the others advance the same page. Mutually exclusive
1247
+ // with the single `interaction` path below. `frame` is reassigned so a
1248
+ // `navigate` step's new content frame backs the rest of the flow and the
1249
+ // final result URL.
1250
+ if (Array.isArray(config.steps) && config.steps.length > 0) {
1251
+ frame = await runFlowSteps(page, frame, config.steps, {
1252
+ url,
1253
+ loadingMarkers,
1254
+ navigation: config.navigation,
1255
+ iframeBackground: config.iframeBackground,
1256
+ preflight,
1257
+ harnessOrigin: resolvedHarnessOrigin,
1258
+ warnings: interactionWarnings,
1259
+ hydrationTimeoutMs,
1260
+ settleMs: config.settleMs,
1261
+ });
1262
+ } else if (config.interaction) {
1263
+ // Record fingerprint before interaction
1264
+ const beforeFingerprint = await getDOMFingerprint(frame);
1265
+
1266
+ // Drive the requested interaction (if any) against the settled frame,
1267
+ // then re-settle, so `preview-interact` captures the RESULT of a click /
1268
+ // fill / press (expanded accordion, open modal) without editing app
1269
+ // source. A no-match target throws here and is caught below as a failed
1270
+ // capture with the candidate-labels hint — never a silent blank shot.
1271
+ await performInteraction(frame, config.interaction, {
1272
+ warnings: interactionWarnings,
1273
+ });
525
1274
  await waitForStablePage(page, frame, 5000, loadingMarkers);
1275
+
1276
+ // Record fingerprint after interaction
1277
+ let afterFingerprint = await getDOMFingerprint(frame);
1278
+
1279
+ // If unchanged and it was a click, retry once after 500ms (covers the hydration race)
1280
+ if (beforeFingerprint === afterFingerprint && config.interaction.action === "click") {
1281
+ interactionRetried = true;
1282
+ await new Promise((resolve) => setTimeout(resolve, 500));
1283
+ // The retry re-resolves the same target; suppress its ambiguity warning
1284
+ // (already captured on the first attempt) to avoid a duplicate.
1285
+ await performInteraction(frame, config.interaction);
1286
+ await waitForStablePage(page, frame, 5000, loadingMarkers);
1287
+ afterFingerprint = await getDOMFingerprint(frame);
1288
+ }
1289
+
1290
+ interactionEffect = classifyInteractionEffect(
1291
+ beforeFingerprint,
1292
+ afterFingerprint,
1293
+ hydration.hydrated,
1294
+ );
1295
+ }
1296
+
1297
+ // Replay the scenario's PERSISTED interaction sequence (if any) in order,
1298
+ // settling between steps, so a declared interactive state — an expanded
1299
+ // section, a revealed hover bar, an open modal — is reproduced on every
1300
+ // capture and recapture, not just in a one-off `preview-interact`. A step
1301
+ // that matches nothing throws and is caught below as a failed capture, so a
1302
+ // sequence that didn't fully run never persists a resting-state screenshot.
1303
+ if (Array.isArray(config.interactions) && config.interactions.length > 0) {
1304
+ await performInteractionSequence(page, frame, config.interactions, {
1305
+ settleMs: 5000,
1306
+ loadingMarkers,
1307
+ warnings: interactionWarnings,
1308
+ });
1309
+ }
1310
+
1311
+ // Snap any remaining mid-flight CSS entrance animation to its final frame
1312
+ // for the static shot — but only when the scenario has NOT declared an
1313
+ // interactive state (a single `interaction`, a persisted `interactions`
1314
+ // sequence, or a multi-step `steps` flow), so an intentionally
1315
+ // animated/collapsed interactive frame is never clobbered by the force.
1316
+ const hasDeclaredInteractiveState =
1317
+ !!config.interaction ||
1318
+ (Array.isArray(config.interactions) && config.interactions.length > 0) ||
1319
+ (Array.isArray(config.steps) && config.steps.length > 0);
1320
+ if (outputPath && loaded && !hasDeclaredInteractiveState) {
1321
+ await forceFinalVisualState(frame).catch(() => {});
1322
+ }
1323
+
1324
+ // Center an isolated-component wrapper that is not already centered. The
1325
+ // shot below is full-viewport with no element clip, so an isolation page
1326
+ // that does not center itself lands in the top-left corner. Unlike
1327
+ // forceFinalVisualState this is NOT gated on hasDeclaredInteractiveState:
1328
+ // an isolation page with a declared interaction still needs to be centered,
1329
+ // and moving the wrapper does not clobber its contents the way
1330
+ // animation-forcing does — so a replayed interaction sequence stays valid.
1331
+ if (outputPath && loaded) {
1332
+ await centerCaptureWrapper(frame).catch(() => {});
526
1333
  }
527
1334
 
528
1335
  if (outputPath && loaded) {
@@ -536,13 +1343,34 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
536
1343
  issues,
537
1344
  outputPath,
538
1345
  url: frame.url() || url,
1346
+ unmockedRoutes,
539
1347
  });
540
1348
 
1349
+ if (config.interaction) {
1350
+ result.interactionEffect = interactionEffect;
1351
+ result.interactionRetried = interactionRetried;
1352
+ }
1353
+
1354
+ // Surface any non-fatal interaction/flow warnings (e.g. an ambiguous
1355
+ // substring text match) so the CLI can print them and the agent can switch
1356
+ // to a precise selector. Only attached when present — a clean run is
1357
+ // byte-for-byte unchanged.
1358
+ if (interactionWarnings.length > 0) {
1359
+ result.warnings = interactionWarnings.slice();
1360
+ }
1361
+
541
1362
  // `capture-state` mode: attach the read-only page-state snapshot so the
542
1363
  // backend can report localStorage + rendered text. Off by default, so a
543
1364
  // normal error-check capture is byte-for-byte unchanged.
544
1365
  if (config.captureState) {
545
1366
  result.state = await dumpPageState(frame, config.stateSelector);
1367
+ // A populated state that renders blank is exactly when an agent reaches
1368
+ // for capture-state, so bundle the client-fetch advisory with the dump it
1369
+ // explains. Only when the page didn't settle cleanly — the SSR /
1370
+ // props-driven happy path stays advisory-free.
1371
+ if (settleAdvisory) {
1372
+ result.state.advisories = [settleAdvisory];
1373
+ }
546
1374
  }
547
1375
 
548
1376
  return result;
@@ -558,6 +1386,7 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
558
1386
  issues,
559
1387
  outputPath,
560
1388
  url,
1389
+ unmockedRoutes,
561
1390
  });
562
1391
  } finally {
563
1392
  await browser.close();
@@ -585,15 +1414,29 @@ async function main() {
585
1414
 
586
1415
  module.exports = {
587
1416
  runScenarioCheck,
1417
+ classifyInteractionEffect,
1418
+ mergeVisibleTextLength,
1419
+ runFlowSteps,
588
1420
  dumpPageState,
1421
+ verifySeededStorageLanded,
589
1422
  readStackLoadingMarkers,
1423
+ scenarioScriptsLiveSocket,
590
1424
  applyBrowserState,
1425
+ buildSessionTokenCookies,
1426
+ captureAuthSkewDetected,
1427
+ SESSION_COOKIE,
591
1428
  isCrossOriginRequest,
1429
+ isCaptureFatalRequestFailure,
592
1430
  stripMarkerHeaders,
593
1431
  main,
594
1432
  launchChromiumWithSelfHeal,
1433
+ isTransientLaunchCrash,
1434
+ CAPTURE_LAUNCH_ARGS,
1435
+ CAPTURE_HOST_RESOLVER_RULES,
1436
+ CAPTURE_LAUNCH_SIGSEGV_RETRIES,
595
1437
  PLAYWRIGHT_INSTALL_COMMAND,
596
1438
  PLAYWRIGHT_MISSING_BROWSER_PATTERN,
1439
+ PLAYWRIGHT_LAUNCH_CRASH_PATTERNS,
597
1440
  };
598
1441
 
599
1442
  if (require.main === module) {