@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.
- package/npm/cli.js +5 -1
- package/npm/cloud.js +2079 -242
- package/npm/codesign-plan.js +68 -0
- package/npm/compose-conflict.js +59 -0
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +361 -0
- package/npm/fleet-asset-version.js +37 -0
- package/npm/fleet-auth-health.js +143 -5
- package/npm/fleet-build-data.js +60 -5
- package/npm/fleet-cleanup.js +243 -9
- package/npm/fleet-destroy-safety.js +230 -13
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-env.js +86 -0
- package/npm/fleet-ghcr-secret.js +129 -0
- package/npm/fleet-git-maintenance.js +142 -0
- package/npm/fleet-host-op-limiter.js +68 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-job-badge.js +28 -12
- package/npm/fleet-live-status.js +40 -8
- package/npm/fleet-memory.js +289 -9
- package/npm/fleet-plans.js +98 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +87 -11
- package/npm/fleet-probe-transport.js +155 -0
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +234 -0
- package/npm/fleet-queue.js +58 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reset.js +32 -0
- package/npm/fleet-revision.js +104 -0
- package/npm/fleet-running-plan.js +115 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +19 -12
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +39 -8
- package/npm/fleet-stuck-status.js +13 -7
- package/npm/fleet-vm-access.js +170 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +136 -3
- package/npm/google-ingress.js +15 -0
- package/npm/ingress-invoke.js +478 -0
- package/npm/launch-config.js +302 -10
- package/npm/scenario-check.js +318 -12
- package/npm/scenario-handlers.js +85 -27
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-mocks.js +129 -26
- package/npm/scenario-playwright.js +330 -22
- package/npm/script-drift.js +72 -1
- package/npm/utils.js +37 -3
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
- package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
- package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
- package/ui/dist/assets/index-CjxWhkSy.css +1 -0
- package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
- package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
- package/ui/dist/assets/react-nrLBr15I.js +28 -0
- package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
- package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
- package/ui/dist/index.html +4 -4
- package/ui/dist/open-source/codeyam-counter.png +0 -0
- package/ui/dist/open-source/el-carot.png +0 -0
- package/ui/dist/open-source/tabcommand.png +0 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
- package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
- package/ui/dist/assets/index-CIXZkwfB.css +0 -1
- 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
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
999
|
+
let baseLocator;
|
|
721
1000
|
let targetDesc;
|
|
1001
|
+
let matchedByText = false;
|
|
1002
|
+
let matchCount;
|
|
722
1003
|
if (typeof text === "string" && text.length > 0) {
|
|
723
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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,
|
package/npm/script-drift.js
CHANGED
|
@@ -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
|
|
151
|
+
const devCheckout = isDevCheckout();
|
|
124
152
|
|
|
125
|
-
if (!
|
|
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({
|
|
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.
|
|
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.
|
|
13
|
-
"@codeyam-editor/codeyam-editor-darwin-x64": "0.1.
|
|
14
|
-
"@codeyam-editor/codeyam-editor-linux-x64": "0.1.
|
|
15
|
-
"@codeyam-editor/codeyam-editor-win32-x64": "0.1.
|
|
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",
|