@lullabot/playwright-testing 0.1.0

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.
Files changed (82) hide show
  1. package/README.md +61 -0
  2. package/bin/github-a11y-summary +5 -0
  3. package/bin/github-failure-summary +8 -0
  4. package/lib/accessibility-baseline-file.d.ts +47 -0
  5. package/lib/accessibility-baseline-file.js +205 -0
  6. package/lib/accessibility-baseline.d.ts +19 -0
  7. package/lib/accessibility-baseline.js +38 -0
  8. package/lib/accessible-screenshot.d.ts +181 -0
  9. package/lib/accessible-screenshot.js +519 -0
  10. package/lib/focus.d.ts +14 -0
  11. package/lib/focus.js +28 -0
  12. package/lib/fonts.d.ts +18 -0
  13. package/lib/fonts.js +24 -0
  14. package/lib/frames.d.ts +7 -0
  15. package/lib/frames.js +27 -0
  16. package/lib/github/a11y-summary.d.ts +55 -0
  17. package/lib/github/a11y-summary.js +383 -0
  18. package/lib/github/attachments.d.ts +98 -0
  19. package/lib/github/attachments.js +297 -0
  20. package/lib/github/failure-summary.d.ts +144 -0
  21. package/lib/github/failure-summary.js +567 -0
  22. package/lib/github/index.d.ts +6 -0
  23. package/lib/github/index.js +35 -0
  24. package/lib/github/report-paths.d.ts +38 -0
  25. package/lib/github/report-paths.js +200 -0
  26. package/lib/hover.d.ts +13 -0
  27. package/lib/hover.js +61 -0
  28. package/lib/images.d.ts +118 -0
  29. package/lib/images.js +260 -0
  30. package/lib/index.d.ts +13 -0
  31. package/lib/index.js +29 -0
  32. package/lib/interaction-states.d.ts +22 -0
  33. package/lib/interaction-states.js +75 -0
  34. package/lib/mock/index.d.ts +1 -0
  35. package/lib/mock/index.js +5 -0
  36. package/lib/mock/youtube.d.ts +5 -0
  37. package/lib/mock/youtube.js +38 -0
  38. package/lib/pseudo-state.d.ts +17 -0
  39. package/lib/pseudo-state.js +50 -0
  40. package/lib/videos.d.ts +134 -0
  41. package/lib/videos.js +349 -0
  42. package/lib/visualdiff.d.ts +154 -0
  43. package/lib/visualdiff.js +197 -0
  44. package/package.json +47 -0
  45. package/src/accessibility-baseline-file.test.ts +181 -0
  46. package/src/accessibility-baseline-file.ts +208 -0
  47. package/src/accessibility-baseline.test.ts +601 -0
  48. package/src/accessibility-baseline.ts +50 -0
  49. package/src/accessible-screenshot.test.ts +597 -0
  50. package/src/accessible-screenshot.ts +809 -0
  51. package/src/focus.test.ts +34 -0
  52. package/src/focus.ts +27 -0
  53. package/src/fonts.test.ts +17 -0
  54. package/src/fonts.ts +23 -0
  55. package/src/frames.test.ts +75 -0
  56. package/src/frames.ts +26 -0
  57. package/src/github/a11y-summary.test.ts +439 -0
  58. package/src/github/a11y-summary.ts +421 -0
  59. package/src/github/attachments.test.ts +248 -0
  60. package/src/github/attachments.ts +328 -0
  61. package/src/github/failure-summary.test.ts +636 -0
  62. package/src/github/failure-summary.ts +720 -0
  63. package/src/github/index.test.ts +24 -0
  64. package/src/github/index.ts +35 -0
  65. package/src/github/report-paths.test.ts +222 -0
  66. package/src/github/report-paths.ts +208 -0
  67. package/src/hover.test.ts +76 -0
  68. package/src/hover.ts +64 -0
  69. package/src/images.test.ts +355 -0
  70. package/src/images.ts +299 -0
  71. package/src/index.ts +13 -0
  72. package/src/interaction-states.test.ts +48 -0
  73. package/src/interaction-states.ts +94 -0
  74. package/src/mock/index.ts +1 -0
  75. package/src/mock/youtube.test.ts +38 -0
  76. package/src/mock/youtube.ts +39 -0
  77. package/src/pseudo-state.test.ts +83 -0
  78. package/src/pseudo-state.ts +69 -0
  79. package/src/videos.test.ts +637 -0
  80. package/src/videos.ts +389 -0
  81. package/src/visualdiff.test.ts +452 -0
  82. package/src/visualdiff.ts +381 -0
@@ -0,0 +1,637 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+
3
+ import { restorePlayback, restoreVideoPlayback, settleVideos, waitForVideos } from './videos'
4
+
5
+ /**
6
+ * A stand-in for the parts of HTMLVideoElement settleVideos() touches.
7
+ *
8
+ * settleVideos() runs in the browser, so it is exercised here against a fake
9
+ * DOM rather than a real page: each video declares whether it already has a
10
+ * frame, whether it is playing, and whether it can be seeked.
11
+ */
12
+ interface FakeVideoOptions {
13
+ src?: string
14
+ /** Sources of nested <source> elements, when there is no src attribute. */
15
+ sourceSrc?: string
16
+ /** HTMLMediaElement.readyState. 2 (HAVE_CURRENT_DATA) means a frame exists. */
17
+ readyState?: number
18
+ /** readyState to switch to after `readyAfterMs`, simulating a slow load. */
19
+ readyAfterMs?: number
20
+ /** When true, readyState only starts climbing once load() is called. */
21
+ readyAfterLoad?: boolean
22
+ /** The preload attribute. "none" means nothing loads until asked for. */
23
+ preload?: string
24
+ paused?: boolean
25
+ autoplay?: boolean
26
+ currentTime?: number
27
+ /** When false, assigning currentTime throws, as on a live stream. */
28
+ seekable?: boolean
29
+ /** When true, a seek is accepted but the `seeked` event never arrives. */
30
+ neverSeeked?: boolean
31
+ rect?: { width: number, height: number }
32
+ style?: { visibility?: string, display?: string }
33
+ /** A scrollable ancestor that scrollIntoView() moves along with the window. */
34
+ scrollParent?: any
35
+ /** Where scrollIntoView() leaves the window, as the browser would. */
36
+ scrollsWindowTo?: { x: number, y: number }
37
+ /** Error thrown after scrollIntoView has moved the page and its ancestors. */
38
+ scrollError?: Error
39
+ }
40
+
41
+ function makeVideo(options: FakeVideoOptions = {}) {
42
+ let currentTime = options.currentTime ?? 0
43
+ let readyAt = options.readyAfterMs === undefined || options.readyAfterLoad
44
+ ? undefined
45
+ : Date.now() + options.readyAfterMs
46
+ const initialReadyState = options.readyState ?? 4
47
+ const listeners: Record<string, Array<() => void>> = {}
48
+
49
+ const video: any = {
50
+ src: options.src ?? '',
51
+ preload: options.preload ?? 'auto',
52
+ autoplay: options.autoplay ?? false,
53
+ paused: options.paused ?? true,
54
+ parentElement: options.scrollParent ?? null,
55
+ scrolledIntoView: 0,
56
+ pauseCalls: 0,
57
+ playCalls: 0,
58
+ loadCalls: 0,
59
+ seeks: [] as number[],
60
+ getBoundingClientRect: () => options.rect ?? { width: 640, height: 480 },
61
+ querySelector: (selector: string) =>
62
+ selector === 'source' && options.sourceSrc
63
+ ? { getAttribute: () => options.sourceSrc }
64
+ : null,
65
+ events: [] as string[],
66
+ addEventListener: (type: string, listener: () => void) => {
67
+ listeners[type] = [...(listeners[type] ?? []), listener]
68
+ },
69
+ removeEventListener: (type: string, listener: () => void) => {
70
+ listeners[type] = (listeners[type] ?? []).filter(l => l !== listener)
71
+ },
72
+ scrollIntoView: () => {
73
+ video.scrolledIntoView++
74
+ video.events.push('scroll')
75
+ // Bringing an element on screen scrolls every scrollable ancestor, not
76
+ // just the window.
77
+ if (options.scrollParent) {
78
+ options.scrollParent.scrollTop = 900
79
+ options.scrollParent.scrollLeft = 40
80
+ }
81
+ if (options.scrollsWindowTo) {
82
+ const win = (globalThis as any).window
83
+ win.scrollX = options.scrollsWindowTo.x
84
+ win.scrollY = options.scrollsWindowTo.y
85
+ }
86
+ if (options.scrollError) throw options.scrollError
87
+ // Chromium starts a muted autoplay video once it is on screen, which is
88
+ // why the attribute has to be cleared before this happens and not after.
89
+ if (video.autoplay) video.paused = false
90
+ },
91
+ pause: () => {
92
+ video.pauseCalls++
93
+ video.events.push('pause')
94
+ video.paused = true
95
+ },
96
+ play: () => {
97
+ video.playCalls++
98
+ video.events.push('play')
99
+ video.paused = false
100
+ return Promise.resolve()
101
+ },
102
+ load: () => {
103
+ video.loadCalls++
104
+ video.events.push('load')
105
+ if (options.readyAfterLoad) {
106
+ readyAt = Date.now() + (options.readyAfterMs ?? 0)
107
+ }
108
+ },
109
+ }
110
+
111
+ Object.defineProperty(video, 'readyState', {
112
+ get: () => (readyAt !== undefined && Date.now() >= readyAt ? 4 : initialReadyState),
113
+ })
114
+
115
+ Object.defineProperty(video, 'currentSrc', {
116
+ get: () => (video.readyState >= 2 ? video.src : ''),
117
+ })
118
+
119
+ Object.defineProperty(video, 'currentTime', {
120
+ get: () => currentTime,
121
+ set: (value: number) => {
122
+ if (options.seekable === false) throw new Error('not seekable')
123
+ video.seeks.push(value)
124
+ currentTime = value
125
+ // A seek is asynchronous: the browser decodes the frame at the new
126
+ // position and only then fires `seeked`.
127
+ if (!options.neverSeeked) {
128
+ setTimeout(() => (listeners.seeked ?? []).forEach(listener => listener()), 0)
129
+ }
130
+ },
131
+ })
132
+
133
+ return video
134
+ }
135
+
136
+ /**
137
+ * A stand-in for a scrollable ancestor, such as a modal body or a carousel.
138
+ */
139
+ function makeScrollBox() {
140
+ return {
141
+ scrollTop: 0,
142
+ scrollLeft: 0,
143
+ scrollHeight: 2000,
144
+ clientHeight: 400,
145
+ scrollWidth: 800,
146
+ clientWidth: 400,
147
+ parentElement: null,
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Install the browser globals settleVideos() reaches for.
153
+ *
154
+ * @param options.lockScroll Accept window.scroll() calls but ignore them, as
155
+ * happens when application UI has locked the body.
156
+ */
157
+ function installDom(videos: any[], scroll = { x: 0, y: 0 }, options: { lockScroll?: boolean } = {}) {
158
+ const scrolls: Array<{ top: number, left: number }> = []
159
+ const win: any = {
160
+ scrollX: scroll.x,
161
+ scrollY: scroll.y,
162
+ scroll: (opts: { top: number, left: number }) => {
163
+ scrolls.push(opts)
164
+ if (options.lockScroll) return
165
+ win.scrollX = opts.left
166
+ win.scrollY = opts.top
167
+ },
168
+ }
169
+ vi.stubGlobal('document', {
170
+ querySelectorAll: (selector: string) => (selector === 'video' ? videos : []),
171
+ })
172
+ vi.stubGlobal('getComputedStyle', (el: any) => ({
173
+ visibility: el.styleVisibility ?? 'visible',
174
+ display: el.styleDisplay ?? 'block',
175
+ }))
176
+ vi.stubGlobal('requestAnimationFrame', (cb: () => void) => {
177
+ setTimeout(cb, 0)
178
+ return 0
179
+ })
180
+ vi.stubGlobal('location', { href: 'https://example.com/page' })
181
+ vi.stubGlobal('window', win)
182
+ return scrolls
183
+ }
184
+
185
+ describe('settleVideos', () => {
186
+ afterEach(() => {
187
+ vi.unstubAllGlobals()
188
+ })
189
+
190
+ it('reports nothing when there are no videos', async () => {
191
+ installDom([])
192
+ expect(await settleVideos({ timeoutMs: 100 })).toEqual({ notReady: [], scrolled: false })
193
+ })
194
+
195
+ it('composites, pauses and rewinds a playing autoplay video', async () => {
196
+ const video = makeVideo({ src: 'https://example.com/v.mp4', autoplay: true, paused: false, currentTime: 3.5 })
197
+ installDom([video])
198
+
199
+ expect(await settleVideos({ timeoutMs: 100 })).toEqual({ notReady: [], scrolled: true })
200
+
201
+ // Scrolled into view once, so Chromium has a reason to composite the frame.
202
+ expect(video.scrolledIntoView).toBe(1)
203
+ // Left paused at the first frame, so the capture is the same every run.
204
+ expect(video.paused).toBe(true)
205
+ expect(video.autoplay).toBe(false)
206
+ expect(video.currentTime).toBe(0)
207
+ expect(video.seeks).toEqual([0])
208
+ })
209
+
210
+ it('pins playback before scrolling, so the scroll cannot restart it', async () => {
211
+ const video = makeVideo({ src: 'https://example.com/v.mp4', autoplay: true, paused: false })
212
+ installDom([video])
213
+
214
+ await settleVideos({ timeoutMs: 100 })
215
+
216
+ // If the scroll came first, Chromium would restart the muted autoplay video
217
+ // and the frame captured would depend on how long the rest of the waits took.
218
+ expect(video.events[0]).toBe('pause')
219
+ expect(video.events.indexOf('scroll')).toBeGreaterThan(0)
220
+ expect(video.paused).toBe(true)
221
+ })
222
+
223
+ it('does not seek a video already at its first frame', async () => {
224
+ const video = makeVideo({ src: 'https://example.com/v.mp4', currentTime: 0 })
225
+ installDom([video])
226
+
227
+ await settleVideos({ timeoutMs: 100 })
228
+
229
+ expect(video.seeks).toEqual([])
230
+ expect(video.paused).toBe(true)
231
+ })
232
+
233
+ it('still settles a video whose source cannot be seeked', async () => {
234
+ const video = makeVideo({ src: 'https://example.com/live.m3u8', paused: false, currentTime: 12, seekable: false })
235
+ installDom([video])
236
+
237
+ expect(await settleVideos({ timeoutMs: 100 })).toEqual({ notReady: [], scrolled: true })
238
+ expect(video.paused).toBe(true)
239
+ })
240
+
241
+ it('does not hang when a seek never completes', async () => {
242
+ const video = makeVideo({ src: 'https://example.com/v.mp4', currentTime: 7, neverSeeked: true })
243
+ installDom([video])
244
+
245
+ expect(await settleVideos({ timeoutMs: 100, paintTimeoutMs: 20 })).toEqual({ notReady: [], scrolled: true })
246
+ expect(video.seeks).toEqual([0])
247
+ })
248
+
249
+ it('does not hang when the page is never painted', async () => {
250
+ const video = makeVideo({ src: 'https://example.com/v.mp4' })
251
+ installDom([video])
252
+ // A page Chromium is not rendering never runs an animation frame callback.
253
+ vi.stubGlobal('requestAnimationFrame', () => 0)
254
+
255
+ expect(await settleVideos({ timeoutMs: 100, paintTimeoutMs: 20 })).toEqual({ notReady: [], scrolled: true })
256
+ })
257
+
258
+ it('waits for a slow video to produce a frame', async () => {
259
+ const video = makeVideo({ src: 'https://example.com/slow.mp4', readyState: 0, readyAfterMs: 120 })
260
+ installDom([video])
261
+
262
+ expect(await settleVideos({ timeoutMs: 2000, pollMs: 10 })).toEqual({ notReady: [], scrolled: true })
263
+ expect(video.readyState).toBe(4)
264
+ })
265
+
266
+ it('gives every video its own timeout instead of a shared budget', async () => {
267
+ const slow = makeVideo({ src: 'https://example.com/never.mp4', readyState: 0 })
268
+ const ready = makeVideo({ src: 'https://example.com/ready.mp4', readyState: 0, readyAfterMs: 30 })
269
+ installDom([slow, ready])
270
+
271
+ // The first video spends its whole timeout without ever becoming ready. The
272
+ // second must still be waited for rather than reported on a budget the
273
+ // first one already emptied.
274
+ const result = await settleVideos({ timeoutMs: 80, pollMs: 10 })
275
+
276
+ expect(result.notReady).toEqual(['https://example.com/never.mp4'])
277
+ expect(ready.readyState).toBe(4)
278
+ })
279
+
280
+ it('loads a lazy video instead of waiting for one that was never asked for', async () => {
281
+ const video = makeVideo({
282
+ src: 'https://example.com/lazy.mp4',
283
+ preload: 'none',
284
+ readyState: 0,
285
+ readyAfterLoad: true,
286
+ readyAfterMs: 30,
287
+ })
288
+ installDom([video])
289
+
290
+ expect(await settleVideos({ timeoutMs: 2000, pollMs: 10 })).toEqual({ notReady: [], scrolled: true })
291
+ // Asked for on screen first, then told to load: polling a preload="none"
292
+ // video without either would just burn the whole timeout.
293
+ expect(video.events.slice(0, 2)).toEqual(['scroll', 'load'])
294
+ expect(video.preload).toBe('auto')
295
+ expect(video.loadCalls).toBe(1)
296
+ })
297
+
298
+ it('reports a video that never produces a frame instead of hanging', async () => {
299
+ const video = makeVideo({ src: 'https://example.com/missing.mp4', readyState: 0 })
300
+ installDom([video])
301
+
302
+ expect(await settleVideos({ timeoutMs: 60, pollMs: 10 })).toEqual({
303
+ notReady: ['https://example.com/missing.mp4'],
304
+ scrolled: true,
305
+ })
306
+ // Reported, but still pinned down: it must not start playing if it becomes
307
+ // ready between here and the capture.
308
+ expect(video.autoplay).toBe(false)
309
+ })
310
+
311
+ it('falls back to a nested <source> when reporting an unready video', async () => {
312
+ const video = makeVideo({ readyState: 0, sourceSrc: '/files/missing.mp4' })
313
+ installDom([video])
314
+
315
+ expect((await settleVideos({ timeoutMs: 60, pollMs: 10 })).notReady)
316
+ .toEqual(['https://example.com/files/missing.mp4'])
317
+ })
318
+
319
+ it('skips videos that are not visible', async () => {
320
+ const zeroSized = makeVideo({ src: 'https://example.com/a.mp4', rect: { width: 0, height: 0 } })
321
+ const hidden = makeVideo({ src: 'https://example.com/b.mp4' })
322
+ hidden.styleVisibility = 'hidden'
323
+ const notDisplayed = makeVideo({ src: 'https://example.com/c.mp4' })
324
+ notDisplayed.styleDisplay = 'none'
325
+ installDom([zeroSized, hidden, notDisplayed])
326
+
327
+ expect(await settleVideos({ timeoutMs: 100 })).toEqual({ notReady: [], scrolled: false })
328
+ expect(zeroSized.scrolledIntoView).toBe(0)
329
+ expect(hidden.scrolledIntoView).toBe(0)
330
+ expect(notDisplayed.scrolledIntoView).toBe(0)
331
+ })
332
+
333
+ it('restores the scroll position it found', async () => {
334
+ const video = makeVideo({ src: 'https://example.com/v.mp4' })
335
+ const scrolls = installDom([video], { x: 12, y: 3400 })
336
+
337
+ await settleVideos({ timeoutMs: 100 })
338
+
339
+ expect(scrolls).toEqual([{ top: 3400, left: 12, behavior: 'instant' }])
340
+ })
341
+
342
+ it('restores the scroll of every container the video was scrolled inside', async () => {
343
+ const box = makeScrollBox()
344
+ const video = makeVideo({ src: 'https://example.com/v.mp4', scrollParent: box })
345
+ installDom([video], { x: 0, y: 200 })
346
+
347
+ await settleVideos({ timeoutMs: 100 })
348
+
349
+ // A video in a modal body or a carousel track must not leave that region
350
+ // showing something the baseline never had.
351
+ expect(box.scrollTop).toBe(0)
352
+ expect(box.scrollLeft).toBe(0)
353
+ })
354
+
355
+ it('restores window and container scroll when settling throws', async () => {
356
+ const box = makeScrollBox()
357
+ const failure = new Error('scroll failed')
358
+ const video = makeVideo({
359
+ src: 'https://example.com/v.mp4',
360
+ scrollParent: box,
361
+ scrollsWindowTo: {x: 0, y: 800},
362
+ scrollError: failure,
363
+ })
364
+ const scrolls = installDom([video], {x: 15, y: 200})
365
+
366
+ await expect(settleVideos({timeoutMs: 100})).rejects.toBe(failure)
367
+
368
+ expect(box.scrollTop).toBe(0)
369
+ expect(box.scrollLeft).toBe(0)
370
+ expect(scrolls).toEqual([{top: 200, left: 15, behavior: 'instant'}])
371
+ })
372
+
373
+ it('asks again when the scroll back is dropped', async () => {
374
+ const video = makeVideo({ src: 'https://example.com/v.mp4', scrollsWindowTo: { x: 0, y: 4200 } })
375
+ // window.scroll is async and does nothing at all when application UI has
376
+ // locked the body.
377
+ const scrolls = installDom([video], { x: 0, y: 900 }, { lockScroll: true })
378
+
379
+ await settleVideos({ timeoutMs: 100 })
380
+
381
+ expect(scrolls).toEqual([
382
+ { top: 900, left: 0, behavior: 'instant' },
383
+ { top: 900, left: 0, behavior: 'instant' },
384
+ ])
385
+ })
386
+
387
+ it('does not scroll at all when the page has no visible videos', async () => {
388
+ const hidden = makeVideo({ src: 'https://example.com/b.mp4', rect: { width: 0, height: 0 } })
389
+ const scrolls = installDom([hidden], { x: 0, y: 900 })
390
+
391
+ await settleVideos({ timeoutMs: 100 })
392
+
393
+ expect(scrolls).toEqual([])
394
+ })
395
+
396
+ it('settles every video on the page', async () => {
397
+ const first = makeVideo({ src: 'https://example.com/1.mp4', autoplay: true, paused: false, currentTime: 2 })
398
+ const second = makeVideo({ src: 'https://example.com/2.mp4', autoplay: true, paused: false, currentTime: 5 })
399
+ installDom([first, second])
400
+
401
+ expect(await settleVideos({ timeoutMs: 100 })).toEqual({ notReady: [], scrolled: true })
402
+ for (const video of [first, second]) {
403
+ expect(video.scrolledIntoView).toBe(1)
404
+ expect(video.paused).toBe(true)
405
+ expect(video.currentTime).toBe(0)
406
+ }
407
+ })
408
+
409
+ it('records the playback state it found so it can be restored', async () => {
410
+ const video = makeVideo({ src: 'https://example.com/v.mp4', autoplay: true, paused: false, currentTime: 3.5 })
411
+ installDom([video])
412
+
413
+ await settleVideos({ timeoutMs: 100 })
414
+
415
+ expect(video.__playwrightTestingVideoState).toEqual({
416
+ autoplay: true,
417
+ paused: false,
418
+ currentTime: 3.5,
419
+ })
420
+ })
421
+
422
+ it('keeps the first recorded state when it settles the same video twice', async () => {
423
+ const video = makeVideo({ src: 'https://example.com/v.mp4', autoplay: true, paused: false, currentTime: 3.5 })
424
+ installDom([video])
425
+
426
+ await settleVideos({ timeoutMs: 100 })
427
+ await settleVideos({ timeoutMs: 100 })
428
+
429
+ // Otherwise a second capture would record the pinned state as the original
430
+ // one and there would be nothing left to restore.
431
+ expect(video.__playwrightTestingVideoState.paused).toBe(false)
432
+ expect(video.__playwrightTestingVideoState.currentTime).toBe(3.5)
433
+ })
434
+ })
435
+
436
+ describe('restorePlayback', () => {
437
+ afterEach(() => {
438
+ vi.unstubAllGlobals()
439
+ })
440
+
441
+ it('puts back the playback state settleVideos() found', async () => {
442
+ const video = makeVideo({ src: 'https://example.com/v.mp4', autoplay: true, paused: false, currentTime: 3.5 })
443
+ installDom([video])
444
+
445
+ await settleVideos({ timeoutMs: 100 })
446
+ restorePlayback()
447
+
448
+ expect(video.autoplay).toBe(true)
449
+ expect(video.paused).toBe(false)
450
+ expect(video.currentTime).toBe(3.5)
451
+ expect(video.playCalls).toBe(1)
452
+ })
453
+
454
+ it('leaves a video that was already paused where it was', async () => {
455
+ const video = makeVideo({ src: 'https://example.com/v.mp4', currentTime: 0 })
456
+ installDom([video])
457
+
458
+ await settleVideos({ timeoutMs: 100 })
459
+ restorePlayback()
460
+
461
+ expect(video.paused).toBe(true)
462
+ expect(video.playCalls).toBe(0)
463
+ })
464
+
465
+ it('ignores videos it never settled', () => {
466
+ const video = makeVideo({ src: 'https://example.com/v.mp4', autoplay: true, paused: false })
467
+ installDom([video])
468
+
469
+ restorePlayback()
470
+
471
+ expect(video.playCalls).toBe(0)
472
+ expect(video.pauseCalls).toBe(0)
473
+ })
474
+
475
+ it('does not undo what the test did after the first restore', async () => {
476
+ const video = makeVideo({ src: 'https://example.com/v.mp4', autoplay: true, paused: false, currentTime: 3.5 })
477
+ installDom([video])
478
+
479
+ await settleVideos({ timeoutMs: 100 })
480
+ restorePlayback()
481
+ video.pause()
482
+ restorePlayback()
483
+
484
+ expect(video.paused).toBe(true)
485
+ expect(video.playCalls).toBe(1)
486
+ })
487
+
488
+ it('survives a video whose source cannot be seeked', async () => {
489
+ const video = makeVideo({ src: 'https://example.com/live.m3u8', paused: false, currentTime: 12, seekable: false })
490
+ installDom([video])
491
+
492
+ await settleVideos({ timeoutMs: 100 })
493
+ expect(() => restorePlayback()).not.toThrow()
494
+ expect(video.paused).toBe(false)
495
+ })
496
+ })
497
+
498
+ describe('waitForVideos', () => {
499
+ let warn: any
500
+
501
+ beforeEach(() => {
502
+ warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
503
+ })
504
+
505
+ afterEach(() => {
506
+ warn.mockRestore()
507
+ })
508
+
509
+ /**
510
+ * A stand-in for the parts of Page waitForVideos() uses, with one result per
511
+ * frame.
512
+ */
513
+ function makePage(results: Array<{notReady: string[], scrolled: boolean} | Error>) {
514
+ const frames = results.map(result => ({
515
+ evaluate: vi.fn().mockImplementation(() =>
516
+ result instanceof Error ? Promise.reject(result) : Promise.resolve(result)
517
+ ),
518
+ }))
519
+ const page = {
520
+ frames: () => frames,
521
+ } as any
522
+ return { page, frames }
523
+ }
524
+
525
+ it('runs the settle in the page with the requested timeout', async () => {
526
+ const { page, frames } = makePage([{ notReady: [], scrolled: true }])
527
+
528
+ expect(await waitForVideos(page, 5000)).toEqual([])
529
+ // The browser-side function is handed to evaluate() by reference so
530
+ // Playwright serializes it: it must not be wrapped in a closure over
531
+ // anything in this module.
532
+ expect(frames[0].evaluate).toHaveBeenCalledWith(settleVideos, { timeoutMs: 5000 })
533
+ expect(warn).not.toHaveBeenCalled()
534
+ })
535
+
536
+ it('defaults to a 5 second timeout', async () => {
537
+ const { page, frames } = makePage([{ notReady: [], scrolled: false }])
538
+
539
+ await waitForVideos(page)
540
+
541
+ expect(frames[0].evaluate).toHaveBeenCalledWith(settleVideos, { timeoutMs: 5000 })
542
+ })
543
+
544
+ it('settles videos in every frame, not just the main one', async () => {
545
+ // Media embeds commonly render inside iframes; a video in one of those is no less able to
546
+ // break a comparison.
547
+ const { page, frames } = makePage([
548
+ { notReady: [], scrolled: true },
549
+ { notReady: ['https://example.com/embed.mp4'], scrolled: true },
550
+ ])
551
+
552
+ expect(await waitForVideos(page)).toEqual(['https://example.com/embed.mp4'])
553
+ expect(frames[1].evaluate).toHaveBeenCalledWith(settleVideos, { timeoutMs: 5000 })
554
+ })
555
+
556
+ it('keeps going when a frame has gone away', async () => {
557
+ const { page } = makePage([
558
+ new Error('Execution context was destroyed'),
559
+ { notReady: [], scrolled: true },
560
+ ])
561
+
562
+ expect(await waitForVideos(page)).toEqual([])
563
+ })
564
+
565
+ it('runs the adapter hook after a video moved the page', async () => {
566
+ const { page } = makePage([{ notReady: [], scrolled: true }])
567
+ const afterScroll = vi.fn().mockResolvedValue(undefined)
568
+
569
+ await waitForVideos(page, {afterScroll})
570
+
571
+ expect(afterScroll).toHaveBeenCalledWith(page)
572
+ })
573
+
574
+ it('does not run the adapter hook when nothing scrolled', async () => {
575
+ const { page } = makePage([{ notReady: [], scrolled: false }])
576
+ const afterScroll = vi.fn().mockResolvedValue(undefined)
577
+
578
+ await waitForVideos(page, {afterScroll})
579
+
580
+ expect(afterScroll).not.toHaveBeenCalled()
581
+ })
582
+
583
+ it('restores playback when the adapter hook fails', async () => {
584
+ const {page, frames} = makePage([{notReady: [], scrolled: true}])
585
+ const failure = new Error('adapter failed')
586
+
587
+ await expect(waitForVideos(page, {
588
+ afterScroll: vi.fn().mockRejectedValue(failure),
589
+ })).rejects.toBe(failure)
590
+
591
+ expect(frames[0].evaluate).toHaveBeenLastCalledWith(restorePlayback)
592
+ })
593
+
594
+ it('warns about, and returns, the videos that never became ready', async () => {
595
+ const { page } = makePage([
596
+ { notReady: ['https://example.com/a.mp4', 'https://example.com/b.mp4'], scrolled: true },
597
+ ])
598
+
599
+ const notReady = await waitForVideos(page, 1000)
600
+
601
+ expect(notReady).toEqual(['https://example.com/a.mp4', 'https://example.com/b.mp4'])
602
+ expect(warn).toHaveBeenCalledTimes(1)
603
+ expect(warn.mock.calls[0][0]).toContain('2 video(s) had no frame available within 1000ms')
604
+ expect(warn.mock.calls[0][0]).toContain('https://example.com/a.mp4, https://example.com/b.mp4')
605
+ })
606
+ })
607
+
608
+ describe('restoreVideoPlayback', () => {
609
+ function makePage(frameCount: number, failing: number[] = []) {
610
+ const frames = Array.from({ length: frameCount }, (_unused, index) => ({
611
+ evaluate: vi.fn().mockImplementation(() =>
612
+ failing.includes(index)
613
+ ? Promise.reject(new Error('Execution context was destroyed'))
614
+ : Promise.resolve(undefined)
615
+ ),
616
+ }))
617
+ return { page: { frames: () => frames } as any, frames }
618
+ }
619
+
620
+ it('restores playback in every frame', async () => {
621
+ const { page, frames } = makePage(2)
622
+
623
+ await restoreVideoPlayback(page)
624
+
625
+ for (const frame of frames) {
626
+ // By reference, for the same serialization reason as settleVideos().
627
+ expect(frame.evaluate).toHaveBeenCalledWith(restorePlayback)
628
+ }
629
+ })
630
+
631
+ it('keeps going when a frame has gone away', async () => {
632
+ const { page, frames } = makePage(2, [0])
633
+
634
+ await expect(restoreVideoPlayback(page)).resolves.toBeUndefined()
635
+ expect(frames[1].evaluate).toHaveBeenCalledWith(restorePlayback)
636
+ })
637
+ })