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 +24 -0
- package/dist/plugins/spinner-waiter.js +11 -2
- package/dist/plugins/video-mode.d.ts +18 -0
- package/dist/plugins/video-mode.js +136 -2
- package/package.json +2 -2
- package/src/plugins/spinner-waiter.ts +12 -2
- package/src/plugins/video-mode.ts +184 -2
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ||
|