@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,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'