@codeyam-editor/codeyam-editor 0.1.1 → 0.1.3

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 (53) hide show
  1. package/npm/cli.js +5 -2
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +1929 -0
  4. package/npm/container.js +22 -3
  5. package/npm/editor-dev.js +368 -37
  6. package/npm/editor.js +133 -52
  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-cleanup.js +297 -0
  14. package/npm/fleet-effect-status.js +179 -0
  15. package/npm/fleet-env.js +125 -0
  16. package/npm/fleet-grouping.js +56 -0
  17. package/npm/fleet-job-badge.js +39 -0
  18. package/npm/fleet-memory.js +157 -0
  19. package/npm/fleet-pool-policy.js +78 -0
  20. package/npm/fleet-pool.js +401 -0
  21. package/npm/fleet-queue.js +40 -0
  22. package/npm/fleet-reconcile.js +36 -0
  23. package/npm/fleet-reset.js +222 -0
  24. package/npm/fleet-roster.js +81 -0
  25. package/npm/launch-config.js +622 -0
  26. package/npm/preflight-guidance.js +77 -0
  27. package/npm/scenario-check.js +323 -14
  28. package/npm/scenario-handlers.js +16 -5
  29. package/npm/scenario-interactivity.js +245 -0
  30. package/npm/scenario-metrics.js +16 -2
  31. package/npm/scenario-mocks.js +62 -25
  32. package/npm/scenario-playwright.js +350 -8
  33. package/npm/utils.js +562 -22
  34. package/npm/vm-urls.js +117 -0
  35. package/package.json +8 -7
  36. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  37. package/ui/dist/assets/EditorShell-CGsBSA6u.js +186 -0
  38. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  39. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  40. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +5 -0
  41. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +1 -0
  42. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +1 -0
  43. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  44. package/ui/dist/assets/index-Bih6HJ_u.css +1 -0
  45. package/ui/dist/assets/index-KDFAgt2e.js +27 -0
  46. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  47. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  48. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  49. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  50. package/ui/dist/favicon.ico +0 -0
  51. package/ui/dist/favicon.png +0 -0
  52. package/ui/dist/index.html +16 -0
  53. package/npm/postinstall.js +0 -132
@@ -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 };
@@ -11,8 +11,51 @@
11
11
 
12
12
  const fs = require("fs");
13
13
  const path = require("path");
14
+ const { execSync } = require("child_process");
14
15
  const { chromium } = require("playwright");
15
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
+
16
59
  const {
17
60
  findErrorPattern,
18
61
  buildErrorContextSnippet,
@@ -31,9 +74,14 @@ const {
31
74
  } = require("./scenario-mocks");
32
75
 
33
76
  const {
77
+ assertAppPortReachable,
34
78
  loadScenarioInIframe,
79
+ loadScenarioTopLevel,
35
80
  waitForStablePage,
81
+ createNetworkTracker,
82
+ waitForNetworkQuiet,
36
83
  collectContentState,
84
+ performInteraction,
37
85
  } = require("./scenario-playwright");
38
86
 
39
87
  const {
@@ -43,12 +91,196 @@ const {
43
91
  handleRequestFailed,
44
92
  } = require("./scenario-handlers");
45
93
 
46
- const BLANK_RETRY_DELAY_MS = 500;
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
+ }
47
239
 
48
- async function runScenarioCheck(config) {
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
+ // `preflight` is injectable (defaulting to the real app-port reachability
279
+ // check) so unit tests that mock the browser can stay network-free.
280
+ async function runScenarioCheck(config, { preflight = assertAppPortReachable } = {}) {
49
281
  const { url, outputPath, width, height, httpMocks = {} } = config;
50
282
  const issues = [];
51
- const browser = await chromium.launch();
283
+ const browser = await launchChromiumWithSelfHeal();
52
284
  const contextOptions = {
53
285
  viewport: { width: width || 1440, height: height || 900 },
54
286
  };
@@ -62,10 +294,23 @@ async function runScenarioCheck(config) {
62
294
  if (config.forcedColors) contextOptions.forcedColors = config.forcedColors;
63
295
  const context = await browser.newContext(contextOptions);
64
296
 
297
+ // Apply the scenario's merged `browserState` (cookies + request
298
+ // headers) to the capture context BEFORE the first navigation.
299
+ // Belt-and-suspenders with the proxy's `Set-Cookie` injection: the
300
+ // proxy handles upstream-bound forwards, this branch handles the
301
+ // capture context's own request headers so an auth-gated route does
302
+ // not redirect to `/login` when the proxy is bypassed.
303
+ await applyBrowserState(context, config);
304
+
65
305
  // Context-level init script runs in ALL frames (including cross-origin iframes)
66
306
  await context.addInitScript(getInitScript());
67
307
 
68
308
  const page = await context.newPage();
309
+ // Attach the network tracker BEFORE navigation so every request (the document
310
+ // and every client-side data fetch) is counted. Used after DOM stability to
311
+ // wait out an in-flight fetch that would otherwise be screenshotted as a
312
+ // loading skeleton.
313
+ const networkTracker = createNetworkTracker(page);
69
314
  await attachHttpMocks(page, httpMocks);
70
315
 
71
316
  page.on("pageerror", (error) => {
@@ -80,15 +325,28 @@ async function runScenarioCheck(config) {
80
325
  });
81
326
 
82
327
  page.on("requestfailed", (request) => {
83
- pushIssue(issues, handleRequestFailed(request));
328
+ const issue = handleRequestFailed(request);
329
+ if (issue) {
330
+ pushIssue(issues, issue);
331
+ }
84
332
  });
85
333
 
86
334
  let loaded = false;
87
335
 
88
336
  try {
89
- const { frame, response } = await loadScenarioInIframe(page, url, {
90
- background: config.iframeBackground,
91
- });
337
+ // Application/route captures navigate at the top level so the
338
+ // first-party session cookie is sent (auth-gated routes render the
339
+ // authenticated page instead of /login); component captures keep the
340
+ // iframe harness for its background/sizing control. The backend signals
341
+ // the choice via `config.navigation` ("topLevel"); absent (the default)
342
+ // means the iframe harness, so existing callers are unchanged.
343
+ const { frame, response } =
344
+ config.navigation === "topLevel"
345
+ ? await loadScenarioTopLevel(page, url, { preflight })
346
+ : await loadScenarioInIframe(page, url, {
347
+ background: config.iframeBackground,
348
+ preflight,
349
+ });
92
350
  loaded = true;
93
351
 
94
352
  if (response && response.status() >= 400) {
@@ -101,7 +359,22 @@ async function runScenarioCheck(config) {
101
359
  );
102
360
  }
103
361
 
104
- await waitForStablePage(page, frame);
362
+ pushRedirectMismatchIssue(issues, url, frame, response, config);
363
+
364
+ // Project loading markers come from config when a caller injects them
365
+ // (unit tests), otherwise from stack.json — so a stable-but-loading app
366
+ // screen is not mistaken for settled content and captured mid-hydration.
367
+ const loadingMarkers = Array.isArray(config.loadingMarkers)
368
+ ? config.loadingMarkers
369
+ : readStackLoadingMarkers();
370
+ await waitForStablePage(page, frame, 10000, loadingMarkers);
371
+
372
+ // DOM-stable does not mean done: a client-side data fetch can still be in
373
+ // flight (the loading skeleton cleared but its replacement content hasn't
374
+ // landed). Wait for the network to go quiet — bounded, so a streaming /
375
+ // long-poll endpoint that never idles caps out and captures anyway rather
376
+ // than hanging.
377
+ await waitForNetworkQuiet(networkTracker);
105
378
 
106
379
  const rejectionMessages = await frame.evaluate(
107
380
  () => window.__codeyamUnhandledRejections || [],
@@ -115,11 +388,12 @@ async function runScenarioCheck(config) {
115
388
  );
116
389
  }
117
390
 
118
- // Cold-start retry: a single re-collect after a short pause covers
119
- // the React.lazy / Suspense-fallback race that flagged 4-of-59
120
- // scenarios as blank against a cold Vite dev server on 2026-04-30.
121
- // One retry is enough — the Suspense window is sub-second in
122
- // practice, longer waits just slow down genuine blank-render bugs.
391
+ // Cold-start retry: a single re-collect after BLANK_RETRY_DELAY_MS covers
392
+ // the React.lazy / Suspense-fallback race where waitForStablePage settles
393
+ // on the still-empty `<div id="root">` shell before the dynamic chunk
394
+ // resolves. One retry is enough — the pause is sized to outlast the
395
+ // chunk-load window; only a genuinely blank page falls through to the
396
+ // blank issue below.
123
397
  let contentState = await collectContentState(frame);
124
398
  let hasContent = hasRenderableContent(contentState);
125
399
  if (!hasContent) {
@@ -140,7 +414,7 @@ async function runScenarioCheck(config) {
140
414
  }
141
415
 
142
416
  // Check for known error states in the rendered content
143
- const bodyText = await frame.evaluate(() => document.body.innerText || "");
417
+ const bodyText = await frame.evaluate(() => document.body?.innerText || "");
144
418
  const matchedPattern = findErrorPattern(bodyText);
145
419
  if (matchedPattern) {
146
420
  const contextSnippet = buildErrorContextSnippet(bodyText, matchedPattern);
@@ -158,6 +432,29 @@ async function runScenarioCheck(config) {
158
432
  );
159
433
  }
160
434
 
435
+ // Hydration / interactivity gate: a page can render content and log no
436
+ // errors yet never have hydrated, leaving every control dead. Read-only
437
+ // (it inspects framework-attachment markers, never clicks), so it is safe
438
+ // to run before the screenshot. Stack-gated and fail-safe — see
439
+ // scenario-interactivity.js — so backend / static / unknown-framework
440
+ // captures are an automatic pass.
441
+ const hydrationIssue = await probeInteractivity(frame, {
442
+ url: page.url() || url,
443
+ });
444
+ if (hydrationIssue) {
445
+ pushIssue(issues, hydrationIssue);
446
+ }
447
+
448
+ // Drive the requested interaction (if any) against the settled frame,
449
+ // then re-settle, so `preview-interact` captures the RESULT of a click /
450
+ // fill / press (expanded accordion, open modal) without editing app
451
+ // source. A no-match target throws here and is caught below as a failed
452
+ // capture with the candidate-labels hint — never a silent blank shot.
453
+ if (config.interaction) {
454
+ await performInteraction(frame, config.interaction);
455
+ await waitForStablePage(page, frame, 5000, loadingMarkers);
456
+ }
457
+
161
458
  if (outputPath && loaded) {
162
459
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
163
460
  await page.screenshot({ path: outputPath, fullPage: false });
@@ -188,6 +485,11 @@ async function runScenarioCheck(config) {
188
485
  }
189
486
  }
190
487
 
488
+ /**
489
+ * npm wrapper entry point for the scenario-check binary: parses the
490
+ * JSON config from argv, drives Playwright to capture the configured
491
+ * URL, and writes the resulting screenshot.
492
+ */
191
493
  async function main() {
192
494
  const config = JSON.parse(process.argv[2] || "{}");
193
495
 
@@ -204,7 +506,14 @@ async function main() {
204
506
 
205
507
  module.exports = {
206
508
  runScenarioCheck,
509
+ readStackLoadingMarkers,
510
+ applyBrowserState,
511
+ isCrossOriginRequest,
512
+ stripMarkerHeaders,
207
513
  main,
514
+ launchChromiumWithSelfHeal,
515
+ PLAYWRIGHT_INSTALL_COMMAND,
516
+ PLAYWRIGHT_MISSING_BROWSER_PATTERN,
208
517
  };
209
518
 
210
519
  if (require.main === module) {
@@ -56,11 +56,22 @@ function handlePageError(error) {
56
56
  }
57
57
 
58
58
  function handleRequestFailed(request) {
59
- return createIssue(
60
- "requestfailed",
61
- request.failure()?.errorText || "Request failed",
62
- { url: request.url() }
63
- );
59
+ const errorText = request.failure()?.errorText || "Request failed";
60
+
61
+ // Filter benign request cancellations. `net::ERR_ABORTED` is what Playwright
62
+ // emits when a request is in-flight at the moment the page is closed (or the
63
+ // iframe is destroyed). For scenarios whose pages fetch large payloads (the
64
+ // editor's own EditorShell mounts a 2.2MB `/api/tests` fetch), the
65
+ // browser.close() at the end of capture races the fetch and produces this
66
+ // event AFTER the screenshot has already been taken — there is no real
67
+ // failure to surface. Genuine network failures arrive under different
68
+ // codes (net::ERR_CONNECTION_REFUSED, net::ERR_NAME_NOT_RESOLVED, etc.)
69
+ // and continue to be reported.
70
+ if (errorText.includes("net::ERR_ABORTED")) {
71
+ return null;
72
+ }
73
+
74
+ return createIssue("requestfailed", errorText, { url: request.url() });
64
75
  }
65
76
 
66
77
  module.exports = {