@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,68 @@
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
+
15
+ const fs = require("node:fs");
16
+ const path = require("node:path");
17
+
18
+ function greenfieldGuidance(projectDir) {
19
+ return (
20
+ `No codeyam-editor project detected in ${projectDir}.\n\n` +
21
+ "Run `codeyam-editor init` to install the agent bundle, then\n" +
22
+ "open Claude Code and invoke `/codeyam-onboard` to set up this project."
23
+ );
24
+ }
25
+
26
+ function legacyGuidance(projectDir) {
27
+ return (
28
+ `Detected legacy codeyam project in ${projectDir}.\n\n` +
29
+ "Run `codeyam-editor init` to install the agent bundle (it will skip\n" +
30
+ "editor.json creation so the legacy classification is preserved), then\n" +
31
+ "open Claude Code and invoke `/codeyam-onboard` to migrate."
32
+ );
33
+ }
34
+
35
+ function isLegacyConfig(configPath) {
36
+ let raw;
37
+ try {
38
+ raw = fs.readFileSync(configPath, "utf8");
39
+ } catch {
40
+ return false;
41
+ }
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(raw);
45
+ } catch {
46
+ return false;
47
+ }
48
+ return parsed && typeof parsed === "object" && "webapps" in parsed;
49
+ }
50
+
51
+ /**
52
+ * Classify a project directory as `modern`, `legacy`, or `none`, mirroring
53
+ * the Rust `classify` / `start_preflight` pair. `guidance` is the exact
54
+ * stderr text to print before exiting with code 2.
55
+ */
56
+ function classifyProject(projectDir) {
57
+ const editorJson = path.join(projectDir, ".codeyam", "editor.json");
58
+ if (fs.existsSync(editorJson)) {
59
+ return { kind: "modern" };
60
+ }
61
+ const legacyConfig = path.join(projectDir, ".codeyam", "config.json");
62
+ if (isLegacyConfig(legacyConfig)) {
63
+ return { kind: "legacy", guidance: legacyGuidance(projectDir) };
64
+ }
65
+ return { kind: "none", guidance: greenfieldGuidance(projectDir) };
66
+ }
67
+
68
+ 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,7 +74,9 @@ const {
31
74
  } = require("./scenario-mocks");
32
75
 
33
76
  const {
77
+ assertAppPortReachable,
34
78
  loadScenarioInIframe,
79
+ loadScenarioTopLevel,
35
80
  waitForStablePage,
36
81
  collectContentState,
37
82
  } = require("./scenario-playwright");
@@ -43,12 +88,196 @@ const {
43
88
  handleRequestFailed,
44
89
  } = require("./scenario-handlers");
45
90
 
46
- const BLANK_RETRY_DELAY_MS = 500;
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 [];
113
+ }
114
+ }
115
+
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;
47
123
 
48
- async function runScenarioCheck(config) {
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) {
131
+ try {
132
+ const href = typeof url === "string" ? url : url.href;
133
+ return new URL(href).origin !== appOrigin;
134
+ } catch (_) {
135
+ return false;
136
+ }
137
+ }
138
+
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()];
147
+ }
148
+ return out;
149
+ }
150
+
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 */
176
+ }
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
+ };
190
+ });
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
+ }
207
+
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
+ );
234
+ }
235
+ }
236
+
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;
247
+ }
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
+ );
273
+ }
274
+
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 } = {}) {
49
278
  const { url, outputPath, width, height, httpMocks = {} } = config;
50
279
  const issues = [];
51
- const browser = await chromium.launch();
280
+ const browser = await launchChromiumWithSelfHeal();
52
281
  const contextOptions = {
53
282
  viewport: { width: width || 1440, height: height || 900 },
54
283
  };
@@ -62,6 +291,14 @@ async function runScenarioCheck(config) {
62
291
  if (config.forcedColors) contextOptions.forcedColors = config.forcedColors;
63
292
  const context = await browser.newContext(contextOptions);
64
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);
301
+
65
302
  // Context-level init script runs in ALL frames (including cross-origin iframes)
66
303
  await context.addInitScript(getInitScript());
67
304
 
@@ -80,15 +317,28 @@ async function runScenarioCheck(config) {
80
317
  });
81
318
 
82
319
  page.on("requestfailed", (request) => {
83
- pushIssue(issues, handleRequestFailed(request));
320
+ const issue = handleRequestFailed(request);
321
+ if (issue) {
322
+ pushIssue(issues, issue);
323
+ }
84
324
  });
85
325
 
86
326
  let loaded = false;
87
327
 
88
328
  try {
89
- const { frame, response } = await loadScenarioInIframe(page, url, {
90
- background: config.iframeBackground,
91
- });
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
+ });
92
342
  loaded = true;
93
343
 
94
344
  if (response && response.status() >= 400) {
@@ -101,7 +351,15 @@ async function runScenarioCheck(config) {
101
351
  );
102
352
  }
103
353
 
104
- 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);
105
363
 
106
364
  const rejectionMessages = await frame.evaluate(
107
365
  () => window.__codeyamUnhandledRejections || [],
@@ -115,11 +373,12 @@ async function runScenarioCheck(config) {
115
373
  );
116
374
  }
117
375
 
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.
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.
123
382
  let contentState = await collectContentState(frame);
124
383
  let hasContent = hasRenderableContent(contentState);
125
384
  if (!hasContent) {
@@ -140,7 +399,7 @@ async function runScenarioCheck(config) {
140
399
  }
141
400
 
142
401
  // Check for known error states in the rendered content
143
- const bodyText = await frame.evaluate(() => document.body.innerText || "");
402
+ const bodyText = await frame.evaluate(() => document.body?.innerText || "");
144
403
  const matchedPattern = findErrorPattern(bodyText);
145
404
  if (matchedPattern) {
146
405
  const contextSnippet = buildErrorContextSnippet(bodyText, matchedPattern);
@@ -158,6 +417,19 @@ async function runScenarioCheck(config) {
158
417
  );
159
418
  }
160
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
+
161
433
  if (outputPath && loaded) {
162
434
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
163
435
  await page.screenshot({ path: outputPath, fullPage: false });
@@ -188,6 +460,11 @@ async function runScenarioCheck(config) {
188
460
  }
189
461
  }
190
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
+ */
191
468
  async function main() {
192
469
  const config = JSON.parse(process.argv[2] || "{}");
193
470
 
@@ -204,7 +481,14 @@ async function main() {
204
481
 
205
482
  module.exports = {
206
483
  runScenarioCheck,
484
+ readStackLoadingMarkers,
485
+ applyBrowserState,
486
+ isCrossOriginRequest,
487
+ stripMarkerHeaders,
207
488
  main,
489
+ launchChromiumWithSelfHeal,
490
+ PLAYWRIGHT_INSTALL_COMMAND,
491
+ PLAYWRIGHT_MISSING_BROWSER_PATTERN,
208
492
  };
209
493
 
210
494
  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 = {