@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.
- package/README.md +61 -0
- package/bin/github-a11y-summary +5 -0
- package/bin/github-failure-summary +8 -0
- package/lib/accessibility-baseline-file.d.ts +47 -0
- package/lib/accessibility-baseline-file.js +205 -0
- package/lib/accessibility-baseline.d.ts +19 -0
- package/lib/accessibility-baseline.js +38 -0
- package/lib/accessible-screenshot.d.ts +181 -0
- package/lib/accessible-screenshot.js +519 -0
- package/lib/focus.d.ts +14 -0
- package/lib/focus.js +28 -0
- package/lib/fonts.d.ts +18 -0
- package/lib/fonts.js +24 -0
- package/lib/frames.d.ts +7 -0
- package/lib/frames.js +27 -0
- package/lib/github/a11y-summary.d.ts +55 -0
- package/lib/github/a11y-summary.js +383 -0
- package/lib/github/attachments.d.ts +98 -0
- package/lib/github/attachments.js +297 -0
- package/lib/github/failure-summary.d.ts +144 -0
- package/lib/github/failure-summary.js +567 -0
- package/lib/github/index.d.ts +6 -0
- package/lib/github/index.js +35 -0
- package/lib/github/report-paths.d.ts +38 -0
- package/lib/github/report-paths.js +200 -0
- package/lib/hover.d.ts +13 -0
- package/lib/hover.js +61 -0
- package/lib/images.d.ts +118 -0
- package/lib/images.js +260 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +29 -0
- package/lib/interaction-states.d.ts +22 -0
- package/lib/interaction-states.js +75 -0
- package/lib/mock/index.d.ts +1 -0
- package/lib/mock/index.js +5 -0
- package/lib/mock/youtube.d.ts +5 -0
- package/lib/mock/youtube.js +38 -0
- package/lib/pseudo-state.d.ts +17 -0
- package/lib/pseudo-state.js +50 -0
- package/lib/videos.d.ts +134 -0
- package/lib/videos.js +349 -0
- package/lib/visualdiff.d.ts +154 -0
- package/lib/visualdiff.js +197 -0
- package/package.json +47 -0
- package/src/accessibility-baseline-file.test.ts +181 -0
- package/src/accessibility-baseline-file.ts +208 -0
- package/src/accessibility-baseline.test.ts +601 -0
- package/src/accessibility-baseline.ts +50 -0
- package/src/accessible-screenshot.test.ts +597 -0
- package/src/accessible-screenshot.ts +809 -0
- package/src/focus.test.ts +34 -0
- package/src/focus.ts +27 -0
- package/src/fonts.test.ts +17 -0
- package/src/fonts.ts +23 -0
- package/src/frames.test.ts +75 -0
- package/src/frames.ts +26 -0
- package/src/github/a11y-summary.test.ts +439 -0
- package/src/github/a11y-summary.ts +421 -0
- package/src/github/attachments.test.ts +248 -0
- package/src/github/attachments.ts +328 -0
- package/src/github/failure-summary.test.ts +636 -0
- package/src/github/failure-summary.ts +720 -0
- package/src/github/index.test.ts +24 -0
- package/src/github/index.ts +35 -0
- package/src/github/report-paths.test.ts +222 -0
- package/src/github/report-paths.ts +208 -0
- package/src/hover.test.ts +76 -0
- package/src/hover.ts +64 -0
- package/src/images.test.ts +355 -0
- package/src/images.ts +299 -0
- package/src/index.ts +13 -0
- package/src/interaction-states.test.ts +48 -0
- package/src/interaction-states.ts +94 -0
- package/src/mock/index.ts +1 -0
- package/src/mock/youtube.test.ts +38 -0
- package/src/mock/youtube.ts +39 -0
- package/src/pseudo-state.test.ts +83 -0
- package/src/pseudo-state.ts +69 -0
- package/src/videos.test.ts +637 -0
- package/src/videos.ts +389 -0
- package/src/visualdiff.test.ts +452 -0
- 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
|
+
})
|