@codeyam-editor/codeyam-editor 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud.js +2079 -242
  3. package/npm/codesign-plan.js +68 -0
  4. package/npm/compose-conflict.js +59 -0
  5. package/npm/empty-project-dir.js +92 -7
  6. package/npm/fleet-add-failure.js +361 -0
  7. package/npm/fleet-asset-version.js +37 -0
  8. package/npm/fleet-auth-health.js +143 -5
  9. package/npm/fleet-build-data.js +60 -5
  10. package/npm/fleet-cleanup.js +243 -9
  11. package/npm/fleet-destroy-safety.js +230 -13
  12. package/npm/fleet-early-roster.js +87 -0
  13. package/npm/fleet-effect-poll.js +110 -0
  14. package/npm/fleet-env.js +86 -0
  15. package/npm/fleet-ghcr-secret.js +129 -0
  16. package/npm/fleet-git-maintenance.js +142 -0
  17. package/npm/fleet-host-op-limiter.js +68 -0
  18. package/npm/fleet-iap-token.js +165 -0
  19. package/npm/fleet-job-badge.js +28 -12
  20. package/npm/fleet-live-status.js +40 -8
  21. package/npm/fleet-memory.js +289 -9
  22. package/npm/fleet-plans.js +98 -4
  23. package/npm/fleet-pool.js +75 -16
  24. package/npm/fleet-prewarm.js +87 -11
  25. package/npm/fleet-probe-transport.js +155 -0
  26. package/npm/fleet-project-drift.js +58 -0
  27. package/npm/fleet-public-url.js +234 -0
  28. package/npm/fleet-queue.js +58 -1
  29. package/npm/fleet-reachability-debounce.js +69 -0
  30. package/npm/fleet-reachability.js +80 -0
  31. package/npm/fleet-reset.js +32 -0
  32. package/npm/fleet-revision.js +104 -0
  33. package/npm/fleet-running-plan.js +115 -0
  34. package/npm/fleet-session-token-cache.js +65 -0
  35. package/npm/fleet-setup-steps.js +19 -12
  36. package/npm/fleet-status-refs.js +96 -0
  37. package/npm/fleet-strand.js +39 -8
  38. package/npm/fleet-stuck-status.js +13 -7
  39. package/npm/fleet-vm-access.js +170 -0
  40. package/npm/fleet-vm-probe-target.js +282 -0
  41. package/npm/fleet-vm-readiness.js +136 -3
  42. package/npm/google-ingress.js +15 -0
  43. package/npm/ingress-invoke.js +478 -0
  44. package/npm/launch-config.js +302 -10
  45. package/npm/scenario-check.js +318 -12
  46. package/npm/scenario-handlers.js +85 -27
  47. package/npm/scenario-interactivity.js +145 -6
  48. package/npm/scenario-issues.js +13 -1
  49. package/npm/scenario-mocks.js +129 -26
  50. package/npm/scenario-playwright.js +330 -22
  51. package/npm/script-drift.js +72 -1
  52. package/npm/utils.js +37 -3
  53. package/package.json +5 -5
  54. package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
  55. package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
  56. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  57. package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
  58. package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
  59. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  60. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
  61. package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
  62. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
  63. package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
  64. package/ui/dist/assets/index-CjxWhkSy.css +1 -0
  65. package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
  66. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  67. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  68. package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
  69. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
  70. package/ui/dist/index.html +4 -4
  71. package/ui/dist/open-source/codeyam-counter.png +0 -0
  72. package/ui/dist/open-source/el-carot.png +0 -0
  73. package/ui/dist/open-source/tabcommand.png +0 -0
  74. package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
  75. package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
  76. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
  77. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
  78. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
  79. package/ui/dist/assets/index-CIXZkwfB.css +0 -1
  80. package/ui/dist/assets/react-CSS0HapR.js +0 -28
@@ -161,6 +161,7 @@ const {
161
161
  const {
162
162
  attachHttpMocks,
163
163
  isDeclaredErrorMock,
164
+ unmockedRouteFrom,
164
165
  } = require("./scenario-mocks");
165
166
 
166
167
  const {
@@ -175,11 +176,112 @@ const {
175
176
  scrollThroughDocument,
176
177
  collectVisibleTextLength,
177
178
  forceFinalVisualState,
179
+ centerCaptureWrapper,
178
180
  performInteraction,
179
181
  waitForPredicate,
180
182
  performInteractionSequence,
181
183
  } = require("./scenario-playwright");
182
184
 
185
+ // Name of the cookie the editor's control-API accepts the session token in —
186
+ // must match `SESSION_COOKIE` in crates/control-api/src/origin_guard.rs. On a
187
+ // non-loopback (cloud) bind the control-API requires this token on the harness
188
+ // route (`/__codeyam_harness`); without it the top-level harness navigation 401s
189
+ // and `#scenario-frame` never attaches, so EVERY capture times out. The browser
190
+ // UI carries this cookie automatically; the headless capturer does not, so we
191
+ // inject it here from the on-box 0600 token file.
192
+ const SESSION_COOKIE = "cy_session";
193
+
194
+ // The loopback names the editor can be addressed by. A cookie is scoped by
195
+ // HOST, and the jar does NOT treat these as interchangeable: a cookie stored for
196
+ // `localhost` is simply not sent to `127.0.0.1`. That matters because the two
197
+ // token-gated capture routes resolve their origin differently — the harness
198
+ // route via `resolveHarnessOrigin()` (typically `localhost`), the preview-proxy
199
+ // route via `PROXY_CAPTURE_LOOPBACK` in handlers.rs, which is deliberately
200
+ // IPv4-pinned to `127.0.0.1` so it matches the forwarder's own pinning. Scoping
201
+ // the cookie to `localhost` alone therefore authenticated the harness route and
202
+ // left every `/__codeyam_preview` capture with no credential, so `preview-verify`
203
+ // / `preview-interact` / `preview-flow` all 401'd on a non-loopback bind.
204
+ // Emitting one cookie per alias is the fix: same token, same on-box editor,
205
+ // whichever name the capture URL happens to use.
206
+ const LOOPBACK_COOKIE_DOMAINS = ["localhost", "127.0.0.1"];
207
+
208
+ // Extract the host from an origin string, or null when it is unparseable.
209
+ function originHost(origin) {
210
+ try {
211
+ return new URL(origin).hostname;
212
+ } catch (_) {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ // Build the Playwright cookie array that authenticates the capture context to
218
+ // the editor's token-gated control-API. Reads the on-box `.codeyam/session-token`
219
+ // (0600) and emits a `cy_session` cookie for every host the capture might address
220
+ // the editor by (see `LOOPBACK_COOKIE_DOMAINS`); cookies are port-agnostic, so
221
+ // one per host covers the harness origin on the control port, the preview-proxy
222
+ // route, and any `/api` call on the app-proxy port. A non-loopback harness origin
223
+ // (a cloud tunnel domain) additionally gets its own host, since no loopback alias
224
+ // would match it. Returns `[]` — a no-op — when there is no token file (a
225
+ // loopback bind that never persisted one) or no resolvable harness origin, so a
226
+ // laptop capture is completely unaffected. `deps` is injectable so the builder is
227
+ // unit-testable without disk.
228
+ function buildSessionTokenCookies({
229
+ readTokenFile = () => {
230
+ const p = path.join(process.cwd(), ".codeyam", "session-token");
231
+ return fs.existsSync(p) ? fs.readFileSync(p, "utf8").trim() : null;
232
+ },
233
+ harnessOrigin = resolveHarnessOrigin(),
234
+ } = {}) {
235
+ try {
236
+ const token = readTokenFile();
237
+ if (!token || !harnessOrigin) return [];
238
+ const host = originHost(harnessOrigin);
239
+ const domains = [...LOOPBACK_COOKIE_DOMAINS];
240
+ // A tunnelled/cloud harness origin is not a loopback alias, so it needs its
241
+ // own entry or the harness navigation goes out uncredentialed.
242
+ if (host && !domains.includes(host)) domains.push(host);
243
+ return domains.map((domain) => ({
244
+ name: SESSION_COOKIE,
245
+ value: token,
246
+ domain,
247
+ path: "/",
248
+ httpOnly: true,
249
+ sameSite: "Lax",
250
+ }));
251
+ } catch (_) {
252
+ // A missing/unreadable token file must never break capture on a loopback
253
+ // bind where the token isn't required anyway — degrade to no cookie.
254
+ return [];
255
+ }
256
+ }
257
+
258
+ // True when a non-empty on-box `.codeyam/session-token` exists (the same
259
+ // 0600 file `buildSessionTokenCookies` reads). Used by the capability-skew
260
+ // guard to tell "no token, loopback bind, nothing to inject" (fine) apart from
261
+ // "a token exists but the capture produced no cookie" (a stale capture script
262
+ // heading into a guaranteed 401). Never throws — an unreadable file reports
263
+ // absent, degrading to today's behavior.
264
+ function onBoxSessionTokenExists() {
265
+ try {
266
+ const p = path.join(process.cwd(), ".codeyam", "session-token");
267
+ return fs.existsSync(p) && fs.readFileSync(p, "utf8").trim().length > 0;
268
+ } catch (_) {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ // Pure predicate behind the capability-skew guard: true when the capture WILL
274
+ // use the token-gated harness route (an origin resolved) yet produced NO
275
+ // `cy_session` cookie despite an on-box token existing — the signature of a
276
+ // generated `.codeyam/capture.js` stale relative to the harness route, heading
277
+ // into a guaranteed 401. Separated from the throw so the decision is
278
+ // unit-testable without a browser context. Returns false the moment any leg is
279
+ // absent (loopback bind with no harness origin, no token, or a cookie was
280
+ // produced), so a healthy capture never trips it.
281
+ function captureAuthSkewDetected({ harnessOrigin, cookieCount, tokenExists }) {
282
+ return Boolean(harnessOrigin) && cookieCount === 0 && Boolean(tokenExists);
283
+ }
284
+
183
285
  const {
184
286
  getInitScript,
185
287
  handleConsoleMessage,
@@ -188,7 +290,7 @@ const {
188
290
  } = require("./scenario-handlers");
189
291
 
190
292
  const {
191
- probeInteractivity,
293
+ waitForHydration,
192
294
  } = require("./scenario-interactivity");
193
295
 
194
296
  // Read project-specific loading markers from `.codeyam/stack.json`
@@ -237,6 +339,27 @@ function scenarioScriptsLiveSocket(config) {
237
339
  }
238
340
  }
239
341
 
342
+ // Classify what a driven interaction actually did, from the DOM fingerprint on
343
+ // either side of it plus the page's hydration state. Pure, so the distinction
344
+ // this encodes is unit-tested without a browser.
345
+ //
346
+ // A click that changed nothing has two very different causes, and reporting both
347
+ // as "none" is what let a dead page masquerade as a flaky harness: a page that
348
+ // NEVER HYDRATED could not have responded to ANY interaction (no client JS is
349
+ // running), whereas a hydrated page with an unchanged DOM really does mean the
350
+ // control is inert. Keeping them apart is what tells a reader whether to go look
351
+ // at their component or at the environment.
352
+ //
353
+ // `hydrated` is the three-valued signal from `waitForHydration`: only a PROVEN
354
+ // dead page (`false`) earns "unhydrated". `null` means the wait could not judge
355
+ // (unknown framework, no controls, probe threw) and must keep reporting "none" —
356
+ // never invent a hydration fault we cannot prove.
357
+ function classifyInteractionEffect(beforeFingerprint, afterFingerprint, hydrated) {
358
+ if (beforeFingerprint !== afterFingerprint) return "changed";
359
+ if (hydrated === false) return "unhydrated";
360
+ return "none";
361
+ }
362
+
240
363
  async function getDOMFingerprint(frame) {
241
364
  try {
242
365
  return await frame.evaluate(() => {
@@ -635,8 +758,24 @@ async function verifySeededStorageLanded(frame, config) {
635
758
  // Returns the frame the flow ended on, so the caller's final screenshot and
636
759
  // result URL reflect the last navigated route.
637
760
  async function runFlowSteps(page, initialFrame, steps, ctx) {
638
- const { url, loadingMarkers, navigation, iframeBackground, preflight, harnessOrigin } =
639
- ctx;
761
+ const {
762
+ url,
763
+ loadingMarkers,
764
+ navigation,
765
+ iframeBackground,
766
+ preflight,
767
+ harnessOrigin,
768
+ hydrationTimeoutMs = 10000,
769
+ settleMs,
770
+ } = ctx;
771
+ // Honor a caller-supplied `settleMs` for the in-flow stability windows,
772
+ // falling back to today's hardcoded defaults (5s for interaction steps, 10s
773
+ // for a navigate). This stops the field from being silently dropped — the
774
+ // exact trap that made a reporter's `settleMs: 8000` a no-op.
775
+ const navigateSettleMs =
776
+ typeof settleMs === "number" && settleMs > 0 ? settleMs : 10000;
777
+ const interactionSettleMs =
778
+ typeof settleMs === "number" && settleMs > 0 ? settleMs : 5000;
640
779
  let frame = initialFrame;
641
780
 
642
781
  for (let i = 0; i < steps.length; i++) {
@@ -655,14 +794,22 @@ async function runFlowSteps(page, initialFrame, steps, ctx) {
655
794
  harnessOrigin,
656
795
  });
657
796
  frame = loadResult.frame;
658
- await waitForStablePage(page, frame, 10000, loadingMarkers);
797
+ await waitForStablePage(page, frame, navigateSettleMs, loadingMarkers);
798
+ // A navigate loads a fresh route that must re-hydrate before the next
799
+ // fill/click, or the interaction lands on inert SSR markup (the same
800
+ // bug the top-level wait fixes, one route deeper). Bounded and
801
+ // stack-gated exactly like the top-level wait.
802
+ await waitForHydration(frame, {
803
+ url: target,
804
+ timeoutMs: hydrationTimeoutMs,
805
+ });
659
806
  break;
660
807
  }
661
808
  case "click":
662
809
  case "fill":
663
810
  case "press":
664
811
  await performInteraction(frame, step);
665
- await waitForStablePage(page, frame, 5000, loadingMarkers);
812
+ await waitForStablePage(page, frame, interactionSettleMs, loadingMarkers);
666
813
  break;
667
814
  case "waitFor":
668
815
  await waitForPredicate(frame, step);
@@ -699,9 +846,14 @@ async function runScenarioCheck(
699
846
  config,
700
847
  { preflight = assertAppPortReachable, harnessOrigin } = {},
701
848
  ) {
702
- const resolvedHarnessOrigin =
703
- harnessOrigin !== undefined ? harnessOrigin : resolveHarnessOrigin();
704
849
  const { url, outputPath, width, height, httpMocks = {} } = config;
850
+ // The harness host mirrors the capture target's host so the framed load stays
851
+ // SAME-SITE — see `resolveHarnessOrigin`. A cross-site frame withholds the
852
+ // SameSite=Lax `cy_session` cookie and every token-gated route 401s.
853
+ const resolvedHarnessOrigin =
854
+ harnessOrigin !== undefined
855
+ ? harnessOrigin
856
+ : resolveHarnessOrigin({ targetUrl: url });
705
857
  // Per-scenario capture-check allowances (see `CaptureAllowances` /
706
858
  // `ScenarioDefinition` on the Rust side). Default false so the guards keep
707
859
  // their strict behavior for every scenario that does not opt in.
@@ -709,7 +861,20 @@ async function runScenarioCheck(
709
861
  const expectedConsoleErrors = !!(config && config.expectedConsoleErrors);
710
862
  let interactionEffect = null;
711
863
  let interactionRetried = false;
864
+ // Non-fatal warnings raised while resolving interaction/flow targets — e.g. a
865
+ // substring text match that hit more than one element. Surfaced on the result
866
+ // so the agent sees the ambiguity instead of a silently-wrong first-match.
867
+ const interactionWarnings = [];
712
868
  const issues = [];
869
+ // Actionable set for self-healing: same-origin routes that returned 4xx with
870
+ // no scenario mock. A hermetic-proxy/upstream 404 is a *successful* HTTP
871
+ // response with status 404 (not a Playwright `requestfailed`), so the only
872
+ // place with method + path + status together is the `page.on("response")`
873
+ // collector below. De-duped by `"<METHOD> <path>"`; surfaced through
874
+ // `buildResult` so the Rust failure message can name each unmocked route and
875
+ // `stub-unmocked-routes` can add stub mocks for them.
876
+ const unmockedRoutes = [];
877
+ const unmockedRouteKeys = new Set();
713
878
  // Origin of the captured page, used to classify failed requests: only a
714
879
  // failure on the page's OWN origin should fail the capture (see
715
880
  // `isCaptureFatalRequestFailure`). Derived from `config.url` exactly as
@@ -742,6 +907,45 @@ async function runScenarioCheck(
742
907
  }
743
908
  const context = await browser.newContext(contextOptions);
744
909
 
910
+ // Authenticate the capture context to the editor's token-gated control-API
911
+ // BEFORE any navigation. On a non-loopback bind the harness route requires the
912
+ // session token; the browser UI carries it as a cookie automatically but the
913
+ // headless capturer does not, so inject it from the on-box token file. Scoped
914
+ // to the SAME resolved harness origin the capture navigates to: when no harness
915
+ // origin resolves (missing server-state, or a test that passes
916
+ // `harnessOrigin: null`) there is nothing to authenticate to, so no cookie is
917
+ // added. The `addCookies` guard keeps a mock context that doesn't implement it
918
+ // from throwing (a real Playwright context always has it).
919
+ const sessionCookies = buildSessionTokenCookies({
920
+ harnessOrigin: resolvedHarnessOrigin,
921
+ });
922
+ // Capability-skew guard. When the capture WILL use the token-gated harness
923
+ // route (a harness origin resolved) AND an on-box session token exists, but no
924
+ // `cy_session` cookie was produced, the token injection is broken — the
925
+ // generated `.codeyam/capture.js` is stale relative to the harness route it
926
+ // must authenticate to. Proceeding would hit a guaranteed 401 and strand every
927
+ // component capture on a `#scenario-frame` timeout. Fail loudly at the source
928
+ // instead, naming the version skew and its fix — closing the gap between the
929
+ // harness route (present) and the capture-side auth (absent).
930
+ if (
931
+ captureAuthSkewDetected({
932
+ harnessOrigin: resolvedHarnessOrigin,
933
+ cookieCount: sessionCookies.length,
934
+ tokenExists: onBoxSessionTokenExists(),
935
+ })
936
+ ) {
937
+ throw new Error(
938
+ `The capture harness origin ${resolvedHarnessOrigin} is token-gated and an ` +
939
+ `on-box \`.codeyam/session-token\` exists, but the capture produced no ` +
940
+ `\`cy_session\` cookie — the generated \`.codeyam/capture.js\` is stale ` +
941
+ `relative to the harness route (its session-token injection is missing or ` +
942
+ `broken). Regenerate the capture script / refresh the editor binary on this VM.`,
943
+ );
944
+ }
945
+ if (sessionCookies.length > 0 && typeof context.addCookies === "function") {
946
+ await context.addCookies(sessionCookies);
947
+ }
948
+
745
949
  // Apply the scenario's merged `browserState` (cookies + request
746
950
  // headers) to the capture context BEFORE the first navigation.
747
951
  // Belt-and-suspenders with the proxy's `Set-Cookie` injection: the
@@ -793,6 +997,30 @@ async function runScenarioCheck(
793
997
  pushIssue(issues, issue);
794
998
  });
795
999
 
1000
+ page.on("response", (response) => {
1001
+ // Collect same-origin 4xx responses that no scenario mock covers — the set
1002
+ // a stub mock would fix. Mirrors the console-guard predicates exactly so the
1003
+ // reported set matches the guard's tolerances: a scenario that provokes
1004
+ // errors by design (`expectedConsoleErrors`) reports none, and a route the
1005
+ // scenario already declares an error mock for is the mock's intended 4xx,
1006
+ // not a missing mock.
1007
+ if (expectedConsoleErrors) return;
1008
+ const req = response.request();
1009
+ const reqUrl = req.url();
1010
+ // Same-origin only — mirror the console/requestfailed origin gate so a
1011
+ // cross-origin sub-resource 4xx is never reported as an unmocked route.
1012
+ if (!isCaptureFatalRequestFailure(reqUrl, appOrigin)) return;
1013
+ // The status-threshold, declared-mock, and path-extraction decision is the
1014
+ // pure `unmockedRouteFrom` helper; the listener keeps only the stateful
1015
+ // same-origin gate above and the dedup below.
1016
+ const route = unmockedRouteFrom(req.method(), reqUrl, response.status(), httpMocks);
1017
+ if (!route) return;
1018
+ const key = `${route.method} ${route.path}`;
1019
+ if (unmockedRouteKeys.has(key)) return;
1020
+ unmockedRouteKeys.add(key);
1021
+ unmockedRoutes.push(route);
1022
+ });
1023
+
796
1024
  page.on("requestfailed", (request) => {
797
1025
  // Per-scenario allowance: the broken-image fallback scenario's `<img>` 404
798
1026
  // is a SAME-origin request failure it exists to demonstrate, so
@@ -843,6 +1071,21 @@ async function runScenarioCheck(
843
1071
  status: response.status(),
844
1072
  }),
845
1073
  );
1074
+ } else if (!response && config.navigation === "topLevel") {
1075
+ // A real top-level document load always yields a response. A null one
1076
+ // means the status could never be checked — and an unverified status is
1077
+ // not a passing status: a 500 whose error body renders text would
1078
+ // otherwise satisfy `hasContent` and pass as `ok`. Fail loudly instead.
1079
+ // The iframe path stays tolerant: there the app response is matched by
1080
+ // exact URL and is legitimately allowed to miss.
1081
+ pushIssue(
1082
+ issues,
1083
+ createIssue(
1084
+ "navigation",
1085
+ "Navigation response unavailable — could not verify the HTTP status of the top-level document",
1086
+ { url },
1087
+ ),
1088
+ );
846
1089
  }
847
1090
 
848
1091
  pushRedirectMismatchIssue(issues, url, frame, response, config);
@@ -862,6 +1105,15 @@ async function runScenarioCheck(
862
1105
  typeof config.stableTimeoutMs === "number" && config.stableTimeoutMs > 0
863
1106
  ? config.stableTimeoutMs
864
1107
  : 10000;
1108
+ // Hydration wait cap. Mirrors `stableTimeoutMs`: defaults to 10s, but a slow
1109
+ // Turbopack cold-compile route can extend it via `config.hydrationTimeoutMs`
1110
+ // without a code change. Bounded and near-zero on a healthy page (attaches in
1111
+ // a poll or two); only a genuinely dead page pays the full cap.
1112
+ const hydrationTimeoutMs =
1113
+ typeof config.hydrationTimeoutMs === "number" &&
1114
+ config.hydrationTimeoutMs > 0
1115
+ ? config.hydrationTimeoutMs
1116
+ : 10000;
865
1117
  const stableOutcome = await waitForStablePage(
866
1118
  page,
867
1119
  frame,
@@ -988,11 +1240,28 @@ async function runScenarioCheck(
988
1240
  // to run before the screenshot. Stack-gated and fail-safe — see
989
1241
  // scenario-interactivity.js — so backend / static / unknown-framework
990
1242
  // captures are an automatic pass.
991
- const hydrationIssue = await probeInteractivity(frame, {
1243
+ // `hydration.hydrated` is also consulted below to classify interactionEffect:
1244
+ // the wait runs BEFORE any interaction, which is exactly the reading we want
1245
+ // — "was this page alive when we found it," not "did our click revive it."
1246
+ //
1247
+ // WAIT for hydration (not just detect it): the one-shot probe read the
1248
+ // attachment state before React had a chance to hydrate SSR markup, so the
1249
+ // subsequent fill/click landed on inert controls and the submit handler
1250
+ // never fired — yet the run reported success. Waiting here gates the
1251
+ // screenshot AND every interaction branch below (steps / interaction /
1252
+ // interactions[] all run after this line) on the client runtime actually
1253
+ // attaching. Stack-gated and fail-safe: a timed-out wait yields
1254
+ // `hydrated: false` (so a truly dead page still classifies `unhydrated`),
1255
+ // and a non-interactive stack returns instantly.
1256
+ const hydration = await waitForHydration(frame, {
992
1257
  url: page.url() || url,
1258
+ timeoutMs: hydrationTimeoutMs,
1259
+ // `undefined` in production → waitForHydration reads .codeyam/stack.json;
1260
+ // a test injects a stack to force the interactive path without a real file.
1261
+ stack: config.stack,
993
1262
  });
994
- if (hydrationIssue) {
995
- pushIssue(issues, hydrationIssue);
1263
+ if (hydration.issue) {
1264
+ pushIssue(issues, hydration.issue);
996
1265
  }
997
1266
 
998
1267
  // Assert the injected seed actually landed in the capture browser, at rest
@@ -1020,6 +1289,9 @@ async function runScenarioCheck(
1020
1289
  iframeBackground: config.iframeBackground,
1021
1290
  preflight,
1022
1291
  harnessOrigin: resolvedHarnessOrigin,
1292
+ warnings: interactionWarnings,
1293
+ hydrationTimeoutMs,
1294
+ settleMs: config.settleMs,
1023
1295
  });
1024
1296
  } else if (config.interaction) {
1025
1297
  // Record fingerprint before interaction
@@ -1030,7 +1302,9 @@ async function runScenarioCheck(
1030
1302
  // fill / press (expanded accordion, open modal) without editing app
1031
1303
  // source. A no-match target throws here and is caught below as a failed
1032
1304
  // capture with the candidate-labels hint — never a silent blank shot.
1033
- await performInteraction(frame, config.interaction);
1305
+ await performInteraction(frame, config.interaction, {
1306
+ warnings: interactionWarnings,
1307
+ });
1034
1308
  await waitForStablePage(page, frame, 5000, loadingMarkers);
1035
1309
 
1036
1310
  // Record fingerprint after interaction
@@ -1040,12 +1314,18 @@ async function runScenarioCheck(
1040
1314
  if (beforeFingerprint === afterFingerprint && config.interaction.action === "click") {
1041
1315
  interactionRetried = true;
1042
1316
  await new Promise((resolve) => setTimeout(resolve, 500));
1317
+ // The retry re-resolves the same target; suppress its ambiguity warning
1318
+ // (already captured on the first attempt) to avoid a duplicate.
1043
1319
  await performInteraction(frame, config.interaction);
1044
1320
  await waitForStablePage(page, frame, 5000, loadingMarkers);
1045
1321
  afterFingerprint = await getDOMFingerprint(frame);
1046
1322
  }
1047
1323
 
1048
- interactionEffect = beforeFingerprint === afterFingerprint ? "none" : "changed";
1324
+ interactionEffect = classifyInteractionEffect(
1325
+ beforeFingerprint,
1326
+ afterFingerprint,
1327
+ hydration.hydrated,
1328
+ );
1049
1329
  }
1050
1330
 
1051
1331
  // Replay the scenario's PERSISTED interaction sequence (if any) in order,
@@ -1058,6 +1338,7 @@ async function runScenarioCheck(
1058
1338
  await performInteractionSequence(page, frame, config.interactions, {
1059
1339
  settleMs: 5000,
1060
1340
  loadingMarkers,
1341
+ warnings: interactionWarnings,
1061
1342
  });
1062
1343
  }
1063
1344
 
@@ -1074,6 +1355,17 @@ async function runScenarioCheck(
1074
1355
  await forceFinalVisualState(frame).catch(() => {});
1075
1356
  }
1076
1357
 
1358
+ // Center an isolated-component wrapper that is not already centered. The
1359
+ // shot below is full-viewport with no element clip, so an isolation page
1360
+ // that does not center itself lands in the top-left corner. Unlike
1361
+ // forceFinalVisualState this is NOT gated on hasDeclaredInteractiveState:
1362
+ // an isolation page with a declared interaction still needs to be centered,
1363
+ // and moving the wrapper does not clobber its contents the way
1364
+ // animation-forcing does — so a replayed interaction sequence stays valid.
1365
+ if (outputPath && loaded) {
1366
+ await centerCaptureWrapper(frame).catch(() => {});
1367
+ }
1368
+
1077
1369
  if (outputPath && loaded) {
1078
1370
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1079
1371
  await page.screenshot({ path: outputPath, fullPage: false });
@@ -1085,6 +1377,7 @@ async function runScenarioCheck(
1085
1377
  issues,
1086
1378
  outputPath,
1087
1379
  url: frame.url() || url,
1380
+ unmockedRoutes,
1088
1381
  });
1089
1382
 
1090
1383
  if (config.interaction) {
@@ -1092,6 +1385,14 @@ async function runScenarioCheck(
1092
1385
  result.interactionRetried = interactionRetried;
1093
1386
  }
1094
1387
 
1388
+ // Surface any non-fatal interaction/flow warnings (e.g. an ambiguous
1389
+ // substring text match) so the CLI can print them and the agent can switch
1390
+ // to a precise selector. Only attached when present — a clean run is
1391
+ // byte-for-byte unchanged.
1392
+ if (interactionWarnings.length > 0) {
1393
+ result.warnings = interactionWarnings.slice();
1394
+ }
1395
+
1095
1396
  // `capture-state` mode: attach the read-only page-state snapshot so the
1096
1397
  // backend can report localStorage + rendered text. Off by default, so a
1097
1398
  // normal error-check capture is byte-for-byte unchanged.
@@ -1119,6 +1420,7 @@ async function runScenarioCheck(
1119
1420
  issues,
1120
1421
  outputPath,
1121
1422
  url,
1423
+ unmockedRoutes,
1122
1424
  });
1123
1425
  } finally {
1124
1426
  await browser.close();
@@ -1146,6 +1448,7 @@ async function main() {
1146
1448
 
1147
1449
  module.exports = {
1148
1450
  runScenarioCheck,
1451
+ classifyInteractionEffect,
1149
1452
  mergeVisibleTextLength,
1150
1453
  runFlowSteps,
1151
1454
  dumpPageState,
@@ -1153,6 +1456,9 @@ module.exports = {
1153
1456
  readStackLoadingMarkers,
1154
1457
  scenarioScriptsLiveSocket,
1155
1458
  applyBrowserState,
1459
+ buildSessionTokenCookies,
1460
+ captureAuthSkewDetected,
1461
+ SESSION_COOKIE,
1156
1462
  isCrossOriginRequest,
1157
1463
  isCaptureFatalRequestFailure,
1158
1464
  stripMarkerHeaders,
@@ -39,44 +39,90 @@ function insecureContextAdvisory(text) {
39
39
  });
40
40
  }
41
41
 
42
+ // The pathname suffix identifying the editor's OWN terminal socket. Matched by
43
+ // suffix rather than full URL because the terminal is proxied through varying
44
+ // origins (localhost, a container IP, a tunnel host) and varying ports.
45
+ const TERMINAL_SOCKET_PATH_SUFFIX = "/ws/terminal";
46
+
42
47
  // `allowWebSocket` keeps the real `WebSocket` for scenarios that script a
43
48
  // `/ws/terminal` transcript (or a WS stream): those captures NEED the socket to
44
49
  // connect so the server replays the scripted agent state into the frame. The
45
- // stub below exists to silence the live terminal's reconnect loop on EVERY
46
- // OTHER capture (a component that opens `/ws/terminal` with no scripted
50
+ // interception below exists to silence the live terminal's reconnect loop on
51
+ // EVERY OTHER capture (a component that opens `/ws/terminal` with no scripted
47
52
  // playback would otherwise screenshot the "Reconnecting…" overlay); for a
48
53
  // scripted scenario the server holds the socket open after replay, so there is
49
54
  // no reconnect spam to silence and stubbing only hides the very state we are
50
55
  // trying to capture. The flag is computed by the capture orchestrator from the
51
56
  // scenario's `mocks.transcripts` / `mocks.streams` — see
52
57
  // `scenarioScriptsLiveSocket` in scenario-check.js.
58
+ //
59
+ // The interception is SCOPED BY URL and never blanket-replaces the global
60
+ // constructor. A dead global `WebSocket` also kills the framework's HMR socket,
61
+ // and on a dev-mode client bootstrap that runs through that socket (validated on
62
+ // Next.js 16 + turbopack) React then never hydrates in the capture browser —
63
+ // silently, with no console error. Every interactive capture of such an app
64
+ // reads as inert: clicks land on un-hydrated SSR markup and `interactionEffect`
65
+ // comes back `none`/`unhydrated`. Letting HMR connect costs nothing in capture
66
+ // noise, because `handleConsoleMessage` below already drops
67
+ // "WebSocket connection to" errors.
53
68
  function getInitScript(allowWebSocket = false) {
54
69
  const webSocketStub = allowWebSocket
55
70
  ? ""
56
71
  : `
57
- // Stub WebSocket during capture to prevent terminal reconnection spam.
58
- window.WebSocket = class StubWebSocket {
59
- static CONNECTING = 0;
60
- static OPEN = 1;
61
- static CLOSING = 2;
62
- static CLOSED = 3;
63
- readyState = 3;
64
- onopen = null;
65
- onclose = null;
66
- onerror = null;
67
- onmessage = null;
68
- send() {}
69
- close() {}
70
- addEventListener() {}
71
- removeEventListener() {}
72
- dispatchEvent() { return false; }
73
- constructor() {
74
- setTimeout(() => {
75
- if (this.onerror) this.onerror(new Event("error"));
76
- if (this.onclose) this.onclose(new CloseEvent("close"));
77
- }, 0);
72
+ // Intercept ONLY the editor's own terminal socket during capture, to
73
+ // prevent terminal reconnection spam. Every other URL — notably the
74
+ // framework's HMR socket, which dev-mode hydration depends on — gets the
75
+ // real WebSocket.
76
+ (() => {
77
+ const RealWebSocket = window.WebSocket;
78
+ if (!RealWebSocket) return;
79
+ class StubWebSocket {
80
+ static CONNECTING = 0;
81
+ static OPEN = 1;
82
+ static CLOSING = 2;
83
+ static CLOSED = 3;
84
+ readyState = 3;
85
+ url = "";
86
+ onopen = null;
87
+ onclose = null;
88
+ onerror = null;
89
+ onmessage = null;
90
+ send() {}
91
+ close() {}
92
+ addEventListener() {}
93
+ removeEventListener() {}
94
+ dispatchEvent() { return false; }
95
+ constructor(url) {
96
+ this.url = String(url);
97
+ setTimeout(() => {
98
+ if (this.onerror) this.onerror(new Event("error"));
99
+ if (this.onclose) this.onclose(new CloseEvent("close"));
100
+ }, 0);
101
+ }
102
+ }
103
+ const isTerminalSocket = (url) => {
104
+ const raw = String(url);
105
+ try {
106
+ return new URL(raw, window.location.href).pathname.endsWith(
107
+ ${JSON.stringify(TERMINAL_SOCKET_PATH_SUFFIX)}
108
+ );
109
+ } catch (_) {
110
+ return raw.includes(${JSON.stringify(TERMINAL_SOCKET_PATH_SUFFIX)});
111
+ }
112
+ };
113
+ function CodeyamWebSocket(url, protocols) {
114
+ if (isTerminalSocket(url)) return new StubWebSocket(url);
115
+ return protocols === undefined
116
+ ? new RealWebSocket(url)
117
+ : new RealWebSocket(url, protocols);
78
118
  }
79
- };`;
119
+ CodeyamWebSocket.prototype = RealWebSocket.prototype;
120
+ CodeyamWebSocket.CONNECTING = 0;
121
+ CodeyamWebSocket.OPEN = 1;
122
+ CodeyamWebSocket.CLOSING = 2;
123
+ CodeyamWebSocket.CLOSED = 3;
124
+ window.WebSocket = CodeyamWebSocket;
125
+ })();`;
80
126
  return `
81
127
  window.__codeyamUnhandledRejections = [];
82
128
  window.addEventListener("unhandledrejection", (event) => {
@@ -98,10 +144,16 @@ function handleConsoleMessage(message) {
98
144
  const advisory = insecureContextAdvisory(text);
99
145
  if (advisory) return advisory;
100
146
 
101
- // Ignore known dev-server WebSocket/HMR errors from Vite proxy
147
+ // Ignore known dev-server WebSocket/HMR errors from Vite proxy, plus the
148
+ // crxjs/Vite dynamic-import reload race: dev loaders `import()` the app entry
149
+ // with a `?t=<timestamp>` cache-buster, and a rapid scenario-reactivation
150
+ // reload aborts the in-flight import, logging "TypeError: Failed to fetch
151
+ // dynamically imported module" (Chrome) or "error loading dynamically
152
+ // imported module" (Vite). Benign — the entry reloads on the next nav.
102
153
  if (
103
154
  text.includes("WebSocket connection to") ||
104
- text.includes("Unsupported Media Type")
155
+ text.includes("Unsupported Media Type") ||
156
+ text.includes("dynamically imported module")
105
157
  ) {
106
158
  return null;
107
159
  }
@@ -122,7 +174,13 @@ function handleConsoleMessage(message) {
122
174
  return null;
123
175
  }
124
176
 
125
- return createIssue("console", text);
177
+ // Attach the offending resource URL the console message already exposes, so
178
+ // the capture-failure message can name the unmocked route (e.g.
179
+ // `GET /api/questions/<id>/research`) instead of an opaque "Failed to load
180
+ // resource". `message.location().url` is the resource that produced the
181
+ // error; `format_issue` renders it as `[<url>]` when populated.
182
+ const url = message.location && message.location().url;
183
+ return createIssue("console", text, url ? { url } : {});
126
184
  }
127
185
 
128
186
  function handlePageError(error) {