@codeyam-editor/codeyam-editor 0.1.3 → 0.1.5
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/cloud-branch-switch.js +1 -1
- package/npm/cloud.js +1693 -96
- package/npm/container.js +14 -0
- package/npm/disk-pressure.js +167 -0
- package/npm/editor-dev.js +107 -15
- package/npm/editor.js +97 -37
- package/npm/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +130 -0
- package/npm/fleet-cleanup.js +145 -7
- package/npm/fleet-destroy-safety.js +55 -0
- package/npm/fleet-effect-status.js +33 -32
- package/npm/fleet-host.js +74 -0
- package/npm/fleet-job-badge.js +46 -2
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +231 -0
- package/npm/fleet-prewarm.js +165 -0
- package/npm/fleet-public-url.js +160 -0
- package/npm/fleet-queue.js +38 -1
- package/npm/fleet-reconcile.js +100 -1
- package/npm/fleet-reset.js +298 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-setup-steps.js +105 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-strand.js +150 -0
- package/npm/fleet-stuck-status.js +111 -0
- package/npm/fleet-vm-access.js +234 -0
- package/npm/fleet-vm-readiness.js +104 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +211 -0
- package/npm/ingress-invoke.js +292 -0
- package/npm/launch-config.js +310 -4
- package/npm/scenario-check.js +684 -33
- package/npm/scenario-handlers.js +94 -12
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +20 -0
- package/npm/scenario-playwright.js +366 -29
- package/npm/script-drift.js +81 -0
- package/npm/utils.js +453 -43
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
- package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
- package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
- package/ui/dist/assets/index-CIXZkwfB.css +1 -0
- package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
- package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
- 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,17 +160,24 @@ const {
|
|
|
71
160
|
|
|
72
161
|
const {
|
|
73
162
|
attachHttpMocks,
|
|
163
|
+
isDeclaredErrorMock,
|
|
74
164
|
} = require("./scenario-mocks");
|
|
75
165
|
|
|
76
166
|
const {
|
|
77
167
|
assertAppPortReachable,
|
|
78
168
|
loadScenarioInIframe,
|
|
79
169
|
loadScenarioTopLevel,
|
|
170
|
+
resolveHarnessOrigin,
|
|
80
171
|
waitForStablePage,
|
|
81
172
|
createNetworkTracker,
|
|
82
173
|
waitForNetworkQuiet,
|
|
83
174
|
collectContentState,
|
|
175
|
+
scrollThroughDocument,
|
|
176
|
+
collectVisibleTextLength,
|
|
177
|
+
forceFinalVisualState,
|
|
84
178
|
performInteraction,
|
|
179
|
+
waitForPredicate,
|
|
180
|
+
performInteractionSequence,
|
|
85
181
|
} = require("./scenario-playwright");
|
|
86
182
|
|
|
87
183
|
const {
|
|
@@ -116,6 +212,49 @@ function readStackLoadingMarkers() {
|
|
|
116
212
|
}
|
|
117
213
|
}
|
|
118
214
|
|
|
215
|
+
// True when the scenario being captured scripts a `/ws/terminal` transcript or
|
|
216
|
+
// a WebSocket stream. Such captures need the REAL `WebSocket` so the server can
|
|
217
|
+
// replay the scripted agent state into the frame — `getInitScript` keeps its
|
|
218
|
+
// reconnect-silencing stub OFF for them (see the comment there). Read from the
|
|
219
|
+
// scenario file by slug (`config.scenarioId`), the same cwd-relative path
|
|
220
|
+
// `readStackLoadingMarkers` uses. Defaults to `false` (stub stays on, today's
|
|
221
|
+
// behavior) for the scenario-less CLI preview path or any unreadable/parse
|
|
222
|
+
// failure, so a missing file never accidentally un-stubs a live terminal.
|
|
223
|
+
function scenarioScriptsLiveSocket(config) {
|
|
224
|
+
const slug = config && config.scenarioId;
|
|
225
|
+
if (!slug) return false;
|
|
226
|
+
try {
|
|
227
|
+
const raw = fs.readFileSync(
|
|
228
|
+
path.join(".codeyam", "scenarios", `${slug}.json`),
|
|
229
|
+
"utf8",
|
|
230
|
+
);
|
|
231
|
+
const mocks = (JSON.parse(raw) || {}).mocks || {};
|
|
232
|
+
const transcripts = mocks.transcripts || {};
|
|
233
|
+
const streams = mocks.streams || {};
|
|
234
|
+
return Object.keys(transcripts).length > 0 || Object.keys(streams).length > 0;
|
|
235
|
+
} catch (_) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function getDOMFingerprint(frame) {
|
|
241
|
+
try {
|
|
242
|
+
return await frame.evaluate(() => {
|
|
243
|
+
const body = document.body;
|
|
244
|
+
if (!body) return "";
|
|
245
|
+
const html = body.innerHTML;
|
|
246
|
+
let hash = 0;
|
|
247
|
+
for (let i = 0; i < html.length; i++) {
|
|
248
|
+
hash = (hash << 5) - hash + html.charCodeAt(i);
|
|
249
|
+
hash |= 0;
|
|
250
|
+
}
|
|
251
|
+
return `${html.length}-${hash}`;
|
|
252
|
+
});
|
|
253
|
+
} catch (err) {
|
|
254
|
+
return "";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
119
258
|
// Cold-start retry pause. waitForStablePage settles as soon as the page is
|
|
120
259
|
// HTML-stable, which for a lazy/Suspense app is the empty `<div id="root">`
|
|
121
260
|
// shell — stable for the ~3s the dynamic chunk takes to load (longer when the
|
|
@@ -124,6 +263,33 @@ function readStackLoadingMarkers() {
|
|
|
124
263
|
// while staying under the test runner's default per-case timeout.
|
|
125
264
|
const BLANK_RETRY_DELAY_MS = 3000;
|
|
126
265
|
|
|
266
|
+
// Pause after scrolling the document to trip scroll-gated reveal observers, so
|
|
267
|
+
// the IntersectionObserver callbacks fire and any opacity/transform entrance
|
|
268
|
+
// transition begins before we measure visible content and screenshot. Driven
|
|
269
|
+
// through `page.waitForTimeout` so it is a real wait in Playwright but instant
|
|
270
|
+
// against the stubbed pages in unit tests.
|
|
271
|
+
const REVEAL_SETTLE_MS = 600;
|
|
272
|
+
|
|
273
|
+
// Measure the frame's visible (painted, non-opacity:0) text and fold it into
|
|
274
|
+
// the content state in place, so the blank gate can distinguish "text rendered
|
|
275
|
+
// but invisible" from a populated frame. Best-effort: a frame/target that
|
|
276
|
+
// cannot be measured (a stubbed test target returns a non-number, an evaluate
|
|
277
|
+
// throws) leaves the state untouched, so `hasRenderableContent` falls back to
|
|
278
|
+
// its DOM-presence behavior rather than treating an un-measurable frame as
|
|
279
|
+
// blank.
|
|
280
|
+
async function mergeVisibleTextLength(contentState, frame) {
|
|
281
|
+
if (!contentState) return;
|
|
282
|
+
let visible;
|
|
283
|
+
try {
|
|
284
|
+
visible = await collectVisibleTextLength(frame);
|
|
285
|
+
} catch (_) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (typeof visible === "number") {
|
|
289
|
+
contentState.visibleTextLength = visible;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
127
293
|
// True when `url` targets a different origin than `appOrigin`. Used to decide
|
|
128
294
|
// whether the codeyam capture markers must be stripped before the request
|
|
129
295
|
// leaves (cross-origin) or may ride along (same-origin, the app's own dev
|
|
@@ -139,6 +305,21 @@ function isCrossOriginRequest(url, appOrigin) {
|
|
|
139
305
|
}
|
|
140
306
|
}
|
|
141
307
|
|
|
308
|
+
// Decide whether a failed request should fail the capture. Only a failure
|
|
309
|
+
// targeting the captured page's OWN origin counts — a first-party route that
|
|
310
|
+
// 500s or a same-origin asset that 404s is a real capture problem. A
|
|
311
|
+
// cross-origin failure (the mocked app's dev port being down, an external
|
|
312
|
+
// CDN/API) is an external resource and is tolerated, so the editor-shell
|
|
313
|
+
// screenshot still succeeds. Mirrors the console handler's tolerance for
|
|
314
|
+
// declared error-mock URLs. When `appOrigin` is unknown (malformed capture
|
|
315
|
+
// URL) the failure stays fatal — the conservative default preserves today's
|
|
316
|
+
// behavior rather than silently swallowing every failure. Pure, so the
|
|
317
|
+
// decision is unit-testable without a live browser.
|
|
318
|
+
function isCaptureFatalRequestFailure(url, appOrigin) {
|
|
319
|
+
if (!appOrigin) return true;
|
|
320
|
+
return !isCrossOriginRequest(url, appOrigin);
|
|
321
|
+
}
|
|
322
|
+
|
|
142
323
|
// Return a copy of `headers` with every name in `markerNames` removed.
|
|
143
324
|
// Names are matched case-insensitively against the (lowercased) header keys
|
|
144
325
|
// Playwright reports. Pure — never mutates its input — so unrelated headers
|
|
@@ -235,6 +416,37 @@ async function applyBrowserState(context, config) {
|
|
|
235
416
|
},
|
|
236
417
|
);
|
|
237
418
|
}
|
|
419
|
+
|
|
420
|
+
// Seed the scenario's `browserState.localStorage` / `.sessionStorage` into
|
|
421
|
+
// the page before any app JS runs. Storage-gated UI (first-run banners,
|
|
422
|
+
// dismissed-prompt flags, persisted view state) is otherwise
|
|
423
|
+
// uncontrollable at capture time. Playwright serializes the function and
|
|
424
|
+
// its arg into the page context, so the function body must not close over
|
|
425
|
+
// outer variables. Only registered when the scenario actually carries
|
|
426
|
+
// storage, preserving the pre-storage capture context for everyone else.
|
|
427
|
+
const localStorageSeed = state.localStorage || {};
|
|
428
|
+
const sessionStorageSeed = state.sessionStorage || {};
|
|
429
|
+
if (
|
|
430
|
+
Object.keys(localStorageSeed).length > 0 ||
|
|
431
|
+
Object.keys(sessionStorageSeed).length > 0
|
|
432
|
+
) {
|
|
433
|
+
await context.addInitScript(
|
|
434
|
+
(storage) => {
|
|
435
|
+
try {
|
|
436
|
+
for (const [key, value] of Object.entries(storage.local)) {
|
|
437
|
+
window.localStorage.setItem(key, value);
|
|
438
|
+
}
|
|
439
|
+
for (const [key, value] of Object.entries(storage.session)) {
|
|
440
|
+
window.sessionStorage.setItem(key, value);
|
|
441
|
+
}
|
|
442
|
+
} catch (_) {
|
|
443
|
+
// Storage unavailable (sandboxed/opaque origin) — the seed is
|
|
444
|
+
// best-effort; never fail the capture over it.
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
{ local: localStorageSeed, session: sessionStorageSeed },
|
|
448
|
+
);
|
|
449
|
+
}
|
|
238
450
|
}
|
|
239
451
|
|
|
240
452
|
// Emit a `redirect-mismatch` issue when the final iframe URL's path
|
|
@@ -275,11 +487,240 @@ function pushRedirectMismatchIssue(issues, requestedUrl, frame, response, config
|
|
|
275
487
|
);
|
|
276
488
|
}
|
|
277
489
|
|
|
490
|
+
// Read-only page-state snapshot for `capture-state`: the full localStorage
|
|
491
|
+
// map, a bounded sample of visible text nodes (document order), and — when a
|
|
492
|
+
// selector is given — that element's text. Evaluated in-page against the
|
|
493
|
+
// settled frame so it reflects exactly what a real capture saw (the proxy
|
|
494
|
+
// already injected the scenario's seed into the served HTML). Every read is
|
|
495
|
+
// individually guarded so a sandboxed/cross-origin localStorage never throws
|
|
496
|
+
// the whole capture; the worst case is an empty section, not a failure.
|
|
497
|
+
async function dumpPageState(frame, selector) {
|
|
498
|
+
return frame.evaluate((sel) => {
|
|
499
|
+
const localStorage = {};
|
|
500
|
+
try {
|
|
501
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
502
|
+
const key = window.localStorage.key(i);
|
|
503
|
+
if (key != null) localStorage[key] = window.localStorage.getItem(key);
|
|
504
|
+
}
|
|
505
|
+
} catch (_) {
|
|
506
|
+
/* localStorage may be unavailable (sandboxed/opaque origin) */
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const visibleText = [];
|
|
510
|
+
try {
|
|
511
|
+
// Reject text inside non-rendered tags (SCRIPT/STYLE/etc.) so an
|
|
512
|
+
// injected proxy script or inline CSS never masquerades as on-screen
|
|
513
|
+
// text — that noise is exactly what makes a state dump misleading.
|
|
514
|
+
const SKIP_TAGS = new Set([
|
|
515
|
+
"SCRIPT",
|
|
516
|
+
"STYLE",
|
|
517
|
+
"NOSCRIPT",
|
|
518
|
+
"TEMPLATE",
|
|
519
|
+
"HEAD",
|
|
520
|
+
"TITLE",
|
|
521
|
+
]);
|
|
522
|
+
const walker = document.createTreeWalker(
|
|
523
|
+
document.body,
|
|
524
|
+
NodeFilter.SHOW_TEXT,
|
|
525
|
+
{
|
|
526
|
+
acceptNode(node) {
|
|
527
|
+
const parent = node.parentElement;
|
|
528
|
+
if (parent && SKIP_TAGS.has(parent.tagName)) {
|
|
529
|
+
return NodeFilter.FILTER_REJECT;
|
|
530
|
+
}
|
|
531
|
+
return (node.textContent || "").trim()
|
|
532
|
+
? NodeFilter.FILTER_ACCEPT
|
|
533
|
+
: NodeFilter.FILTER_REJECT;
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
);
|
|
537
|
+
let node;
|
|
538
|
+
while ((node = walker.nextNode()) && visibleText.length < 40) {
|
|
539
|
+
const text = (node.textContent || "").replace(/\s+/g, " ").trim();
|
|
540
|
+
if (text) visibleText.push(text);
|
|
541
|
+
}
|
|
542
|
+
} catch (_) {
|
|
543
|
+
/* no body / detached document */
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
let selectorText = null;
|
|
547
|
+
if (sel) {
|
|
548
|
+
try {
|
|
549
|
+
const el = document.querySelector(sel);
|
|
550
|
+
if (el) selectorText = (el.textContent || "").replace(/\s+/g, " ").trim();
|
|
551
|
+
} catch (_) {
|
|
552
|
+
/* invalid selector — leave selectorText null */
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return { localStorage, visibleText, selectorText };
|
|
557
|
+
}, selector || null);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Landed-state verification: assert the localStorage the capture INJECTED
|
|
561
|
+
// (`config.browserState.localStorage`, which already carries the seed-session
|
|
562
|
+
// overlay the editor merged in) actually reached the capture browser after the
|
|
563
|
+
// page settled. The core promise of a seeded scenario is remote control of the
|
|
564
|
+
// app — a seed that silently doesn't land produces an empty screenshot that
|
|
565
|
+
// looks like a successful capture of an empty app, which is exactly the
|
|
566
|
+
// failure this guards. Returns a loud `seed-not-landed` issue (which fails the
|
|
567
|
+
// capture, since `ok` requires zero issues) when a non-empty injected seed is
|
|
568
|
+
// missing/empty on read-back, or `null` when there was nothing to verify, the
|
|
569
|
+
// seed landed, or storage is unavailable (sandboxed/opaque origin — never fail
|
|
570
|
+
// the capture over the verifier itself).
|
|
571
|
+
async function verifySeededStorageLanded(frame, config) {
|
|
572
|
+
const expected =
|
|
573
|
+
(config && config.browserState && config.browserState.localStorage) || {};
|
|
574
|
+
const expectedKeys = Object.keys(expected);
|
|
575
|
+
if (expectedKeys.length === 0) return null; // nothing was seeded into storage
|
|
576
|
+
|
|
577
|
+
let readback;
|
|
578
|
+
try {
|
|
579
|
+
readback = await frame.evaluate((keys) => {
|
|
580
|
+
try {
|
|
581
|
+
const allKeys = [];
|
|
582
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
583
|
+
const k = window.localStorage.key(i);
|
|
584
|
+
if (k != null) allKeys.push(k);
|
|
585
|
+
}
|
|
586
|
+
const present = {};
|
|
587
|
+
for (const k of keys) {
|
|
588
|
+
const v = window.localStorage.getItem(k);
|
|
589
|
+
present[k] = v != null && v !== "";
|
|
590
|
+
}
|
|
591
|
+
return { present, allKeys };
|
|
592
|
+
} catch (_) {
|
|
593
|
+
// localStorage unavailable (sandboxed/opaque origin) — can't verify.
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
}, expectedKeys);
|
|
597
|
+
} catch (_) {
|
|
598
|
+
return null; // evaluate failed — never fail a capture over the verifier.
|
|
599
|
+
}
|
|
600
|
+
if (!readback) return null; // storage unavailable — best-effort, don't fail.
|
|
601
|
+
|
|
602
|
+
const missing = expectedKeys.filter((k) => !readback.present[k]);
|
|
603
|
+
if (missing.length === 0) return null; // every seeded key landed — success.
|
|
604
|
+
|
|
605
|
+
const actual = readback.allKeys.length ? readback.allKeys.join(", ") : "<none>";
|
|
606
|
+
return createIssue(
|
|
607
|
+
"seed-not-landed",
|
|
608
|
+
`Seeded localStorage did not reach the capture browser: expected non-empty ` +
|
|
609
|
+
`keys [${expectedKeys.join(", ")}] but [${missing.join(", ")}] are ` +
|
|
610
|
+
`missing/empty after load (browser localStorage holds: [${actual}]). The ` +
|
|
611
|
+
`seed did NOT land, so this screenshot shows DEFAULT/EMPTY state — fix the ` +
|
|
612
|
+
`seed; do not delete the scenario. Likely causes: (1) the seed-session ` +
|
|
613
|
+
`overlay was stale or empty (re-run the seed adapter to refresh it), (2) the ` +
|
|
614
|
+
`adapter's stdout localStorage map failed to parse, or (3) the injection ` +
|
|
615
|
+
`path is down (the seeded origin differs from the captured page's origin).`,
|
|
616
|
+
{ url: (frame && frame.url && frame.url()) || (config && config.url) },
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Drive an ordered list of flow steps against ONE already-loaded browser
|
|
621
|
+
// session so a scripted multi-step demo (`editor preview-flow`) is captured as
|
|
622
|
+
// the real round-trip — click state and client transients persist across
|
|
623
|
+
// steps, which N independent fresh-load captures could never reproduce. Each
|
|
624
|
+
// step is one of:
|
|
625
|
+
// - navigate: re-load a route (resolved relative to the initial url) using
|
|
626
|
+
// the same loader strategy as the initial load, then re-settle. Returns
|
|
627
|
+
// the new content frame so subsequent steps target the navigated page.
|
|
628
|
+
// - click / fill / press: a `performInteraction` against the current frame,
|
|
629
|
+
// then re-settle.
|
|
630
|
+
// - waitFor: hold until a visible-text / selector predicate (bounded).
|
|
631
|
+
// - capture: write a numbered filmstrip frame to the step's `outputPath`.
|
|
632
|
+
// A failing step THROWS with its 1-based index and action, so the outer catch
|
|
633
|
+
// in `runScenarioCheck` reports exactly which step broke (and, for waitFor,
|
|
634
|
+
// the predicate that never appeared) instead of a silent blank capture.
|
|
635
|
+
// Returns the frame the flow ended on, so the caller's final screenshot and
|
|
636
|
+
// result URL reflect the last navigated route.
|
|
637
|
+
async function runFlowSteps(page, initialFrame, steps, ctx) {
|
|
638
|
+
const { url, loadingMarkers, navigation, iframeBackground, preflight, harnessOrigin } =
|
|
639
|
+
ctx;
|
|
640
|
+
let frame = initialFrame;
|
|
641
|
+
|
|
642
|
+
for (let i = 0; i < steps.length; i++) {
|
|
643
|
+
const step = steps[i] || {};
|
|
644
|
+
const n = i + 1;
|
|
645
|
+
try {
|
|
646
|
+
switch (step.action) {
|
|
647
|
+
case "navigate": {
|
|
648
|
+
const target = new URL(step.path, url).href;
|
|
649
|
+
const loadResult =
|
|
650
|
+
navigation === "topLevel"
|
|
651
|
+
? await loadScenarioTopLevel(page, target, { preflight })
|
|
652
|
+
: await loadScenarioInIframe(page, target, {
|
|
653
|
+
background: iframeBackground,
|
|
654
|
+
preflight,
|
|
655
|
+
harnessOrigin,
|
|
656
|
+
});
|
|
657
|
+
frame = loadResult.frame;
|
|
658
|
+
await waitForStablePage(page, frame, 10000, loadingMarkers);
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
case "click":
|
|
662
|
+
case "fill":
|
|
663
|
+
case "press":
|
|
664
|
+
await performInteraction(frame, step);
|
|
665
|
+
await waitForStablePage(page, frame, 5000, loadingMarkers);
|
|
666
|
+
break;
|
|
667
|
+
case "waitFor":
|
|
668
|
+
await waitForPredicate(frame, step);
|
|
669
|
+
break;
|
|
670
|
+
case "capture":
|
|
671
|
+
if (step.outputPath) {
|
|
672
|
+
fs.mkdirSync(path.dirname(step.outputPath), { recursive: true });
|
|
673
|
+
await page.screenshot({ path: step.outputPath, fullPage: false });
|
|
674
|
+
}
|
|
675
|
+
break;
|
|
676
|
+
default:
|
|
677
|
+
throw new Error(
|
|
678
|
+
`unknown step action "${step.action}" (expected navigate | click | fill | press | waitFor | capture)`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
} catch (error) {
|
|
682
|
+
throw new Error(
|
|
683
|
+
`flow step ${n} (${step.action}) failed: ${error.message || String(error)}`,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return frame;
|
|
689
|
+
}
|
|
690
|
+
|
|
278
691
|
// `preflight` is injectable (defaulting to the real app-port reachability
|
|
279
692
|
// check) so unit tests that mock the browser can stay network-free.
|
|
280
|
-
|
|
693
|
+
// `harnessOrigin` is likewise injectable: it defaults to resolving the editor's
|
|
694
|
+
// harness origin from `.codeyam/server-state.json`, but a test passes an
|
|
695
|
+
// explicit value (e.g. `null` to force the legacy `setContent` harness) so the
|
|
696
|
+
// iframe-load path never silently depends on a server-state file in cwd.
|
|
697
|
+
// Resolved ONCE here and threaded into every iframe load below.
|
|
698
|
+
async function runScenarioCheck(
|
|
699
|
+
config,
|
|
700
|
+
{ preflight = assertAppPortReachable, harnessOrigin } = {},
|
|
701
|
+
) {
|
|
702
|
+
const resolvedHarnessOrigin =
|
|
703
|
+
harnessOrigin !== undefined ? harnessOrigin : resolveHarnessOrigin();
|
|
281
704
|
const { url, outputPath, width, height, httpMocks = {} } = config;
|
|
705
|
+
// Per-scenario capture-check allowances (see `CaptureAllowances` /
|
|
706
|
+
// `ScenarioDefinition` on the Rust side). Default false so the guards keep
|
|
707
|
+
// their strict behavior for every scenario that does not opt in.
|
|
708
|
+
const allowMinimalRender = !!(config && config.allowMinimalRender);
|
|
709
|
+
const expectedConsoleErrors = !!(config && config.expectedConsoleErrors);
|
|
710
|
+
let interactionEffect = null;
|
|
711
|
+
let interactionRetried = false;
|
|
282
712
|
const issues = [];
|
|
713
|
+
// Origin of the captured page, used to classify failed requests: only a
|
|
714
|
+
// failure on the page's OWN origin should fail the capture (see
|
|
715
|
+
// `isCaptureFatalRequestFailure`). Derived from `config.url` exactly as
|
|
716
|
+
// `applyBrowserState` does; a malformed URL leaves it null, which keeps every
|
|
717
|
+
// request failure fatal (today's behavior).
|
|
718
|
+
let appOrigin = null;
|
|
719
|
+
try {
|
|
720
|
+
appOrigin = new URL(url).origin;
|
|
721
|
+
} catch (_) {
|
|
722
|
+
/* malformed capture URL — the guard no-ops and failures stay fatal */
|
|
723
|
+
}
|
|
283
724
|
const browser = await launchChromiumWithSelfHeal();
|
|
284
725
|
const contextOptions = {
|
|
285
726
|
viewport: { width: width || 1440, height: height || 900 },
|
|
@@ -292,6 +733,13 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
292
733
|
if (config.timezoneId) contextOptions.timezoneId = config.timezoneId;
|
|
293
734
|
if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion;
|
|
294
735
|
if (config.forcedColors) contextOptions.forcedColors = config.forcedColors;
|
|
736
|
+
// When the opt-in HTTPS preview (`proxy.httpsPreview`) is on, the capture
|
|
737
|
+
// origin is `https://localhost:<port>` served by the reverse proxy's
|
|
738
|
+
// self-signed cert. Accept it for capture only — gated on the https origin so
|
|
739
|
+
// plain-HTTP captures keep full TLS-error fidelity.
|
|
740
|
+
if (typeof appOrigin === "string" && appOrigin.startsWith("https://")) {
|
|
741
|
+
contextOptions.ignoreHTTPSErrors = true;
|
|
742
|
+
}
|
|
295
743
|
const context = await browser.newContext(contextOptions);
|
|
296
744
|
|
|
297
745
|
// Apply the scenario's merged `browserState` (cookies + request
|
|
@@ -302,8 +750,11 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
302
750
|
// not redirect to `/login` when the proxy is bypassed.
|
|
303
751
|
await applyBrowserState(context, config);
|
|
304
752
|
|
|
305
|
-
// Context-level init script runs in ALL frames (including cross-origin iframes)
|
|
306
|
-
|
|
753
|
+
// Context-level init script runs in ALL frames (including cross-origin iframes).
|
|
754
|
+
// Keep the real WebSocket for scenarios that script a `/ws/terminal`
|
|
755
|
+
// transcript / WS stream so the scripted agent state reaches the capture;
|
|
756
|
+
// every other capture gets the reconnect-silencing stub.
|
|
757
|
+
await context.addInitScript(getInitScript(scenarioScriptsLiveSocket(config)));
|
|
307
758
|
|
|
308
759
|
const page = await context.newPage();
|
|
309
760
|
// Attach the network tracker BEFORE navigation so every request (the document
|
|
@@ -319,12 +770,42 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
319
770
|
|
|
320
771
|
page.on("console", (message) => {
|
|
321
772
|
const issue = handleConsoleMessage(message);
|
|
322
|
-
if (issue)
|
|
323
|
-
|
|
324
|
-
|
|
773
|
+
if (!issue) return;
|
|
774
|
+
// Per-scenario allowance: a scenario that provokes console errors by design
|
|
775
|
+
// (a broken-image fallback whose `<img>` is meant to 404) opts in via
|
|
776
|
+
// `expectedConsoleErrors`. Load-bearing: without it the console-error guard
|
|
777
|
+
// fails the capture instead of screenshotting the intended fallback UI. A
|
|
778
|
+
// non-opted-in scenario still fails, so an unexpected console error is never
|
|
779
|
+
// silently tolerated.
|
|
780
|
+
if (expectedConsoleErrors) return;
|
|
781
|
+
// Console errors produced by the scenario's OWN declared error mocks
|
|
782
|
+
// (status >= 400) are the intended behavior of an error-state scenario,
|
|
783
|
+
// not a capture problem — skip them so "History - Load Error"-style
|
|
784
|
+
// scenarios can screenshot the failure UI they exist to demonstrate.
|
|
785
|
+
const sourceUrl = message.location && message.location().url;
|
|
786
|
+
if (sourceUrl && isDeclaredErrorMock(httpMocks, sourceUrl)) return;
|
|
787
|
+
// A console error originating from a cross-origin resource — the "Failed to
|
|
788
|
+
// load resource" Chromium logs alongside a cross-origin requestfailed (the
|
|
789
|
+
// down app dev port, an external CDN) — is an external-resource failure, not
|
|
790
|
+
// an editor-shell problem. Tolerate it for the same reason, and via the same
|
|
791
|
+
// predicate, as the requestfailed handler below.
|
|
792
|
+
if (sourceUrl && !isCaptureFatalRequestFailure(sourceUrl, appOrigin)) return;
|
|
793
|
+
pushIssue(issues, issue);
|
|
325
794
|
});
|
|
326
795
|
|
|
327
796
|
page.on("requestfailed", (request) => {
|
|
797
|
+
// Per-scenario allowance: the broken-image fallback scenario's `<img>` 404
|
|
798
|
+
// is a SAME-origin request failure it exists to demonstrate, so
|
|
799
|
+
// `expectedConsoleErrors` tolerates it here too (the console guard above
|
|
800
|
+
// relaxes the paired "Failed to load resource" error). Load-bearing for the
|
|
801
|
+
// same reason; a non-opted-in same-origin failure still fails the capture.
|
|
802
|
+
if (expectedConsoleErrors) return;
|
|
803
|
+
// A cross-origin sub-resource failing must not fail an editor-shell
|
|
804
|
+
// screenshot — only the captured page's OWN origin counts. The most common
|
|
805
|
+
// case here is the live preview pane reaching the mocked project's app dev
|
|
806
|
+
// port (e.g. http://localhost:3000), which is not running in the
|
|
807
|
+
// self-hosting capture container.
|
|
808
|
+
if (!isCaptureFatalRequestFailure(request.url(), appOrigin)) return;
|
|
328
809
|
const issue = handleRequestFailed(request);
|
|
329
810
|
if (issue) {
|
|
330
811
|
pushIssue(issues, issue);
|
|
@@ -340,13 +821,18 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
340
821
|
// iframe harness for its background/sizing control. The backend signals
|
|
341
822
|
// the choice via `config.navigation` ("topLevel"); absent (the default)
|
|
342
823
|
// means the iframe harness, so existing callers are unchanged.
|
|
343
|
-
const
|
|
824
|
+
const loadResult =
|
|
344
825
|
config.navigation === "topLevel"
|
|
345
826
|
? await loadScenarioTopLevel(page, url, { preflight })
|
|
346
827
|
: await loadScenarioInIframe(page, url, {
|
|
347
828
|
background: config.iframeBackground,
|
|
348
829
|
preflight,
|
|
830
|
+
harnessOrigin: resolvedHarnessOrigin,
|
|
349
831
|
});
|
|
832
|
+
// `frame` is `let` so a `navigate` flow step (below) can re-point it at the
|
|
833
|
+
// freshly-loaded route's content frame; `response` is the initial load only.
|
|
834
|
+
let frame = loadResult.frame;
|
|
835
|
+
const response = loadResult.response;
|
|
350
836
|
loaded = true;
|
|
351
837
|
|
|
352
838
|
if (response && response.status() >= 400) {
|
|
@@ -367,14 +853,34 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
367
853
|
const loadingMarkers = Array.isArray(config.loadingMarkers)
|
|
368
854
|
? config.loadingMarkers
|
|
369
855
|
: readStackLoadingMarkers();
|
|
370
|
-
|
|
856
|
+
// Content-stability window. Defaults to 10s; the editor's own WS-driven
|
|
857
|
+
// terminal/build route under self-hosting passes a longer `stableTimeoutMs`
|
|
858
|
+
// (its network never goes idle, so the default expired before the WS replay
|
|
859
|
+
// landed and the capture came back blank — VM1). Every other route omits the
|
|
860
|
+
// field and keeps the 10s default.
|
|
861
|
+
const stableTimeoutMs =
|
|
862
|
+
typeof config.stableTimeoutMs === "number" && config.stableTimeoutMs > 0
|
|
863
|
+
? config.stableTimeoutMs
|
|
864
|
+
: 10000;
|
|
865
|
+
const stableOutcome = await waitForStablePage(
|
|
866
|
+
page,
|
|
867
|
+
frame,
|
|
868
|
+
stableTimeoutMs,
|
|
869
|
+
loadingMarkers,
|
|
870
|
+
);
|
|
371
871
|
|
|
372
872
|
// DOM-stable does not mean done: a client-side data fetch can still be in
|
|
373
873
|
// flight (the loading skeleton cleared but its replacement content hasn't
|
|
374
874
|
// landed). Wait for the network to go quiet — bounded, so a streaming /
|
|
375
875
|
// long-poll endpoint that never idles caps out and captures anyway rather
|
|
376
876
|
// than hanging.
|
|
377
|
-
await waitForNetworkQuiet(networkTracker);
|
|
877
|
+
const networkOutcome = await waitForNetworkQuiet(networkTracker);
|
|
878
|
+
|
|
879
|
+
// If the page never settled (a loading marker outlasted the wait) or the
|
|
880
|
+
// network never went quiet, the screenshot likely caught a client-fetched
|
|
881
|
+
// page mid-load. Compute the non-blocking advisory now, while both settle
|
|
882
|
+
// signals are in hand, and surface it on the capture-state report below.
|
|
883
|
+
const settleAdvisory = buildSettleAdvisory(stableOutcome, networkOutcome);
|
|
378
884
|
|
|
379
885
|
const rejectionMessages = await frame.evaluate(
|
|
380
886
|
() => window.__codeyamUnhandledRejections || [],
|
|
@@ -388,6 +894,23 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
388
894
|
);
|
|
389
895
|
}
|
|
390
896
|
|
|
897
|
+
// Reveal-suppression fix: scroll-gated entrance animations (content held at
|
|
898
|
+
// `opacity:0` until an IntersectionObserver fires on scroll) render blank in
|
|
899
|
+
// isolation, where the app shell that wires those observers is absent.
|
|
900
|
+
// Emulate reduced motion — so reduced-motion-aware stylesheets drop their
|
|
901
|
+
// entrance animations and render the final state — and scroll the document
|
|
902
|
+
// end-to-end to trip every observer, so the content reveals BEFORE we
|
|
903
|
+
// measure and screenshot it, with no per-project force-reveal script. Both
|
|
904
|
+
// are best-effort: a page/frame that cannot emulate or scroll (a
|
|
905
|
+
// backend/static capture, a stubbed test target) is a no-op.
|
|
906
|
+
if (typeof page.emulateMedia === "function") {
|
|
907
|
+
await page.emulateMedia({ reducedMotion: "reduce" }).catch(() => {});
|
|
908
|
+
}
|
|
909
|
+
await scrollThroughDocument(frame).catch(() => {});
|
|
910
|
+
if (typeof page.waitForTimeout === "function") {
|
|
911
|
+
await page.waitForTimeout(REVEAL_SETTLE_MS);
|
|
912
|
+
}
|
|
913
|
+
|
|
391
914
|
// Cold-start retry: a single re-collect after BLANK_RETRY_DELAY_MS covers
|
|
392
915
|
// the React.lazy / Suspense-fallback race where waitForStablePage settles
|
|
393
916
|
// on the still-empty `<div id="root">` shell before the dynamic chunk
|
|
@@ -395,13 +918,27 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
395
918
|
// chunk-load window; only a genuinely blank page falls through to the
|
|
396
919
|
// blank issue below.
|
|
397
920
|
let contentState = await collectContentState(frame);
|
|
921
|
+
await mergeVisibleTextLength(contentState, frame);
|
|
398
922
|
let hasContent = hasRenderableContent(contentState);
|
|
399
923
|
if (!hasContent) {
|
|
400
924
|
await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS));
|
|
401
925
|
contentState = await collectContentState(frame);
|
|
926
|
+
await mergeVisibleTextLength(contentState, frame);
|
|
402
927
|
hasContent = hasRenderableContent(contentState);
|
|
403
928
|
}
|
|
404
929
|
|
|
930
|
+
// Per-scenario allowance: a scenario whose intended UI is intrinsically
|
|
931
|
+
// minimal (an empty `<textarea>` the blank heuristic can't "see") opts in
|
|
932
|
+
// via `allowMinimalRender`. Accept the near-blank render as valid content so
|
|
933
|
+
// BOTH the blank issue below is skipped AND `buildResult`'s `ok` (which
|
|
934
|
+
// independently requires `hasContent`) can be true — otherwise the capture
|
|
935
|
+
// would still fail with an empty issue list. The cold-start retry already
|
|
936
|
+
// ran above, so a non-opted-in blank (a genuinely empty render) still falls
|
|
937
|
+
// through to the issue below.
|
|
938
|
+
if (!hasContent && allowMinimalRender) {
|
|
939
|
+
hasContent = true;
|
|
940
|
+
}
|
|
941
|
+
|
|
405
942
|
if (!hasContent) {
|
|
406
943
|
pushIssue(
|
|
407
944
|
issues,
|
|
@@ -413,16 +950,29 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
413
950
|
);
|
|
414
951
|
}
|
|
415
952
|
|
|
416
|
-
// Check for
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
953
|
+
// Check for codeyam's scenario-renderer error fallback. We anchor on the
|
|
954
|
+
// DOM marker the ScenarioRenderer stamps on its error / seed-error frames
|
|
955
|
+
// rather than scanning the whole page body for ERROR_PATTERNS — a
|
|
956
|
+
// legitimately-mocked scenario whose content quotes one of those harness
|
|
957
|
+
// phrases must not be flagged. Apps with no codeyam ScenarioRenderer never
|
|
958
|
+
// emit the marker, so their pages are treated as healthy regardless of body
|
|
959
|
+
// text. The scan is scoped to the marked element's own text.
|
|
960
|
+
const errorMarker = await frame.evaluate((attr) => {
|
|
961
|
+
const el = document.querySelector(`[${attr}]`);
|
|
962
|
+
if (!el) return null;
|
|
963
|
+
return {
|
|
964
|
+
reason: el.getAttribute(attr) || "",
|
|
965
|
+
text: el.innerText || el.textContent || "",
|
|
966
|
+
};
|
|
967
|
+
}, SCENARIO_ERROR_MARKER);
|
|
968
|
+
const scenarioError = findScenarioError(errorMarker);
|
|
969
|
+
if (scenarioError) {
|
|
970
|
+
const { matchedPattern, contextSnippet } = scenarioError;
|
|
421
971
|
pushIssue(
|
|
422
972
|
issues,
|
|
423
973
|
createIssue(
|
|
424
974
|
"error-state",
|
|
425
|
-
`Page contains error content (matched "${matchedPattern}"): ${contextSnippet ??
|
|
975
|
+
`Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? ""}`,
|
|
426
976
|
{
|
|
427
977
|
url: page.url() || url,
|
|
428
978
|
matchedPattern,
|
|
@@ -445,14 +995,83 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
445
995
|
pushIssue(issues, hydrationIssue);
|
|
446
996
|
}
|
|
447
997
|
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
if (
|
|
998
|
+
// Assert the injected seed actually landed in the capture browser, at rest
|
|
999
|
+
// and BEFORE any interaction can legitimately mutate storage. A non-empty
|
|
1000
|
+
// seed that didn't reach localStorage means the screenshot will show
|
|
1001
|
+
// default/empty state — fail loudly instead of emitting a misleading frame.
|
|
1002
|
+
const seedNotLandedIssue = await verifySeededStorageLanded(frame, config);
|
|
1003
|
+
if (seedNotLandedIssue) {
|
|
1004
|
+
pushIssue(issues, seedNotLandedIssue);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Scripted multi-step flow (`editor preview-flow`): drive an ordered
|
|
1008
|
+
// sequence of steps in THIS one browser session — so a behavioral
|
|
1009
|
+
// round-trip (click → observe → click) is captured as the real flow, not N
|
|
1010
|
+
// independent fresh-load snapshots. Each `capture` step writes a numbered
|
|
1011
|
+
// filmstrip frame; the others advance the same page. Mutually exclusive
|
|
1012
|
+
// with the single `interaction` path below. `frame` is reassigned so a
|
|
1013
|
+
// `navigate` step's new content frame backs the rest of the flow and the
|
|
1014
|
+
// final result URL.
|
|
1015
|
+
if (Array.isArray(config.steps) && config.steps.length > 0) {
|
|
1016
|
+
frame = await runFlowSteps(page, frame, config.steps, {
|
|
1017
|
+
url,
|
|
1018
|
+
loadingMarkers,
|
|
1019
|
+
navigation: config.navigation,
|
|
1020
|
+
iframeBackground: config.iframeBackground,
|
|
1021
|
+
preflight,
|
|
1022
|
+
harnessOrigin: resolvedHarnessOrigin,
|
|
1023
|
+
});
|
|
1024
|
+
} else if (config.interaction) {
|
|
1025
|
+
// Record fingerprint before interaction
|
|
1026
|
+
const beforeFingerprint = await getDOMFingerprint(frame);
|
|
1027
|
+
|
|
1028
|
+
// Drive the requested interaction (if any) against the settled frame,
|
|
1029
|
+
// then re-settle, so `preview-interact` captures the RESULT of a click /
|
|
1030
|
+
// fill / press (expanded accordion, open modal) without editing app
|
|
1031
|
+
// source. A no-match target throws here and is caught below as a failed
|
|
1032
|
+
// capture with the candidate-labels hint — never a silent blank shot.
|
|
454
1033
|
await performInteraction(frame, config.interaction);
|
|
455
1034
|
await waitForStablePage(page, frame, 5000, loadingMarkers);
|
|
1035
|
+
|
|
1036
|
+
// Record fingerprint after interaction
|
|
1037
|
+
let afterFingerprint = await getDOMFingerprint(frame);
|
|
1038
|
+
|
|
1039
|
+
// If unchanged and it was a click, retry once after 500ms (covers the hydration race)
|
|
1040
|
+
if (beforeFingerprint === afterFingerprint && config.interaction.action === "click") {
|
|
1041
|
+
interactionRetried = true;
|
|
1042
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1043
|
+
await performInteraction(frame, config.interaction);
|
|
1044
|
+
await waitForStablePage(page, frame, 5000, loadingMarkers);
|
|
1045
|
+
afterFingerprint = await getDOMFingerprint(frame);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
interactionEffect = beforeFingerprint === afterFingerprint ? "none" : "changed";
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Replay the scenario's PERSISTED interaction sequence (if any) in order,
|
|
1052
|
+
// settling between steps, so a declared interactive state — an expanded
|
|
1053
|
+
// section, a revealed hover bar, an open modal — is reproduced on every
|
|
1054
|
+
// capture and recapture, not just in a one-off `preview-interact`. A step
|
|
1055
|
+
// that matches nothing throws and is caught below as a failed capture, so a
|
|
1056
|
+
// sequence that didn't fully run never persists a resting-state screenshot.
|
|
1057
|
+
if (Array.isArray(config.interactions) && config.interactions.length > 0) {
|
|
1058
|
+
await performInteractionSequence(page, frame, config.interactions, {
|
|
1059
|
+
settleMs: 5000,
|
|
1060
|
+
loadingMarkers,
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Snap any remaining mid-flight CSS entrance animation to its final frame
|
|
1065
|
+
// for the static shot — but only when the scenario has NOT declared an
|
|
1066
|
+
// interactive state (a single `interaction`, a persisted `interactions`
|
|
1067
|
+
// sequence, or a multi-step `steps` flow), so an intentionally
|
|
1068
|
+
// animated/collapsed interactive frame is never clobbered by the force.
|
|
1069
|
+
const hasDeclaredInteractiveState =
|
|
1070
|
+
!!config.interaction ||
|
|
1071
|
+
(Array.isArray(config.interactions) && config.interactions.length > 0) ||
|
|
1072
|
+
(Array.isArray(config.steps) && config.steps.length > 0);
|
|
1073
|
+
if (outputPath && loaded && !hasDeclaredInteractiveState) {
|
|
1074
|
+
await forceFinalVisualState(frame).catch(() => {});
|
|
456
1075
|
}
|
|
457
1076
|
|
|
458
1077
|
if (outputPath && loaded) {
|
|
@@ -460,13 +1079,34 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
460
1079
|
await page.screenshot({ path: outputPath, fullPage: false });
|
|
461
1080
|
}
|
|
462
1081
|
|
|
463
|
-
|
|
1082
|
+
const result = buildResult({
|
|
464
1083
|
loaded,
|
|
465
1084
|
hasContent,
|
|
466
1085
|
issues,
|
|
467
1086
|
outputPath,
|
|
468
1087
|
url: frame.url() || url,
|
|
469
1088
|
});
|
|
1089
|
+
|
|
1090
|
+
if (config.interaction) {
|
|
1091
|
+
result.interactionEffect = interactionEffect;
|
|
1092
|
+
result.interactionRetried = interactionRetried;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// `capture-state` mode: attach the read-only page-state snapshot so the
|
|
1096
|
+
// backend can report localStorage + rendered text. Off by default, so a
|
|
1097
|
+
// normal error-check capture is byte-for-byte unchanged.
|
|
1098
|
+
if (config.captureState) {
|
|
1099
|
+
result.state = await dumpPageState(frame, config.stateSelector);
|
|
1100
|
+
// A populated state that renders blank is exactly when an agent reaches
|
|
1101
|
+
// for capture-state, so bundle the client-fetch advisory with the dump it
|
|
1102
|
+
// explains. Only when the page didn't settle cleanly — the SSR /
|
|
1103
|
+
// props-driven happy path stays advisory-free.
|
|
1104
|
+
if (settleAdvisory) {
|
|
1105
|
+
result.state.advisories = [settleAdvisory];
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
return result;
|
|
470
1110
|
} catch (error) {
|
|
471
1111
|
pushIssue(
|
|
472
1112
|
issues,
|
|
@@ -506,14 +1146,25 @@ async function main() {
|
|
|
506
1146
|
|
|
507
1147
|
module.exports = {
|
|
508
1148
|
runScenarioCheck,
|
|
1149
|
+
mergeVisibleTextLength,
|
|
1150
|
+
runFlowSteps,
|
|
1151
|
+
dumpPageState,
|
|
1152
|
+
verifySeededStorageLanded,
|
|
509
1153
|
readStackLoadingMarkers,
|
|
1154
|
+
scenarioScriptsLiveSocket,
|
|
510
1155
|
applyBrowserState,
|
|
511
1156
|
isCrossOriginRequest,
|
|
1157
|
+
isCaptureFatalRequestFailure,
|
|
512
1158
|
stripMarkerHeaders,
|
|
513
1159
|
main,
|
|
514
1160
|
launchChromiumWithSelfHeal,
|
|
1161
|
+
isTransientLaunchCrash,
|
|
1162
|
+
CAPTURE_LAUNCH_ARGS,
|
|
1163
|
+
CAPTURE_HOST_RESOLVER_RULES,
|
|
1164
|
+
CAPTURE_LAUNCH_SIGSEGV_RETRIES,
|
|
515
1165
|
PLAYWRIGHT_INSTALL_COMMAND,
|
|
516
1166
|
PLAYWRIGHT_MISSING_BROWSER_PATTERN,
|
|
1167
|
+
PLAYWRIGHT_LAUNCH_CRASH_PATTERNS,
|
|
517
1168
|
};
|
|
518
1169
|
|
|
519
1170
|
if (require.main === module) {
|