@codeyam-editor/codeyam-editor 0.1.4 → 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 +1415 -73
  3. package/npm/container.js +3 -0
  4. package/npm/disk-pressure.js +78 -0
  5. package/npm/editor-dev.js +96 -15
  6. package/npm/editor.js +85 -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 +51 -2
  10. package/npm/fleet-cleanup.js +118 -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 +1 -1
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +135 -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 +43 -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 +204 -3
  35. package/npm/scenario-check.js +603 -32
  36. package/npm/scenario-handlers.js +78 -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 +329 -50
  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-BuK0lizt.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-tMMdarZD.js +0 -187
  61. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-BGqairFV.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
@@ -345,11 +557,170 @@ async function dumpPageState(frame, selector) {
345
557
  }, selector || null);
346
558
  }
347
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
+
348
691
  // `preflight` is injectable (defaulting to the real app-port reachability
349
692
  // check) so unit tests that mock the browser can stay network-free.
350
- 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();
351
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;
352
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
+ }
353
724
  const browser = await launchChromiumWithSelfHeal();
354
725
  const contextOptions = {
355
726
  viewport: { width: width || 1440, height: height || 900 },
@@ -362,6 +733,13 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
362
733
  if (config.timezoneId) contextOptions.timezoneId = config.timezoneId;
363
734
  if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion;
364
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
+ }
365
743
  const context = await browser.newContext(contextOptions);
366
744
 
367
745
  // Apply the scenario's merged `browserState` (cookies + request
@@ -372,8 +750,11 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
372
750
  // not redirect to `/login` when the proxy is bypassed.
373
751
  await applyBrowserState(context, config);
374
752
 
375
- // Context-level init script runs in ALL frames (including cross-origin iframes)
376
- 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)));
377
758
 
378
759
  const page = await context.newPage();
379
760
  // Attach the network tracker BEFORE navigation so every request (the document
@@ -389,12 +770,42 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
389
770
 
390
771
  page.on("console", (message) => {
391
772
  const issue = handleConsoleMessage(message);
392
- if (issue) {
393
- pushIssue(issues, issue);
394
- }
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);
395
794
  });
396
795
 
397
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;
398
809
  const issue = handleRequestFailed(request);
399
810
  if (issue) {
400
811
  pushIssue(issues, issue);
@@ -410,13 +821,18 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
410
821
  // iframe harness for its background/sizing control. The backend signals
411
822
  // the choice via `config.navigation` ("topLevel"); absent (the default)
412
823
  // means the iframe harness, so existing callers are unchanged.
413
- const { frame, response } =
824
+ const loadResult =
414
825
  config.navigation === "topLevel"
415
826
  ? await loadScenarioTopLevel(page, url, { preflight })
416
827
  : await loadScenarioInIframe(page, url, {
417
828
  background: config.iframeBackground,
418
829
  preflight,
830
+ harnessOrigin: resolvedHarnessOrigin,
419
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;
420
836
  loaded = true;
421
837
 
422
838
  if (response && response.status() >= 400) {
@@ -437,14 +853,34 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
437
853
  const loadingMarkers = Array.isArray(config.loadingMarkers)
438
854
  ? config.loadingMarkers
439
855
  : readStackLoadingMarkers();
440
- 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
+ );
441
871
 
442
872
  // DOM-stable does not mean done: a client-side data fetch can still be in
443
873
  // flight (the loading skeleton cleared but its replacement content hasn't
444
874
  // landed). Wait for the network to go quiet — bounded, so a streaming /
445
875
  // long-poll endpoint that never idles caps out and captures anyway rather
446
876
  // than hanging.
447
- 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);
448
884
 
449
885
  const rejectionMessages = await frame.evaluate(
450
886
  () => window.__codeyamUnhandledRejections || [],
@@ -458,6 +894,23 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
458
894
  );
459
895
  }
460
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
+
461
914
  // Cold-start retry: a single re-collect after BLANK_RETRY_DELAY_MS covers
462
915
  // the React.lazy / Suspense-fallback race where waitForStablePage settles
463
916
  // on the still-empty `<div id="root">` shell before the dynamic chunk
@@ -465,13 +918,27 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
465
918
  // chunk-load window; only a genuinely blank page falls through to the
466
919
  // blank issue below.
467
920
  let contentState = await collectContentState(frame);
921
+ await mergeVisibleTextLength(contentState, frame);
468
922
  let hasContent = hasRenderableContent(contentState);
469
923
  if (!hasContent) {
470
924
  await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS));
471
925
  contentState = await collectContentState(frame);
926
+ await mergeVisibleTextLength(contentState, frame);
472
927
  hasContent = hasRenderableContent(contentState);
473
928
  }
474
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
+
475
942
  if (!hasContent) {
476
943
  pushIssue(
477
944
  issues,
@@ -483,16 +950,29 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
483
950
  );
484
951
  }
485
952
 
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);
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;
491
971
  pushIssue(
492
972
  issues,
493
973
  createIssue(
494
974
  "error-state",
495
- `Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? bodyText.slice(0, 200)}`,
975
+ `Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? ""}`,
496
976
  {
497
977
  url: page.url() || url,
498
978
  matchedPattern,
@@ -515,14 +995,83 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
515
995
  pushIssue(issues, hydrationIssue);
516
996
  }
517
997
 
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) {
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.
524
1033
  await performInteraction(frame, config.interaction);
525
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(() => {});
526
1075
  }
527
1076
 
528
1077
  if (outputPath && loaded) {
@@ -538,11 +1087,23 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
538
1087
  url: frame.url() || url,
539
1088
  });
540
1089
 
1090
+ if (config.interaction) {
1091
+ result.interactionEffect = interactionEffect;
1092
+ result.interactionRetried = interactionRetried;
1093
+ }
1094
+
541
1095
  // `capture-state` mode: attach the read-only page-state snapshot so the
542
1096
  // backend can report localStorage + rendered text. Off by default, so a
543
1097
  // normal error-check capture is byte-for-byte unchanged.
544
1098
  if (config.captureState) {
545
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
+ }
546
1107
  }
547
1108
 
548
1109
  return result;
@@ -585,15 +1146,25 @@ async function main() {
585
1146
 
586
1147
  module.exports = {
587
1148
  runScenarioCheck,
1149
+ mergeVisibleTextLength,
1150
+ runFlowSteps,
588
1151
  dumpPageState,
1152
+ verifySeededStorageLanded,
589
1153
  readStackLoadingMarkers,
1154
+ scenarioScriptsLiveSocket,
590
1155
  applyBrowserState,
591
1156
  isCrossOriginRequest,
1157
+ isCaptureFatalRequestFailure,
592
1158
  stripMarkerHeaders,
593
1159
  main,
594
1160
  launchChromiumWithSelfHeal,
1161
+ isTransientLaunchCrash,
1162
+ CAPTURE_LAUNCH_ARGS,
1163
+ CAPTURE_HOST_RESOLVER_RULES,
1164
+ CAPTURE_LAUNCH_SIGSEGV_RETRIES,
595
1165
  PLAYWRIGHT_INSTALL_COMMAND,
596
1166
  PLAYWRIGHT_MISSING_BROWSER_PATTERN,
1167
+ PLAYWRIGHT_LAUNCH_CRASH_PATTERNS,
597
1168
  };
598
1169
 
599
1170
  if (require.main === module) {