@codeyam-editor/codeyam-editor 0.1.4 → 0.1.6
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 -1
- package/npm/cloud-branch-switch.js +1 -1
- package/npm/cloud.js +3166 -150
- package/npm/codesign-plan.js +68 -0
- package/npm/container.js +3 -0
- package/npm/disk-pressure.js +78 -0
- package/npm/editor-dev.js +96 -15
- package/npm/editor.js +85 -37
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +252 -0
- package/npm/fleet-asset-version.js +37 -0
- package/npm/fleet-auth-health.js +213 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +109 -5
- package/npm/fleet-cleanup.js +232 -16
- package/npm/fleet-destroy-safety.js +272 -0
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-effect-status.js +33 -32
- package/npm/fleet-env.js +86 -0
- package/npm/fleet-ghcr-secret.js +129 -0
- package/npm/fleet-git-maintenance.js +142 -0
- package/npm/fleet-host-op-limiter.js +68 -0
- package/npm/fleet-host.js +74 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-job-badge.js +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +155 -0
- package/npm/fleet-memory.js +245 -9
- package/npm/fleet-plans.js +233 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +241 -0
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +394 -0
- package/npm/fleet-queue.js +95 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reconcile.js +43 -1
- package/npm/fleet-reset.js +330 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-running-plan.js +85 -0
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +112 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +181 -0
- package/npm/fleet-stuck-status.js +117 -0
- package/npm/fleet-vm-access.js +404 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +237 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +226 -0
- package/npm/ingress-invoke.js +770 -0
- package/npm/launch-config.js +386 -9
- package/npm/scenario-check.js +880 -37
- package/npm/scenario-handlers.js +154 -30
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +144 -21
- package/npm/scenario-playwright.js +649 -38
- package/npm/script-drift.js +152 -0
- package/npm/utils.js +365 -52
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
- package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
- package/ui/dist/assets/index-BygCiJSb.css +1 -0
- package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
- package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
- package/ui/dist/assets/react-nrLBr15I.js +28 -0
- package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -0
- package/ui/dist/index.html +4 -4
- package/ui/dist/open-source/codeyam-counter.png +0 -0
- package/ui/dist/open-source/el-carot.png +0 -0
- package/ui/dist/open-source/tabcommand.png +0 -0
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-BGqairFV.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
- package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
package/npm/scenario-check.js
CHANGED
|
@@ -20,17 +20,88 @@ const { chromium } = require("playwright");
|
|
|
20
20
|
const PLAYWRIGHT_MISSING_BROWSER_PATTERN = "Executable doesn't exist";
|
|
21
21
|
const PLAYWRIGHT_INSTALL_COMMAND = "npx playwright install chromium";
|
|
22
22
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
23
|
+
// Substrings Playwright/Chromium emit when the browser process dies on a
|
|
24
|
+
// transient launch crash. On the cloud VM the base image has no dbus session,
|
|
25
|
+
// so Chromium intermittently aborts at launch with `Received signal 11`
|
|
26
|
+
// (SIGSEGV) — an environmental fault, not a scenario fault. Match either form
|
|
27
|
+
// because the wording varies across Playwright releases and the OS signal
|
|
28
|
+
// reporter.
|
|
29
|
+
const PLAYWRIGHT_LAUNCH_CRASH_PATTERNS = ["SIGSEGV", "Received signal 11"];
|
|
30
|
+
// A transient SIGSEGV launch crash is environmental, so a bounded retry clears
|
|
31
|
+
// it. Keep it small — a genuinely broken environment must still surface loudly
|
|
32
|
+
// rather than spin.
|
|
33
|
+
const CAPTURE_LAUNCH_SIGSEGV_RETRIES = 2;
|
|
34
|
+
// Short backoff between SIGSEGV retries so the transient fault has a moment to
|
|
35
|
+
// clear; small enough not to eat the per-slug recapture budget.
|
|
36
|
+
const CAPTURE_LAUNCH_SIGSEGV_BACKOFF_MS = 250;
|
|
37
|
+
|
|
38
|
+
// Pin the headless capture browser's `localhost` resolution to the IPv4
|
|
39
|
+
// loopback the editor's listeners bind. The browser-facing preview origin is
|
|
40
|
+
// now `localhost` (secure-context apps refuse a bare IP — see
|
|
41
|
+
// `BROWSER_FACING_HOST`), but on a dual-stack host `localhost` can resolve to
|
|
42
|
+
// `::1` first, which nothing answers on — stalling the capture or paying a
|
|
43
|
+
// happy-eyeballs fallback delay. Forcing the map removes that intermittency
|
|
44
|
+
// deterministically for capture. The operator's OWN preview browser is covered
|
|
45
|
+
// separately by the editor binding `::1` alongside `127.0.0.1` (see start.rs).
|
|
46
|
+
const CAPTURE_HOST_RESOLVER_RULES = "MAP localhost 127.0.0.1";
|
|
47
|
+
const CAPTURE_LAUNCH_ARGS = [
|
|
48
|
+
`--host-resolver-rules=${CAPTURE_HOST_RESOLVER_RULES}`,
|
|
49
|
+
// Cloud-VM-no-dbus / containerized-launch hardening. Each flag below is
|
|
50
|
+
// inert on a healthy desktop, so the laptop capture path is unchanged; on
|
|
51
|
+
// the dbus-less cloud base image they stop Chromium from SIGSEGV-ing at
|
|
52
|
+
// launch (signal 11). See plan chromium-capture-dbus-crash-hardening.
|
|
53
|
+
// No setuid sandbox in the container (the cloud VM runs capture without the
|
|
54
|
+
// kernel namespaces the sandbox needs).
|
|
55
|
+
"--no-sandbox",
|
|
56
|
+
// /dev/shm is tiny in many containers; route shared memory to /tmp so the
|
|
57
|
+
// renderer doesn't crash allocating it.
|
|
58
|
+
"--disable-dev-shm-usage",
|
|
59
|
+
// No GPU on the headless cloud VM — avoid the GL init path that aborts.
|
|
60
|
+
"--disable-gpu",
|
|
61
|
+
// Stub out the dbus integration Chromium reaches for at launch; the cloud
|
|
62
|
+
// base image has no dbus session bus, which is the proximate cause of the
|
|
63
|
+
// signal-11 abort.
|
|
64
|
+
"--disable-features=DBus",
|
|
65
|
+
];
|
|
66
|
+
// Point Chromium's dbus client at a dead address so it never blocks on (or
|
|
67
|
+
// crashes against) a missing session bus. Spread over the real env in the
|
|
68
|
+
// default launcher so the laptop path keeps its normal environment.
|
|
69
|
+
const CAPTURE_LAUNCH_DBUS_ENV = { DBUS_SESSION_BUS_ADDRESS: "/dev/null" };
|
|
70
|
+
|
|
71
|
+
// Does this launch error look like a transient SIGSEGV browser crash?
|
|
72
|
+
// (environmental — e.g. the cloud VM's missing dbus session bus — not a
|
|
73
|
+
// scenario fault, so it is safe to retry).
|
|
74
|
+
function isTransientLaunchCrash(error) {
|
|
75
|
+
return (
|
|
76
|
+
error &&
|
|
77
|
+
typeof error.message === "string" &&
|
|
78
|
+
PLAYWRIGHT_LAUNCH_CRASH_PATTERNS.some((pattern) =>
|
|
79
|
+
error.message.includes(pattern),
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Self-heal around `chromium.launch()`. Two recoverable classes:
|
|
85
|
+
//
|
|
86
|
+
// 1. "missing browser" — run `npx playwright install chromium` synchronously
|
|
87
|
+
// (with `stdio: "inherit"` so the user sees progress) and retry once.
|
|
88
|
+
// 2. transient SIGSEGV launch crash — retry the launch up to
|
|
89
|
+
// `CAPTURE_LAUNCH_SIGSEGV_RETRIES` times with a short backoff, since the
|
|
90
|
+
// crash is environmental (dbus-less cloud VM) and clears on a retry.
|
|
91
|
+
//
|
|
92
|
+
// For any unrecognized error, or after exhausting retries, rethrow the
|
|
93
|
+
// ORIGINAL Playwright error so the existing `Scenario check failed: <stderr>`
|
|
94
|
+
// path keeps showing the actionable message — looping would hide a real ops
|
|
95
|
+
// failure under a slow timeout.
|
|
30
96
|
async function launchChromiumWithSelfHeal({
|
|
31
|
-
launch = () =>
|
|
97
|
+
launch = () =>
|
|
98
|
+
chromium.launch({
|
|
99
|
+
args: CAPTURE_LAUNCH_ARGS,
|
|
100
|
+
env: { ...process.env, ...CAPTURE_LAUNCH_DBUS_ENV },
|
|
101
|
+
}),
|
|
32
102
|
install = () => execSync(PLAYWRIGHT_INSTALL_COMMAND, { stdio: "inherit" }),
|
|
33
103
|
stderr = process.stderr,
|
|
104
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
34
105
|
} = {}) {
|
|
35
106
|
try {
|
|
36
107
|
return await launch();
|
|
@@ -39,7 +110,24 @@ async function launchChromiumWithSelfHeal({
|
|
|
39
110
|
error &&
|
|
40
111
|
typeof error.message === "string" &&
|
|
41
112
|
error.message.includes(PLAYWRIGHT_MISSING_BROWSER_PATTERN);
|
|
42
|
-
|
|
113
|
+
const isTransientCrash = isTransientLaunchCrash(error);
|
|
114
|
+
if (!isMissingBrowser && !isTransientCrash) throw error;
|
|
115
|
+
|
|
116
|
+
if (isTransientCrash) {
|
|
117
|
+
for (let attempt = 1; attempt <= CAPTURE_LAUNCH_SIGSEGV_RETRIES; attempt++) {
|
|
118
|
+
stderr.write(
|
|
119
|
+
`Chromium crashed at launch (transient, likely a dbus-less environment) — retry ${attempt}/${CAPTURE_LAUNCH_SIGSEGV_RETRIES}.\n`,
|
|
120
|
+
);
|
|
121
|
+
await sleep(CAPTURE_LAUNCH_SIGSEGV_BACKOFF_MS);
|
|
122
|
+
try {
|
|
123
|
+
return await launch();
|
|
124
|
+
} catch (retryError) {
|
|
125
|
+
if (!isTransientLaunchCrash(retryError)) throw retryError;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
|
|
43
131
|
stderr.write(
|
|
44
132
|
"Playwright's Chromium browser is missing — installing it now (one-time ~150 MB download). Subsequent runs will be instant.\n",
|
|
45
133
|
);
|
|
@@ -57,9 +145,10 @@ async function launchChromiumWithSelfHeal({
|
|
|
57
145
|
}
|
|
58
146
|
|
|
59
147
|
const {
|
|
60
|
-
|
|
61
|
-
|
|
148
|
+
findScenarioError,
|
|
149
|
+
SCENARIO_ERROR_MARKER,
|
|
62
150
|
hasRenderableContent,
|
|
151
|
+
buildSettleAdvisory,
|
|
63
152
|
describeBlankReason,
|
|
64
153
|
} = require("./scenario-metrics");
|
|
65
154
|
|
|
@@ -71,19 +160,99 @@ const {
|
|
|
71
160
|
|
|
72
161
|
const {
|
|
73
162
|
attachHttpMocks,
|
|
163
|
+
isDeclaredErrorMock,
|
|
164
|
+
unmockedRouteFrom,
|
|
74
165
|
} = require("./scenario-mocks");
|
|
75
166
|
|
|
76
167
|
const {
|
|
77
168
|
assertAppPortReachable,
|
|
78
169
|
loadScenarioInIframe,
|
|
79
170
|
loadScenarioTopLevel,
|
|
171
|
+
resolveHarnessOrigin,
|
|
80
172
|
waitForStablePage,
|
|
81
173
|
createNetworkTracker,
|
|
82
174
|
waitForNetworkQuiet,
|
|
83
175
|
collectContentState,
|
|
176
|
+
scrollThroughDocument,
|
|
177
|
+
collectVisibleTextLength,
|
|
178
|
+
forceFinalVisualState,
|
|
179
|
+
centerCaptureWrapper,
|
|
84
180
|
performInteraction,
|
|
181
|
+
waitForPredicate,
|
|
182
|
+
performInteractionSequence,
|
|
85
183
|
} = require("./scenario-playwright");
|
|
86
184
|
|
|
185
|
+
// Name of the cookie the editor's control-API accepts the session token in —
|
|
186
|
+
// must match `SESSION_COOKIE` in crates/control-api/src/origin_guard.rs. On a
|
|
187
|
+
// non-loopback (cloud) bind the control-API requires this token on the harness
|
|
188
|
+
// route (`/__codeyam_harness`); without it the top-level harness navigation 401s
|
|
189
|
+
// and `#scenario-frame` never attaches, so EVERY capture times out. The browser
|
|
190
|
+
// UI carries this cookie automatically; the headless capturer does not, so we
|
|
191
|
+
// inject it here from the on-box 0600 token file.
|
|
192
|
+
const SESSION_COOKIE = "cy_session";
|
|
193
|
+
|
|
194
|
+
// Build the Playwright cookie array that authenticates the capture context to
|
|
195
|
+
// the editor's token-gated control-API. Reads the on-box `.codeyam/session-token`
|
|
196
|
+
// (0600) and scopes a `cy_session` cookie to `localhost` (localhost cookies are
|
|
197
|
+
// port-agnostic, so the same cookie covers the harness origin on the control
|
|
198
|
+
// port and any `/api` call on the app-proxy port). Returns `[]` — a no-op — when
|
|
199
|
+
// there is no token file (a loopback bind that never persisted one) or no
|
|
200
|
+
// resolvable harness origin, so a laptop capture is completely unaffected.
|
|
201
|
+
// `deps` is injectable so the builder is unit-testable without disk.
|
|
202
|
+
function buildSessionTokenCookies({
|
|
203
|
+
readTokenFile = () => {
|
|
204
|
+
const p = path.join(process.cwd(), ".codeyam", "session-token");
|
|
205
|
+
return fs.existsSync(p) ? fs.readFileSync(p, "utf8").trim() : null;
|
|
206
|
+
},
|
|
207
|
+
harnessOrigin = resolveHarnessOrigin(),
|
|
208
|
+
} = {}) {
|
|
209
|
+
try {
|
|
210
|
+
const token = readTokenFile();
|
|
211
|
+
if (!token || !harnessOrigin) return [];
|
|
212
|
+
return [
|
|
213
|
+
{
|
|
214
|
+
name: SESSION_COOKIE,
|
|
215
|
+
value: token,
|
|
216
|
+
domain: "localhost",
|
|
217
|
+
path: "/",
|
|
218
|
+
httpOnly: true,
|
|
219
|
+
sameSite: "Lax",
|
|
220
|
+
},
|
|
221
|
+
];
|
|
222
|
+
} catch (_) {
|
|
223
|
+
// A missing/unreadable token file must never break capture on a loopback
|
|
224
|
+
// bind where the token isn't required anyway — degrade to no cookie.
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// True when a non-empty on-box `.codeyam/session-token` exists (the same
|
|
230
|
+
// 0600 file `buildSessionTokenCookies` reads). Used by the capability-skew
|
|
231
|
+
// guard to tell "no token, loopback bind, nothing to inject" (fine) apart from
|
|
232
|
+
// "a token exists but the capture produced no cookie" (a stale capture script
|
|
233
|
+
// heading into a guaranteed 401). Never throws — an unreadable file reports
|
|
234
|
+
// absent, degrading to today's behavior.
|
|
235
|
+
function onBoxSessionTokenExists() {
|
|
236
|
+
try {
|
|
237
|
+
const p = path.join(process.cwd(), ".codeyam", "session-token");
|
|
238
|
+
return fs.existsSync(p) && fs.readFileSync(p, "utf8").trim().length > 0;
|
|
239
|
+
} catch (_) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Pure predicate behind the capability-skew guard: true when the capture WILL
|
|
245
|
+
// use the token-gated harness route (an origin resolved) yet produced NO
|
|
246
|
+
// `cy_session` cookie despite an on-box token existing — the signature of a
|
|
247
|
+
// generated `.codeyam/capture.js` stale relative to the harness route, heading
|
|
248
|
+
// into a guaranteed 401. Separated from the throw so the decision is
|
|
249
|
+
// unit-testable without a browser context. Returns false the moment any leg is
|
|
250
|
+
// absent (loopback bind with no harness origin, no token, or a cookie was
|
|
251
|
+
// produced), so a healthy capture never trips it.
|
|
252
|
+
function captureAuthSkewDetected({ harnessOrigin, cookieCount, tokenExists }) {
|
|
253
|
+
return Boolean(harnessOrigin) && cookieCount === 0 && Boolean(tokenExists);
|
|
254
|
+
}
|
|
255
|
+
|
|
87
256
|
const {
|
|
88
257
|
getInitScript,
|
|
89
258
|
handleConsoleMessage,
|
|
@@ -92,7 +261,7 @@ const {
|
|
|
92
261
|
} = require("./scenario-handlers");
|
|
93
262
|
|
|
94
263
|
const {
|
|
95
|
-
|
|
264
|
+
waitForHydration,
|
|
96
265
|
} = require("./scenario-interactivity");
|
|
97
266
|
|
|
98
267
|
// Read project-specific loading markers from `.codeyam/stack.json`
|
|
@@ -116,6 +285,70 @@ function readStackLoadingMarkers() {
|
|
|
116
285
|
}
|
|
117
286
|
}
|
|
118
287
|
|
|
288
|
+
// True when the scenario being captured scripts a `/ws/terminal` transcript or
|
|
289
|
+
// a WebSocket stream. Such captures need the REAL `WebSocket` so the server can
|
|
290
|
+
// replay the scripted agent state into the frame — `getInitScript` keeps its
|
|
291
|
+
// reconnect-silencing stub OFF for them (see the comment there). Read from the
|
|
292
|
+
// scenario file by slug (`config.scenarioId`), the same cwd-relative path
|
|
293
|
+
// `readStackLoadingMarkers` uses. Defaults to `false` (stub stays on, today's
|
|
294
|
+
// behavior) for the scenario-less CLI preview path or any unreadable/parse
|
|
295
|
+
// failure, so a missing file never accidentally un-stubs a live terminal.
|
|
296
|
+
function scenarioScriptsLiveSocket(config) {
|
|
297
|
+
const slug = config && config.scenarioId;
|
|
298
|
+
if (!slug) return false;
|
|
299
|
+
try {
|
|
300
|
+
const raw = fs.readFileSync(
|
|
301
|
+
path.join(".codeyam", "scenarios", `${slug}.json`),
|
|
302
|
+
"utf8",
|
|
303
|
+
);
|
|
304
|
+
const mocks = (JSON.parse(raw) || {}).mocks || {};
|
|
305
|
+
const transcripts = mocks.transcripts || {};
|
|
306
|
+
const streams = mocks.streams || {};
|
|
307
|
+
return Object.keys(transcripts).length > 0 || Object.keys(streams).length > 0;
|
|
308
|
+
} catch (_) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Classify what a driven interaction actually did, from the DOM fingerprint on
|
|
314
|
+
// either side of it plus the page's hydration state. Pure, so the distinction
|
|
315
|
+
// this encodes is unit-tested without a browser.
|
|
316
|
+
//
|
|
317
|
+
// A click that changed nothing has two very different causes, and reporting both
|
|
318
|
+
// as "none" is what let a dead page masquerade as a flaky harness: a page that
|
|
319
|
+
// NEVER HYDRATED could not have responded to ANY interaction (no client JS is
|
|
320
|
+
// running), whereas a hydrated page with an unchanged DOM really does mean the
|
|
321
|
+
// control is inert. Keeping them apart is what tells a reader whether to go look
|
|
322
|
+
// at their component or at the environment.
|
|
323
|
+
//
|
|
324
|
+
// `hydrated` is the three-valued signal from `waitForHydration`: only a PROVEN
|
|
325
|
+
// dead page (`false`) earns "unhydrated". `null` means the wait could not judge
|
|
326
|
+
// (unknown framework, no controls, probe threw) and must keep reporting "none" —
|
|
327
|
+
// never invent a hydration fault we cannot prove.
|
|
328
|
+
function classifyInteractionEffect(beforeFingerprint, afterFingerprint, hydrated) {
|
|
329
|
+
if (beforeFingerprint !== afterFingerprint) return "changed";
|
|
330
|
+
if (hydrated === false) return "unhydrated";
|
|
331
|
+
return "none";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function getDOMFingerprint(frame) {
|
|
335
|
+
try {
|
|
336
|
+
return await frame.evaluate(() => {
|
|
337
|
+
const body = document.body;
|
|
338
|
+
if (!body) return "";
|
|
339
|
+
const html = body.innerHTML;
|
|
340
|
+
let hash = 0;
|
|
341
|
+
for (let i = 0; i < html.length; i++) {
|
|
342
|
+
hash = (hash << 5) - hash + html.charCodeAt(i);
|
|
343
|
+
hash |= 0;
|
|
344
|
+
}
|
|
345
|
+
return `${html.length}-${hash}`;
|
|
346
|
+
});
|
|
347
|
+
} catch (err) {
|
|
348
|
+
return "";
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
119
352
|
// Cold-start retry pause. waitForStablePage settles as soon as the page is
|
|
120
353
|
// HTML-stable, which for a lazy/Suspense app is the empty `<div id="root">`
|
|
121
354
|
// shell — stable for the ~3s the dynamic chunk takes to load (longer when the
|
|
@@ -124,6 +357,33 @@ function readStackLoadingMarkers() {
|
|
|
124
357
|
// while staying under the test runner's default per-case timeout.
|
|
125
358
|
const BLANK_RETRY_DELAY_MS = 3000;
|
|
126
359
|
|
|
360
|
+
// Pause after scrolling the document to trip scroll-gated reveal observers, so
|
|
361
|
+
// the IntersectionObserver callbacks fire and any opacity/transform entrance
|
|
362
|
+
// transition begins before we measure visible content and screenshot. Driven
|
|
363
|
+
// through `page.waitForTimeout` so it is a real wait in Playwright but instant
|
|
364
|
+
// against the stubbed pages in unit tests.
|
|
365
|
+
const REVEAL_SETTLE_MS = 600;
|
|
366
|
+
|
|
367
|
+
// Measure the frame's visible (painted, non-opacity:0) text and fold it into
|
|
368
|
+
// the content state in place, so the blank gate can distinguish "text rendered
|
|
369
|
+
// but invisible" from a populated frame. Best-effort: a frame/target that
|
|
370
|
+
// cannot be measured (a stubbed test target returns a non-number, an evaluate
|
|
371
|
+
// throws) leaves the state untouched, so `hasRenderableContent` falls back to
|
|
372
|
+
// its DOM-presence behavior rather than treating an un-measurable frame as
|
|
373
|
+
// blank.
|
|
374
|
+
async function mergeVisibleTextLength(contentState, frame) {
|
|
375
|
+
if (!contentState) return;
|
|
376
|
+
let visible;
|
|
377
|
+
try {
|
|
378
|
+
visible = await collectVisibleTextLength(frame);
|
|
379
|
+
} catch (_) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (typeof visible === "number") {
|
|
383
|
+
contentState.visibleTextLength = visible;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
127
387
|
// True when `url` targets a different origin than `appOrigin`. Used to decide
|
|
128
388
|
// whether the codeyam capture markers must be stripped before the request
|
|
129
389
|
// leaves (cross-origin) or may ride along (same-origin, the app's own dev
|
|
@@ -139,6 +399,21 @@ function isCrossOriginRequest(url, appOrigin) {
|
|
|
139
399
|
}
|
|
140
400
|
}
|
|
141
401
|
|
|
402
|
+
// Decide whether a failed request should fail the capture. Only a failure
|
|
403
|
+
// targeting the captured page's OWN origin counts — a first-party route that
|
|
404
|
+
// 500s or a same-origin asset that 404s is a real capture problem. A
|
|
405
|
+
// cross-origin failure (the mocked app's dev port being down, an external
|
|
406
|
+
// CDN/API) is an external resource and is tolerated, so the editor-shell
|
|
407
|
+
// screenshot still succeeds. Mirrors the console handler's tolerance for
|
|
408
|
+
// declared error-mock URLs. When `appOrigin` is unknown (malformed capture
|
|
409
|
+
// URL) the failure stays fatal — the conservative default preserves today's
|
|
410
|
+
// behavior rather than silently swallowing every failure. Pure, so the
|
|
411
|
+
// decision is unit-testable without a live browser.
|
|
412
|
+
function isCaptureFatalRequestFailure(url, appOrigin) {
|
|
413
|
+
if (!appOrigin) return true;
|
|
414
|
+
return !isCrossOriginRequest(url, appOrigin);
|
|
415
|
+
}
|
|
416
|
+
|
|
142
417
|
// Return a copy of `headers` with every name in `markerNames` removed.
|
|
143
418
|
// Names are matched case-insensitively against the (lowercased) header keys
|
|
144
419
|
// Playwright reports. Pure — never mutates its input — so unrelated headers
|
|
@@ -235,6 +510,37 @@ async function applyBrowserState(context, config) {
|
|
|
235
510
|
},
|
|
236
511
|
);
|
|
237
512
|
}
|
|
513
|
+
|
|
514
|
+
// Seed the scenario's `browserState.localStorage` / `.sessionStorage` into
|
|
515
|
+
// the page before any app JS runs. Storage-gated UI (first-run banners,
|
|
516
|
+
// dismissed-prompt flags, persisted view state) is otherwise
|
|
517
|
+
// uncontrollable at capture time. Playwright serializes the function and
|
|
518
|
+
// its arg into the page context, so the function body must not close over
|
|
519
|
+
// outer variables. Only registered when the scenario actually carries
|
|
520
|
+
// storage, preserving the pre-storage capture context for everyone else.
|
|
521
|
+
const localStorageSeed = state.localStorage || {};
|
|
522
|
+
const sessionStorageSeed = state.sessionStorage || {};
|
|
523
|
+
if (
|
|
524
|
+
Object.keys(localStorageSeed).length > 0 ||
|
|
525
|
+
Object.keys(sessionStorageSeed).length > 0
|
|
526
|
+
) {
|
|
527
|
+
await context.addInitScript(
|
|
528
|
+
(storage) => {
|
|
529
|
+
try {
|
|
530
|
+
for (const [key, value] of Object.entries(storage.local)) {
|
|
531
|
+
window.localStorage.setItem(key, value);
|
|
532
|
+
}
|
|
533
|
+
for (const [key, value] of Object.entries(storage.session)) {
|
|
534
|
+
window.sessionStorage.setItem(key, value);
|
|
535
|
+
}
|
|
536
|
+
} catch (_) {
|
|
537
|
+
// Storage unavailable (sandboxed/opaque origin) — the seed is
|
|
538
|
+
// best-effort; never fail the capture over it.
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
{ local: localStorageSeed, session: sessionStorageSeed },
|
|
542
|
+
);
|
|
543
|
+
}
|
|
238
544
|
}
|
|
239
545
|
|
|
240
546
|
// Emit a `redirect-mismatch` issue when the final iframe URL's path
|
|
@@ -345,11 +651,207 @@ async function dumpPageState(frame, selector) {
|
|
|
345
651
|
}, selector || null);
|
|
346
652
|
}
|
|
347
653
|
|
|
654
|
+
// Landed-state verification: assert the localStorage the capture INJECTED
|
|
655
|
+
// (`config.browserState.localStorage`, which already carries the seed-session
|
|
656
|
+
// overlay the editor merged in) actually reached the capture browser after the
|
|
657
|
+
// page settled. The core promise of a seeded scenario is remote control of the
|
|
658
|
+
// app — a seed that silently doesn't land produces an empty screenshot that
|
|
659
|
+
// looks like a successful capture of an empty app, which is exactly the
|
|
660
|
+
// failure this guards. Returns a loud `seed-not-landed` issue (which fails the
|
|
661
|
+
// capture, since `ok` requires zero issues) when a non-empty injected seed is
|
|
662
|
+
// missing/empty on read-back, or `null` when there was nothing to verify, the
|
|
663
|
+
// seed landed, or storage is unavailable (sandboxed/opaque origin — never fail
|
|
664
|
+
// the capture over the verifier itself).
|
|
665
|
+
async function verifySeededStorageLanded(frame, config) {
|
|
666
|
+
const expected =
|
|
667
|
+
(config && config.browserState && config.browserState.localStorage) || {};
|
|
668
|
+
const expectedKeys = Object.keys(expected);
|
|
669
|
+
if (expectedKeys.length === 0) return null; // nothing was seeded into storage
|
|
670
|
+
|
|
671
|
+
let readback;
|
|
672
|
+
try {
|
|
673
|
+
readback = await frame.evaluate((keys) => {
|
|
674
|
+
try {
|
|
675
|
+
const allKeys = [];
|
|
676
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
677
|
+
const k = window.localStorage.key(i);
|
|
678
|
+
if (k != null) allKeys.push(k);
|
|
679
|
+
}
|
|
680
|
+
const present = {};
|
|
681
|
+
for (const k of keys) {
|
|
682
|
+
const v = window.localStorage.getItem(k);
|
|
683
|
+
present[k] = v != null && v !== "";
|
|
684
|
+
}
|
|
685
|
+
return { present, allKeys };
|
|
686
|
+
} catch (_) {
|
|
687
|
+
// localStorage unavailable (sandboxed/opaque origin) — can't verify.
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
}, expectedKeys);
|
|
691
|
+
} catch (_) {
|
|
692
|
+
return null; // evaluate failed — never fail a capture over the verifier.
|
|
693
|
+
}
|
|
694
|
+
if (!readback) return null; // storage unavailable — best-effort, don't fail.
|
|
695
|
+
|
|
696
|
+
const missing = expectedKeys.filter((k) => !readback.present[k]);
|
|
697
|
+
if (missing.length === 0) return null; // every seeded key landed — success.
|
|
698
|
+
|
|
699
|
+
const actual = readback.allKeys.length ? readback.allKeys.join(", ") : "<none>";
|
|
700
|
+
return createIssue(
|
|
701
|
+
"seed-not-landed",
|
|
702
|
+
`Seeded localStorage did not reach the capture browser: expected non-empty ` +
|
|
703
|
+
`keys [${expectedKeys.join(", ")}] but [${missing.join(", ")}] are ` +
|
|
704
|
+
`missing/empty after load (browser localStorage holds: [${actual}]). The ` +
|
|
705
|
+
`seed did NOT land, so this screenshot shows DEFAULT/EMPTY state — fix the ` +
|
|
706
|
+
`seed; do not delete the scenario. Likely causes: (1) the seed-session ` +
|
|
707
|
+
`overlay was stale or empty (re-run the seed adapter to refresh it), (2) the ` +
|
|
708
|
+
`adapter's stdout localStorage map failed to parse, or (3) the injection ` +
|
|
709
|
+
`path is down (the seeded origin differs from the captured page's origin).`,
|
|
710
|
+
{ url: (frame && frame.url && frame.url()) || (config && config.url) },
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Drive an ordered list of flow steps against ONE already-loaded browser
|
|
715
|
+
// session so a scripted multi-step demo (`editor preview-flow`) is captured as
|
|
716
|
+
// the real round-trip — click state and client transients persist across
|
|
717
|
+
// steps, which N independent fresh-load captures could never reproduce. Each
|
|
718
|
+
// step is one of:
|
|
719
|
+
// - navigate: re-load a route (resolved relative to the initial url) using
|
|
720
|
+
// the same loader strategy as the initial load, then re-settle. Returns
|
|
721
|
+
// the new content frame so subsequent steps target the navigated page.
|
|
722
|
+
// - click / fill / press: a `performInteraction` against the current frame,
|
|
723
|
+
// then re-settle.
|
|
724
|
+
// - waitFor: hold until a visible-text / selector predicate (bounded).
|
|
725
|
+
// - capture: write a numbered filmstrip frame to the step's `outputPath`.
|
|
726
|
+
// A failing step THROWS with its 1-based index and action, so the outer catch
|
|
727
|
+
// in `runScenarioCheck` reports exactly which step broke (and, for waitFor,
|
|
728
|
+
// the predicate that never appeared) instead of a silent blank capture.
|
|
729
|
+
// Returns the frame the flow ended on, so the caller's final screenshot and
|
|
730
|
+
// result URL reflect the last navigated route.
|
|
731
|
+
async function runFlowSteps(page, initialFrame, steps, ctx) {
|
|
732
|
+
const {
|
|
733
|
+
url,
|
|
734
|
+
loadingMarkers,
|
|
735
|
+
navigation,
|
|
736
|
+
iframeBackground,
|
|
737
|
+
preflight,
|
|
738
|
+
harnessOrigin,
|
|
739
|
+
hydrationTimeoutMs = 10000,
|
|
740
|
+
settleMs,
|
|
741
|
+
} = ctx;
|
|
742
|
+
// Honor a caller-supplied `settleMs` for the in-flow stability windows,
|
|
743
|
+
// falling back to today's hardcoded defaults (5s for interaction steps, 10s
|
|
744
|
+
// for a navigate). This stops the field from being silently dropped — the
|
|
745
|
+
// exact trap that made a reporter's `settleMs: 8000` a no-op.
|
|
746
|
+
const navigateSettleMs =
|
|
747
|
+
typeof settleMs === "number" && settleMs > 0 ? settleMs : 10000;
|
|
748
|
+
const interactionSettleMs =
|
|
749
|
+
typeof settleMs === "number" && settleMs > 0 ? settleMs : 5000;
|
|
750
|
+
let frame = initialFrame;
|
|
751
|
+
|
|
752
|
+
for (let i = 0; i < steps.length; i++) {
|
|
753
|
+
const step = steps[i] || {};
|
|
754
|
+
const n = i + 1;
|
|
755
|
+
try {
|
|
756
|
+
switch (step.action) {
|
|
757
|
+
case "navigate": {
|
|
758
|
+
const target = new URL(step.path, url).href;
|
|
759
|
+
const loadResult =
|
|
760
|
+
navigation === "topLevel"
|
|
761
|
+
? await loadScenarioTopLevel(page, target, { preflight })
|
|
762
|
+
: await loadScenarioInIframe(page, target, {
|
|
763
|
+
background: iframeBackground,
|
|
764
|
+
preflight,
|
|
765
|
+
harnessOrigin,
|
|
766
|
+
});
|
|
767
|
+
frame = loadResult.frame;
|
|
768
|
+
await waitForStablePage(page, frame, navigateSettleMs, loadingMarkers);
|
|
769
|
+
// A navigate loads a fresh route that must re-hydrate before the next
|
|
770
|
+
// fill/click, or the interaction lands on inert SSR markup (the same
|
|
771
|
+
// bug the top-level wait fixes, one route deeper). Bounded and
|
|
772
|
+
// stack-gated exactly like the top-level wait.
|
|
773
|
+
await waitForHydration(frame, {
|
|
774
|
+
url: target,
|
|
775
|
+
timeoutMs: hydrationTimeoutMs,
|
|
776
|
+
});
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
case "click":
|
|
780
|
+
case "fill":
|
|
781
|
+
case "press":
|
|
782
|
+
await performInteraction(frame, step);
|
|
783
|
+
await waitForStablePage(page, frame, interactionSettleMs, loadingMarkers);
|
|
784
|
+
break;
|
|
785
|
+
case "waitFor":
|
|
786
|
+
await waitForPredicate(frame, step);
|
|
787
|
+
break;
|
|
788
|
+
case "capture":
|
|
789
|
+
if (step.outputPath) {
|
|
790
|
+
fs.mkdirSync(path.dirname(step.outputPath), { recursive: true });
|
|
791
|
+
await page.screenshot({ path: step.outputPath, fullPage: false });
|
|
792
|
+
}
|
|
793
|
+
break;
|
|
794
|
+
default:
|
|
795
|
+
throw new Error(
|
|
796
|
+
`unknown step action "${step.action}" (expected navigate | click | fill | press | waitFor | capture)`,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
} catch (error) {
|
|
800
|
+
throw new Error(
|
|
801
|
+
`flow step ${n} (${step.action}) failed: ${error.message || String(error)}`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return frame;
|
|
807
|
+
}
|
|
808
|
+
|
|
348
809
|
// `preflight` is injectable (defaulting to the real app-port reachability
|
|
349
810
|
// check) so unit tests that mock the browser can stay network-free.
|
|
350
|
-
|
|
811
|
+
// `harnessOrigin` is likewise injectable: it defaults to resolving the editor's
|
|
812
|
+
// harness origin from `.codeyam/server-state.json`, but a test passes an
|
|
813
|
+
// explicit value (e.g. `null` to force the legacy `setContent` harness) so the
|
|
814
|
+
// iframe-load path never silently depends on a server-state file in cwd.
|
|
815
|
+
// Resolved ONCE here and threaded into every iframe load below.
|
|
816
|
+
async function runScenarioCheck(
|
|
817
|
+
config,
|
|
818
|
+
{ preflight = assertAppPortReachable, harnessOrigin } = {},
|
|
819
|
+
) {
|
|
820
|
+
const resolvedHarnessOrigin =
|
|
821
|
+
harnessOrigin !== undefined ? harnessOrigin : resolveHarnessOrigin();
|
|
351
822
|
const { url, outputPath, width, height, httpMocks = {} } = config;
|
|
823
|
+
// Per-scenario capture-check allowances (see `CaptureAllowances` /
|
|
824
|
+
// `ScenarioDefinition` on the Rust side). Default false so the guards keep
|
|
825
|
+
// their strict behavior for every scenario that does not opt in.
|
|
826
|
+
const allowMinimalRender = !!(config && config.allowMinimalRender);
|
|
827
|
+
const expectedConsoleErrors = !!(config && config.expectedConsoleErrors);
|
|
828
|
+
let interactionEffect = null;
|
|
829
|
+
let interactionRetried = false;
|
|
830
|
+
// Non-fatal warnings raised while resolving interaction/flow targets — e.g. a
|
|
831
|
+
// substring text match that hit more than one element. Surfaced on the result
|
|
832
|
+
// so the agent sees the ambiguity instead of a silently-wrong first-match.
|
|
833
|
+
const interactionWarnings = [];
|
|
352
834
|
const issues = [];
|
|
835
|
+
// Actionable set for self-healing: same-origin routes that returned 4xx with
|
|
836
|
+
// no scenario mock. A hermetic-proxy/upstream 404 is a *successful* HTTP
|
|
837
|
+
// response with status 404 (not a Playwright `requestfailed`), so the only
|
|
838
|
+
// place with method + path + status together is the `page.on("response")`
|
|
839
|
+
// collector below. De-duped by `"<METHOD> <path>"`; surfaced through
|
|
840
|
+
// `buildResult` so the Rust failure message can name each unmocked route and
|
|
841
|
+
// `stub-unmocked-routes` can add stub mocks for them.
|
|
842
|
+
const unmockedRoutes = [];
|
|
843
|
+
const unmockedRouteKeys = new Set();
|
|
844
|
+
// Origin of the captured page, used to classify failed requests: only a
|
|
845
|
+
// failure on the page's OWN origin should fail the capture (see
|
|
846
|
+
// `isCaptureFatalRequestFailure`). Derived from `config.url` exactly as
|
|
847
|
+
// `applyBrowserState` does; a malformed URL leaves it null, which keeps every
|
|
848
|
+
// request failure fatal (today's behavior).
|
|
849
|
+
let appOrigin = null;
|
|
850
|
+
try {
|
|
851
|
+
appOrigin = new URL(url).origin;
|
|
852
|
+
} catch (_) {
|
|
853
|
+
/* malformed capture URL — the guard no-ops and failures stay fatal */
|
|
854
|
+
}
|
|
353
855
|
const browser = await launchChromiumWithSelfHeal();
|
|
354
856
|
const contextOptions = {
|
|
355
857
|
viewport: { width: width || 1440, height: height || 900 },
|
|
@@ -362,8 +864,54 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
362
864
|
if (config.timezoneId) contextOptions.timezoneId = config.timezoneId;
|
|
363
865
|
if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion;
|
|
364
866
|
if (config.forcedColors) contextOptions.forcedColors = config.forcedColors;
|
|
867
|
+
// When the opt-in HTTPS preview (`proxy.httpsPreview`) is on, the capture
|
|
868
|
+
// origin is `https://localhost:<port>` served by the reverse proxy's
|
|
869
|
+
// self-signed cert. Accept it for capture only — gated on the https origin so
|
|
870
|
+
// plain-HTTP captures keep full TLS-error fidelity.
|
|
871
|
+
if (typeof appOrigin === "string" && appOrigin.startsWith("https://")) {
|
|
872
|
+
contextOptions.ignoreHTTPSErrors = true;
|
|
873
|
+
}
|
|
365
874
|
const context = await browser.newContext(contextOptions);
|
|
366
875
|
|
|
876
|
+
// Authenticate the capture context to the editor's token-gated control-API
|
|
877
|
+
// BEFORE any navigation. On a non-loopback bind the harness route requires the
|
|
878
|
+
// session token; the browser UI carries it as a cookie automatically but the
|
|
879
|
+
// headless capturer does not, so inject it from the on-box token file. Scoped
|
|
880
|
+
// to the SAME resolved harness origin the capture navigates to: when no harness
|
|
881
|
+
// origin resolves (missing server-state, or a test that passes
|
|
882
|
+
// `harnessOrigin: null`) there is nothing to authenticate to, so no cookie is
|
|
883
|
+
// added. The `addCookies` guard keeps a mock context that doesn't implement it
|
|
884
|
+
// from throwing (a real Playwright context always has it).
|
|
885
|
+
const sessionCookies = buildSessionTokenCookies({
|
|
886
|
+
harnessOrigin: resolvedHarnessOrigin,
|
|
887
|
+
});
|
|
888
|
+
// Capability-skew guard. When the capture WILL use the token-gated harness
|
|
889
|
+
// route (a harness origin resolved) AND an on-box session token exists, but no
|
|
890
|
+
// `cy_session` cookie was produced, the token injection is broken — the
|
|
891
|
+
// generated `.codeyam/capture.js` is stale relative to the harness route it
|
|
892
|
+
// must authenticate to. Proceeding would hit a guaranteed 401 and strand every
|
|
893
|
+
// component capture on a `#scenario-frame` timeout. Fail loudly at the source
|
|
894
|
+
// instead, naming the version skew and its fix — closing the gap between the
|
|
895
|
+
// harness route (present) and the capture-side auth (absent).
|
|
896
|
+
if (
|
|
897
|
+
captureAuthSkewDetected({
|
|
898
|
+
harnessOrigin: resolvedHarnessOrigin,
|
|
899
|
+
cookieCount: sessionCookies.length,
|
|
900
|
+
tokenExists: onBoxSessionTokenExists(),
|
|
901
|
+
})
|
|
902
|
+
) {
|
|
903
|
+
throw new Error(
|
|
904
|
+
`The capture harness origin ${resolvedHarnessOrigin} is token-gated and an ` +
|
|
905
|
+
`on-box \`.codeyam/session-token\` exists, but the capture produced no ` +
|
|
906
|
+
`\`cy_session\` cookie — the generated \`.codeyam/capture.js\` is stale ` +
|
|
907
|
+
`relative to the harness route (its session-token injection is missing or ` +
|
|
908
|
+
`broken). Regenerate the capture script / refresh the editor binary on this VM.`,
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
if (sessionCookies.length > 0 && typeof context.addCookies === "function") {
|
|
912
|
+
await context.addCookies(sessionCookies);
|
|
913
|
+
}
|
|
914
|
+
|
|
367
915
|
// Apply the scenario's merged `browserState` (cookies + request
|
|
368
916
|
// headers) to the capture context BEFORE the first navigation.
|
|
369
917
|
// Belt-and-suspenders with the proxy's `Set-Cookie` injection: the
|
|
@@ -372,8 +920,11 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
372
920
|
// not redirect to `/login` when the proxy is bypassed.
|
|
373
921
|
await applyBrowserState(context, config);
|
|
374
922
|
|
|
375
|
-
// Context-level init script runs in ALL frames (including cross-origin iframes)
|
|
376
|
-
|
|
923
|
+
// Context-level init script runs in ALL frames (including cross-origin iframes).
|
|
924
|
+
// Keep the real WebSocket for scenarios that script a `/ws/terminal`
|
|
925
|
+
// transcript / WS stream so the scripted agent state reaches the capture;
|
|
926
|
+
// every other capture gets the reconnect-silencing stub.
|
|
927
|
+
await context.addInitScript(getInitScript(scenarioScriptsLiveSocket(config)));
|
|
377
928
|
|
|
378
929
|
const page = await context.newPage();
|
|
379
930
|
// Attach the network tracker BEFORE navigation so every request (the document
|
|
@@ -389,12 +940,66 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
389
940
|
|
|
390
941
|
page.on("console", (message) => {
|
|
391
942
|
const issue = handleConsoleMessage(message);
|
|
392
|
-
if (issue)
|
|
393
|
-
|
|
394
|
-
|
|
943
|
+
if (!issue) return;
|
|
944
|
+
// Per-scenario allowance: a scenario that provokes console errors by design
|
|
945
|
+
// (a broken-image fallback whose `<img>` is meant to 404) opts in via
|
|
946
|
+
// `expectedConsoleErrors`. Load-bearing: without it the console-error guard
|
|
947
|
+
// fails the capture instead of screenshotting the intended fallback UI. A
|
|
948
|
+
// non-opted-in scenario still fails, so an unexpected console error is never
|
|
949
|
+
// silently tolerated.
|
|
950
|
+
if (expectedConsoleErrors) return;
|
|
951
|
+
// Console errors produced by the scenario's OWN declared error mocks
|
|
952
|
+
// (status >= 400) are the intended behavior of an error-state scenario,
|
|
953
|
+
// not a capture problem — skip them so "History - Load Error"-style
|
|
954
|
+
// scenarios can screenshot the failure UI they exist to demonstrate.
|
|
955
|
+
const sourceUrl = message.location && message.location().url;
|
|
956
|
+
if (sourceUrl && isDeclaredErrorMock(httpMocks, sourceUrl)) return;
|
|
957
|
+
// A console error originating from a cross-origin resource — the "Failed to
|
|
958
|
+
// load resource" Chromium logs alongside a cross-origin requestfailed (the
|
|
959
|
+
// down app dev port, an external CDN) — is an external-resource failure, not
|
|
960
|
+
// an editor-shell problem. Tolerate it for the same reason, and via the same
|
|
961
|
+
// predicate, as the requestfailed handler below.
|
|
962
|
+
if (sourceUrl && !isCaptureFatalRequestFailure(sourceUrl, appOrigin)) return;
|
|
963
|
+
pushIssue(issues, issue);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
page.on("response", (response) => {
|
|
967
|
+
// Collect same-origin 4xx responses that no scenario mock covers — the set
|
|
968
|
+
// a stub mock would fix. Mirrors the console-guard predicates exactly so the
|
|
969
|
+
// reported set matches the guard's tolerances: a scenario that provokes
|
|
970
|
+
// errors by design (`expectedConsoleErrors`) reports none, and a route the
|
|
971
|
+
// scenario already declares an error mock for is the mock's intended 4xx,
|
|
972
|
+
// not a missing mock.
|
|
973
|
+
if (expectedConsoleErrors) return;
|
|
974
|
+
const req = response.request();
|
|
975
|
+
const reqUrl = req.url();
|
|
976
|
+
// Same-origin only — mirror the console/requestfailed origin gate so a
|
|
977
|
+
// cross-origin sub-resource 4xx is never reported as an unmocked route.
|
|
978
|
+
if (!isCaptureFatalRequestFailure(reqUrl, appOrigin)) return;
|
|
979
|
+
// The status-threshold, declared-mock, and path-extraction decision is the
|
|
980
|
+
// pure `unmockedRouteFrom` helper; the listener keeps only the stateful
|
|
981
|
+
// same-origin gate above and the dedup below.
|
|
982
|
+
const route = unmockedRouteFrom(req.method(), reqUrl, response.status(), httpMocks);
|
|
983
|
+
if (!route) return;
|
|
984
|
+
const key = `${route.method} ${route.path}`;
|
|
985
|
+
if (unmockedRouteKeys.has(key)) return;
|
|
986
|
+
unmockedRouteKeys.add(key);
|
|
987
|
+
unmockedRoutes.push(route);
|
|
395
988
|
});
|
|
396
989
|
|
|
397
990
|
page.on("requestfailed", (request) => {
|
|
991
|
+
// Per-scenario allowance: the broken-image fallback scenario's `<img>` 404
|
|
992
|
+
// is a SAME-origin request failure it exists to demonstrate, so
|
|
993
|
+
// `expectedConsoleErrors` tolerates it here too (the console guard above
|
|
994
|
+
// relaxes the paired "Failed to load resource" error). Load-bearing for the
|
|
995
|
+
// same reason; a non-opted-in same-origin failure still fails the capture.
|
|
996
|
+
if (expectedConsoleErrors) return;
|
|
997
|
+
// A cross-origin sub-resource failing must not fail an editor-shell
|
|
998
|
+
// screenshot — only the captured page's OWN origin counts. The most common
|
|
999
|
+
// case here is the live preview pane reaching the mocked project's app dev
|
|
1000
|
+
// port (e.g. http://localhost:3000), which is not running in the
|
|
1001
|
+
// self-hosting capture container.
|
|
1002
|
+
if (!isCaptureFatalRequestFailure(request.url(), appOrigin)) return;
|
|
398
1003
|
const issue = handleRequestFailed(request);
|
|
399
1004
|
if (issue) {
|
|
400
1005
|
pushIssue(issues, issue);
|
|
@@ -410,13 +1015,18 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
410
1015
|
// iframe harness for its background/sizing control. The backend signals
|
|
411
1016
|
// the choice via `config.navigation` ("topLevel"); absent (the default)
|
|
412
1017
|
// means the iframe harness, so existing callers are unchanged.
|
|
413
|
-
const
|
|
1018
|
+
const loadResult =
|
|
414
1019
|
config.navigation === "topLevel"
|
|
415
1020
|
? await loadScenarioTopLevel(page, url, { preflight })
|
|
416
1021
|
: await loadScenarioInIframe(page, url, {
|
|
417
1022
|
background: config.iframeBackground,
|
|
418
1023
|
preflight,
|
|
1024
|
+
harnessOrigin: resolvedHarnessOrigin,
|
|
419
1025
|
});
|
|
1026
|
+
// `frame` is `let` so a `navigate` flow step (below) can re-point it at the
|
|
1027
|
+
// freshly-loaded route's content frame; `response` is the initial load only.
|
|
1028
|
+
let frame = loadResult.frame;
|
|
1029
|
+
const response = loadResult.response;
|
|
420
1030
|
loaded = true;
|
|
421
1031
|
|
|
422
1032
|
if (response && response.status() >= 400) {
|
|
@@ -427,6 +1037,21 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
427
1037
|
status: response.status(),
|
|
428
1038
|
}),
|
|
429
1039
|
);
|
|
1040
|
+
} else if (!response && config.navigation === "topLevel") {
|
|
1041
|
+
// A real top-level document load always yields a response. A null one
|
|
1042
|
+
// means the status could never be checked — and an unverified status is
|
|
1043
|
+
// not a passing status: a 500 whose error body renders text would
|
|
1044
|
+
// otherwise satisfy `hasContent` and pass as `ok`. Fail loudly instead.
|
|
1045
|
+
// The iframe path stays tolerant: there the app response is matched by
|
|
1046
|
+
// exact URL and is legitimately allowed to miss.
|
|
1047
|
+
pushIssue(
|
|
1048
|
+
issues,
|
|
1049
|
+
createIssue(
|
|
1050
|
+
"navigation",
|
|
1051
|
+
"Navigation response unavailable — could not verify the HTTP status of the top-level document",
|
|
1052
|
+
{ url },
|
|
1053
|
+
),
|
|
1054
|
+
);
|
|
430
1055
|
}
|
|
431
1056
|
|
|
432
1057
|
pushRedirectMismatchIssue(issues, url, frame, response, config);
|
|
@@ -437,14 +1062,43 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
437
1062
|
const loadingMarkers = Array.isArray(config.loadingMarkers)
|
|
438
1063
|
? config.loadingMarkers
|
|
439
1064
|
: readStackLoadingMarkers();
|
|
440
|
-
|
|
1065
|
+
// Content-stability window. Defaults to 10s; the editor's own WS-driven
|
|
1066
|
+
// terminal/build route under self-hosting passes a longer `stableTimeoutMs`
|
|
1067
|
+
// (its network never goes idle, so the default expired before the WS replay
|
|
1068
|
+
// landed and the capture came back blank — VM1). Every other route omits the
|
|
1069
|
+
// field and keeps the 10s default.
|
|
1070
|
+
const stableTimeoutMs =
|
|
1071
|
+
typeof config.stableTimeoutMs === "number" && config.stableTimeoutMs > 0
|
|
1072
|
+
? config.stableTimeoutMs
|
|
1073
|
+
: 10000;
|
|
1074
|
+
// Hydration wait cap. Mirrors `stableTimeoutMs`: defaults to 10s, but a slow
|
|
1075
|
+
// Turbopack cold-compile route can extend it via `config.hydrationTimeoutMs`
|
|
1076
|
+
// without a code change. Bounded and near-zero on a healthy page (attaches in
|
|
1077
|
+
// a poll or two); only a genuinely dead page pays the full cap.
|
|
1078
|
+
const hydrationTimeoutMs =
|
|
1079
|
+
typeof config.hydrationTimeoutMs === "number" &&
|
|
1080
|
+
config.hydrationTimeoutMs > 0
|
|
1081
|
+
? config.hydrationTimeoutMs
|
|
1082
|
+
: 10000;
|
|
1083
|
+
const stableOutcome = await waitForStablePage(
|
|
1084
|
+
page,
|
|
1085
|
+
frame,
|
|
1086
|
+
stableTimeoutMs,
|
|
1087
|
+
loadingMarkers,
|
|
1088
|
+
);
|
|
441
1089
|
|
|
442
1090
|
// DOM-stable does not mean done: a client-side data fetch can still be in
|
|
443
1091
|
// flight (the loading skeleton cleared but its replacement content hasn't
|
|
444
1092
|
// landed). Wait for the network to go quiet — bounded, so a streaming /
|
|
445
1093
|
// long-poll endpoint that never idles caps out and captures anyway rather
|
|
446
1094
|
// than hanging.
|
|
447
|
-
await waitForNetworkQuiet(networkTracker);
|
|
1095
|
+
const networkOutcome = await waitForNetworkQuiet(networkTracker);
|
|
1096
|
+
|
|
1097
|
+
// If the page never settled (a loading marker outlasted the wait) or the
|
|
1098
|
+
// network never went quiet, the screenshot likely caught a client-fetched
|
|
1099
|
+
// page mid-load. Compute the non-blocking advisory now, while both settle
|
|
1100
|
+
// signals are in hand, and surface it on the capture-state report below.
|
|
1101
|
+
const settleAdvisory = buildSettleAdvisory(stableOutcome, networkOutcome);
|
|
448
1102
|
|
|
449
1103
|
const rejectionMessages = await frame.evaluate(
|
|
450
1104
|
() => window.__codeyamUnhandledRejections || [],
|
|
@@ -458,6 +1112,23 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
458
1112
|
);
|
|
459
1113
|
}
|
|
460
1114
|
|
|
1115
|
+
// Reveal-suppression fix: scroll-gated entrance animations (content held at
|
|
1116
|
+
// `opacity:0` until an IntersectionObserver fires on scroll) render blank in
|
|
1117
|
+
// isolation, where the app shell that wires those observers is absent.
|
|
1118
|
+
// Emulate reduced motion — so reduced-motion-aware stylesheets drop their
|
|
1119
|
+
// entrance animations and render the final state — and scroll the document
|
|
1120
|
+
// end-to-end to trip every observer, so the content reveals BEFORE we
|
|
1121
|
+
// measure and screenshot it, with no per-project force-reveal script. Both
|
|
1122
|
+
// are best-effort: a page/frame that cannot emulate or scroll (a
|
|
1123
|
+
// backend/static capture, a stubbed test target) is a no-op.
|
|
1124
|
+
if (typeof page.emulateMedia === "function") {
|
|
1125
|
+
await page.emulateMedia({ reducedMotion: "reduce" }).catch(() => {});
|
|
1126
|
+
}
|
|
1127
|
+
await scrollThroughDocument(frame).catch(() => {});
|
|
1128
|
+
if (typeof page.waitForTimeout === "function") {
|
|
1129
|
+
await page.waitForTimeout(REVEAL_SETTLE_MS);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
461
1132
|
// Cold-start retry: a single re-collect after BLANK_RETRY_DELAY_MS covers
|
|
462
1133
|
// the React.lazy / Suspense-fallback race where waitForStablePage settles
|
|
463
1134
|
// on the still-empty `<div id="root">` shell before the dynamic chunk
|
|
@@ -465,13 +1136,27 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
465
1136
|
// chunk-load window; only a genuinely blank page falls through to the
|
|
466
1137
|
// blank issue below.
|
|
467
1138
|
let contentState = await collectContentState(frame);
|
|
1139
|
+
await mergeVisibleTextLength(contentState, frame);
|
|
468
1140
|
let hasContent = hasRenderableContent(contentState);
|
|
469
1141
|
if (!hasContent) {
|
|
470
1142
|
await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS));
|
|
471
1143
|
contentState = await collectContentState(frame);
|
|
1144
|
+
await mergeVisibleTextLength(contentState, frame);
|
|
472
1145
|
hasContent = hasRenderableContent(contentState);
|
|
473
1146
|
}
|
|
474
1147
|
|
|
1148
|
+
// Per-scenario allowance: a scenario whose intended UI is intrinsically
|
|
1149
|
+
// minimal (an empty `<textarea>` the blank heuristic can't "see") opts in
|
|
1150
|
+
// via `allowMinimalRender`. Accept the near-blank render as valid content so
|
|
1151
|
+
// BOTH the blank issue below is skipped AND `buildResult`'s `ok` (which
|
|
1152
|
+
// independently requires `hasContent`) can be true — otherwise the capture
|
|
1153
|
+
// would still fail with an empty issue list. The cold-start retry already
|
|
1154
|
+
// ran above, so a non-opted-in blank (a genuinely empty render) still falls
|
|
1155
|
+
// through to the issue below.
|
|
1156
|
+
if (!hasContent && allowMinimalRender) {
|
|
1157
|
+
hasContent = true;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
475
1160
|
if (!hasContent) {
|
|
476
1161
|
pushIssue(
|
|
477
1162
|
issues,
|
|
@@ -483,16 +1168,29 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
483
1168
|
);
|
|
484
1169
|
}
|
|
485
1170
|
|
|
486
|
-
// Check for
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
1171
|
+
// Check for codeyam's scenario-renderer error fallback. We anchor on the
|
|
1172
|
+
// DOM marker the ScenarioRenderer stamps on its error / seed-error frames
|
|
1173
|
+
// rather than scanning the whole page body for ERROR_PATTERNS — a
|
|
1174
|
+
// legitimately-mocked scenario whose content quotes one of those harness
|
|
1175
|
+
// phrases must not be flagged. Apps with no codeyam ScenarioRenderer never
|
|
1176
|
+
// emit the marker, so their pages are treated as healthy regardless of body
|
|
1177
|
+
// text. The scan is scoped to the marked element's own text.
|
|
1178
|
+
const errorMarker = await frame.evaluate((attr) => {
|
|
1179
|
+
const el = document.querySelector(`[${attr}]`);
|
|
1180
|
+
if (!el) return null;
|
|
1181
|
+
return {
|
|
1182
|
+
reason: el.getAttribute(attr) || "",
|
|
1183
|
+
text: el.innerText || el.textContent || "",
|
|
1184
|
+
};
|
|
1185
|
+
}, SCENARIO_ERROR_MARKER);
|
|
1186
|
+
const scenarioError = findScenarioError(errorMarker);
|
|
1187
|
+
if (scenarioError) {
|
|
1188
|
+
const { matchedPattern, contextSnippet } = scenarioError;
|
|
491
1189
|
pushIssue(
|
|
492
1190
|
issues,
|
|
493
1191
|
createIssue(
|
|
494
1192
|
"error-state",
|
|
495
|
-
`Page contains error content (matched "${matchedPattern}"): ${contextSnippet ??
|
|
1193
|
+
`Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? ""}`,
|
|
496
1194
|
{
|
|
497
1195
|
url: page.url() || url,
|
|
498
1196
|
matchedPattern,
|
|
@@ -508,21 +1206,130 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
508
1206
|
// to run before the screenshot. Stack-gated and fail-safe — see
|
|
509
1207
|
// scenario-interactivity.js — so backend / static / unknown-framework
|
|
510
1208
|
// captures are an automatic pass.
|
|
511
|
-
|
|
1209
|
+
// `hydration.hydrated` is also consulted below to classify interactionEffect:
|
|
1210
|
+
// the wait runs BEFORE any interaction, which is exactly the reading we want
|
|
1211
|
+
// — "was this page alive when we found it," not "did our click revive it."
|
|
1212
|
+
//
|
|
1213
|
+
// WAIT for hydration (not just detect it): the one-shot probe read the
|
|
1214
|
+
// attachment state before React had a chance to hydrate SSR markup, so the
|
|
1215
|
+
// subsequent fill/click landed on inert controls and the submit handler
|
|
1216
|
+
// never fired — yet the run reported success. Waiting here gates the
|
|
1217
|
+
// screenshot AND every interaction branch below (steps / interaction /
|
|
1218
|
+
// interactions[] all run after this line) on the client runtime actually
|
|
1219
|
+
// attaching. Stack-gated and fail-safe: a timed-out wait yields
|
|
1220
|
+
// `hydrated: false` (so a truly dead page still classifies `unhydrated`),
|
|
1221
|
+
// and a non-interactive stack returns instantly.
|
|
1222
|
+
const hydration = await waitForHydration(frame, {
|
|
512
1223
|
url: page.url() || url,
|
|
1224
|
+
timeoutMs: hydrationTimeoutMs,
|
|
1225
|
+
// `undefined` in production → waitForHydration reads .codeyam/stack.json;
|
|
1226
|
+
// a test injects a stack to force the interactive path without a real file.
|
|
1227
|
+
stack: config.stack,
|
|
513
1228
|
});
|
|
514
|
-
if (
|
|
515
|
-
pushIssue(issues,
|
|
1229
|
+
if (hydration.issue) {
|
|
1230
|
+
pushIssue(issues, hydration.issue);
|
|
516
1231
|
}
|
|
517
1232
|
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
if (
|
|
524
|
-
|
|
1233
|
+
// Assert the injected seed actually landed in the capture browser, at rest
|
|
1234
|
+
// and BEFORE any interaction can legitimately mutate storage. A non-empty
|
|
1235
|
+
// seed that didn't reach localStorage means the screenshot will show
|
|
1236
|
+
// default/empty state — fail loudly instead of emitting a misleading frame.
|
|
1237
|
+
const seedNotLandedIssue = await verifySeededStorageLanded(frame, config);
|
|
1238
|
+
if (seedNotLandedIssue) {
|
|
1239
|
+
pushIssue(issues, seedNotLandedIssue);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Scripted multi-step flow (`editor preview-flow`): drive an ordered
|
|
1243
|
+
// sequence of steps in THIS one browser session — so a behavioral
|
|
1244
|
+
// round-trip (click → observe → click) is captured as the real flow, not N
|
|
1245
|
+
// independent fresh-load snapshots. Each `capture` step writes a numbered
|
|
1246
|
+
// filmstrip frame; the others advance the same page. Mutually exclusive
|
|
1247
|
+
// with the single `interaction` path below. `frame` is reassigned so a
|
|
1248
|
+
// `navigate` step's new content frame backs the rest of the flow and the
|
|
1249
|
+
// final result URL.
|
|
1250
|
+
if (Array.isArray(config.steps) && config.steps.length > 0) {
|
|
1251
|
+
frame = await runFlowSteps(page, frame, config.steps, {
|
|
1252
|
+
url,
|
|
1253
|
+
loadingMarkers,
|
|
1254
|
+
navigation: config.navigation,
|
|
1255
|
+
iframeBackground: config.iframeBackground,
|
|
1256
|
+
preflight,
|
|
1257
|
+
harnessOrigin: resolvedHarnessOrigin,
|
|
1258
|
+
warnings: interactionWarnings,
|
|
1259
|
+
hydrationTimeoutMs,
|
|
1260
|
+
settleMs: config.settleMs,
|
|
1261
|
+
});
|
|
1262
|
+
} else if (config.interaction) {
|
|
1263
|
+
// Record fingerprint before interaction
|
|
1264
|
+
const beforeFingerprint = await getDOMFingerprint(frame);
|
|
1265
|
+
|
|
1266
|
+
// Drive the requested interaction (if any) against the settled frame,
|
|
1267
|
+
// then re-settle, so `preview-interact` captures the RESULT of a click /
|
|
1268
|
+
// fill / press (expanded accordion, open modal) without editing app
|
|
1269
|
+
// source. A no-match target throws here and is caught below as a failed
|
|
1270
|
+
// capture with the candidate-labels hint — never a silent blank shot.
|
|
1271
|
+
await performInteraction(frame, config.interaction, {
|
|
1272
|
+
warnings: interactionWarnings,
|
|
1273
|
+
});
|
|
525
1274
|
await waitForStablePage(page, frame, 5000, loadingMarkers);
|
|
1275
|
+
|
|
1276
|
+
// Record fingerprint after interaction
|
|
1277
|
+
let afterFingerprint = await getDOMFingerprint(frame);
|
|
1278
|
+
|
|
1279
|
+
// If unchanged and it was a click, retry once after 500ms (covers the hydration race)
|
|
1280
|
+
if (beforeFingerprint === afterFingerprint && config.interaction.action === "click") {
|
|
1281
|
+
interactionRetried = true;
|
|
1282
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1283
|
+
// The retry re-resolves the same target; suppress its ambiguity warning
|
|
1284
|
+
// (already captured on the first attempt) to avoid a duplicate.
|
|
1285
|
+
await performInteraction(frame, config.interaction);
|
|
1286
|
+
await waitForStablePage(page, frame, 5000, loadingMarkers);
|
|
1287
|
+
afterFingerprint = await getDOMFingerprint(frame);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
interactionEffect = classifyInteractionEffect(
|
|
1291
|
+
beforeFingerprint,
|
|
1292
|
+
afterFingerprint,
|
|
1293
|
+
hydration.hydrated,
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Replay the scenario's PERSISTED interaction sequence (if any) in order,
|
|
1298
|
+
// settling between steps, so a declared interactive state — an expanded
|
|
1299
|
+
// section, a revealed hover bar, an open modal — is reproduced on every
|
|
1300
|
+
// capture and recapture, not just in a one-off `preview-interact`. A step
|
|
1301
|
+
// that matches nothing throws and is caught below as a failed capture, so a
|
|
1302
|
+
// sequence that didn't fully run never persists a resting-state screenshot.
|
|
1303
|
+
if (Array.isArray(config.interactions) && config.interactions.length > 0) {
|
|
1304
|
+
await performInteractionSequence(page, frame, config.interactions, {
|
|
1305
|
+
settleMs: 5000,
|
|
1306
|
+
loadingMarkers,
|
|
1307
|
+
warnings: interactionWarnings,
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Snap any remaining mid-flight CSS entrance animation to its final frame
|
|
1312
|
+
// for the static shot — but only when the scenario has NOT declared an
|
|
1313
|
+
// interactive state (a single `interaction`, a persisted `interactions`
|
|
1314
|
+
// sequence, or a multi-step `steps` flow), so an intentionally
|
|
1315
|
+
// animated/collapsed interactive frame is never clobbered by the force.
|
|
1316
|
+
const hasDeclaredInteractiveState =
|
|
1317
|
+
!!config.interaction ||
|
|
1318
|
+
(Array.isArray(config.interactions) && config.interactions.length > 0) ||
|
|
1319
|
+
(Array.isArray(config.steps) && config.steps.length > 0);
|
|
1320
|
+
if (outputPath && loaded && !hasDeclaredInteractiveState) {
|
|
1321
|
+
await forceFinalVisualState(frame).catch(() => {});
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Center an isolated-component wrapper that is not already centered. The
|
|
1325
|
+
// shot below is full-viewport with no element clip, so an isolation page
|
|
1326
|
+
// that does not center itself lands in the top-left corner. Unlike
|
|
1327
|
+
// forceFinalVisualState this is NOT gated on hasDeclaredInteractiveState:
|
|
1328
|
+
// an isolation page with a declared interaction still needs to be centered,
|
|
1329
|
+
// and moving the wrapper does not clobber its contents the way
|
|
1330
|
+
// animation-forcing does — so a replayed interaction sequence stays valid.
|
|
1331
|
+
if (outputPath && loaded) {
|
|
1332
|
+
await centerCaptureWrapper(frame).catch(() => {});
|
|
526
1333
|
}
|
|
527
1334
|
|
|
528
1335
|
if (outputPath && loaded) {
|
|
@@ -536,13 +1343,34 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
536
1343
|
issues,
|
|
537
1344
|
outputPath,
|
|
538
1345
|
url: frame.url() || url,
|
|
1346
|
+
unmockedRoutes,
|
|
539
1347
|
});
|
|
540
1348
|
|
|
1349
|
+
if (config.interaction) {
|
|
1350
|
+
result.interactionEffect = interactionEffect;
|
|
1351
|
+
result.interactionRetried = interactionRetried;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// Surface any non-fatal interaction/flow warnings (e.g. an ambiguous
|
|
1355
|
+
// substring text match) so the CLI can print them and the agent can switch
|
|
1356
|
+
// to a precise selector. Only attached when present — a clean run is
|
|
1357
|
+
// byte-for-byte unchanged.
|
|
1358
|
+
if (interactionWarnings.length > 0) {
|
|
1359
|
+
result.warnings = interactionWarnings.slice();
|
|
1360
|
+
}
|
|
1361
|
+
|
|
541
1362
|
// `capture-state` mode: attach the read-only page-state snapshot so the
|
|
542
1363
|
// backend can report localStorage + rendered text. Off by default, so a
|
|
543
1364
|
// normal error-check capture is byte-for-byte unchanged.
|
|
544
1365
|
if (config.captureState) {
|
|
545
1366
|
result.state = await dumpPageState(frame, config.stateSelector);
|
|
1367
|
+
// A populated state that renders blank is exactly when an agent reaches
|
|
1368
|
+
// for capture-state, so bundle the client-fetch advisory with the dump it
|
|
1369
|
+
// explains. Only when the page didn't settle cleanly — the SSR /
|
|
1370
|
+
// props-driven happy path stays advisory-free.
|
|
1371
|
+
if (settleAdvisory) {
|
|
1372
|
+
result.state.advisories = [settleAdvisory];
|
|
1373
|
+
}
|
|
546
1374
|
}
|
|
547
1375
|
|
|
548
1376
|
return result;
|
|
@@ -558,6 +1386,7 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
558
1386
|
issues,
|
|
559
1387
|
outputPath,
|
|
560
1388
|
url,
|
|
1389
|
+
unmockedRoutes,
|
|
561
1390
|
});
|
|
562
1391
|
} finally {
|
|
563
1392
|
await browser.close();
|
|
@@ -585,15 +1414,29 @@ async function main() {
|
|
|
585
1414
|
|
|
586
1415
|
module.exports = {
|
|
587
1416
|
runScenarioCheck,
|
|
1417
|
+
classifyInteractionEffect,
|
|
1418
|
+
mergeVisibleTextLength,
|
|
1419
|
+
runFlowSteps,
|
|
588
1420
|
dumpPageState,
|
|
1421
|
+
verifySeededStorageLanded,
|
|
589
1422
|
readStackLoadingMarkers,
|
|
1423
|
+
scenarioScriptsLiveSocket,
|
|
590
1424
|
applyBrowserState,
|
|
1425
|
+
buildSessionTokenCookies,
|
|
1426
|
+
captureAuthSkewDetected,
|
|
1427
|
+
SESSION_COOKIE,
|
|
591
1428
|
isCrossOriginRequest,
|
|
1429
|
+
isCaptureFatalRequestFailure,
|
|
592
1430
|
stripMarkerHeaders,
|
|
593
1431
|
main,
|
|
594
1432
|
launchChromiumWithSelfHeal,
|
|
1433
|
+
isTransientLaunchCrash,
|
|
1434
|
+
CAPTURE_LAUNCH_ARGS,
|
|
1435
|
+
CAPTURE_HOST_RESOLVER_RULES,
|
|
1436
|
+
CAPTURE_LAUNCH_SIGSEGV_RETRIES,
|
|
595
1437
|
PLAYWRIGHT_INSTALL_COMMAND,
|
|
596
1438
|
PLAYWRIGHT_MISSING_BROWSER_PATTERN,
|
|
1439
|
+
PLAYWRIGHT_LAUNCH_CRASH_PATTERNS,
|
|
597
1440
|
};
|
|
598
1441
|
|
|
599
1442
|
if (require.main === module) {
|