middlewright 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -168,6 +168,30 @@ page.videoMode.setEndTime();
168
168
 
169
169
  When Playwright video recording is enabled, `videoMode` saves `video-raw.webm`, uses `ffmpeg` to write `video-rendered.webm`, writes a sibling `video-mode.html` frame-stepper for inspecting both videos, and attaches all of them with `video-mode.json` to the test report. The frame-stepper stores its active video and frame in the URL, so links like `video-mode.html?active=rendered&frame=28` reopen the same frame. If `ffmpeg` or `ffprobe` is missing, the render step fails plainly so you know to install ffmpeg.
170
170
 
171
+ #### Trimming the blank startup lead-in (`trimStart`)
172
+
173
+ Recording begins at browser-context creation, so a video usually opens with a few seconds of `about:blank` + loading shell before the app paints. `trimStart` finds where that lead-in ends and starts the video on real content — instead of calling `setStartTime()` by hand in every test.
174
+
175
+ ```ts
176
+ videoMode({ /* trimStart: "auto" is the default */ });
177
+
178
+ // start when a known "ready" element first becomes visible (falls back to
179
+ // blank detection if it never appears):
180
+ videoMode({ trimStart: ["selector", "[data-app-ready]"] });
181
+
182
+ // pin the start for a video whose exact frames you assert on:
183
+ videoMode({ trimStart: "never" });
184
+ ```
185
+
186
+ `trimStart` is one of:
187
+
188
+ - **`"auto"`** (default) — pick a sensible strategy; currently the blank detector. Chosen so consumers get lead-in trimming just by upgrading.
189
+ - **`"detect-blank"`** — decode a coarse strip of the opening seconds and find the first frame that *differs* from the opening frame (the moment the static blank lead-in ends), starting there only when the lead-in is long enough to be worth trimming. Keys on change-from-the-opening-frame, not how "busy" a frame is, so it's robust to letterbox bars and dark loading shells.
190
+ - **`["selector", css]`** — start the moment `css` first becomes visible (waited for once, live); falls back to blank detection if it never appears.
191
+ - **`"never"`** — don't trim.
192
+
193
+ An explicit `setStartTime()` always wins over `trimStart`.
194
+
171
195
  `video-mode.json` records raw dead-air spans and highlight rectangles. `deadAirThreshold` is applied only when writing the rendered video: dead-air spans longer than the threshold are sped up so they render within that duration. Spans at or below the threshold are left at normal speed. `highlight` duration and `finalHold` are also applied at render time, so they do not slow down the browser test. `highlight: true` is equivalent to the default pointer mode, `{ mode: "pointer", duration: 1000 }`. For outline boxes, use a simple solid CSS-style string:
172
196
 
173
197
  ```ts
@@ -75,7 +75,7 @@ export const spinnerWaiter = Object.assign((options = {}) => {
75
75
  // Check for spinner
76
76
  const spinnerSelector = settings.spinnerSelectors.join(",");
77
77
  const spinnerLocator = page.locator(spinnerSelector);
78
- const spinnerVisible = await spinnerLocator.isVisible();
78
+ const spinnerVisible = await anySpinnerVisible(spinnerLocator);
79
79
  if (!spinnerVisible) {
80
80
  // No spinner - call action, suggest adding one if it fails
81
81
  settings.log(`${locator} not ready, no spinner, failing fast`);
@@ -166,10 +166,19 @@ async function waitForReadyWhileSpinning(target, method, spinner, { timeout = 10
166
166
  if (await locatorIsReady(target, method))
167
167
  return "appeared";
168
168
  const elapsed = Date.now() - start;
169
- if (elapsed > spinnerGracePeriodMs && !(await spinner.isVisible()))
169
+ if (elapsed > spinnerGracePeriodMs && !(await anySpinnerVisible(spinner)))
170
170
  return "spinner-gone";
171
171
  await new Promise((resolve) => setTimeout(resolve, 250));
172
172
  }
173
173
  return "timeout";
174
174
  }
175
+ /**
176
+ * Multi-element-safe "is any spinner visible": the spinner selector union can
177
+ * legitimately match several loading indicators at once (e.g. two panels each
178
+ * showing a pending fallback), where a bare `locator.isVisible()` throws a
179
+ * strict-mode violation. `filter({ visible: true })` needs no strictness.
180
+ */
181
+ async function anySpinnerVisible(spinnerLocator) {
182
+ return (await spinnerLocator.filter({ visible: true }).count()) > 0;
183
+ }
175
184
  export { defaultSelectors };
@@ -99,6 +99,24 @@ export type VideoModeOptions = {
99
99
  * so they fit within this duration.
100
100
  */
101
101
  deadAirThreshold?: number;
102
+ /**
103
+ * Where the rendered video starts, trimming the blank "startup" lead-in
104
+ * (about:blank, the loading shell, the pre-hydration app frame) so it opens on
105
+ * real content instead of a white screen. An explicit `setStartTime()` always
106
+ * wins over this.
107
+ *
108
+ * - `"auto"` (default): pick a sensible strategy — currently the blank
109
+ * detector below. Chosen so consumers get lead-in trimming just by upgrading.
110
+ * - `"detect-blank"`: find where the leading blank frames end in the recorded
111
+ * pixels (the first frame that differs from the opening frame) and start
112
+ * there, when that lead-in is long enough to be worth trimming.
113
+ * - `["selector", css]`: start the moment `css` first becomes visible (waited
114
+ * for live, once); falls back to blank detection if it never appears.
115
+ * - `"never"`: don't trim. Use this for a video whose exact frames you assert
116
+ * on, since trimming shifts the timeline.
117
+ */
118
+ trimStart?: VideoModeTrimStart;
102
119
  };
120
+ export type VideoModeTrimStart = "auto" | "detect-blank" | "never" | ["selector", string];
103
121
  /** Records video-mode facts and renders annotations into the recorded video. */
104
122
  export declare const videoMode: (options?: VideoModeOptions) => VideoModePlugin;
@@ -54,6 +54,98 @@ const resolveDeadAirThreshold = (thresholdMs) => {
54
54
  }
55
55
  return thresholdMs;
56
56
  };
57
+ // A `selector` falls back to blank detection if it never shows, so a bad
58
+ // selector can't leave the video opening on the blank lead-in.
59
+ const TRIM_START_SELECTOR_TIMEOUT_MS = 30_000;
60
+ // Only trim when the detected blank lead-in is at least this long, so a video
61
+ // that was never really blank isn't nudged.
62
+ const TRIM_START_MIN_LEAD_IN_MS = 1000;
63
+ const resolveTrimStart = (trimStart) => {
64
+ const value = trimStart === undefined ? "auto" : trimStart;
65
+ if (Array.isArray(value)) {
66
+ const [kind, selector] = value;
67
+ if (kind !== "selector" || typeof selector !== "string" || selector.length === 0) {
68
+ throw new Error('videoMode trimStart tuple must be ["selector", "<css>"]');
69
+ }
70
+ return { selector, detectBlank: true };
71
+ }
72
+ switch (value) {
73
+ case "never":
74
+ return { detectBlank: false };
75
+ case "auto":
76
+ case "detect-blank":
77
+ return { detectBlank: true };
78
+ default:
79
+ throw new Error('videoMode trimStart must be "auto", "detect-blank", "never", or ["selector", "<css>"]');
80
+ }
81
+ };
82
+ // Blank-lead-in detection tuning. The recorded startup is a run of *identical*
83
+ // frames — the browser paints nothing new (about:blank, then a static loading
84
+ // shell) until content arrives. So the signal isn't how "busy" a frame is (a
85
+ // letterbox bar or a solid-but-dark shell would fool that); it's the first frame
86
+ // that *differs* from the opening frame. We decode a coarse, tiny greyscale strip
87
+ // of the opening seconds and find where it first changes and stays changed.
88
+ const AUTO_START_SAMPLE_FPS = 5;
89
+ const AUTO_START_SAMPLE_SIZE = 48;
90
+ const AUTO_START_MAX_SCAN_MS = 30_000;
91
+ // Mean per-pixel greyscale delta (0-255) above which a frame counts as "changed"
92
+ // from the opening frame. Comfortably above VP8 quantisation noise on a static
93
+ // scene (which stays ~0) and below the jump when real content paints.
94
+ const AUTO_START_DIFF_THRESHOLD = 1.5;
95
+ const frameMeanAbsDiff = (frame, reference) => {
96
+ let sum = 0;
97
+ for (let index = 0; index < frame.length; index += 1) {
98
+ sum += Math.abs(frame[index] - reference[index]);
99
+ }
100
+ return sum / frame.length;
101
+ };
102
+ /**
103
+ * Return the timestamp (ms) where the leading blank frames end and the screen
104
+ * first changes (content paints), or undefined when the video never opens with a
105
+ * static lead-in (nothing to trim).
106
+ */
107
+ const detectBlankLeadInEndMs = async (inputPath) => {
108
+ const size = AUTO_START_SAMPLE_SIZE;
109
+ const frameSize = size * size;
110
+ let stdout;
111
+ try {
112
+ const result = await execFile("ffmpeg", [
113
+ "-hide_banner",
114
+ "-loglevel",
115
+ "error",
116
+ "-i",
117
+ inputPath,
118
+ "-vf",
119
+ `fps=${AUTO_START_SAMPLE_FPS},scale=${size}:${size},format=gray`,
120
+ "-t",
121
+ formatSeconds(AUTO_START_MAX_SCAN_MS),
122
+ "-f",
123
+ "rawvideo",
124
+ "-pix_fmt",
125
+ "gray",
126
+ "pipe:1",
127
+ ], { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 });
128
+ stdout = result.stdout;
129
+ }
130
+ catch {
131
+ // Detection is best-effort; a decode failure just means "don't trim".
132
+ return undefined;
133
+ }
134
+ const frameCount = Math.floor(stdout.length / frameSize);
135
+ if (frameCount < 2) {
136
+ return undefined;
137
+ }
138
+ const firstFrame = stdout.subarray(0, frameSize);
139
+ const hasChanged = (index) => frameMeanAbsDiff(stdout.subarray(index * frameSize, (index + 1) * frameSize), firstFrame) > AUTO_START_DIFF_THRESHOLD;
140
+ // First frame that differs from the opening frame *and* stays changed (two
141
+ // consecutive samples), so a single decode blip can't trip it.
142
+ for (let index = 1; index < frameCount - 1; index += 1) {
143
+ if (hasChanged(index) && hasChanged(index + 1)) {
144
+ return Math.round((index / AUTO_START_SAMPLE_FPS) * 1000);
145
+ }
146
+ }
147
+ return undefined;
148
+ };
57
149
  const resolveNonNegativeNumber = (options) => {
58
150
  const value = options.value === undefined ? options.defaultValue : options.value;
59
151
  if (!Number.isFinite(value) || value < 0) {
@@ -1264,6 +1356,7 @@ export const videoMode = (options = {}) => {
1264
1356
  const skipMethods = options.skipMethods || ["waitFor"];
1265
1357
  const skipStackFrames = options.skipStackFrames || [];
1266
1358
  const deadAirThreshold = resolveDeadAirThreshold(options.deadAirThreshold);
1359
+ const trimStart = resolveTrimStart(options.trimStart);
1267
1360
  const state = {
1268
1361
  deadAirDepth: 0,
1269
1362
  deadAirSpans: [],
@@ -1364,7 +1457,7 @@ export const videoMode = (options = {}) => {
1364
1457
  }
1365
1458
  },
1366
1459
  testLifecycle: (emitter) => {
1367
- const offBeforeTest = emitter.on("beforeTest", () => {
1460
+ const offBeforeTest = emitter.on("beforeTest", ({ page }) => {
1368
1461
  state.deadAirDepth = 0;
1369
1462
  state.deadAirSpans = [];
1370
1463
  state.highlightImageIndex = 0;
@@ -1380,12 +1473,29 @@ export const videoMode = (options = {}) => {
1380
1473
  else {
1381
1474
  state.startedAt = performance.now();
1382
1475
  }
1476
+ // Start the video from the moment the app's "ready" element first shows.
1477
+ // Kicked off now (before the test navigates), it resolves whenever the
1478
+ // element appears; a timeout or a page close just leaves the blank
1479
+ // detector to handle it. Never let it reject the test.
1480
+ if (trimStart.selector) {
1481
+ page
1482
+ .locator(trimStart.selector)
1483
+ .first()
1484
+ .waitFor({ state: "visible", timeout: TRIM_START_SELECTOR_TIMEOUT_MS })
1485
+ .then(() => {
1486
+ if (state.sourceRange.start === undefined)
1487
+ controls.setStartTime();
1488
+ })
1489
+ .catch(() => { });
1490
+ }
1383
1491
  });
1384
1492
  const offAfterTestFinalize = emitter.on("afterTestFinalize", async ({ page, testInfo }) => {
1385
1493
  const metadataBeforeVideo = metadataFor(state);
1386
1494
  const deadAir = metadataBeforeVideo.deadAir;
1387
1495
  const highlights = metadataBeforeVideo.highlights;
1388
- const sourceRange = metadataBeforeVideo.sourceRange;
1496
+ // Note: sourceRange is read fresh from state below, not snapshotted here —
1497
+ // a selector `waitFor` can still resolve during the awaits in this handler
1498
+ // and call setStartTime(), and the render must see that.
1389
1499
  const video = page.video();
1390
1500
  if (video) {
1391
1501
  const paths = videoModeOutputPaths(testInfo);
@@ -1401,6 +1511,30 @@ export const videoMode = (options = {}) => {
1401
1511
  contentType: "video/webm",
1402
1512
  path: paths.raw,
1403
1513
  });
1514
+ // No explicit or selector-driven start: fall back to detecting where
1515
+ // the blank startup ends in the recorded pixels, and trim to there
1516
+ // when the lead-in is long enough to be worth removing. The second
1517
+ // `undefined` check guards the window across the ffmpeg await: if a
1518
+ // selector start landed meanwhile, it wins.
1519
+ //
1520
+ // This start is in the raw recording's timebase (t=0 = context
1521
+ // creation), which is exactly what the ffmpeg trim wants. Highlights
1522
+ // and dead-air are in videoMode time (from `startedAt` at plugin
1523
+ // construction); the two origins differ only by the fixture-setup gap
1524
+ // between context creation and construction — sub-frame in practice and
1525
+ // independent of how long the app takes to paint — so annotations stay
1526
+ // aligned with the trimmed video. (`setStartTime` instead shares the
1527
+ // videoMode timebase, so its trim and annotations shift together.)
1528
+ if (trimStart.detectBlank && state.sourceRange.start === undefined) {
1529
+ const detectedStart = await detectBlankLeadInEndMs(paths.raw);
1530
+ if (detectedStart !== undefined &&
1531
+ detectedStart >= TRIM_START_MIN_LEAD_IN_MS &&
1532
+ state.sourceRange.start === undefined) {
1533
+ state.sourceRange.start = detectedStart;
1534
+ }
1535
+ }
1536
+ // Read fresh, so an explicit, selector, or pixel-detected start all show.
1537
+ const sourceRange = metadataFor(state).sourceRange;
1404
1538
  if (highlights.length > 0 ||
1405
1539
  deadAirThreshold !== undefined ||
1406
1540
  finalHold > 0 ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "middlewright",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A plugin/middleware system for Playwright locator actions — spinner-aware waiting, hydration gates, UI error reporting, video highlighting, and LLM-powered recovery. A hack, but a useful one.",
5
5
  "keywords": [
6
6
  "playwright",
@@ -45,7 +45,7 @@
45
45
  "node": ">=20"
46
46
  },
47
47
  "git": {
48
- "sha": "d9b03dd"
48
+ "sha": "c746cbd"
49
49
  },
50
50
  "scripts": {
51
51
  "build": "tsc -p tsconfig.build.json",
@@ -101,7 +101,7 @@ export const spinnerWaiter = Object.assign(
101
101
  // Check for spinner
102
102
  const spinnerSelector = settings.spinnerSelectors.join(",");
103
103
  const spinnerLocator = page.locator(spinnerSelector) as LocatorWithOriginal;
104
- const spinnerVisible = await spinnerLocator.isVisible();
104
+ const spinnerVisible = await anySpinnerVisible(spinnerLocator);
105
105
 
106
106
  if (!spinnerVisible) {
107
107
  // No spinner - call action, suggest adding one if it fails
@@ -211,10 +211,20 @@ async function waitForReadyWhileSpinning(
211
211
  while (Date.now() - start < timeout) {
212
212
  if (await locatorIsReady(target, method)) return "appeared";
213
213
  const elapsed = Date.now() - start;
214
- if (elapsed > spinnerGracePeriodMs && !(await spinner.isVisible())) return "spinner-gone";
214
+ if (elapsed > spinnerGracePeriodMs && !(await anySpinnerVisible(spinner))) return "spinner-gone";
215
215
  await new Promise((resolve) => setTimeout(resolve, 250));
216
216
  }
217
217
  return "timeout";
218
218
  }
219
219
 
220
+ /**
221
+ * Multi-element-safe "is any spinner visible": the spinner selector union can
222
+ * legitimately match several loading indicators at once (e.g. two panels each
223
+ * showing a pending fallback), where a bare `locator.isVisible()` throws a
224
+ * strict-mode violation. `filter({ visible: true })` needs no strictness.
225
+ */
226
+ async function anySpinnerVisible(spinnerLocator: Locator): Promise<boolean> {
227
+ return (await spinnerLocator.filter({ visible: true }).count()) > 0;
228
+ }
229
+
220
230
  export { defaultSelectors };
@@ -159,8 +159,27 @@ export type VideoModeOptions = {
159
159
  * so they fit within this duration.
160
160
  */
161
161
  deadAirThreshold?: number;
162
+ /**
163
+ * Where the rendered video starts, trimming the blank "startup" lead-in
164
+ * (about:blank, the loading shell, the pre-hydration app frame) so it opens on
165
+ * real content instead of a white screen. An explicit `setStartTime()` always
166
+ * wins over this.
167
+ *
168
+ * - `"auto"` (default): pick a sensible strategy — currently the blank
169
+ * detector below. Chosen so consumers get lead-in trimming just by upgrading.
170
+ * - `"detect-blank"`: find where the leading blank frames end in the recorded
171
+ * pixels (the first frame that differs from the opening frame) and start
172
+ * there, when that lead-in is long enough to be worth trimming.
173
+ * - `["selector", css]`: start the moment `css` first becomes visible (waited
174
+ * for live, once); falls back to blank detection if it never appears.
175
+ * - `"never"`: don't trim. Use this for a video whose exact frames you assert
176
+ * on, since trimming shifts the timeline.
177
+ */
178
+ trimStart?: VideoModeTrimStart;
162
179
  };
163
180
 
181
+ export type VideoModeTrimStart = "auto" | "detect-blank" | "never" | ["selector", string];
182
+
164
183
  type VideoModeState = {
165
184
  deadAirDepth: number;
166
185
  deadAirSpans: VideoModeSpan[];
@@ -276,6 +295,123 @@ const resolveDeadAirThreshold = (thresholdMs: number | undefined) => {
276
295
  return thresholdMs;
277
296
  };
278
297
 
298
+ type ResolvedTrimStart = {
299
+ selector?: string;
300
+ detectBlank: boolean;
301
+ };
302
+
303
+ // A `selector` falls back to blank detection if it never shows, so a bad
304
+ // selector can't leave the video opening on the blank lead-in.
305
+ const TRIM_START_SELECTOR_TIMEOUT_MS = 30_000;
306
+ // Only trim when the detected blank lead-in is at least this long, so a video
307
+ // that was never really blank isn't nudged.
308
+ const TRIM_START_MIN_LEAD_IN_MS = 1000;
309
+
310
+ const resolveTrimStart = (trimStart: VideoModeOptions["trimStart"]): ResolvedTrimStart => {
311
+ const value = trimStart === undefined ? "auto" : trimStart;
312
+
313
+ if (Array.isArray(value)) {
314
+ const [kind, selector] = value;
315
+ if (kind !== "selector" || typeof selector !== "string" || selector.length === 0) {
316
+ throw new Error('videoMode trimStart tuple must be ["selector", "<css>"]');
317
+ }
318
+ return { selector, detectBlank: true };
319
+ }
320
+
321
+ switch (value) {
322
+ case "never":
323
+ return { detectBlank: false };
324
+ case "auto":
325
+ case "detect-blank":
326
+ return { detectBlank: true };
327
+ default:
328
+ throw new Error(
329
+ 'videoMode trimStart must be "auto", "detect-blank", "never", or ["selector", "<css>"]',
330
+ );
331
+ }
332
+ };
333
+
334
+ // Blank-lead-in detection tuning. The recorded startup is a run of *identical*
335
+ // frames — the browser paints nothing new (about:blank, then a static loading
336
+ // shell) until content arrives. So the signal isn't how "busy" a frame is (a
337
+ // letterbox bar or a solid-but-dark shell would fool that); it's the first frame
338
+ // that *differs* from the opening frame. We decode a coarse, tiny greyscale strip
339
+ // of the opening seconds and find where it first changes and stays changed.
340
+ const AUTO_START_SAMPLE_FPS = 5;
341
+ const AUTO_START_SAMPLE_SIZE = 48;
342
+ const AUTO_START_MAX_SCAN_MS = 30_000;
343
+ // Mean per-pixel greyscale delta (0-255) above which a frame counts as "changed"
344
+ // from the opening frame. Comfortably above VP8 quantisation noise on a static
345
+ // scene (which stays ~0) and below the jump when real content paints.
346
+ const AUTO_START_DIFF_THRESHOLD = 1.5;
347
+
348
+ const frameMeanAbsDiff = (frame: Buffer, reference: Buffer) => {
349
+ let sum = 0;
350
+ for (let index = 0; index < frame.length; index += 1) {
351
+ sum += Math.abs(frame[index] - reference[index]);
352
+ }
353
+ return sum / frame.length;
354
+ };
355
+
356
+ /**
357
+ * Return the timestamp (ms) where the leading blank frames end and the screen
358
+ * first changes (content paints), or undefined when the video never opens with a
359
+ * static lead-in (nothing to trim).
360
+ */
361
+ const detectBlankLeadInEndMs = async (inputPath: string): Promise<number | undefined> => {
362
+ const size = AUTO_START_SAMPLE_SIZE;
363
+ const frameSize = size * size;
364
+ let stdout: Buffer;
365
+ try {
366
+ const result = await execFile(
367
+ "ffmpeg",
368
+ [
369
+ "-hide_banner",
370
+ "-loglevel",
371
+ "error",
372
+ "-i",
373
+ inputPath,
374
+ "-vf",
375
+ `fps=${AUTO_START_SAMPLE_FPS},scale=${size}:${size},format=gray`,
376
+ "-t",
377
+ formatSeconds(AUTO_START_MAX_SCAN_MS),
378
+ "-f",
379
+ "rawvideo",
380
+ "-pix_fmt",
381
+ "gray",
382
+ "pipe:1",
383
+ ],
384
+ { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 },
385
+ );
386
+ stdout = result.stdout as Buffer;
387
+ } catch {
388
+ // Detection is best-effort; a decode failure just means "don't trim".
389
+ return undefined;
390
+ }
391
+
392
+ const frameCount = Math.floor(stdout.length / frameSize);
393
+ if (frameCount < 2) {
394
+ return undefined;
395
+ }
396
+
397
+ const firstFrame = stdout.subarray(0, frameSize);
398
+ const hasChanged = (index: number) =>
399
+ frameMeanAbsDiff(
400
+ stdout.subarray(index * frameSize, (index + 1) * frameSize),
401
+ firstFrame,
402
+ ) > AUTO_START_DIFF_THRESHOLD;
403
+
404
+ // First frame that differs from the opening frame *and* stays changed (two
405
+ // consecutive samples), so a single decode blip can't trip it.
406
+ for (let index = 1; index < frameCount - 1; index += 1) {
407
+ if (hasChanged(index) && hasChanged(index + 1)) {
408
+ return Math.round((index / AUTO_START_SAMPLE_FPS) * 1000);
409
+ }
410
+ }
411
+
412
+ return undefined;
413
+ };
414
+
279
415
  const resolveNonNegativeNumber = (options: {
280
416
  defaultValue: number;
281
417
  name: string;
@@ -1835,6 +1971,7 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
1835
1971
  const skipMethods = options.skipMethods || ["waitFor"];
1836
1972
  const skipStackFrames = options.skipStackFrames || [];
1837
1973
  const deadAirThreshold = resolveDeadAirThreshold(options.deadAirThreshold);
1974
+ const trimStart = resolveTrimStart(options.trimStart);
1838
1975
  const state: VideoModeState = {
1839
1976
  deadAirDepth: 0,
1840
1977
  deadAirSpans: [],
@@ -1949,7 +2086,7 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
1949
2086
  },
1950
2087
 
1951
2088
  testLifecycle: (emitter) => {
1952
- const offBeforeTest = emitter.on("beforeTest", () => {
2089
+ const offBeforeTest = emitter.on("beforeTest", ({ page }) => {
1953
2090
  state.deadAirDepth = 0;
1954
2091
  state.deadAirSpans = [];
1955
2092
  state.highlightImageIndex = 0;
@@ -1964,13 +2101,30 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
1964
2101
  } else {
1965
2102
  state.startedAt = performance.now();
1966
2103
  }
2104
+
2105
+ // Start the video from the moment the app's "ready" element first shows.
2106
+ // Kicked off now (before the test navigates), it resolves whenever the
2107
+ // element appears; a timeout or a page close just leaves the blank
2108
+ // detector to handle it. Never let it reject the test.
2109
+ if (trimStart.selector) {
2110
+ page
2111
+ .locator(trimStart.selector)
2112
+ .first()
2113
+ .waitFor({ state: "visible", timeout: TRIM_START_SELECTOR_TIMEOUT_MS })
2114
+ .then(() => {
2115
+ if (state.sourceRange.start === undefined) controls.setStartTime();
2116
+ })
2117
+ .catch(() => {});
2118
+ }
1967
2119
  });
1968
2120
 
1969
2121
  const offAfterTestFinalize = emitter.on("afterTestFinalize", async ({ page, testInfo }) => {
1970
2122
  const metadataBeforeVideo = metadataFor(state);
1971
2123
  const deadAir = metadataBeforeVideo.deadAir;
1972
2124
  const highlights = metadataBeforeVideo.highlights;
1973
- const sourceRange = metadataBeforeVideo.sourceRange;
2125
+ // Note: sourceRange is read fresh from state below, not snapshotted here —
2126
+ // a selector `waitFor` can still resolve during the awaits in this handler
2127
+ // and call setStartTime(), and the render must see that.
1974
2128
  const video = page.video();
1975
2129
 
1976
2130
  if (video) {
@@ -1990,6 +2144,34 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
1990
2144
  path: paths.raw,
1991
2145
  });
1992
2146
 
2147
+ // No explicit or selector-driven start: fall back to detecting where
2148
+ // the blank startup ends in the recorded pixels, and trim to there
2149
+ // when the lead-in is long enough to be worth removing. The second
2150
+ // `undefined` check guards the window across the ffmpeg await: if a
2151
+ // selector start landed meanwhile, it wins.
2152
+ //
2153
+ // This start is in the raw recording's timebase (t=0 = context
2154
+ // creation), which is exactly what the ffmpeg trim wants. Highlights
2155
+ // and dead-air are in videoMode time (from `startedAt` at plugin
2156
+ // construction); the two origins differ only by the fixture-setup gap
2157
+ // between context creation and construction — sub-frame in practice and
2158
+ // independent of how long the app takes to paint — so annotations stay
2159
+ // aligned with the trimmed video. (`setStartTime` instead shares the
2160
+ // videoMode timebase, so its trim and annotations shift together.)
2161
+ if (trimStart.detectBlank && state.sourceRange.start === undefined) {
2162
+ const detectedStart = await detectBlankLeadInEndMs(paths.raw);
2163
+ if (
2164
+ detectedStart !== undefined &&
2165
+ detectedStart >= TRIM_START_MIN_LEAD_IN_MS &&
2166
+ state.sourceRange.start === undefined
2167
+ ) {
2168
+ state.sourceRange.start = detectedStart;
2169
+ }
2170
+ }
2171
+
2172
+ // Read fresh, so an explicit, selector, or pixel-detected start all show.
2173
+ const sourceRange = metadataFor(state).sourceRange;
2174
+
1993
2175
  if (
1994
2176
  highlights.length > 0 ||
1995
2177
  deadAirThreshold !== undefined ||