middlewright 0.1.0 → 0.1.1

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.
@@ -1,124 +1,1468 @@
1
- var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
- if (value !== null && value !== void 0) {
3
- if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
- var dispose, inner;
5
- if (async) {
6
- if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
- dispose = value[Symbol.asyncDispose];
8
- }
9
- if (dispose === void 0) {
10
- if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
- dispose = value[Symbol.dispose];
12
- if (async) inner = dispose;
13
- }
14
- if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
- if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
- env.stack.push({ value: value, dispose: dispose, async: async });
17
- }
18
- else if (async) {
19
- env.stack.push({ async: true });
1
+ /**
2
+ * video-mode: record action timings and render watchable videos after the run.
3
+ *
4
+ * Extracted from the iterate monorepo's internal Playwright test
5
+ * infrastructure (github.com/iterate/iterate, private). Modification from the
6
+ * original: the hardcoded skip for iterate's test-helpers file is now the
7
+ * `skipStackFrames` option.
8
+ */
9
+ import { execFile as execFileCallback } from "node:child_process";
10
+ import { createHash } from "node:crypto";
11
+ import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
12
+ import { extname, join } from "node:path";
13
+ import { promisify } from "node:util";
14
+ const execFile = promisify(execFileCallback);
15
+ const VIDEO_MODE_METADATA_FILE = "video-mode.json";
16
+ const VIDEO_MODE_PLAYER_FILE = "video-mode.html";
17
+ const VIDEO_MODE_RAW_FILE = "video-raw.webm";
18
+ const VIDEO_MODE_RENDERED_FILE = "video-rendered.webm";
19
+ const VIDEO_MODE_REPORT_PLAYER_FILE = "video-mode-report.html";
20
+ const VIDEO_MODE_POINTER_FILE = "video-mode-pointer.png";
21
+ const VIDEO_MODE_CLICK_POINTER_FILE = "video-mode-click-pointer.png";
22
+ const VIDEO_MODE_TEXT_POINTER_FILE = "video-mode-text-pointer.png";
23
+ // Pointer assets adapted from Pictogrammers Material Design Icons:
24
+ // cursor-default.svg, cursor-pointer.svg, and cursor-text.svg.
25
+ // Source: https://github.com/Templarian/MaterialDesign
26
+ // Icons are distributed under the Pictogrammers Free License / Apache 2.0.
27
+ const VIDEO_MODE_POINTER_PNG = "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";
28
+ const VIDEO_MODE_POINTER_SOURCE_SIZE = 64;
29
+ const VIDEO_MODE_POINTER_SIZE = 28;
30
+ const VIDEO_MODE_POINTER_HOTSPOT = { x: 18, y: 6 };
31
+ const VIDEO_MODE_CLICK_POINTER_PNG = "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";
32
+ const VIDEO_MODE_CLICK_POINTER_SOURCE_SIZE = 64;
33
+ const VIDEO_MODE_CLICK_POINTER_SIZE = 28;
34
+ const VIDEO_MODE_CLICK_POINTER_HOTSPOT = { x: 28, y: 7 };
35
+ const VIDEO_MODE_TEXT_POINTER_PNG = "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFGklEQVR4nOxbS0scWRT+Wk3PTDIOzEJm48LxgathZJwHoszoZsBNEERBFDdCIBBXLsSliIu48Se4EcFlNro0voKBCHkICcFHC+4kkMTEmLajuafqnsqt6mr7VnnLB1UfHOrR99y696tzH1R/pwQxRwlijjJEh1SBcxWn0PMv5HOWvxZSMA+qs0XYDXntjbJ3wp7K81PP8XdhPyv1qDgW9kiWPfX4hibCdARQo18Lq9UoOy3svvQZEdaj4fNS2J/CTjxGCEWCyQiguv4Q9iSAz3t5/EnfBf8Jey4spxiRECoSTEZASq2vvLwcjY2NeYU2Nzext7fHl66OV1ZWorY2P3jW19dxcHDgVC3sprDP8pneIREIpgjgSHLGe0VFBRYWFnwLT09PY2RkxCGCOj4xMYGeHv9RUFNToxJwSxo9izr9BVcoArSGVF9fH7LZLAYGBqzrsbGxgp33wY+wCaDOqsOA2xCIhMgI2N/fR1tbm6tAVVWVda+/vx/d3d04OjpCOp1GV1eX9fvU1BQWFxexu7vr8qO6FKSFfS8sC3ulKZEWuPPcaBOgekqF/QV7qToTzc3NWFlZcd1ramrC2toaNHAP9iT4ThqNjU+wCeGhoA3TO8Fnwj4WK7S6uorx8XHnenR0VLfz1NEMAgy3YohiJ/ibsL/xbbb+AXbYUrj+L+wfKrS8vOw4LC0tqf6PhT2EPbmpMzyN80153zvxXYmNEDWCGvUW9jAgAnjC+g42AR8gCdjZ2XEcM5mMWs883B3lzhIBtBs8gr0EZuHeA4SC6QjgZYkaRw0tlfd4wvrABXO5XKE6yJ87m5XHnOfeIWwSjpEfKYFgigB+OL8paiSv03Sels8qOj/IOvhN05hX3/axtM/yd7rPERIKUQwBaigvSXR+Qz4nDAGHcL9tNRKOlXuXvhNk8BAgMBnU+FLoE0D+/JY/SR/1bXst9C4QMB8BDG4YHSkaaDgQCVnNepgEKs9EMAEcaae4YqsAgRvD6/SJ5zynWc8J3DM/H71v+1ydJ0T1RUhtWEoxncaqnWMi/N68EVzEN8Ewn7D8OnnucPdDlN8EVRhttEnE/qtwQgBijoQAxBwJAYg5EgIQcyQEIOZICEDMkRCAmCMhADFHQgBijoQAxBwJAYg5EgIQcyRyeVwMolClG8FF5Auo/w47zysrKzvLr5Cx7Eb9B/lciIKAQvkCpBS7w4Xq6uoch/r6emxvb/Nlh6yDBJAkqmKJDIkkSF2p/k2uiqVDIYp8AdL01xcr2NnZ6Zx3dHRgfn6eL5ul+eGVMJKgX+98gdbW1jwleUtLi6Ug1cC/wl7guuYLNDQ0YHJy0jqfm5tDKpVCe3u7pR0eHBzExsZGno8nX4ByDIzlC5gUS5ORCtQSS1dXV2Nra6ugw8zMDHp7e63z2dlZSz1eCJQvoMwRpKtfhz0n0PxACjIWTAbWC5rcB2gLmClhYnh42LkeGhqy7mmCtMckvyXJPAsw1RUnEEzL5SkCLB28ZsqMC5opM3dhZ51RvtGBNI6CwLrhS8kXUHAojzcD+BjNFzC9DHK+wC2NsovCHsAm7zbsbLBiMJ4vYDoCaEz+Av98gVJZjt56Bv66v1/xLSGK62XQJEcy+jewN0g0BNQIuPQhQI2m3R91mPIEvPkC6mSlqstVErgePqqTNCtHabxTlPH4Zy1xqNyBi8wX4Ajgcn67OQLripkI1YfF1yyjvzb5ApzddRYBagQwCV4CVCW5kXyBrwAAAP//qVxqLgAAAAZJREFUAwCpkwfomMTHxQAAAABJRU5ErkJggg==";
36
+ const VIDEO_MODE_TEXT_POINTER_SOURCE_SIZE = 64;
37
+ const VIDEO_MODE_TEXT_POINTER_SIZE = 28;
38
+ const VIDEO_MODE_TEXT_POINTER_HOTSPOT = { x: 32, y: 32 };
39
+ const CURSOR_MOVEMENT_MIN_MS = 200;
40
+ const CURSOR_MOVEMENT_MAX_MS = 1000;
41
+ const CURSOR_MOVEMENT_MIN_IDEAL_MS = 300;
42
+ const CURSOR_MOVEMENT_MAX_IDEAL_MS = 700;
43
+ const CURSOR_MOVEMENT_SPEED_PX_PER_SECOND = 600;
44
+ const CURSOR_REST_BEFORE_ACTION_MS = 200;
45
+ const CURSOR_TARGET_HOLD_IDEAL_MS = 1000;
46
+ const TEXT_CURSOR_HOLD_IDEAL_MS = 800;
47
+ const TEXT_CURSOR_POINTER_TAIL_MS = 200;
48
+ const resolveDeadAirThreshold = (thresholdMs) => {
49
+ if (thresholdMs === undefined) {
50
+ return undefined;
51
+ }
52
+ if (!Number.isFinite(thresholdMs) || thresholdMs < 0) {
53
+ throw new Error("videoMode deadAirThreshold must be a non-negative number");
54
+ }
55
+ return thresholdMs;
56
+ };
57
+ const resolveNonNegativeNumber = (options) => {
58
+ const value = options.value === undefined ? options.defaultValue : options.value;
59
+ if (!Number.isFinite(value) || value < 0) {
60
+ throw new Error(`${options.name} must be a non-negative number`);
20
61
  }
21
62
  return value;
22
63
  };
23
- var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
- return function (env) {
25
- function fail(e) {
26
- env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
- env.hasError = true;
64
+ const parseOutlineHighlightStyle = (style) => {
65
+ const match = /^([0-9]+(?:\.[0-9]+)?)px solid (.+)$/.exec(style.trim());
66
+ if (!match) {
67
+ throw new Error("videoMode highlight.style must look like '1px solid yellow'");
68
+ }
69
+ const thickness = Number(match[1]);
70
+ const color = match[2].trim();
71
+ if (!Number.isFinite(thickness) || thickness < 0 || color.length === 0) {
72
+ throw new Error("videoMode highlight.style must look like '1px solid yellow'");
73
+ }
74
+ return { color, thickness };
75
+ };
76
+ const resolveVideoModeHighlight = (options) => {
77
+ const rawHighlight = options.highlight === undefined ? true : options.highlight;
78
+ if (rawHighlight === false) {
79
+ return { mode: "off" };
80
+ }
81
+ if (rawHighlight === true) {
82
+ return {
83
+ color: "gold",
84
+ durationMs: resolveNonNegativeNumber({
85
+ defaultValue: 1000,
86
+ name: "videoMode highlight.duration",
87
+ value: undefined,
88
+ }),
89
+ mode: "pointer",
90
+ thickness: 3,
91
+ };
92
+ }
93
+ if (!rawHighlight || typeof rawHighlight !== "object" || !("mode" in rawHighlight)) {
94
+ throw new Error("videoMode highlight must be true, false, or a highlight options object");
95
+ }
96
+ const durationMs = resolveNonNegativeNumber({
97
+ defaultValue: 1000,
98
+ name: "videoMode highlight.duration",
99
+ value: rawHighlight.duration,
100
+ });
101
+ if (rawHighlight.mode === "pointer") {
102
+ return {
103
+ color: "gold",
104
+ durationMs,
105
+ mode: "pointer",
106
+ thickness: 3,
107
+ };
108
+ }
109
+ if (rawHighlight.mode === "outline") {
110
+ const parsed = parseOutlineHighlightStyle(rawHighlight.style || "3px solid gold");
111
+ return {
112
+ color: parsed.color,
113
+ durationMs,
114
+ mode: "outline",
115
+ thickness: parsed.thickness,
116
+ };
117
+ }
118
+ throw new Error("videoMode highlight.mode must be 'pointer' or 'outline'");
119
+ };
120
+ const resolveVideoTimestamp = (name, ms) => {
121
+ if (!Number.isFinite(ms) || ms < 0) {
122
+ throw new Error(`videoMode.${name}() requires a non-negative timestamp`);
123
+ }
124
+ return Math.round(ms);
125
+ };
126
+ const normalizeSourceRange = (sourceRange) => {
127
+ const normalized = {};
128
+ if (sourceRange.start !== undefined) {
129
+ normalized.start = Math.round(sourceRange.start);
130
+ }
131
+ if (sourceRange.end !== undefined) {
132
+ normalized.end = Math.round(sourceRange.end);
133
+ }
134
+ return normalized;
135
+ };
136
+ const sourceRangeIsSet = (sourceRange) => {
137
+ return sourceRange.start !== undefined || sourceRange.end !== undefined;
138
+ };
139
+ const metadataFor = (state) => {
140
+ return {
141
+ deadAir: mergeVideoSpans(state.deadAirSpans),
142
+ highlights: normalizeVideoHighlights(state.highlights),
143
+ outputs: state.outputs,
144
+ schemaVersion: 1,
145
+ sourceRange: normalizeSourceRange(state.sourceRange),
146
+ timebase: "ms",
147
+ };
148
+ };
149
+ const videoModeOutputPaths = (testInfo) => {
150
+ return {
151
+ metadata: join(testInfo.outputDir, VIDEO_MODE_METADATA_FILE),
152
+ player: join(testInfo.outputDir, VIDEO_MODE_PLAYER_FILE),
153
+ raw: join(testInfo.outputDir, VIDEO_MODE_RAW_FILE),
154
+ rendered: join(testInfo.outputDir, VIDEO_MODE_RENDERED_FILE),
155
+ reportPlayer: join(testInfo.outputDir, VIDEO_MODE_REPORT_PLAYER_FILE),
156
+ };
157
+ };
158
+ const readVideoModeMetadata = async (path, fallback) => {
159
+ try {
160
+ return JSON.parse(await readFile(path, "utf8"));
161
+ }
162
+ catch (error) {
163
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
164
+ return fallback();
28
165
  }
29
- var r, s = 0;
30
- function next() {
31
- while (r = env.stack.pop()) {
32
- try {
33
- if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
- if (r.dispose) {
35
- var result = r.dispose.call(r.value);
36
- if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
- }
38
- else s |= 1;
39
- }
40
- catch (e) {
41
- fail(e);
42
- }
43
- }
44
- if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
- if (env.hasError) throw env.error;
166
+ throw error;
167
+ }
168
+ };
169
+ const recordHighlight = async (options) => {
170
+ if (options.state.startedAt === undefined || options.durationMs <= 0) {
171
+ return;
172
+ }
173
+ if (!(await locatorIsAttached(options.locator))) {
174
+ return;
175
+ }
176
+ try {
177
+ const snapshot = await options.locator.evaluate((element) => {
178
+ const rect = element.getBoundingClientRect();
179
+ return {
180
+ rect: {
181
+ height: rect.height,
182
+ width: rect.width,
183
+ x: rect.left,
184
+ y: rect.top,
185
+ },
186
+ viewport: {
187
+ height: window.innerHeight,
188
+ width: window.innerWidth,
189
+ },
190
+ };
191
+ });
192
+ if (snapshot.rect.width <= 0 ||
193
+ snapshot.rect.height <= 0 ||
194
+ snapshot.viewport.width <= 0 ||
195
+ snapshot.viewport.height <= 0) {
196
+ return;
46
197
  }
47
- return next();
48
- };
49
- })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
- var e = new Error(message);
51
- return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
- });
53
- /** Highlight element, pause, return disposable that unhighlights */
54
- const setupHighlight = async (locator, style, pauseMs) => {
198
+ const image = `video-mode-highlight-${options.state.highlightImageIndex}.png`;
199
+ options.state.highlightImageIndex += 1;
200
+ const imagePath = join(options.testInfo.outputDir, image);
201
+ await mkdir(options.testInfo.outputDir, { recursive: true });
202
+ await options.locator.page().screenshot({ path: imagePath, scale: "css" });
203
+ const start = Math.round(performance.now() - options.state.startedAt);
204
+ const highlight = {
205
+ color: options.color,
206
+ end: start + Math.round(options.durationMs),
207
+ image,
208
+ method: options.method,
209
+ rect: snapshot.rect,
210
+ start,
211
+ thickness: options.thickness,
212
+ viewport: snapshot.viewport,
213
+ };
214
+ options.state.highlights.push(highlight);
215
+ return highlight;
216
+ }
217
+ catch {
218
+ // Element may disappear between the actionability wait and the snapshot.
219
+ }
220
+ };
221
+ const recordDeadAirSpan = (state, span) => {
222
+ const start = Math.round(span.start);
223
+ const end = Math.round(span.end);
224
+ if (end > start) {
225
+ state.deadAirSpans.push({ end, start });
226
+ }
227
+ };
228
+ const recordDeadAir = async (state, action) => {
229
+ if (state.startedAt === undefined || state.deadAirDepth > 0) {
230
+ return await action();
231
+ }
232
+ const start = performance.now() - state.startedAt;
233
+ state.deadAirDepth += 1;
234
+ try {
235
+ return await action();
236
+ }
237
+ finally {
238
+ state.deadAirDepth -= 1;
239
+ const end = performance.now() - state.startedAt;
240
+ recordDeadAirSpan(state, {
241
+ end: Math.round(end),
242
+ start: Math.round(start),
243
+ });
244
+ }
245
+ };
246
+ const mergeVideoSpans = (spans) => {
247
+ const sorted = spans
248
+ .map((span) => ({
249
+ end: Math.round(span.end),
250
+ start: Math.round(span.start),
251
+ }))
252
+ .filter((span) => span.end > span.start)
253
+ .sort((left, right) => left.start - right.start || left.end - right.end);
254
+ const merged = [];
255
+ for (const span of sorted) {
256
+ const previous = merged[merged.length - 1];
257
+ if (previous && span.start <= previous.end) {
258
+ previous.end = Math.max(previous.end, span.end);
259
+ continue;
260
+ }
261
+ merged.push(span);
262
+ }
263
+ return merged;
264
+ };
265
+ const normalizeVideoHighlights = (highlights) => {
266
+ return highlights
267
+ .map((highlight) => ({
268
+ ...highlight,
269
+ actionEnd: highlight.actionEnd === undefined
270
+ ? undefined
271
+ : Math.max(Math.round(highlight.actionEnd), Math.round(highlight.start)),
272
+ end: Math.round(highlight.end),
273
+ rect: {
274
+ height: Math.round(highlight.rect.height),
275
+ width: Math.round(highlight.rect.width),
276
+ x: Math.round(highlight.rect.x),
277
+ y: Math.round(highlight.rect.y),
278
+ },
279
+ start: Math.round(highlight.start),
280
+ thickness: Math.round(highlight.thickness),
281
+ viewport: {
282
+ height: Math.round(highlight.viewport.height),
283
+ width: Math.round(highlight.viewport.width),
284
+ },
285
+ }))
286
+ .filter((highlight) => highlight.end > highlight.start)
287
+ .filter((highlight) => highlight.rect.width > 0 && highlight.rect.height > 0)
288
+ .filter((highlight) => highlight.viewport.width > 0 && highlight.viewport.height > 0)
289
+ .sort((left, right) => left.start - right.start || left.end - right.end);
290
+ };
291
+ const formatSeconds = (ms) => {
292
+ const value = (ms / 1000).toFixed(3).replace(/\.?0+$/, "");
293
+ return value || "0";
294
+ };
295
+ const formatFilterNumber = (value) => {
296
+ return Number(value.toFixed(6)).toString();
297
+ };
298
+ const clipVideoSpan = (span, range) => {
299
+ const start = Math.max(range.start, Math.min(Math.round(span.start), range.end));
300
+ const end = Math.max(range.start, Math.min(Math.round(span.end), range.end));
301
+ if (end <= start) {
302
+ return undefined;
303
+ }
304
+ return { end, start };
305
+ };
306
+ const videoSpansOverlap = (left, right) => {
307
+ return left.start < right.end && right.start < left.end;
308
+ };
309
+ const deadAirSpeed = (span, thresholdMs) => {
310
+ const duration = span.end - span.start;
311
+ if (duration <= thresholdMs) {
312
+ return 1;
313
+ }
314
+ if (thresholdMs === 0) {
315
+ return Infinity;
316
+ }
317
+ return duration / thresholdMs;
318
+ };
319
+ const locatorIsAttached = async (locator) => {
55
320
  try {
56
- await locator.evaluate((el, s) => {
57
- const prev = el.getAttribute("style") || "";
58
- el.setAttribute("data-video-prev-style", prev);
59
- el.setAttribute("style", `${prev}; outline: ${s} !important; outline-offset: 2px !important;`);
60
- }, style);
321
+ return (await locator.count()) > 0;
61
322
  }
62
323
  catch {
63
- // Element may not be ready yet, ignore
324
+ return false;
325
+ }
326
+ };
327
+ const recordAttachedWaitFromTiming = (state, timing) => {
328
+ if (state.startedAt === undefined || timing.attachedAtStart) {
329
+ return;
330
+ }
331
+ if (timing.attachedAt === undefined) {
332
+ return;
333
+ }
334
+ const start = Math.round(timing.actionStartedAt - state.startedAt);
335
+ const end = Math.round(timing.attachedAt - state.startedAt);
336
+ recordDeadAirSpan(state, { end, start });
337
+ };
338
+ const recordActionElapsedDeadAirFromTiming = (state, timing, options) => {
339
+ if (state.startedAt === undefined) {
340
+ return;
341
+ }
342
+ const start = Math.round(timing.actionStartedAt - state.startedAt);
343
+ const end = Math.round(performance.now() - state.startedAt);
344
+ if (end - start < options.minimumMs) {
345
+ return;
346
+ }
347
+ recordDeadAirSpan(state, { end, start });
348
+ };
349
+ const recordMiddlewareWaitBeforeVideoMode = (state, timing) => {
350
+ if (state.startedAt === undefined) {
351
+ return;
64
352
  }
65
- await new Promise((resolve) => setTimeout(resolve, pauseMs));
353
+ const currentMiddleware = [...timing.middlewares]
354
+ .reverse()
355
+ .find((middleware) => middleware.name === "video-mode" && middleware.endedAt === undefined);
356
+ if (!currentMiddleware) {
357
+ return;
358
+ }
359
+ const start = Math.round(timing.actionStartedAt - state.startedAt);
360
+ const end = Math.round(currentMiddleware.startedAt - state.startedAt);
361
+ recordDeadAirSpan(state, { end, start });
362
+ };
363
+ const renderVideoSegments = (options) => {
364
+ const finalEnd = Math.max(0, Math.round(options.finalEnd));
365
+ const start = Math.max(0, Math.min(Math.round(options.start), finalEnd));
366
+ if (finalEnd <= start) {
367
+ return [];
368
+ }
369
+ if (options.thresholdMs === undefined) {
370
+ return [{ end: finalEnd, speed: 1, start }];
371
+ }
372
+ const thresholdMs = options.thresholdMs;
373
+ const deadAir = mergeVideoSpans(options.deadAir
374
+ .map((span) => clipVideoSpan(span, { end: finalEnd, start }))
375
+ .filter((span) => Boolean(span)));
376
+ const boundaries = new Set([start, finalEnd]);
377
+ for (const span of deadAir) {
378
+ boundaries.add(span.start);
379
+ boundaries.add(span.end);
380
+ }
381
+ const sortedBoundaries = [...boundaries].sort((left, right) => left - right);
382
+ const segments = [];
383
+ for (let index = 0; index < sortedBoundaries.length - 1; index += 1) {
384
+ const start = sortedBoundaries[index];
385
+ const end = sortedBoundaries[index + 1];
386
+ if (end <= start) {
387
+ continue;
388
+ }
389
+ const deadAirSpan = deadAir.find((span) => videoSpansOverlap(span, { end, start }));
390
+ const speed = deadAirSpan ? deadAirSpeed(deadAirSpan, thresholdMs) : 1;
391
+ if (!Number.isFinite(speed)) {
392
+ continue;
393
+ }
394
+ const previous = segments[segments.length - 1];
395
+ if (previous && previous.end === start && previous.speed === speed) {
396
+ previous.end = end;
397
+ continue;
398
+ }
399
+ segments.push({ end, speed, start });
400
+ }
401
+ return segments;
402
+ };
403
+ const videoPieces = (options) => {
404
+ const pieces = [];
405
+ const frameMs = 50;
406
+ for (const segment of options.segments) {
407
+ let cursor = segment.start;
408
+ const highlights = options.highlights.filter((highlight) => highlight.start >= segment.start && highlight.start < segment.end);
409
+ for (let index = 0; index < highlights.length; index += 1) {
410
+ const highlight = highlights[index];
411
+ const nextHighlight = highlights[index + 1];
412
+ if (highlight.start > cursor) {
413
+ pieces.push({ end: highlight.start, speed: segment.speed, start: cursor });
414
+ }
415
+ let frameStart = Math.max(segment.start, highlight.start - frameMs);
416
+ let frameEnd = highlight.start;
417
+ if (frameEnd <= frameStart) {
418
+ frameStart = highlight.start;
419
+ frameEnd = Math.min(segment.end, frameStart + frameMs);
420
+ }
421
+ if (frameEnd > frameStart) {
422
+ pieces.push({ end: frameEnd, highlight, speed: segment.speed, start: frameStart });
423
+ }
424
+ let nextCursor = Math.max(highlight.start, highlight.actionEnd || highlight.start);
425
+ if (nextHighlight && highlight.end > nextHighlight.start) {
426
+ nextCursor = Math.max(nextCursor, nextHighlight.start);
427
+ }
428
+ cursor = Math.min(segment.end, nextCursor);
429
+ }
430
+ if (segment.end > cursor) {
431
+ pieces.push({ end: segment.end, speed: segment.speed, start: cursor });
432
+ }
433
+ }
434
+ return pieces.filter((piece) => piece.end > piece.start);
435
+ };
436
+ const scaleHighlight = (highlight, video) => {
437
+ const scale = Math.min(video.width / highlight.viewport.width, video.height / highlight.viewport.height);
438
+ const x = Math.max(0, Math.round(highlight.rect.x * scale));
439
+ const y = Math.max(0, Math.round(highlight.rect.y * scale));
440
+ const width = Math.max(1, Math.round(highlight.rect.width * scale));
441
+ const height = Math.max(1, Math.round(highlight.rect.height * scale));
66
442
  return {
67
- [Symbol.dispose]: () => {
68
- // Fire-and-forget cleanup - don't wait for it
69
- locator
70
- .evaluate((el) => {
71
- const prev = el.getAttribute("data-video-prev-style");
72
- if (typeof prev === "string") {
73
- el.setAttribute("style", prev);
74
- el.removeAttribute("data-video-prev-style");
75
- }
76
- })
77
- .catch(() => {
78
- // Element may be gone or not actionable, ignore
79
- });
80
- },
443
+ height: Math.min(height, Math.max(1, video.height - y)),
444
+ width: Math.min(width, Math.max(1, video.width - x)),
445
+ x,
446
+ y,
447
+ };
448
+ };
449
+ const scaledViewportSize = (viewport, video) => {
450
+ const scale = Math.min(video.width / viewport.width, video.height / viewport.height);
451
+ return {
452
+ height: Math.max(1, Math.round(viewport.height * scale)),
453
+ width: Math.max(1, Math.round(viewport.width * scale)),
454
+ };
455
+ };
456
+ const drawboxFilter = (highlight, video) => {
457
+ const rect = scaleHighlight(highlight, video);
458
+ return [
459
+ `drawbox=x=${rect.x}`,
460
+ `y=${rect.y}`,
461
+ `w=${rect.width}`,
462
+ `h=${rect.height}`,
463
+ `color=${highlight.color}`,
464
+ `t=${Math.max(1, Math.round(highlight.thickness))}`,
465
+ ].join(":");
466
+ };
467
+ const renderedPieceDuration = (piece) => {
468
+ const sourceDuration = (piece.end - piece.start) / piece.speed;
469
+ if (!piece.highlight) {
470
+ return sourceDuration;
471
+ }
472
+ const highlightDuration = piece.highlight.end - piece.highlight.start;
473
+ if (piece.highlight.image) {
474
+ return highlightDuration;
475
+ }
476
+ return Math.max(sourceDuration, highlightDuration);
477
+ };
478
+ const renderedVideoPieces = (pieces) => {
479
+ let cursor = 0;
480
+ const rendered = [];
481
+ for (const piece of pieces) {
482
+ const duration = renderedPieceDuration(piece);
483
+ rendered.push({
484
+ ...piece,
485
+ outputEnd: cursor + duration,
486
+ outputStart: cursor,
487
+ });
488
+ cursor += duration;
489
+ }
490
+ return rendered;
491
+ };
492
+ const highlightCursorPoint = (highlight, video) => {
493
+ const rect = scaleHighlight(highlight, video);
494
+ return {
495
+ x: Math.max(0, Math.min(video.width - 1, rect.x + rect.width / 2)),
496
+ y: Math.max(0, Math.min(video.height - 1, rect.y + rect.height / 2)),
497
+ };
498
+ };
499
+ const pushCursorWaypoint = (waypoints, waypoint) => {
500
+ const rounded = {
501
+ at: Math.round(waypoint.at),
502
+ x: Math.round(waypoint.x),
503
+ y: Math.round(waypoint.y),
504
+ };
505
+ const previous = waypoints[waypoints.length - 1];
506
+ if (previous && previous.at === rounded.at) {
507
+ previous.x = rounded.x;
508
+ previous.y = rounded.y;
509
+ return;
510
+ }
511
+ waypoints.push(rounded);
512
+ };
513
+ const cursorTargets = (options) => {
514
+ const targets = [];
515
+ for (const highlight of options.highlights) {
516
+ const piece = options.pieces.find((candidate) => candidate.highlight === highlight);
517
+ if (!piece) {
518
+ continue;
519
+ }
520
+ targets.push({
521
+ method: highlight.method,
522
+ outputEnd: piece.outputEnd,
523
+ outputStart: piece.outputStart,
524
+ point: highlightCursorPoint(highlight, options.video),
525
+ });
526
+ }
527
+ return targets;
528
+ };
529
+ const cursorMovementIdealDuration = (options) => {
530
+ const distance = Math.hypot(options.target.point.x - options.currentPoint.x, options.target.point.y - options.currentPoint.y);
531
+ const duration = (distance / CURSOR_MOVEMENT_SPEED_PX_PER_SECOND) * 1000;
532
+ return Math.max(CURSOR_MOVEMENT_MIN_IDEAL_MS, Math.min(CURSOR_MOVEMENT_MAX_IDEAL_MS, duration));
533
+ };
534
+ const cursorArrivalDeadline = (options) => {
535
+ const target = options.target;
536
+ const latestArrivalWithMinimumRest = Math.max(target.outputStart, target.outputEnd - CURSOR_REST_BEFORE_ACTION_MS);
537
+ const idealHoldArrival = Math.max(target.outputStart, target.outputEnd - CURSOR_TARGET_HOLD_IDEAL_MS);
538
+ const readableMovementArrival = options.earliestStart + cursorMovementIdealDuration(options);
539
+ return Math.min(latestArrivalWithMinimumRest, Math.max(idealHoldArrival, readableMovementArrival));
540
+ };
541
+ const cursorMovementTiming = (options) => {
542
+ const deadline = cursorArrivalDeadline(options);
543
+ const available = Math.max(0, deadline - options.earliestStart);
544
+ const idealDuration = Math.min(cursorMovementIdealDuration(options), CURSOR_MOVEMENT_MAX_MS);
545
+ const duration = available < CURSOR_MOVEMENT_MIN_MS ? available : Math.min(idealDuration, available);
546
+ const startAt = Math.max(options.earliestStart, deadline - duration);
547
+ return {
548
+ arriveAt: startAt + duration,
549
+ startAt,
81
550
  };
82
551
  };
83
552
  /**
84
- * Highlights elements before actions and pauses for video recording.
85
- * Also pauses after tests complete for better video endings.
553
+ * Plan cursor motion backwards from each action's click/commit moment.
554
+ *
555
+ * The cursor starts in the center and switches to a target-specific shape only
556
+ * after arriving. Movement aims for a readable distance-based speed, clamped to
557
+ * a short 300-700ms range, while still compressing toward 200ms when the
558
+ * existing video timeline has no room. The arrival point preserves an ideal 1s
559
+ * target hold when possible and otherwise gives the hand/text cursor most of
560
+ * the configured highlight hold without extending product interaction time.
86
561
  */
562
+ const cursorPlan = (targets, video) => {
563
+ const waypoints = [];
564
+ const plannedTargets = [];
565
+ let currentPoint = {
566
+ x: video.width / 2,
567
+ y: video.height / 2,
568
+ };
569
+ let earliestStart = 0;
570
+ if (targets.length === 0) {
571
+ return { targets: plannedTargets, waypoints };
572
+ }
573
+ pushCursorWaypoint(waypoints, {
574
+ at: 0,
575
+ x: currentPoint.x,
576
+ y: currentPoint.y,
577
+ });
578
+ for (let index = 0; index < targets.length; index += 1) {
579
+ const target = targets[index];
580
+ const nextTarget = targets[index + 1];
581
+ const movement = cursorMovementTiming({ currentPoint, earliestStart, target });
582
+ const holdEnd = nextTarget
583
+ ? Math.min(target.outputEnd, nextTarget.outputStart)
584
+ : target.outputEnd;
585
+ pushCursorWaypoint(waypoints, {
586
+ at: movement.startAt,
587
+ x: currentPoint.x,
588
+ y: currentPoint.y,
589
+ });
590
+ pushCursorWaypoint(waypoints, {
591
+ at: movement.arriveAt,
592
+ x: target.point.x,
593
+ y: target.point.y,
594
+ });
595
+ pushCursorWaypoint(waypoints, {
596
+ at: Math.max(holdEnd, movement.arriveAt),
597
+ x: target.point.x,
598
+ y: target.point.y,
599
+ });
600
+ plannedTargets.push({
601
+ ...target,
602
+ arriveAt: movement.arriveAt,
603
+ });
604
+ currentPoint = target.point;
605
+ earliestStart = Math.max(earliestStart, target.outputEnd);
606
+ }
607
+ return { targets: plannedTargets, waypoints };
608
+ };
609
+ const methodCursorSpans = (targets, methods) => {
610
+ return targets
611
+ .filter((target) => target.method !== undefined && methods.includes(target.method))
612
+ .map((target) => ({
613
+ end: target.outputEnd,
614
+ start: target.arriveAt,
615
+ }))
616
+ .filter((span) => span.end > span.start);
617
+ };
618
+ const textCursorSpans = (targets) => {
619
+ return targets
620
+ .filter((target) => target.method === "fill" || target.method === "type")
621
+ .map((target) => ({
622
+ end: Math.min(target.arriveAt + TEXT_CURSOR_HOLD_IDEAL_MS, Math.max(target.arriveAt, target.outputEnd - TEXT_CURSOR_POINTER_TAIL_MS)),
623
+ start: target.arriveAt,
624
+ }))
625
+ .filter((span) => span.end > span.start);
626
+ };
627
+ const cursorActivitySpan = (targets, waypoints) => {
628
+ if (targets.length === 0 || waypoints.length === 0) {
629
+ return undefined;
630
+ }
631
+ const lastTarget = targets[targets.length - 1];
632
+ const tailDuration = Math.max(0, lastTarget.outputEnd - lastTarget.outputStart);
633
+ return {
634
+ end: lastTarget.outputEnd + tailDuration,
635
+ start: waypoints[0].at,
636
+ };
637
+ };
638
+ const cursorExpression = (waypoints, property) => {
639
+ let expression = formatFilterNumber(waypoints[waypoints.length - 1][property]);
640
+ for (let index = waypoints.length - 2; index >= 0; index -= 1) {
641
+ const from = waypoints[index];
642
+ const to = waypoints[index + 1];
643
+ if (to.at <= from.at) {
644
+ continue;
645
+ }
646
+ const start = formatSeconds(from.at);
647
+ const end = formatSeconds(to.at);
648
+ const progress = `((t-${start})/(${end}-${start}))`;
649
+ const eased = `((${progress})*(${progress})*(3-2*(${progress})))`;
650
+ const delta = to[property] - from[property];
651
+ const value = delta === 0
652
+ ? formatFilterNumber(from[property])
653
+ : `(${formatFilterNumber(from[property])}+(${formatFilterNumber(delta)})*${eased})`;
654
+ expression = `if(between(t\\,${start}\\,${end})\\,${value}\\,${expression})`;
655
+ }
656
+ return expression;
657
+ };
658
+ const cursorOverlayFilters = (options) => {
659
+ if (options.waypoints.length === 0) {
660
+ return `[${options.inputLabel}]null[${options.outputLabel}]`;
661
+ }
662
+ const pointerScale = options.pointerInput.size / options.pointerInput.sourceSize;
663
+ const x = `(${cursorExpression(options.waypoints, "x")})-${formatFilterNumber(options.pointerInput.hotspot.x * pointerScale)}`;
664
+ const y = `(${cursorExpression(options.waypoints, "y")})-${formatFilterNumber(options.pointerInput.hotspot.y * pointerScale)}`;
665
+ return [
666
+ `[${options.pointerInput.inputIndex}:v]scale=w=${options.pointerInput.size}:h=${options.pointerInput.size},format=rgba[pointercursor]`,
667
+ [
668
+ `[${options.inputLabel}][pointercursor]overlay=x='${x}'`,
669
+ `y='${y}'`,
670
+ "eval=frame",
671
+ "eof_action=pass",
672
+ `enable='${options.enable}'[${options.outputLabel}]`,
673
+ ].join(":"),
674
+ ].join(";");
675
+ };
676
+ const videoSpanExpression = (spans) => {
677
+ return spans
678
+ .map((span) => `between(t\\,${formatSeconds(span.start)}\\,${formatSeconds(span.end)})`)
679
+ .join("+");
680
+ };
681
+ const renderedVideoFilter = (options) => {
682
+ const highlightInputByImage = new Map(options.highlightInputs.map((input) => [input.image, input]));
683
+ const pieces = videoPieces({
684
+ highlights: options.highlights,
685
+ segments: options.segments,
686
+ });
687
+ const renderedPieces = renderedVideoPieces(pieces);
688
+ const targets = cursorTargets({
689
+ highlights: options.highlights,
690
+ pieces: renderedPieces,
691
+ video: options.video,
692
+ });
693
+ const plan = cursorPlan(targets, options.video);
694
+ const clickSpans = methodCursorSpans(plan.targets, ["click"]);
695
+ const textSpans = textCursorSpans(plan.targets);
696
+ const activitySpan = cursorActivitySpan(targets, plan.waypoints);
697
+ const clickSpanExpression = videoSpanExpression(clickSpans);
698
+ const textSpanExpression = videoSpanExpression(textSpans);
699
+ if (pieces.length === 0) {
700
+ return undefined;
701
+ }
702
+ const filters = [];
703
+ const labels = [];
704
+ for (let index = 0; index < pieces.length; index += 1) {
705
+ const piece = pieces[index];
706
+ const label = `render${index}`;
707
+ labels.push(`[${label}]`);
708
+ const operations = [];
709
+ if (piece.highlight?.image && highlightInputByImage.has(piece.highlight.image)) {
710
+ const input = highlightInputByImage.get(piece.highlight.image);
711
+ const scaledViewport = scaledViewportSize(piece.highlight.viewport, options.video);
712
+ operations.push(`[${input.inputIndex}:v]scale=w=${scaledViewport.width}:h=${scaledViewport.height}`);
713
+ operations.push(`pad=w=${options.video.width}:h=${options.video.height}:x=0:y=0:color=gray`);
714
+ if (options.highlightMode === "outline") {
715
+ operations.push(drawboxFilter(piece.highlight, options.video));
716
+ }
717
+ operations.push(`trim=start=0:end=${formatSeconds(piece.highlight.end - piece.highlight.start)}`);
718
+ operations.push("setpts=PTS-STARTPTS");
719
+ }
720
+ else {
721
+ operations.push(`[0:v]trim=start=${formatSeconds(piece.start)}:end=${formatSeconds(piece.end)}`);
722
+ operations.push(`setpts=(PTS-STARTPTS)/${formatFilterNumber(piece.speed)}`);
723
+ }
724
+ if (piece.highlight && !piece.highlight.image) {
725
+ const sourceDuration = (piece.end - piece.start) / piece.speed;
726
+ if (options.highlightMode === "outline") {
727
+ operations.push(drawboxFilter(piece.highlight, options.video));
728
+ }
729
+ operations.push(`tpad=stop_mode=clone:stop_duration=${formatSeconds(Math.max(0, piece.highlight.end - piece.highlight.start - sourceDuration))}`);
730
+ }
731
+ filters.push(`${operations.join(",")}[${label}]`);
732
+ }
733
+ const concatLabel = labels.length === 1 ? labels[0].slice(1, -1) : "renderconcat";
734
+ if (labels.length > 1) {
735
+ filters.push(`${labels.join("")}concat=n=${labels.length}:v=1:a=0[${concatLabel}]`);
736
+ }
737
+ const finalHoldMs = Math.max(0, Math.round(options.finalHoldMs));
738
+ const outputLabel = finalHoldMs > 0 ? "renderout" : concatLabel;
739
+ if (finalHoldMs > 0) {
740
+ filters.push(`[${concatLabel}]tpad=stop_mode=clone:stop_duration=${formatSeconds(finalHoldMs)}[${outputLabel}]`);
741
+ }
742
+ if (options.highlightMode === "pointer" &&
743
+ options.cursorPointerInput &&
744
+ plan.waypoints.length > 0 &&
745
+ activitySpan) {
746
+ const cursorOutputLabel = "renderpointer";
747
+ const cursorActivityExpression = `between(t\\,${formatSeconds(activitySpan.start)}\\,${formatSeconds(activitySpan.end)})`;
748
+ const specialCursorExpression = [clickSpanExpression, textSpanExpression]
749
+ .filter(Boolean)
750
+ .join("+");
751
+ const cursorEnable = specialCursorExpression
752
+ ? `${cursorActivityExpression}*not(${specialCursorExpression})`
753
+ : cursorActivityExpression;
754
+ filters.push(cursorOverlayFilters({
755
+ enable: cursorEnable,
756
+ inputLabel: outputLabel,
757
+ outputLabel: cursorOutputLabel,
758
+ pointerInput: options.cursorPointerInput,
759
+ waypoints: plan.waypoints,
760
+ }));
761
+ let pointerOutputLabel = cursorOutputLabel;
762
+ if (options.textPointerInput && textSpanExpression) {
763
+ const textPointerOutputLabel = "rendertextpointer";
764
+ filters.push(cursorOverlayFilters({
765
+ enable: textSpanExpression,
766
+ inputLabel: pointerOutputLabel,
767
+ outputLabel: textPointerOutputLabel,
768
+ pointerInput: options.textPointerInput,
769
+ waypoints: plan.waypoints,
770
+ }));
771
+ pointerOutputLabel = textPointerOutputLabel;
772
+ }
773
+ if (options.clickPointerInput && clickSpanExpression) {
774
+ const clickPointerOutputLabel = "renderclickpointer";
775
+ filters.push(cursorOverlayFilters({
776
+ enable: clickSpanExpression,
777
+ inputLabel: pointerOutputLabel,
778
+ outputLabel: clickPointerOutputLabel,
779
+ pointerInput: options.clickPointerInput,
780
+ waypoints: plan.waypoints,
781
+ }));
782
+ return {
783
+ outputLabel: clickPointerOutputLabel,
784
+ value: filters.join(";"),
785
+ };
786
+ }
787
+ return {
788
+ outputLabel: pointerOutputLabel,
789
+ value: filters.join(";"),
790
+ };
791
+ }
792
+ return {
793
+ outputLabel,
794
+ value: filters.join(";"),
795
+ };
796
+ };
797
+ const videoInfo = async (path) => {
798
+ const { stdout } = await execFile("ffprobe", [
799
+ "-v",
800
+ "error",
801
+ "-show_entries",
802
+ "format=duration:stream=width,height",
803
+ "-of",
804
+ "json",
805
+ path,
806
+ ], { maxBuffer: 1024 * 1024 });
807
+ const payload = JSON.parse(stdout);
808
+ const seconds = Number(payload.format?.duration);
809
+ const stream = payload.streams?.find((candidate) => candidate.width && candidate.height);
810
+ if (!Number.isFinite(seconds) || seconds <= 0 || !stream) {
811
+ throw new Error(`Could not read video duration from ffprobe output: ${stdout}`);
812
+ }
813
+ return {
814
+ durationMs: Math.round(seconds * 1000),
815
+ height: Number(stream.height),
816
+ width: Number(stream.width),
817
+ };
818
+ };
819
+ const waitForNonEmptyFile = async (path, timeoutMs = 5000) => {
820
+ const start = Date.now();
821
+ let lastError;
822
+ while (Date.now() - start < timeoutMs) {
823
+ try {
824
+ const stats = await stat(path);
825
+ if (stats.size > 0) {
826
+ return;
827
+ }
828
+ lastError = new Error(`File exists but is empty: ${path}`);
829
+ }
830
+ catch (error) {
831
+ lastError = error;
832
+ }
833
+ await new Promise((resolve) => setTimeout(resolve, 100));
834
+ }
835
+ throw lastError instanceof Error
836
+ ? lastError
837
+ : new Error(`Timed out waiting for non-empty file: ${path}`);
838
+ };
839
+ const escapeHtml = (value) => {
840
+ const entities = {
841
+ "&": "&amp;",
842
+ '"': "&quot;",
843
+ "'": "&#39;",
844
+ "<": "&lt;",
845
+ ">": "&gt;",
846
+ };
847
+ return value.replace(/[&"'<>]/g, (character) => entities[character]);
848
+ };
849
+ const videoElementHtml = (options) => {
850
+ const activeKey = escapeHtml(options.activeKey);
851
+ const label = escapeHtml(options.label);
852
+ const source = escapeHtml(options.source);
853
+ return `
854
+ <section class="video-section" data-active-key="${activeKey}">
855
+ <div class="section-title">${label}</div>
856
+ <video controls preload="metadata" tabindex="0" data-active-key="${activeKey}">
857
+ <source src="${source}" type="video/webm" />
858
+ </video>
859
+ </section>`;
860
+ };
861
+ const playwrightReportAttachmentName = async (path) => {
862
+ const data = await readFile(path);
863
+ return `${createHash("sha1").update(data).digest("hex")}${extname(path)}`;
864
+ };
865
+ const videoModePlayerHtml = (options) => {
866
+ const primary = options.rendered || options.raw;
867
+ const primaryLabel = options.rendered ? "Rendered video" : "Raw video";
868
+ const primaryActiveKey = options.rendered ? "rendered" : "raw";
869
+ const rawDetails = options.rendered
870
+ ? `
871
+ <details>
872
+ <summary>Raw video</summary>
873
+ ${videoElementHtml({ activeKey: "raw", label: "Raw video", source: options.raw })}
874
+ </details>`
875
+ : "";
876
+ return `<!doctype html>
877
+ <html lang="en">
878
+ <head>
879
+ <meta charset="utf-8" />
880
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
881
+ <title>video-mode player</title>
882
+ <style>
883
+ :root {
884
+ color-scheme: dark;
885
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
886
+ background: #111;
887
+ color: #eee;
888
+ }
889
+
890
+ body {
891
+ margin: 0;
892
+ min-height: 100vh;
893
+ background: #111;
894
+ }
895
+
896
+ header {
897
+ align-items: center;
898
+ background: #1d1d1d;
899
+ border-bottom: 1px solid #333;
900
+ display: grid;
901
+ gap: 12px;
902
+ grid-template-columns: 1fr auto auto auto;
903
+ padding: 10px 12px;
904
+ position: sticky;
905
+ top: 0;
906
+ z-index: 1;
907
+ }
908
+
909
+ main {
910
+ display: grid;
911
+ gap: 14px;
912
+ grid-template-columns: minmax(0, 1fr) 260px;
913
+ padding: 12px;
914
+ }
915
+
916
+ video {
917
+ background: #000;
918
+ max-height: calc(100vh - 92px);
919
+ outline: 1px solid #333;
920
+ width: 100%;
921
+ }
922
+
923
+ label,
924
+ button,
925
+ input {
926
+ font: inherit;
927
+ }
928
+
929
+ input {
930
+ background: #050505;
931
+ border: 1px solid #555;
932
+ color: #eee;
933
+ padding: 6px 8px;
934
+ width: 72px;
935
+ }
936
+
937
+ button {
938
+ background: #333;
939
+ border: 1px solid #666;
940
+ color: #eee;
941
+ cursor: pointer;
942
+ padding: 6px 10px;
943
+ }
944
+
945
+ aside {
946
+ color: #ddd;
947
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
948
+ font-size: 13px;
949
+ line-height: 1.5;
950
+ }
951
+
952
+ details {
953
+ border-top: 1px solid #333;
954
+ margin-top: 14px;
955
+ padding-top: 12px;
956
+ }
957
+
958
+ summary {
959
+ cursor: pointer;
960
+ margin-bottom: 12px;
961
+ }
962
+
963
+ a {
964
+ color: #9ecbff;
965
+ }
966
+
967
+ .section-title {
968
+ color: #aaa;
969
+ font-size: 13px;
970
+ margin-bottom: 8px;
971
+ }
972
+
973
+ .hint {
974
+ color: #aaa;
975
+ margin-top: 12px;
976
+ }
977
+
978
+ @media (max-width: 820px) {
979
+ header,
980
+ main {
981
+ grid-template-columns: 1fr;
982
+ }
983
+
984
+ video {
985
+ max-height: none;
986
+ }
987
+ }
988
+ </style>
989
+ </head>
990
+ <body>
991
+ <header>
992
+ <div>video-mode player</div>
993
+ <label>fps <input id="fps" type="number" min="1" step="1" value="25" /></label>
994
+ <button id="back" type="button">-1 frame</button>
995
+ <button id="forward" type="button">+1 frame</button>
996
+ </header>
997
+ <main>
998
+ <div>
999
+ ${videoElementHtml({ activeKey: primaryActiveKey, label: primaryLabel, source: primary })}
1000
+ ${rawDetails}
1001
+ </div>
1002
+ <aside>
1003
+ <div>active: <span id="active">-</span></div>
1004
+ <div>time: <span id="time">0.000</span>s</div>
1005
+ <div>frame: <span id="frame">0</span></div>
1006
+ <div>duration: <span id="duration">?</span>s</div>
1007
+ <div class="hint">Left/right steps one frame. Shift+left/right steps ten. Space toggles play.</div>
1008
+ <div class="hint"><a href="${VIDEO_MODE_METADATA_FILE}">${VIDEO_MODE_METADATA_FILE}</a></div>
1009
+ </aside>
1010
+ </main>
1011
+ <script>
1012
+ const videos = Array.from(document.querySelectorAll("video"));
1013
+ const fps = document.querySelector("#fps");
1014
+ const active = document.querySelector("#active");
1015
+ const time = document.querySelector("#time");
1016
+ const frame = document.querySelector("#frame");
1017
+ const duration = document.querySelector("#duration");
1018
+ const videoByActiveKey = new Map(videos.map((video) => [video.dataset.activeKey, video]));
1019
+ let activeVideo = videos[0];
1020
+
1021
+ const rate = () => Number(fps.value) || 25;
1022
+ const currentFrame = () => Math.round(activeVideo.currentTime * rate());
1023
+ const activeKeyFor = (video) => video.dataset.activeKey || "rendered";
1024
+ const revealVideo = (video) => {
1025
+ const details = video.closest("details");
1026
+ if (details) details.open = true;
1027
+ };
1028
+ const writeStateToUrl = () => {
1029
+ const next = new URL(location.href);
1030
+ next.searchParams.set("active", activeKeyFor(activeVideo));
1031
+ next.searchParams.set("frame", String(currentFrame()));
1032
+ history.replaceState(null, "", next.href);
1033
+ };
1034
+
1035
+ const setActiveVideo = (video, writeUrl) => {
1036
+ activeVideo = video;
1037
+ revealVideo(video);
1038
+ update(writeUrl);
1039
+ };
1040
+
1041
+ const update = (writeUrl) => {
1042
+ const title = activeVideo.closest(".video-section").querySelector(".section-title").textContent;
1043
+ active.textContent = title;
1044
+ time.textContent = activeVideo.currentTime.toFixed(3);
1045
+ frame.textContent = String(currentFrame());
1046
+ duration.textContent = Number.isFinite(activeVideo.duration) ? activeVideo.duration.toFixed(3) : "?";
1047
+ if (writeUrl) writeStateToUrl();
1048
+ };
1049
+
1050
+ const seekToFrame = (frameNumber, writeUrl) => {
1051
+ if (!Number.isFinite(frameNumber)) return;
1052
+ if (!Number.isFinite(activeVideo.duration)) {
1053
+ activeVideo.addEventListener("loadedmetadata", () => seekToFrame(frameNumber, writeUrl), {
1054
+ once: true,
1055
+ });
1056
+ return;
1057
+ }
1058
+
1059
+ activeVideo.pause();
1060
+ activeVideo.currentTime = Math.max(
1061
+ 0,
1062
+ Math.min(activeVideo.duration, Math.round(frameNumber) / rate()),
1063
+ );
1064
+ update(writeUrl);
1065
+ };
1066
+
1067
+ const stepFrames = (count) => {
1068
+ seekToFrame(currentFrame() + count, true);
1069
+ };
1070
+
1071
+ for (const video of videos) {
1072
+ video.addEventListener("focus", () => setActiveVideo(video, true));
1073
+ video.addEventListener("pointerdown", () => setActiveVideo(video, true));
1074
+ video.addEventListener("mouseenter", () => setActiveVideo(video, true));
1075
+ video.addEventListener("loadedmetadata", () => update(false));
1076
+ video.addEventListener("seeked", () => update(true));
1077
+ video.addEventListener("timeupdate", () => update(true));
1078
+ }
1079
+
1080
+ document.querySelector("#back").addEventListener("click", () => stepFrames(-1));
1081
+ document.querySelector("#forward").addEventListener("click", () => stepFrames(1));
1082
+ fps.addEventListener("input", () => update(true));
1083
+ window.addEventListener("keydown", (event) => {
1084
+ if (event.target instanceof HTMLInputElement) return;
1085
+ let handled = true;
1086
+ if (event.key === "ArrowRight") {
1087
+ stepFrames(event.shiftKey ? 10 : 1);
1088
+ } else if (event.key === "ArrowLeft") {
1089
+ stepFrames(event.shiftKey ? -10 : -1);
1090
+ } else if (event.key === " ") {
1091
+ if (activeVideo.paused) activeVideo.play();
1092
+ else activeVideo.pause();
1093
+ } else {
1094
+ handled = false;
1095
+ }
1096
+ if (!handled) return;
1097
+ event.preventDefault();
1098
+ event.stopPropagation();
1099
+ }, { capture: true });
1100
+
1101
+ const applyUrlState = () => {
1102
+ const params = new URL(location.href).searchParams;
1103
+ const requestedVideo = videoByActiveKey.get(params.get("active"));
1104
+ setActiveVideo(requestedVideo || activeVideo, false);
1105
+ const requestedFrame = Number(params.get("frame"));
1106
+ if (Number.isFinite(requestedFrame)) {
1107
+ seekToFrame(requestedFrame, false);
1108
+ return;
1109
+ }
1110
+ update(false);
1111
+ };
1112
+
1113
+ document.body.tabIndex = -1;
1114
+ document.body.focus();
1115
+ applyUrlState();
1116
+ </script>
1117
+ </body>
1118
+ </html>
1119
+ `;
1120
+ };
1121
+ const renderVideo = async (options) => {
1122
+ const info = await videoInfo(options.inputPath);
1123
+ const sourceRangeStart = options.sourceRange.start === undefined ? 0 : options.sourceRange.start;
1124
+ const sourceRangeEnd = options.sourceRange.end === undefined ? info.durationMs : options.sourceRange.end;
1125
+ const rangeStart = Math.max(0, Math.min(Math.round(sourceRangeStart), info.durationMs));
1126
+ const rangeEnd = Math.max(0, Math.min(Math.round(sourceRangeEnd), info.durationMs));
1127
+ if (rangeEnd <= rangeStart) {
1128
+ console.warn(`videoMode source range is empty: start ${rangeStart}ms must be before end ${rangeEnd}ms`);
1129
+ return false;
1130
+ }
1131
+ const highlightInputs = options.highlights
1132
+ .filter((highlight) => highlight.image)
1133
+ .map((highlight, index) => ({
1134
+ durationMs: highlight.end - highlight.start,
1135
+ image: highlight.image,
1136
+ inputIndex: index + 1,
1137
+ path: join(options.outputDir, highlight.image),
1138
+ }));
1139
+ const shouldRenderPointer = options.highlightMode === "pointer" && options.highlights.length > 0;
1140
+ const cursorPointerInput = shouldRenderPointer
1141
+ ? {
1142
+ hotspot: VIDEO_MODE_POINTER_HOTSPOT,
1143
+ inputIndex: highlightInputs.length + 1,
1144
+ path: join(options.outputDir, VIDEO_MODE_POINTER_FILE),
1145
+ size: VIDEO_MODE_POINTER_SIZE,
1146
+ sourceSize: VIDEO_MODE_POINTER_SOURCE_SIZE,
1147
+ }
1148
+ : undefined;
1149
+ const clickPointerInput = shouldRenderPointer
1150
+ ? {
1151
+ hotspot: VIDEO_MODE_CLICK_POINTER_HOTSPOT,
1152
+ inputIndex: highlightInputs.length + 2,
1153
+ path: join(options.outputDir, VIDEO_MODE_CLICK_POINTER_FILE),
1154
+ size: VIDEO_MODE_CLICK_POINTER_SIZE,
1155
+ sourceSize: VIDEO_MODE_CLICK_POINTER_SOURCE_SIZE,
1156
+ }
1157
+ : undefined;
1158
+ const textPointerInput = shouldRenderPointer
1159
+ ? {
1160
+ hotspot: VIDEO_MODE_TEXT_POINTER_HOTSPOT,
1161
+ inputIndex: highlightInputs.length + 3,
1162
+ path: join(options.outputDir, VIDEO_MODE_TEXT_POINTER_FILE),
1163
+ size: VIDEO_MODE_TEXT_POINTER_SIZE,
1164
+ sourceSize: VIDEO_MODE_TEXT_POINTER_SOURCE_SIZE,
1165
+ }
1166
+ : undefined;
1167
+ if (cursorPointerInput) {
1168
+ await writeFile(cursorPointerInput.path, Buffer.from(VIDEO_MODE_POINTER_PNG, "base64"));
1169
+ }
1170
+ if (clickPointerInput) {
1171
+ await writeFile(clickPointerInput.path, Buffer.from(VIDEO_MODE_CLICK_POINTER_PNG, "base64"));
1172
+ }
1173
+ if (textPointerInput) {
1174
+ await writeFile(textPointerInput.path, Buffer.from(VIDEO_MODE_TEXT_POINTER_PNG, "base64"));
1175
+ }
1176
+ const segments = renderVideoSegments({
1177
+ deadAir: options.deadAir,
1178
+ finalEnd: rangeEnd,
1179
+ start: rangeStart,
1180
+ thresholdMs: options.thresholdMs,
1181
+ });
1182
+ const filter = renderedVideoFilter({
1183
+ clickPointerInput,
1184
+ cursorPointerInput,
1185
+ finalHoldMs: options.finalHoldMs,
1186
+ highlightMode: options.highlightMode,
1187
+ highlightInputs,
1188
+ highlights: options.highlights,
1189
+ segments,
1190
+ textPointerInput,
1191
+ video: info,
1192
+ });
1193
+ if (!filter) {
1194
+ return false;
1195
+ }
1196
+ await execFile("ffmpeg", [
1197
+ "-hide_banner",
1198
+ "-loglevel",
1199
+ "error",
1200
+ "-y",
1201
+ "-i",
1202
+ options.inputPath,
1203
+ ...highlightInputs.flatMap((input) => [
1204
+ "-loop",
1205
+ "1",
1206
+ "-t",
1207
+ formatSeconds(input.durationMs),
1208
+ "-i",
1209
+ input.path,
1210
+ ]),
1211
+ ...(cursorPointerInput ? ["-loop", "1", "-i", cursorPointerInput.path] : []),
1212
+ ...(clickPointerInput ? ["-loop", "1", "-i", clickPointerInput.path] : []),
1213
+ ...(textPointerInput ? ["-loop", "1", "-i", textPointerInput.path] : []),
1214
+ "-filter_complex",
1215
+ filter.value,
1216
+ "-map",
1217
+ `[${filter.outputLabel}]`,
1218
+ "-an",
1219
+ options.outputPath,
1220
+ ], { maxBuffer: 10 * 1024 * 1024 });
1221
+ return true;
1222
+ };
1223
+ /** Records video-mode facts and renders annotations into the recorded video. */
87
1224
  export const videoMode = (options = {}) => {
88
- const pauseBefore = options.pauseBefore || 1000;
89
- const pauseAfterTest = options.pauseAfterTest || 3000;
90
- const highlightStyle = options.highlightStyle || "3px solid gold";
1225
+ if (process.env.PWDEBUG) {
1226
+ let testInfoForOutputPaths;
1227
+ const controls = {
1228
+ deadAir: async (action) => {
1229
+ return await action();
1230
+ },
1231
+ getVideoTimestamp: () => 0,
1232
+ metadata: async () => ({
1233
+ deadAir: [],
1234
+ highlights: [],
1235
+ outputs: {},
1236
+ schemaVersion: 1,
1237
+ sourceRange: {},
1238
+ timebase: "ms",
1239
+ }),
1240
+ outputPaths: () => {
1241
+ if (!testInfoForOutputPaths) {
1242
+ throw new Error("videoMode.outputPaths() is only available after addPlugins registers videoMode");
1243
+ }
1244
+ return videoModeOutputPaths(testInfoForOutputPaths);
1245
+ },
1246
+ setEndTime: () => { },
1247
+ setStartTime: () => { },
1248
+ };
1249
+ return {
1250
+ ...controls,
1251
+ name: "video-mode",
1252
+ pageExtension: ({ testInfo }) => {
1253
+ testInfoForOutputPaths = testInfo;
1254
+ return { videoMode: controls };
1255
+ },
1256
+ };
1257
+ }
1258
+ const finalHold = resolveNonNegativeNumber({
1259
+ defaultValue: 3000,
1260
+ name: "videoMode finalHold",
1261
+ value: options.finalHold,
1262
+ });
1263
+ const highlight = resolveVideoModeHighlight(options);
91
1264
  const skipMethods = options.skipMethods || ["waitFor"];
92
1265
  const skipStackFrames = options.skipStackFrames || [];
1266
+ const deadAirThreshold = resolveDeadAirThreshold(options.deadAirThreshold);
1267
+ const state = {
1268
+ deadAirDepth: 0,
1269
+ deadAirSpans: [],
1270
+ highlightImageIndex: 0,
1271
+ highlights: [],
1272
+ outputs: {},
1273
+ sourceRange: {},
1274
+ startedAt: performance.now(),
1275
+ };
1276
+ let testInfoForOutputPaths;
1277
+ const getVideoTimestamp = () => {
1278
+ const now = performance.now();
1279
+ return Math.round(now - (state.startedAt || now));
1280
+ };
1281
+ const controls = {
1282
+ deadAir: async (action) => {
1283
+ return await recordDeadAir(state, action);
1284
+ },
1285
+ getVideoTimestamp,
1286
+ metadata: async () => {
1287
+ if (!testInfoForOutputPaths) {
1288
+ return metadataFor(state);
1289
+ }
1290
+ return await readVideoModeMetadata(videoModeOutputPaths(testInfoForOutputPaths).metadata, () => metadataFor(state));
1291
+ },
1292
+ outputPaths: () => {
1293
+ if (!testInfoForOutputPaths) {
1294
+ throw new Error("videoMode.outputPaths() is only available after addPlugins registers videoMode");
1295
+ }
1296
+ return videoModeOutputPaths(testInfoForOutputPaths);
1297
+ },
1298
+ setEndTime: (ms = getVideoTimestamp()) => {
1299
+ state.sourceRange.end = resolveVideoTimestamp("setEndTime", ms);
1300
+ },
1301
+ setStartTime: (ms = getVideoTimestamp()) => {
1302
+ state.sourceRange.start = resolveVideoTimestamp("setStartTime", ms);
1303
+ },
1304
+ };
93
1305
  return {
1306
+ ...controls,
94
1307
  name: "video-mode",
95
- middleware: async ({ locator, method }, next) => {
96
- const env_1 = { stack: [], error: void 0, hasError: false };
97
- try {
98
- if (skipMethods.includes(method))
1308
+ pageExtension: ({ testInfo }) => {
1309
+ testInfoForOutputPaths = testInfo;
1310
+ return { videoMode: controls };
1311
+ },
1312
+ middleware: async ({ locator, method, testInfo, timing }, next) => {
1313
+ if (state.deadAirDepth > 0)
1314
+ return next();
1315
+ // Skip if called from internal helpers (navigation, login flows etc)
1316
+ if (skipStackFrames.length > 0) {
1317
+ const stack = new Error().stack || "";
1318
+ if (skipStackFrames.some((frame) => stack.includes(frame)))
99
1319
  return next();
100
- // Skip if called from internal helpers (navigation, login flows etc)
101
- if (skipStackFrames.length > 0) {
102
- const stack = new Error().stack || "";
103
- if (skipStackFrames.some((frame) => stack.includes(frame)))
104
- return next();
1320
+ }
1321
+ if (method === "waitFor") {
1322
+ try {
1323
+ return await next();
1324
+ }
1325
+ finally {
1326
+ recordActionElapsedDeadAirFromTiming(state, timing, { minimumMs: 0 });
1327
+ }
1328
+ }
1329
+ recordMiddlewareWaitBeforeVideoMode(state, timing);
1330
+ if (skipMethods.includes(method)) {
1331
+ try {
1332
+ return await next();
1333
+ }
1334
+ finally {
1335
+ if (timing.attachedAtStart) {
1336
+ recordActionElapsedDeadAirFromTiming(state, timing, { minimumMs: 50 });
1337
+ }
1338
+ recordAttachedWaitFromTiming(state, timing);
105
1339
  }
106
- const _ = __addDisposableResource(env_1, await setupHighlight(locator, highlightStyle, pauseBefore), false);
107
- return await next();
108
1340
  }
109
- catch (e_1) {
110
- env_1.error = e_1;
111
- env_1.hasError = true;
1341
+ const recordedHighlight = highlight.mode === "off"
1342
+ ? undefined
1343
+ : await recordHighlight({
1344
+ color: highlight.color,
1345
+ durationMs: highlight.durationMs,
1346
+ locator,
1347
+ method,
1348
+ state,
1349
+ testInfo,
1350
+ thickness: highlight.thickness,
1351
+ });
1352
+ try {
1353
+ return await next();
112
1354
  }
113
1355
  finally {
114
- __disposeResources(env_1);
1356
+ if (recordedHighlight && state.startedAt !== undefined) {
1357
+ recordedHighlight.actionEnd = Math.max(recordedHighlight.start, Math.round(performance.now() - state.startedAt));
1358
+ }
1359
+ if (!recordedHighlight &&
1360
+ (timing.attachedAtStart || timing.attachedAt === undefined)) {
1361
+ recordActionElapsedDeadAirFromTiming(state, timing, { minimumMs: 50 });
1362
+ }
1363
+ recordAttachedWaitFromTiming(state, timing);
115
1364
  }
116
1365
  },
117
1366
  testLifecycle: (emitter) => {
118
- return emitter.on("afterTest", async ({ testInfo }) => {
119
- await new Promise((resolve) => setTimeout(resolve, pauseAfterTest));
120
- console.log(`video will be written to ${testInfo.outputDir}/video.webm`);
1367
+ const offBeforeTest = emitter.on("beforeTest", () => {
1368
+ state.deadAirDepth = 0;
1369
+ state.deadAirSpans = [];
1370
+ state.highlightImageIndex = 0;
1371
+ state.highlights = [];
1372
+ state.outputs = {};
1373
+ state.sourceRange = {};
1374
+ if (state.startedAt) {
1375
+ recordDeadAirSpan(state, {
1376
+ end: Math.round(performance.now() - state.startedAt),
1377
+ start: 0,
1378
+ });
1379
+ }
1380
+ else {
1381
+ state.startedAt = performance.now();
1382
+ }
1383
+ });
1384
+ const offAfterTestFinalize = emitter.on("afterTestFinalize", async ({ page, testInfo }) => {
1385
+ const metadataBeforeVideo = metadataFor(state);
1386
+ const deadAir = metadataBeforeVideo.deadAir;
1387
+ const highlights = metadataBeforeVideo.highlights;
1388
+ const sourceRange = metadataBeforeVideo.sourceRange;
1389
+ const video = page.video();
1390
+ if (video) {
1391
+ const paths = videoModeOutputPaths(testInfo);
1392
+ await mkdir(testInfo.outputDir, { recursive: true });
1393
+ if (!page.isClosed()) {
1394
+ await page.close({ runBeforeUnload: false });
1395
+ }
1396
+ const recordedVideoPath = await video.path();
1397
+ await waitForNonEmptyFile(recordedVideoPath);
1398
+ await copyFile(recordedVideoPath, paths.raw);
1399
+ state.outputs.raw = VIDEO_MODE_RAW_FILE;
1400
+ await testInfo.attach("video-raw", {
1401
+ contentType: "video/webm",
1402
+ path: paths.raw,
1403
+ });
1404
+ if (highlights.length > 0 ||
1405
+ deadAirThreshold !== undefined ||
1406
+ finalHold > 0 ||
1407
+ sourceRangeIsSet(sourceRange)) {
1408
+ const wroteRenderedVideo = await renderVideo({
1409
+ deadAir,
1410
+ finalHoldMs: finalHold,
1411
+ highlightMode: highlight.mode === "pointer" ? "pointer" : "outline",
1412
+ highlights,
1413
+ inputPath: paths.raw,
1414
+ outputDir: testInfo.outputDir,
1415
+ outputPath: paths.rendered,
1416
+ sourceRange,
1417
+ thresholdMs: deadAirThreshold,
1418
+ });
1419
+ if (wroteRenderedVideo) {
1420
+ state.outputs.rendered = VIDEO_MODE_RENDERED_FILE;
1421
+ await testInfo.attach("video-rendered", {
1422
+ contentType: "video/webm",
1423
+ path: paths.rendered,
1424
+ });
1425
+ }
1426
+ }
1427
+ state.outputs.player = VIDEO_MODE_PLAYER_FILE;
1428
+ await writeFile(paths.player, videoModePlayerHtml({
1429
+ raw: state.outputs.raw,
1430
+ rendered: state.outputs.rendered,
1431
+ }));
1432
+ const reportPlayerHtml = videoModePlayerHtml({
1433
+ raw: await playwrightReportAttachmentName(paths.raw),
1434
+ rendered: state.outputs.rendered
1435
+ ? await playwrightReportAttachmentName(paths.rendered)
1436
+ : undefined,
1437
+ });
1438
+ await writeFile(paths.reportPlayer, reportPlayerHtml);
1439
+ await testInfo.attach("video-mode-player", {
1440
+ contentType: "text/html",
1441
+ path: paths.reportPlayer,
1442
+ });
1443
+ }
1444
+ const metadata = metadataFor(state);
1445
+ if (metadata.deadAir.length > 0 ||
1446
+ metadata.highlights.length > 0 ||
1447
+ metadata.outputs.player ||
1448
+ metadata.outputs.raw ||
1449
+ metadata.outputs.rendered ||
1450
+ sourceRangeIsSet(metadata.sourceRange)) {
1451
+ const path = videoModeOutputPaths(testInfo).metadata;
1452
+ await mkdir(testInfo.outputDir, { recursive: true });
1453
+ await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`);
1454
+ await testInfo.attach("video-mode", {
1455
+ contentType: "application/json",
1456
+ path,
1457
+ });
1458
+ }
1459
+ state.startedAt = undefined;
1460
+ console.log(`video-mode metadata written to ${videoModeOutputPaths(testInfo).metadata}`);
121
1461
  });
1462
+ return () => {
1463
+ offBeforeTest();
1464
+ offAfterTestFinalize();
1465
+ };
122
1466
  },
123
1467
  };
124
1468
  };