@codeyam-editor/codeyam-editor 0.1.5 → 0.1.7

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 (80) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud.js +2079 -242
  3. package/npm/codesign-plan.js +68 -0
  4. package/npm/compose-conflict.js +59 -0
  5. package/npm/empty-project-dir.js +92 -7
  6. package/npm/fleet-add-failure.js +361 -0
  7. package/npm/fleet-asset-version.js +37 -0
  8. package/npm/fleet-auth-health.js +143 -5
  9. package/npm/fleet-build-data.js +60 -5
  10. package/npm/fleet-cleanup.js +243 -9
  11. package/npm/fleet-destroy-safety.js +230 -13
  12. package/npm/fleet-early-roster.js +87 -0
  13. package/npm/fleet-effect-poll.js +110 -0
  14. package/npm/fleet-env.js +86 -0
  15. package/npm/fleet-ghcr-secret.js +129 -0
  16. package/npm/fleet-git-maintenance.js +142 -0
  17. package/npm/fleet-host-op-limiter.js +68 -0
  18. package/npm/fleet-iap-token.js +165 -0
  19. package/npm/fleet-job-badge.js +28 -12
  20. package/npm/fleet-live-status.js +40 -8
  21. package/npm/fleet-memory.js +289 -9
  22. package/npm/fleet-plans.js +98 -4
  23. package/npm/fleet-pool.js +75 -16
  24. package/npm/fleet-prewarm.js +87 -11
  25. package/npm/fleet-probe-transport.js +155 -0
  26. package/npm/fleet-project-drift.js +58 -0
  27. package/npm/fleet-public-url.js +234 -0
  28. package/npm/fleet-queue.js +58 -1
  29. package/npm/fleet-reachability-debounce.js +69 -0
  30. package/npm/fleet-reachability.js +80 -0
  31. package/npm/fleet-reset.js +32 -0
  32. package/npm/fleet-revision.js +104 -0
  33. package/npm/fleet-running-plan.js +115 -0
  34. package/npm/fleet-session-token-cache.js +65 -0
  35. package/npm/fleet-setup-steps.js +19 -12
  36. package/npm/fleet-status-refs.js +96 -0
  37. package/npm/fleet-strand.js +39 -8
  38. package/npm/fleet-stuck-status.js +13 -7
  39. package/npm/fleet-vm-access.js +170 -0
  40. package/npm/fleet-vm-probe-target.js +282 -0
  41. package/npm/fleet-vm-readiness.js +136 -3
  42. package/npm/google-ingress.js +15 -0
  43. package/npm/ingress-invoke.js +478 -0
  44. package/npm/launch-config.js +302 -10
  45. package/npm/scenario-check.js +318 -12
  46. package/npm/scenario-handlers.js +85 -27
  47. package/npm/scenario-interactivity.js +145 -6
  48. package/npm/scenario-issues.js +13 -1
  49. package/npm/scenario-mocks.js +129 -26
  50. package/npm/scenario-playwright.js +330 -22
  51. package/npm/script-drift.js +72 -1
  52. package/npm/utils.js +37 -3
  53. package/package.json +5 -5
  54. package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
  55. package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
  56. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  57. package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
  58. package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
  59. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  60. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
  61. package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
  62. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
  63. package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
  64. package/ui/dist/assets/index-CjxWhkSy.css +1 -0
  65. package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
  66. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  67. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  68. package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
  69. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
  70. package/ui/dist/index.html +4 -4
  71. package/ui/dist/open-source/codeyam-counter.png +0 -0
  72. package/ui/dist/open-source/el-carot.png +0 -0
  73. package/ui/dist/open-source/tabcommand.png +0 -0
  74. package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
  75. package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
  76. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
  77. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
  78. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
  79. package/ui/dist/assets/index-CIXZkwfB.css +0 -1
  80. package/ui/dist/assets/react-CSS0HapR.js +0 -28
@@ -146,20 +146,54 @@ function defaultReadServerState() {
146
146
  return JSON.parse(fs.readFileSync(statePath, "utf8"));
147
147
  }
148
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 } = {}) {
149
+ // Resolve the loopback origin that serves the iframe-harness route. The harness
150
+ // is mounted on the editor's control-api listener, whose port is recorded in
151
+ // `.codeyam/server-state.json` as `controlPort` (the same file the top-level
152
+ // loader already reads for `appPort`). Returns `http://127.0.0.1:<controlPort>`
153
+ // — a secure context the nested iframe inherits, exactly as `localhost` is — or
154
+ // null when the state file is missing/unreadable, in which case the caller falls
155
+ // back to the legacy in-page `setContent` harness. `readStateFile` is injectable
156
+ // so the resolver is unit-testable without disk.
157
+ //
158
+ // The HOST is taken from `targetUrl` — the URL the iframe will load — and only
159
+ // the port comes from server-state. That mirroring is load-bearing, because the
160
+ // two capture modes address the editor by different loopback spellings:
161
+ // `/__codeyam_preview` captures are pinned to `127.0.0.1` (PROXY_CAPTURE_LOOPBACK
162
+ // in handlers.rs, matching the forwarder's own pinning) while direct app-port
163
+ // captures use `localhost`. `localhost` and `127.0.0.1` are DIFFERENT sites to
164
+ // the cookie jar, so whenever the harness host and the iframe host disagree the
165
+ // nested load is cross-site — and the `cy_session` cookie is `SameSite=Lax`,
166
+ // which rides top-level navigations only. It is therefore withheld from the
167
+ // iframe request (and from the `/api/*` calls the framed page makes), and the
168
+ // token-gated routes answer 401 on a non-loopback bind.
169
+ //
170
+ // Hardcoding EITHER spelling only moves the failure between the two modes:
171
+ // `localhost` 401s every `/__codeyam_preview` capture, `127.0.0.1` 401s the
172
+ // framed page's own `/api/scenarios` + `/api/render-environment` calls. Raising
173
+ // the cookie to `SameSite=None` fixes neither — Chromium rejects `None` without
174
+ // `Secure`, and these origins are plain http. Mirroring the target's host is what
175
+ // keeps the harness same-site in both modes, which is also the same-origin model
176
+ // the `/__codeyam_preview` subpath proxy exists to provide.
177
+ //
178
+ // `targetUrl` omitted or unparseable falls back to `127.0.0.1`, the spelling the
179
+ // proxy-route capture (the token-gated one) uses.
180
+ function resolveHarnessOrigin({
181
+ readStateFile = defaultReadServerState,
182
+ targetUrl = null,
183
+ } = {}) {
158
184
  try {
159
185
  const state = readStateFile();
160
186
  const port = state && state.controlPort;
161
187
  if (typeof port === "number" && port > 0) {
162
- return `http://localhost:${port}`;
188
+ let host = "127.0.0.1";
189
+ if (targetUrl) {
190
+ try {
191
+ host = new URL(targetUrl).hostname || host;
192
+ } catch (_) {
193
+ /* unparseable target — keep the proxy-route default */
194
+ }
195
+ }
196
+ return `http://${host}:${port}`;
163
197
  }
164
198
  } catch (_) {
165
199
  /* missing/unreadable state — fall back to setContent */
@@ -336,6 +370,110 @@ async function forceFinalVisualState(target) {
336
370
  });
337
371
  }
338
372
 
373
+ // Center the isolated-component wrapper in the viewport before the shot.
374
+ //
375
+ // The capture pipeline always shoots the full viewport (`page.screenshot({
376
+ // fullPage: false })`) and never an element clip, so an isolation page that
377
+ // does not center its own component strands it in the top-left corner of every
378
+ // frame. Next.js does this in its isolation layout and the query-param stacks
379
+ // do it in `.codeyam/harness/isolate.tsx`, but the server-rendered scaffolds
380
+ // historically did not — and neither do pages hand-authored before that was
381
+ // fixed. This pass closes the gap at capture time, for any stack.
382
+ //
383
+ // It MEASURES rather than assumes: a wrapper that is already centered is left
384
+ // alone, so pages that already do the right thing are byte-identical. The
385
+ // `#codeyam-capture` marker (the cross-stack isolation convention; expo emits
386
+ // it via `nativeID`) is the gate, so an ordinary route capture is never
387
+ // touched. Centering is applied to `<body>` and to each ancestor strictly
388
+ // between body and the wrapper — never to the wrapper itself, which frequently
389
+ // carries the component's own padding/background and must keep its own box.
390
+ // Each axis independently falls back to `flex-start` when the wrapper overflows
391
+ // the viewport, so an oversized component loses its bottom/right edge rather
392
+ // than being clipped symmetrically out of its top/left one.
393
+ //
394
+ // Idempotent (a single injected style id), best-effort, and returns a small
395
+ // result object describing what it did.
396
+ async function centerCaptureWrapper(target) {
397
+ return target.evaluate(() => {
398
+ const STYLE_ID = "__codeyam_center_capture";
399
+ const ANCESTOR_CLASS = "__codeyam-center-ancestor";
400
+ // Subpixel layout and fractional scrollbar widths make exact gap equality
401
+ // unreliable; a couple of px of slop keeps already-centered pages on the
402
+ // no-op branch.
403
+ const TOLERANCE_PX = 2;
404
+
405
+ if (typeof document.getElementById !== "function") {
406
+ return { applied: false, reason: "no-dom" };
407
+ }
408
+ if (document.getElementById(STYLE_ID)) {
409
+ return { applied: false, reason: "already-applied" };
410
+ }
411
+ const wrapper = document.getElementById("codeyam-capture");
412
+ if (!wrapper || typeof wrapper.getBoundingClientRect !== "function") {
413
+ return { applied: false, reason: "no-capture-marker" };
414
+ }
415
+
416
+ const rect = wrapper.getBoundingClientRect();
417
+ const viewportWidth = window.innerWidth;
418
+ const viewportHeight = window.innerHeight;
419
+
420
+ const leftGap = rect.left;
421
+ const rightGap = viewportWidth - rect.right;
422
+ const topGap = rect.top;
423
+ const bottomGap = viewportHeight - rect.bottom;
424
+
425
+ const fitsHorizontally = rect.width <= viewportWidth;
426
+ const fitsVertically = rect.height <= viewportHeight;
427
+ const centeredHorizontally =
428
+ fitsHorizontally && Math.abs(leftGap - rightGap) <= TOLERANCE_PX;
429
+ const centeredVertically =
430
+ fitsVertically && Math.abs(topGap - bottomGap) <= TOLERANCE_PX;
431
+
432
+ if (centeredHorizontally && centeredVertically) {
433
+ return { applied: false, reason: "already-centered" };
434
+ }
435
+
436
+ const horizontal = fitsHorizontally ? "center" : "flex-start";
437
+ const vertical = fitsVertically ? "center" : "flex-start";
438
+
439
+ // Stamp the ancestor chain so the injected rule targets exactly the
440
+ // elements between <body> and the wrapper and cannot leak elsewhere.
441
+ let node = wrapper.parentElement;
442
+ while (node && node !== document.body) {
443
+ if (node.classList && typeof node.classList.add === "function") {
444
+ node.classList.add(ANCESTOR_CLASS);
445
+ }
446
+ node = node.parentElement;
447
+ }
448
+
449
+ if (typeof document.createElement !== "function") {
450
+ return { applied: false, reason: "no-dom" };
451
+ }
452
+ const style = document.createElement("style");
453
+ style.id = STYLE_ID;
454
+ style.textContent =
455
+ "body, ." +
456
+ ANCESTOR_CLASS +
457
+ " {" +
458
+ "margin: 0;" +
459
+ "min-height: 100vh;" +
460
+ "display: flex;" +
461
+ "align-items: " +
462
+ vertical +
463
+ ";" +
464
+ "justify-content: " +
465
+ horizontal +
466
+ ";" +
467
+ "}";
468
+ const head = document.head || document.documentElement;
469
+ if (!head || typeof head.appendChild !== "function") {
470
+ return { applied: false, reason: "no-dom" };
471
+ }
472
+ head.appendChild(style);
473
+ return { applied: true, horizontal, vertical };
474
+ });
475
+ }
476
+
339
477
  async function collectImageStates(target) {
340
478
  return target.evaluate(() =>
341
479
  Array.from(document.images || []).map((img) => ({
@@ -575,9 +713,31 @@ async function loadScenarioInIframe(
575
713
  // and the nested scenario iframe inherits it (the Sveltia-class fix). The
576
714
  // inner iframe `src` is still `url`, so the document-response probe above is
577
715
  // unchanged.
578
- await page.goto(buildHarnessUrl(resolvedHarnessOrigin, url, background), {
716
+ const harnessUrl = buildHarnessUrl(resolvedHarnessOrigin, url, background);
717
+ const harnessResponse = await page.goto(harnessUrl, {
579
718
  waitUntil: "domcontentloaded",
580
719
  });
720
+ // Fail loudly on a fail-closed harness-auth rejection BEFORE the blind 30s
721
+ // `#scenario-frame` wait below. On a non-loopback (0.0.0.0 / cloud) bind the
722
+ // harness control-API route is session-token-gated; a generated
723
+ // `.codeyam/capture.js` (or an editor binary) that predates the `cy_session`
724
+ // cookie injection navigates without a token -> the server refuses the
725
+ // request with 401 -> the harness document never renders -> every component
726
+ // scenario times out on `#scenario-frame` with a generic Playwright error
727
+ // that hides the real cause. Surfacing the 401 here turns that blind timeout
728
+ // into a one-line root cause on the very first failing capture, naming both
729
+ // fixes. (Route/page scenarios navigate top-level to the un-gated app proxy,
730
+ // so they never hit this path.)
731
+ if (harnessResponse && harnessResponse.status() === 401) {
732
+ throw new Error(
733
+ `Capture harness route ${harnessUrl} returned 401 (fail-closed ` +
734
+ `session-token auth on a non-loopback bind). The capture script did not ` +
735
+ `send the \`cy_session\` token — most often a generated ` +
736
+ `\`.codeyam/capture.js\` (or an editor binary) that predates session-token ` +
737
+ `injection. Fixes: refresh the editor binary on this VM, or set ` +
738
+ `\`CODEYAM_INSECURE_BIND=1\` and restart the editor.`,
739
+ );
740
+ }
581
741
  } else {
582
742
  // Degraded fallback: no resolvable harness origin (server-state missing), so
583
743
  // use the legacy in-page harness. The top-level document is then
@@ -677,6 +837,14 @@ async function loadScenarioTopLevel(
677
837
  }
678
838
  }
679
839
 
840
+ // The CSS selector for "clickable / interactive" elements — buttons, links,
841
+ // role=button, form controls, <summary>, and anything with an onclick. Shared
842
+ // by the candidate-label collector (below) and the text-target resolver
843
+ // (`resolveTextTarget`), which prefers an interactive element over a plain
844
+ // text node when a click/press label matches both.
845
+ const INTERACTIVE_SELECTOR =
846
+ "button, a[href], [role=button], input, select, textarea, summary, [onclick]";
847
+
680
848
  // Collect up to 20 distinct visible labels of interactive elements on the
681
849
  // page — buttons, links, role=button, form controls, <summary>, and anything
682
850
  // with an onclick. Used to build an ACTIONABLE error when an interaction's
@@ -685,9 +853,7 @@ async function loadScenarioTopLevel(
685
853
  // mean one of these?" hint. Pure read (no clicks); falls back to value /
686
854
  // aria-label / placeholder when an element has no text.
687
855
  async function collectInteractiveLabels(frame) {
688
- return frame.evaluate(() => {
689
- const selector =
690
- "button, a[href], [role=button], input, select, textarea, summary, [onclick]";
856
+ return frame.evaluate((selector) => {
691
857
  const nodes = Array.from(document.querySelectorAll(selector));
692
858
  const labels = nodes
693
859
  .map((node) => {
@@ -701,7 +867,39 @@ async function collectInteractiveLabels(frame) {
701
867
  })
702
868
  .filter((label) => label.length > 0);
703
869
  return Array.from(new Set(labels)).slice(0, 20);
704
- });
870
+ }, INTERACTIVE_SELECTOR);
871
+ }
872
+
873
+ // Describe the elements a substring text match resolved, so an ambiguity
874
+ // warning can name the competing controls (e.g. the preset button "Bet" vs the
875
+ // disclosure button "…or bet on"). Reads each matched element's inner text via
876
+ // Playwright's `allInnerTexts`, caps the list at 5, and trims each to a single
877
+ // short line. Pure read; degrades to a count-only description if the locator
878
+ // API or the page can't produce texts.
879
+ async function describeMatchCandidates(baseLocator, matchCount) {
880
+ const CAP = 5;
881
+ const MAX_LEN = 60;
882
+ let texts = [];
883
+ try {
884
+ if (typeof baseLocator.allInnerTexts === "function") {
885
+ texts = await baseLocator.allInnerTexts();
886
+ }
887
+ } catch (_) {
888
+ texts = [];
889
+ }
890
+ const described = texts
891
+ .map((t) => String(t).replace(/\s+/g, " ").trim())
892
+ .filter((t) => t.length > 0)
893
+ .slice(0, CAP)
894
+ .map((t) => (t.length > MAX_LEN ? `${t.slice(0, MAX_LEN)}…` : t))
895
+ .map((t) => `"${t}"`);
896
+ if (described.length === 0) {
897
+ return [`${matchCount} elements (text unavailable)`];
898
+ }
899
+ if (matchCount > described.length) {
900
+ described.push(`…and ${matchCount - described.length} more`);
901
+ }
902
+ return described;
705
903
  }
706
904
 
707
905
  // Drive a single user-style interaction against the settled frame before the
@@ -714,16 +912,103 @@ async function collectInteractiveLabels(frame) {
714
912
  // `Enter`). On a no-match target this THROWS with the list of candidate
715
913
  // interactive labels — the capture script's outer catch turns that into a
716
914
  // failed capture with an actionable message, never a silent blank screenshot.
717
- async function performInteraction(frame, interaction, { timeoutMs = 5000 } = {}) {
915
+ //
916
+ // Resolve a visible-text target to the element most likely intended, instead
917
+ // of blindly taking the first substring hit. A bare
918
+ // `getByText(text, { exact: false })` is a case-insensitive SUBSTRING match, so
919
+ // a label can land on a plain text node or a placeholder rather than the chip /
920
+ // button the agent meant (observed: a `"Japan"` click hit placeholder text, not
921
+ // the chip; a `"Bet"` click hit a disclosure button described "or bet on"). We
922
+ // try increasingly-loose locators in priority order and take the first that
923
+ // matches anything:
924
+ // 1. exact + interactive (for click/press)
925
+ // 2. exact
926
+ // 3. substring + interactive (for click/press)
927
+ // 4. substring
928
+ // so an exact, clickable element wins over a substring plain-text node. Returns
929
+ // the winning tier's locator plus its match count; when nothing matches at all,
930
+ // returns the substring locator (count 0) so the caller's zero-match error path
931
+ // still fires with its candidate-label hint.
932
+ async function resolveTextTarget(frame, text, action) {
933
+ const preferInteractive = action === "click" || action === "press";
934
+ const exactBase = frame.getByText(text, { exact: true });
935
+ const substrBase = frame.getByText(text, { exact: false });
936
+
937
+ const tiers = [];
938
+ // `.and()` (Playwright ≥1.34) intersects two locators to the elements
939
+ // matching both — here, the text element that is ALSO interactive. Guard on
940
+ // its presence so a locator without `.and` degrades to text-only tiers.
941
+ if (preferInteractive && typeof exactBase.and === "function") {
942
+ const interactive = frame.locator(INTERACTIVE_SELECTOR);
943
+ tiers.push(exactBase.and(interactive));
944
+ tiers.push(exactBase);
945
+ tiers.push(substrBase.and(interactive));
946
+ tiers.push(substrBase);
947
+ } else {
948
+ tiers.push(exactBase);
949
+ tiers.push(substrBase);
950
+ }
951
+
952
+ for (const loc of tiers) {
953
+ const count = await loc.count();
954
+ if (count > 0) {
955
+ return { baseLocator: loc, matchCount: count };
956
+ }
957
+ }
958
+ return { baseLocator: substrBase, matchCount: 0 };
959
+ }
960
+
961
+ // Pick the single element to act on from a resolved locator. When more than one
962
+ // candidate remains within the winning tier, prefer a VISIBLE element over a
963
+ // hidden one rather than acting on raw `.first()` (a hidden duplicate — an
964
+ // off-screen menu clone, an aria-hidden mirror — is almost never the intended
965
+ // target). Degrades to `.first()` when the locator API lacks a visible filter
966
+ // or no candidate is visible.
967
+ async function pickBestCandidate(baseLocator, matchCount) {
968
+ if (matchCount <= 1) {
969
+ return baseLocator.first();
970
+ }
971
+ try {
972
+ if (typeof baseLocator.filter === "function") {
973
+ const visible = baseLocator.filter({ visible: true });
974
+ if ((await visible.count()) > 0) {
975
+ return visible.first();
976
+ }
977
+ }
978
+ } catch (_) {
979
+ // Locator API without a `{ visible: true }` filter — fall through.
980
+ }
981
+ return baseLocator.first();
982
+ }
983
+
984
+ // Text matching prefers an EXACT, INTERACTIVE element over a looser substring /
985
+ // plain-text hit (see `resolveTextTarget`), so the first attempt lands on the
986
+ // chip or button the agent meant. When a genuine ambiguity survives that
987
+ // resolution (>1 candidate in the winning tier), we still act — on the best
988
+ // visible candidate — but push an actionable warning naming every candidate
989
+ // into the optional `warnings` array so the agent can switch to an exact
990
+ // selector; a zero-match throws an error that spells out the substring caveat
991
+ // and the exact-selector / URL-query-param alternatives.
992
+ async function performInteraction(
993
+ frame,
994
+ interaction,
995
+ { timeoutMs = 5000, warnings } = {},
996
+ ) {
718
997
  const { action, selector, text, value } = interaction || {};
719
998
 
720
- let locator;
999
+ let baseLocator;
721
1000
  let targetDesc;
1001
+ let matchedByText = false;
1002
+ let matchCount;
722
1003
  if (typeof text === "string" && text.length > 0) {
723
- locator = frame.getByText(text, { exact: false }).first();
1004
+ const resolved = await resolveTextTarget(frame, text, action);
1005
+ baseLocator = resolved.baseLocator;
1006
+ matchCount = resolved.matchCount;
724
1007
  targetDesc = `text "${text}"`;
1008
+ matchedByText = true;
725
1009
  } else if (typeof selector === "string" && selector.length > 0) {
726
- locator = frame.locator(selector).first();
1010
+ baseLocator = frame.locator(selector);
1011
+ matchCount = await baseLocator.count();
727
1012
  targetDesc = `selector "${selector}"`;
728
1013
  } else {
729
1014
  throw new Error(
@@ -731,14 +1016,34 @@ async function performInteraction(frame, interaction, { timeoutMs = 5000 } = {})
731
1016
  );
732
1017
  }
733
1018
 
734
- const matchCount = await locator.count();
735
1019
  if (matchCount === 0) {
736
1020
  const candidates = await collectInteractiveLabels(frame);
737
1021
  const candidateList =
738
1022
  candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
739
1023
  throw new Error(
740
1024
  `preview-interact: no element matched ${targetDesc}. ` +
741
- `Candidate interactive labels: ${candidateList}`,
1025
+ `Text is matched as a case-insensitive SUBSTRING, so a misspelled or ` +
1026
+ `over-specific label matches nothing — prefer an exact/role/testid CSS ` +
1027
+ `selector (e.g. {"selector":"[data-testid=\\"save\\"]"}) for a precise ` +
1028
+ `target. Many filter/status/sort states are also reachable directly via a ` +
1029
+ `URL query param (e.g. add "?status=active" to the path) with no ` +
1030
+ `interaction at all. Candidate interactive labels: ${candidateList}`,
1031
+ );
1032
+ }
1033
+
1034
+ const locator = await pickBestCandidate(baseLocator, matchCount);
1035
+
1036
+ // Ambiguity warning: a text target that still resolves >1 element within the
1037
+ // winning tier acts on the best visible candidate, which may not be the one
1038
+ // intended. Name the competing elements instead of silently proceeding. Only
1039
+ // fires for text matches — an explicit selector that matches many is the
1040
+ // caller's deliberate choice.
1041
+ if (matchedByText && matchCount > 1 && Array.isArray(warnings)) {
1042
+ warnings.push(
1043
+ `preview-interact: ${targetDesc} matched ${matchCount} elements — acting on the best visible candidate. ` +
1044
+ `Text resolution prefers an exact, interactive match, but several remain, so this may not be the element you meant. ` +
1045
+ `Candidates: ${(await describeMatchCandidates(baseLocator, matchCount)).join(" | ")}. ` +
1046
+ `Use an exact/role/testid selector to disambiguate.`,
742
1047
  );
743
1048
  }
744
1049
 
@@ -829,11 +1134,12 @@ async function performInteractionSequence(
829
1134
  settleMs = 5000,
830
1135
  loadingMarkers,
831
1136
  settle = waitForStablePage,
1137
+ warnings,
832
1138
  } = {},
833
1139
  ) {
834
1140
  for (let i = 0; i < interactions.length; i += 1) {
835
1141
  try {
836
- await performInteraction(frame, interactions[i], { timeoutMs });
1142
+ await performInteraction(frame, interactions[i], { timeoutMs, warnings });
837
1143
  } catch (err) {
838
1144
  // Prefix the failing step's index so a miss in a multi-step sequence is
839
1145
  // locatable, matching the model-side `interactions[i]` validator.
@@ -856,6 +1162,7 @@ module.exports = {
856
1162
  scrollThroughDocument,
857
1163
  collectVisibleTextLength,
858
1164
  forceFinalVisualState,
1165
+ centerCaptureWrapper,
859
1166
  collectImageStates,
860
1167
  waitForImagesSettled,
861
1168
  waitForAnimationsSettled,
@@ -865,6 +1172,7 @@ module.exports = {
865
1172
  loadScenarioInIframe,
866
1173
  loadScenarioTopLevel,
867
1174
  collectInteractiveLabels,
1175
+ describeMatchCandidates,
868
1176
  performInteraction,
869
1177
  waitForPredicate,
870
1178
  performInteractionSequence,
@@ -76,6 +76,77 @@ function classifyScriptDrift({ files }) {
76
76
  return { verdict: 'clean', staleFiles: [] };
77
77
  }
78
78
 
79
+ /**
80
+ * Decide, for one streamed provisioning script, whether the VM should receive the
81
+ * canonical remote-tip bytes or the local on-disk copy. The remote tip is the
82
+ * reviewed, canonical source: a checkout that is merely *behind* origin (a normal,
83
+ * expected consequence of an active fleet landing commits to these scripts) then
84
+ * streams exactly the bytes the drift guard expects — never stale on-disk bytes,
85
+ * never a false STALE abort. The local copy wins only when the operator authored
86
+ * content the remote tip does not have — an uncommitted (dirty) edit or a committed
87
+ * local-ahead change (developer mode / diverged) — or when the remote facts are
88
+ * unavailable (offline fallback, so an offline operator can still provision).
89
+ *
90
+ * @param {Object} file
91
+ * @param {string|null} file.localHash
92
+ * @param {string|null} file.remoteHash null/undefined ⇒ remote unreachable
93
+ * @param {string|null} file.baseHash
94
+ * @param {boolean} file.dirty
95
+ * @returns {'remote' | 'local'}
96
+ */
97
+ function streamedScriptSource({ localHash, remoteHash, baseHash, dirty }) {
98
+ // Remote unreachable / ref missing → the only bytes we have are on disk.
99
+ if (remoteHash === null || remoteHash === undefined) {
100
+ return 'local';
101
+ }
102
+ // Uncommitted local edit → the operator is provisioning unreviewed bytes on
103
+ // purpose (developer mode, or an explicit CODEYAM_SKIP_SCRIPT_DRIFT_CHECK bypass).
104
+ if (dirty) {
105
+ return 'local';
106
+ }
107
+ // Local content the remote tip does not have (committed local-ahead, or a genuine
108
+ // divergence): local differs from the remote tip AND from the merge base → honor
109
+ // the local commit rather than silently overwriting it with the remote tip.
110
+ if (localHash !== remoteHash && localHash !== baseHash) {
111
+ return 'local';
112
+ }
113
+ // Clean, or merely behind the remote tip → stream the canonical remote-tip bytes.
114
+ return 'remote';
115
+ }
116
+
117
+ /**
118
+ * Decide what the streamed-script drift preflight should do, from the classifier
119
+ * verdict plus which streamed files carry uncommitted edits. A checkout that is
120
+ * merely *behind* origin (verdict 'stale') is benign — the add streams the
121
+ * canonical remote-tip bytes (see streamedScriptSource), so it never blocks. The
122
+ * guard fires ('block') only for local content the remote tip does not have: an
123
+ * uncommitted (dirty) edit, or a genuine divergence. A committed local-ahead
124
+ * change is developer mode — warn, don't block.
125
+ *
126
+ * @param {Object} o
127
+ * @param {string} o.verdict classifyScriptDrift verdict
128
+ * @param {string[]} [o.staleFiles] files the classifier flagged (the diverged set)
129
+ * @param {string[]} [o.dirtyFiles] streamed files with uncommitted local edits
130
+ * @returns {{ block: boolean, reason: 'clean'|'behind'|'local-ahead'|'dirty'|'diverged', blockedFiles: string[] }}
131
+ */
132
+ function scriptDriftGuardAction({ verdict, staleFiles = [], dirtyFiles = [] }) {
133
+ if (verdict === 'diverged') {
134
+ return { block: true, reason: 'diverged', blockedFiles: staleFiles };
135
+ }
136
+ if (dirtyFiles.length > 0) {
137
+ return { block: true, reason: 'dirty', blockedFiles: dirtyFiles };
138
+ }
139
+ if (verdict === 'stale') {
140
+ return { block: false, reason: 'behind', blockedFiles: [] };
141
+ }
142
+ if (verdict === 'local-ahead') {
143
+ return { block: false, reason: 'local-ahead', blockedFiles: [] };
144
+ }
145
+ return { block: false, reason: 'clean', blockedFiles: [] };
146
+ }
147
+
79
148
  module.exports = {
80
- classifyScriptDrift
149
+ classifyScriptDrift,
150
+ streamedScriptSource,
151
+ scriptDriftGuardAction
81
152
  };
package/npm/utils.js CHANGED
@@ -103,6 +103,34 @@ function findBinary() {
103
103
  return null;
104
104
  }
105
105
 
106
+ /**
107
+ * Whether this install is a dev checkout (the source tree) rather than an
108
+ * npm-installed package. Signalled by a `Cargo.toml` at `rootDir` — present in
109
+ * the git checkout, absent in the npm install dir. The single source of truth
110
+ * for the checkout-vs-installed distinction; both `ensureBinary` (build-from-
111
+ * source vs error) and `serverSpawnEnv` (distribution-channel gating) read it.
112
+ * Accepts an explicit dir so it is unit-testable against a temp tree.
113
+ */
114
+ function isDevCheckout(dir = rootDir) {
115
+ return fs.existsSync(path.join(dir, "Cargo.toml"));
116
+ }
117
+
118
+ /**
119
+ * Env for a launched editor-server, tagging the distribution channel so the
120
+ * server's update-availability check knows an npm install can be upgraded via
121
+ * `npm install -g …@latest`. Pure: returns a NEW object, never mutates the
122
+ * input.
123
+ *
124
+ * Installed (non-dev-checkout) → set `CODEYAM_DISTRIBUTION=npm`, so the server
125
+ * polls the npm registry and surfaces the upgrade banner. Dev checkout → leave
126
+ * the env untouched (any pre-existing value from the parent env is preserved),
127
+ * so a `codeyam-editor` run from source is never nagged to "upgrade".
128
+ */
129
+ function serverSpawnEnv(baseEnv, { devCheckout } = {}) {
130
+ if (devCheckout) return { ...baseEnv };
131
+ return { ...baseEnv, CODEYAM_DISTRIBUTION: "npm" };
132
+ }
133
+
106
134
  /**
107
135
  * Return the binary path, building from source if we're in a dev checkout.
108
136
  *
@@ -120,9 +148,9 @@ function ensureBinary() {
120
148
  const existing = findBinary();
121
149
  if (existing) return existing;
122
150
 
123
- const isDevCheckout = fs.existsSync(path.join(rootDir, "Cargo.toml"));
151
+ const devCheckout = isDevCheckout();
124
152
 
125
- if (!isDevCheckout) {
153
+ if (!devCheckout) {
126
154
  const info = platformPackageInfo();
127
155
  if (!info) {
128
156
  console.error(
@@ -1051,7 +1079,11 @@ async function ensureServer(port = 14199) {
1051
1079
  const child = spawn(
1052
1080
  binary,
1053
1081
  ["start", "--no-open", "--port", String(port)],
1054
- buildServerSpawnOptions({ cwd: process.cwd(), env: process.env, logFd }),
1082
+ buildServerSpawnOptions({
1083
+ cwd: process.cwd(),
1084
+ env: serverSpawnEnv(process.env, { devCheckout: isDevCheckout() }),
1085
+ logFd,
1086
+ }),
1055
1087
  );
1056
1088
  if (logFd !== null) {
1057
1089
  try {
@@ -1192,6 +1224,8 @@ module.exports = {
1192
1224
  findBrokerBinary,
1193
1225
  verifyBrokerBinaryPresent,
1194
1226
  ensureBinary,
1227
+ isDevCheckout,
1228
+ serverSpawnEnv,
1195
1229
  staleBinaryBanner,
1196
1230
  enforceFreshBinary,
1197
1231
  uiDistDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codeyam-editor/codeyam-editor",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Language-agnostic managed execution sandbox for scenario-driven development",
5
5
  "bin": {
6
6
  "codeyam-editor": "./npm/cli.js"
@@ -9,10 +9,10 @@
9
9
  "playwright": "^1.58.2"
10
10
  },
11
11
  "optionalDependencies": {
12
- "@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.5",
13
- "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.5",
14
- "@codeyam-editor/codeyam-editor-linux-x64": "0.1.5",
15
- "@codeyam-editor/codeyam-editor-win32-x64": "0.1.5"
12
+ "@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.7",
13
+ "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.7",
14
+ "@codeyam-editor/codeyam-editor-linux-x64": "0.1.7",
15
+ "@codeyam-editor/codeyam-editor-win32-x64": "0.1.7"
16
16
  },
17
17
  "keywords": [
18
18
  "codeyam",