@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.
- package/npm/cli.js +5 -2
- package/npm/cloud-branch-switch.js +442 -0
- package/npm/cloud.js +1929 -0
- package/npm/container.js +22 -3
- package/npm/editor-dev.js +368 -37
- package/npm/editor.js +133 -52
- package/npm/empty-project-dir.js +33 -0
- package/npm/fleet-auth.js +111 -0
- package/npm/fleet-branch.js +49 -0
- package/npm/fleet-broker-death.js +44 -0
- package/npm/fleet-broker-debounce.js +63 -0
- package/npm/fleet-broker-status.js +45 -0
- package/npm/fleet-cleanup.js +297 -0
- package/npm/fleet-effect-status.js +179 -0
- package/npm/fleet-env.js +125 -0
- package/npm/fleet-grouping.js +56 -0
- package/npm/fleet-job-badge.js +39 -0
- package/npm/fleet-memory.js +157 -0
- package/npm/fleet-pool-policy.js +78 -0
- package/npm/fleet-pool.js +401 -0
- package/npm/fleet-queue.js +40 -0
- package/npm/fleet-reconcile.js +36 -0
- package/npm/fleet-reset.js +222 -0
- package/npm/fleet-roster.js +81 -0
- package/npm/launch-config.js +622 -0
- package/npm/preflight-guidance.js +77 -0
- package/npm/scenario-check.js +323 -14
- package/npm/scenario-handlers.js +16 -5
- package/npm/scenario-interactivity.js +245 -0
- package/npm/scenario-metrics.js +16 -2
- package/npm/scenario-mocks.js +62 -25
- package/npm/scenario-playwright.js +350 -8
- package/npm/utils.js +562 -22
- package/npm/vm-urls.js +117 -0
- package/package.json +8 -7
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
- package/ui/dist/assets/EditorShell-CGsBSA6u.js +186 -0
- package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-Bih6HJ_u.css +1 -0
- package/ui/dist/assets/index-KDFAgt2e.js +27 -0
- package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
- package/ui/dist/assets/react-CSS0HapR.js +28 -0
- package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
- package/ui/dist/assets/xterm--24IGk-x.js +9 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/favicon.png +0 -0
- package/ui/dist/index.html +16 -0
- 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 };
|
package/npm/scenario-check.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
119
|
-
// the React.lazy / Suspense-fallback race
|
|
120
|
-
//
|
|
121
|
-
// One retry is enough — the
|
|
122
|
-
//
|
|
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
|
|
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) {
|
package/npm/scenario-handlers.js
CHANGED
|
@@ -56,11 +56,22 @@ function handlePageError(error) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function handleRequestFailed(request) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 = {
|