@codeyam-editor/codeyam-editor 0.1.15 → 0.1.16

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.
@@ -11,29 +11,7 @@
11
11
 
12
12
  const fs = require("fs");
13
13
  const path = require("path");
14
- const { execSync } = require("child_process");
15
- const { chromium } = require("playwright");
16
-
17
- // Substring Playwright has emitted across every 1.x release when the
18
- // browser cache is empty. Match the substring (not the full string)
19
- // because Playwright includes the offending path inline.
20
- const PLAYWRIGHT_MISSING_BROWSER_PATTERN = "Executable doesn't exist";
21
- const PLAYWRIGHT_INSTALL_COMMAND = "npx playwright install chromium";
22
-
23
- // 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;
14
+ const { launchChromiumWithSelfHeal } = require("./scenario-launch");
37
15
 
38
16
  // The sentinel the reverse proxy stamps on its dev-server-down placeholder —
39
17
  // the other half of this contract is
@@ -44,306 +22,6 @@ const CAPTURE_LAUNCH_SIGSEGV_BACKOFF_MS = 250;
44
22
  // the moment `render_placeholder` is reworded.
45
23
  const DEV_SERVER_DOWN_HEADER = "x-codeyam-dev-server-down";
46
24
 
47
- // Pin the headless capture browser's `localhost` resolution to the IPv4
48
- // loopback the editor's listeners bind. The browser-facing preview origin is
49
- // now `localhost` (secure-context apps refuse a bare IP — see
50
- // `BROWSER_FACING_HOST`), but on a dual-stack host `localhost` can resolve to
51
- // `::1` first, which nothing answers on — stalling the capture or paying a
52
- // happy-eyeballs fallback delay. Forcing the map removes that intermittency
53
- // deterministically for capture. The operator's OWN preview browser is covered
54
- // separately by the editor binding `::1` alongside `127.0.0.1` (see start.rs).
55
- const CAPTURE_HOST_RESOLVER_RULES = "MAP localhost 127.0.0.1";
56
- const CAPTURE_LAUNCH_ARGS = [
57
- `--host-resolver-rules=${CAPTURE_HOST_RESOLVER_RULES}`,
58
- // Cloud-VM-no-dbus / containerized-launch hardening. Each flag below is
59
- // inert on a healthy desktop, so the laptop capture path is unchanged; on
60
- // the dbus-less cloud base image they stop Chromium from SIGSEGV-ing at
61
- // launch (signal 11). See plan chromium-capture-dbus-crash-hardening.
62
- // No setuid sandbox in the container (the cloud VM runs capture without the
63
- // kernel namespaces the sandbox needs).
64
- "--no-sandbox",
65
- // /dev/shm is tiny in many containers; route shared memory to /tmp so the
66
- // renderer doesn't crash allocating it.
67
- "--disable-dev-shm-usage",
68
- // No GPU on the headless cloud VM — avoid the GL init path that aborts.
69
- "--disable-gpu",
70
- // Software WebGL2, asked for EXPLICITLY rather than inherited. Playwright
71
- // adds this itself on Linux, which is why `--disable-gpu` left canvas apps
72
- // with working WebGL2 there — and why it did not on macOS, where Playwright
73
- // does not add it: `getContext("webgl2")` returned null and the WebGL2
74
- // capture test failed on every mac dev machine while passing in CI. That is
75
- // the host-capability dependency CLAUDE.md forbids, so the flag is now
76
- // stated here for every platform. A duplicate on Linux is harmless
77
- // (last-wins, same value). WebGPU is still unavailable either way.
78
- "--enable-unsafe-swiftshader",
79
- ];
80
- // Features the capture browser disables on top of Playwright's own list.
81
- // DBus: stub out the dbus integration Chromium reaches for at launch; the cloud
82
- // base image has no dbus session bus, which is the proximate cause of the
83
- // signal-11 abort.
84
- //
85
- // These are NOT a second `--disable-features=` switch. Chromium keeps only the
86
- // LAST value of a repeated switch (verified empirically: `--disable-features=
87
- // WebHID` hides `navigator.hid`, and a later `--disable-features=DBus` brings it
88
- // back), so appending our own switch silently re-enabled every feature
89
- // Playwright disables. `captureLaunchOptions` merges them into one value.
90
- const CAPTURE_DISABLED_FEATURES = ["DBus"];
91
- const DISABLE_FEATURES_PREFIX = "--disable-features=";
92
-
93
- // Playwright's own default `--disable-features=<list>` switch, exactly as it
94
- // will pass it, or null when it cannot be read. Read from playwright-core's
95
- // switch table (not part of its public exports, so it is required by file path
96
- // and any failure degrades to null rather than throwing).
97
- function playwrightDefaultDisableFeaturesArg() {
98
- try {
99
- const playwrightDir = path.dirname(require.resolve("playwright/package.json"));
100
- const coreDir = path.dirname(
101
- require.resolve("playwright-core/package.json", { paths: [playwrightDir] }),
102
- );
103
- const { chromiumSwitches } = require(
104
- path.join(coreDir, "lib", "server", "chromium", "chromiumSwitches.js"),
105
- );
106
- const found = chromiumSwitches(false).find(
107
- (arg) => typeof arg === "string" && arg.startsWith(DISABLE_FEATURES_PREFIX),
108
- );
109
- return found || null;
110
- } catch (_) {
111
- return null;
112
- }
113
- }
114
-
115
- // One `--disable-features=` value carrying the default list plus `extra`,
116
- // de-duplicated and order-preserving.
117
- function mergeDisableFeaturesArg(defaultArg, extra) {
118
- const base =
119
- typeof defaultArg === "string" && defaultArg.startsWith(DISABLE_FEATURES_PREFIX)
120
- ? defaultArg.slice(DISABLE_FEATURES_PREFIX.length).split(",")
121
- : [];
122
- const merged = [];
123
- for (const feature of base.concat(extra)) {
124
- if (feature && !merged.includes(feature)) merged.push(feature);
125
- }
126
- return DISABLE_FEATURES_PREFIX + merged.join(",");
127
- }
128
-
129
- // The `chromium.launch` args for capture. Playwright's default
130
- // `--disable-features` switch is suppressed via `ignoreDefaultArgs` and
131
- // re-supplied merged with ours. Should the suppression ever miss (its match is
132
- // exact-string), our merged switch is still the later one and a superset, so
133
- // the last-wins rule still lands every feature.
134
- function captureLaunchOptions({
135
- defaultDisableFeaturesArg = playwrightDefaultDisableFeaturesArg(),
136
- } = {}) {
137
- const args = [
138
- ...CAPTURE_LAUNCH_ARGS,
139
- mergeDisableFeaturesArg(defaultDisableFeaturesArg, CAPTURE_DISABLED_FEATURES),
140
- ];
141
- return defaultDisableFeaturesArg
142
- ? { args, ignoreDefaultArgs: [defaultDisableFeaturesArg] }
143
- : { args };
144
- }
145
- // Point Chromium's dbus client at a dead address so it never blocks on (or
146
- // crashes against) a missing session bus. Spread over the real env in the
147
- // default launcher so the laptop path keeps its normal environment.
148
- const CAPTURE_LAUNCH_DBUS_ENV = { DBUS_SESSION_BUS_ADDRESS: "/dev/null" };
149
-
150
- // Does this launch error look like a transient SIGSEGV browser crash?
151
- // (environmental — e.g. the cloud VM's missing dbus session bus — not a
152
- // scenario fault, so it is safe to retry).
153
- function isTransientLaunchCrash(error) {
154
- return (
155
- error &&
156
- typeof error.message === "string" &&
157
- PLAYWRIGHT_LAUNCH_CRASH_PATTERNS.some((pattern) =>
158
- error.message.includes(pattern),
159
- )
160
- );
161
- }
162
-
163
- // Stable first token of the error a missing-system-library launch failure is
164
- // rethrown as. The Rust side keys on it (`scenario_check.rs`
165
- // `CAPTURE_BROWSER_DEPS_MISSING_MARKER`) to turn the failure into a blocked
166
- // precondition, so the two spellings must stay greppable from each other.
167
- const CAPTURE_BROWSER_DEPS_MISSING_MARKER = "CODEYAM_CAPTURE_BROWSER_DEPS_MISSING";
168
- // The two shapes a missing shared library takes in a launch error: the dynamic
169
- // loader's own line (Chromium starts and dies), and Playwright's host
170
- // validation box listing every missing library before it even spawns.
171
- const MISSING_SHARED_LIBRARY_PATTERNS = [
172
- "error while loading shared libraries",
173
- "Host system is missing dependencies",
174
- ];
175
- const SHARED_LIBRARY_NAME_PATTERN = /\blib[\w+-]+(?:\.[\w+-]+)*?\.so(?:\.\d+)*/g;
176
- const PLAYWRIGHT_INSTALL_DEPS_COMMAND = "npx playwright install-deps chromium";
177
- // Nix package for the libraries Chromium most often lacks on Replit, keyed by
178
- // the stem before `.so`. A library outside this map still gets a remedy — it is
179
- // just named without a package suggestion.
180
- const REPLIT_NIX_PACKAGE_FOR_LIBRARY = {
181
- "libglib-2.0": "pkgs.glib",
182
- "libgobject-2.0": "pkgs.glib",
183
- "libgio-2.0": "pkgs.glib",
184
- libnss3: "pkgs.nss",
185
- libnssutil3: "pkgs.nss",
186
- libsmime3: "pkgs.nss",
187
- libnspr4: "pkgs.nspr",
188
- "libatk-1.0": "pkgs.atk",
189
- "libatk-bridge-2.0": "pkgs.at-spi2-atk",
190
- "libatspi": "pkgs.at-spi2-core",
191
- libcups: "pkgs.cups",
192
- "libdbus-1": "pkgs.dbus",
193
- libdrm: "pkgs.libdrm",
194
- libgbm: "pkgs.mesa",
195
- libxkbcommon: "pkgs.libxkbcommon",
196
- libasound: "pkgs.alsa-lib",
197
- "libpango-1.0": "pkgs.pango",
198
- libcairo: "pkgs.cairo",
199
- libexpat: "pkgs.expat",
200
- libX11: "pkgs.xorg.libX11",
201
- libXcomposite: "pkgs.xorg.libXcomposite",
202
- libXdamage: "pkgs.xorg.libXdamage",
203
- libXext: "pkgs.xorg.libXext",
204
- libXfixes: "pkgs.xorg.libXfixes",
205
- libXrandr: "pkgs.xorg.libXrandr",
206
- libxcb: "pkgs.xorg.libxcb",
207
- };
208
-
209
- // Does this launch error say the host is missing a shared library Chromium
210
- // needs? Not retryable and not fixed by downloading the browser again.
211
- function isMissingSystemLibrary(error) {
212
- return (
213
- !!error &&
214
- typeof error.message === "string" &&
215
- MISSING_SHARED_LIBRARY_PATTERNS.some((pattern) =>
216
- error.message.includes(pattern),
217
- )
218
- );
219
- }
220
-
221
- // Every distinct `lib*.so*` name a missing-library launch error mentions, in
222
- // first-seen order. Covers both the loader line and Playwright's list.
223
- function missingLibraryNames(error) {
224
- const message = (error && error.message) || "";
225
- return [...new Set(message.match(SHARED_LIBRARY_NAME_PATTERN) || [])];
226
- }
227
-
228
- // The fix for the platform the capture is running on. Replit is detected by the
229
- // same env signals `preview_host_provider/replit.rs` uses; there system
230
- // packages come from replit.nix. Any other Linux host gets Playwright's own
231
- // dependency installer; anything else gets the library names and a hint.
232
- function missingLibraryRemedy(libraries, { env = process.env, platform = process.platform } = {}) {
233
- const named = libraries.length > 0 ? libraries.join(", ") : "the missing libraries";
234
- if (env.REPL_ID || env.REPLIT_DEV_DOMAIN) {
235
- const packages = [
236
- ...new Set(
237
- libraries
238
- .map((lib) => REPLIT_NIX_PACKAGE_FOR_LIBRARY[lib.split(".so")[0]])
239
- .filter(Boolean),
240
- ),
241
- ];
242
- const suggestion =
243
- packages.length > 0 ? ` (${packages.join(", ")})` : "";
244
- return `add the Nix package(s) providing ${named}${suggestion} to the \`deps\` list in replit.nix, then restart the Repl and re-run the capture`;
245
- }
246
- if (platform === "linux") {
247
- return `install Chromium's system libraries with \`${PLAYWRIGHT_INSTALL_DEPS_COMMAND}\` (may need sudo), then re-run the capture`;
248
- }
249
- return `install the system package(s) that provide ${named} for this OS, then re-run the capture`;
250
- }
251
-
252
- // The rethrown error for a missing-library launch failure: marker line naming
253
- // the libraries, a `remedy:` line, then the original Playwright message as
254
- // diagnostic. Line-oriented so the Rust side can lift each part out.
255
- function missingSystemLibraryError(error, { env, platform } = {}) {
256
- const libraries = missingLibraryNames(error);
257
- const remedy = missingLibraryRemedy(libraries, { env, platform });
258
- const wrapped = new Error(
259
- `${CAPTURE_BROWSER_DEPS_MISSING_MARKER} ${libraries.join(",")}\n` +
260
- `remedy: ${remedy}\n` +
261
- error.message,
262
- );
263
- wrapped.cause = error;
264
- return wrapped;
265
- }
266
-
267
- // Self-heal around `chromium.launch()`. Two recoverable classes:
268
- //
269
- // 1. "missing browser" — run `npx playwright install chromium` synchronously
270
- // (with `stdio: "inherit"` so the user sees progress) and retry once.
271
- // 2. transient SIGSEGV launch crash — retry the launch up to
272
- // `CAPTURE_LAUNCH_SIGSEGV_RETRIES` times with a short backoff, since the
273
- // crash is environmental (dbus-less cloud VM) and clears on a retry.
274
- //
275
- // A third class is diagnosed but deliberately NOT healed: a host missing a
276
- // system library Chromium links against. Installing system packages changes
277
- // the user's environment (replit.nix, apt, often root), so it is rethrown —
278
- // with no retry and no browser install — as `missingSystemLibraryError`,
279
- // naming the library and the platform's remedy.
280
- //
281
- // For any unrecognized error, or after exhausting retries, rethrow the
282
- // ORIGINAL Playwright error so the existing `Scenario check failed: <stderr>`
283
- // path keeps showing the actionable message — looping would hide a real ops
284
- // failure under a slow timeout.
285
- async function launchChromiumWithSelfHeal({
286
- launch = () =>
287
- chromium.launch({
288
- ...captureLaunchOptions(),
289
- env: { ...process.env, ...CAPTURE_LAUNCH_DBUS_ENV },
290
- }),
291
- install = () => execSync(PLAYWRIGHT_INSTALL_COMMAND, { stdio: "inherit" }),
292
- stderr = process.stderr,
293
- sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
294
- env = process.env,
295
- platform = process.platform,
296
- } = {}) {
297
- try {
298
- return await launch();
299
- } catch (error) {
300
- if (isMissingSystemLibrary(error)) {
301
- throw missingSystemLibraryError(error, { env, platform });
302
- }
303
- const isMissingBrowser =
304
- error &&
305
- typeof error.message === "string" &&
306
- error.message.includes(PLAYWRIGHT_MISSING_BROWSER_PATTERN);
307
- const isTransientCrash = isTransientLaunchCrash(error);
308
- if (!isMissingBrowser && !isTransientCrash) throw error;
309
-
310
- if (isTransientCrash) {
311
- for (let attempt = 1; attempt <= CAPTURE_LAUNCH_SIGSEGV_RETRIES; attempt++) {
312
- stderr.write(
313
- `Chromium crashed at launch (transient, likely a dbus-less environment) — retry ${attempt}/${CAPTURE_LAUNCH_SIGSEGV_RETRIES}.\n`,
314
- );
315
- await sleep(CAPTURE_LAUNCH_SIGSEGV_BACKOFF_MS);
316
- try {
317
- return await launch();
318
- } catch (retryError) {
319
- if (!isTransientLaunchCrash(retryError)) throw retryError;
320
- }
321
- }
322
- throw error;
323
- }
324
-
325
- stderr.write(
326
- "Playwright's Chromium browser is missing — installing it now (one-time ~150 MB download). Subsequent runs will be instant.\n",
327
- );
328
- try {
329
- install();
330
- } catch (_installError) {
331
- throw error;
332
- }
333
- try {
334
- return await launch();
335
- } catch (retryError) {
336
- // A fresh download onto a host without Chromium's system libraries
337
- // (the common first-capture shape on Replit) fails HERE, and the
338
- // original "missing browser" error would now be a lie.
339
- if (isMissingSystemLibrary(retryError)) {
340
- throw missingSystemLibraryError(retryError, { env, platform });
341
- }
342
- throw error;
343
- }
344
- }
345
- }
346
-
347
25
  const {
348
26
  findScenarioError,
349
27
  SCENARIO_ERROR_MARKER,
@@ -378,6 +56,8 @@ const {
378
56
  createIssue,
379
57
  pushIssue,
380
58
  buildResult,
59
+ RELOADED_AFTER_READY,
60
+ retryOnLiveReload,
381
61
  } = require("./scenario-issues");
382
62
 
383
63
  const {
@@ -394,6 +74,7 @@ const {
394
74
  resolveHarnessOrigin,
395
75
  waitForStablePage,
396
76
  waitForReadySignal,
77
+ watchNavigationAfterReady,
397
78
  createNetworkTracker,
398
79
  waitForNetworkQuiet,
399
80
  collectContentState,
@@ -402,7 +83,6 @@ const {
402
83
  forceFinalVisualState,
403
84
  centerCaptureWrapper,
404
85
  isCanvasInteraction,
405
- devicePixelRatioOf,
406
86
  resolveInteractionTarget,
407
87
  performInteraction,
408
88
  pixelSnapshot,
@@ -412,6 +92,7 @@ const {
412
92
  performInteractionSequence,
413
93
  logCaptureTiming,
414
94
  } = require("./scenario-playwright");
95
+ const { devicePixelRatioOf } = require("./scenario-targeting");
415
96
 
416
97
  // Name of the cookie the editor's control-API accepts the session token in —
417
98
  // must match `SESSION_COOKIE` in crates/control-api/src/origin_guard.rs. On a
@@ -2046,6 +1727,48 @@ async function closeBrowserBounded(browser, boundMs = 5000) {
2046
1727
  return outcome;
2047
1728
  }
2048
1729
 
1730
+ // A flow's `navigate` step: load `stepPath` (resolved relative to the flow's
1731
+ // initial url) with the same loader strategy as the initial load, re-settle,
1732
+ // and re-wait for hydration. Returns the new content frame and its hydration
1733
+ // verdict, which later steps target and classify against.
1734
+ async function navigateFlowStep(page, stepPath, ctx) {
1735
+ const { url, navigation, preflight, iframeBackground, harnessOrigin } = ctx;
1736
+ const target = new URL(stepPath, url).href;
1737
+ const loadResult =
1738
+ navigation === "topLevel"
1739
+ ? await loadScenarioTopLevel(page, target, { preflight })
1740
+ : await loadScenarioInIframe(page, target, {
1741
+ background: iframeBackground,
1742
+ preflight,
1743
+ harnessOrigin,
1744
+ });
1745
+ const frame = loadResult.frame;
1746
+ await waitForStablePage(page, frame, ctx.settleMs, ctx.loadingMarkers);
1747
+ // A navigate loads a fresh route that must re-hydrate before the next
1748
+ // fill/click, or the interaction lands on inert SSR markup (the same
1749
+ // bug the top-level wait fixes, one route deeper). Bounded and
1750
+ // stack-gated exactly like the top-level wait.
1751
+ const navHydration = await waitForHydration(frame, {
1752
+ // `target` is only where we aimed; the frame's own URL is where we
1753
+ // landed, so a redirect during the step is followed. Falls back to
1754
+ // `target` when a detached frame cannot answer.
1755
+ url: safeFrameUrl(frame) || target,
1756
+ timeoutMs: ctx.hydrationTimeoutMs,
1757
+ probeCounterpart: ctx.probeCounterpart,
1758
+ });
1759
+ // The mount that breaks a top-level load breaks it here too, and
1760
+ // before this a route that died one navigation deep got no
1761
+ // attribution at all. Report it so the editor can escalate the
1762
+ // preview off the subpath for the rest of the session.
1763
+ //
1764
+ // The returned issue is deliberately NOT pushed: this wait exists to
1765
+ // gate the next interaction, and adding an issue here would give a
1766
+ // flow that passes today a new way to fail. Computing a verdict and
1767
+ // not raising it reads as a bug otherwise — it is the point.
1768
+ await escalateSubpathHydration(navHydration);
1769
+ return { frame, hydrated: navHydration.hydrated };
1770
+ }
1771
+
2049
1772
  // Drive an ordered list of flow steps against ONE already-loaded browser
2050
1773
  // session so a scripted multi-step demo (`editor preview-flow`) is captured as
2051
1774
  // the real round-trip — click state and client transients persist across
@@ -2066,15 +1789,9 @@ async function closeBrowserBounded(browser, boundMs = 5000) {
2066
1789
  // result URL reflect the last navigated route.
2067
1790
  async function runFlowSteps(page, initialFrame, steps, ctx) {
2068
1791
  const {
2069
- url,
2070
1792
  loadingMarkers,
2071
- navigation,
2072
- iframeBackground,
2073
- preflight,
2074
- harnessOrigin,
2075
1793
  hydrationTimeoutMs = 10000,
2076
1794
  settleMs,
2077
- probeCounterpart,
2078
1795
  stepEffects,
2079
1796
  downloads,
2080
1797
  downloadDir,
@@ -2111,43 +1828,13 @@ async function runFlowSteps(page, initialFrame, steps, ctx) {
2111
1828
  logCaptureTiming("flow-step", { step: n, action: step.action, outcome: "started" });
2112
1829
  try {
2113
1830
  switch (step.action) {
2114
- case "navigate": {
2115
- const target = new URL(step.path, url).href;
2116
- const loadResult =
2117
- navigation === "topLevel"
2118
- ? await loadScenarioTopLevel(page, target, { preflight })
2119
- : await loadScenarioInIframe(page, target, {
2120
- background: iframeBackground,
2121
- preflight,
2122
- harnessOrigin,
2123
- });
2124
- frame = loadResult.frame;
2125
- await waitForStablePage(page, frame, navigateSettleMs, loadingMarkers);
2126
- // A navigate loads a fresh route that must re-hydrate before the next
2127
- // fill/click, or the interaction lands on inert SSR markup (the same
2128
- // bug the top-level wait fixes, one route deeper). Bounded and
2129
- // stack-gated exactly like the top-level wait.
2130
- const navHydration = await waitForHydration(frame, {
2131
- // `target` is only where we aimed; the frame's own URL is where we
2132
- // landed, so a redirect during the step is followed. Falls back to
2133
- // `target` when a detached frame cannot answer.
2134
- url: safeFrameUrl(frame) || target,
2135
- timeoutMs: hydrationTimeoutMs,
2136
- probeCounterpart,
2137
- });
2138
- // The mount that breaks a top-level load breaks it here too, and
2139
- // before this a route that died one navigation deep got no
2140
- // attribution at all. Report it so the editor can escalate the
2141
- // preview off the subpath for the rest of the session.
2142
- //
2143
- // The returned issue is deliberately NOT pushed: this wait exists to
2144
- // gate the next interaction, and adding an issue here would give a
2145
- // flow that passes today a new way to fail. Computing a verdict and
2146
- // not raising it reads as a bug otherwise — it is the point.
2147
- await escalateSubpathHydration(navHydration);
2148
- hydrated = navHydration.hydrated;
1831
+ case "navigate":
1832
+ ({ frame, hydrated } = await navigateFlowStep(page, step.path, {
1833
+ ...ctx,
1834
+ settleMs: navigateSettleMs,
1835
+ hydrationTimeoutMs,
1836
+ }));
2149
1837
  break;
2150
- }
2151
1838
  case "click":
2152
1839
  case "fill":
2153
1840
  case "press":
@@ -2244,6 +1931,14 @@ async function runFlowSteps(page, initialFrame, steps, ctx) {
2244
1931
  return frame;
2245
1932
  }
2246
1933
 
1934
+ // One capture, retried once if a dev server's live reload interrupted it (see
1935
+ // `retryOnLiveReload`).
1936
+ async function runScenarioCheck(config, options = {}) {
1937
+ return retryOnLiveReload(() => runScenarioCheckOnce(config, options), {
1938
+ onRetry: (firstError) => logCaptureTiming("live-reload-retry", { firstError }),
1939
+ });
1940
+ }
1941
+
2247
1942
  // `preflight` is injectable (defaulting to the real app-port reachability
2248
1943
  // check) so unit tests that mock the browser can stay network-free.
2249
1944
  // `harnessOrigin` is likewise injectable: it defaults to resolving the editor's
@@ -2251,7 +1946,7 @@ async function runFlowSteps(page, initialFrame, steps, ctx) {
2251
1946
  // explicit value (e.g. `null` to force the legacy `setContent` harness) so the
2252
1947
  // iframe-load path never silently depends on a server-state file in cwd.
2253
1948
  // Resolved ONCE here and threaded into every iframe load below.
2254
- async function runScenarioCheck(
1949
+ async function runScenarioCheckOnce(
2255
1950
  config,
2256
1951
  { preflight = assertAppPortReachable, harnessOrigin } = {},
2257
1952
  ) {
@@ -2603,6 +2298,9 @@ async function runScenarioCheck(
2603
2298
  if (!readyOutcome.ready) {
2604
2299
  pushIssue(issues, readySignalTimeoutIssue(readiness, page.url() || url));
2605
2300
  }
2301
+ // Checked before the screenshot below; a declared interaction may navigate
2302
+ // on purpose, so it is only acted on for a static capture.
2303
+ const reloadWatch = watchNavigationAfterReady(page, frame);
2606
2304
  // Held here — after stability, before the content/hydration assertions and
2607
2305
  // the screenshot — so every downstream check sees the same frame the
2608
2306
  // scenario asked for rather than an earlier one.
@@ -2898,6 +2596,9 @@ async function runScenarioCheck(
2898
2596
  !!config.interaction ||
2899
2597
  (Array.isArray(config.interactions) && config.interactions.length > 0) ||
2900
2598
  (Array.isArray(config.steps) && config.steps.length > 0);
2599
+ if (reloadWatch.navigated() && !hasDeclaredInteractiveState) {
2600
+ throw new Error(RELOADED_AFTER_READY);
2601
+ }
2901
2602
  if (outputPath && loaded && !hasDeclaredInteractiveState) {
2902
2603
  await forceFinalVisualState(frame).catch(() => {});
2903
2604
  }
@@ -3126,17 +2827,6 @@ module.exports = {
3126
2827
  DEV_SERVER_DOWN_HEADER,
3127
2828
  stripMarkerHeaders,
3128
2829
  main,
3129
- launchChromiumWithSelfHeal,
3130
- isTransientLaunchCrash,
3131
- isMissingSystemLibrary,
3132
- missingLibraryNames,
3133
- missingLibraryRemedy,
3134
- CAPTURE_BROWSER_DEPS_MISSING_MARKER,
3135
- CAPTURE_LAUNCH_ARGS,
3136
- CAPTURE_DISABLED_FEATURES,
3137
- captureLaunchOptions,
3138
- mergeDisableFeaturesArg,
3139
- playwrightDefaultDisableFeaturesArg,
3140
2830
  DEFAULT_READY_TIMEOUT_MS,
3141
2831
  resolveReadySignal,
3142
2832
  readStackReadySignal,
@@ -3149,11 +2839,6 @@ module.exports = {
3149
2839
  readySignalTimeoutIssue,
3150
2840
  canvasBlankIssue,
3151
2841
  decodeScreenshotPng,
3152
- CAPTURE_HOST_RESOLVER_RULES,
3153
- CAPTURE_LAUNCH_SIGSEGV_RETRIES,
3154
- PLAYWRIGHT_INSTALL_COMMAND,
3155
- PLAYWRIGHT_MISSING_BROWSER_PATTERN,
3156
- PLAYWRIGHT_LAUNCH_CRASH_PATTERNS,
3157
2842
  };
3158
2843
 
3159
2844
  if (require.main === module) {
@@ -111,9 +111,56 @@ function buildResult({
111
111
  };
112
112
  }
113
113
 
114
+ // The message a capture fails with when the scenario frame navigated after its
115
+ // ready signal arrived — a dev server's live reload swapping the page under the
116
+ // shot.
117
+ const RELOADED_AFTER_READY =
118
+ "The scenario page navigated after it signalled ready (a dev-server live reload)";
119
+
120
+ // Failure messages that mean the page was reloaded mid-capture rather than that
121
+ // the app is broken: Playwright's destroyed-context and detached-frame errors,
122
+ // and the marker above. Any dev server with live reload (Vite full reload,
123
+ // trunk, Flutter) produces them, so the match names no stack.
124
+ const LIVE_RELOAD_FAILURE_PATTERNS = [
125
+ /Execution context was destroyed/i,
126
+ /Frame was detached/i,
127
+ /navigated after it signalled ready/,
128
+ ];
129
+
130
+ // The first issue of a failed capture whose message names a mid-capture
131
+ // reload, or null. A capture that passed, or failed for any other reason, is
132
+ // never a reload candidate.
133
+ function liveReloadIssue(result) {
134
+ if (!result || result.ok) return null;
135
+ return (
136
+ (result.issues || []).find((issue) =>
137
+ LIVE_RELOAD_FAILURE_PATTERNS.some((re) => re.test(String(issue.message || ""))),
138
+ ) || null
139
+ );
140
+ }
141
+
142
+ // Run a capture `attempt`, and run it ONCE more when a dev server's live reload
143
+ // interrupted it: the reload is a temporary condition, and the second attempt
144
+ // waits for the ready signal on the reloaded page from scratch. The retry is
145
+ // recorded on the returned result (`reloadRetry`) so it is visible rather than
146
+ // silently absorbed, and a second failure is returned as-is, with its own
147
+ // reason. `onRetry` is told the first failure before the retry starts.
148
+ async function retryOnLiveReload(attempt, { onRetry = () => {} } = {}) {
149
+ const first = await attempt();
150
+ const reload = liveReloadIssue(first);
151
+ if (!reload) return first;
152
+ onRetry(reload.message);
153
+ const second = await attempt();
154
+ second.reloadRetry = { retried: true, firstError: reload.message };
155
+ return second;
156
+ }
157
+
114
158
  module.exports = {
115
159
  createIssue,
116
160
  pushIssue,
117
161
  buildResult,
118
162
  deriveOriginSplit,
163
+ RELOADED_AFTER_READY,
164
+ liveReloadIssue,
165
+ retryOnLiveReload,
119
166
  };