@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,355 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { decodeVisibleImages, settleImage, waitForImages, waitForImagesToDecode } from './images'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A stand-in for the parts of HTMLImageElement decodeVisibleImages() touches.
|
|
7
|
+
*
|
|
8
|
+
* decodeVisibleImages() runs in the browser, so it is exercised here against a
|
|
9
|
+
* fake DOM rather than a real page: each image declares whether it is loaded,
|
|
10
|
+
* still loading, or broken, and a broken one can be told to recover the way a
|
|
11
|
+
* Stage File Proxy URL does once its on-demand fetch finishes.
|
|
12
|
+
*/
|
|
13
|
+
interface FakeImageOptions {
|
|
14
|
+
src?: string
|
|
15
|
+
currentSrc?: string
|
|
16
|
+
width?: number
|
|
17
|
+
height?: number
|
|
18
|
+
rect?: { width: number, height: number }
|
|
19
|
+
style?: { visibility?: string, display?: string }
|
|
20
|
+
/** 'loaded', 'loading' (never completes on its own), or 'broken'. */
|
|
21
|
+
state?: 'loaded' | 'loading' | 'broken'
|
|
22
|
+
/** Whether a re-request makes a broken image decode. */
|
|
23
|
+
recoversOnReload?: boolean
|
|
24
|
+
/** <source> elements of an enclosing <picture>, if any. */
|
|
25
|
+
sources?: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeImage(options: FakeImageOptions = {}) {
|
|
29
|
+
const state = options.state ?? 'loaded'
|
|
30
|
+
const sources = options.sources
|
|
31
|
+
let src = options.src ?? 'https://example.com/image.jpg'
|
|
32
|
+
let broken = state === 'broken'
|
|
33
|
+
|
|
34
|
+
const img: any = {
|
|
35
|
+
width: options.width ?? 100,
|
|
36
|
+
height: options.height ?? 100,
|
|
37
|
+
complete: state !== 'loading',
|
|
38
|
+
naturalWidth: state === 'loaded' ? 100 : 0,
|
|
39
|
+
currentSrc: options.currentSrc ?? '',
|
|
40
|
+
removedAttributes: [] as string[],
|
|
41
|
+
picture: sources
|
|
42
|
+
? {
|
|
43
|
+
sources: sources.slice(),
|
|
44
|
+
querySelectorAll: (_selector: string) =>
|
|
45
|
+
img.picture.sources.map((name: string) => ({
|
|
46
|
+
remove: () => {
|
|
47
|
+
img.picture.sources = img.picture.sources.filter((s: string) => s !== name)
|
|
48
|
+
},
|
|
49
|
+
})),
|
|
50
|
+
}
|
|
51
|
+
: null,
|
|
52
|
+
getBoundingClientRect: () => options.rect ?? { width: 100, height: 100 },
|
|
53
|
+
style: {
|
|
54
|
+
visibility: options.style?.visibility ?? 'visible',
|
|
55
|
+
display: options.style?.display ?? 'block',
|
|
56
|
+
},
|
|
57
|
+
decode: () => (broken ? Promise.reject(new Error('decode failed')) : Promise.resolve()),
|
|
58
|
+
closest: (selector: string) => (selector === 'picture' ? img.picture : null),
|
|
59
|
+
removeAttribute: (name: string) => {
|
|
60
|
+
img.removedAttributes.push(name)
|
|
61
|
+
},
|
|
62
|
+
/** Pretend the network finished, for images that start out loading. */
|
|
63
|
+
finishLoading: () => {
|
|
64
|
+
img.complete = true
|
|
65
|
+
img.naturalWidth = 100
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Object.defineProperty(img, 'src', {
|
|
70
|
+
get: () => src,
|
|
71
|
+
set: (value: string) => {
|
|
72
|
+
src = value
|
|
73
|
+
// A re-request clears whatever the browser had resolved from srcset.
|
|
74
|
+
img.currentSrc = ''
|
|
75
|
+
if (options.recoversOnReload) {
|
|
76
|
+
broken = false
|
|
77
|
+
img.naturalWidth = 100
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return img
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stubDom(images: any[]) {
|
|
86
|
+
vi.stubGlobal('document', { images })
|
|
87
|
+
vi.stubGlobal('location', { href: 'https://example.com/page' })
|
|
88
|
+
vi.stubGlobal('getComputedStyle', (element: any) => element.style)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Real timers, so the polling is real: keep the intervals tiny.
|
|
92
|
+
const fast = { pollMs: 1, reloadIntervalMs: 0 }
|
|
93
|
+
|
|
94
|
+
describe('decodeVisibleImages', () => {
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
vi.unstubAllGlobals()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns no failures when every visible image has decoded', async () => {
|
|
100
|
+
stubDom([makeImage(), makeImage()])
|
|
101
|
+
|
|
102
|
+
expect(await decodeVisibleImages({ timeoutMs: 1000, ...fast })).toEqual([])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('treats a dimensionless but decodable image as loaded', async () => {
|
|
106
|
+
// An SVG with no intrinsic size is complete with a naturalWidth of 0, but
|
|
107
|
+
// decode() resolves for it. It must not be re-requested or reported.
|
|
108
|
+
const svg = makeImage({ src: 'https://example.com/logo.svg', state: 'broken' })
|
|
109
|
+
svg.decode = () => Promise.resolve()
|
|
110
|
+
stubDom([svg])
|
|
111
|
+
|
|
112
|
+
expect(await decodeVisibleImages({ timeoutMs: 1000, ...fast })).toEqual([])
|
|
113
|
+
expect(svg.src).toBe('https://example.com/logo.svg')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('ignores 1x1, zero-size, and hidden images even when they are broken', async () => {
|
|
117
|
+
stubDom([
|
|
118
|
+
makeImage({ state: 'broken', width: 1, height: 1 }),
|
|
119
|
+
makeImage({ state: 'broken', rect: { width: 0, height: 0 } }),
|
|
120
|
+
makeImage({ state: 'broken', style: { visibility: 'hidden' } }),
|
|
121
|
+
makeImage({ state: 'broken', style: { display: 'none' } }),
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
expect(await decodeVisibleImages({ timeoutMs: 0, ...fast })).toEqual([])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('waits for an image that is still loading', async () => {
|
|
128
|
+
const loading = makeImage({ state: 'loading' })
|
|
129
|
+
stubDom([loading])
|
|
130
|
+
setTimeout(() => loading.finishLoading(), 5)
|
|
131
|
+
|
|
132
|
+
expect(await decodeVisibleImages({ timeoutMs: 2000, ...fast })).toEqual([])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('re-requests a broken image and reports success once it decodes', async () => {
|
|
136
|
+
const broken = makeImage({
|
|
137
|
+
src: 'https://example.com/broken.jpg',
|
|
138
|
+
currentSrc: 'https://example.com/broken-800.jpg',
|
|
139
|
+
state: 'broken',
|
|
140
|
+
recoversOnReload: true,
|
|
141
|
+
sources: ['source-1', 'source-2'],
|
|
142
|
+
})
|
|
143
|
+
stubDom([broken])
|
|
144
|
+
|
|
145
|
+
expect(await decodeVisibleImages({
|
|
146
|
+
timeoutMs: 2000,
|
|
147
|
+
recoverErroredImages: true,
|
|
148
|
+
...fast,
|
|
149
|
+
})).toEqual([])
|
|
150
|
+
// The retry reuses the URL already resolved from srcset, cache-busted...
|
|
151
|
+
expect(broken.src).toMatch(/^https:\/\/example\.com\/broken-800\.jpg\?playwrightReload=\d+$/)
|
|
152
|
+
// ...with the responsive sources dropped so that exact URL is fetched.
|
|
153
|
+
expect(broken.removedAttributes).toContain('srcset')
|
|
154
|
+
expect(broken.picture.sources).toEqual([])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('does not rewrite a broken image source unless recovery is opted into', async () => {
|
|
158
|
+
const broken = makeImage({
|
|
159
|
+
src: 'https://example.com/broken.jpg',
|
|
160
|
+
state: 'broken',
|
|
161
|
+
recoversOnReload: true,
|
|
162
|
+
})
|
|
163
|
+
stubDom([broken])
|
|
164
|
+
|
|
165
|
+
expect(await decodeVisibleImages({timeoutMs: 0, ...fast})).toEqual([
|
|
166
|
+
'https://example.com/broken.jpg',
|
|
167
|
+
])
|
|
168
|
+
expect(broken.src).toBe('https://example.com/broken.jpg')
|
|
169
|
+
expect(broken.removedAttributes).toEqual([])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('leaves data: URLs alone', async () => {
|
|
173
|
+
const data = makeImage({ src: 'data:image/png;base64,AAAA', state: 'broken' })
|
|
174
|
+
stubDom([data])
|
|
175
|
+
|
|
176
|
+
expect(await decodeVisibleImages({ timeoutMs: 0, ...fast })).toEqual(['data:image/png;base64,AAAA'])
|
|
177
|
+
expect(data.src).toBe('data:image/png;base64,AAAA')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('reports the images that never decode instead of returning silently', async () => {
|
|
181
|
+
const broken = makeImage({ src: 'https://example.com/missing.jpg', state: 'broken' })
|
|
182
|
+
const loading = makeImage({ src: 'https://example.com/slow.jpg', state: 'loading' })
|
|
183
|
+
stubDom([makeImage(), broken, loading])
|
|
184
|
+
|
|
185
|
+
const undecoded = await decodeVisibleImages({ timeoutMs: 20, ...fast })
|
|
186
|
+
|
|
187
|
+
// The cache-busting parameter the retries added is stripped back off, so
|
|
188
|
+
// the reported URL is the one the page asked for.
|
|
189
|
+
expect(undecoded).toEqual([
|
|
190
|
+
'https://example.com/missing.jpg',
|
|
191
|
+
'https://example.com/slow.jpg',
|
|
192
|
+
])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('checks the images at least once even with an elapsed timeout', async () => {
|
|
196
|
+
stubDom([makeImage({ src: 'https://example.com/missing.jpg', state: 'broken' })])
|
|
197
|
+
|
|
198
|
+
expect(await decodeVisibleImages({ timeoutMs: 0, ...fast })).toEqual([
|
|
199
|
+
'https://example.com/missing.jpg',
|
|
200
|
+
])
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A stand-in for the parts of HTMLImageElement settleImage() touches.
|
|
206
|
+
*
|
|
207
|
+
* settleImage() also runs in the browser, so it is exercised against a fake
|
|
208
|
+
* element whose load and error events can be fired on demand.
|
|
209
|
+
*/
|
|
210
|
+
function makeSettleImage(options: { width?: number, height?: number, complete?: boolean } = {}) {
|
|
211
|
+
const listeners: Record<string, Array<() => void>> = { load: [], error: [] }
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
width: options.width ?? 100,
|
|
215
|
+
height: options.height ?? 100,
|
|
216
|
+
complete: options.complete ?? false,
|
|
217
|
+
/** Assigning these instead of adding listeners would clobber the page's. */
|
|
218
|
+
onload: null,
|
|
219
|
+
onerror: null,
|
|
220
|
+
addEventListener: (type: string, handler: () => void) => {
|
|
221
|
+
listeners[type].push(handler)
|
|
222
|
+
},
|
|
223
|
+
removeEventListener: (type: string, handler: () => void) => {
|
|
224
|
+
listeners[type] = listeners[type].filter(h => h !== handler)
|
|
225
|
+
},
|
|
226
|
+
fire: (type: 'load' | 'error') => {
|
|
227
|
+
listeners[type].slice().forEach(handler => handler())
|
|
228
|
+
},
|
|
229
|
+
listenerCount: () => listeners.load.length + listeners.error.length,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
describe('settleImage', () => {
|
|
234
|
+
it('does not wait for an image that has already settled', () => {
|
|
235
|
+
const settled = makeSettleImage({ complete: true })
|
|
236
|
+
|
|
237
|
+
// No promise back means nothing to await.
|
|
238
|
+
expect(settleImage(settled as any)).toBeUndefined()
|
|
239
|
+
expect(settled.listenerCount()).toBe(0)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('does not wait for a 1x1 visually-hidden image', () => {
|
|
243
|
+
// Chrome never loads these at desktop widths, so waiting would hang.
|
|
244
|
+
const hidden = makeSettleImage({ width: 1, height: 1, complete: false })
|
|
245
|
+
|
|
246
|
+
expect(settleImage(hidden as any)).toBeUndefined()
|
|
247
|
+
expect(hidden.listenerCount()).toBe(0)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('resolves once a loading image loads', async () => {
|
|
251
|
+
const loading = makeSettleImage()
|
|
252
|
+
const settled = settleImage(loading as any)
|
|
253
|
+
loading.fire('load')
|
|
254
|
+
|
|
255
|
+
await expect(settled).resolves.toBeUndefined()
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('resolves once a loading image errors', async () => {
|
|
259
|
+
// The regression: an image that 404s or 503s after the wait begins only
|
|
260
|
+
// ever fires `error`. Waiting on `load` alone hung until the test timed
|
|
261
|
+
// out, and never reached the decode retry that could have recovered it.
|
|
262
|
+
const loading = makeSettleImage()
|
|
263
|
+
const settled = settleImage(loading as any)
|
|
264
|
+
loading.fire('error')
|
|
265
|
+
|
|
266
|
+
await expect(settled).resolves.toBeUndefined()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('cleans up both listeners once settled, and leaves the page handlers alone', async () => {
|
|
270
|
+
const loading = makeSettleImage()
|
|
271
|
+
const settled = settleImage(loading as any)
|
|
272
|
+
expect(loading.listenerCount()).toBe(2)
|
|
273
|
+
|
|
274
|
+
loading.fire('error')
|
|
275
|
+
await settled
|
|
276
|
+
|
|
277
|
+
expect(loading.listenerCount()).toBe(0)
|
|
278
|
+
// Assigning onload/onerror would have replaced whatever the page installed.
|
|
279
|
+
expect(loading.onload).toBeNull()
|
|
280
|
+
expect(loading.onerror).toBeNull()
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
describe('waitForImagesToDecode', () => {
|
|
285
|
+
let warn: any
|
|
286
|
+
|
|
287
|
+
beforeEach(() => {
|
|
288
|
+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
afterEach(() => {
|
|
292
|
+
warn.mockRestore()
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
function makePage(undecoded: string[]) {
|
|
296
|
+
return {
|
|
297
|
+
evaluate: vi.fn().mockResolvedValue(undecoded),
|
|
298
|
+
} as any
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
it('runs the decode poll in the page with the requested timeout', async () => {
|
|
302
|
+
const page = makePage([])
|
|
303
|
+
|
|
304
|
+
expect(await waitForImagesToDecode(page, 5000)).toEqual([])
|
|
305
|
+
// The browser-side function is handed to evaluate() by reference so
|
|
306
|
+
// Playwright serializes it: it must not be wrapped in a closure over
|
|
307
|
+
// anything in this module.
|
|
308
|
+
expect(page.evaluate).toHaveBeenCalledWith(decodeVisibleImages, {
|
|
309
|
+
timeoutMs: 5000,
|
|
310
|
+
recoverErroredImages: false,
|
|
311
|
+
})
|
|
312
|
+
expect(warn).not.toHaveBeenCalled()
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('defaults to a 15 second timeout', async () => {
|
|
316
|
+
const page = makePage([])
|
|
317
|
+
|
|
318
|
+
await waitForImagesToDecode(page)
|
|
319
|
+
|
|
320
|
+
expect(page.evaluate).toHaveBeenCalledWith(decodeVisibleImages, {
|
|
321
|
+
timeoutMs: 15000,
|
|
322
|
+
recoverErroredImages: false,
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('warns about, and returns, the images that never decoded', async () => {
|
|
327
|
+
const page = makePage(['https://example.com/a.jpg', 'https://example.com/b.jpg'])
|
|
328
|
+
|
|
329
|
+
const undecoded = await waitForImagesToDecode(page, 1000)
|
|
330
|
+
|
|
331
|
+
expect(undecoded).toEqual(['https://example.com/a.jpg', 'https://example.com/b.jpg'])
|
|
332
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
333
|
+
expect(warn.mock.calls[0][0]).toContain('2 image(s) did not finish loading within 1000ms')
|
|
334
|
+
expect(warn.mock.calls[0][0]).toContain('https://example.com/a.jpg, https://example.com/b.jpg')
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
describe('waitForImages', () => {
|
|
339
|
+
it('restores the viewport and runs the adapter hook when decoding fails', async () => {
|
|
340
|
+
const failure = new Error('decode failed')
|
|
341
|
+
const afterScroll = vi.fn().mockResolvedValue(undefined)
|
|
342
|
+
const page = {
|
|
343
|
+
locator: vi.fn().mockReturnValue({all: vi.fn().mockResolvedValue([])}),
|
|
344
|
+
evaluate: vi.fn((callback: unknown) =>
|
|
345
|
+
callback === decodeVisibleImages ? Promise.reject(failure) : Promise.resolve(undefined)
|
|
346
|
+
),
|
|
347
|
+
waitForFunction: vi.fn().mockResolvedValue(undefined),
|
|
348
|
+
} as any
|
|
349
|
+
|
|
350
|
+
await expect(waitForImages(page, 'img', {afterScroll})).rejects.toBe(failure)
|
|
351
|
+
|
|
352
|
+
expect(page.waitForFunction).toHaveBeenCalledOnce()
|
|
353
|
+
expect(afterScroll).toHaveBeenCalledWith(page)
|
|
354
|
+
})
|
|
355
|
+
})
|
package/src/images.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import {Page} from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
export interface WaitForImagesOptions {
|
|
4
|
+
/** How long to wait for visible images to decode. */
|
|
5
|
+
decodeTimeoutMs?: number;
|
|
6
|
+
/**
|
|
7
|
+
* Re-request broken images with a cache-busting URL. This mutates `src`,
|
|
8
|
+
* `srcset`, and enclosing `<picture>` sources, so it is disabled by default.
|
|
9
|
+
*/
|
|
10
|
+
recoverErroredImages?: boolean;
|
|
11
|
+
/** Optional adapter hook run after the viewport has returned to the top. */
|
|
12
|
+
afterScroll?: (page: Page) => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wait for images specified by a selector to load.
|
|
17
|
+
*
|
|
18
|
+
* The function must scroll the page to handle lazy-loading images. After all
|
|
19
|
+
* images have loaded, the page is scrolled back to the top.
|
|
20
|
+
*
|
|
21
|
+
* See https://github.com/microsoft/playwright/issues/14388 for further details.
|
|
22
|
+
*
|
|
23
|
+
* @param page
|
|
24
|
+
* @param selector
|
|
25
|
+
*/
|
|
26
|
+
export async function waitForImages(
|
|
27
|
+
page: Page,
|
|
28
|
+
selector: string,
|
|
29
|
+
options: WaitForImagesOptions = {},
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
const locators = page.locator(selector);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// Trigger lazy-loading images. Since this should be fast, and we don't want to
|
|
35
|
+
// have to deal with concurrency bugs, we do this in serial.
|
|
36
|
+
for (const l of await locators.all()) {
|
|
37
|
+
// Ensure images are connected to the DOM before trying to scroll to them.
|
|
38
|
+
// https://github.com/microsoft/playwright/issues/23758
|
|
39
|
+
if (await l.evaluate(image => image.isConnected)) {
|
|
40
|
+
await l.scrollIntoViewIfNeeded();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Make sure all images have loaded.
|
|
45
|
+
const promises = (await locators.all()).map(locator => locator.evaluate(settleImage));
|
|
46
|
+
await Promise.all(promises);
|
|
47
|
+
|
|
48
|
+
// The wait above treats an errored image as "loaded". Decode each visible
|
|
49
|
+
// image as well; source rewriting is available only when explicitly enabled.
|
|
50
|
+
await waitForImagesToDecode(
|
|
51
|
+
page,
|
|
52
|
+
options.decodeTimeoutMs ?? 15000,
|
|
53
|
+
options.recoverErroredImages ?? false,
|
|
54
|
+
);
|
|
55
|
+
} finally {
|
|
56
|
+
// Lazy-loading scrolls the document. Always put it back, including when an
|
|
57
|
+
// image listener, decode poll, or adapter hook fails.
|
|
58
|
+
await page.evaluate(() =>
|
|
59
|
+
window.scroll({
|
|
60
|
+
top: 0,
|
|
61
|
+
left: 0,
|
|
62
|
+
behavior: 'instant',
|
|
63
|
+
})
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// window.scroll is async and doesn't return a promise, so wait until the
|
|
67
|
+
// browser confirms we are at the top again.
|
|
68
|
+
const scrollState = {forced: false};
|
|
69
|
+
await page.waitForFunction(state => {
|
|
70
|
+
if (window.scrollY !== 0 && !state.forced) {
|
|
71
|
+
window.scroll({top: 0, left: 0, behavior: 'instant'});
|
|
72
|
+
state.forced = true;
|
|
73
|
+
}
|
|
74
|
+
return window.scrollY === 0;
|
|
75
|
+
}, scrollState);
|
|
76
|
+
|
|
77
|
+
await options.afterScroll?.(page);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Wait for a single image to stop being in flight.
|
|
83
|
+
*
|
|
84
|
+
* Returns without a promise for an image that needs no waiting at all: one that
|
|
85
|
+
* has already settled, or a 1x1 image that is visually hidden for accessibility
|
|
86
|
+
* (a common visually-hidden accessibility technique), which would otherwise
|
|
87
|
+
* hang forever -- even
|
|
88
|
+
* though such images are :visible, Chrome doesn't load them at desktop widths. See
|
|
89
|
+
* https://www.tpgi.com/the-anatomy-of-visually-hidden/ for how .visually-hidden
|
|
90
|
+
* works.
|
|
91
|
+
*
|
|
92
|
+
* Otherwise it waits for the request to finish, whether it succeeds or fails.
|
|
93
|
+
* Waiting on `load` alone would hang until the test timed out on any image that
|
|
94
|
+
* errors after the wait begins -- a 404, or an image proxy that fails while it
|
|
95
|
+
* fetches the original on demand -- because such an image only ever
|
|
96
|
+
* fires `error`. That hang is worse than useless here: it happens before
|
|
97
|
+
* waitForImagesToDecode() runs, so it also denies the one piece of code that
|
|
98
|
+
* knows how to re-request a broken image the chance to recover it. Settling on
|
|
99
|
+
* `error` hands the image on to that recovery instead.
|
|
100
|
+
*
|
|
101
|
+
* Listeners are added rather than assigned to `onload`/`onerror` so that any
|
|
102
|
+
* handler the page itself installed keeps working.
|
|
103
|
+
*
|
|
104
|
+
* This runs in the browser via `evaluate()`, which serializes the function
|
|
105
|
+
* source, so it must stay self-contained and reference nothing else in this
|
|
106
|
+
* module. It is exported so the waiting can be tested directly.
|
|
107
|
+
*
|
|
108
|
+
* @param image
|
|
109
|
+
*/
|
|
110
|
+
export function settleImage(image: HTMLImageElement): void | Promise<void> {
|
|
111
|
+
if ((image.width <= 1 && image.height <= 1) || image.complete) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
return new Promise<void>(resolve => {
|
|
115
|
+
const settled = () => {
|
|
116
|
+
image.removeEventListener("load", settled);
|
|
117
|
+
image.removeEventListener("error", settled);
|
|
118
|
+
resolve();
|
|
119
|
+
};
|
|
120
|
+
image.addEventListener("load", settled);
|
|
121
|
+
image.addEventListener("error", settled);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Wait for all image tags on the page to load.
|
|
127
|
+
*
|
|
128
|
+
* @param page
|
|
129
|
+
*/
|
|
130
|
+
export async function waitForAllImages(
|
|
131
|
+
page: Page,
|
|
132
|
+
options: WaitForImagesOptions = {},
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
await waitForImages(page, 'img:visible', options);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Poll every visible image until it decodes, optionally re-requesting failures.
|
|
139
|
+
*
|
|
140
|
+
* A loaded image is `complete` with a `naturalWidth` greater than zero. An
|
|
141
|
+
* errored request (a 404, or an image proxy that fails while it
|
|
142
|
+
* fetches the original on demand) is `complete` with a `naturalWidth` of 0 and
|
|
143
|
+
* would otherwise be screenshotted as a broken image. A zero `naturalWidth` is
|
|
144
|
+
* ambiguous, though -- a valid but dimensionless image such as an SVG without an
|
|
145
|
+
* intrinsic size reports it too -- so `decode()` disambiguates: it rejects only
|
|
146
|
+
* for a genuine failure. When recovery is explicitly enabled, a broken image
|
|
147
|
+
* is re-requested with a cache-busting query parameter. The retry reuses
|
|
148
|
+
* `currentSrc` (the URL already
|
|
149
|
+
* chosen from `srcset`) and drops the responsive sources so that exact image
|
|
150
|
+
* loads, keeping the render identical to a clean first load. 1x1
|
|
151
|
+
* visually-hidden images are skipped.
|
|
152
|
+
*
|
|
153
|
+
* This runs in the browser via `page.evaluate()`, which serializes the function
|
|
154
|
+
* source, so it must stay self-contained and reference nothing else in this
|
|
155
|
+
* module. It is exported separately from waitForImagesToDecode() so the polling
|
|
156
|
+
* can be tested directly.
|
|
157
|
+
*
|
|
158
|
+
* Every image is checked at least once, even with a timeout of zero, and the
|
|
159
|
+
* images that never settled are returned rather than swallowed so the caller
|
|
160
|
+
* can report them.
|
|
161
|
+
*
|
|
162
|
+
* @param options.timeoutMs How long to keep polling before giving up.
|
|
163
|
+
* @param options.pollMs How long to sleep between polls.
|
|
164
|
+
* @param options.reloadIntervalMs How long to leave a re-requested image to
|
|
165
|
+
* resolve before re-requesting it again.
|
|
166
|
+
* @param options.recoverErroredImages Whether broken image sources may be
|
|
167
|
+
* rewritten and re-requested. Defaults to false.
|
|
168
|
+
* @returns The URLs of the images that never decoded. Empty when they all did.
|
|
169
|
+
*/
|
|
170
|
+
export async function decodeVisibleImages(
|
|
171
|
+
options: {
|
|
172
|
+
timeoutMs: number,
|
|
173
|
+
pollMs?: number,
|
|
174
|
+
reloadIntervalMs?: number,
|
|
175
|
+
recoverErroredImages?: boolean,
|
|
176
|
+
}
|
|
177
|
+
): Promise<string[]> {
|
|
178
|
+
const timeoutMs = options.timeoutMs;
|
|
179
|
+
const pollMs = options.pollMs ?? 250;
|
|
180
|
+
const reloadIntervalMs = options.reloadIntervalMs ?? 2000;
|
|
181
|
+
const recoverErroredImages = options.recoverErroredImages ?? false;
|
|
182
|
+
const deadline = Date.now() + timeoutMs;
|
|
183
|
+
const isCandidate = (img: HTMLImageElement) => {
|
|
184
|
+
// Skip 1x1 visually-hidden images (see waitForImages above).
|
|
185
|
+
if (img.width <= 1 && img.height <= 1) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
const rect = img.getBoundingClientRect();
|
|
189
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
const style = getComputedStyle(img);
|
|
193
|
+
return style.visibility !== "hidden" && style.display !== "none";
|
|
194
|
+
};
|
|
195
|
+
const reload = (img: HTMLImageElement) => {
|
|
196
|
+
const src = img.currentSrc || img.src;
|
|
197
|
+
if (!src || src.startsWith("data:")) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const url = new URL(src, location.href);
|
|
202
|
+
url.searchParams.set("playwrightReload", String(Date.now()));
|
|
203
|
+
// Drop the responsive sources so the resolved URL we just loaded is the
|
|
204
|
+
// one fetched, instead of re-running srcset selection.
|
|
205
|
+
const picture = img.closest("picture");
|
|
206
|
+
if (picture) {
|
|
207
|
+
picture.querySelectorAll("source").forEach(source => source.remove());
|
|
208
|
+
}
|
|
209
|
+
img.removeAttribute("srcset");
|
|
210
|
+
img.src = url.href;
|
|
211
|
+
} catch {
|
|
212
|
+
// Ignore anything that is not a reloadable URL.
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
let lastReload = 0;
|
|
217
|
+
// Checking before testing the deadline means a zero or already-elapsed
|
|
218
|
+
// timeout still reports on the images instead of claiming success.
|
|
219
|
+
for (;;) {
|
|
220
|
+
const candidates = Array.from(document.images).filter(isCandidate);
|
|
221
|
+
// Collect the verdicts positionally rather than pushing as each check
|
|
222
|
+
// settles, so anything reported below stays in document order.
|
|
223
|
+
const verdicts = await Promise.all(candidates.map(async img => {
|
|
224
|
+
if (img.complete && img.naturalWidth > 0) {
|
|
225
|
+
return "loaded"; // Loaded with intrinsic dimensions.
|
|
226
|
+
}
|
|
227
|
+
if (!img.complete) {
|
|
228
|
+
return "loading";
|
|
229
|
+
}
|
|
230
|
+
// Complete with a zero naturalWidth is ambiguous: a genuine load error
|
|
231
|
+
// (404/503) rejects decode(), while a valid but dimensionless image
|
|
232
|
+
// (such as an SVG with no intrinsic size) resolves it. Only the former
|
|
233
|
+
// should be re-requested; the latter is already settled.
|
|
234
|
+
try {
|
|
235
|
+
await img.decode();
|
|
236
|
+
return "loaded";
|
|
237
|
+
} catch {
|
|
238
|
+
return "errored";
|
|
239
|
+
}
|
|
240
|
+
}));
|
|
241
|
+
const pending = candidates.filter((img, index) => verdicts[index] !== "loaded");
|
|
242
|
+
const errored = candidates.filter((img, index) => verdicts[index] === "errored");
|
|
243
|
+
if (pending.length === 0) {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
if (Date.now() >= deadline) {
|
|
247
|
+
return pending.map(img => {
|
|
248
|
+
const src = img.currentSrc || img.src;
|
|
249
|
+
try {
|
|
250
|
+
// Report the URL as the page authored it, without the cache-busting
|
|
251
|
+
// parameter a retry added, so the warning is greppable.
|
|
252
|
+
const url = new URL(src, location.href);
|
|
253
|
+
url.searchParams.delete("playwrightReload");
|
|
254
|
+
return url.href;
|
|
255
|
+
} catch {
|
|
256
|
+
return src;
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// Re-request errored images, throttled so each retry has time to resolve.
|
|
261
|
+
if (recoverErroredImages && Date.now() - lastReload > reloadIntervalMs) {
|
|
262
|
+
lastReload = Date.now();
|
|
263
|
+
errored.forEach(reload);
|
|
264
|
+
}
|
|
265
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Wait for every visible image to finish decoding.
|
|
271
|
+
*
|
|
272
|
+
* See decodeVisibleImages() for how an image is judged loaded, broken, or
|
|
273
|
+
* merely dimensionless, and how broken ones are re-requested.
|
|
274
|
+
*
|
|
275
|
+
* Giving up is not an error: a page that legitimately references a missing
|
|
276
|
+
* image should still be screenshotted and still have its accessibility checked,
|
|
277
|
+
* and the screenshot comparison is what fails. But the wait is not silent
|
|
278
|
+
* either -- the images that never decoded are warned about, so a mysterious
|
|
279
|
+
* pixel diff (and the timeout's worth of delay before it) has an explanation --
|
|
280
|
+
* and they are returned so a caller can assert on them.
|
|
281
|
+
*
|
|
282
|
+
* @param page
|
|
283
|
+
* @param timeoutMs How long to wait for images to decode before giving up.
|
|
284
|
+
* @param recoverErroredImages Whether broken image sources may be rewritten.
|
|
285
|
+
* @returns The URLs of the images that never decoded. Empty when they all did.
|
|
286
|
+
*/
|
|
287
|
+
export async function waitForImagesToDecode(
|
|
288
|
+
page: Page,
|
|
289
|
+
timeoutMs = 15000,
|
|
290
|
+
recoverErroredImages = false,
|
|
291
|
+
): Promise<string[]> {
|
|
292
|
+
const undecoded = await page.evaluate(decodeVisibleImages, {timeoutMs, recoverErroredImages});
|
|
293
|
+
if (undecoded.length > 0) {
|
|
294
|
+
console.warn(
|
|
295
|
+
`waitForImagesToDecode: ${undecoded.length} image(s) did not finish loading within ${timeoutMs}ms and may be captured as broken: ${undecoded.join(', ')}`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return undecoded;
|
|
299
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './focus.js'
|
|
2
|
+
export * from './accessibility-baseline.js'
|
|
3
|
+
export * from './accessibility-baseline-file.js'
|
|
4
|
+
export * from './accessible-screenshot.js'
|
|
5
|
+
export * from './fonts.js'
|
|
6
|
+
export * from './frames.js'
|
|
7
|
+
export * from './hover.js'
|
|
8
|
+
export * from './images.js'
|
|
9
|
+
export * from './interaction-states.js'
|
|
10
|
+
export * from './pseudo-state.js'
|
|
11
|
+
export * from './videos.js'
|
|
12
|
+
export * from './visualdiff.js'
|
|
13
|
+
export * from './mock/index.js'
|