@codeyam-editor/codeyam-editor 0.1.0-staging.01efa1a
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 +19 -0
- package/npm/cloud-branch-switch.js +442 -0
- package/npm/cloud.js +2184 -0
- package/npm/container.js +54 -0
- package/npm/editor-dev.js +459 -0
- package/npm/editor.js +272 -0
- 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-build-data.js +81 -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 +83 -0
- package/npm/fleet-memory.js +157 -0
- package/npm/fleet-plans.js +96 -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 +93 -0
- package/npm/fleet-reset.js +222 -0
- package/npm/fleet-roster.js +81 -0
- package/npm/launch-config.js +717 -0
- package/npm/open.js +15 -0
- package/npm/preflight-guidance.js +77 -0
- package/npm/scenario-check.js +604 -0
- package/npm/scenario-handlers.js +82 -0
- package/npm/scenario-interactivity.js +245 -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 +534 -0
- package/npm/utils.js +823 -0
- package/npm/vm-urls.js +117 -0
- package/package.json +32 -0
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
- package/ui/dist/assets/EditorShell-By-2rl7D.js +187 -0
- package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-Cg8zOFNZ.js +5 -0
- package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-WOUYbba7.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CBTlIVPs.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-DzoF9RKw.js +27 -0
- package/ui/dist/assets/index-ckz94rKI.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/open.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const { spawn } = require("child_process");
|
|
2
|
+
|
|
3
|
+
/** Open a URL in the default browser. */
|
|
4
|
+
function open(url) {
|
|
5
|
+
const cmd =
|
|
6
|
+
process.platform === "darwin"
|
|
7
|
+
? "open"
|
|
8
|
+
: process.platform === "win32"
|
|
9
|
+
? "start"
|
|
10
|
+
: "xdg-open";
|
|
11
|
+
|
|
12
|
+
spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { open };
|
|
@@ -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 };
|
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const { execSync } = require("child_process");
|
|
15
|
+
const { chromium } = require("playwright");
|
|
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
|
+
|
|
59
|
+
const {
|
|
60
|
+
findErrorPattern,
|
|
61
|
+
buildErrorContextSnippet,
|
|
62
|
+
hasRenderableContent,
|
|
63
|
+
describeBlankReason,
|
|
64
|
+
} = require("./scenario-metrics");
|
|
65
|
+
|
|
66
|
+
const {
|
|
67
|
+
createIssue,
|
|
68
|
+
pushIssue,
|
|
69
|
+
buildResult,
|
|
70
|
+
} = require("./scenario-issues");
|
|
71
|
+
|
|
72
|
+
const {
|
|
73
|
+
attachHttpMocks,
|
|
74
|
+
} = require("./scenario-mocks");
|
|
75
|
+
|
|
76
|
+
const {
|
|
77
|
+
assertAppPortReachable,
|
|
78
|
+
loadScenarioInIframe,
|
|
79
|
+
loadScenarioTopLevel,
|
|
80
|
+
waitForStablePage,
|
|
81
|
+
createNetworkTracker,
|
|
82
|
+
waitForNetworkQuiet,
|
|
83
|
+
collectContentState,
|
|
84
|
+
performInteraction,
|
|
85
|
+
} = require("./scenario-playwright");
|
|
86
|
+
|
|
87
|
+
const {
|
|
88
|
+
getInitScript,
|
|
89
|
+
handleConsoleMessage,
|
|
90
|
+
handlePageError,
|
|
91
|
+
handleRequestFailed,
|
|
92
|
+
} = require("./scenario-handlers");
|
|
93
|
+
|
|
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
|
+
}
|
|
239
|
+
|
|
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
|
+
// Read-only page-state snapshot for `capture-state`: the full localStorage
|
|
279
|
+
// map, a bounded sample of visible text nodes (document order), and — when a
|
|
280
|
+
// selector is given — that element's text. Evaluated in-page against the
|
|
281
|
+
// settled frame so it reflects exactly what a real capture saw (the proxy
|
|
282
|
+
// already injected the scenario's seed into the served HTML). Every read is
|
|
283
|
+
// individually guarded so a sandboxed/cross-origin localStorage never throws
|
|
284
|
+
// the whole capture; the worst case is an empty section, not a failure.
|
|
285
|
+
async function dumpPageState(frame, selector) {
|
|
286
|
+
return frame.evaluate((sel) => {
|
|
287
|
+
const localStorage = {};
|
|
288
|
+
try {
|
|
289
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
290
|
+
const key = window.localStorage.key(i);
|
|
291
|
+
if (key != null) localStorage[key] = window.localStorage.getItem(key);
|
|
292
|
+
}
|
|
293
|
+
} catch (_) {
|
|
294
|
+
/* localStorage may be unavailable (sandboxed/opaque origin) */
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const visibleText = [];
|
|
298
|
+
try {
|
|
299
|
+
// Reject text inside non-rendered tags (SCRIPT/STYLE/etc.) so an
|
|
300
|
+
// injected proxy script or inline CSS never masquerades as on-screen
|
|
301
|
+
// text — that noise is exactly what makes a state dump misleading.
|
|
302
|
+
const SKIP_TAGS = new Set([
|
|
303
|
+
"SCRIPT",
|
|
304
|
+
"STYLE",
|
|
305
|
+
"NOSCRIPT",
|
|
306
|
+
"TEMPLATE",
|
|
307
|
+
"HEAD",
|
|
308
|
+
"TITLE",
|
|
309
|
+
]);
|
|
310
|
+
const walker = document.createTreeWalker(
|
|
311
|
+
document.body,
|
|
312
|
+
NodeFilter.SHOW_TEXT,
|
|
313
|
+
{
|
|
314
|
+
acceptNode(node) {
|
|
315
|
+
const parent = node.parentElement;
|
|
316
|
+
if (parent && SKIP_TAGS.has(parent.tagName)) {
|
|
317
|
+
return NodeFilter.FILTER_REJECT;
|
|
318
|
+
}
|
|
319
|
+
return (node.textContent || "").trim()
|
|
320
|
+
? NodeFilter.FILTER_ACCEPT
|
|
321
|
+
: NodeFilter.FILTER_REJECT;
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
let node;
|
|
326
|
+
while ((node = walker.nextNode()) && visibleText.length < 40) {
|
|
327
|
+
const text = (node.textContent || "").replace(/\s+/g, " ").trim();
|
|
328
|
+
if (text) visibleText.push(text);
|
|
329
|
+
}
|
|
330
|
+
} catch (_) {
|
|
331
|
+
/* no body / detached document */
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let selectorText = null;
|
|
335
|
+
if (sel) {
|
|
336
|
+
try {
|
|
337
|
+
const el = document.querySelector(sel);
|
|
338
|
+
if (el) selectorText = (el.textContent || "").replace(/\s+/g, " ").trim();
|
|
339
|
+
} catch (_) {
|
|
340
|
+
/* invalid selector — leave selectorText null */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { localStorage, visibleText, selectorText };
|
|
345
|
+
}, selector || null);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// `preflight` is injectable (defaulting to the real app-port reachability
|
|
349
|
+
// check) so unit tests that mock the browser can stay network-free.
|
|
350
|
+
async function runScenarioCheck(config, { preflight = assertAppPortReachable } = {}) {
|
|
351
|
+
const { url, outputPath, width, height, httpMocks = {} } = config;
|
|
352
|
+
const issues = [];
|
|
353
|
+
const browser = await launchChromiumWithSelfHeal();
|
|
354
|
+
const contextOptions = {
|
|
355
|
+
viewport: { width: width || 1440, height: height || 900 },
|
|
356
|
+
};
|
|
357
|
+
if (config.colorScheme) contextOptions.colorScheme = config.colorScheme;
|
|
358
|
+
if (config.deviceScaleFactor)
|
|
359
|
+
contextOptions.deviceScaleFactor = config.deviceScaleFactor;
|
|
360
|
+
if (config.userAgent) contextOptions.userAgent = config.userAgent;
|
|
361
|
+
if (config.locale) contextOptions.locale = config.locale;
|
|
362
|
+
if (config.timezoneId) contextOptions.timezoneId = config.timezoneId;
|
|
363
|
+
if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion;
|
|
364
|
+
if (config.forcedColors) contextOptions.forcedColors = config.forcedColors;
|
|
365
|
+
const context = await browser.newContext(contextOptions);
|
|
366
|
+
|
|
367
|
+
// Apply the scenario's merged `browserState` (cookies + request
|
|
368
|
+
// headers) to the capture context BEFORE the first navigation.
|
|
369
|
+
// Belt-and-suspenders with the proxy's `Set-Cookie` injection: the
|
|
370
|
+
// proxy handles upstream-bound forwards, this branch handles the
|
|
371
|
+
// capture context's own request headers so an auth-gated route does
|
|
372
|
+
// not redirect to `/login` when the proxy is bypassed.
|
|
373
|
+
await applyBrowserState(context, config);
|
|
374
|
+
|
|
375
|
+
// Context-level init script runs in ALL frames (including cross-origin iframes)
|
|
376
|
+
await context.addInitScript(getInitScript());
|
|
377
|
+
|
|
378
|
+
const page = await context.newPage();
|
|
379
|
+
// Attach the network tracker BEFORE navigation so every request (the document
|
|
380
|
+
// and every client-side data fetch) is counted. Used after DOM stability to
|
|
381
|
+
// wait out an in-flight fetch that would otherwise be screenshotted as a
|
|
382
|
+
// loading skeleton.
|
|
383
|
+
const networkTracker = createNetworkTracker(page);
|
|
384
|
+
await attachHttpMocks(page, httpMocks);
|
|
385
|
+
|
|
386
|
+
page.on("pageerror", (error) => {
|
|
387
|
+
pushIssue(issues, handlePageError(error));
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
page.on("console", (message) => {
|
|
391
|
+
const issue = handleConsoleMessage(message);
|
|
392
|
+
if (issue) {
|
|
393
|
+
pushIssue(issues, issue);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
page.on("requestfailed", (request) => {
|
|
398
|
+
const issue = handleRequestFailed(request);
|
|
399
|
+
if (issue) {
|
|
400
|
+
pushIssue(issues, issue);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
let loaded = false;
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
// Application/route captures navigate at the top level so the
|
|
408
|
+
// first-party session cookie is sent (auth-gated routes render the
|
|
409
|
+
// authenticated page instead of /login); component captures keep the
|
|
410
|
+
// iframe harness for its background/sizing control. The backend signals
|
|
411
|
+
// the choice via `config.navigation` ("topLevel"); absent (the default)
|
|
412
|
+
// means the iframe harness, so existing callers are unchanged.
|
|
413
|
+
const { frame, response } =
|
|
414
|
+
config.navigation === "topLevel"
|
|
415
|
+
? await loadScenarioTopLevel(page, url, { preflight })
|
|
416
|
+
: await loadScenarioInIframe(page, url, {
|
|
417
|
+
background: config.iframeBackground,
|
|
418
|
+
preflight,
|
|
419
|
+
});
|
|
420
|
+
loaded = true;
|
|
421
|
+
|
|
422
|
+
if (response && response.status() >= 400) {
|
|
423
|
+
pushIssue(
|
|
424
|
+
issues,
|
|
425
|
+
createIssue("navigation", `Navigation returned HTTP ${response.status()}`, {
|
|
426
|
+
url: response.url(),
|
|
427
|
+
status: response.status(),
|
|
428
|
+
}),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
pushRedirectMismatchIssue(issues, url, frame, response, config);
|
|
433
|
+
|
|
434
|
+
// Project loading markers come from config when a caller injects them
|
|
435
|
+
// (unit tests), otherwise from stack.json — so a stable-but-loading app
|
|
436
|
+
// screen is not mistaken for settled content and captured mid-hydration.
|
|
437
|
+
const loadingMarkers = Array.isArray(config.loadingMarkers)
|
|
438
|
+
? config.loadingMarkers
|
|
439
|
+
: readStackLoadingMarkers();
|
|
440
|
+
await waitForStablePage(page, frame, 10000, loadingMarkers);
|
|
441
|
+
|
|
442
|
+
// DOM-stable does not mean done: a client-side data fetch can still be in
|
|
443
|
+
// flight (the loading skeleton cleared but its replacement content hasn't
|
|
444
|
+
// landed). Wait for the network to go quiet — bounded, so a streaming /
|
|
445
|
+
// long-poll endpoint that never idles caps out and captures anyway rather
|
|
446
|
+
// than hanging.
|
|
447
|
+
await waitForNetworkQuiet(networkTracker);
|
|
448
|
+
|
|
449
|
+
const rejectionMessages = await frame.evaluate(
|
|
450
|
+
() => window.__codeyamUnhandledRejections || [],
|
|
451
|
+
);
|
|
452
|
+
for (const message of rejectionMessages) {
|
|
453
|
+
pushIssue(
|
|
454
|
+
issues,
|
|
455
|
+
createIssue("unhandledrejection", message, {
|
|
456
|
+
url: page.url() || url,
|
|
457
|
+
}),
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Cold-start retry: a single re-collect after BLANK_RETRY_DELAY_MS covers
|
|
462
|
+
// the React.lazy / Suspense-fallback race where waitForStablePage settles
|
|
463
|
+
// on the still-empty `<div id="root">` shell before the dynamic chunk
|
|
464
|
+
// resolves. One retry is enough — the pause is sized to outlast the
|
|
465
|
+
// chunk-load window; only a genuinely blank page falls through to the
|
|
466
|
+
// blank issue below.
|
|
467
|
+
let contentState = await collectContentState(frame);
|
|
468
|
+
let hasContent = hasRenderableContent(contentState);
|
|
469
|
+
if (!hasContent) {
|
|
470
|
+
await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS));
|
|
471
|
+
contentState = await collectContentState(frame);
|
|
472
|
+
hasContent = hasRenderableContent(contentState);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!hasContent) {
|
|
476
|
+
pushIssue(
|
|
477
|
+
issues,
|
|
478
|
+
createIssue(
|
|
479
|
+
"blank",
|
|
480
|
+
`Page rendered no visible content (${describeBlankReason(contentState)})`,
|
|
481
|
+
{ url: page.url() || url },
|
|
482
|
+
),
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Check for known error states in the rendered content
|
|
487
|
+
const bodyText = await frame.evaluate(() => document.body?.innerText || "");
|
|
488
|
+
const matchedPattern = findErrorPattern(bodyText);
|
|
489
|
+
if (matchedPattern) {
|
|
490
|
+
const contextSnippet = buildErrorContextSnippet(bodyText, matchedPattern);
|
|
491
|
+
pushIssue(
|
|
492
|
+
issues,
|
|
493
|
+
createIssue(
|
|
494
|
+
"error-state",
|
|
495
|
+
`Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? bodyText.slice(0, 200)}`,
|
|
496
|
+
{
|
|
497
|
+
url: page.url() || url,
|
|
498
|
+
matchedPattern,
|
|
499
|
+
contextSnippet,
|
|
500
|
+
},
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Hydration / interactivity gate: a page can render content and log no
|
|
506
|
+
// errors yet never have hydrated, leaving every control dead. Read-only
|
|
507
|
+
// (it inspects framework-attachment markers, never clicks), so it is safe
|
|
508
|
+
// to run before the screenshot. Stack-gated and fail-safe — see
|
|
509
|
+
// scenario-interactivity.js — so backend / static / unknown-framework
|
|
510
|
+
// captures are an automatic pass.
|
|
511
|
+
const hydrationIssue = await probeInteractivity(frame, {
|
|
512
|
+
url: page.url() || url,
|
|
513
|
+
});
|
|
514
|
+
if (hydrationIssue) {
|
|
515
|
+
pushIssue(issues, hydrationIssue);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Drive the requested interaction (if any) against the settled frame,
|
|
519
|
+
// then re-settle, so `preview-interact` captures the RESULT of a click /
|
|
520
|
+
// fill / press (expanded accordion, open modal) without editing app
|
|
521
|
+
// source. A no-match target throws here and is caught below as a failed
|
|
522
|
+
// capture with the candidate-labels hint — never a silent blank shot.
|
|
523
|
+
if (config.interaction) {
|
|
524
|
+
await performInteraction(frame, config.interaction);
|
|
525
|
+
await waitForStablePage(page, frame, 5000, loadingMarkers);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (outputPath && loaded) {
|
|
529
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
530
|
+
await page.screenshot({ path: outputPath, fullPage: false });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const result = buildResult({
|
|
534
|
+
loaded,
|
|
535
|
+
hasContent,
|
|
536
|
+
issues,
|
|
537
|
+
outputPath,
|
|
538
|
+
url: frame.url() || url,
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// `capture-state` mode: attach the read-only page-state snapshot so the
|
|
542
|
+
// backend can report localStorage + rendered text. Off by default, so a
|
|
543
|
+
// normal error-check capture is byte-for-byte unchanged.
|
|
544
|
+
if (config.captureState) {
|
|
545
|
+
result.state = await dumpPageState(frame, config.stateSelector);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return result;
|
|
549
|
+
} catch (error) {
|
|
550
|
+
pushIssue(
|
|
551
|
+
issues,
|
|
552
|
+
createIssue("navigation", error.message || String(error), { url }),
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
return buildResult({
|
|
556
|
+
loaded,
|
|
557
|
+
hasContent: false,
|
|
558
|
+
issues,
|
|
559
|
+
outputPath,
|
|
560
|
+
url,
|
|
561
|
+
});
|
|
562
|
+
} finally {
|
|
563
|
+
await browser.close();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* npm wrapper entry point for the scenario-check binary: parses the
|
|
569
|
+
* JSON config from argv, drives Playwright to capture the configured
|
|
570
|
+
* URL, and writes the resulting screenshot.
|
|
571
|
+
*/
|
|
572
|
+
async function main() {
|
|
573
|
+
const config = JSON.parse(process.argv[2] || "{}");
|
|
574
|
+
|
|
575
|
+
if (!config.url) {
|
|
576
|
+
console.error(
|
|
577
|
+
"Usage: node scenario-check.js '{\"url\":\"...\",\"outputPath\":\"...\",\"width\":1440,\"height\":900}'",
|
|
578
|
+
);
|
|
579
|
+
process.exit(1);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const result = await runScenarioCheck(config);
|
|
583
|
+
console.log(JSON.stringify(result));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
module.exports = {
|
|
587
|
+
runScenarioCheck,
|
|
588
|
+
dumpPageState,
|
|
589
|
+
readStackLoadingMarkers,
|
|
590
|
+
applyBrowserState,
|
|
591
|
+
isCrossOriginRequest,
|
|
592
|
+
stripMarkerHeaders,
|
|
593
|
+
main,
|
|
594
|
+
launchChromiumWithSelfHeal,
|
|
595
|
+
PLAYWRIGHT_INSTALL_COMMAND,
|
|
596
|
+
PLAYWRIGHT_MISSING_BROWSER_PATTERN,
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
if (require.main === module) {
|
|
600
|
+
main().catch((error) => {
|
|
601
|
+
console.error(error.message || String(error));
|
|
602
|
+
process.exit(1);
|
|
603
|
+
});
|
|
604
|
+
}
|