@codeyam-editor/codeyam-editor 0.1.0-staging.01efa1a

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 (56) hide show
  1. package/npm/cli.js +19 -0
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +2184 -0
  4. package/npm/container.js +54 -0
  5. package/npm/editor-dev.js +459 -0
  6. package/npm/editor.js +272 -0
  7. package/npm/empty-project-dir.js +33 -0
  8. package/npm/fleet-auth.js +111 -0
  9. package/npm/fleet-branch.js +49 -0
  10. package/npm/fleet-broker-death.js +44 -0
  11. package/npm/fleet-broker-debounce.js +63 -0
  12. package/npm/fleet-broker-status.js +45 -0
  13. package/npm/fleet-build-data.js +81 -0
  14. package/npm/fleet-cleanup.js +297 -0
  15. package/npm/fleet-effect-status.js +179 -0
  16. package/npm/fleet-env.js +125 -0
  17. package/npm/fleet-grouping.js +56 -0
  18. package/npm/fleet-job-badge.js +83 -0
  19. package/npm/fleet-memory.js +157 -0
  20. package/npm/fleet-plans.js +96 -0
  21. package/npm/fleet-pool-policy.js +78 -0
  22. package/npm/fleet-pool.js +401 -0
  23. package/npm/fleet-queue.js +40 -0
  24. package/npm/fleet-reconcile.js +93 -0
  25. package/npm/fleet-reset.js +222 -0
  26. package/npm/fleet-roster.js +81 -0
  27. package/npm/launch-config.js +717 -0
  28. package/npm/open.js +15 -0
  29. package/npm/preflight-guidance.js +77 -0
  30. package/npm/scenario-check.js +604 -0
  31. package/npm/scenario-handlers.js +82 -0
  32. package/npm/scenario-interactivity.js +245 -0
  33. package/npm/scenario-issues.js +35 -0
  34. package/npm/scenario-metrics.js +108 -0
  35. package/npm/scenario-mocks.js +107 -0
  36. package/npm/scenario-playwright.js +534 -0
  37. package/npm/utils.js +823 -0
  38. package/npm/vm-urls.js +117 -0
  39. package/package.json +32 -0
  40. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  41. package/ui/dist/assets/EditorShell-By-2rl7D.js +187 -0
  42. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  43. package/ui/dist/assets/ScenarioDataPanel-Cg8zOFNZ.js +5 -0
  44. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  45. package/ui/dist/assets/ScenarioDataPanelFullPage-WOUYbba7.js +1 -0
  46. package/ui/dist/assets/ScenarioRenderer-CBTlIVPs.js +1 -0
  47. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  48. package/ui/dist/assets/index-DzoF9RKw.js +27 -0
  49. package/ui/dist/assets/index-ckz94rKI.css +1 -0
  50. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  51. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  52. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  53. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  54. package/ui/dist/favicon.ico +0 -0
  55. package/ui/dist/favicon.png +0 -0
  56. package/ui/dist/index.html +16 -0
package/npm/open.js ADDED
@@ -0,0 +1,15 @@
1
+ const { spawn } = require("child_process");
2
+
3
+ /** Open a URL in the default browser. */
4
+ function open(url) {
5
+ const cmd =
6
+ process.platform === "darwin"
7
+ ? "open"
8
+ : process.platform === "win32"
9
+ ? "start"
10
+ : "xdg-open";
11
+
12
+ spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
13
+ }
14
+
15
+ module.exports = { open };
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Launcher-side mirror of `start_preflight` in
5
+ * `crates/codeyam-editor/src/commands/start.rs`. The Rust side is the source
6
+ * of truth for the guidance strings; if it changes, update this file in the
7
+ * same commit and keep the `crates/codeyam-editor/tests/start_preflight_exit_code.rs`
8
+ * test in sync.
9
+ *
10
+ * Mirrors the heuristic in `migration_status::is_legacy_config`: a
11
+ * `.codeyam/config.json` whose JSON has a top-level `webapps` key marks a
12
+ * legacy project. Anything else with no `editor.json` is greenfield.
13
+ *
14
+ * Rust-side behavior note (improve39): `start_preflight` no longer bails on an
15
+ * EMPTY greenfield folder — it auto-inits in place and boots the editor's wired
16
+ * scaffold flow. It still bails (exit 2) for an existing-source `none` folder
17
+ * (the `greenfieldGuidance` text below) and for legacy. This module is consumed
18
+ * by the launcher's bail/guidance path only; `classifyProject` does not itself
19
+ * decide empty-vs-non-empty (the launcher's `planPreflightAction` /
20
+ * `postLaunchInitBanner` in editor-dev.js own that split and intentionally
21
+ * auto-init all `none` projects as a local-dev convenience).
22
+ */
23
+
24
+ const fs = require("node:fs");
25
+ const path = require("node:path");
26
+
27
+ function greenfieldGuidance(projectDir) {
28
+ return (
29
+ `No codeyam-editor project detected in ${projectDir}.\n\n` +
30
+ "Run `codeyam-editor init` to install the agent bundle, then\n" +
31
+ "open Claude Code and invoke `/codeyam-onboard` to set up this project."
32
+ );
33
+ }
34
+
35
+ function legacyGuidance(projectDir) {
36
+ return (
37
+ `Detected legacy codeyam project in ${projectDir}.\n\n` +
38
+ "Run `codeyam-editor init` to install the agent bundle (it will skip\n" +
39
+ "editor.json creation so the legacy classification is preserved), then\n" +
40
+ "open Claude Code and invoke `/codeyam-onboard` to migrate."
41
+ );
42
+ }
43
+
44
+ function isLegacyConfig(configPath) {
45
+ let raw;
46
+ try {
47
+ raw = fs.readFileSync(configPath, "utf8");
48
+ } catch {
49
+ return false;
50
+ }
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ } catch {
55
+ return false;
56
+ }
57
+ return parsed && typeof parsed === "object" && "webapps" in parsed;
58
+ }
59
+
60
+ /**
61
+ * Classify a project directory as `modern`, `legacy`, or `none`, mirroring
62
+ * the Rust `classify` / `start_preflight` pair. `guidance` is the exact
63
+ * stderr text to print before exiting with code 2.
64
+ */
65
+ function classifyProject(projectDir) {
66
+ const editorJson = path.join(projectDir, ".codeyam", "editor.json");
67
+ if (fs.existsSync(editorJson)) {
68
+ return { kind: "modern" };
69
+ }
70
+ const legacyConfig = path.join(projectDir, ".codeyam", "config.json");
71
+ if (isLegacyConfig(legacyConfig)) {
72
+ return { kind: "legacy", guidance: legacyGuidance(projectDir) };
73
+ }
74
+ return { kind: "none", guidance: greenfieldGuidance(projectDir) };
75
+ }
76
+
77
+ module.exports = { classifyProject };
@@ -0,0 +1,604 @@
1
+ #!/usr/bin/env node
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
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const { execSync } = require("child_process");
15
+ const { chromium } = require("playwright");
16
+
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
+ }
57
+ }
58
+
59
+ const {
60
+ findErrorPattern,
61
+ buildErrorContextSnippet,
62
+ hasRenderableContent,
63
+ describeBlankReason,
64
+ } = require("./scenario-metrics");
65
+
66
+ const {
67
+ createIssue,
68
+ pushIssue,
69
+ buildResult,
70
+ } = require("./scenario-issues");
71
+
72
+ const {
73
+ attachHttpMocks,
74
+ } = require("./scenario-mocks");
75
+
76
+ const {
77
+ assertAppPortReachable,
78
+ loadScenarioInIframe,
79
+ loadScenarioTopLevel,
80
+ waitForStablePage,
81
+ createNetworkTracker,
82
+ waitForNetworkQuiet,
83
+ collectContentState,
84
+ performInteraction,
85
+ } = require("./scenario-playwright");
86
+
87
+ const {
88
+ getInitScript,
89
+ handleConsoleMessage,
90
+ handlePageError,
91
+ handleRequestFailed,
92
+ } = require("./scenario-handlers");
93
+
94
+ const {
95
+ probeInteractivity,
96
+ } = require("./scenario-interactivity");
97
+
98
+ // Read project-specific loading markers from `.codeyam/stack.json`
99
+ // (`capture.loadingMarkers`). The capture script runs with cwd = project dir
100
+ // (scenario_check.rs sets `.current_dir(project_dir)`), so this relative path
101
+ // resolves to the project's own config. An app's loading copy ("Loading…",
102
+ // "Please wait") is app-specific, so it lives in stack.json rather than being
103
+ // hardcoded into the shared harness; the codeyam-harness defaults in
104
+ // scenario-metrics.js always apply on top. Never throws — a missing or
105
+ // malformed stack.json just yields no extra markers.
106
+ function readStackLoadingMarkers() {
107
+ try {
108
+ const raw = fs.readFileSync(path.join(".codeyam", "stack.json"), "utf8");
109
+ const stack = JSON.parse(raw);
110
+ const markers = stack && stack.capture && stack.capture.loadingMarkers;
111
+ return Array.isArray(markers)
112
+ ? markers.filter((m) => typeof m === "string" && m.length > 0)
113
+ : [];
114
+ } catch (_) {
115
+ return [];
116
+ }
117
+ }
118
+
119
+ // Cold-start retry pause. waitForStablePage settles as soon as the page is
120
+ // HTML-stable, which for a lazy/Suspense app is the empty `<div id="root">`
121
+ // shell — stable for the ~3s the dynamic chunk takes to load (longer when the
122
+ // scenario's mocks slow the boot). 500ms re-checked before the chunk resolved
123
+ // and reported a false blank; this pause must comfortably exceed that window
124
+ // while staying under the test runner's default per-case timeout.
125
+ const BLANK_RETRY_DELAY_MS = 3000;
126
+
127
+ // True when `url` targets a different origin than `appOrigin`. Used to decide
128
+ // whether the codeyam capture markers must be stripped before the request
129
+ // leaves (cross-origin) or may ride along (same-origin, the app's own dev
130
+ // server). A malformed URL counts as same-origin (false) so we never strip
131
+ // markers from a request we can't classify — the conservative default keeps
132
+ // same-origin behavior unchanged. `url` may be a string or a URL-like object.
133
+ function isCrossOriginRequest(url, appOrigin) {
134
+ try {
135
+ const href = typeof url === "string" ? url : url.href;
136
+ return new URL(href).origin !== appOrigin;
137
+ } catch (_) {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ // Return a copy of `headers` with every name in `markerNames` removed.
143
+ // Names are matched case-insensitively against the (lowercased) header keys
144
+ // Playwright reports. Pure — never mutates its input — so unrelated headers
145
+ // (Accept, User-Agent, a scenario's own requestHeaders) survive untouched.
146
+ function stripMarkerHeaders(headers, markerNames) {
147
+ const out = { ...headers };
148
+ for (const name of markerNames) {
149
+ delete out[name.toLowerCase()];
150
+ }
151
+ return out;
152
+ }
153
+
154
+ // Apply the scenario's merged `browserState` to a Playwright context and
155
+ // stamp the codeyam capture markers on every request the context makes.
156
+ //
157
+ // Cookies need a concrete URL to bind to (Playwright requires either
158
+ // `url` or `domain`+`path`); we derive domain/path from the requested
159
+ // capture URL when the scenario didn't pin them. Request headers go
160
+ // through `setExtraHTTPHeaders` so every navigation and resource
161
+ // request in the context carries them.
162
+ //
163
+ // Every capture-originated request carries `X-Codeyam-Capture: 1` (and
164
+ // `X-Codeyam-Scenario: <slug>` when a scenario is active) so a dev-server
165
+ // log can tell the headless capture apart from the operator's own browser
166
+ // hitting the same route — they are otherwise indistinguishable. These are
167
+ // defaults: a scenario's own `requestHeaders` are merged on top and win,
168
+ // so a user can override or clear them.
169
+ async function applyBrowserState(context, config) {
170
+ const state = (config && config.browserState) || {};
171
+ const cookies = state.cookies || {};
172
+ const cookieEntries = Object.entries(cookies);
173
+ if (cookieEntries.length > 0) {
174
+ let host = "127.0.0.1";
175
+ try {
176
+ host = new URL(config.url).hostname || host;
177
+ } catch (_) {
178
+ /* fall back to localhost if config.url is malformed */
179
+ }
180
+ const playwrightCookies = cookieEntries.map(([name, raw]) => {
181
+ const descriptor =
182
+ raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
183
+ const value = typeof raw === "string" ? raw : descriptor.value || "";
184
+ return {
185
+ name,
186
+ value,
187
+ domain: descriptor.domain || host,
188
+ path: descriptor.path || "/",
189
+ sameSite: descriptor.sameSite || "Lax",
190
+ httpOnly: descriptor.httpOnly === true,
191
+ secure: descriptor.secure === true,
192
+ };
193
+ });
194
+ await context.addCookies(playwrightCookies);
195
+ }
196
+ const codeyamHeaders = {
197
+ "X-Codeyam-Capture": "1",
198
+ ...(config && config.scenarioId
199
+ ? { "X-Codeyam-Scenario": config.scenarioId }
200
+ : {}),
201
+ };
202
+ // Scenario `requestHeaders` are merged last so a user value overrides
203
+ // the codeyam default (e.g. setting `X-Codeyam-Capture: "0"` as an
204
+ // escape hatch). `codeyamHeaders` always has at least the capture marker,
205
+ // so this fires on every capture — including ones with no browserState.
206
+ const headers = { ...codeyamHeaders, ...(state.requestHeaders || {}) };
207
+ if (Object.keys(headers).length > 0) {
208
+ await context.setExtraHTTPHeaders(headers);
209
+ }
210
+
211
+ // Strip the codeyam capture markers (and any scenario request headers) from
212
+ // CROSS-ORIGIN requests. setExtraHTTPHeaders applies context-wide, so the
213
+ // custom `X-Codeyam-*` headers ride along on third-party subresource
214
+ // requests (Google Fonts, CDNs, external APIs) too — and a non-safelisted
215
+ // request header forces a CORS preflight those hosts reject
216
+ // (`Request header field x-codeyam-capture is not allowed`), which fails the
217
+ // whole capture. The markers are only meaningful to the app's OWN dev server
218
+ // (same-origin), so re-send cross-origin requests without them. Same-origin
219
+ // requests are never matched here, so dev-module/HMR loading is untouched.
220
+ let appOrigin = null;
221
+ try {
222
+ appOrigin = new URL(config.url).origin;
223
+ } catch (_) {
224
+ /* malformed capture URL — skip the cross-origin guard entirely */
225
+ }
226
+ if (appOrigin && typeof context.route === "function") {
227
+ // Only the codeyam markers are stripped — a scenario's own requestHeaders
228
+ // are the author's deliberate choice and left intact.
229
+ const markerNames = Object.keys(codeyamHeaders);
230
+ await context.route(
231
+ (url) => isCrossOriginRequest(url, appOrigin),
232
+ async (route) => {
233
+ const reqHeaders = stripMarkerHeaders(route.request().headers(), markerNames);
234
+ await route.continue({ headers: reqHeaders });
235
+ },
236
+ );
237
+ }
238
+ }
239
+
240
+ // Emit a `redirect-mismatch` issue when the final iframe URL's path
241
+ // differs from the requested path. This converts the silent
242
+ // screenshot-of-/login failure (auth lost in the capture context) into
243
+ // a typed, actionable diagnostic the agent can route to.
244
+ function pushRedirectMismatchIssue(issues, requestedUrl, frame, response, config) {
245
+ let requestedPath;
246
+ try {
247
+ requestedPath = new URL(requestedUrl).pathname;
248
+ } catch (_) {
249
+ return;
250
+ }
251
+ const finalUrl = (frame && frame.url && frame.url()) || requestedUrl;
252
+ let finalPath;
253
+ try {
254
+ finalPath = new URL(finalUrl).pathname;
255
+ } catch (_) {
256
+ return;
257
+ }
258
+ if (requestedPath === finalPath) return;
259
+ const cookies =
260
+ (config && config.browserState && config.browserState.cookies) || {};
261
+ const hasCookies = Object.keys(cookies).length > 0;
262
+ const hint = hasCookies
263
+ ? " — scenario carries browserState.cookies; auth likely lost in the capture context (run `codeyam-editor editor scenario-explain <slug>` to verify)"
264
+ : "";
265
+ pushIssue(
266
+ issues,
267
+ createIssue(
268
+ "redirect-mismatch",
269
+ `Capture URL redirected from ${requestedPath} to ${finalPath}${hint}`,
270
+ {
271
+ url: finalUrl,
272
+ status: response && response.status ? response.status() : null,
273
+ },
274
+ ),
275
+ );
276
+ }
277
+
278
+ // Read-only page-state snapshot for `capture-state`: the full localStorage
279
+ // map, a bounded sample of visible text nodes (document order), and — when a
280
+ // selector is given — that element's text. Evaluated in-page against the
281
+ // settled frame so it reflects exactly what a real capture saw (the proxy
282
+ // already injected the scenario's seed into the served HTML). Every read is
283
+ // individually guarded so a sandboxed/cross-origin localStorage never throws
284
+ // the whole capture; the worst case is an empty section, not a failure.
285
+ async function dumpPageState(frame, selector) {
286
+ return frame.evaluate((sel) => {
287
+ const localStorage = {};
288
+ try {
289
+ for (let i = 0; i < window.localStorage.length; i++) {
290
+ const key = window.localStorage.key(i);
291
+ if (key != null) localStorage[key] = window.localStorage.getItem(key);
292
+ }
293
+ } catch (_) {
294
+ /* localStorage may be unavailable (sandboxed/opaque origin) */
295
+ }
296
+
297
+ const visibleText = [];
298
+ try {
299
+ // Reject text inside non-rendered tags (SCRIPT/STYLE/etc.) so an
300
+ // injected proxy script or inline CSS never masquerades as on-screen
301
+ // text — that noise is exactly what makes a state dump misleading.
302
+ const SKIP_TAGS = new Set([
303
+ "SCRIPT",
304
+ "STYLE",
305
+ "NOSCRIPT",
306
+ "TEMPLATE",
307
+ "HEAD",
308
+ "TITLE",
309
+ ]);
310
+ const walker = document.createTreeWalker(
311
+ document.body,
312
+ NodeFilter.SHOW_TEXT,
313
+ {
314
+ acceptNode(node) {
315
+ const parent = node.parentElement;
316
+ if (parent && SKIP_TAGS.has(parent.tagName)) {
317
+ return NodeFilter.FILTER_REJECT;
318
+ }
319
+ return (node.textContent || "").trim()
320
+ ? NodeFilter.FILTER_ACCEPT
321
+ : NodeFilter.FILTER_REJECT;
322
+ },
323
+ },
324
+ );
325
+ let node;
326
+ while ((node = walker.nextNode()) && visibleText.length < 40) {
327
+ const text = (node.textContent || "").replace(/\s+/g, " ").trim();
328
+ if (text) visibleText.push(text);
329
+ }
330
+ } catch (_) {
331
+ /* no body / detached document */
332
+ }
333
+
334
+ let selectorText = null;
335
+ if (sel) {
336
+ try {
337
+ const el = document.querySelector(sel);
338
+ if (el) selectorText = (el.textContent || "").replace(/\s+/g, " ").trim();
339
+ } catch (_) {
340
+ /* invalid selector — leave selectorText null */
341
+ }
342
+ }
343
+
344
+ return { localStorage, visibleText, selectorText };
345
+ }, selector || null);
346
+ }
347
+
348
+ // `preflight` is injectable (defaulting to the real app-port reachability
349
+ // check) so unit tests that mock the browser can stay network-free.
350
+ async function runScenarioCheck(config, { preflight = assertAppPortReachable } = {}) {
351
+ const { url, outputPath, width, height, httpMocks = {} } = config;
352
+ const issues = [];
353
+ const browser = await launchChromiumWithSelfHeal();
354
+ const contextOptions = {
355
+ viewport: { width: width || 1440, height: height || 900 },
356
+ };
357
+ if (config.colorScheme) contextOptions.colorScheme = config.colorScheme;
358
+ if (config.deviceScaleFactor)
359
+ contextOptions.deviceScaleFactor = config.deviceScaleFactor;
360
+ if (config.userAgent) contextOptions.userAgent = config.userAgent;
361
+ if (config.locale) contextOptions.locale = config.locale;
362
+ if (config.timezoneId) contextOptions.timezoneId = config.timezoneId;
363
+ if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion;
364
+ if (config.forcedColors) contextOptions.forcedColors = config.forcedColors;
365
+ const context = await browser.newContext(contextOptions);
366
+
367
+ // Apply the scenario's merged `browserState` (cookies + request
368
+ // headers) to the capture context BEFORE the first navigation.
369
+ // Belt-and-suspenders with the proxy's `Set-Cookie` injection: the
370
+ // proxy handles upstream-bound forwards, this branch handles the
371
+ // capture context's own request headers so an auth-gated route does
372
+ // not redirect to `/login` when the proxy is bypassed.
373
+ await applyBrowserState(context, config);
374
+
375
+ // Context-level init script runs in ALL frames (including cross-origin iframes)
376
+ await context.addInitScript(getInitScript());
377
+
378
+ const page = await context.newPage();
379
+ // Attach the network tracker BEFORE navigation so every request (the document
380
+ // and every client-side data fetch) is counted. Used after DOM stability to
381
+ // wait out an in-flight fetch that would otherwise be screenshotted as a
382
+ // loading skeleton.
383
+ const networkTracker = createNetworkTracker(page);
384
+ await attachHttpMocks(page, httpMocks);
385
+
386
+ page.on("pageerror", (error) => {
387
+ pushIssue(issues, handlePageError(error));
388
+ });
389
+
390
+ page.on("console", (message) => {
391
+ const issue = handleConsoleMessage(message);
392
+ if (issue) {
393
+ pushIssue(issues, issue);
394
+ }
395
+ });
396
+
397
+ page.on("requestfailed", (request) => {
398
+ const issue = handleRequestFailed(request);
399
+ if (issue) {
400
+ pushIssue(issues, issue);
401
+ }
402
+ });
403
+
404
+ let loaded = false;
405
+
406
+ try {
407
+ // Application/route captures navigate at the top level so the
408
+ // first-party session cookie is sent (auth-gated routes render the
409
+ // authenticated page instead of /login); component captures keep the
410
+ // iframe harness for its background/sizing control. The backend signals
411
+ // the choice via `config.navigation` ("topLevel"); absent (the default)
412
+ // means the iframe harness, so existing callers are unchanged.
413
+ const { frame, response } =
414
+ config.navigation === "topLevel"
415
+ ? await loadScenarioTopLevel(page, url, { preflight })
416
+ : await loadScenarioInIframe(page, url, {
417
+ background: config.iframeBackground,
418
+ preflight,
419
+ });
420
+ loaded = true;
421
+
422
+ if (response && response.status() >= 400) {
423
+ pushIssue(
424
+ issues,
425
+ createIssue("navigation", `Navigation returned HTTP ${response.status()}`, {
426
+ url: response.url(),
427
+ status: response.status(),
428
+ }),
429
+ );
430
+ }
431
+
432
+ pushRedirectMismatchIssue(issues, url, frame, response, config);
433
+
434
+ // Project loading markers come from config when a caller injects them
435
+ // (unit tests), otherwise from stack.json — so a stable-but-loading app
436
+ // screen is not mistaken for settled content and captured mid-hydration.
437
+ const loadingMarkers = Array.isArray(config.loadingMarkers)
438
+ ? config.loadingMarkers
439
+ : readStackLoadingMarkers();
440
+ await waitForStablePage(page, frame, 10000, loadingMarkers);
441
+
442
+ // DOM-stable does not mean done: a client-side data fetch can still be in
443
+ // flight (the loading skeleton cleared but its replacement content hasn't
444
+ // landed). Wait for the network to go quiet — bounded, so a streaming /
445
+ // long-poll endpoint that never idles caps out and captures anyway rather
446
+ // than hanging.
447
+ await waitForNetworkQuiet(networkTracker);
448
+
449
+ const rejectionMessages = await frame.evaluate(
450
+ () => window.__codeyamUnhandledRejections || [],
451
+ );
452
+ for (const message of rejectionMessages) {
453
+ pushIssue(
454
+ issues,
455
+ createIssue("unhandledrejection", message, {
456
+ url: page.url() || url,
457
+ }),
458
+ );
459
+ }
460
+
461
+ // Cold-start retry: a single re-collect after BLANK_RETRY_DELAY_MS covers
462
+ // the React.lazy / Suspense-fallback race where waitForStablePage settles
463
+ // on the still-empty `<div id="root">` shell before the dynamic chunk
464
+ // resolves. One retry is enough — the pause is sized to outlast the
465
+ // chunk-load window; only a genuinely blank page falls through to the
466
+ // blank issue below.
467
+ let contentState = await collectContentState(frame);
468
+ let hasContent = hasRenderableContent(contentState);
469
+ if (!hasContent) {
470
+ await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS));
471
+ contentState = await collectContentState(frame);
472
+ hasContent = hasRenderableContent(contentState);
473
+ }
474
+
475
+ if (!hasContent) {
476
+ pushIssue(
477
+ issues,
478
+ createIssue(
479
+ "blank",
480
+ `Page rendered no visible content (${describeBlankReason(contentState)})`,
481
+ { url: page.url() || url },
482
+ ),
483
+ );
484
+ }
485
+
486
+ // Check for known error states in the rendered content
487
+ const bodyText = await frame.evaluate(() => document.body?.innerText || "");
488
+ const matchedPattern = findErrorPattern(bodyText);
489
+ if (matchedPattern) {
490
+ const contextSnippet = buildErrorContextSnippet(bodyText, matchedPattern);
491
+ pushIssue(
492
+ issues,
493
+ createIssue(
494
+ "error-state",
495
+ `Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? bodyText.slice(0, 200)}`,
496
+ {
497
+ url: page.url() || url,
498
+ matchedPattern,
499
+ contextSnippet,
500
+ },
501
+ ),
502
+ );
503
+ }
504
+
505
+ // Hydration / interactivity gate: a page can render content and log no
506
+ // errors yet never have hydrated, leaving every control dead. Read-only
507
+ // (it inspects framework-attachment markers, never clicks), so it is safe
508
+ // to run before the screenshot. Stack-gated and fail-safe — see
509
+ // scenario-interactivity.js — so backend / static / unknown-framework
510
+ // captures are an automatic pass.
511
+ const hydrationIssue = await probeInteractivity(frame, {
512
+ url: page.url() || url,
513
+ });
514
+ if (hydrationIssue) {
515
+ pushIssue(issues, hydrationIssue);
516
+ }
517
+
518
+ // Drive the requested interaction (if any) against the settled frame,
519
+ // then re-settle, so `preview-interact` captures the RESULT of a click /
520
+ // fill / press (expanded accordion, open modal) without editing app
521
+ // source. A no-match target throws here and is caught below as a failed
522
+ // capture with the candidate-labels hint — never a silent blank shot.
523
+ if (config.interaction) {
524
+ await performInteraction(frame, config.interaction);
525
+ await waitForStablePage(page, frame, 5000, loadingMarkers);
526
+ }
527
+
528
+ if (outputPath && loaded) {
529
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
530
+ await page.screenshot({ path: outputPath, fullPage: false });
531
+ }
532
+
533
+ const result = buildResult({
534
+ loaded,
535
+ hasContent,
536
+ issues,
537
+ outputPath,
538
+ url: frame.url() || url,
539
+ });
540
+
541
+ // `capture-state` mode: attach the read-only page-state snapshot so the
542
+ // backend can report localStorage + rendered text. Off by default, so a
543
+ // normal error-check capture is byte-for-byte unchanged.
544
+ if (config.captureState) {
545
+ result.state = await dumpPageState(frame, config.stateSelector);
546
+ }
547
+
548
+ return result;
549
+ } catch (error) {
550
+ pushIssue(
551
+ issues,
552
+ createIssue("navigation", error.message || String(error), { url }),
553
+ );
554
+
555
+ return buildResult({
556
+ loaded,
557
+ hasContent: false,
558
+ issues,
559
+ outputPath,
560
+ url,
561
+ });
562
+ } finally {
563
+ await browser.close();
564
+ }
565
+ }
566
+
567
+ /**
568
+ * npm wrapper entry point for the scenario-check binary: parses the
569
+ * JSON config from argv, drives Playwright to capture the configured
570
+ * URL, and writes the resulting screenshot.
571
+ */
572
+ async function main() {
573
+ const config = JSON.parse(process.argv[2] || "{}");
574
+
575
+ if (!config.url) {
576
+ console.error(
577
+ "Usage: node scenario-check.js '{\"url\":\"...\",\"outputPath\":\"...\",\"width\":1440,\"height\":900}'",
578
+ );
579
+ process.exit(1);
580
+ }
581
+
582
+ const result = await runScenarioCheck(config);
583
+ console.log(JSON.stringify(result));
584
+ }
585
+
586
+ module.exports = {
587
+ runScenarioCheck,
588
+ dumpPageState,
589
+ readStackLoadingMarkers,
590
+ applyBrowserState,
591
+ isCrossOriginRequest,
592
+ stripMarkerHeaders,
593
+ main,
594
+ launchChromiumWithSelfHeal,
595
+ PLAYWRIGHT_INSTALL_COMMAND,
596
+ PLAYWRIGHT_MISSING_BROWSER_PATTERN,
597
+ };
598
+
599
+ if (require.main === module) {
600
+ main().catch((error) => {
601
+ console.error(error.message || String(error));
602
+ process.exit(1);
603
+ });
604
+ }