@codeyam-editor/codeyam-editor 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm/cloud-branch-switch.js +1 -1
- package/npm/cloud.js +1693 -96
- package/npm/container.js +14 -0
- package/npm/disk-pressure.js +167 -0
- package/npm/editor-dev.js +107 -15
- package/npm/editor.js +97 -37
- package/npm/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +130 -0
- package/npm/fleet-cleanup.js +145 -7
- package/npm/fleet-destroy-safety.js +55 -0
- package/npm/fleet-effect-status.js +33 -32
- package/npm/fleet-host.js +74 -0
- package/npm/fleet-job-badge.js +46 -2
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +231 -0
- package/npm/fleet-prewarm.js +165 -0
- package/npm/fleet-public-url.js +160 -0
- package/npm/fleet-queue.js +38 -1
- package/npm/fleet-reconcile.js +100 -1
- package/npm/fleet-reset.js +298 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-setup-steps.js +105 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-strand.js +150 -0
- package/npm/fleet-stuck-status.js +111 -0
- package/npm/fleet-vm-access.js +234 -0
- package/npm/fleet-vm-readiness.js +104 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +211 -0
- package/npm/ingress-invoke.js +292 -0
- package/npm/launch-config.js +310 -4
- package/npm/scenario-check.js +684 -33
- package/npm/scenario-handlers.js +94 -12
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +20 -0
- package/npm/scenario-playwright.js +366 -29
- package/npm/script-drift.js +81 -0
- package/npm/utils.js +453 -43
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
- package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
- package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
- package/ui/dist/assets/index-CIXZkwfB.css +1 -0
- package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
- package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
- 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,133 @@ 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
|
+
|
|
161
339
|
async function collectImageStates(target) {
|
|
162
340
|
return target.evaluate(() =>
|
|
163
341
|
Array.from(document.images || []).map((img) => ({
|
|
@@ -346,7 +524,7 @@ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers
|
|
|
346
524
|
outcome: "stabilized",
|
|
347
525
|
elapsedMs: Date.now() - started,
|
|
348
526
|
});
|
|
349
|
-
return;
|
|
527
|
+
return { stabilized: true, hadLoadingMarkers: false };
|
|
350
528
|
}
|
|
351
529
|
} else {
|
|
352
530
|
stableCount = 0;
|
|
@@ -356,6 +534,9 @@ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers
|
|
|
356
534
|
}
|
|
357
535
|
// Hit the cap without stabilizing — record WHY: a persistent loading marker
|
|
358
536
|
// (app stuck) vs HTML still mutating each poll (HMR / animation / re-render).
|
|
537
|
+
// The returned `hadLoadingMarkers` is the signal the capture advisory keys
|
|
538
|
+
// off: a marker still on screen at the cap means the page never finished its
|
|
539
|
+
// (likely client-side) load, so the screenshot caught its loading state.
|
|
359
540
|
logCaptureTiming("stable-page", {
|
|
360
541
|
outcome: "timed-out",
|
|
361
542
|
elapsedMs: Date.now() - started,
|
|
@@ -363,6 +544,7 @@ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers
|
|
|
363
544
|
lastHadLoadingMarkers,
|
|
364
545
|
lastHtmlStillChanging: lastHtmlChanged,
|
|
365
546
|
});
|
|
547
|
+
return { stabilized: false, hadLoadingMarkers: lastHadLoadingMarkers };
|
|
366
548
|
}
|
|
367
549
|
|
|
368
550
|
// `preflight` is injectable so unit tests that drive a mock page can stay
|
|
@@ -370,9 +552,13 @@ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers
|
|
|
370
552
|
async function loadScenarioInIframe(
|
|
371
553
|
page,
|
|
372
554
|
url,
|
|
373
|
-
{ background, preflight = assertAppPortReachable } = {},
|
|
555
|
+
{ background, preflight = assertAppPortReachable, harnessOrigin } = {},
|
|
374
556
|
) {
|
|
375
557
|
await preflight(url);
|
|
558
|
+
// `undefined` (the default) means "resolve from server-state"; an explicit
|
|
559
|
+
// value (including `null`) is honored as-is so tests can force either path.
|
|
560
|
+
const resolvedHarnessOrigin =
|
|
561
|
+
harnessOrigin !== undefined ? harnessOrigin : resolveHarnessOrigin();
|
|
376
562
|
const navStarted = Date.now();
|
|
377
563
|
const responsePromise = page
|
|
378
564
|
.waitForResponse(
|
|
@@ -383,9 +569,25 @@ async function loadScenarioInIframe(
|
|
|
383
569
|
)
|
|
384
570
|
.catch(() => null);
|
|
385
571
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
572
|
+
if (resolvedHarnessOrigin) {
|
|
573
|
+
// Navigate the page TOP-LEVEL to the harness document served from the
|
|
574
|
+
// editor's `localhost` origin, so the ancestor document is a secure context
|
|
575
|
+
// and the nested scenario iframe inherits it (the Sveltia-class fix). The
|
|
576
|
+
// inner iframe `src` is still `url`, so the document-response probe above is
|
|
577
|
+
// unchanged.
|
|
578
|
+
await page.goto(buildHarnessUrl(resolvedHarnessOrigin, url, background), {
|
|
579
|
+
waitUntil: "domcontentloaded",
|
|
580
|
+
});
|
|
581
|
+
} else {
|
|
582
|
+
// Degraded fallback: no resolvable harness origin (server-state missing), so
|
|
583
|
+
// use the legacy in-page harness. The top-level document is then
|
|
584
|
+
// `about:blank` — NOT a secure context — so a secure-context-gated app may
|
|
585
|
+
// refuse to mount, but every non-secure-context scenario captures exactly as
|
|
586
|
+
// before.
|
|
587
|
+
await page.setContent(buildIframeHarness(url, { background }), {
|
|
588
|
+
waitUntil: "domcontentloaded",
|
|
589
|
+
});
|
|
590
|
+
}
|
|
389
591
|
|
|
390
592
|
const frameHandle = await page.waitForSelector("#scenario-frame", {
|
|
391
593
|
state: "attached",
|
|
@@ -420,16 +622,59 @@ async function loadScenarioTopLevel(
|
|
|
420
622
|
) {
|
|
421
623
|
await preflight(url);
|
|
422
624
|
const navStarted = Date.now();
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
625
|
+
try {
|
|
626
|
+
const response = await page.goto(url, {
|
|
627
|
+
waitUntil: "load",
|
|
628
|
+
timeout: 30000,
|
|
629
|
+
});
|
|
630
|
+
logCaptureTiming("navigate-toplevel", {
|
|
631
|
+
elapsedMs: Date.now() - navStarted,
|
|
632
|
+
status: response ? response.status() : null,
|
|
633
|
+
url,
|
|
634
|
+
});
|
|
635
|
+
return { frame: page.mainFrame(), response };
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (error.message && error.message.toLowerCase().includes("timeout")) {
|
|
638
|
+
let parsed = null;
|
|
639
|
+
try {
|
|
640
|
+
parsed = new URL(url);
|
|
641
|
+
} catch (_) {}
|
|
642
|
+
if (parsed) {
|
|
643
|
+
let appPort = null;
|
|
644
|
+
try {
|
|
645
|
+
const path = require("path");
|
|
646
|
+
const statePath = path.join(process.cwd(), ".codeyam", "server-state.json");
|
|
647
|
+
if (fs.existsSync(statePath)) {
|
|
648
|
+
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
649
|
+
appPort = state.appPort;
|
|
650
|
+
}
|
|
651
|
+
} catch (_) {}
|
|
652
|
+
|
|
653
|
+
if (appPort) {
|
|
654
|
+
let appHealthy = false;
|
|
655
|
+
try {
|
|
656
|
+
await assertAppPortReachable(`http://127.0.0.1:${appPort}/`, { timeoutMs: 500 });
|
|
657
|
+
appHealthy = true;
|
|
658
|
+
} catch (_) {}
|
|
659
|
+
|
|
660
|
+
if (appHealthy) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
`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}.`
|
|
663
|
+
);
|
|
664
|
+
} else {
|
|
665
|
+
throw new Error(
|
|
666
|
+
`proxy navigation timed out: the proxy at 127.0.0.1:${parsed.port} did not respond within 30000ms. app also unresponsive on :${appPort}.`
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
} else {
|
|
670
|
+
throw new Error(
|
|
671
|
+
`proxy navigation timed out: the proxy at 127.0.0.1:${parsed.port} did not respond within 30000ms. proxy dead/hung?`
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
433
678
|
}
|
|
434
679
|
|
|
435
680
|
// Collect up to 20 distinct visible labels of interactive elements on the
|
|
@@ -497,20 +742,104 @@ async function performInteraction(frame, interaction, { timeoutMs = 5000 } = {})
|
|
|
497
742
|
);
|
|
498
743
|
}
|
|
499
744
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
745
|
+
try {
|
|
746
|
+
switch (action) {
|
|
747
|
+
case "click":
|
|
748
|
+
await locator.click({ timeout: timeoutMs });
|
|
749
|
+
break;
|
|
750
|
+
case "fill":
|
|
751
|
+
await locator.fill(value ?? "", { timeout: timeoutMs });
|
|
752
|
+
break;
|
|
753
|
+
case "press":
|
|
754
|
+
await locator.press(value || "Enter", { timeout: timeoutMs });
|
|
755
|
+
break;
|
|
756
|
+
case "hover":
|
|
757
|
+
// Reveals hover-only affordances (an action bar, a tooltip) — one of the
|
|
758
|
+
// most common ephemeral states a resting-render screenshot misses.
|
|
759
|
+
await locator.hover({ timeout: timeoutMs });
|
|
760
|
+
break;
|
|
761
|
+
default:
|
|
762
|
+
throw new Error(
|
|
763
|
+
`preview-interact: unknown action "${action}" (expected click | fill | press | hover)`,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
} catch (error) {
|
|
767
|
+
const candidates = await collectInteractiveLabels(frame);
|
|
768
|
+
const candidateList =
|
|
769
|
+
candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
|
|
770
|
+
throw new Error(
|
|
771
|
+
`preview-interact: action "${action}" failed against ${targetDesc}: ${error.message || String(error)}. ` +
|
|
772
|
+
`Candidate interactive labels: ${candidateList}`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Hold until a visible-text or selector predicate becomes true, bounded by a
|
|
778
|
+
// wall-clock timeout. Behavioral demos hinge on transient states (an overlay
|
|
779
|
+
// appears, then a new round renders); a flow step waits for that real signal
|
|
780
|
+
// instead of a fixed sleep — the same "hold to a real signal with a safety
|
|
781
|
+
// bound" rule the rest of the capture pipeline follows. THROWS on timeout
|
|
782
|
+
// naming the predicate and the bound, so the capture script's outer catch
|
|
783
|
+
// turns a never-appearing predicate into an actionable failure rather than an
|
|
784
|
+
// infinite hang. `text` is matched case-insensitively as a substring (the
|
|
785
|
+
// agent reliably knows the copy it rendered); `selector` is a CSS selector.
|
|
786
|
+
async function waitForPredicate(frame, predicate, { defaultTimeoutMs = 8000 } = {}) {
|
|
787
|
+
const { text, selector, timeoutMs } = predicate || {};
|
|
788
|
+
const bound =
|
|
789
|
+
typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : defaultTimeoutMs;
|
|
790
|
+
|
|
791
|
+
let locator;
|
|
792
|
+
let desc;
|
|
793
|
+
if (typeof text === "string" && text.length > 0) {
|
|
794
|
+
locator = frame.getByText(text, { exact: false }).first();
|
|
795
|
+
desc = `text "${text}"`;
|
|
796
|
+
} else if (typeof selector === "string" && selector.length > 0) {
|
|
797
|
+
locator = frame.locator(selector).first();
|
|
798
|
+
desc = `selector "${selector}"`;
|
|
799
|
+
} else {
|
|
800
|
+
throw new Error("waitFor: predicate requires a `text` or `selector` target");
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
try {
|
|
804
|
+
await locator.waitFor({ state: "visible", timeout: bound });
|
|
805
|
+
} catch (_) {
|
|
806
|
+
throw new Error(
|
|
807
|
+
`waitFor: predicate ${desc} did not become visible within ${bound}ms`,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Drive an ordered sequence of interactions against the settled frame, settling
|
|
813
|
+
// the page between each so a later step sees the DOM the earlier one produced.
|
|
814
|
+
// This is the persisted-scenario path (`scenario.interactions`): unlike the
|
|
815
|
+
// single fire-and-forget `preview-interact`, the whole sequence is replayed on
|
|
816
|
+
// every capture and recapture. Any step that matches nothing throws (with the
|
|
817
|
+
// candidate-labels hint from `performInteraction`), and the caller turns that
|
|
818
|
+
// into a failed capture — never a silent resting-state screenshot for a
|
|
819
|
+
// sequence that didn't fully run.
|
|
820
|
+
// `settle` is injectable so unit tests that drive a mock frame stay
|
|
821
|
+
// network-free and fast; production callers use the default real
|
|
822
|
+
// `waitForStablePage` re-settle between steps.
|
|
823
|
+
async function performInteractionSequence(
|
|
824
|
+
page,
|
|
825
|
+
frame,
|
|
826
|
+
interactions,
|
|
827
|
+
{
|
|
828
|
+
timeoutMs = 5000,
|
|
829
|
+
settleMs = 5000,
|
|
830
|
+
loadingMarkers,
|
|
831
|
+
settle = waitForStablePage,
|
|
832
|
+
} = {},
|
|
833
|
+
) {
|
|
834
|
+
for (let i = 0; i < interactions.length; i += 1) {
|
|
835
|
+
try {
|
|
836
|
+
await performInteraction(frame, interactions[i], { timeoutMs });
|
|
837
|
+
} catch (err) {
|
|
838
|
+
// Prefix the failing step's index so a miss in a multi-step sequence is
|
|
839
|
+
// locatable, matching the model-side `interactions[i]` validator.
|
|
840
|
+
throw new Error(`interactions[${i}]: ${err.message}`);
|
|
841
|
+
}
|
|
842
|
+
await settle(page, frame, settleMs, loadingMarkers);
|
|
514
843
|
}
|
|
515
844
|
}
|
|
516
845
|
|
|
@@ -520,7 +849,13 @@ module.exports = {
|
|
|
520
849
|
assertAppPortReachable,
|
|
521
850
|
escapeHtmlAttribute,
|
|
522
851
|
buildIframeHarness,
|
|
852
|
+
HARNESS_PATH,
|
|
853
|
+
resolveHarnessOrigin,
|
|
854
|
+
buildHarnessUrl,
|
|
523
855
|
collectContentState,
|
|
856
|
+
scrollThroughDocument,
|
|
857
|
+
collectVisibleTextLength,
|
|
858
|
+
forceFinalVisualState,
|
|
524
859
|
collectImageStates,
|
|
525
860
|
waitForImagesSettled,
|
|
526
861
|
waitForAnimationsSettled,
|
|
@@ -531,4 +866,6 @@ module.exports = {
|
|
|
531
866
|
loadScenarioTopLevel,
|
|
532
867
|
collectInteractiveLabels,
|
|
533
868
|
performInteraction,
|
|
869
|
+
waitForPredicate,
|
|
870
|
+
performInteractionSequence,
|
|
534
871
|
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Given a list of files with their hash values, decide if there is any script drift.
|
|
5
|
+
* @param {Object} options
|
|
6
|
+
* @param {Array<Object>} options.files
|
|
7
|
+
* Each file entry has:
|
|
8
|
+
* {
|
|
9
|
+
* path: string,
|
|
10
|
+
* localHash: string,
|
|
11
|
+
* remoteHash: string | null, // null indicates fetch/hash failure
|
|
12
|
+
* baseHash: string | null,
|
|
13
|
+
* dirty: boolean
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* Returns:
|
|
17
|
+
* {
|
|
18
|
+
* verdict: 'clean' | 'local-ahead' | 'stale' | 'diverged',
|
|
19
|
+
* staleFiles: string[] // List of paths that are stale/diverged
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
function classifyScriptDrift({ files }) {
|
|
23
|
+
if (!files || files.length === 0) {
|
|
24
|
+
return { verdict: 'clean', staleFiles: [] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let hasStale = false;
|
|
28
|
+
let hasDiverged = false;
|
|
29
|
+
let hasLocalAhead = false;
|
|
30
|
+
const staleFiles = [];
|
|
31
|
+
|
|
32
|
+
for (const f of files) {
|
|
33
|
+
// If remoteHash or baseHash is missing/null/undefined, we treat it as fetch/hash failure.
|
|
34
|
+
// In that case, we cannot safely declare the file stale/diverged, so we pass through.
|
|
35
|
+
if (f.remoteHash === null || f.baseHash === null || f.remoteHash === undefined || f.baseHash === undefined) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const localHash = f.localHash;
|
|
40
|
+
const remoteHash = f.remoteHash;
|
|
41
|
+
const baseHash = f.baseHash;
|
|
42
|
+
const dirty = !!f.dirty;
|
|
43
|
+
|
|
44
|
+
// 1. Identical local and remote, and not dirty locally -> clean
|
|
45
|
+
if (localHash === remoteHash && !dirty) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 2. Remote hasn't changed from base, but local has changed (or is dirty)
|
|
50
|
+
if (remoteHash === baseHash) {
|
|
51
|
+
if (localHash !== baseHash || dirty) {
|
|
52
|
+
hasLocalAhead = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// 3. Local hasn't changed from base, but remote has changed from base
|
|
56
|
+
else if (localHash === baseHash && !dirty) {
|
|
57
|
+
hasStale = true;
|
|
58
|
+
staleFiles.push(f.path);
|
|
59
|
+
}
|
|
60
|
+
// 4. Both local and remote have changed from base and they differ from each other
|
|
61
|
+
else {
|
|
62
|
+
hasDiverged = true;
|
|
63
|
+
staleFiles.push(f.path);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (hasDiverged) {
|
|
68
|
+
return { verdict: 'diverged', staleFiles };
|
|
69
|
+
}
|
|
70
|
+
if (hasStale) {
|
|
71
|
+
return { verdict: 'stale', staleFiles };
|
|
72
|
+
}
|
|
73
|
+
if (hasLocalAhead) {
|
|
74
|
+
return { verdict: 'local-ahead', staleFiles: [] };
|
|
75
|
+
}
|
|
76
|
+
return { verdict: 'clean', staleFiles: [] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
classifyScriptDrift
|
|
81
|
+
};
|