@codeyam-editor/codeyam-editor 0.1.0 → 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 +28 -5
- package/npm/editor-dev.js +378 -0
- package/npm/editor.js +199 -12
- 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 +355 -290
- package/npm/scenario-handlers.js +82 -0
- package/npm/scenario-interactivity.js +199 -0
- package/npm/scenario-issues.js +35 -0
- package/npm/scenario-metrics.js +108 -0
- package/npm/scenario-mocks.js +107 -0
- package/npm/scenario-playwright.js +355 -0
- package/npm/utils.js +548 -22
- package/npm/vm-urls.js +117 -0
- package/package.json +13 -6
- 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 -95
package/npm/scenario-check.js
CHANGED
|
@@ -1,322 +1,344 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// Render environment (colorScheme, deviceScaleFactor, userAgent, locale,
|
|
4
|
+
// timezoneId, reduceMotion, forcedColors) is read from config when present
|
|
5
|
+
// and passed to browser.newContext(). This is what makes screenshots match
|
|
6
|
+
// the Live Preview iframe's host browser — see docs/rendering.md.
|
|
7
|
+
//
|
|
8
|
+
// iframeBackground is forwarded to buildIframeHarness so the capture paints
|
|
9
|
+
// the user's editor-shell background (or whatever the UI detected) behind
|
|
10
|
+
// the iframe instead of a hardcoded white.
|
|
11
|
+
|
|
3
12
|
const fs = require("fs");
|
|
4
13
|
const path = require("path");
|
|
14
|
+
const { execSync } = require("child_process");
|
|
5
15
|
const { chromium } = require("playwright");
|
|
6
16
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
}
|
|
25
57
|
}
|
|
26
58
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
59
|
+
const {
|
|
60
|
+
findErrorPattern,
|
|
61
|
+
buildErrorContextSnippet,
|
|
62
|
+
hasRenderableContent,
|
|
63
|
+
describeBlankReason,
|
|
64
|
+
} = require("./scenario-metrics");
|
|
32
65
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
66
|
+
const {
|
|
67
|
+
createIssue,
|
|
68
|
+
pushIssue,
|
|
69
|
+
buildResult,
|
|
70
|
+
} = require("./scenario-issues");
|
|
36
71
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
message,
|
|
41
|
-
url: extra.url ?? null,
|
|
42
|
-
status: extra.status ?? null,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
72
|
+
const {
|
|
73
|
+
attachHttpMocks,
|
|
74
|
+
} = require("./scenario-mocks");
|
|
45
75
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
76
|
+
const {
|
|
77
|
+
assertAppPortReachable,
|
|
78
|
+
loadScenarioInIframe,
|
|
79
|
+
loadScenarioTopLevel,
|
|
80
|
+
waitForStablePage,
|
|
81
|
+
collectContentState,
|
|
82
|
+
} = require("./scenario-playwright");
|
|
83
|
+
|
|
84
|
+
const {
|
|
85
|
+
getInitScript,
|
|
86
|
+
handleConsoleMessage,
|
|
87
|
+
handlePageError,
|
|
88
|
+
handleRequestFailed,
|
|
89
|
+
} = require("./scenario-handlers");
|
|
90
|
+
|
|
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 [];
|
|
50
113
|
}
|
|
51
114
|
}
|
|
52
115
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
width: 100%;
|
|
69
|
-
height: 100%;
|
|
70
|
-
overflow: hidden;
|
|
71
|
-
background: #fff;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
iframe {
|
|
75
|
-
display: block;
|
|
76
|
-
width: 100%;
|
|
77
|
-
height: 100%;
|
|
78
|
-
border: 0;
|
|
79
|
-
background: #fff;
|
|
80
|
-
}
|
|
81
|
-
</style>
|
|
82
|
-
</head>
|
|
83
|
-
<body>
|
|
84
|
-
<iframe id="scenario-frame" title="Scenario Preview" src="${escapedUrl}"></iframe>
|
|
85
|
-
</body>
|
|
86
|
-
</html>`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function buildResult({ loaded, hasContent, issues, outputPath, url }) {
|
|
90
|
-
return {
|
|
91
|
-
ok: loaded && hasContent && issues.length === 0,
|
|
92
|
-
loaded,
|
|
93
|
-
hasContent,
|
|
94
|
-
url,
|
|
95
|
-
outputPath: outputPath ?? null,
|
|
96
|
-
issues,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function normalizeMockCandidates(url) {
|
|
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;
|
|
123
|
+
|
|
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) {
|
|
101
131
|
try {
|
|
102
|
-
const
|
|
103
|
-
return
|
|
104
|
-
} catch {
|
|
105
|
-
return
|
|
132
|
+
const href = typeof url === "string" ? url : url.href;
|
|
133
|
+
return new URL(href).origin !== appOrigin;
|
|
134
|
+
} catch (_) {
|
|
135
|
+
return false;
|
|
106
136
|
}
|
|
107
137
|
}
|
|
108
138
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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()];
|
|
117
147
|
}
|
|
118
|
-
return
|
|
148
|
+
return out;
|
|
119
149
|
}
|
|
120
150
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 */
|
|
142
176
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
await page.route("**/active-mocks.json", async (route) => {
|
|
157
|
-
await route.fulfill({
|
|
158
|
-
status: 200,
|
|
159
|
-
headers: { "content-type": "application/json" },
|
|
160
|
-
body: "[]",
|
|
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
|
+
};
|
|
161
190
|
});
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
let lastHtml = "";
|
|
179
|
-
let stableCount = 0;
|
|
180
|
-
|
|
181
|
-
while (Date.now() - started < timeoutMs) {
|
|
182
|
-
await page.waitForTimeout(500);
|
|
183
|
-
|
|
184
|
-
const pageState = await target.evaluate(() => ({
|
|
185
|
-
bodyText: document.body.innerText,
|
|
186
|
-
html: document.body.innerHTML,
|
|
187
|
-
}));
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
!hasLoadingMarkers(pageState.bodyText) &&
|
|
191
|
-
pageState.html === lastHtml
|
|
192
|
-
) {
|
|
193
|
-
stableCount += 1;
|
|
194
|
-
if (stableCount >= 2) return;
|
|
195
|
-
} else {
|
|
196
|
-
stableCount = 0;
|
|
197
|
-
}
|
|
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
|
+
}
|
|
198
207
|
|
|
199
|
-
|
|
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
|
+
);
|
|
200
234
|
}
|
|
201
235
|
}
|
|
202
236
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
await page.setContent(buildIframeHarness(url), { waitUntil: "domcontentloaded" });
|
|
214
|
-
|
|
215
|
-
const frameHandle = await page.waitForSelector("#scenario-frame", {
|
|
216
|
-
state: "attached",
|
|
217
|
-
timeout: 30000,
|
|
218
|
-
});
|
|
219
|
-
const frame = await frameHandle.contentFrame();
|
|
220
|
-
if (!frame) {
|
|
221
|
-
throw new Error("Scenario iframe did not attach");
|
|
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;
|
|
222
247
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
);
|
|
227
273
|
}
|
|
228
274
|
|
|
229
|
-
|
|
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 } = {}) {
|
|
230
278
|
const { url, outputPath, width, height, httpMocks = {} } = config;
|
|
231
279
|
const issues = [];
|
|
232
|
-
const browser = await
|
|
233
|
-
const
|
|
280
|
+
const browser = await launchChromiumWithSelfHeal();
|
|
281
|
+
const contextOptions = {
|
|
234
282
|
viewport: { width: width || 1440, height: height || 900 },
|
|
235
|
-
}
|
|
283
|
+
};
|
|
284
|
+
if (config.colorScheme) contextOptions.colorScheme = config.colorScheme;
|
|
285
|
+
if (config.deviceScaleFactor)
|
|
286
|
+
contextOptions.deviceScaleFactor = config.deviceScaleFactor;
|
|
287
|
+
if (config.userAgent) contextOptions.userAgent = config.userAgent;
|
|
288
|
+
if (config.locale) contextOptions.locale = config.locale;
|
|
289
|
+
if (config.timezoneId) contextOptions.timezoneId = config.timezoneId;
|
|
290
|
+
if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion;
|
|
291
|
+
if (config.forcedColors) contextOptions.forcedColors = config.forcedColors;
|
|
292
|
+
const context = await browser.newContext(contextOptions);
|
|
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);
|
|
236
301
|
|
|
237
302
|
// Context-level init script runs in ALL frames (including cross-origin iframes)
|
|
238
|
-
await context.addInitScript(()
|
|
239
|
-
window.__codeyamUnhandledRejections = [];
|
|
240
|
-
window.addEventListener("unhandledrejection", (event) => {
|
|
241
|
-
const reason = event.reason;
|
|
242
|
-
const message =
|
|
243
|
-
reason instanceof Error ? reason.message : String(reason);
|
|
244
|
-
window.__codeyamUnhandledRejections.push(message);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
// Stub WebSocket during capture to prevent terminal reconnection spam.
|
|
248
|
-
// Returns an object that behaves like a closed WebSocket — the terminal
|
|
249
|
-
// will attempt to reconnect a few times then give up silently.
|
|
250
|
-
window.WebSocket = class StubWebSocket {
|
|
251
|
-
static CONNECTING = 0;
|
|
252
|
-
static OPEN = 1;
|
|
253
|
-
static CLOSING = 2;
|
|
254
|
-
static CLOSED = 3;
|
|
255
|
-
readyState = 3;
|
|
256
|
-
onopen = null;
|
|
257
|
-
onclose = null;
|
|
258
|
-
onerror = null;
|
|
259
|
-
onmessage = null;
|
|
260
|
-
send() {}
|
|
261
|
-
close() {}
|
|
262
|
-
addEventListener() {}
|
|
263
|
-
removeEventListener() {}
|
|
264
|
-
dispatchEvent() { return false; }
|
|
265
|
-
constructor() {
|
|
266
|
-
// Fire onerror then onclose on next tick so the terminal sees a failed connection
|
|
267
|
-
setTimeout(() => {
|
|
268
|
-
if (this.onerror) this.onerror(new Event("error"));
|
|
269
|
-
if (this.onclose) this.onclose(new CloseEvent("close"));
|
|
270
|
-
}, 0);
|
|
271
|
-
}
|
|
272
|
-
};
|
|
273
|
-
});
|
|
303
|
+
await context.addInitScript(getInitScript());
|
|
274
304
|
|
|
275
305
|
const page = await context.newPage();
|
|
276
306
|
await attachHttpMocks(page, httpMocks);
|
|
277
307
|
|
|
278
308
|
page.on("pageerror", (error) => {
|
|
279
|
-
pushIssue(
|
|
280
|
-
issues,
|
|
281
|
-
createIssue("pageerror", error.message || String(error), {
|
|
282
|
-
url: page.url() || url,
|
|
283
|
-
}),
|
|
284
|
-
);
|
|
309
|
+
pushIssue(issues, handlePageError(error));
|
|
285
310
|
});
|
|
286
311
|
|
|
287
312
|
page.on("console", (message) => {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
text.includes("WebSocket connection to") ||
|
|
293
|
-
text.includes("Unsupported Media Type")
|
|
294
|
-
) {
|
|
295
|
-
return;
|
|
313
|
+
const issue = handleConsoleMessage(message);
|
|
314
|
+
if (issue) {
|
|
315
|
+
pushIssue(issues, issue);
|
|
296
316
|
}
|
|
297
|
-
pushIssue(
|
|
298
|
-
issues,
|
|
299
|
-
createIssue("console", text, {
|
|
300
|
-
url: page.url() || url,
|
|
301
|
-
}),
|
|
302
|
-
);
|
|
303
317
|
});
|
|
304
318
|
|
|
305
319
|
page.on("requestfailed", (request) => {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
request.failure()?.errorText || "Request failed",
|
|
311
|
-
{ url: request.url() },
|
|
312
|
-
),
|
|
313
|
-
);
|
|
320
|
+
const issue = handleRequestFailed(request);
|
|
321
|
+
if (issue) {
|
|
322
|
+
pushIssue(issues, issue);
|
|
323
|
+
}
|
|
314
324
|
});
|
|
315
325
|
|
|
316
326
|
let loaded = false;
|
|
317
327
|
|
|
318
328
|
try {
|
|
319
|
-
|
|
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
|
+
});
|
|
320
342
|
loaded = true;
|
|
321
343
|
|
|
322
344
|
if (response && response.status() >= 400) {
|
|
@@ -329,7 +351,15 @@ async function runScenarioCheck(config) {
|
|
|
329
351
|
);
|
|
330
352
|
}
|
|
331
353
|
|
|
332
|
-
|
|
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);
|
|
333
363
|
|
|
334
364
|
const rejectionMessages = await frame.evaluate(
|
|
335
365
|
() => window.__codeyamUnhandledRejections || [],
|
|
@@ -343,29 +373,63 @@ async function runScenarioCheck(config) {
|
|
|
343
373
|
);
|
|
344
374
|
}
|
|
345
375
|
|
|
346
|
-
|
|
347
|
-
|
|
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.
|
|
382
|
+
let contentState = await collectContentState(frame);
|
|
383
|
+
let hasContent = hasRenderableContent(contentState);
|
|
384
|
+
if (!hasContent) {
|
|
385
|
+
await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS));
|
|
386
|
+
contentState = await collectContentState(frame);
|
|
387
|
+
hasContent = hasRenderableContent(contentState);
|
|
388
|
+
}
|
|
348
389
|
|
|
349
390
|
if (!hasContent) {
|
|
350
391
|
pushIssue(
|
|
351
392
|
issues,
|
|
352
|
-
createIssue(
|
|
353
|
-
|
|
354
|
-
|
|
393
|
+
createIssue(
|
|
394
|
+
"blank",
|
|
395
|
+
`Page rendered no visible content (${describeBlankReason(contentState)})`,
|
|
396
|
+
{ url: page.url() || url },
|
|
397
|
+
),
|
|
355
398
|
);
|
|
356
399
|
}
|
|
357
400
|
|
|
358
401
|
// Check for known error states in the rendered content
|
|
359
|
-
const bodyText = await frame.evaluate(() => document.body
|
|
360
|
-
|
|
402
|
+
const bodyText = await frame.evaluate(() => document.body?.innerText || "");
|
|
403
|
+
const matchedPattern = findErrorPattern(bodyText);
|
|
404
|
+
if (matchedPattern) {
|
|
405
|
+
const contextSnippet = buildErrorContextSnippet(bodyText, matchedPattern);
|
|
361
406
|
pushIssue(
|
|
362
407
|
issues,
|
|
363
|
-
createIssue(
|
|
364
|
-
|
|
365
|
-
|
|
408
|
+
createIssue(
|
|
409
|
+
"error-state",
|
|
410
|
+
`Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? bodyText.slice(0, 200)}`,
|
|
411
|
+
{
|
|
412
|
+
url: page.url() || url,
|
|
413
|
+
matchedPattern,
|
|
414
|
+
contextSnippet,
|
|
415
|
+
},
|
|
416
|
+
),
|
|
366
417
|
);
|
|
367
418
|
}
|
|
368
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
|
+
|
|
369
433
|
if (outputPath && loaded) {
|
|
370
434
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
371
435
|
await page.screenshot({ path: outputPath, fullPage: false });
|
|
@@ -396,6 +460,11 @@ async function runScenarioCheck(config) {
|
|
|
396
460
|
}
|
|
397
461
|
}
|
|
398
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
|
+
*/
|
|
399
468
|
async function main() {
|
|
400
469
|
const config = JSON.parse(process.argv[2] || "{}");
|
|
401
470
|
|
|
@@ -411,19 +480,15 @@ async function main() {
|
|
|
411
480
|
}
|
|
412
481
|
|
|
413
482
|
module.exports = {
|
|
414
|
-
attachHttpMocks,
|
|
415
|
-
buildResult,
|
|
416
|
-
buildIframeHarness,
|
|
417
|
-
createIssue,
|
|
418
|
-
findHttpMock,
|
|
419
|
-
hasErrorPatterns,
|
|
420
|
-
hasLoadingMarkers,
|
|
421
|
-
hasRenderableContent,
|
|
422
|
-
loadScenarioInIframe,
|
|
423
|
-
normalizeMockCandidates,
|
|
424
|
-
pushIssue,
|
|
425
483
|
runScenarioCheck,
|
|
426
|
-
|
|
484
|
+
readStackLoadingMarkers,
|
|
485
|
+
applyBrowserState,
|
|
486
|
+
isCrossOriginRequest,
|
|
487
|
+
stripMarkerHeaders,
|
|
488
|
+
main,
|
|
489
|
+
launchChromiumWithSelfHeal,
|
|
490
|
+
PLAYWRIGHT_INSTALL_COMMAND,
|
|
491
|
+
PLAYWRIGHT_MISSING_BROWSER_PATTERN,
|
|
427
492
|
};
|
|
428
493
|
|
|
429
494
|
if (require.main === module) {
|