@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.
- package/npm/cli.js +5 -1
- package/npm/cloud-branch-switch.js +1 -1
- package/npm/cloud.js +3166 -150
- package/npm/codesign-plan.js +68 -0
- package/npm/container.js +3 -0
- package/npm/disk-pressure.js +78 -0
- package/npm/editor-dev.js +96 -15
- package/npm/editor.js +85 -37
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +252 -0
- package/npm/fleet-asset-version.js +37 -0
- package/npm/fleet-auth-health.js +213 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +109 -5
- package/npm/fleet-cleanup.js +232 -16
- package/npm/fleet-destroy-safety.js +272 -0
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-effect-status.js +33 -32
- 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-host.js +74 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-job-badge.js +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +155 -0
- package/npm/fleet-memory.js +245 -9
- package/npm/fleet-plans.js +233 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +241 -0
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +394 -0
- package/npm/fleet-queue.js +95 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reconcile.js +43 -1
- package/npm/fleet-reset.js +330 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-running-plan.js +85 -0
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +112 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +181 -0
- package/npm/fleet-stuck-status.js +117 -0
- package/npm/fleet-vm-access.js +404 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +237 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +226 -0
- package/npm/ingress-invoke.js +770 -0
- package/npm/launch-config.js +386 -9
- package/npm/scenario-check.js +880 -37
- package/npm/scenario-handlers.js +154 -30
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +144 -21
- package/npm/scenario-playwright.js +649 -38
- package/npm/script-drift.js +152 -0
- package/npm/utils.js +365 -52
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
- package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
- package/ui/dist/assets/index-BygCiJSb.css +1 -0
- package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
- 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-S3Qk1Y_d.js +1 -0
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -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/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-BGqairFV.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
- 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
|
-
|
|
387
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
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
|
|
965
|
+
let baseLocator;
|
|
476
966
|
let targetDesc;
|
|
967
|
+
let matchedByText = false;
|
|
968
|
+
let matchCount;
|
|
477
969
|
if (typeof text === "string" && text.length > 0) {
|
|
478
|
-
|
|
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
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
};
|