@codeyam-editor/codeyam-editor 0.1.3 → 0.1.5

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 (66) hide show
  1. package/npm/cloud-branch-switch.js +1 -1
  2. package/npm/cloud.js +1693 -96
  3. package/npm/container.js +14 -0
  4. package/npm/disk-pressure.js +167 -0
  5. package/npm/editor-dev.js +107 -15
  6. package/npm/editor.js +97 -37
  7. package/npm/fleet-auth-health.js +75 -0
  8. package/npm/fleet-auth.js +6 -1
  9. package/npm/fleet-build-data.js +130 -0
  10. package/npm/fleet-cleanup.js +145 -7
  11. package/npm/fleet-destroy-safety.js +55 -0
  12. package/npm/fleet-effect-status.js +33 -32
  13. package/npm/fleet-host.js +74 -0
  14. package/npm/fleet-job-badge.js +46 -2
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +231 -0
  18. package/npm/fleet-prewarm.js +165 -0
  19. package/npm/fleet-public-url.js +160 -0
  20. package/npm/fleet-queue.js +38 -1
  21. package/npm/fleet-reconcile.js +100 -1
  22. package/npm/fleet-reset.js +298 -13
  23. package/npm/fleet-roster.js +6 -8
  24. package/npm/fleet-self-update.js +142 -0
  25. package/npm/fleet-setup-steps.js +105 -0
  26. package/npm/fleet-sleep-status.js +33 -0
  27. package/npm/fleet-strand.js +150 -0
  28. package/npm/fleet-stuck-status.js +111 -0
  29. package/npm/fleet-vm-access.js +234 -0
  30. package/npm/fleet-vm-readiness.js +104 -0
  31. package/npm/fleet-vm-slugs.js +116 -0
  32. package/npm/google-ingress.js +211 -0
  33. package/npm/ingress-invoke.js +292 -0
  34. package/npm/launch-config.js +310 -4
  35. package/npm/scenario-check.js +684 -33
  36. package/npm/scenario-handlers.js +94 -12
  37. package/npm/scenario-metrics.js +116 -1
  38. package/npm/scenario-mocks.js +20 -0
  39. package/npm/scenario-playwright.js +366 -29
  40. package/npm/script-drift.js +81 -0
  41. package/npm/utils.js +453 -43
  42. package/npm/vm-urls.js +69 -8
  43. package/package.json +5 -5
  44. package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
  45. package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
  46. package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
  47. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
  48. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
  49. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
  50. package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
  51. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
  52. package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
  53. package/ui/dist/assets/index-CIXZkwfB.css +1 -0
  54. package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
  55. package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
  56. package/ui/dist/codeyam-mark.svg +4 -0
  57. package/ui/dist/codeyam-wordmark.svg +5 -0
  58. package/ui/dist/index.html +2 -2
  59. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  60. package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
  61. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
  66. 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,17 +160,24 @@ const {
71
160
 
72
161
  const {
73
162
  attachHttpMocks,
163
+ isDeclaredErrorMock,
74
164
  } = require("./scenario-mocks");
75
165
 
76
166
  const {
77
167
  assertAppPortReachable,
78
168
  loadScenarioInIframe,
79
169
  loadScenarioTopLevel,
170
+ resolveHarnessOrigin,
80
171
  waitForStablePage,
81
172
  createNetworkTracker,
82
173
  waitForNetworkQuiet,
83
174
  collectContentState,
175
+ scrollThroughDocument,
176
+ collectVisibleTextLength,
177
+ forceFinalVisualState,
84
178
  performInteraction,
179
+ waitForPredicate,
180
+ performInteractionSequence,
85
181
  } = require("./scenario-playwright");
86
182
 
87
183
  const {
@@ -116,6 +212,49 @@ function readStackLoadingMarkers() {
116
212
  }
117
213
  }
118
214
 
215
+ // True when the scenario being captured scripts a `/ws/terminal` transcript or
216
+ // a WebSocket stream. Such captures need the REAL `WebSocket` so the server can
217
+ // replay the scripted agent state into the frame — `getInitScript` keeps its
218
+ // reconnect-silencing stub OFF for them (see the comment there). Read from the
219
+ // scenario file by slug (`config.scenarioId`), the same cwd-relative path
220
+ // `readStackLoadingMarkers` uses. Defaults to `false` (stub stays on, today's
221
+ // behavior) for the scenario-less CLI preview path or any unreadable/parse
222
+ // failure, so a missing file never accidentally un-stubs a live terminal.
223
+ function scenarioScriptsLiveSocket(config) {
224
+ const slug = config && config.scenarioId;
225
+ if (!slug) return false;
226
+ try {
227
+ const raw = fs.readFileSync(
228
+ path.join(".codeyam", "scenarios", `${slug}.json`),
229
+ "utf8",
230
+ );
231
+ const mocks = (JSON.parse(raw) || {}).mocks || {};
232
+ const transcripts = mocks.transcripts || {};
233
+ const streams = mocks.streams || {};
234
+ return Object.keys(transcripts).length > 0 || Object.keys(streams).length > 0;
235
+ } catch (_) {
236
+ return false;
237
+ }
238
+ }
239
+
240
+ async function getDOMFingerprint(frame) {
241
+ try {
242
+ return await frame.evaluate(() => {
243
+ const body = document.body;
244
+ if (!body) return "";
245
+ const html = body.innerHTML;
246
+ let hash = 0;
247
+ for (let i = 0; i < html.length; i++) {
248
+ hash = (hash << 5) - hash + html.charCodeAt(i);
249
+ hash |= 0;
250
+ }
251
+ return `${html.length}-${hash}`;
252
+ });
253
+ } catch (err) {
254
+ return "";
255
+ }
256
+ }
257
+
119
258
  // Cold-start retry pause. waitForStablePage settles as soon as the page is
120
259
  // HTML-stable, which for a lazy/Suspense app is the empty `<div id="root">`
121
260
  // shell — stable for the ~3s the dynamic chunk takes to load (longer when the
@@ -124,6 +263,33 @@ function readStackLoadingMarkers() {
124
263
  // while staying under the test runner's default per-case timeout.
125
264
  const BLANK_RETRY_DELAY_MS = 3000;
126
265
 
266
+ // Pause after scrolling the document to trip scroll-gated reveal observers, so
267
+ // the IntersectionObserver callbacks fire and any opacity/transform entrance
268
+ // transition begins before we measure visible content and screenshot. Driven
269
+ // through `page.waitForTimeout` so it is a real wait in Playwright but instant
270
+ // against the stubbed pages in unit tests.
271
+ const REVEAL_SETTLE_MS = 600;
272
+
273
+ // Measure the frame's visible (painted, non-opacity:0) text and fold it into
274
+ // the content state in place, so the blank gate can distinguish "text rendered
275
+ // but invisible" from a populated frame. Best-effort: a frame/target that
276
+ // cannot be measured (a stubbed test target returns a non-number, an evaluate
277
+ // throws) leaves the state untouched, so `hasRenderableContent` falls back to
278
+ // its DOM-presence behavior rather than treating an un-measurable frame as
279
+ // blank.
280
+ async function mergeVisibleTextLength(contentState, frame) {
281
+ if (!contentState) return;
282
+ let visible;
283
+ try {
284
+ visible = await collectVisibleTextLength(frame);
285
+ } catch (_) {
286
+ return;
287
+ }
288
+ if (typeof visible === "number") {
289
+ contentState.visibleTextLength = visible;
290
+ }
291
+ }
292
+
127
293
  // True when `url` targets a different origin than `appOrigin`. Used to decide
128
294
  // whether the codeyam capture markers must be stripped before the request
129
295
  // leaves (cross-origin) or may ride along (same-origin, the app's own dev
@@ -139,6 +305,21 @@ function isCrossOriginRequest(url, appOrigin) {
139
305
  }
140
306
  }
141
307
 
308
+ // Decide whether a failed request should fail the capture. Only a failure
309
+ // targeting the captured page's OWN origin counts — a first-party route that
310
+ // 500s or a same-origin asset that 404s is a real capture problem. A
311
+ // cross-origin failure (the mocked app's dev port being down, an external
312
+ // CDN/API) is an external resource and is tolerated, so the editor-shell
313
+ // screenshot still succeeds. Mirrors the console handler's tolerance for
314
+ // declared error-mock URLs. When `appOrigin` is unknown (malformed capture
315
+ // URL) the failure stays fatal — the conservative default preserves today's
316
+ // behavior rather than silently swallowing every failure. Pure, so the
317
+ // decision is unit-testable without a live browser.
318
+ function isCaptureFatalRequestFailure(url, appOrigin) {
319
+ if (!appOrigin) return true;
320
+ return !isCrossOriginRequest(url, appOrigin);
321
+ }
322
+
142
323
  // Return a copy of `headers` with every name in `markerNames` removed.
143
324
  // Names are matched case-insensitively against the (lowercased) header keys
144
325
  // Playwright reports. Pure — never mutates its input — so unrelated headers
@@ -235,6 +416,37 @@ async function applyBrowserState(context, config) {
235
416
  },
236
417
  );
237
418
  }
419
+
420
+ // Seed the scenario's `browserState.localStorage` / `.sessionStorage` into
421
+ // the page before any app JS runs. Storage-gated UI (first-run banners,
422
+ // dismissed-prompt flags, persisted view state) is otherwise
423
+ // uncontrollable at capture time. Playwright serializes the function and
424
+ // its arg into the page context, so the function body must not close over
425
+ // outer variables. Only registered when the scenario actually carries
426
+ // storage, preserving the pre-storage capture context for everyone else.
427
+ const localStorageSeed = state.localStorage || {};
428
+ const sessionStorageSeed = state.sessionStorage || {};
429
+ if (
430
+ Object.keys(localStorageSeed).length > 0 ||
431
+ Object.keys(sessionStorageSeed).length > 0
432
+ ) {
433
+ await context.addInitScript(
434
+ (storage) => {
435
+ try {
436
+ for (const [key, value] of Object.entries(storage.local)) {
437
+ window.localStorage.setItem(key, value);
438
+ }
439
+ for (const [key, value] of Object.entries(storage.session)) {
440
+ window.sessionStorage.setItem(key, value);
441
+ }
442
+ } catch (_) {
443
+ // Storage unavailable (sandboxed/opaque origin) — the seed is
444
+ // best-effort; never fail the capture over it.
445
+ }
446
+ },
447
+ { local: localStorageSeed, session: sessionStorageSeed },
448
+ );
449
+ }
238
450
  }
239
451
 
240
452
  // Emit a `redirect-mismatch` issue when the final iframe URL's path
@@ -275,11 +487,240 @@ function pushRedirectMismatchIssue(issues, requestedUrl, frame, response, config
275
487
  );
276
488
  }
277
489
 
490
+ // Read-only page-state snapshot for `capture-state`: the full localStorage
491
+ // map, a bounded sample of visible text nodes (document order), and — when a
492
+ // selector is given — that element's text. Evaluated in-page against the
493
+ // settled frame so it reflects exactly what a real capture saw (the proxy
494
+ // already injected the scenario's seed into the served HTML). Every read is
495
+ // individually guarded so a sandboxed/cross-origin localStorage never throws
496
+ // the whole capture; the worst case is an empty section, not a failure.
497
+ async function dumpPageState(frame, selector) {
498
+ return frame.evaluate((sel) => {
499
+ const localStorage = {};
500
+ try {
501
+ for (let i = 0; i < window.localStorage.length; i++) {
502
+ const key = window.localStorage.key(i);
503
+ if (key != null) localStorage[key] = window.localStorage.getItem(key);
504
+ }
505
+ } catch (_) {
506
+ /* localStorage may be unavailable (sandboxed/opaque origin) */
507
+ }
508
+
509
+ const visibleText = [];
510
+ try {
511
+ // Reject text inside non-rendered tags (SCRIPT/STYLE/etc.) so an
512
+ // injected proxy script or inline CSS never masquerades as on-screen
513
+ // text — that noise is exactly what makes a state dump misleading.
514
+ const SKIP_TAGS = new Set([
515
+ "SCRIPT",
516
+ "STYLE",
517
+ "NOSCRIPT",
518
+ "TEMPLATE",
519
+ "HEAD",
520
+ "TITLE",
521
+ ]);
522
+ const walker = document.createTreeWalker(
523
+ document.body,
524
+ NodeFilter.SHOW_TEXT,
525
+ {
526
+ acceptNode(node) {
527
+ const parent = node.parentElement;
528
+ if (parent && SKIP_TAGS.has(parent.tagName)) {
529
+ return NodeFilter.FILTER_REJECT;
530
+ }
531
+ return (node.textContent || "").trim()
532
+ ? NodeFilter.FILTER_ACCEPT
533
+ : NodeFilter.FILTER_REJECT;
534
+ },
535
+ },
536
+ );
537
+ let node;
538
+ while ((node = walker.nextNode()) && visibleText.length < 40) {
539
+ const text = (node.textContent || "").replace(/\s+/g, " ").trim();
540
+ if (text) visibleText.push(text);
541
+ }
542
+ } catch (_) {
543
+ /* no body / detached document */
544
+ }
545
+
546
+ let selectorText = null;
547
+ if (sel) {
548
+ try {
549
+ const el = document.querySelector(sel);
550
+ if (el) selectorText = (el.textContent || "").replace(/\s+/g, " ").trim();
551
+ } catch (_) {
552
+ /* invalid selector — leave selectorText null */
553
+ }
554
+ }
555
+
556
+ return { localStorage, visibleText, selectorText };
557
+ }, selector || null);
558
+ }
559
+
560
+ // Landed-state verification: assert the localStorage the capture INJECTED
561
+ // (`config.browserState.localStorage`, which already carries the seed-session
562
+ // overlay the editor merged in) actually reached the capture browser after the
563
+ // page settled. The core promise of a seeded scenario is remote control of the
564
+ // app — a seed that silently doesn't land produces an empty screenshot that
565
+ // looks like a successful capture of an empty app, which is exactly the
566
+ // failure this guards. Returns a loud `seed-not-landed` issue (which fails the
567
+ // capture, since `ok` requires zero issues) when a non-empty injected seed is
568
+ // missing/empty on read-back, or `null` when there was nothing to verify, the
569
+ // seed landed, or storage is unavailable (sandboxed/opaque origin — never fail
570
+ // the capture over the verifier itself).
571
+ async function verifySeededStorageLanded(frame, config) {
572
+ const expected =
573
+ (config && config.browserState && config.browserState.localStorage) || {};
574
+ const expectedKeys = Object.keys(expected);
575
+ if (expectedKeys.length === 0) return null; // nothing was seeded into storage
576
+
577
+ let readback;
578
+ try {
579
+ readback = await frame.evaluate((keys) => {
580
+ try {
581
+ const allKeys = [];
582
+ for (let i = 0; i < window.localStorage.length; i++) {
583
+ const k = window.localStorage.key(i);
584
+ if (k != null) allKeys.push(k);
585
+ }
586
+ const present = {};
587
+ for (const k of keys) {
588
+ const v = window.localStorage.getItem(k);
589
+ present[k] = v != null && v !== "";
590
+ }
591
+ return { present, allKeys };
592
+ } catch (_) {
593
+ // localStorage unavailable (sandboxed/opaque origin) — can't verify.
594
+ return null;
595
+ }
596
+ }, expectedKeys);
597
+ } catch (_) {
598
+ return null; // evaluate failed — never fail a capture over the verifier.
599
+ }
600
+ if (!readback) return null; // storage unavailable — best-effort, don't fail.
601
+
602
+ const missing = expectedKeys.filter((k) => !readback.present[k]);
603
+ if (missing.length === 0) return null; // every seeded key landed — success.
604
+
605
+ const actual = readback.allKeys.length ? readback.allKeys.join(", ") : "<none>";
606
+ return createIssue(
607
+ "seed-not-landed",
608
+ `Seeded localStorage did not reach the capture browser: expected non-empty ` +
609
+ `keys [${expectedKeys.join(", ")}] but [${missing.join(", ")}] are ` +
610
+ `missing/empty after load (browser localStorage holds: [${actual}]). The ` +
611
+ `seed did NOT land, so this screenshot shows DEFAULT/EMPTY state — fix the ` +
612
+ `seed; do not delete the scenario. Likely causes: (1) the seed-session ` +
613
+ `overlay was stale or empty (re-run the seed adapter to refresh it), (2) the ` +
614
+ `adapter's stdout localStorage map failed to parse, or (3) the injection ` +
615
+ `path is down (the seeded origin differs from the captured page's origin).`,
616
+ { url: (frame && frame.url && frame.url()) || (config && config.url) },
617
+ );
618
+ }
619
+
620
+ // Drive an ordered list of flow steps against ONE already-loaded browser
621
+ // session so a scripted multi-step demo (`editor preview-flow`) is captured as
622
+ // the real round-trip — click state and client transients persist across
623
+ // steps, which N independent fresh-load captures could never reproduce. Each
624
+ // step is one of:
625
+ // - navigate: re-load a route (resolved relative to the initial url) using
626
+ // the same loader strategy as the initial load, then re-settle. Returns
627
+ // the new content frame so subsequent steps target the navigated page.
628
+ // - click / fill / press: a `performInteraction` against the current frame,
629
+ // then re-settle.
630
+ // - waitFor: hold until a visible-text / selector predicate (bounded).
631
+ // - capture: write a numbered filmstrip frame to the step's `outputPath`.
632
+ // A failing step THROWS with its 1-based index and action, so the outer catch
633
+ // in `runScenarioCheck` reports exactly which step broke (and, for waitFor,
634
+ // the predicate that never appeared) instead of a silent blank capture.
635
+ // Returns the frame the flow ended on, so the caller's final screenshot and
636
+ // result URL reflect the last navigated route.
637
+ async function runFlowSteps(page, initialFrame, steps, ctx) {
638
+ const { url, loadingMarkers, navigation, iframeBackground, preflight, harnessOrigin } =
639
+ ctx;
640
+ let frame = initialFrame;
641
+
642
+ for (let i = 0; i < steps.length; i++) {
643
+ const step = steps[i] || {};
644
+ const n = i + 1;
645
+ try {
646
+ switch (step.action) {
647
+ case "navigate": {
648
+ const target = new URL(step.path, url).href;
649
+ const loadResult =
650
+ navigation === "topLevel"
651
+ ? await loadScenarioTopLevel(page, target, { preflight })
652
+ : await loadScenarioInIframe(page, target, {
653
+ background: iframeBackground,
654
+ preflight,
655
+ harnessOrigin,
656
+ });
657
+ frame = loadResult.frame;
658
+ await waitForStablePage(page, frame, 10000, loadingMarkers);
659
+ break;
660
+ }
661
+ case "click":
662
+ case "fill":
663
+ case "press":
664
+ await performInteraction(frame, step);
665
+ await waitForStablePage(page, frame, 5000, loadingMarkers);
666
+ break;
667
+ case "waitFor":
668
+ await waitForPredicate(frame, step);
669
+ break;
670
+ case "capture":
671
+ if (step.outputPath) {
672
+ fs.mkdirSync(path.dirname(step.outputPath), { recursive: true });
673
+ await page.screenshot({ path: step.outputPath, fullPage: false });
674
+ }
675
+ break;
676
+ default:
677
+ throw new Error(
678
+ `unknown step action "${step.action}" (expected navigate | click | fill | press | waitFor | capture)`,
679
+ );
680
+ }
681
+ } catch (error) {
682
+ throw new Error(
683
+ `flow step ${n} (${step.action}) failed: ${error.message || String(error)}`,
684
+ );
685
+ }
686
+ }
687
+
688
+ return frame;
689
+ }
690
+
278
691
  // `preflight` is injectable (defaulting to the real app-port reachability
279
692
  // check) so unit tests that mock the browser can stay network-free.
280
- async function runScenarioCheck(config, { preflight = assertAppPortReachable } = {}) {
693
+ // `harnessOrigin` is likewise injectable: it defaults to resolving the editor's
694
+ // harness origin from `.codeyam/server-state.json`, but a test passes an
695
+ // explicit value (e.g. `null` to force the legacy `setContent` harness) so the
696
+ // iframe-load path never silently depends on a server-state file in cwd.
697
+ // Resolved ONCE here and threaded into every iframe load below.
698
+ async function runScenarioCheck(
699
+ config,
700
+ { preflight = assertAppPortReachable, harnessOrigin } = {},
701
+ ) {
702
+ const resolvedHarnessOrigin =
703
+ harnessOrigin !== undefined ? harnessOrigin : resolveHarnessOrigin();
281
704
  const { url, outputPath, width, height, httpMocks = {} } = config;
705
+ // Per-scenario capture-check allowances (see `CaptureAllowances` /
706
+ // `ScenarioDefinition` on the Rust side). Default false so the guards keep
707
+ // their strict behavior for every scenario that does not opt in.
708
+ const allowMinimalRender = !!(config && config.allowMinimalRender);
709
+ const expectedConsoleErrors = !!(config && config.expectedConsoleErrors);
710
+ let interactionEffect = null;
711
+ let interactionRetried = false;
282
712
  const issues = [];
713
+ // Origin of the captured page, used to classify failed requests: only a
714
+ // failure on the page's OWN origin should fail the capture (see
715
+ // `isCaptureFatalRequestFailure`). Derived from `config.url` exactly as
716
+ // `applyBrowserState` does; a malformed URL leaves it null, which keeps every
717
+ // request failure fatal (today's behavior).
718
+ let appOrigin = null;
719
+ try {
720
+ appOrigin = new URL(url).origin;
721
+ } catch (_) {
722
+ /* malformed capture URL — the guard no-ops and failures stay fatal */
723
+ }
283
724
  const browser = await launchChromiumWithSelfHeal();
284
725
  const contextOptions = {
285
726
  viewport: { width: width || 1440, height: height || 900 },
@@ -292,6 +733,13 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
292
733
  if (config.timezoneId) contextOptions.timezoneId = config.timezoneId;
293
734
  if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion;
294
735
  if (config.forcedColors) contextOptions.forcedColors = config.forcedColors;
736
+ // When the opt-in HTTPS preview (`proxy.httpsPreview`) is on, the capture
737
+ // origin is `https://localhost:<port>` served by the reverse proxy's
738
+ // self-signed cert. Accept it for capture only — gated on the https origin so
739
+ // plain-HTTP captures keep full TLS-error fidelity.
740
+ if (typeof appOrigin === "string" && appOrigin.startsWith("https://")) {
741
+ contextOptions.ignoreHTTPSErrors = true;
742
+ }
295
743
  const context = await browser.newContext(contextOptions);
296
744
 
297
745
  // Apply the scenario's merged `browserState` (cookies + request
@@ -302,8 +750,11 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
302
750
  // not redirect to `/login` when the proxy is bypassed.
303
751
  await applyBrowserState(context, config);
304
752
 
305
- // Context-level init script runs in ALL frames (including cross-origin iframes)
306
- await context.addInitScript(getInitScript());
753
+ // Context-level init script runs in ALL frames (including cross-origin iframes).
754
+ // Keep the real WebSocket for scenarios that script a `/ws/terminal`
755
+ // transcript / WS stream so the scripted agent state reaches the capture;
756
+ // every other capture gets the reconnect-silencing stub.
757
+ await context.addInitScript(getInitScript(scenarioScriptsLiveSocket(config)));
307
758
 
308
759
  const page = await context.newPage();
309
760
  // Attach the network tracker BEFORE navigation so every request (the document
@@ -319,12 +770,42 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
319
770
 
320
771
  page.on("console", (message) => {
321
772
  const issue = handleConsoleMessage(message);
322
- if (issue) {
323
- pushIssue(issues, issue);
324
- }
773
+ if (!issue) return;
774
+ // Per-scenario allowance: a scenario that provokes console errors by design
775
+ // (a broken-image fallback whose `<img>` is meant to 404) opts in via
776
+ // `expectedConsoleErrors`. Load-bearing: without it the console-error guard
777
+ // fails the capture instead of screenshotting the intended fallback UI. A
778
+ // non-opted-in scenario still fails, so an unexpected console error is never
779
+ // silently tolerated.
780
+ if (expectedConsoleErrors) return;
781
+ // Console errors produced by the scenario's OWN declared error mocks
782
+ // (status >= 400) are the intended behavior of an error-state scenario,
783
+ // not a capture problem — skip them so "History - Load Error"-style
784
+ // scenarios can screenshot the failure UI they exist to demonstrate.
785
+ const sourceUrl = message.location && message.location().url;
786
+ if (sourceUrl && isDeclaredErrorMock(httpMocks, sourceUrl)) return;
787
+ // A console error originating from a cross-origin resource — the "Failed to
788
+ // load resource" Chromium logs alongside a cross-origin requestfailed (the
789
+ // down app dev port, an external CDN) — is an external-resource failure, not
790
+ // an editor-shell problem. Tolerate it for the same reason, and via the same
791
+ // predicate, as the requestfailed handler below.
792
+ if (sourceUrl && !isCaptureFatalRequestFailure(sourceUrl, appOrigin)) return;
793
+ pushIssue(issues, issue);
325
794
  });
326
795
 
327
796
  page.on("requestfailed", (request) => {
797
+ // Per-scenario allowance: the broken-image fallback scenario's `<img>` 404
798
+ // is a SAME-origin request failure it exists to demonstrate, so
799
+ // `expectedConsoleErrors` tolerates it here too (the console guard above
800
+ // relaxes the paired "Failed to load resource" error). Load-bearing for the
801
+ // same reason; a non-opted-in same-origin failure still fails the capture.
802
+ if (expectedConsoleErrors) return;
803
+ // A cross-origin sub-resource failing must not fail an editor-shell
804
+ // screenshot — only the captured page's OWN origin counts. The most common
805
+ // case here is the live preview pane reaching the mocked project's app dev
806
+ // port (e.g. http://localhost:3000), which is not running in the
807
+ // self-hosting capture container.
808
+ if (!isCaptureFatalRequestFailure(request.url(), appOrigin)) return;
328
809
  const issue = handleRequestFailed(request);
329
810
  if (issue) {
330
811
  pushIssue(issues, issue);
@@ -340,13 +821,18 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
340
821
  // iframe harness for its background/sizing control. The backend signals
341
822
  // the choice via `config.navigation` ("topLevel"); absent (the default)
342
823
  // means the iframe harness, so existing callers are unchanged.
343
- const { frame, response } =
824
+ const loadResult =
344
825
  config.navigation === "topLevel"
345
826
  ? await loadScenarioTopLevel(page, url, { preflight })
346
827
  : await loadScenarioInIframe(page, url, {
347
828
  background: config.iframeBackground,
348
829
  preflight,
830
+ harnessOrigin: resolvedHarnessOrigin,
349
831
  });
832
+ // `frame` is `let` so a `navigate` flow step (below) can re-point it at the
833
+ // freshly-loaded route's content frame; `response` is the initial load only.
834
+ let frame = loadResult.frame;
835
+ const response = loadResult.response;
350
836
  loaded = true;
351
837
 
352
838
  if (response && response.status() >= 400) {
@@ -367,14 +853,34 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
367
853
  const loadingMarkers = Array.isArray(config.loadingMarkers)
368
854
  ? config.loadingMarkers
369
855
  : readStackLoadingMarkers();
370
- await waitForStablePage(page, frame, 10000, loadingMarkers);
856
+ // Content-stability window. Defaults to 10s; the editor's own WS-driven
857
+ // terminal/build route under self-hosting passes a longer `stableTimeoutMs`
858
+ // (its network never goes idle, so the default expired before the WS replay
859
+ // landed and the capture came back blank — VM1). Every other route omits the
860
+ // field and keeps the 10s default.
861
+ const stableTimeoutMs =
862
+ typeof config.stableTimeoutMs === "number" && config.stableTimeoutMs > 0
863
+ ? config.stableTimeoutMs
864
+ : 10000;
865
+ const stableOutcome = await waitForStablePage(
866
+ page,
867
+ frame,
868
+ stableTimeoutMs,
869
+ loadingMarkers,
870
+ );
371
871
 
372
872
  // DOM-stable does not mean done: a client-side data fetch can still be in
373
873
  // flight (the loading skeleton cleared but its replacement content hasn't
374
874
  // landed). Wait for the network to go quiet — bounded, so a streaming /
375
875
  // long-poll endpoint that never idles caps out and captures anyway rather
376
876
  // than hanging.
377
- await waitForNetworkQuiet(networkTracker);
877
+ const networkOutcome = await waitForNetworkQuiet(networkTracker);
878
+
879
+ // If the page never settled (a loading marker outlasted the wait) or the
880
+ // network never went quiet, the screenshot likely caught a client-fetched
881
+ // page mid-load. Compute the non-blocking advisory now, while both settle
882
+ // signals are in hand, and surface it on the capture-state report below.
883
+ const settleAdvisory = buildSettleAdvisory(stableOutcome, networkOutcome);
378
884
 
379
885
  const rejectionMessages = await frame.evaluate(
380
886
  () => window.__codeyamUnhandledRejections || [],
@@ -388,6 +894,23 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
388
894
  );
389
895
  }
390
896
 
897
+ // Reveal-suppression fix: scroll-gated entrance animations (content held at
898
+ // `opacity:0` until an IntersectionObserver fires on scroll) render blank in
899
+ // isolation, where the app shell that wires those observers is absent.
900
+ // Emulate reduced motion — so reduced-motion-aware stylesheets drop their
901
+ // entrance animations and render the final state — and scroll the document
902
+ // end-to-end to trip every observer, so the content reveals BEFORE we
903
+ // measure and screenshot it, with no per-project force-reveal script. Both
904
+ // are best-effort: a page/frame that cannot emulate or scroll (a
905
+ // backend/static capture, a stubbed test target) is a no-op.
906
+ if (typeof page.emulateMedia === "function") {
907
+ await page.emulateMedia({ reducedMotion: "reduce" }).catch(() => {});
908
+ }
909
+ await scrollThroughDocument(frame).catch(() => {});
910
+ if (typeof page.waitForTimeout === "function") {
911
+ await page.waitForTimeout(REVEAL_SETTLE_MS);
912
+ }
913
+
391
914
  // Cold-start retry: a single re-collect after BLANK_RETRY_DELAY_MS covers
392
915
  // the React.lazy / Suspense-fallback race where waitForStablePage settles
393
916
  // on the still-empty `<div id="root">` shell before the dynamic chunk
@@ -395,13 +918,27 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
395
918
  // chunk-load window; only a genuinely blank page falls through to the
396
919
  // blank issue below.
397
920
  let contentState = await collectContentState(frame);
921
+ await mergeVisibleTextLength(contentState, frame);
398
922
  let hasContent = hasRenderableContent(contentState);
399
923
  if (!hasContent) {
400
924
  await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS));
401
925
  contentState = await collectContentState(frame);
926
+ await mergeVisibleTextLength(contentState, frame);
402
927
  hasContent = hasRenderableContent(contentState);
403
928
  }
404
929
 
930
+ // Per-scenario allowance: a scenario whose intended UI is intrinsically
931
+ // minimal (an empty `<textarea>` the blank heuristic can't "see") opts in
932
+ // via `allowMinimalRender`. Accept the near-blank render as valid content so
933
+ // BOTH the blank issue below is skipped AND `buildResult`'s `ok` (which
934
+ // independently requires `hasContent`) can be true — otherwise the capture
935
+ // would still fail with an empty issue list. The cold-start retry already
936
+ // ran above, so a non-opted-in blank (a genuinely empty render) still falls
937
+ // through to the issue below.
938
+ if (!hasContent && allowMinimalRender) {
939
+ hasContent = true;
940
+ }
941
+
405
942
  if (!hasContent) {
406
943
  pushIssue(
407
944
  issues,
@@ -413,16 +950,29 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
413
950
  );
414
951
  }
415
952
 
416
- // Check for known error states in the rendered content
417
- const bodyText = await frame.evaluate(() => document.body?.innerText || "");
418
- const matchedPattern = findErrorPattern(bodyText);
419
- if (matchedPattern) {
420
- const contextSnippet = buildErrorContextSnippet(bodyText, matchedPattern);
953
+ // Check for codeyam's scenario-renderer error fallback. We anchor on the
954
+ // DOM marker the ScenarioRenderer stamps on its error / seed-error frames
955
+ // rather than scanning the whole page body for ERROR_PATTERNS — a
956
+ // legitimately-mocked scenario whose content quotes one of those harness
957
+ // phrases must not be flagged. Apps with no codeyam ScenarioRenderer never
958
+ // emit the marker, so their pages are treated as healthy regardless of body
959
+ // text. The scan is scoped to the marked element's own text.
960
+ const errorMarker = await frame.evaluate((attr) => {
961
+ const el = document.querySelector(`[${attr}]`);
962
+ if (!el) return null;
963
+ return {
964
+ reason: el.getAttribute(attr) || "",
965
+ text: el.innerText || el.textContent || "",
966
+ };
967
+ }, SCENARIO_ERROR_MARKER);
968
+ const scenarioError = findScenarioError(errorMarker);
969
+ if (scenarioError) {
970
+ const { matchedPattern, contextSnippet } = scenarioError;
421
971
  pushIssue(
422
972
  issues,
423
973
  createIssue(
424
974
  "error-state",
425
- `Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? bodyText.slice(0, 200)}`,
975
+ `Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? ""}`,
426
976
  {
427
977
  url: page.url() || url,
428
978
  matchedPattern,
@@ -445,14 +995,83 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
445
995
  pushIssue(issues, hydrationIssue);
446
996
  }
447
997
 
448
- // Drive the requested interaction (if any) against the settled frame,
449
- // then re-settle, so `preview-interact` captures the RESULT of a click /
450
- // fill / press (expanded accordion, open modal) without editing app
451
- // source. A no-match target throws here and is caught below as a failed
452
- // capture with the candidate-labels hint — never a silent blank shot.
453
- if (config.interaction) {
998
+ // Assert the injected seed actually landed in the capture browser, at rest
999
+ // and BEFORE any interaction can legitimately mutate storage. A non-empty
1000
+ // seed that didn't reach localStorage means the screenshot will show
1001
+ // default/empty state — fail loudly instead of emitting a misleading frame.
1002
+ const seedNotLandedIssue = await verifySeededStorageLanded(frame, config);
1003
+ if (seedNotLandedIssue) {
1004
+ pushIssue(issues, seedNotLandedIssue);
1005
+ }
1006
+
1007
+ // Scripted multi-step flow (`editor preview-flow`): drive an ordered
1008
+ // sequence of steps in THIS one browser session — so a behavioral
1009
+ // round-trip (click → observe → click) is captured as the real flow, not N
1010
+ // independent fresh-load snapshots. Each `capture` step writes a numbered
1011
+ // filmstrip frame; the others advance the same page. Mutually exclusive
1012
+ // with the single `interaction` path below. `frame` is reassigned so a
1013
+ // `navigate` step's new content frame backs the rest of the flow and the
1014
+ // final result URL.
1015
+ if (Array.isArray(config.steps) && config.steps.length > 0) {
1016
+ frame = await runFlowSteps(page, frame, config.steps, {
1017
+ url,
1018
+ loadingMarkers,
1019
+ navigation: config.navigation,
1020
+ iframeBackground: config.iframeBackground,
1021
+ preflight,
1022
+ harnessOrigin: resolvedHarnessOrigin,
1023
+ });
1024
+ } else if (config.interaction) {
1025
+ // Record fingerprint before interaction
1026
+ const beforeFingerprint = await getDOMFingerprint(frame);
1027
+
1028
+ // Drive the requested interaction (if any) against the settled frame,
1029
+ // then re-settle, so `preview-interact` captures the RESULT of a click /
1030
+ // fill / press (expanded accordion, open modal) without editing app
1031
+ // source. A no-match target throws here and is caught below as a failed
1032
+ // capture with the candidate-labels hint — never a silent blank shot.
454
1033
  await performInteraction(frame, config.interaction);
455
1034
  await waitForStablePage(page, frame, 5000, loadingMarkers);
1035
+
1036
+ // Record fingerprint after interaction
1037
+ let afterFingerprint = await getDOMFingerprint(frame);
1038
+
1039
+ // If unchanged and it was a click, retry once after 500ms (covers the hydration race)
1040
+ if (beforeFingerprint === afterFingerprint && config.interaction.action === "click") {
1041
+ interactionRetried = true;
1042
+ await new Promise((resolve) => setTimeout(resolve, 500));
1043
+ await performInteraction(frame, config.interaction);
1044
+ await waitForStablePage(page, frame, 5000, loadingMarkers);
1045
+ afterFingerprint = await getDOMFingerprint(frame);
1046
+ }
1047
+
1048
+ interactionEffect = beforeFingerprint === afterFingerprint ? "none" : "changed";
1049
+ }
1050
+
1051
+ // Replay the scenario's PERSISTED interaction sequence (if any) in order,
1052
+ // settling between steps, so a declared interactive state — an expanded
1053
+ // section, a revealed hover bar, an open modal — is reproduced on every
1054
+ // capture and recapture, not just in a one-off `preview-interact`. A step
1055
+ // that matches nothing throws and is caught below as a failed capture, so a
1056
+ // sequence that didn't fully run never persists a resting-state screenshot.
1057
+ if (Array.isArray(config.interactions) && config.interactions.length > 0) {
1058
+ await performInteractionSequence(page, frame, config.interactions, {
1059
+ settleMs: 5000,
1060
+ loadingMarkers,
1061
+ });
1062
+ }
1063
+
1064
+ // Snap any remaining mid-flight CSS entrance animation to its final frame
1065
+ // for the static shot — but only when the scenario has NOT declared an
1066
+ // interactive state (a single `interaction`, a persisted `interactions`
1067
+ // sequence, or a multi-step `steps` flow), so an intentionally
1068
+ // animated/collapsed interactive frame is never clobbered by the force.
1069
+ const hasDeclaredInteractiveState =
1070
+ !!config.interaction ||
1071
+ (Array.isArray(config.interactions) && config.interactions.length > 0) ||
1072
+ (Array.isArray(config.steps) && config.steps.length > 0);
1073
+ if (outputPath && loaded && !hasDeclaredInteractiveState) {
1074
+ await forceFinalVisualState(frame).catch(() => {});
456
1075
  }
457
1076
 
458
1077
  if (outputPath && loaded) {
@@ -460,13 +1079,34 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
460
1079
  await page.screenshot({ path: outputPath, fullPage: false });
461
1080
  }
462
1081
 
463
- return buildResult({
1082
+ const result = buildResult({
464
1083
  loaded,
465
1084
  hasContent,
466
1085
  issues,
467
1086
  outputPath,
468
1087
  url: frame.url() || url,
469
1088
  });
1089
+
1090
+ if (config.interaction) {
1091
+ result.interactionEffect = interactionEffect;
1092
+ result.interactionRetried = interactionRetried;
1093
+ }
1094
+
1095
+ // `capture-state` mode: attach the read-only page-state snapshot so the
1096
+ // backend can report localStorage + rendered text. Off by default, so a
1097
+ // normal error-check capture is byte-for-byte unchanged.
1098
+ if (config.captureState) {
1099
+ result.state = await dumpPageState(frame, config.stateSelector);
1100
+ // A populated state that renders blank is exactly when an agent reaches
1101
+ // for capture-state, so bundle the client-fetch advisory with the dump it
1102
+ // explains. Only when the page didn't settle cleanly — the SSR /
1103
+ // props-driven happy path stays advisory-free.
1104
+ if (settleAdvisory) {
1105
+ result.state.advisories = [settleAdvisory];
1106
+ }
1107
+ }
1108
+
1109
+ return result;
470
1110
  } catch (error) {
471
1111
  pushIssue(
472
1112
  issues,
@@ -506,14 +1146,25 @@ async function main() {
506
1146
 
507
1147
  module.exports = {
508
1148
  runScenarioCheck,
1149
+ mergeVisibleTextLength,
1150
+ runFlowSteps,
1151
+ dumpPageState,
1152
+ verifySeededStorageLanded,
509
1153
  readStackLoadingMarkers,
1154
+ scenarioScriptsLiveSocket,
510
1155
  applyBrowserState,
511
1156
  isCrossOriginRequest,
1157
+ isCaptureFatalRequestFailure,
512
1158
  stripMarkerHeaders,
513
1159
  main,
514
1160
  launchChromiumWithSelfHeal,
1161
+ isTransientLaunchCrash,
1162
+ CAPTURE_LAUNCH_ARGS,
1163
+ CAPTURE_HOST_RESOLVER_RULES,
1164
+ CAPTURE_LAUNCH_SIGSEGV_RETRIES,
515
1165
  PLAYWRIGHT_INSTALL_COMMAND,
516
1166
  PLAYWRIGHT_MISSING_BROWSER_PATTERN,
1167
+ PLAYWRIGHT_LAUNCH_CRASH_PATTERNS,
517
1168
  };
518
1169
 
519
1170
  if (require.main === module) {