@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.
- package/npm/cli.js +5 -2
- package/npm/cloud-branch-switch.js +442 -0
- package/npm/cloud.js +1521 -0
- package/npm/container.js +22 -3
- package/npm/editor-dev.js +286 -36
- package/npm/editor.js +98 -35
- package/npm/empty-project-dir.js +33 -0
- package/npm/fleet-auth.js +111 -0
- package/npm/fleet-cleanup.js +124 -0
- package/npm/fleet-env.js +125 -0
- package/npm/fleet-memory.js +127 -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 +144 -0
- package/npm/fleet-roster.js +81 -0
- package/npm/launch-config.js +544 -0
- package/npm/preflight-guidance.js +68 -0
- package/npm/scenario-check.js +298 -14
- package/npm/scenario-handlers.js +16 -5
- package/npm/scenario-interactivity.js +199 -0
- package/npm/scenario-metrics.js +16 -2
- package/npm/scenario-mocks.js +62 -25
- package/npm/scenario-playwright.js +169 -6
- package/npm/utils.js +507 -21
- 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-DWaeBxgj.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-zJSXtXL4.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-C0YHQB-T.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-BUYNFqKK.js +27 -0
- package/ui/dist/assets/index-DVr5rY2d.css +1 -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,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 };
|
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,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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
119
|
-
// the React.lazy / Suspense-fallback race
|
|
120
|
-
//
|
|
121
|
-
// One retry is enough — the
|
|
122
|
-
//
|
|
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
|
|
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) {
|
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 = {
|