@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.
Files changed (66) hide show
  1. package/npm/cloud-branch-switch.js +1 -1
  2. package/npm/cloud.js +1693 -96
  3. package/npm/container.js +14 -0
  4. package/npm/disk-pressure.js +167 -0
  5. package/npm/editor-dev.js +107 -15
  6. package/npm/editor.js +97 -37
  7. package/npm/fleet-auth-health.js +75 -0
  8. package/npm/fleet-auth.js +6 -1
  9. package/npm/fleet-build-data.js +130 -0
  10. package/npm/fleet-cleanup.js +145 -7
  11. package/npm/fleet-destroy-safety.js +55 -0
  12. package/npm/fleet-effect-status.js +33 -32
  13. package/npm/fleet-host.js +74 -0
  14. package/npm/fleet-job-badge.js +46 -2
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +231 -0
  18. package/npm/fleet-prewarm.js +165 -0
  19. package/npm/fleet-public-url.js +160 -0
  20. package/npm/fleet-queue.js +38 -1
  21. package/npm/fleet-reconcile.js +100 -1
  22. package/npm/fleet-reset.js +298 -13
  23. package/npm/fleet-roster.js +6 -8
  24. package/npm/fleet-self-update.js +142 -0
  25. package/npm/fleet-setup-steps.js +105 -0
  26. package/npm/fleet-sleep-status.js +33 -0
  27. package/npm/fleet-strand.js +150 -0
  28. package/npm/fleet-stuck-status.js +111 -0
  29. package/npm/fleet-vm-access.js +234 -0
  30. package/npm/fleet-vm-readiness.js +104 -0
  31. package/npm/fleet-vm-slugs.js +116 -0
  32. package/npm/google-ingress.js +211 -0
  33. package/npm/ingress-invoke.js +292 -0
  34. package/npm/launch-config.js +310 -4
  35. package/npm/scenario-check.js +684 -33
  36. package/npm/scenario-handlers.js +94 -12
  37. package/npm/scenario-metrics.js +116 -1
  38. package/npm/scenario-mocks.js +20 -0
  39. package/npm/scenario-playwright.js +366 -29
  40. package/npm/script-drift.js +81 -0
  41. package/npm/utils.js +453 -43
  42. package/npm/vm-urls.js +69 -8
  43. package/package.json +5 -5
  44. package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
  45. package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
  46. package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
  47. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
  48. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
  49. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
  50. package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
  51. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
  52. package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
  53. package/ui/dist/assets/index-CIXZkwfB.css +1 -0
  54. package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
  55. package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
  56. package/ui/dist/codeyam-mark.svg +4 -0
  57. package/ui/dist/codeyam-wordmark.svg +5 -0
  58. package/ui/dist/index.html +2 -2
  59. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  60. package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
  61. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
  66. 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
- await page.setContent(buildIframeHarness(url, { background }), {
387
- waitUntil: "domcontentloaded",
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
- const response = await page.goto(url, {
424
- waitUntil: "load",
425
- timeout: 30000,
426
- });
427
- logCaptureTiming("navigate-toplevel", {
428
- elapsedMs: Date.now() - navStarted,
429
- status: response ? response.status() : null,
430
- url,
431
- });
432
- return { frame: page.mainFrame(), response };
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
- switch (action) {
501
- case "click":
502
- await locator.click({ timeout: timeoutMs });
503
- break;
504
- case "fill":
505
- await locator.fill(value ?? "", { timeout: timeoutMs });
506
- break;
507
- case "press":
508
- await locator.press(value || "Enter", { timeout: timeoutMs });
509
- break;
510
- default:
511
- throw new Error(
512
- `preview-interact: unknown action "${action}" (expected click | fill | press)`,
513
- );
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
+ };