@codeyam-editor/codeyam-editor 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud-branch-switch.js +1 -1
  3. package/npm/cloud.js +3166 -150
  4. package/npm/codesign-plan.js +68 -0
  5. package/npm/container.js +3 -0
  6. package/npm/disk-pressure.js +78 -0
  7. package/npm/editor-dev.js +96 -15
  8. package/npm/editor.js +85 -37
  9. package/npm/empty-project-dir.js +92 -7
  10. package/npm/fleet-add-failure.js +252 -0
  11. package/npm/fleet-asset-version.js +37 -0
  12. package/npm/fleet-auth-health.js +213 -0
  13. package/npm/fleet-auth.js +6 -1
  14. package/npm/fleet-build-data.js +109 -5
  15. package/npm/fleet-cleanup.js +232 -16
  16. package/npm/fleet-destroy-safety.js +272 -0
  17. package/npm/fleet-early-roster.js +87 -0
  18. package/npm/fleet-effect-poll.js +110 -0
  19. package/npm/fleet-effect-status.js +33 -32
  20. package/npm/fleet-env.js +86 -0
  21. package/npm/fleet-ghcr-secret.js +129 -0
  22. package/npm/fleet-git-maintenance.js +142 -0
  23. package/npm/fleet-host-op-limiter.js +68 -0
  24. package/npm/fleet-host.js +74 -0
  25. package/npm/fleet-iap-token.js +165 -0
  26. package/npm/fleet-job-badge.js +1 -1
  27. package/npm/fleet-launchd.js +171 -0
  28. package/npm/fleet-live-status.js +155 -0
  29. package/npm/fleet-memory.js +245 -9
  30. package/npm/fleet-plans.js +233 -4
  31. package/npm/fleet-pool.js +75 -16
  32. package/npm/fleet-prewarm.js +241 -0
  33. package/npm/fleet-project-drift.js +58 -0
  34. package/npm/fleet-public-url.js +394 -0
  35. package/npm/fleet-queue.js +95 -1
  36. package/npm/fleet-reachability-debounce.js +69 -0
  37. package/npm/fleet-reachability.js +80 -0
  38. package/npm/fleet-reconcile.js +43 -1
  39. package/npm/fleet-reset.js +330 -13
  40. package/npm/fleet-roster.js +6 -8
  41. package/npm/fleet-running-plan.js +85 -0
  42. package/npm/fleet-self-update.js +142 -0
  43. package/npm/fleet-session-token-cache.js +65 -0
  44. package/npm/fleet-setup-steps.js +112 -0
  45. package/npm/fleet-sleep-status.js +33 -0
  46. package/npm/fleet-status-refs.js +96 -0
  47. package/npm/fleet-strand.js +181 -0
  48. package/npm/fleet-stuck-status.js +117 -0
  49. package/npm/fleet-vm-access.js +404 -0
  50. package/npm/fleet-vm-probe-target.js +282 -0
  51. package/npm/fleet-vm-readiness.js +237 -0
  52. package/npm/fleet-vm-slugs.js +116 -0
  53. package/npm/google-ingress.js +226 -0
  54. package/npm/ingress-invoke.js +770 -0
  55. package/npm/launch-config.js +386 -9
  56. package/npm/scenario-check.js +880 -37
  57. package/npm/scenario-handlers.js +154 -30
  58. package/npm/scenario-interactivity.js +145 -6
  59. package/npm/scenario-issues.js +13 -1
  60. package/npm/scenario-metrics.js +116 -1
  61. package/npm/scenario-mocks.js +144 -21
  62. package/npm/scenario-playwright.js +649 -38
  63. package/npm/script-drift.js +152 -0
  64. package/npm/utils.js +365 -52
  65. package/npm/vm-urls.js +69 -8
  66. package/package.json +5 -5
  67. package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
  68. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
  69. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  70. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
  71. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
  72. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
  73. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  74. package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
  75. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
  76. package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
  77. package/ui/dist/assets/index-BygCiJSb.css +1 -0
  78. package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
  79. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  80. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  81. package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
  82. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
  83. package/ui/dist/codeyam-mark.svg +4 -0
  84. package/ui/dist/codeyam-wordmark.svg +5 -0
  85. package/ui/dist/index.html +4 -4
  86. package/ui/dist/open-source/codeyam-counter.png +0 -0
  87. package/ui/dist/open-source/el-carot.png +0 -0
  88. package/ui/dist/open-source/tabcommand.png +0 -0
  89. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  90. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  91. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  92. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  93. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  94. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  95. package/ui/dist/assets/index-BGqairFV.css +0 -1
  96. package/ui/dist/assets/react-CSS0HapR.js +0 -28
  97. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -129,6 +129,57 @@ function buildIframeHarness(url, { background = "transparent" } = {}) {
129
129
  </html>`;
130
130
  }
131
131
 
132
+ const path = require("path");
133
+
134
+ // The reserved route the editor serves the secure-context iframe harness from.
135
+ // Mirrors `HARNESS_PATH` in crates/control-api/src/preview_proxy_route.rs — the
136
+ // canonical source of the harness markup now lives in that Rust handler; the
137
+ // `buildIframeHarness` template above survives only as the degraded fallback
138
+ // below for when the harness origin can't be resolved.
139
+ const HARNESS_PATH = "/__codeyam_harness";
140
+
141
+ // Read `.codeyam/server-state.json` (cwd is the project dir — see the timing
142
+ // log note above) and return its parsed object, or null when it is absent.
143
+ function defaultReadServerState() {
144
+ const statePath = path.join(process.cwd(), ".codeyam", "server-state.json");
145
+ if (!fs.existsSync(statePath)) return null;
146
+ return JSON.parse(fs.readFileSync(statePath, "utf8"));
147
+ }
148
+
149
+ // Resolve the `localhost` origin that serves the iframe-harness route. The
150
+ // harness is mounted on the editor's control-api listener, whose port is
151
+ // recorded in `.codeyam/server-state.json` as `controlPort` (the same file the
152
+ // top-level loader already reads for `appPort`). Returns
153
+ // `http://localhost:<controlPort>` — a secure context the nested iframe
154
+ // inherits — or null when the state file is missing/unreadable, in which case
155
+ // the caller falls back to the legacy in-page `setContent` harness.
156
+ // `readStateFile` is injectable so the resolver is unit-testable without disk.
157
+ function resolveHarnessOrigin({ readStateFile = defaultReadServerState } = {}) {
158
+ try {
159
+ const state = readStateFile();
160
+ const port = state && state.controlPort;
161
+ if (typeof port === "number" && port > 0) {
162
+ return `http://localhost:${port}`;
163
+ }
164
+ } catch (_) {
165
+ /* missing/unreadable state — fall back to setContent */
166
+ }
167
+ return null;
168
+ }
169
+
170
+ // Build the top-level harness URL for a scenario `url` and background. The
171
+ // editor-served harness document embeds `src` as the iframe URL, so the nested
172
+ // scenario inherits the harness's secure context. The background rides along as
173
+ // `bg`. Pure — `URLSearchParams` does the percent-encoding so a scenario URL
174
+ // with its own query string survives intact. Pure.
175
+ function buildHarnessUrl(harnessOrigin, url, background) {
176
+ const params = new URLSearchParams({ src: url });
177
+ if (background != null && background !== "") {
178
+ params.set("bg", String(background));
179
+ }
180
+ return `${harnessOrigin}${HARNESS_PATH}?${params.toString()}`;
181
+ }
182
+
132
183
  async function collectContentState(target) {
133
184
  return target.evaluate(() => {
134
185
  const root = document.getElementById("root");
@@ -158,6 +209,237 @@ async function collectContentState(target) {
158
209
  });
159
210
  }
160
211
 
212
+ // Trip scroll-gated entrance animations before capture. Many web/visual
213
+ // stacks reveal sections with an IntersectionObserver that flips an
214
+ // `opacity:0` element to its final state once it scrolls into view. Captured
215
+ // in isolation — where the app shell that wires those observers is absent —
216
+ // or on a tall page captured at a fixed viewport, the observers never fire and
217
+ // the content stays invisible, so the screenshot is blank. Scroll the document
218
+ // end-to-end in steps to trip every observer, then return to the top so the
219
+ // captured frame starts where the app does. Stack-agnostic: it drives the same
220
+ // scroll a real user would, touching no framework API. A frame whose document
221
+ // fits in (or is shorter than) the viewport still gets a nudge-and-restore so a
222
+ // single-screen page's scroll-triggered reveal fires too. Returns the measured
223
+ // document height (0 when there is nothing to scroll).
224
+ async function scrollThroughDocument(target) {
225
+ return target.evaluate(() => {
226
+ const doc = document.scrollingElement || document.documentElement;
227
+ const total = Math.max(
228
+ (doc && doc.scrollHeight) || 0,
229
+ document.body ? document.body.scrollHeight : 0,
230
+ );
231
+ if (typeof window.scrollTo !== "function") return total;
232
+ const viewport = window.innerHeight || 0;
233
+ if (total <= viewport || total === 0) {
234
+ // Even a single-viewport page may gate a reveal on the first scroll
235
+ // event; a nudge-and-restore fires those observers without moving the
236
+ // captured frame off the top.
237
+ window.scrollTo(0, 1);
238
+ window.scrollTo(0, 0);
239
+ return total;
240
+ }
241
+ const stride = Math.max(1, Math.floor((total - viewport) / 8));
242
+ for (let y = 0; y <= total; y += stride) {
243
+ window.scrollTo(0, y);
244
+ }
245
+ window.scrollTo(0, 0);
246
+ return total;
247
+ });
248
+ }
249
+
250
+ // Sum the length of text that is actually PAINTED to the frame — text whose
251
+ // element ancestor chain is not hidden by `display:none` / `visibility:hidden`
252
+ // and is not collapsed to `opacity:0`, and which occupies a non-zero box. This
253
+ // is the signal the blank-frame gate uses to catch a reveal-suppressed capture:
254
+ // a frame whose DOM rendered text (so `bodyTextLength > 0`) but whose every
255
+ // section is still sitting at the `opacity:0` start of an entrance animation
256
+ // that never fired, leaving the screenshot blank. Counts text mid-transition
257
+ // (any opacity above exactly 0) as visible, so a reveal that has begun is not
258
+ // flagged. Returns `undefined` when the DOM-walking / computed-style APIs are
259
+ // unavailable (e.g. a stubbed test target) so callers fall back to the legacy
260
+ // DOM-presence behavior rather than treating an un-measurable frame as blank.
261
+ async function collectVisibleTextLength(target) {
262
+ return target.evaluate(() => {
263
+ if (
264
+ typeof document.createTreeWalker !== "function" ||
265
+ typeof window.getComputedStyle !== "function" ||
266
+ typeof NodeFilter === "undefined"
267
+ ) {
268
+ return undefined;
269
+ }
270
+ const root = document.body || document.documentElement;
271
+ if (!root) return 0;
272
+ const isHidden = (el) => {
273
+ for (
274
+ let node = el;
275
+ node && node.nodeType === 1;
276
+ node = node.parentElement
277
+ ) {
278
+ const style = window.getComputedStyle(node);
279
+ if (!style) continue;
280
+ if (style.display === "none" || style.visibility === "hidden")
281
+ return true;
282
+ if (parseFloat(style.opacity) === 0) return true;
283
+ }
284
+ return false;
285
+ };
286
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
287
+ let total = 0;
288
+ let node;
289
+ while ((node = walker.nextNode())) {
290
+ const text = (node.nodeValue || "").trim();
291
+ if (!text) continue;
292
+ const el = node.parentElement;
293
+ if (!el || isHidden(el)) continue;
294
+ const rect = el.getBoundingClientRect();
295
+ if (rect.width <= 0 || rect.height <= 0) continue;
296
+ total += text.length;
297
+ }
298
+ return total;
299
+ });
300
+ }
301
+
302
+ // Inject a capture-only stylesheet that snaps entrance animations to their
303
+ // FINAL frame: remove animation/transition timing and force the common
304
+ // "hidden until revealed" symptoms (`opacity:0`, an entrance `transform`) back
305
+ // to their resting values. This is the belt to `scrollThroughDocument`'s
306
+ // suspenders — it covers pure-CSS keyframe entrances that are mid-flight or
307
+ // stuck at an `opacity:0` start state even after the observers fired. It
308
+ // targets the generic CSS symptom, not any framework's reveal class, so it
309
+ // works for any stack. The caller gates this OFF when a scenario declares an
310
+ // interactive state, so an intentionally animated/collapsed interactive frame
311
+ // is never clobbered. Idempotent (a single injected style id) and best-effort.
312
+ // Returns true when the style is present after the call.
313
+ async function forceFinalVisualState(target) {
314
+ return target.evaluate(() => {
315
+ const STYLE_ID = "__codeyam_force_final_state";
316
+ if (
317
+ typeof document.getElementById === "function" &&
318
+ document.getElementById(STYLE_ID)
319
+ ) {
320
+ return true;
321
+ }
322
+ if (typeof document.createElement !== "function") return false;
323
+ const style = document.createElement("style");
324
+ style.id = STYLE_ID;
325
+ style.textContent =
326
+ "*, *::before, *::after {" +
327
+ "animation: none !important;" +
328
+ "transition: none !important;" +
329
+ "opacity: 1 !important;" +
330
+ "transform: none !important;" +
331
+ "}";
332
+ const head = document.head || document.documentElement;
333
+ if (!head || typeof head.appendChild !== "function") return false;
334
+ head.appendChild(style);
335
+ return true;
336
+ });
337
+ }
338
+
339
+ // Center the isolated-component wrapper in the viewport before the shot.
340
+ //
341
+ // The capture pipeline always shoots the full viewport (`page.screenshot({
342
+ // fullPage: false })`) and never an element clip, so an isolation page that
343
+ // does not center its own component strands it in the top-left corner of every
344
+ // frame. Next.js does this in its isolation layout and the query-param stacks
345
+ // do it in `.codeyam/harness/isolate.tsx`, but the server-rendered scaffolds
346
+ // historically did not — and neither do pages hand-authored before that was
347
+ // fixed. This pass closes the gap at capture time, for any stack.
348
+ //
349
+ // It MEASURES rather than assumes: a wrapper that is already centered is left
350
+ // alone, so pages that already do the right thing are byte-identical. The
351
+ // `#codeyam-capture` marker (the cross-stack isolation convention; expo emits
352
+ // it via `nativeID`) is the gate, so an ordinary route capture is never
353
+ // touched. Centering is applied to `<body>` and to each ancestor strictly
354
+ // between body and the wrapper — never to the wrapper itself, which frequently
355
+ // carries the component's own padding/background and must keep its own box.
356
+ // Each axis independently falls back to `flex-start` when the wrapper overflows
357
+ // the viewport, so an oversized component loses its bottom/right edge rather
358
+ // than being clipped symmetrically out of its top/left one.
359
+ //
360
+ // Idempotent (a single injected style id), best-effort, and returns a small
361
+ // result object describing what it did.
362
+ async function centerCaptureWrapper(target) {
363
+ return target.evaluate(() => {
364
+ const STYLE_ID = "__codeyam_center_capture";
365
+ const ANCESTOR_CLASS = "__codeyam-center-ancestor";
366
+ // Subpixel layout and fractional scrollbar widths make exact gap equality
367
+ // unreliable; a couple of px of slop keeps already-centered pages on the
368
+ // no-op branch.
369
+ const TOLERANCE_PX = 2;
370
+
371
+ if (typeof document.getElementById !== "function") {
372
+ return { applied: false, reason: "no-dom" };
373
+ }
374
+ if (document.getElementById(STYLE_ID)) {
375
+ return { applied: false, reason: "already-applied" };
376
+ }
377
+ const wrapper = document.getElementById("codeyam-capture");
378
+ if (!wrapper || typeof wrapper.getBoundingClientRect !== "function") {
379
+ return { applied: false, reason: "no-capture-marker" };
380
+ }
381
+
382
+ const rect = wrapper.getBoundingClientRect();
383
+ const viewportWidth = window.innerWidth;
384
+ const viewportHeight = window.innerHeight;
385
+
386
+ const leftGap = rect.left;
387
+ const rightGap = viewportWidth - rect.right;
388
+ const topGap = rect.top;
389
+ const bottomGap = viewportHeight - rect.bottom;
390
+
391
+ const fitsHorizontally = rect.width <= viewportWidth;
392
+ const fitsVertically = rect.height <= viewportHeight;
393
+ const centeredHorizontally =
394
+ fitsHorizontally && Math.abs(leftGap - rightGap) <= TOLERANCE_PX;
395
+ const centeredVertically =
396
+ fitsVertically && Math.abs(topGap - bottomGap) <= TOLERANCE_PX;
397
+
398
+ if (centeredHorizontally && centeredVertically) {
399
+ return { applied: false, reason: "already-centered" };
400
+ }
401
+
402
+ const horizontal = fitsHorizontally ? "center" : "flex-start";
403
+ const vertical = fitsVertically ? "center" : "flex-start";
404
+
405
+ // Stamp the ancestor chain so the injected rule targets exactly the
406
+ // elements between <body> and the wrapper and cannot leak elsewhere.
407
+ let node = wrapper.parentElement;
408
+ while (node && node !== document.body) {
409
+ if (node.classList && typeof node.classList.add === "function") {
410
+ node.classList.add(ANCESTOR_CLASS);
411
+ }
412
+ node = node.parentElement;
413
+ }
414
+
415
+ if (typeof document.createElement !== "function") {
416
+ return { applied: false, reason: "no-dom" };
417
+ }
418
+ const style = document.createElement("style");
419
+ style.id = STYLE_ID;
420
+ style.textContent =
421
+ "body, ." +
422
+ ANCESTOR_CLASS +
423
+ " {" +
424
+ "margin: 0;" +
425
+ "min-height: 100vh;" +
426
+ "display: flex;" +
427
+ "align-items: " +
428
+ vertical +
429
+ ";" +
430
+ "justify-content: " +
431
+ horizontal +
432
+ ";" +
433
+ "}";
434
+ const head = document.head || document.documentElement;
435
+ if (!head || typeof head.appendChild !== "function") {
436
+ return { applied: false, reason: "no-dom" };
437
+ }
438
+ head.appendChild(style);
439
+ return { applied: true, horizontal, vertical };
440
+ });
441
+ }
442
+
161
443
  async function collectImageStates(target) {
162
444
  return target.evaluate(() =>
163
445
  Array.from(document.images || []).map((img) => ({
@@ -346,7 +628,7 @@ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers
346
628
  outcome: "stabilized",
347
629
  elapsedMs: Date.now() - started,
348
630
  });
349
- return;
631
+ return { stabilized: true, hadLoadingMarkers: false };
350
632
  }
351
633
  } else {
352
634
  stableCount = 0;
@@ -356,6 +638,9 @@ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers
356
638
  }
357
639
  // Hit the cap without stabilizing — record WHY: a persistent loading marker
358
640
  // (app stuck) vs HTML still mutating each poll (HMR / animation / re-render).
641
+ // The returned `hadLoadingMarkers` is the signal the capture advisory keys
642
+ // off: a marker still on screen at the cap means the page never finished its
643
+ // (likely client-side) load, so the screenshot caught its loading state.
359
644
  logCaptureTiming("stable-page", {
360
645
  outcome: "timed-out",
361
646
  elapsedMs: Date.now() - started,
@@ -363,6 +648,7 @@ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers
363
648
  lastHadLoadingMarkers,
364
649
  lastHtmlStillChanging: lastHtmlChanged,
365
650
  });
651
+ return { stabilized: false, hadLoadingMarkers: lastHadLoadingMarkers };
366
652
  }
367
653
 
368
654
  // `preflight` is injectable so unit tests that drive a mock page can stay
@@ -370,9 +656,13 @@ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers
370
656
  async function loadScenarioInIframe(
371
657
  page,
372
658
  url,
373
- { background, preflight = assertAppPortReachable } = {},
659
+ { background, preflight = assertAppPortReachable, harnessOrigin } = {},
374
660
  ) {
375
661
  await preflight(url);
662
+ // `undefined` (the default) means "resolve from server-state"; an explicit
663
+ // value (including `null`) is honored as-is so tests can force either path.
664
+ const resolvedHarnessOrigin =
665
+ harnessOrigin !== undefined ? harnessOrigin : resolveHarnessOrigin();
376
666
  const navStarted = Date.now();
377
667
  const responsePromise = page
378
668
  .waitForResponse(
@@ -383,9 +673,47 @@ async function loadScenarioInIframe(
383
673
  )
384
674
  .catch(() => null);
385
675
 
386
- await page.setContent(buildIframeHarness(url, { background }), {
387
- waitUntil: "domcontentloaded",
388
- });
676
+ if (resolvedHarnessOrigin) {
677
+ // Navigate the page TOP-LEVEL to the harness document served from the
678
+ // editor's `localhost` origin, so the ancestor document is a secure context
679
+ // and the nested scenario iframe inherits it (the Sveltia-class fix). The
680
+ // inner iframe `src` is still `url`, so the document-response probe above is
681
+ // unchanged.
682
+ const harnessUrl = buildHarnessUrl(resolvedHarnessOrigin, url, background);
683
+ const harnessResponse = await page.goto(harnessUrl, {
684
+ waitUntil: "domcontentloaded",
685
+ });
686
+ // Fail loudly on a fail-closed harness-auth rejection BEFORE the blind 30s
687
+ // `#scenario-frame` wait below. On a non-loopback (0.0.0.0 / cloud) bind the
688
+ // harness control-API route is session-token-gated; a generated
689
+ // `.codeyam/capture.js` (or an editor binary) that predates the `cy_session`
690
+ // cookie injection navigates without a token -> the server refuses the
691
+ // request with 401 -> the harness document never renders -> every component
692
+ // scenario times out on `#scenario-frame` with a generic Playwright error
693
+ // that hides the real cause. Surfacing the 401 here turns that blind timeout
694
+ // into a one-line root cause on the very first failing capture, naming both
695
+ // fixes. (Route/page scenarios navigate top-level to the un-gated app proxy,
696
+ // so they never hit this path.)
697
+ if (harnessResponse && harnessResponse.status() === 401) {
698
+ throw new Error(
699
+ `Capture harness route ${harnessUrl} returned 401 (fail-closed ` +
700
+ `session-token auth on a non-loopback bind). The capture script did not ` +
701
+ `send the \`cy_session\` token — most often a generated ` +
702
+ `\`.codeyam/capture.js\` (or an editor binary) that predates session-token ` +
703
+ `injection. Fixes: refresh the editor binary on this VM, or set ` +
704
+ `\`CODEYAM_INSECURE_BIND=1\` and restart the editor.`,
705
+ );
706
+ }
707
+ } else {
708
+ // Degraded fallback: no resolvable harness origin (server-state missing), so
709
+ // use the legacy in-page harness. The top-level document is then
710
+ // `about:blank` — NOT a secure context — so a secure-context-gated app may
711
+ // refuse to mount, but every non-secure-context scenario captures exactly as
712
+ // before.
713
+ await page.setContent(buildIframeHarness(url, { background }), {
714
+ waitUntil: "domcontentloaded",
715
+ });
716
+ }
389
717
 
390
718
  const frameHandle = await page.waitForSelector("#scenario-frame", {
391
719
  state: "attached",
@@ -420,18 +748,69 @@ async function loadScenarioTopLevel(
420
748
  ) {
421
749
  await preflight(url);
422
750
  const navStarted = Date.now();
423
- const response = await page.goto(url, {
424
- waitUntil: "load",
425
- timeout: 30000,
426
- });
427
- logCaptureTiming("navigate-toplevel", {
428
- elapsedMs: Date.now() - navStarted,
429
- status: response ? response.status() : null,
430
- url,
431
- });
432
- return { frame: page.mainFrame(), response };
751
+ try {
752
+ const response = await page.goto(url, {
753
+ waitUntil: "load",
754
+ timeout: 30000,
755
+ });
756
+ logCaptureTiming("navigate-toplevel", {
757
+ elapsedMs: Date.now() - navStarted,
758
+ status: response ? response.status() : null,
759
+ url,
760
+ });
761
+ return { frame: page.mainFrame(), response };
762
+ } catch (error) {
763
+ if (error.message && error.message.toLowerCase().includes("timeout")) {
764
+ let parsed = null;
765
+ try {
766
+ parsed = new URL(url);
767
+ } catch (_) {}
768
+ if (parsed) {
769
+ let appPort = null;
770
+ try {
771
+ const path = require("path");
772
+ const statePath = path.join(process.cwd(), ".codeyam", "server-state.json");
773
+ if (fs.existsSync(statePath)) {
774
+ const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
775
+ appPort = state.appPort;
776
+ }
777
+ } catch (_) {}
778
+
779
+ if (appPort) {
780
+ let appHealthy = false;
781
+ try {
782
+ await assertAppPortReachable(`http://127.0.0.1:${appPort}/`, { timeoutMs: 500 });
783
+ appHealthy = true;
784
+ } catch (_) {}
785
+
786
+ if (appHealthy) {
787
+ throw new Error(
788
+ `proxy navigation timed out: the proxy at 127.0.0.1:${parsed.port} did not respond within 30000ms. app healthy on :${appPort}, proxy dead on :${parsed.port}.`
789
+ );
790
+ } else {
791
+ throw new Error(
792
+ `proxy navigation timed out: the proxy at 127.0.0.1:${parsed.port} did not respond within 30000ms. app also unresponsive on :${appPort}.`
793
+ );
794
+ }
795
+ } else {
796
+ throw new Error(
797
+ `proxy navigation timed out: the proxy at 127.0.0.1:${parsed.port} did not respond within 30000ms. proxy dead/hung?`
798
+ );
799
+ }
800
+ }
801
+ }
802
+ throw error;
803
+ }
433
804
  }
434
805
 
806
+ // The CSS selector for "clickable / interactive" elements — buttons, links,
807
+ // role=button, form controls, <summary>, and anything with an onclick. Shared
808
+ // by the candidate-label collector (below) and the text-target resolver
809
+ // (`resolveTextTarget`), which prefers an interactive element over a plain
810
+ // text node when a click/press label matches both.
811
+ const INTERACTIVE_SELECTOR =
812
+ "button, a[href], [role=button], input, select, textarea, summary, [onclick]";
813
+
435
814
  // Collect up to 20 distinct visible labels of interactive elements on the
436
815
  // page — buttons, links, role=button, form controls, <summary>, and anything
437
816
  // with an onclick. Used to build an ACTIONABLE error when an interaction's
@@ -440,9 +819,7 @@ async function loadScenarioTopLevel(
440
819
  // mean one of these?" hint. Pure read (no clicks); falls back to value /
441
820
  // aria-label / placeholder when an element has no text.
442
821
  async function collectInteractiveLabels(frame) {
443
- return frame.evaluate(() => {
444
- const selector =
445
- "button, a[href], [role=button], input, select, textarea, summary, [onclick]";
822
+ return frame.evaluate((selector) => {
446
823
  const nodes = Array.from(document.querySelectorAll(selector));
447
824
  const labels = nodes
448
825
  .map((node) => {
@@ -456,7 +833,39 @@ async function collectInteractiveLabels(frame) {
456
833
  })
457
834
  .filter((label) => label.length > 0);
458
835
  return Array.from(new Set(labels)).slice(0, 20);
459
- });
836
+ }, INTERACTIVE_SELECTOR);
837
+ }
838
+
839
+ // Describe the elements a substring text match resolved, so an ambiguity
840
+ // warning can name the competing controls (e.g. the preset button "Bet" vs the
841
+ // disclosure button "…or bet on"). Reads each matched element's inner text via
842
+ // Playwright's `allInnerTexts`, caps the list at 5, and trims each to a single
843
+ // short line. Pure read; degrades to a count-only description if the locator
844
+ // API or the page can't produce texts.
845
+ async function describeMatchCandidates(baseLocator, matchCount) {
846
+ const CAP = 5;
847
+ const MAX_LEN = 60;
848
+ let texts = [];
849
+ try {
850
+ if (typeof baseLocator.allInnerTexts === "function") {
851
+ texts = await baseLocator.allInnerTexts();
852
+ }
853
+ } catch (_) {
854
+ texts = [];
855
+ }
856
+ const described = texts
857
+ .map((t) => String(t).replace(/\s+/g, " ").trim())
858
+ .filter((t) => t.length > 0)
859
+ .slice(0, CAP)
860
+ .map((t) => (t.length > MAX_LEN ? `${t.slice(0, MAX_LEN)}…` : t))
861
+ .map((t) => `"${t}"`);
862
+ if (described.length === 0) {
863
+ return [`${matchCount} elements (text unavailable)`];
864
+ }
865
+ if (matchCount > described.length) {
866
+ described.push(`…and ${matchCount - described.length} more`);
867
+ }
868
+ return described;
460
869
  }
461
870
 
462
871
  // Drive a single user-style interaction against the settled frame before the
@@ -469,16 +878,103 @@ async function collectInteractiveLabels(frame) {
469
878
  // `Enter`). On a no-match target this THROWS with the list of candidate
470
879
  // interactive labels — the capture script's outer catch turns that into a
471
880
  // failed capture with an actionable message, never a silent blank screenshot.
472
- async function performInteraction(frame, interaction, { timeoutMs = 5000 } = {}) {
881
+ //
882
+ // Resolve a visible-text target to the element most likely intended, instead
883
+ // of blindly taking the first substring hit. A bare
884
+ // `getByText(text, { exact: false })` is a case-insensitive SUBSTRING match, so
885
+ // a label can land on a plain text node or a placeholder rather than the chip /
886
+ // button the agent meant (observed: a `"Japan"` click hit placeholder text, not
887
+ // the chip; a `"Bet"` click hit a disclosure button described "or bet on"). We
888
+ // try increasingly-loose locators in priority order and take the first that
889
+ // matches anything:
890
+ // 1. exact + interactive (for click/press)
891
+ // 2. exact
892
+ // 3. substring + interactive (for click/press)
893
+ // 4. substring
894
+ // so an exact, clickable element wins over a substring plain-text node. Returns
895
+ // the winning tier's locator plus its match count; when nothing matches at all,
896
+ // returns the substring locator (count 0) so the caller's zero-match error path
897
+ // still fires with its candidate-label hint.
898
+ async function resolveTextTarget(frame, text, action) {
899
+ const preferInteractive = action === "click" || action === "press";
900
+ const exactBase = frame.getByText(text, { exact: true });
901
+ const substrBase = frame.getByText(text, { exact: false });
902
+
903
+ const tiers = [];
904
+ // `.and()` (Playwright ≥1.34) intersects two locators to the elements
905
+ // matching both — here, the text element that is ALSO interactive. Guard on
906
+ // its presence so a locator without `.and` degrades to text-only tiers.
907
+ if (preferInteractive && typeof exactBase.and === "function") {
908
+ const interactive = frame.locator(INTERACTIVE_SELECTOR);
909
+ tiers.push(exactBase.and(interactive));
910
+ tiers.push(exactBase);
911
+ tiers.push(substrBase.and(interactive));
912
+ tiers.push(substrBase);
913
+ } else {
914
+ tiers.push(exactBase);
915
+ tiers.push(substrBase);
916
+ }
917
+
918
+ for (const loc of tiers) {
919
+ const count = await loc.count();
920
+ if (count > 0) {
921
+ return { baseLocator: loc, matchCount: count };
922
+ }
923
+ }
924
+ return { baseLocator: substrBase, matchCount: 0 };
925
+ }
926
+
927
+ // Pick the single element to act on from a resolved locator. When more than one
928
+ // candidate remains within the winning tier, prefer a VISIBLE element over a
929
+ // hidden one rather than acting on raw `.first()` (a hidden duplicate — an
930
+ // off-screen menu clone, an aria-hidden mirror — is almost never the intended
931
+ // target). Degrades to `.first()` when the locator API lacks a visible filter
932
+ // or no candidate is visible.
933
+ async function pickBestCandidate(baseLocator, matchCount) {
934
+ if (matchCount <= 1) {
935
+ return baseLocator.first();
936
+ }
937
+ try {
938
+ if (typeof baseLocator.filter === "function") {
939
+ const visible = baseLocator.filter({ visible: true });
940
+ if ((await visible.count()) > 0) {
941
+ return visible.first();
942
+ }
943
+ }
944
+ } catch (_) {
945
+ // Locator API without a `{ visible: true }` filter — fall through.
946
+ }
947
+ return baseLocator.first();
948
+ }
949
+
950
+ // Text matching prefers an EXACT, INTERACTIVE element over a looser substring /
951
+ // plain-text hit (see `resolveTextTarget`), so the first attempt lands on the
952
+ // chip or button the agent meant. When a genuine ambiguity survives that
953
+ // resolution (>1 candidate in the winning tier), we still act — on the best
954
+ // visible candidate — but push an actionable warning naming every candidate
955
+ // into the optional `warnings` array so the agent can switch to an exact
956
+ // selector; a zero-match throws an error that spells out the substring caveat
957
+ // and the exact-selector / URL-query-param alternatives.
958
+ async function performInteraction(
959
+ frame,
960
+ interaction,
961
+ { timeoutMs = 5000, warnings } = {},
962
+ ) {
473
963
  const { action, selector, text, value } = interaction || {};
474
964
 
475
- let locator;
965
+ let baseLocator;
476
966
  let targetDesc;
967
+ let matchedByText = false;
968
+ let matchCount;
477
969
  if (typeof text === "string" && text.length > 0) {
478
- locator = frame.getByText(text, { exact: false }).first();
970
+ const resolved = await resolveTextTarget(frame, text, action);
971
+ baseLocator = resolved.baseLocator;
972
+ matchCount = resolved.matchCount;
479
973
  targetDesc = `text "${text}"`;
974
+ matchedByText = true;
480
975
  } else if (typeof selector === "string" && selector.length > 0) {
481
- locator = frame.locator(selector).first();
976
+ baseLocator = frame.locator(selector);
977
+ matchCount = await baseLocator.count();
482
978
  targetDesc = `selector "${selector}"`;
483
979
  } else {
484
980
  throw new Error(
@@ -486,31 +982,136 @@ async function performInteraction(frame, interaction, { timeoutMs = 5000 } = {})
486
982
  );
487
983
  }
488
984
 
489
- const matchCount = await locator.count();
490
985
  if (matchCount === 0) {
491
986
  const candidates = await collectInteractiveLabels(frame);
492
987
  const candidateList =
493
988
  candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
494
989
  throw new Error(
495
990
  `preview-interact: no element matched ${targetDesc}. ` +
991
+ `Text is matched as a case-insensitive SUBSTRING, so a misspelled or ` +
992
+ `over-specific label matches nothing — prefer an exact/role/testid CSS ` +
993
+ `selector (e.g. {"selector":"[data-testid=\\"save\\"]"}) for a precise ` +
994
+ `target. Many filter/status/sort states are also reachable directly via a ` +
995
+ `URL query param (e.g. add "?status=active" to the path) with no ` +
996
+ `interaction at all. Candidate interactive labels: ${candidateList}`,
997
+ );
998
+ }
999
+
1000
+ const locator = await pickBestCandidate(baseLocator, matchCount);
1001
+
1002
+ // Ambiguity warning: a text target that still resolves >1 element within the
1003
+ // winning tier acts on the best visible candidate, which may not be the one
1004
+ // intended. Name the competing elements instead of silently proceeding. Only
1005
+ // fires for text matches — an explicit selector that matches many is the
1006
+ // caller's deliberate choice.
1007
+ if (matchedByText && matchCount > 1 && Array.isArray(warnings)) {
1008
+ warnings.push(
1009
+ `preview-interact: ${targetDesc} matched ${matchCount} elements — acting on the best visible candidate. ` +
1010
+ `Text resolution prefers an exact, interactive match, but several remain, so this may not be the element you meant. ` +
1011
+ `Candidates: ${(await describeMatchCandidates(baseLocator, matchCount)).join(" | ")}. ` +
1012
+ `Use an exact/role/testid selector to disambiguate.`,
1013
+ );
1014
+ }
1015
+
1016
+ try {
1017
+ switch (action) {
1018
+ case "click":
1019
+ await locator.click({ timeout: timeoutMs });
1020
+ break;
1021
+ case "fill":
1022
+ await locator.fill(value ?? "", { timeout: timeoutMs });
1023
+ break;
1024
+ case "press":
1025
+ await locator.press(value || "Enter", { timeout: timeoutMs });
1026
+ break;
1027
+ case "hover":
1028
+ // Reveals hover-only affordances (an action bar, a tooltip) — one of the
1029
+ // most common ephemeral states a resting-render screenshot misses.
1030
+ await locator.hover({ timeout: timeoutMs });
1031
+ break;
1032
+ default:
1033
+ throw new Error(
1034
+ `preview-interact: unknown action "${action}" (expected click | fill | press | hover)`,
1035
+ );
1036
+ }
1037
+ } catch (error) {
1038
+ const candidates = await collectInteractiveLabels(frame);
1039
+ const candidateList =
1040
+ candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
1041
+ throw new Error(
1042
+ `preview-interact: action "${action}" failed against ${targetDesc}: ${error.message || String(error)}. ` +
496
1043
  `Candidate interactive labels: ${candidateList}`,
497
1044
  );
498
1045
  }
1046
+ }
499
1047
 
500
- switch (action) {
501
- case "click":
502
- await locator.click({ timeout: timeoutMs });
503
- break;
504
- case "fill":
505
- await locator.fill(value ?? "", { timeout: timeoutMs });
506
- break;
507
- case "press":
508
- await locator.press(value || "Enter", { timeout: timeoutMs });
509
- break;
510
- default:
511
- throw new Error(
512
- `preview-interact: unknown action "${action}" (expected click | fill | press)`,
513
- );
1048
+ // Hold until a visible-text or selector predicate becomes true, bounded by a
1049
+ // wall-clock timeout. Behavioral demos hinge on transient states (an overlay
1050
+ // appears, then a new round renders); a flow step waits for that real signal
1051
+ // instead of a fixed sleep — the same "hold to a real signal with a safety
1052
+ // bound" rule the rest of the capture pipeline follows. THROWS on timeout
1053
+ // naming the predicate and the bound, so the capture script's outer catch
1054
+ // turns a never-appearing predicate into an actionable failure rather than an
1055
+ // infinite hang. `text` is matched case-insensitively as a substring (the
1056
+ // agent reliably knows the copy it rendered); `selector` is a CSS selector.
1057
+ async function waitForPredicate(frame, predicate, { defaultTimeoutMs = 8000 } = {}) {
1058
+ const { text, selector, timeoutMs } = predicate || {};
1059
+ const bound =
1060
+ typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : defaultTimeoutMs;
1061
+
1062
+ let locator;
1063
+ let desc;
1064
+ if (typeof text === "string" && text.length > 0) {
1065
+ locator = frame.getByText(text, { exact: false }).first();
1066
+ desc = `text "${text}"`;
1067
+ } else if (typeof selector === "string" && selector.length > 0) {
1068
+ locator = frame.locator(selector).first();
1069
+ desc = `selector "${selector}"`;
1070
+ } else {
1071
+ throw new Error("waitFor: predicate requires a `text` or `selector` target");
1072
+ }
1073
+
1074
+ try {
1075
+ await locator.waitFor({ state: "visible", timeout: bound });
1076
+ } catch (_) {
1077
+ throw new Error(
1078
+ `waitFor: predicate ${desc} did not become visible within ${bound}ms`,
1079
+ );
1080
+ }
1081
+ }
1082
+
1083
+ // Drive an ordered sequence of interactions against the settled frame, settling
1084
+ // the page between each so a later step sees the DOM the earlier one produced.
1085
+ // This is the persisted-scenario path (`scenario.interactions`): unlike the
1086
+ // single fire-and-forget `preview-interact`, the whole sequence is replayed on
1087
+ // every capture and recapture. Any step that matches nothing throws (with the
1088
+ // candidate-labels hint from `performInteraction`), and the caller turns that
1089
+ // into a failed capture — never a silent resting-state screenshot for a
1090
+ // sequence that didn't fully run.
1091
+ // `settle` is injectable so unit tests that drive a mock frame stay
1092
+ // network-free and fast; production callers use the default real
1093
+ // `waitForStablePage` re-settle between steps.
1094
+ async function performInteractionSequence(
1095
+ page,
1096
+ frame,
1097
+ interactions,
1098
+ {
1099
+ timeoutMs = 5000,
1100
+ settleMs = 5000,
1101
+ loadingMarkers,
1102
+ settle = waitForStablePage,
1103
+ warnings,
1104
+ } = {},
1105
+ ) {
1106
+ for (let i = 0; i < interactions.length; i += 1) {
1107
+ try {
1108
+ await performInteraction(frame, interactions[i], { timeoutMs, warnings });
1109
+ } catch (err) {
1110
+ // Prefix the failing step's index so a miss in a multi-step sequence is
1111
+ // locatable, matching the model-side `interactions[i]` validator.
1112
+ throw new Error(`interactions[${i}]: ${err.message}`);
1113
+ }
1114
+ await settle(page, frame, settleMs, loadingMarkers);
514
1115
  }
515
1116
  }
516
1117
 
@@ -520,7 +1121,14 @@ module.exports = {
520
1121
  assertAppPortReachable,
521
1122
  escapeHtmlAttribute,
522
1123
  buildIframeHarness,
1124
+ HARNESS_PATH,
1125
+ resolveHarnessOrigin,
1126
+ buildHarnessUrl,
523
1127
  collectContentState,
1128
+ scrollThroughDocument,
1129
+ collectVisibleTextLength,
1130
+ forceFinalVisualState,
1131
+ centerCaptureWrapper,
524
1132
  collectImageStates,
525
1133
  waitForImagesSettled,
526
1134
  waitForAnimationsSettled,
@@ -530,5 +1138,8 @@ module.exports = {
530
1138
  loadScenarioInIframe,
531
1139
  loadScenarioTopLevel,
532
1140
  collectInteractiveLabels,
1141
+ describeMatchCandidates,
533
1142
  performInteraction,
1143
+ waitForPredicate,
1144
+ performInteractionSequence,
534
1145
  };