@codeyam-editor/codeyam-editor 0.1.1 → 0.1.2

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 (46) hide show
  1. package/npm/cli.js +5 -2
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +1521 -0
  4. package/npm/container.js +22 -3
  5. package/npm/editor-dev.js +286 -36
  6. package/npm/editor.js +98 -35
  7. package/npm/empty-project-dir.js +33 -0
  8. package/npm/fleet-auth.js +111 -0
  9. package/npm/fleet-cleanup.js +124 -0
  10. package/npm/fleet-env.js +125 -0
  11. package/npm/fleet-memory.js +127 -0
  12. package/npm/fleet-pool-policy.js +78 -0
  13. package/npm/fleet-pool.js +401 -0
  14. package/npm/fleet-queue.js +40 -0
  15. package/npm/fleet-reconcile.js +36 -0
  16. package/npm/fleet-reset.js +144 -0
  17. package/npm/fleet-roster.js +81 -0
  18. package/npm/launch-config.js +544 -0
  19. package/npm/preflight-guidance.js +68 -0
  20. package/npm/scenario-check.js +298 -14
  21. package/npm/scenario-handlers.js +16 -5
  22. package/npm/scenario-interactivity.js +199 -0
  23. package/npm/scenario-metrics.js +16 -2
  24. package/npm/scenario-mocks.js +62 -25
  25. package/npm/scenario-playwright.js +169 -6
  26. package/npm/utils.js +507 -21
  27. package/npm/vm-urls.js +117 -0
  28. package/package.json +8 -7
  29. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  30. package/ui/dist/assets/EditorShell-DWaeBxgj.js +186 -0
  31. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  32. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  33. package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +5 -0
  34. package/ui/dist/assets/ScenarioDataPanelFullPage-C0YHQB-T.js +1 -0
  35. package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +1 -0
  36. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  37. package/ui/dist/assets/index-BUYNFqKK.js +27 -0
  38. package/ui/dist/assets/index-DVr5rY2d.css +1 -0
  39. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  40. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  41. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  42. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  43. package/ui/dist/favicon.ico +0 -0
  44. package/ui/dist/favicon.png +0 -0
  45. package/ui/dist/index.html +16 -0
  46. package/npm/postinstall.js +0 -132
@@ -0,0 +1,199 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { createIssue } = require("./scenario-issues");
4
+
5
+ // Hydration / interactivity probe for the headless capture browser.
6
+ //
7
+ // Background: a page can return HTTP 200, render visible content, log no
8
+ // console errors, and still be completely dead — the client framework never
9
+ // hydrated, so every button and handler is inert. The status / render /
10
+ // console / image gates all pass and the broken page sails through to the
11
+ // user (the catalog whose filter buttons did nothing). This probe asserts the
12
+ // page actually became interactive before the capture gate reports success,
13
+ // so a non-hydrating page can no longer pass `client-errors`.
14
+ //
15
+ // Stack assumption: WHICH framework's hydration we look for is data-driven
16
+ // from `.codeyam/stack.json` (or an explicit override) — never hardcoded into
17
+ // the capture flow. Stacks with no client runtime (backend services, CLIs,
18
+ // static HTML) are a documented no-op pass. Frameworks for which we have no
19
+ // reliable in-page attachment signal are ALSO a conservative pass: we never
20
+ // flag a page we cannot prove is dead, so a healthy Svelte / Solid / vanilla
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.
23
+
24
+ // Frameworks we have an in-page attachment detector for. Inference only
25
+ // resolves to one of these; an unrecognised framework yields `null`, which
26
+ // the caller treats as "cannot determine" (conservative pass).
27
+ const KNOWN_FRAMEWORKS = ["react", "vue"];
28
+
29
+ // Read `.codeyam/stack.json` relative to the capture script's cwd (the project
30
+ // dir — `scenario_check.rs` sets `.current_dir(project_dir)`). Never throws: a
31
+ // missing or malformed file yields `null` so the probe degrades to a no-op
32
+ // rather than breaking a capture.
33
+ function readStackJson() {
34
+ try {
35
+ const raw = fs.readFileSync(path.join(".codeyam", "stack.json"), "utf8");
36
+ return JSON.parse(raw);
37
+ } catch (_) {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ // Scan a stack descriptor's identity fields for a framework we can probe.
43
+ // Pure — no I/O — so the matching rules are unit-tested without a stack.json.
44
+ function inferFramework(stack) {
45
+ if (!stack) return null;
46
+ const haystack = [
47
+ stack.id,
48
+ stack.name,
49
+ ...(Array.isArray(stack.technologies) ? stack.technologies : []),
50
+ ]
51
+ .filter((s) => typeof s === "string")
52
+ .join(" ")
53
+ .toLowerCase();
54
+ for (const fw of KNOWN_FRAMEWORKS) {
55
+ if (haystack.includes(fw)) return fw;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ // Decide, from a stack descriptor, whether the capture should expect a
61
+ // hydrated client runtime and which framework to probe for. Pure.
62
+ //
63
+ // `capture.interactivity === false` is an explicit opt-out for stacks that
64
+ // render no client runtime. `capture.interactivity.framework` is an explicit
65
+ // override when inference can't see the framework in the identity fields.
66
+ // Otherwise we infer: a known client framework, or `routing.type ===
67
+ // "client-side"`, implies a runtime that must hydrate; backend / static / CLI
68
+ // stacks match neither and no-op.
69
+ function resolveInteractivityExpectation(stack) {
70
+ const capture = (stack && stack.capture) || {};
71
+ if (capture.interactivity === false) {
72
+ return { expectInteractive: false, framework: null };
73
+ }
74
+ const explicit =
75
+ capture.interactivity && typeof capture.interactivity === "object"
76
+ ? capture.interactivity.framework
77
+ : null;
78
+ if (typeof explicit === "string" && explicit.length > 0) {
79
+ return { expectInteractive: true, framework: explicit.toLowerCase() };
80
+ }
81
+ const framework = inferFramework(stack);
82
+ const routingType =
83
+ stack && stack.routing && typeof stack.routing.type === "string"
84
+ ? stack.routing.type
85
+ : null;
86
+ const expectInteractive = framework != null || routingType === "client-side";
87
+ return { expectInteractive, framework };
88
+ }
89
+
90
+ // Run the in-page detection inside the loaded frame. Read-only: it inspects
91
+ // DOM-node properties left by a framework's hydration but never clicks or
92
+ // mutates anything, so it is safe to run before the screenshot is taken.
93
+ //
94
+ // Returns `{ controlCount, frameworkAttached }` where `frameworkAttached` is
95
+ // `true` (runtime demonstrably attached), `false` (controls exist but no
96
+ // attachment signal — the dead-hydration case), or `null` (no detector for
97
+ // this framework → cannot judge).
98
+ async function collectHydrationState(frame, { framework } = {}) {
99
+ return frame.evaluate((fw) => {
100
+ const SELECTOR =
101
+ 'button, [role="button"], a[href], input:not([type="hidden"]), select, textarea, summary, [onclick]';
102
+ const controls = Array.from(document.querySelectorAll(SELECTOR));
103
+
104
+ // Per-framework attachment detectors. Each returns true when the framework
105
+ // has demonstrably attached its client runtime to a live interactive node.
106
+ // The presence of a detector for `fw` is itself the signal that we CAN
107
+ // judge this framework; the default (no detector) means "cannot determine".
108
+ const detectors = {
109
+ react: (els) =>
110
+ els.some(
111
+ (el) =>
112
+ !!el &&
113
+ Object.keys(el).some(
114
+ (k) =>
115
+ k.startsWith("__reactFiber$") ||
116
+ k.startsWith("__reactProps$") ||
117
+ k.startsWith("__reactContainer$"),
118
+ ),
119
+ ),
120
+ vue: (els) =>
121
+ els.some(
122
+ (el) =>
123
+ !!el &&
124
+ (el.__vue__ != null ||
125
+ el.__vnode != null ||
126
+ el.__vueParentComponent != null),
127
+ ) || !!document.querySelector("[data-v-app]"),
128
+ };
129
+
130
+ const detector = detectors[fw];
131
+ const frameworkAttached = detector ? detector(controls) : null;
132
+ return { controlCount: controls.length, frameworkAttached };
133
+ }, framework || null);
134
+ }
135
+
136
+ // Turn a collected state into a `hydration` issue, or `null` to pass. Pure, so
137
+ // every branch is unit-tested without a browser.
138
+ //
139
+ // Pass when: no client runtime expected (no-op stacks); no interactive control
140
+ // to probe (a static content page in a client app is legitimately inert); the
141
+ // framework attached; OR we couldn't determine attachment (`null` — never
142
+ // false-positive). Flag only the proven-dead case: controls exist AND the
143
+ // framework's runtime is demonstrably not attached.
144
+ function interpretHydration({
145
+ expectInteractive,
146
+ controlCount,
147
+ frameworkAttached,
148
+ framework,
149
+ url,
150
+ }) {
151
+ if (!expectInteractive) return null;
152
+ if (!(controlCount > 0)) return null;
153
+ if (frameworkAttached !== false) return null;
154
+ const fw = framework || "the client framework";
155
+ const plural = controlCount === 1 ? "" : "s";
156
+ return createIssue(
157
+ "hydration",
158
+ `Page rendered ${controlCount} interactive control${plural} but ${fw} never attached ` +
159
+ `event handlers — the page is not interactive (hydration did not run). Client JS may ` +
160
+ `not be executing; check the preview proxy and the browser console. Run ` +
161
+ "`codeyam-editor editor diagnose-preview --path <route>` to pinpoint a proxy " +
162
+ `HTML-injection blocker.`,
163
+ { url: url ?? null },
164
+ );
165
+ }
166
+
167
+ // Orchestrator called from the capture flow: resolve the expectation (from the
168
+ // project's stack.json unless the caller injects a `stack`), short-circuit when
169
+ // no client runtime is expected, collect the in-page state, and interpret it.
170
+ // Never throws — a probe failure must not break an otherwise-good capture.
171
+ async function probeInteractivity(frame, { url, stack } = {}) {
172
+ const descriptor = stack !== undefined ? stack : readStackJson();
173
+ const { expectInteractive, framework } =
174
+ resolveInteractivityExpectation(descriptor);
175
+ if (!expectInteractive) return null;
176
+ let state;
177
+ try {
178
+ state = await collectHydrationState(frame, { framework });
179
+ } catch (_) {
180
+ return null;
181
+ }
182
+ return interpretHydration({
183
+ expectInteractive: true,
184
+ controlCount: state.controlCount,
185
+ frameworkAttached: state.frameworkAttached,
186
+ framework,
187
+ url,
188
+ });
189
+ }
190
+
191
+ module.exports = {
192
+ KNOWN_FRAMEWORKS,
193
+ readStackJson,
194
+ inferFramework,
195
+ resolveInteractivityExpectation,
196
+ collectHydrationState,
197
+ interpretHydration,
198
+ probeInteractivity,
199
+ };
@@ -5,8 +5,22 @@ const LOADING_MARKERS = [
5
5
  "disconnected",
6
6
  ];
7
7
 
8
- function hasLoadingMarkers(text) {
9
- return LOADING_MARKERS.some((marker) => text.includes(marker));
8
+ // `extraMarkers` are project-supplied (from stack.json `capture.loadingMarkers`)
9
+ // because an app's own loading copy ("Loading…", "Please wait") is
10
+ // app-specific and must NOT be hardcoded into the shared harness — only the
11
+ // four codeyam-harness markers above are universal. Matching is
12
+ // case-insensitive so "Loading…" and "loading…" both count; without the
13
+ // project markers a stable app loading screen looks "ready" to
14
+ // waitForStablePage and gets captured mid-hydration.
15
+ function hasLoadingMarkers(text, extraMarkers = []) {
16
+ if (!text) return false;
17
+ const lower = text.toLowerCase();
18
+ const markers = LOADING_MARKERS.concat(
19
+ Array.isArray(extraMarkers) ? extraMarkers : [],
20
+ );
21
+ return markers.some(
22
+ (marker) => marker && lower.includes(String(marker).toLowerCase()),
23
+ );
10
24
  }
11
25
 
12
26
  function hasRenderableContent(state) {
@@ -19,41 +19,76 @@ function findHttpMock(httpMocks, request) {
19
19
  return null;
20
20
  }
21
21
 
22
+ // The set of path/URL targets declared by the mock keys. A key is
23
+ // `"<METHOD> <target>"` (e.g. `"GET /api/plans"`); we strip the method so the
24
+ // route matcher can decide whether a request *could* match any mock without
25
+ // knowing the method (the handler re-checks method via findHttpMock).
26
+ function mockedTargets(httpMocks) {
27
+ const targets = new Set();
28
+ for (const key of Object.keys(httpMocks)) {
29
+ const spaceIdx = key.indexOf(" ");
30
+ if (spaceIdx === -1) continue;
31
+ targets.add(key.slice(spaceIdx + 1));
32
+ }
33
+ return targets;
34
+ }
35
+
36
+ // True when a request URL matches one of the declared mock targets. Accepts
37
+ // either a string or a WHATWG URL (Playwright's URL-matcher passes a URL).
38
+ function requestTargetsMock(targets, url) {
39
+ const href = typeof url === "string" ? url : url.href;
40
+ return normalizeMockCandidates(href).some((candidate) =>
41
+ targets.has(candidate),
42
+ );
43
+ }
44
+
22
45
  async function attachHttpMocks(page, httpMocks) {
23
46
  if (!httpMocks || Object.keys(httpMocks).length === 0) return;
24
47
 
25
- await page.route("**/*", async (route) => {
26
- const mock = findHttpMock(httpMocks, route.request());
27
- if (!mock) {
28
- await route.continue();
29
- return;
30
- }
48
+ // Intercept ONLY requests whose path matches a declared mock — not every
49
+ // request. A blanket `page.route("**/*")` intercepts the dev server's ESM
50
+ // module/script/style requests too, and routing them through
51
+ // `route.continue()` breaks Vite dev-mode module loading: the lazy app
52
+ // chunks never resolve and the SPA renders blank. Scoping the matcher to
53
+ // mocked targets lets those requests load natively while still mocking the API.
54
+ const targets = mockedTargets(httpMocks);
55
+ await page.route(
56
+ (url) => requestTargetsMock(targets, url),
57
+ async (route) => {
58
+ const mock = findHttpMock(httpMocks, route.request());
59
+ if (!mock) {
60
+ await route.continue();
61
+ return;
62
+ }
31
63
 
32
- const headers = { ...(mock.headers || {}) };
33
- let body;
34
- if (mock.body !== undefined) {
35
- body =
36
- typeof mock.body === "string" ? mock.body : JSON.stringify(mock.body);
37
- const hasContentType = Object.keys(headers).some(
38
- (key) => key.toLowerCase() === "content-type",
39
- );
40
- if (!hasContentType) {
41
- headers["content-type"] = "application/json";
64
+ const headers = { ...(mock.headers || {}) };
65
+ let body;
66
+ if (mock.body !== undefined) {
67
+ body =
68
+ typeof mock.body === "string"
69
+ ? mock.body
70
+ : JSON.stringify(mock.body);
71
+ const hasContentType = Object.keys(headers).some(
72
+ (key) => key.toLowerCase() === "content-type",
73
+ );
74
+ if (!hasContentType) {
75
+ headers["content-type"] = "application/json";
76
+ }
42
77
  }
43
- }
44
78
 
45
- await route.fulfill({
46
- status: mock.status || 200,
47
- headers,
48
- body,
49
- });
50
- });
79
+ await route.fulfill({
80
+ status: mock.status || 200,
81
+ headers,
82
+ body,
83
+ });
84
+ },
85
+ );
51
86
 
52
87
  // Disable the in-page fetch mock by returning an empty active-mocks.json.
53
88
  // The HTML injects a script that synchronously loads this file and
54
89
  // monkey-patches window.fetch, which would bypass Playwright's route
55
- // interception. This route is registered AFTER **/* so it takes priority
56
- // (Playwright uses LIFO ordering for route handlers).
90
+ // interception. This route is registered AFTER the mock matcher so it takes
91
+ // priority for that path (Playwright uses LIFO ordering for route handlers).
57
92
  await page.route("**/active-mocks.json", async (route) => {
58
93
  await route.fulfill({
59
94
  status: 200,
@@ -66,5 +101,7 @@ async function attachHttpMocks(page, httpMocks) {
66
101
  module.exports = {
67
102
  normalizeMockCandidates,
68
103
  findHttpMock,
104
+ mockedTargets,
105
+ requestTargetsMock,
69
106
  attachHttpMocks,
70
107
  };
@@ -2,6 +2,92 @@ const {
2
2
  hasLoadingMarkers,
3
3
  shouldStopWaitingForImages,
4
4
  } = require("./scenario-metrics");
5
+ const fs = require("fs");
6
+
7
+ // PROTOTYPE (improve35 capture diagnostics): append a per-phase timing line to
8
+ // a file so we can see WHERE a slow/timed-out capture spends its budget — even
9
+ // when the editor kills this script on timeout (stderr is lost then, but the
10
+ // file survives because each line is flushed synchronously). The cwd is the
11
+ // project dir (scenario_check.rs sets `.current_dir(project_dir)`), so this
12
+ // lands at `<project>/.codeyam/logs/capture-timing.log`. Diagnostics only —
13
+ // never throws, never affects the capture result.
14
+ function logCaptureTiming(phase, data) {
15
+ try {
16
+ const line = `[${new Date().toISOString()}] [capture-timing] phase=${phase} ${JSON.stringify(
17
+ data,
18
+ )}\n`;
19
+ fs.appendFileSync(".codeyam/logs/capture-timing.log", line);
20
+ } catch (_) {
21
+ /* diagnostics must never break a capture */
22
+ }
23
+ }
24
+
25
+ const net = require("net");
26
+
27
+ // Resolve a URL to the TCP {host, port} a pre-flight connect should target, or
28
+ // null when there is nothing to pre-check — an unparseable URL, or a non-http(s)
29
+ // target like `data:`/blank that the capture renders with no network origin.
30
+ // Pure (no socket) so the parse, protocol gate, and default-port rules are
31
+ // unit-tested without opening a connection.
32
+ function resolveTcpTarget(url) {
33
+ let parsed;
34
+ try {
35
+ parsed = new URL(url);
36
+ } catch (_) {
37
+ return null;
38
+ }
39
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
40
+ return {
41
+ host: parsed.hostname,
42
+ port: Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 80),
43
+ };
44
+ }
45
+
46
+ // Fast pre-flight (improve35): is anything accepting TCP on the app port? A
47
+ // refused connection (the editor's reverse proxy is down) fails in milliseconds;
48
+ // without this, the iframe's `waitForLoadState("load")` hangs the FULL 30s on a
49
+ // dead origin (the observed capture failure — capture-timing showed
50
+ // navigate-iframe elapsedMs=30004, status=null). A connection that ACCEPTS is
51
+ // good enough to proceed — a slow HTTP response *after* connect is a cold
52
+ // compile, handled by the normal load wait + the editor's retry. So we only bail
53
+ // on a hard refusal/timeout, never on slowness.
54
+ async function assertAppPortReachable(url, { timeoutMs = 2500 } = {}) {
55
+ const target = resolveTcpTarget(url);
56
+ if (!target) return; // non-http target (data:, blank) — nothing to pre-check
57
+ const { host, port } = target;
58
+ const started = Date.now();
59
+ await new Promise((resolve, reject) => {
60
+ const socket = net.connect({ host, port });
61
+ const finish = (err) => {
62
+ socket.destroy();
63
+ const elapsedMs = Date.now() - started;
64
+ logCaptureTiming("app-port-reachable", {
65
+ host,
66
+ port,
67
+ reachable: !err,
68
+ elapsedMs,
69
+ error: err ? err.message : null,
70
+ });
71
+ if (err) reject(err);
72
+ else resolve();
73
+ };
74
+ socket.setTimeout(timeoutMs, () =>
75
+ finish(
76
+ new Error(
77
+ `app port unreachable: TCP connect to ${host}:${port} timed out after ${timeoutMs}ms — the editor's reverse proxy is not accepting connections (proxy down?)`,
78
+ ),
79
+ ),
80
+ );
81
+ socket.once("connect", () => finish());
82
+ socket.once("error", (e) =>
83
+ finish(
84
+ new Error(
85
+ `app port unreachable: ${host}:${port} ${e.code || e.message} — is the editor's reverse proxy up? (a capture cannot render a dead app port)`,
86
+ ),
87
+ ),
88
+ );
89
+ });
90
+ }
5
91
 
6
92
  function escapeHtmlAttribute(value) {
7
93
  return String(value).replaceAll("&", "&amp;").replaceAll('"', "&quot;");
@@ -62,7 +148,7 @@ async function collectContentState(target) {
62
148
  }
63
149
  }
64
150
  return {
65
- bodyTextLength: document.body.innerText.trim().length,
151
+ bodyTextLength: document.body ? document.body.innerText.trim().length : 0,
66
152
  rootChildCount: root ? root.childElementCount : 0,
67
153
  rootTextLength: root ? (root.textContent || "").trim().length : 0,
68
154
  imageCount: imgs.length,
@@ -99,6 +185,18 @@ async function waitForImagesSettled(
99
185
  }
100
186
  const elapsedMs = Date.now() - started;
101
187
  const allComplete = images.every((img) => img && img.complete === true);
188
+ const incompleteSrcs = images
189
+ .filter((img) => !img || img.complete !== true || !(img.naturalWidth > 0))
190
+ .map((img) => (img && img.src) || "")
191
+ .slice(0, 6);
192
+ logCaptureTiming("images-settled", {
193
+ elapsedMs,
194
+ settled: allComplete,
195
+ total: images.length,
196
+ incompleteCount: incompleteSrcs.length,
197
+ incompleteSrcs,
198
+ overallTimeoutMs,
199
+ });
102
200
  return { settled: allComplete, images, elapsedMs };
103
201
  }
104
202
 
@@ -121,20 +219,29 @@ async function waitForAnimationsSettled(
121
219
  return { settled: false, elapsedMs: Date.now() - started };
122
220
  }
123
221
 
124
- async function waitForStablePage(page, target, timeoutMs = 10000) {
222
+ // `loadingMarkers` are the project's app-specific loading strings (from
223
+ // stack.json `capture.loadingMarkers`); they extend the codeyam-harness
224
+ // defaults so a stable-but-still-loading app screen counts as "not ready"
225
+ // and the loop keeps waiting instead of capturing the loading flash.
226
+ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers = []) {
125
227
  const started = Date.now();
126
228
  let lastHtml = "";
127
229
  let stableCount = 0;
230
+ let lastHadLoadingMarkers = false;
231
+ let lastHtmlChanged = false;
128
232
 
129
233
  while (Date.now() - started < timeoutMs) {
130
234
  await page.waitForTimeout(500);
131
235
 
132
236
  const pageState = await target.evaluate(() => ({
133
- bodyText: document.body.innerText,
134
- html: document.body.innerHTML,
237
+ bodyText: document.body?.innerText ?? "",
238
+ html: document.body?.innerHTML ?? "",
135
239
  }));
136
240
 
137
- if (!hasLoadingMarkers(pageState.bodyText) && pageState.html === lastHtml) {
241
+ lastHadLoadingMarkers = hasLoadingMarkers(pageState.bodyText, loadingMarkers);
242
+ lastHtmlChanged = pageState.html !== lastHtml;
243
+
244
+ if (!lastHadLoadingMarkers && !lastHtmlChanged) {
138
245
  stableCount += 1;
139
246
  if (stableCount >= 2) {
140
247
  const remaining = () => Math.max(0, timeoutMs - (Date.now() - started));
@@ -142,6 +249,10 @@ async function waitForStablePage(page, target, timeoutMs = 10000) {
142
249
  timeoutMs: Math.min(2000, remaining()),
143
250
  });
144
251
  await waitForImagesSettled(target, { overallTimeoutMs: remaining() });
252
+ logCaptureTiming("stable-page", {
253
+ outcome: "stabilized",
254
+ elapsedMs: Date.now() - started,
255
+ });
145
256
  return;
146
257
  }
147
258
  } else {
@@ -150,9 +261,26 @@ async function waitForStablePage(page, target, timeoutMs = 10000) {
150
261
 
151
262
  lastHtml = pageState.html;
152
263
  }
264
+ // Hit the cap without stabilizing — record WHY: a persistent loading marker
265
+ // (app stuck) vs HTML still mutating each poll (HMR / animation / re-render).
266
+ logCaptureTiming("stable-page", {
267
+ outcome: "timed-out",
268
+ elapsedMs: Date.now() - started,
269
+ timeoutMs,
270
+ lastHadLoadingMarkers,
271
+ lastHtmlStillChanging: lastHtmlChanged,
272
+ });
153
273
  }
154
274
 
155
- async function loadScenarioInIframe(page, url, { background } = {}) {
275
+ // `preflight` is injectable so unit tests that drive a mock page can stay
276
+ // network-free; production callers use the default real reachability check.
277
+ async function loadScenarioInIframe(
278
+ page,
279
+ url,
280
+ { background, preflight = assertAppPortReachable } = {},
281
+ ) {
282
+ await preflight(url);
283
+ const navStarted = Date.now();
156
284
  const responsePromise = page
157
285
  .waitForResponse(
158
286
  (response) =>
@@ -177,10 +305,44 @@ async function loadScenarioInIframe(page, url, { background } = {}) {
177
305
 
178
306
  await frame.waitForLoadState("load", { timeout: 30000 });
179
307
  const response = await responsePromise;
308
+ logCaptureTiming("navigate-iframe", {
309
+ elapsedMs: Date.now() - navStarted,
310
+ status: response ? response.status() : null,
311
+ url,
312
+ });
180
313
  return { frame, response };
181
314
  }
182
315
 
316
+ // Load the scenario as a top-level navigation instead of embedding it in
317
+ // the iframe harness. A top-level document is a first-party context, so a
318
+ // `SameSite=Lax` session cookie is sent on the navigation — which is what
319
+ // auth-gated application routes need to render the authenticated page
320
+ // rather than redirecting to /login. The returned `frame` is the page's
321
+ // main frame so callers can treat it uniformly with the iframe path
322
+ // (`frame.url()`, `frame.evaluate(...)`, `waitForStablePage(page, frame)`).
323
+ async function loadScenarioTopLevel(
324
+ page,
325
+ url,
326
+ { preflight = assertAppPortReachable } = {},
327
+ ) {
328
+ await preflight(url);
329
+ const navStarted = Date.now();
330
+ const response = await page.goto(url, {
331
+ waitUntil: "load",
332
+ timeout: 30000,
333
+ });
334
+ logCaptureTiming("navigate-toplevel", {
335
+ elapsedMs: Date.now() - navStarted,
336
+ status: response ? response.status() : null,
337
+ url,
338
+ });
339
+ return { frame: page.mainFrame(), response };
340
+ }
341
+
183
342
  module.exports = {
343
+ logCaptureTiming,
344
+ resolveTcpTarget,
345
+ assertAppPortReachable,
184
346
  escapeHtmlAttribute,
185
347
  buildIframeHarness,
186
348
  collectContentState,
@@ -189,4 +351,5 @@ module.exports = {
189
351
  waitForAnimationsSettled,
190
352
  waitForStablePage,
191
353
  loadScenarioInIframe,
354
+ loadScenarioTopLevel,
192
355
  };