@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.
Files changed (97) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud-branch-switch.js +1 -1
  3. package/npm/cloud.js +3166 -150
  4. package/npm/codesign-plan.js +68 -0
  5. package/npm/container.js +3 -0
  6. package/npm/disk-pressure.js +78 -0
  7. package/npm/editor-dev.js +96 -15
  8. package/npm/editor.js +85 -37
  9. package/npm/empty-project-dir.js +92 -7
  10. package/npm/fleet-add-failure.js +252 -0
  11. package/npm/fleet-asset-version.js +37 -0
  12. package/npm/fleet-auth-health.js +213 -0
  13. package/npm/fleet-auth.js +6 -1
  14. package/npm/fleet-build-data.js +109 -5
  15. package/npm/fleet-cleanup.js +232 -16
  16. package/npm/fleet-destroy-safety.js +272 -0
  17. package/npm/fleet-early-roster.js +87 -0
  18. package/npm/fleet-effect-poll.js +110 -0
  19. package/npm/fleet-effect-status.js +33 -32
  20. package/npm/fleet-env.js +86 -0
  21. package/npm/fleet-ghcr-secret.js +129 -0
  22. package/npm/fleet-git-maintenance.js +142 -0
  23. package/npm/fleet-host-op-limiter.js +68 -0
  24. package/npm/fleet-host.js +74 -0
  25. package/npm/fleet-iap-token.js +165 -0
  26. package/npm/fleet-job-badge.js +1 -1
  27. package/npm/fleet-launchd.js +171 -0
  28. package/npm/fleet-live-status.js +155 -0
  29. package/npm/fleet-memory.js +245 -9
  30. package/npm/fleet-plans.js +233 -4
  31. package/npm/fleet-pool.js +75 -16
  32. package/npm/fleet-prewarm.js +241 -0
  33. package/npm/fleet-project-drift.js +58 -0
  34. package/npm/fleet-public-url.js +394 -0
  35. package/npm/fleet-queue.js +95 -1
  36. package/npm/fleet-reachability-debounce.js +69 -0
  37. package/npm/fleet-reachability.js +80 -0
  38. package/npm/fleet-reconcile.js +43 -1
  39. package/npm/fleet-reset.js +330 -13
  40. package/npm/fleet-roster.js +6 -8
  41. package/npm/fleet-running-plan.js +85 -0
  42. package/npm/fleet-self-update.js +142 -0
  43. package/npm/fleet-session-token-cache.js +65 -0
  44. package/npm/fleet-setup-steps.js +112 -0
  45. package/npm/fleet-sleep-status.js +33 -0
  46. package/npm/fleet-status-refs.js +96 -0
  47. package/npm/fleet-strand.js +181 -0
  48. package/npm/fleet-stuck-status.js +117 -0
  49. package/npm/fleet-vm-access.js +404 -0
  50. package/npm/fleet-vm-probe-target.js +282 -0
  51. package/npm/fleet-vm-readiness.js +237 -0
  52. package/npm/fleet-vm-slugs.js +116 -0
  53. package/npm/google-ingress.js +226 -0
  54. package/npm/ingress-invoke.js +770 -0
  55. package/npm/launch-config.js +386 -9
  56. package/npm/scenario-check.js +880 -37
  57. package/npm/scenario-handlers.js +154 -30
  58. package/npm/scenario-interactivity.js +145 -6
  59. package/npm/scenario-issues.js +13 -1
  60. package/npm/scenario-metrics.js +116 -1
  61. package/npm/scenario-mocks.js +144 -21
  62. package/npm/scenario-playwright.js +649 -38
  63. package/npm/script-drift.js +152 -0
  64. package/npm/utils.js +365 -52
  65. package/npm/vm-urls.js +69 -8
  66. package/package.json +5 -5
  67. package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
  68. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
  69. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  70. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
  71. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
  72. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
  73. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  74. package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
  75. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
  76. package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
  77. package/ui/dist/assets/index-BygCiJSb.css +1 -0
  78. package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
  79. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  80. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  81. package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
  82. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
  83. package/ui/dist/codeyam-mark.svg +4 -0
  84. package/ui/dist/codeyam-wordmark.svg +5 -0
  85. package/ui/dist/index.html +4 -4
  86. package/ui/dist/open-source/codeyam-counter.png +0 -0
  87. package/ui/dist/open-source/el-carot.png +0 -0
  88. package/ui/dist/open-source/tabcommand.png +0 -0
  89. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  90. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  91. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  92. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  93. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  94. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  95. package/ui/dist/assets/index-BGqairFV.css +0 -1
  96. package/ui/dist/assets/react-CSS0HapR.js +0 -28
  97. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -1,6 +1,128 @@
1
1
  const { createIssue } = require("./scenario-issues");
2
2
 
3
- function getInitScript() {
3
+ // Known substrings that mean the app refused to initialize because it was loaded
4
+ // from an *insecure context* — a bare dotted-quad IP rather than HTTPS or the
5
+ // hostname `localhost`. This is a whole CLASS of failure (Sveltia CMS's
6
+ // "only works with HTTPS or localhost", anything gating on `isSecureContext`,
7
+ // `crypto.subtle`, service workers, `Secure` cookies, WebAuthn), not one app.
8
+ // Matching reclassifies the error as an actionable `insecure-host` advisory
9
+ // instead of a generic console/page failure that surfaces as a bare
10
+ // `screenshot=null` with no explanation. Substrings are matched
11
+ // case-insensitively; keep the set small and well-known.
12
+ const INSECURE_CONTEXT_SIGNATURES = [
13
+ "only works with https or localhost",
14
+ "issecurecontext",
15
+ "secure context",
16
+ "requires https",
17
+ ];
18
+
19
+ // The single actionable message the `insecure-host` advisory carries. Names the
20
+ // class of failure and the fix (the preview origin is `localhost` by default;
21
+ // stubborn apps can opt into HTTPS) rather than echoing one app's raw error.
22
+ const INSECURE_HOST_ADVISORY_MESSAGE =
23
+ "The app refused to run because it was loaded from an insecure context " +
24
+ "(a bare IP). It requires a secure context — HTTPS or the hostname " +
25
+ "`localhost`. The preview origin is `localhost` by default; if this persists, " +
26
+ "the app may require HTTPS even on localhost — enable `proxy.httpsPreview` in " +
27
+ ".codeyam/editor.json.";
28
+
29
+ // Reclassify an error string as an `insecure-host` advisory when it matches a
30
+ // known insecure-context signature, else null. Pure; the original text is kept
31
+ // as the `contextSnippet` so the operator can still see what the app logged.
32
+ function insecureContextAdvisory(text) {
33
+ if (typeof text !== "string" || text.length === 0) return null;
34
+ const lower = text.toLowerCase();
35
+ const matched = INSECURE_CONTEXT_SIGNATURES.some((sig) => lower.includes(sig));
36
+ if (!matched) return null;
37
+ return createIssue("insecure-host", INSECURE_HOST_ADVISORY_MESSAGE, {
38
+ contextSnippet: text,
39
+ });
40
+ }
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
+
47
+ // `allowWebSocket` keeps the real `WebSocket` for scenarios that script a
48
+ // `/ws/terminal` transcript (or a WS stream): those captures NEED the socket to
49
+ // connect so the server replays the scripted agent state into the frame. The
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
52
+ // playback would otherwise screenshot the "Reconnecting…" overlay); for a
53
+ // scripted scenario the server holds the socket open after replay, so there is
54
+ // no reconnect spam to silence and stubbing only hides the very state we are
55
+ // trying to capture. The flag is computed by the capture orchestrator from the
56
+ // scenario's `mocks.transcripts` / `mocks.streams` — see
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.
68
+ function getInitScript(allowWebSocket = false) {
69
+ const webSocketStub = allowWebSocket
70
+ ? ""
71
+ : `
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);
118
+ }
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
+ })();`;
4
126
  return `
5
127
  window.__codeyamUnhandledRejections = [];
6
128
  window.addEventListener("unhandledrejection", (event) => {
@@ -9,41 +131,29 @@ function getInitScript() {
9
131
  reason instanceof Error ? reason.message : String(reason);
10
132
  window.__codeyamUnhandledRejections.push(message);
11
133
  });
12
-
13
- // Stub WebSocket during capture to prevent terminal reconnection spam.
14
- window.WebSocket = class StubWebSocket {
15
- static CONNECTING = 0;
16
- static OPEN = 1;
17
- static CLOSING = 2;
18
- static CLOSED = 3;
19
- readyState = 3;
20
- onopen = null;
21
- onclose = null;
22
- onerror = null;
23
- onmessage = null;
24
- send() {}
25
- close() {}
26
- addEventListener() {}
27
- removeEventListener() {}
28
- dispatchEvent() { return false; }
29
- constructor() {
30
- setTimeout(() => {
31
- if (this.onerror) this.onerror(new Event("error"));
32
- if (this.onclose) this.onclose(new CloseEvent("close"));
33
- }, 0);
34
- }
35
- };
134
+ ${webSocketStub}
36
135
  `;
37
136
  }
38
137
 
39
138
  function handleConsoleMessage(message) {
40
139
  if (message.type() !== "error") return null;
41
140
  const text = message.text();
42
-
43
- // Ignore known dev-server WebSocket/HMR errors from Vite proxy
141
+
142
+ // An insecure-context refusal wins over the generic `console` classification:
143
+ // it is the targeted, actionable signal for the secure-context app class.
144
+ const advisory = insecureContextAdvisory(text);
145
+ if (advisory) return advisory;
146
+
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.
44
153
  if (
45
154
  text.includes("WebSocket connection to") ||
46
- text.includes("Unsupported Media Type")
155
+ text.includes("Unsupported Media Type") ||
156
+ text.includes("dynamically imported module")
47
157
  ) {
48
158
  return null;
49
159
  }
@@ -64,11 +174,22 @@ function handleConsoleMessage(message) {
64
174
  return null;
65
175
  }
66
176
 
67
- 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 } : {});
68
184
  }
69
185
 
70
186
  function handlePageError(error) {
71
- return createIssue("pageerror", error.message || String(error));
187
+ const text = error.message || String(error);
188
+ // A secure-context guard often throws at boot rather than logging — surface
189
+ // the same actionable advisory for the pageerror path.
190
+ const advisory = insecureContextAdvisory(text);
191
+ if (advisory) return advisory;
192
+ return createIssue("pageerror", text);
72
193
  }
73
194
 
74
195
  function handleRequestFailed(request) {
@@ -95,4 +216,7 @@ module.exports = {
95
216
  handleConsoleMessage,
96
217
  handlePageError,
97
218
  handleRequestFailed,
219
+ insecureContextAdvisory,
220
+ INSECURE_CONTEXT_SIGNATURES,
221
+ INSECURE_HOST_ADVISORY_MESSAGE,
98
222
  };
@@ -19,13 +19,36 @@ const { createIssue } = require("./scenario-issues");
19
19
  // reliable in-page attachment signal are ALSO a conservative pass: we never
20
20
  // flag a page we cannot prove is dead, so a healthy Svelte / Solid / vanilla
21
21
  // page is never a false negative. A new framework detector is a new entry in
22
- // `detectors` below plus a `KNOWN_FRAMEWORKS` mapping — not an edit here.
22
+ // `detectors` below plus a `KNOWN_FRAMEWORKS` mapping — not an edit here; a
23
+ // meta-framework that hydrates an already-detected runtime is a new row in
24
+ // `META_FRAMEWORK_ALIASES`.
23
25
 
24
26
  // Frameworks we have an in-page attachment detector for. Inference only
25
27
  // resolves to one of these; an unrecognised framework yields `null`, which
26
28
  // the caller treats as "cannot determine" (conservative pass).
27
29
  const KNOWN_FRAMEWORKS = ["react", "vue"];
28
30
 
31
+ // Meta-frameworks mapped to the underlying runtime whose hydration detector
32
+ // applies. These do not carry their runtime's name in their stack identity: a
33
+ // Next.js app is `id: "nextjs-prisma-sqlite"`, `name: "Next.js + Prisma +
34
+ // SQLite"` — nothing contains the literal "react". Without this map the
35
+ // substring scan below returns `null`, the capture skips the hydration gate,
36
+ // and a genuinely un-hydrated page is reported as an inert control rather than
37
+ // as hydration never having run.
38
+ //
39
+ // Saying "Next hydrates React" is a fact about the runtime, not a framework
40
+ // assumption, so this stays consistent with the data-driven design above: a new
41
+ // meta-framework is a new row here, not a new branch in the capture flow.
42
+ //
43
+ // Matched with word boundaries rather than bare substrings so an unrelated name
44
+ // like "NextGen CLI" or "Remixer" cannot masquerade as a meta-framework.
45
+ const META_FRAMEWORK_ALIASES = [
46
+ { pattern: /\bnext(\.js|js)?\b/, framework: "react" },
47
+ { pattern: /\bremix\b/, framework: "react" },
48
+ { pattern: /\bgatsby\b/, framework: "react" },
49
+ { pattern: /\bnuxt(\.js|js)?\b/, framework: "vue" },
50
+ ];
51
+
29
52
  // Read `.codeyam/stack.json` relative to the capture script's cwd (the project
30
53
  // dir — `scenario_check.rs` sets `.current_dir(project_dir)`). Never throws: a
31
54
  // missing or malformed file yields `null` so the probe degrades to a no-op
@@ -51,6 +74,9 @@ function inferFramework(stack) {
51
74
  .filter((s) => typeof s === "string")
52
75
  .join(" ")
53
76
  .toLowerCase();
77
+ for (const { pattern, framework } of META_FRAMEWORK_ALIASES) {
78
+ if (pattern.test(haystack)) return framework;
79
+ }
54
80
  for (const fw of KNOWN_FRAMEWORKS) {
55
81
  if (haystack.includes(fw)) return fw;
56
82
  }
@@ -214,24 +240,136 @@ function interpretHydration({
214
240
  // project's stack.json unless the caller injects a `stack`), short-circuit when
215
241
  // no client runtime is expected, collect the in-page state, and interpret it.
216
242
  // Never throws — a probe failure must not break an otherwise-good capture.
217
- async function probeInteractivity(frame, { url, stack } = {}) {
243
+ //
244
+ // Returns `{ hydrated, issue }`:
245
+ // hydrated — `true` (runtime demonstrably attached), `false` (PROVEN dead:
246
+ // framework-owned controls rendered but nothing attached), or `null`
247
+ // ("cannot determine" — no client runtime expected, no control to probe, no
248
+ // detector for the framework, or the probe threw).
249
+ // issue — the `hydration` issue to surface, or `null` to pass.
250
+ //
251
+ // `hydrated` is deliberately three-valued rather than a bare boolean: the
252
+ // capture flow branches a page to `interactionEffect: "unhydrated"` ONLY on a
253
+ // proven `false`. A `null` must never be read as "dead" — that would turn every
254
+ // unknown-framework page into a false hydration failure.
255
+ async function probeHydrationState(frame, { url, stack } = {}) {
218
256
  const descriptor = stack !== undefined ? stack : readStackJson();
219
257
  const { expectInteractive, framework } =
220
258
  resolveInteractivityExpectation(descriptor);
221
- if (!expectInteractive) return null;
259
+ if (!expectInteractive) return { hydrated: null, issue: null };
222
260
  let state;
223
261
  try {
224
262
  state = await collectHydrationState(frame, { framework });
225
263
  } catch (_) {
226
- return null;
264
+ return { hydrated: null, issue: null };
227
265
  }
228
- return interpretHydration({
266
+ const issue = interpretHydration({
229
267
  expectInteractive: true,
230
268
  controlCount: state.controlCount,
231
269
  frameworkAttached: state.frameworkAttached,
232
270
  framework,
233
271
  url,
234
272
  });
273
+ // `frameworkAttached` is already the three-valued signal `hydrated` needs —
274
+ // pass it through rather than re-deriving it from the presence of an issue,
275
+ // which would conflate "no issue" (a pass) with "hydrated" (a positive).
276
+ return { hydrated: state.frameworkAttached, issue };
277
+ }
278
+
279
+ // Sleep for `ms` — a promisified `setTimeout`, so the poll loop yields the
280
+ // event loop between ticks instead of spinning.
281
+ function sleep(ms) {
282
+ return new Promise((resolve) => setTimeout(resolve, ms));
283
+ }
284
+
285
+ // Bounded WAIT for hydration — the polling generalization of
286
+ // `probeHydrationState`. Where the probe reads the attachment state once,
287
+ // this re-polls `collectHydrationState` until the framework demonstrably
288
+ // attaches, the state becomes unjudgeable, or a wall-clock cap elapses. It is
289
+ // the seam the capture flow calls so that every screenshot and every driven
290
+ // interaction happens AFTER the client runtime has attached, not on inert SSR
291
+ // markup (the bug where a fill/click landed before React hydrated, the handler
292
+ // never fired, yet the run reported success).
293
+ //
294
+ // Stack-agnostic by construction: it resolves the same expectation as the
295
+ // probe, so a non-interactive stack (backend/static/CLI, or an explicit
296
+ // `capture.interactivity === false`) returns immediately with `hydrated: null`
297
+ // and never waits. An unknown framework (no detector) reads
298
+ // `frameworkAttached === null` on the first poll and also passes instantly — we
299
+ // never block on a page we cannot judge.
300
+ //
301
+ // Never fails closed: a timeout yields `hydrated: false` and lets the existing
302
+ // `interpretHydration` path decide whether to surface the loud `hydration`
303
+ // issue; a thrown probe (detached frame mid-nav) is caught and treated as a
304
+ // pass. The wait can never hang a capture (hard cap) and never turns a page we
305
+ // cannot prove dead into a failure.
306
+ //
307
+ // Honest limitation: the poll can only judge a page that has already rendered
308
+ // framework-owned controls. `waitForStablePage` runs first (its `rootUnpainted`
309
+ // guard waits for the SPA root to paint), so by the time this runs, content is
310
+ // present for the SSR case this guards; a pure-CSR shell that renders zero
311
+ // controls reads `null` and passes instantly, unchanged from today.
312
+ //
313
+ // Returns `{ hydrated, issue, timedOut, waitedMs }` where `hydrated` is the
314
+ // same three-valued signal `probeHydrationState` returns.
315
+ async function waitForHydration(
316
+ frame,
317
+ { url, stack, framework, timeoutMs = 10000, pollIntervalMs = 150 } = {},
318
+ ) {
319
+ let resolvedFramework;
320
+ let expectInteractive;
321
+ if (typeof framework === "string" && framework.length > 0) {
322
+ expectInteractive = true;
323
+ resolvedFramework = framework.toLowerCase();
324
+ } else {
325
+ const descriptor = stack !== undefined ? stack : readStackJson();
326
+ const resolution = resolveInteractivityExpectation(descriptor);
327
+ expectInteractive = resolution.expectInteractive;
328
+ resolvedFramework = resolution.framework;
329
+ }
330
+ if (!expectInteractive) {
331
+ return { hydrated: null, issue: null, timedOut: false, waitedMs: 0 };
332
+ }
333
+
334
+ const start = Date.now();
335
+ let state = { controlCount: 0, frameworkAttached: null };
336
+ let timedOut = false;
337
+ for (;;) {
338
+ try {
339
+ state = await collectHydrationState(frame, {
340
+ framework: resolvedFramework,
341
+ });
342
+ } catch (_) {
343
+ // Detached frame mid-navigation (or any probe throw): treat as "cannot
344
+ // determine" and pass — a probe failure must never fail a capture.
345
+ state = { controlCount: 0, frameworkAttached: null };
346
+ break;
347
+ }
348
+ // `true` (attached) and `null` (cannot judge) are both terminal — the
349
+ // conservative "can't prove dead" signal the probe already trusts. Only a
350
+ // proven `false` keeps us waiting.
351
+ if (state.frameworkAttached !== false) break;
352
+ // Stop before a sleep that would overrun the cap, so the wait stays bounded.
353
+ if (Date.now() - start + pollIntervalMs >= timeoutMs) {
354
+ timedOut = true;
355
+ break;
356
+ }
357
+ await sleep(pollIntervalMs);
358
+ }
359
+
360
+ const issue = interpretHydration({
361
+ expectInteractive: true,
362
+ controlCount: state.controlCount,
363
+ frameworkAttached: state.frameworkAttached,
364
+ framework: resolvedFramework,
365
+ url,
366
+ });
367
+ return {
368
+ hydrated: state.frameworkAttached,
369
+ issue,
370
+ timedOut,
371
+ waitedMs: Date.now() - start,
372
+ };
235
373
  }
236
374
 
237
375
  module.exports = {
@@ -241,5 +379,6 @@ module.exports = {
241
379
  resolveInteractivityExpectation,
242
380
  collectHydrationState,
243
381
  interpretHydration,
244
- probeInteractivity,
382
+ probeHydrationState,
383
+ waitForHydration,
245
384
  };
@@ -17,7 +17,14 @@ function pushIssue(issues, issue) {
17
17
  }
18
18
  }
19
19
 
20
- function buildResult({ loaded, hasContent, issues, outputPath, url }) {
20
+ function buildResult({
21
+ loaded,
22
+ hasContent,
23
+ issues,
24
+ outputPath,
25
+ url,
26
+ unmockedRoutes = [],
27
+ }) {
21
28
  return {
22
29
  ok: loaded && hasContent && issues.length === 0,
23
30
  loaded,
@@ -25,6 +32,11 @@ function buildResult({ loaded, hasContent, issues, outputPath, url }) {
25
32
  url,
26
33
  outputPath: outputPath ?? null,
27
34
  issues,
35
+ // Diagnostic-only: same-origin 4xx routes with no scenario mock. Does NOT
36
+ // affect `ok` — the paired console error already fails the capture; this is
37
+ // the actionable route list the failure message and `stub-unmocked-routes`
38
+ // consume. Defaults to `[]` so callers that omit it are unchanged.
39
+ unmockedRoutes,
28
40
  };
29
41
  }
30
42
 
@@ -25,6 +25,34 @@ function hasLoadingMarkers(text, extraMarkers = []) {
25
25
 
26
26
  function hasRenderableContent(state) {
27
27
  if (!state) return false;
28
+
29
+ // Reveal-suppression guard. A scroll-gated entrance animation starts its
30
+ // content at `opacity:0` and reveals it via an IntersectionObserver wired
31
+ // from the app shell. Captured in isolation (no shell), the observer never
32
+ // fires, so the text renders into the DOM — `bodyTextLength`/`rootTextLength`
33
+ // are non-zero — yet stays visually invisible and the screenshot is blank.
34
+ // `visibleTextLength` is the length of text actually PAINTED to the frame
35
+ // (computed-style opacity/visibility aware). When the DOM carries text but
36
+ // none of it is visible AND there is no visible media, the frame is
37
+ // near-blank: report no content so the blank gate rejects it instead of
38
+ // waving through an empty shell. `visibleTextLength` is optional — older
39
+ // capture configs (and stubbed test targets) omit it, in which case the
40
+ // original DOM-presence behavior below is preserved byte-for-byte.
41
+ const domTextLength = Math.max(
42
+ state.bodyTextLength || 0,
43
+ state.rootTextLength || 0,
44
+ );
45
+ const hasVisibleMedia =
46
+ (state.loadedImageCount || 0) > 0 || (state.mediaBboxCount || 0) > 0;
47
+ if (
48
+ typeof state.visibleTextLength === "number" &&
49
+ domTextLength > 0 &&
50
+ state.visibleTextLength === 0 &&
51
+ !hasVisibleMedia
52
+ ) {
53
+ return false;
54
+ }
55
+
28
56
  if (
29
57
  state.rootChildCount > 0 ||
30
58
  state.rootTextLength > 0 ||
@@ -40,7 +68,17 @@ function hasRenderableContent(state) {
40
68
  function describeBlankReason(state) {
41
69
  if (!state) return "no content state collected";
42
70
  const parts = [];
43
- if (!(state.bodyTextLength > 0)) {
71
+ // Distinguish "no text in the DOM at all" from "text rendered but nothing is
72
+ // visible" — the latter is the reveal-suppression symptom (opacity:0
73
+ // entrance content whose observer never fired in isolation), which points
74
+ // the author at a different fix than a genuinely empty page.
75
+ if (
76
+ typeof state.visibleTextLength === "number" &&
77
+ state.bodyTextLength > 0 &&
78
+ state.visibleTextLength === 0
79
+ ) {
80
+ parts.push("text rendered but not visible (reveal-suppressed?)");
81
+ } else if (!(state.bodyTextLength > 0)) {
44
82
  parts.push("no text");
45
83
  }
46
84
  const imageCount = state.imageCount || 0;
@@ -69,6 +107,16 @@ const ERROR_PATTERNS = [
69
107
  "Scenario Error",
70
108
  ];
71
109
 
110
+ // The DOM attribute codeyam's own ScenarioRenderer stamps on its error and
111
+ // seed-error fallback frames. Error detection anchors on this marker rather
112
+ // than scanning the whole page body for ERROR_PATTERNS: a legitimately-mocked
113
+ // scenario whose *content* quotes one of those harness phrases (a journal entry
114
+ // describing the error component, say) must not be flagged as a failed capture.
115
+ // Arbitrary client apps that never render a codeyam ScenarioRenderer never emit
116
+ // the marker, so their pages are treated as healthy regardless of body text —
117
+ // these patterns were always codeyam-harness-specific, never generic.
118
+ const SCENARIO_ERROR_MARKER = "data-codeyam-scenario-error";
119
+
72
120
  function hasErrorPatterns(text) {
73
121
  return ERROR_PATTERNS.some((pattern) => text.includes(pattern));
74
122
  }
@@ -95,14 +143,81 @@ function buildErrorContextSnippet(text, pattern) {
95
143
  return `${prefix}${slice}${suffix}`;
96
144
  }
97
145
 
146
+ // Classify a scenario-error fallback into a `{ matchedPattern, contextSnippet }`
147
+ // from the marker info collected off the page. `marker` is the
148
+ // `{ reason, text }` read from the `[data-codeyam-scenario-error]` element, or
149
+ // null when no such element is present. Returns null when there is no marked
150
+ // fallback — the page is healthy and no error-content failure is raised.
151
+ //
152
+ // When the marked fallback text contains a known ERROR_PATTERN (the render-error
153
+ // path renders `Component "X" not found in registry`), that pattern is named so
154
+ // the downstream Rust classifier can still extract the component; otherwise the
155
+ // marker's `reason` attribute classifies it (the seed-error fallback, whose
156
+ // "Seed Error" copy is intentionally not an ERROR_PATTERN). Because the text is
157
+ // scoped to the fallback element — not the whole body — content elsewhere on the
158
+ // page can quote these phrases freely without tripping detection.
159
+ function findScenarioError(marker) {
160
+ // The capture passes the `frame.evaluate` result of querying the marker
161
+ // element: either null (no fallback rendered) or a `{reason, text}` object.
162
+ // Anything else (a bare string, a primitive) is not a marked error — guard
163
+ // so a non-object never reads as a present marker and false-flags the page.
164
+ if (!marker || typeof marker !== "object") return null;
165
+ const text = marker.text || "";
166
+ const reason = marker.reason || "";
167
+ const matchedPattern = findErrorPattern(text) || reason || "scenario error";
168
+ const contextSnippet =
169
+ buildErrorContextSnippet(text, matchedPattern) ||
170
+ (text ? text.replace(/\s+/g, " ").trim().slice(0, 200) : null);
171
+ return { matchedPattern, contextSnippet };
172
+ }
173
+
174
+ // Build the non-blocking "this page looks client-fetched" advisory from the two
175
+ // settle signals the capture already computes: `stableOutcome` from
176
+ // waitForStablePage (did the page settle, and was a loading marker still up at
177
+ // the cap?) and `networkOutcome` from waitForNetworkQuiet (did in-flight network
178
+ // go quiet, or did the bounded wait cap out?). Captures settle within a bounded
179
+ // window so a never-idle stream can't hang them — which means a slower
180
+ // client-side fetch lands AFTER the window and the screenshot catches the
181
+ // loading/initial state instead of the populated one. When either signal fires,
182
+ // name the cause and the two reliable fixes (server-render the data, or drive a
183
+ // props-driven isolated component scenario) so the agent reaches for those up
184
+ // front instead of rediscovering the constraint by trial and error. Returns the
185
+ // advisory string, or null when the page settled cleanly (the SSR / props-driven
186
+ // happy path — no advisory, so a healthy capture stays noise-free).
187
+ function buildSettleAdvisory(stableOutcome, networkOutcome) {
188
+ const causes = [];
189
+ if (
190
+ stableOutcome &&
191
+ stableOutcome.stabilized === false &&
192
+ stableOutcome.hadLoadingMarkers === true
193
+ ) {
194
+ causes.push("a loading marker was still on the page");
195
+ }
196
+ if (networkOutcome && networkOutcome.quiet === false) {
197
+ causes.push("network requests were still in flight");
198
+ }
199
+ if (causes.length === 0) return null;
200
+ return (
201
+ `When this page was captured, ${causes.join(" and ")} — it likely fetches ` +
202
+ `its data on the client. Captures settle within a bounded window, so data ` +
203
+ `that arrives via a client-side fetch after that window renders as the ` +
204
+ `loading/initial state, not the populated state. To capture a populated ` +
205
+ `state, server-render the data (SSR/RSC) or drive a props-driven isolated ` +
206
+ `component scenario.`
207
+ );
208
+ }
209
+
98
210
  module.exports = {
99
211
  hasLoadingMarkers,
100
212
  hasRenderableContent,
213
+ buildSettleAdvisory,
101
214
  describeBlankReason,
102
215
  shouldStopWaitingForImages,
103
216
  hasErrorPatterns,
104
217
  findErrorPattern,
218
+ findScenarioError,
105
219
  buildErrorContextSnippet,
106
220
  ERROR_PATTERNS,
221
+ SCENARIO_ERROR_MARKER,
107
222
  ERROR_CONTEXT_RADIUS,
108
223
  };