@codeyam-editor/codeyam-editor 0.1.0 → 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 (47) 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 +28 -5
  5. package/npm/editor-dev.js +378 -0
  6. package/npm/editor.js +199 -12
  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 +355 -290
  21. package/npm/scenario-handlers.js +82 -0
  22. package/npm/scenario-interactivity.js +199 -0
  23. package/npm/scenario-issues.js +35 -0
  24. package/npm/scenario-metrics.js +108 -0
  25. package/npm/scenario-mocks.js +107 -0
  26. package/npm/scenario-playwright.js +355 -0
  27. package/npm/utils.js +548 -22
  28. package/npm/vm-urls.js +117 -0
  29. package/package.json +13 -6
  30. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  31. package/ui/dist/assets/EditorShell-DWaeBxgj.js +186 -0
  32. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  33. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  34. package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +5 -0
  35. package/ui/dist/assets/ScenarioDataPanelFullPage-C0YHQB-T.js +1 -0
  36. package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +1 -0
  37. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  38. package/ui/dist/assets/index-BUYNFqKK.js +27 -0
  39. package/ui/dist/assets/index-DVr5rY2d.css +1 -0
  40. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  41. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  42. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  43. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  44. package/ui/dist/favicon.ico +0 -0
  45. package/ui/dist/favicon.png +0 -0
  46. package/ui/dist/index.html +16 -0
  47. package/npm/postinstall.js +0 -95
@@ -1,322 +1,344 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // Render environment (colorScheme, deviceScaleFactor, userAgent, locale,
4
+ // timezoneId, reduceMotion, forcedColors) is read from config when present
5
+ // and passed to browser.newContext(). This is what makes screenshots match
6
+ // the Live Preview iframe's host browser — see docs/rendering.md.
7
+ //
8
+ // iframeBackground is forwarded to buildIframeHarness so the capture paints
9
+ // the user's editor-shell background (or whatever the UI detected) behind
10
+ // the iframe instead of a hardcoded white.
11
+
3
12
  const fs = require("fs");
4
13
  const path = require("path");
14
+ const { execSync } = require("child_process");
5
15
  const { chromium } = require("playwright");
6
16
 
7
- const LOADING_MARKERS = [
8
- "Loading scenario...",
9
- "Loading tests...",
10
- "Loading scenarios...",
11
- "disconnected",
12
- ];
13
-
14
- function hasLoadingMarkers(text) {
15
- return LOADING_MARKERS.some((marker) => text.includes(marker));
16
- }
17
-
18
- function hasRenderableContent(state) {
19
- return Boolean(
20
- state &&
21
- (state.rootChildCount > 0 ||
22
- state.rootTextLength > 0 ||
23
- state.bodyTextLength > 0),
24
- );
17
+ // Substring Playwright has emitted across every 1.x release when the
18
+ // browser cache is empty. Match the substring (not the full string)
19
+ // because Playwright includes the offending path inline.
20
+ const PLAYWRIGHT_MISSING_BROWSER_PATTERN = "Executable doesn't exist";
21
+ const PLAYWRIGHT_INSTALL_COMMAND = "npx playwright install chromium";
22
+
23
+ // One-shot self-heal around `chromium.launch()`. If the first launch
24
+ // throws the "missing browser" error, run `npx playwright install
25
+ // chromium` synchronously (with `stdio: "inherit"` so the user sees
26
+ // progress) and retry the launch exactly once. If the install or the
27
+ // retry fails, rethrow the ORIGINAL Playwright error so the existing
28
+ // `Scenario check failed: <stderr>` path keeps showing the actionable
29
+ // message — looping would hide a real ops failure under a slow timeout.
30
+ async function launchChromiumWithSelfHeal({
31
+ launch = () => chromium.launch(),
32
+ install = () => execSync(PLAYWRIGHT_INSTALL_COMMAND, { stdio: "inherit" }),
33
+ stderr = process.stderr,
34
+ } = {}) {
35
+ try {
36
+ return await launch();
37
+ } catch (error) {
38
+ const isMissingBrowser =
39
+ error &&
40
+ typeof error.message === "string" &&
41
+ error.message.includes(PLAYWRIGHT_MISSING_BROWSER_PATTERN);
42
+ if (!isMissingBrowser) throw error;
43
+ stderr.write(
44
+ "Playwright's Chromium browser is missing — installing it now (one-time ~150 MB download). Subsequent runs will be instant.\n",
45
+ );
46
+ try {
47
+ install();
48
+ } catch (_installError) {
49
+ throw error;
50
+ }
51
+ try {
52
+ return await launch();
53
+ } catch (_retryError) {
54
+ throw error;
55
+ }
56
+ }
25
57
  }
26
58
 
27
- const ERROR_PATTERNS = [
28
- "not found in registry",
29
- "Component not found",
30
- "Scenario Error",
31
- ];
59
+ const {
60
+ findErrorPattern,
61
+ buildErrorContextSnippet,
62
+ hasRenderableContent,
63
+ describeBlankReason,
64
+ } = require("./scenario-metrics");
32
65
 
33
- function hasErrorPatterns(text) {
34
- return ERROR_PATTERNS.some((pattern) => text.includes(pattern));
35
- }
66
+ const {
67
+ createIssue,
68
+ pushIssue,
69
+ buildResult,
70
+ } = require("./scenario-issues");
36
71
 
37
- function createIssue(kind, message, extra = {}) {
38
- return {
39
- kind,
40
- message,
41
- url: extra.url ?? null,
42
- status: extra.status ?? null,
43
- };
44
- }
72
+ const {
73
+ attachHttpMocks,
74
+ } = require("./scenario-mocks");
45
75
 
46
- function pushIssue(issues, issue) {
47
- const key = JSON.stringify(issue);
48
- if (!issues.some((existing) => JSON.stringify(existing) === key)) {
49
- issues.push(issue);
76
+ const {
77
+ assertAppPortReachable,
78
+ loadScenarioInIframe,
79
+ loadScenarioTopLevel,
80
+ waitForStablePage,
81
+ collectContentState,
82
+ } = require("./scenario-playwright");
83
+
84
+ const {
85
+ getInitScript,
86
+ handleConsoleMessage,
87
+ handlePageError,
88
+ handleRequestFailed,
89
+ } = require("./scenario-handlers");
90
+
91
+ const {
92
+ probeInteractivity,
93
+ } = require("./scenario-interactivity");
94
+
95
+ // Read project-specific loading markers from `.codeyam/stack.json`
96
+ // (`capture.loadingMarkers`). The capture script runs with cwd = project dir
97
+ // (scenario_check.rs sets `.current_dir(project_dir)`), so this relative path
98
+ // resolves to the project's own config. An app's loading copy ("Loading…",
99
+ // "Please wait") is app-specific, so it lives in stack.json rather than being
100
+ // hardcoded into the shared harness; the codeyam-harness defaults in
101
+ // scenario-metrics.js always apply on top. Never throws — a missing or
102
+ // malformed stack.json just yields no extra markers.
103
+ function readStackLoadingMarkers() {
104
+ try {
105
+ const raw = fs.readFileSync(path.join(".codeyam", "stack.json"), "utf8");
106
+ const stack = JSON.parse(raw);
107
+ const markers = stack && stack.capture && stack.capture.loadingMarkers;
108
+ return Array.isArray(markers)
109
+ ? markers.filter((m) => typeof m === "string" && m.length > 0)
110
+ : [];
111
+ } catch (_) {
112
+ return [];
50
113
  }
51
114
  }
52
115
 
53
- function escapeHtmlAttribute(value) {
54
- return String(value)
55
- .replaceAll("&", "&amp;")
56
- .replaceAll('"', "&quot;");
57
- }
58
-
59
- function buildIframeHarness(url) {
60
- const escapedUrl = escapeHtmlAttribute(url);
61
- return `<!doctype html>
62
- <html>
63
- <head>
64
- <meta charset="utf-8">
65
- <style>
66
- html, body {
67
- margin: 0;
68
- width: 100%;
69
- height: 100%;
70
- overflow: hidden;
71
- background: #fff;
72
- }
73
-
74
- iframe {
75
- display: block;
76
- width: 100%;
77
- height: 100%;
78
- border: 0;
79
- background: #fff;
80
- }
81
- </style>
82
- </head>
83
- <body>
84
- <iframe id="scenario-frame" title="Scenario Preview" src="${escapedUrl}"></iframe>
85
- </body>
86
- </html>`;
87
- }
88
-
89
- function buildResult({ loaded, hasContent, issues, outputPath, url }) {
90
- return {
91
- ok: loaded && hasContent && issues.length === 0,
92
- loaded,
93
- hasContent,
94
- url,
95
- outputPath: outputPath ?? null,
96
- issues,
97
- };
98
- }
99
-
100
- function normalizeMockCandidates(url) {
116
+ // Cold-start retry pause. waitForStablePage settles as soon as the page is
117
+ // HTML-stable, which for a lazy/Suspense app is the empty `<div id="root">`
118
+ // shell — stable for the ~3s the dynamic chunk takes to load (longer when the
119
+ // scenario's mocks slow the boot). 500ms re-checked before the chunk resolved
120
+ // and reported a false blank; this pause must comfortably exceed that window
121
+ // while staying under the test runner's default per-case timeout.
122
+ const BLANK_RETRY_DELAY_MS = 3000;
123
+
124
+ // True when `url` targets a different origin than `appOrigin`. Used to decide
125
+ // whether the codeyam capture markers must be stripped before the request
126
+ // leaves (cross-origin) or may ride along (same-origin, the app's own dev
127
+ // server). A malformed URL counts as same-origin (false) so we never strip
128
+ // markers from a request we can't classify — the conservative default keeps
129
+ // same-origin behavior unchanged. `url` may be a string or a URL-like object.
130
+ function isCrossOriginRequest(url, appOrigin) {
101
131
  try {
102
- const parsed = new URL(url);
103
- return [url, `${parsed.pathname}${parsed.search}`, parsed.pathname];
104
- } catch {
105
- return [url];
132
+ const href = typeof url === "string" ? url : url.href;
133
+ return new URL(href).origin !== appOrigin;
134
+ } catch (_) {
135
+ return false;
106
136
  }
107
137
  }
108
138
 
109
- function findHttpMock(httpMocks, request) {
110
- const method = request.method().toUpperCase();
111
- const candidates = normalizeMockCandidates(request.url());
112
- for (const candidate of candidates) {
113
- const key = `${method} ${candidate}`;
114
- if (httpMocks[key]) {
115
- return httpMocks[key];
116
- }
139
+ // Return a copy of `headers` with every name in `markerNames` removed.
140
+ // Names are matched case-insensitively against the (lowercased) header keys
141
+ // Playwright reports. Pure — never mutates its input — so unrelated headers
142
+ // (Accept, User-Agent, a scenario's own requestHeaders) survive untouched.
143
+ function stripMarkerHeaders(headers, markerNames) {
144
+ const out = { ...headers };
145
+ for (const name of markerNames) {
146
+ delete out[name.toLowerCase()];
117
147
  }
118
- return null;
148
+ return out;
119
149
  }
120
150
 
121
- async function attachHttpMocks(page, httpMocks) {
122
- if (!httpMocks || Object.keys(httpMocks).length === 0) return;
123
-
124
- await page.route("**/*", async (route) => {
125
- const mock = findHttpMock(httpMocks, route.request());
126
- if (!mock) {
127
- await route.continue();
128
- return;
129
- }
130
-
131
- const headers = { ...(mock.headers || {}) };
132
- let body;
133
- if (mock.body !== undefined) {
134
- body =
135
- typeof mock.body === "string" ? mock.body : JSON.stringify(mock.body);
136
- const hasContentType = Object.keys(headers).some(
137
- (key) => key.toLowerCase() === "content-type",
138
- );
139
- if (!hasContentType) {
140
- headers["content-type"] = "application/json";
141
- }
151
+ // Apply the scenario's merged `browserState` to a Playwright context and
152
+ // stamp the codeyam capture markers on every request the context makes.
153
+ //
154
+ // Cookies need a concrete URL to bind to (Playwright requires either
155
+ // `url` or `domain`+`path`); we derive domain/path from the requested
156
+ // capture URL when the scenario didn't pin them. Request headers go
157
+ // through `setExtraHTTPHeaders` so every navigation and resource
158
+ // request in the context carries them.
159
+ //
160
+ // Every capture-originated request carries `X-Codeyam-Capture: 1` (and
161
+ // `X-Codeyam-Scenario: <slug>` when a scenario is active) so a dev-server
162
+ // log can tell the headless capture apart from the operator's own browser
163
+ // hitting the same route — they are otherwise indistinguishable. These are
164
+ // defaults: a scenario's own `requestHeaders` are merged on top and win,
165
+ // so a user can override or clear them.
166
+ async function applyBrowserState(context, config) {
167
+ const state = (config && config.browserState) || {};
168
+ const cookies = state.cookies || {};
169
+ const cookieEntries = Object.entries(cookies);
170
+ if (cookieEntries.length > 0) {
171
+ let host = "127.0.0.1";
172
+ try {
173
+ host = new URL(config.url).hostname || host;
174
+ } catch (_) {
175
+ /* fall back to localhost if config.url is malformed */
142
176
  }
143
-
144
- await route.fulfill({
145
- status: mock.status || 200,
146
- headers,
147
- body,
148
- });
149
- });
150
-
151
- // Disable the in-page fetch mock by returning an empty active-mocks.json.
152
- // The HTML injects a script that synchronously loads this file and
153
- // monkey-patches window.fetch, which would bypass Playwright's route
154
- // interception. This route is registered AFTER **/* so it takes priority
155
- // (Playwright uses LIFO ordering for route handlers).
156
- await page.route("**/active-mocks.json", async (route) => {
157
- await route.fulfill({
158
- status: 200,
159
- headers: { "content-type": "application/json" },
160
- body: "[]",
177
+ const playwrightCookies = cookieEntries.map(([name, raw]) => {
178
+ const descriptor =
179
+ raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
180
+ const value = typeof raw === "string" ? raw : descriptor.value || "";
181
+ return {
182
+ name,
183
+ value,
184
+ domain: descriptor.domain || host,
185
+ path: descriptor.path || "/",
186
+ sameSite: descriptor.sameSite || "Lax",
187
+ httpOnly: descriptor.httpOnly === true,
188
+ secure: descriptor.secure === true,
189
+ };
161
190
  });
162
- });
163
- }
164
-
165
- async function collectContentState(target) {
166
- return target.evaluate(() => {
167
- const root = document.getElementById("root");
168
- return {
169
- bodyTextLength: document.body.innerText.trim().length,
170
- rootChildCount: root ? root.childElementCount : 0,
171
- rootTextLength: root ? (root.textContent || "").trim().length : 0,
172
- };
173
- });
174
- }
175
-
176
- async function waitForStablePage(page, target, timeoutMs = 10000) {
177
- const started = Date.now();
178
- let lastHtml = "";
179
- let stableCount = 0;
180
-
181
- while (Date.now() - started < timeoutMs) {
182
- await page.waitForTimeout(500);
183
-
184
- const pageState = await target.evaluate(() => ({
185
- bodyText: document.body.innerText,
186
- html: document.body.innerHTML,
187
- }));
188
-
189
- if (
190
- !hasLoadingMarkers(pageState.bodyText) &&
191
- pageState.html === lastHtml
192
- ) {
193
- stableCount += 1;
194
- if (stableCount >= 2) return;
195
- } else {
196
- stableCount = 0;
197
- }
191
+ await context.addCookies(playwrightCookies);
192
+ }
193
+ const codeyamHeaders = {
194
+ "X-Codeyam-Capture": "1",
195
+ ...(config && config.scenarioId
196
+ ? { "X-Codeyam-Scenario": config.scenarioId }
197
+ : {}),
198
+ };
199
+ // Scenario `requestHeaders` are merged last so a user value overrides
200
+ // the codeyam default (e.g. setting `X-Codeyam-Capture: "0"` as an
201
+ // escape hatch). `codeyamHeaders` always has at least the capture marker,
202
+ // so this fires on every capture — including ones with no browserState.
203
+ const headers = { ...codeyamHeaders, ...(state.requestHeaders || {}) };
204
+ if (Object.keys(headers).length > 0) {
205
+ await context.setExtraHTTPHeaders(headers);
206
+ }
198
207
 
199
- lastHtml = pageState.html;
208
+ // Strip the codeyam capture markers (and any scenario request headers) from
209
+ // CROSS-ORIGIN requests. setExtraHTTPHeaders applies context-wide, so the
210
+ // custom `X-Codeyam-*` headers ride along on third-party subresource
211
+ // requests (Google Fonts, CDNs, external APIs) too — and a non-safelisted
212
+ // request header forces a CORS preflight those hosts reject
213
+ // (`Request header field x-codeyam-capture is not allowed`), which fails the
214
+ // whole capture. The markers are only meaningful to the app's OWN dev server
215
+ // (same-origin), so re-send cross-origin requests without them. Same-origin
216
+ // requests are never matched here, so dev-module/HMR loading is untouched.
217
+ let appOrigin = null;
218
+ try {
219
+ appOrigin = new URL(config.url).origin;
220
+ } catch (_) {
221
+ /* malformed capture URL — skip the cross-origin guard entirely */
222
+ }
223
+ if (appOrigin && typeof context.route === "function") {
224
+ // Only the codeyam markers are stripped — a scenario's own requestHeaders
225
+ // are the author's deliberate choice and left intact.
226
+ const markerNames = Object.keys(codeyamHeaders);
227
+ await context.route(
228
+ (url) => isCrossOriginRequest(url, appOrigin),
229
+ async (route) => {
230
+ const reqHeaders = stripMarkerHeaders(route.request().headers(), markerNames);
231
+ await route.continue({ headers: reqHeaders });
232
+ },
233
+ );
200
234
  }
201
235
  }
202
236
 
203
- async function loadScenarioInIframe(page, url) {
204
- const responsePromise = page
205
- .waitForResponse(
206
- (response) =>
207
- response.request().resourceType() === "document" &&
208
- response.url() === url,
209
- { timeout: 30000 },
210
- )
211
- .catch(() => null);
212
-
213
- await page.setContent(buildIframeHarness(url), { waitUntil: "domcontentloaded" });
214
-
215
- const frameHandle = await page.waitForSelector("#scenario-frame", {
216
- state: "attached",
217
- timeout: 30000,
218
- });
219
- const frame = await frameHandle.contentFrame();
220
- if (!frame) {
221
- throw new Error("Scenario iframe did not attach");
237
+ // Emit a `redirect-mismatch` issue when the final iframe URL's path
238
+ // differs from the requested path. This converts the silent
239
+ // screenshot-of-/login failure (auth lost in the capture context) into
240
+ // a typed, actionable diagnostic the agent can route to.
241
+ function pushRedirectMismatchIssue(issues, requestedUrl, frame, response, config) {
242
+ let requestedPath;
243
+ try {
244
+ requestedPath = new URL(requestedUrl).pathname;
245
+ } catch (_) {
246
+ return;
222
247
  }
223
-
224
- await frame.waitForLoadState("load", { timeout: 30000 });
225
- const response = await responsePromise;
226
- return { frame, response };
248
+ const finalUrl = (frame && frame.url && frame.url()) || requestedUrl;
249
+ let finalPath;
250
+ try {
251
+ finalPath = new URL(finalUrl).pathname;
252
+ } catch (_) {
253
+ return;
254
+ }
255
+ if (requestedPath === finalPath) return;
256
+ const cookies =
257
+ (config && config.browserState && config.browserState.cookies) || {};
258
+ const hasCookies = Object.keys(cookies).length > 0;
259
+ const hint = hasCookies
260
+ ? " — scenario carries browserState.cookies; auth likely lost in the capture context (run `codeyam-editor editor scenario-explain <slug>` to verify)"
261
+ : "";
262
+ pushIssue(
263
+ issues,
264
+ createIssue(
265
+ "redirect-mismatch",
266
+ `Capture URL redirected from ${requestedPath} to ${finalPath}${hint}`,
267
+ {
268
+ url: finalUrl,
269
+ status: response && response.status ? response.status() : null,
270
+ },
271
+ ),
272
+ );
227
273
  }
228
274
 
229
- async function runScenarioCheck(config) {
275
+ // `preflight` is injectable (defaulting to the real app-port reachability
276
+ // check) so unit tests that mock the browser can stay network-free.
277
+ async function runScenarioCheck(config, { preflight = assertAppPortReachable } = {}) {
230
278
  const { url, outputPath, width, height, httpMocks = {} } = config;
231
279
  const issues = [];
232
- const browser = await chromium.launch();
233
- const context = await browser.newContext({
280
+ const browser = await launchChromiumWithSelfHeal();
281
+ const contextOptions = {
234
282
  viewport: { width: width || 1440, height: height || 900 },
235
- });
283
+ };
284
+ if (config.colorScheme) contextOptions.colorScheme = config.colorScheme;
285
+ if (config.deviceScaleFactor)
286
+ contextOptions.deviceScaleFactor = config.deviceScaleFactor;
287
+ if (config.userAgent) contextOptions.userAgent = config.userAgent;
288
+ if (config.locale) contextOptions.locale = config.locale;
289
+ if (config.timezoneId) contextOptions.timezoneId = config.timezoneId;
290
+ if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion;
291
+ if (config.forcedColors) contextOptions.forcedColors = config.forcedColors;
292
+ const context = await browser.newContext(contextOptions);
293
+
294
+ // Apply the scenario's merged `browserState` (cookies + request
295
+ // headers) to the capture context BEFORE the first navigation.
296
+ // Belt-and-suspenders with the proxy's `Set-Cookie` injection: the
297
+ // proxy handles upstream-bound forwards, this branch handles the
298
+ // capture context's own request headers so an auth-gated route does
299
+ // not redirect to `/login` when the proxy is bypassed.
300
+ await applyBrowserState(context, config);
236
301
 
237
302
  // Context-level init script runs in ALL frames (including cross-origin iframes)
238
- await context.addInitScript(() => {
239
- window.__codeyamUnhandledRejections = [];
240
- window.addEventListener("unhandledrejection", (event) => {
241
- const reason = event.reason;
242
- const message =
243
- reason instanceof Error ? reason.message : String(reason);
244
- window.__codeyamUnhandledRejections.push(message);
245
- });
246
-
247
- // Stub WebSocket during capture to prevent terminal reconnection spam.
248
- // Returns an object that behaves like a closed WebSocket — the terminal
249
- // will attempt to reconnect a few times then give up silently.
250
- window.WebSocket = class StubWebSocket {
251
- static CONNECTING = 0;
252
- static OPEN = 1;
253
- static CLOSING = 2;
254
- static CLOSED = 3;
255
- readyState = 3;
256
- onopen = null;
257
- onclose = null;
258
- onerror = null;
259
- onmessage = null;
260
- send() {}
261
- close() {}
262
- addEventListener() {}
263
- removeEventListener() {}
264
- dispatchEvent() { return false; }
265
- constructor() {
266
- // Fire onerror then onclose on next tick so the terminal sees a failed connection
267
- setTimeout(() => {
268
- if (this.onerror) this.onerror(new Event("error"));
269
- if (this.onclose) this.onclose(new CloseEvent("close"));
270
- }, 0);
271
- }
272
- };
273
- });
303
+ await context.addInitScript(getInitScript());
274
304
 
275
305
  const page = await context.newPage();
276
306
  await attachHttpMocks(page, httpMocks);
277
307
 
278
308
  page.on("pageerror", (error) => {
279
- pushIssue(
280
- issues,
281
- createIssue("pageerror", error.message || String(error), {
282
- url: page.url() || url,
283
- }),
284
- );
309
+ pushIssue(issues, handlePageError(error));
285
310
  });
286
311
 
287
312
  page.on("console", (message) => {
288
- if (message.type() !== "error") return;
289
- const text = message.text();
290
- // Ignore known dev-server WebSocket/HMR errors from Vite proxy
291
- if (
292
- text.includes("WebSocket connection to") ||
293
- text.includes("Unsupported Media Type")
294
- ) {
295
- return;
313
+ const issue = handleConsoleMessage(message);
314
+ if (issue) {
315
+ pushIssue(issues, issue);
296
316
  }
297
- pushIssue(
298
- issues,
299
- createIssue("console", text, {
300
- url: page.url() || url,
301
- }),
302
- );
303
317
  });
304
318
 
305
319
  page.on("requestfailed", (request) => {
306
- pushIssue(
307
- issues,
308
- createIssue(
309
- "requestfailed",
310
- request.failure()?.errorText || "Request failed",
311
- { url: request.url() },
312
- ),
313
- );
320
+ const issue = handleRequestFailed(request);
321
+ if (issue) {
322
+ pushIssue(issues, issue);
323
+ }
314
324
  });
315
325
 
316
326
  let loaded = false;
317
327
 
318
328
  try {
319
- const { frame, response } = await loadScenarioInIframe(page, url);
329
+ // Application/route captures navigate at the top level so the
330
+ // first-party session cookie is sent (auth-gated routes render the
331
+ // authenticated page instead of /login); component captures keep the
332
+ // iframe harness for its background/sizing control. The backend signals
333
+ // the choice via `config.navigation` ("topLevel"); absent (the default)
334
+ // means the iframe harness, so existing callers are unchanged.
335
+ const { frame, response } =
336
+ config.navigation === "topLevel"
337
+ ? await loadScenarioTopLevel(page, url, { preflight })
338
+ : await loadScenarioInIframe(page, url, {
339
+ background: config.iframeBackground,
340
+ preflight,
341
+ });
320
342
  loaded = true;
321
343
 
322
344
  if (response && response.status() >= 400) {
@@ -329,7 +351,15 @@ async function runScenarioCheck(config) {
329
351
  );
330
352
  }
331
353
 
332
- await waitForStablePage(page, frame);
354
+ pushRedirectMismatchIssue(issues, url, frame, response, config);
355
+
356
+ // Project loading markers come from config when a caller injects them
357
+ // (unit tests), otherwise from stack.json — so a stable-but-loading app
358
+ // screen is not mistaken for settled content and captured mid-hydration.
359
+ const loadingMarkers = Array.isArray(config.loadingMarkers)
360
+ ? config.loadingMarkers
361
+ : readStackLoadingMarkers();
362
+ await waitForStablePage(page, frame, 10000, loadingMarkers);
333
363
 
334
364
  const rejectionMessages = await frame.evaluate(
335
365
  () => window.__codeyamUnhandledRejections || [],
@@ -343,29 +373,63 @@ async function runScenarioCheck(config) {
343
373
  );
344
374
  }
345
375
 
346
- const contentState = await collectContentState(frame);
347
- const hasContent = hasRenderableContent(contentState);
376
+ // Cold-start retry: a single re-collect after BLANK_RETRY_DELAY_MS covers
377
+ // the React.lazy / Suspense-fallback race where waitForStablePage settles
378
+ // on the still-empty `<div id="root">` shell before the dynamic chunk
379
+ // resolves. One retry is enough — the pause is sized to outlast the
380
+ // chunk-load window; only a genuinely blank page falls through to the
381
+ // blank issue below.
382
+ let contentState = await collectContentState(frame);
383
+ let hasContent = hasRenderableContent(contentState);
384
+ if (!hasContent) {
385
+ await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS));
386
+ contentState = await collectContentState(frame);
387
+ hasContent = hasRenderableContent(contentState);
388
+ }
348
389
 
349
390
  if (!hasContent) {
350
391
  pushIssue(
351
392
  issues,
352
- createIssue("blank", "Page rendered no visible content", {
353
- url: page.url() || url,
354
- }),
393
+ createIssue(
394
+ "blank",
395
+ `Page rendered no visible content (${describeBlankReason(contentState)})`,
396
+ { url: page.url() || url },
397
+ ),
355
398
  );
356
399
  }
357
400
 
358
401
  // Check for known error states in the rendered content
359
- const bodyText = await frame.evaluate(() => document.body.innerText || "");
360
- if (hasErrorPatterns(bodyText)) {
402
+ const bodyText = await frame.evaluate(() => document.body?.innerText || "");
403
+ const matchedPattern = findErrorPattern(bodyText);
404
+ if (matchedPattern) {
405
+ const contextSnippet = buildErrorContextSnippet(bodyText, matchedPattern);
361
406
  pushIssue(
362
407
  issues,
363
- createIssue("error-state", `Page contains error content: ${bodyText.slice(0, 200)}`, {
364
- url: page.url() || url,
365
- }),
408
+ createIssue(
409
+ "error-state",
410
+ `Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? bodyText.slice(0, 200)}`,
411
+ {
412
+ url: page.url() || url,
413
+ matchedPattern,
414
+ contextSnippet,
415
+ },
416
+ ),
366
417
  );
367
418
  }
368
419
 
420
+ // Hydration / interactivity gate: a page can render content and log no
421
+ // errors yet never have hydrated, leaving every control dead. Read-only
422
+ // (it inspects framework-attachment markers, never clicks), so it is safe
423
+ // to run before the screenshot. Stack-gated and fail-safe — see
424
+ // scenario-interactivity.js — so backend / static / unknown-framework
425
+ // captures are an automatic pass.
426
+ const hydrationIssue = await probeInteractivity(frame, {
427
+ url: page.url() || url,
428
+ });
429
+ if (hydrationIssue) {
430
+ pushIssue(issues, hydrationIssue);
431
+ }
432
+
369
433
  if (outputPath && loaded) {
370
434
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
371
435
  await page.screenshot({ path: outputPath, fullPage: false });
@@ -396,6 +460,11 @@ async function runScenarioCheck(config) {
396
460
  }
397
461
  }
398
462
 
463
+ /**
464
+ * npm wrapper entry point for the scenario-check binary: parses the
465
+ * JSON config from argv, drives Playwright to capture the configured
466
+ * URL, and writes the resulting screenshot.
467
+ */
399
468
  async function main() {
400
469
  const config = JSON.parse(process.argv[2] || "{}");
401
470
 
@@ -411,19 +480,15 @@ async function main() {
411
480
  }
412
481
 
413
482
  module.exports = {
414
- attachHttpMocks,
415
- buildResult,
416
- buildIframeHarness,
417
- createIssue,
418
- findHttpMock,
419
- hasErrorPatterns,
420
- hasLoadingMarkers,
421
- hasRenderableContent,
422
- loadScenarioInIframe,
423
- normalizeMockCandidates,
424
- pushIssue,
425
483
  runScenarioCheck,
426
- waitForStablePage,
484
+ readStackLoadingMarkers,
485
+ applyBrowserState,
486
+ isCrossOriginRequest,
487
+ stripMarkerHeaders,
488
+ main,
489
+ launchChromiumWithSelfHeal,
490
+ PLAYWRIGHT_INSTALL_COMMAND,
491
+ PLAYWRIGHT_MISSING_BROWSER_PATTERN,
427
492
  };
428
493
 
429
494
  if (require.main === module) {