@codeyam-editor/codeyam-editor 0.1.4 → 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 +1415 -73
- 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/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +51 -2
- package/npm/fleet-cleanup.js +118 -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 +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +135 -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 +43 -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 +204 -3
- package/npm/scenario-check.js +603 -32
- package/npm/scenario-handlers.js +78 -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 +329 -50
- 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-BuK0lizt.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-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/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
|
|
@@ -345,11 +557,170 @@ async function dumpPageState(frame, selector) {
|
|
|
345
557
|
}, selector || null);
|
|
346
558
|
}
|
|
347
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
|
+
|
|
348
691
|
// `preflight` is injectable (defaulting to the real app-port reachability
|
|
349
692
|
// check) so unit tests that mock the browser can stay network-free.
|
|
350
|
-
|
|
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();
|
|
351
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;
|
|
352
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
|
+
}
|
|
353
724
|
const browser = await launchChromiumWithSelfHeal();
|
|
354
725
|
const contextOptions = {
|
|
355
726
|
viewport: { width: width || 1440, height: height || 900 },
|
|
@@ -362,6 +733,13 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
362
733
|
if (config.timezoneId) contextOptions.timezoneId = config.timezoneId;
|
|
363
734
|
if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion;
|
|
364
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
|
+
}
|
|
365
743
|
const context = await browser.newContext(contextOptions);
|
|
366
744
|
|
|
367
745
|
// Apply the scenario's merged `browserState` (cookies + request
|
|
@@ -372,8 +750,11 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
372
750
|
// not redirect to `/login` when the proxy is bypassed.
|
|
373
751
|
await applyBrowserState(context, config);
|
|
374
752
|
|
|
375
|
-
// Context-level init script runs in ALL frames (including cross-origin iframes)
|
|
376
|
-
|
|
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)));
|
|
377
758
|
|
|
378
759
|
const page = await context.newPage();
|
|
379
760
|
// Attach the network tracker BEFORE navigation so every request (the document
|
|
@@ -389,12 +770,42 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
389
770
|
|
|
390
771
|
page.on("console", (message) => {
|
|
391
772
|
const issue = handleConsoleMessage(message);
|
|
392
|
-
if (issue)
|
|
393
|
-
|
|
394
|
-
|
|
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);
|
|
395
794
|
});
|
|
396
795
|
|
|
397
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;
|
|
398
809
|
const issue = handleRequestFailed(request);
|
|
399
810
|
if (issue) {
|
|
400
811
|
pushIssue(issues, issue);
|
|
@@ -410,13 +821,18 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
410
821
|
// iframe harness for its background/sizing control. The backend signals
|
|
411
822
|
// the choice via `config.navigation` ("topLevel"); absent (the default)
|
|
412
823
|
// means the iframe harness, so existing callers are unchanged.
|
|
413
|
-
const
|
|
824
|
+
const loadResult =
|
|
414
825
|
config.navigation === "topLevel"
|
|
415
826
|
? await loadScenarioTopLevel(page, url, { preflight })
|
|
416
827
|
: await loadScenarioInIframe(page, url, {
|
|
417
828
|
background: config.iframeBackground,
|
|
418
829
|
preflight,
|
|
830
|
+
harnessOrigin: resolvedHarnessOrigin,
|
|
419
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;
|
|
420
836
|
loaded = true;
|
|
421
837
|
|
|
422
838
|
if (response && response.status() >= 400) {
|
|
@@ -437,14 +853,34 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
437
853
|
const loadingMarkers = Array.isArray(config.loadingMarkers)
|
|
438
854
|
? config.loadingMarkers
|
|
439
855
|
: readStackLoadingMarkers();
|
|
440
|
-
|
|
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
|
+
);
|
|
441
871
|
|
|
442
872
|
// DOM-stable does not mean done: a client-side data fetch can still be in
|
|
443
873
|
// flight (the loading skeleton cleared but its replacement content hasn't
|
|
444
874
|
// landed). Wait for the network to go quiet — bounded, so a streaming /
|
|
445
875
|
// long-poll endpoint that never idles caps out and captures anyway rather
|
|
446
876
|
// than hanging.
|
|
447
|
-
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);
|
|
448
884
|
|
|
449
885
|
const rejectionMessages = await frame.evaluate(
|
|
450
886
|
() => window.__codeyamUnhandledRejections || [],
|
|
@@ -458,6 +894,23 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
458
894
|
);
|
|
459
895
|
}
|
|
460
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
|
+
|
|
461
914
|
// Cold-start retry: a single re-collect after BLANK_RETRY_DELAY_MS covers
|
|
462
915
|
// the React.lazy / Suspense-fallback race where waitForStablePage settles
|
|
463
916
|
// on the still-empty `<div id="root">` shell before the dynamic chunk
|
|
@@ -465,13 +918,27 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
465
918
|
// chunk-load window; only a genuinely blank page falls through to the
|
|
466
919
|
// blank issue below.
|
|
467
920
|
let contentState = await collectContentState(frame);
|
|
921
|
+
await mergeVisibleTextLength(contentState, frame);
|
|
468
922
|
let hasContent = hasRenderableContent(contentState);
|
|
469
923
|
if (!hasContent) {
|
|
470
924
|
await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS));
|
|
471
925
|
contentState = await collectContentState(frame);
|
|
926
|
+
await mergeVisibleTextLength(contentState, frame);
|
|
472
927
|
hasContent = hasRenderableContent(contentState);
|
|
473
928
|
}
|
|
474
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
|
+
|
|
475
942
|
if (!hasContent) {
|
|
476
943
|
pushIssue(
|
|
477
944
|
issues,
|
|
@@ -483,16 +950,29 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
483
950
|
);
|
|
484
951
|
}
|
|
485
952
|
|
|
486
|
-
// Check for
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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;
|
|
491
971
|
pushIssue(
|
|
492
972
|
issues,
|
|
493
973
|
createIssue(
|
|
494
974
|
"error-state",
|
|
495
|
-
`Page contains error content (matched "${matchedPattern}"): ${contextSnippet ??
|
|
975
|
+
`Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? ""}`,
|
|
496
976
|
{
|
|
497
977
|
url: page.url() || url,
|
|
498
978
|
matchedPattern,
|
|
@@ -515,14 +995,83 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
515
995
|
pushIssue(issues, hydrationIssue);
|
|
516
996
|
}
|
|
517
997
|
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
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.
|
|
524
1033
|
await performInteraction(frame, config.interaction);
|
|
525
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(() => {});
|
|
526
1075
|
}
|
|
527
1076
|
|
|
528
1077
|
if (outputPath && loaded) {
|
|
@@ -538,11 +1087,23 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
538
1087
|
url: frame.url() || url,
|
|
539
1088
|
});
|
|
540
1089
|
|
|
1090
|
+
if (config.interaction) {
|
|
1091
|
+
result.interactionEffect = interactionEffect;
|
|
1092
|
+
result.interactionRetried = interactionRetried;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
541
1095
|
// `capture-state` mode: attach the read-only page-state snapshot so the
|
|
542
1096
|
// backend can report localStorage + rendered text. Off by default, so a
|
|
543
1097
|
// normal error-check capture is byte-for-byte unchanged.
|
|
544
1098
|
if (config.captureState) {
|
|
545
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
|
+
}
|
|
546
1107
|
}
|
|
547
1108
|
|
|
548
1109
|
return result;
|
|
@@ -585,15 +1146,25 @@ async function main() {
|
|
|
585
1146
|
|
|
586
1147
|
module.exports = {
|
|
587
1148
|
runScenarioCheck,
|
|
1149
|
+
mergeVisibleTextLength,
|
|
1150
|
+
runFlowSteps,
|
|
588
1151
|
dumpPageState,
|
|
1152
|
+
verifySeededStorageLanded,
|
|
589
1153
|
readStackLoadingMarkers,
|
|
1154
|
+
scenarioScriptsLiveSocket,
|
|
590
1155
|
applyBrowserState,
|
|
591
1156
|
isCrossOriginRequest,
|
|
1157
|
+
isCaptureFatalRequestFailure,
|
|
592
1158
|
stripMarkerHeaders,
|
|
593
1159
|
main,
|
|
594
1160
|
launchChromiumWithSelfHeal,
|
|
1161
|
+
isTransientLaunchCrash,
|
|
1162
|
+
CAPTURE_LAUNCH_ARGS,
|
|
1163
|
+
CAPTURE_HOST_RESOLVER_RULES,
|
|
1164
|
+
CAPTURE_LAUNCH_SIGSEGV_RETRIES,
|
|
595
1165
|
PLAYWRIGHT_INSTALL_COMMAND,
|
|
596
1166
|
PLAYWRIGHT_MISSING_BROWSER_PATTERN,
|
|
1167
|
+
PLAYWRIGHT_LAUNCH_CRASH_PATTERNS,
|
|
597
1168
|
};
|
|
598
1169
|
|
|
599
1170
|
if (require.main === module) {
|