@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
package/src/videos.ts ADDED
@@ -0,0 +1,389 @@
1
+ import {Page} from "@playwright/test";
2
+
3
+ /**
4
+ * Settle every <video> on the page so a screenshot of it is reproducible.
5
+ *
6
+ * A `<video>` is the one replaced element nothing else in the screenshot path
7
+ * covers. waitForImages() only looks at `img`, and Playwright's
8
+ * `animations: 'disabled'` only fast-forwards CSS transitions and Web
9
+ * Animations -- it does not touch media playback. That leaves two separate ways
10
+ * for a video to break a visual comparison:
11
+ *
12
+ * 1. **The frame is captured before it exists.** A full-page screenshot rasters
13
+ * the whole document, including regions that were never in the viewport, so
14
+ * it will happily capture a video that is still loading. The region paints as
15
+ * an empty box on one capture and as the video on the next, which Playwright
16
+ * reports as "Failed to take two consecutive stable screenshots" -- the run
17
+ * burns its whole stability window and fails even though the image it ended
18
+ * up with was correct. When the video is slower still, the captures agree on
19
+ * the empty box, which is worse: it passes stability and bakes a blank
20
+ * rectangle into the baseline if snapshots are being regenerated.
21
+ * 2. **The frame is captured while the video is playing.** An `autoplay muted
22
+ * loop` video renders whatever moment the shutter caught. Chromium only
23
+ * autoplays a muted video while it is on screen, so whether such a baseline
24
+ * is reproducible at all currently rests on nothing more principled than
25
+ * whether an earlier scroll happened to bring the video into the viewport.
26
+ *
27
+ * Both are fixed together: pin playback first, bring the video on screen so it
28
+ * both loads and composites, wait for a decodable frame, and rewind.
29
+ *
30
+ * Note this is deliberately *not* built on `requestVideoFrameCallback()`, which
31
+ * looks like the right signal and is not: it only fires when a frame is
32
+ * presented, and a paused off-screen video never presents one. Measured against
33
+ * a real page it never fired at all -- every call sat there until its timeout,
34
+ * adding the full timeout to each capture and reporting nothing.
35
+ */
36
+
37
+ /**
38
+ * Settle every visible video, in the browser.
39
+ *
40
+ * This runs in the browser via `page.evaluate()`, which serializes the function
41
+ * source, so it must stay self-contained and reference nothing else in this
42
+ * module. It is exported separately from waitForVideos() so it can be tested
43
+ * directly.
44
+ *
45
+ * The order of operations matters and is not the obvious one:
46
+ *
47
+ * - Playback is pinned -- `autoplay` cleared and the element paused -- *before*
48
+ * the video is scrolled into view, because bringing a muted `autoplay` video
49
+ * on screen is exactly what starts it: measured on a real page, scrolling to
50
+ * it first and pausing after left `currentTime` at 0.6s and climbing, while
51
+ * pinning first held it at 0 through the same scroll. Pinning first turns the
52
+ * scroll into a pure repaint.
53
+ * - The video is scrolled into view *before* its readiness is polled, because
54
+ * for a lazily loaded video the scroll is what starts the load. Polling first
55
+ * would sit there watching a `preload="none"` element that has been told not
56
+ * to fetch anything, burn the whole timeout, and only then scroll it on
57
+ * screen -- leaving the capture racing a load that had just begun.
58
+ *
59
+ * Videos are handled in serial: each one is scrolled into view in turn, and
60
+ * doing that concurrently would just mean the last one wins. Every scroll is
61
+ * undone -- the window offset and any scrollable ancestor `scrollIntoView()`
62
+ * moved -- so this composes with callers that care where the page is left;
63
+ * waitForImages(), in particular, does its own careful scroll back to the top.
64
+ *
65
+ * Playback state is not restored here, because the whole point is to leave the
66
+ * videos pinned at their first frame for the capture. It is recorded on each
67
+ * element so restorePlayback() can put it back afterwards.
68
+ *
69
+ * A video that never produces a frame is not an error. A page that legitimately
70
+ * references a missing or undecodable video should still be screenshotted and
71
+ * still have its accessibility checked, and the comparison is what fails. The
72
+ * wait is not silent either: the sources that never became ready are returned
73
+ * so the caller can report them.
74
+ *
75
+ * @param options.timeoutMs How long to wait for each video to become ready.
76
+ * @param options.pollMs How long to sleep between readiness checks.
77
+ * @param options.paintTimeoutMs How long to wait on a frame or a seek that may
78
+ * never arrive.
79
+ * @returns The sources of the videos that never became ready, and whether the
80
+ * page was scrolled at all.
81
+ */
82
+ export async function settleVideos(
83
+ options: {timeoutMs: number, pollMs?: number, paintTimeoutMs?: number}
84
+ ): Promise<{notReady: string[], scrolled: boolean}> {
85
+ const timeoutMs = options.timeoutMs;
86
+ const pollMs = options.pollMs ?? 50;
87
+ const paintTimeoutMs = options.paintTimeoutMs ?? 1000;
88
+ // HTMLMediaElement readyStates: nothing loaded at all, and a frame exists for
89
+ // the current position.
90
+ const HAVE_NOTHING = 0;
91
+ const HAVE_CURRENT_DATA = 2;
92
+
93
+ const isCandidate = (video: HTMLVideoElement) => {
94
+ const rect = video.getBoundingClientRect();
95
+ if (rect.width === 0 || rect.height === 0) {
96
+ return false;
97
+ }
98
+ const style = getComputedStyle(video);
99
+ return style.visibility !== "hidden" && style.display !== "none";
100
+ };
101
+
102
+ // Taking a screenshot should not be what stops a video: a test may well go on
103
+ // to assert that it is playing. Record enough to put it back, on the element
104
+ // itself so a later evaluate() in the same page can find it, and only the
105
+ // first time so repeated captures do not record the pinned state as the
106
+ // original one. The key is spelled out here and in restorePlayback() rather
107
+ // than shared, because a reference to anything in this module would be a
108
+ // ReferenceError once this function is serialized into the browser.
109
+ const remember = (video: HTMLVideoElement) => {
110
+ if (!("__playwrightTestingVideoState" in video)) {
111
+ (video as any)["__playwrightTestingVideoState"] = {
112
+ autoplay: video.autoplay,
113
+ paused: video.paused,
114
+ currentTime: video.currentTime,
115
+ };
116
+ }
117
+ };
118
+
119
+ // A muted autoplay video plays whenever it is on screen, so clear the
120
+ // attribute as well as pausing -- otherwise Chromium restarts it the moment
121
+ // the scroll below makes it visible.
122
+ const pin = (video: HTMLVideoElement) => {
123
+ video.autoplay = false;
124
+ if (!video.paused) {
125
+ video.pause();
126
+ }
127
+ };
128
+
129
+ // Two frames, because the first only gets as far as scheduling the paint that
130
+ // the second then observes. The timeout is not decoration: Chromium stops
131
+ // running animation frames for a page it is not rendering -- one behind
132
+ // another tab of the same context, say -- and page.evaluate() has no timeout
133
+ // of its own, so an unguarded wait here hangs the whole capture.
134
+ const nextPaint = () => new Promise<void>(resolve => {
135
+ const timer = setTimeout(resolve, paintTimeoutMs);
136
+ requestAnimationFrame(() => requestAnimationFrame(() => {
137
+ clearTimeout(timer);
138
+ resolve();
139
+ }));
140
+ });
141
+
142
+ // Assigning `currentTime` only *starts* a seek: the browser still has to
143
+ // decode the frame at the new position and present it, which lands several
144
+ // frames later when the position is not a keyframe. Waiting for `seeked` is
145
+ // what makes the rewind visible in the capture rather than a race.
146
+ const seeked = (video: HTMLVideoElement) => new Promise<void>(resolve => {
147
+ const done = () => {
148
+ clearTimeout(timer);
149
+ video.removeEventListener("seeked", done);
150
+ resolve();
151
+ };
152
+ const timer = setTimeout(done, paintTimeoutMs);
153
+ video.addEventListener("seeked", done);
154
+ });
155
+
156
+ // scrollIntoView() scrolls every scrollable ancestor, not just the window, so
157
+ // record where each of them was. Without this a video inside a modal body, an
158
+ // off-canvas tray or a carousel track leaves that region showing something
159
+ // the baseline never had, and the pixel diff lands nowhere near the video.
160
+ const ancestorScrolls = (video: HTMLVideoElement) => {
161
+ const saved: Array<{element: Element, top: number, left: number}> = [];
162
+ for (let node = video.parentElement; node; node = node.parentElement) {
163
+ if (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth) {
164
+ saved.push({element: node, top: node.scrollTop, left: node.scrollLeft});
165
+ }
166
+ }
167
+ return saved;
168
+ };
169
+
170
+ const sourceOf = (video: HTMLVideoElement) => {
171
+ const src = video.currentSrc || video.src
172
+ || video.querySelector("source")?.getAttribute("src") || "";
173
+ try {
174
+ return new URL(src, location.href).href;
175
+ } catch {
176
+ return src;
177
+ }
178
+ };
179
+
180
+ const videos = Array.from(document.querySelectorAll("video")).filter(isCandidate);
181
+ if (videos.length === 0) {
182
+ return {notReady: [], scrolled: false};
183
+ }
184
+
185
+ const scrollX = window.scrollX;
186
+ const scrollY = window.scrollY;
187
+ const notReady: string[] = [];
188
+
189
+ const restoreWindowScroll = async () => {
190
+ window.scroll({top: scrollY, left: scrollX, behavior: "instant"});
191
+ await nextPaint();
192
+ // window.scroll is async and can be dropped outright -- when application UI
193
+ // has locked the body, for example -- so confirm it landed and ask once more.
194
+ if (window.scrollX !== scrollX || window.scrollY !== scrollY) {
195
+ window.scroll({top: scrollY, left: scrollX, behavior: "instant"});
196
+ await nextPaint();
197
+ }
198
+ };
199
+
200
+ try {
201
+ for (const video of videos) {
202
+ remember(video);
203
+ pin(video);
204
+
205
+ // Bringing the video on screen is both what starts a lazy load and what
206
+ // gives Chromium a reason to composite the first frame. Without it a
207
+ // full-page capture can raster the region before the frame is ready to
208
+ // paint.
209
+ const ancestors = ancestorScrolls(video);
210
+ try {
211
+ video.scrollIntoView({block: "center", inline: "nearest", behavior: "instant"});
212
+ await nextPaint();
213
+
214
+ // `preload="none"` means the browser fetches nothing until playback is
215
+ // asked for, and nothing here ever asks. Scrolling alone does not override
216
+ // it, so say what is wanted and let the poll below wait for it.
217
+ if (video.readyState === HAVE_NOTHING) {
218
+ video.preload = "auto";
219
+ try {
220
+ video.load();
221
+ } catch {
222
+ // Nothing loadable; reported as not ready below.
223
+ }
224
+ }
225
+
226
+ // Each video gets its own deadline. A single deadline for the call would
227
+ // make `timeoutMs` a budget the first slow video can spend in full, so the
228
+ // second one is reported as having no frame without ever being waited for.
229
+ const deadline = Date.now() + timeoutMs;
230
+ const readyBeforeWait = video.readyState >= HAVE_CURRENT_DATA;
231
+ while (video.readyState < HAVE_CURRENT_DATA && Date.now() < deadline) {
232
+ await new Promise(resolve => setTimeout(resolve, pollMs));
233
+ }
234
+ if (video.readyState < HAVE_CURRENT_DATA) {
235
+ // Reported below, but still settled: a video that becomes ready between
236
+ // here and the capture must not start playing off the back of `autoplay`.
237
+ notReady.push(sourceOf(video));
238
+ } else if (!readyBeforeWait) {
239
+ // The frame only arrived during the wait, so spend a paint compositing it
240
+ // while the video is still on screen. A video that was already ready was
241
+ // composited by the paint after the scroll.
242
+ await nextPaint();
243
+ }
244
+
245
+ // Belt and braces: `pin()` above should have kept it still through the
246
+ // scroll, and rewinding costs nothing if it did. `currentTime` throws on a
247
+ // source that is not seekable, such as a live stream.
248
+ pin(video);
249
+ try {
250
+ if (video.currentTime !== 0) {
251
+ video.currentTime = 0;
252
+ await seeked(video);
253
+ await nextPaint();
254
+ }
255
+ } catch {
256
+ // Not seekable; whatever frame it is showing is the best available.
257
+ }
258
+ } finally {
259
+ for (const {element, top, left} of ancestors) {
260
+ element.scrollTop = top;
261
+ element.scrollLeft = left;
262
+ }
263
+ }
264
+ }
265
+ } finally {
266
+ await restoreWindowScroll();
267
+ }
268
+
269
+ return {notReady, scrolled: true};
270
+ }
271
+
272
+ /**
273
+ * Put back the playback state settleVideos() recorded, in the browser.
274
+ *
275
+ * Like settleVideos() this is serialized into the page by `page.evaluate()`, so
276
+ * it must stay self-contained, and it is exported separately from
277
+ * restoreVideoPlayback() so it can be tested directly.
278
+ *
279
+ * Videos that were not settled are left alone, and the recorded state is
280
+ * cleared as it is applied so a second call is a no-op rather than a rewind of
281
+ * whatever the test did in between.
282
+ */
283
+ export function restorePlayback(): void {
284
+ for (const video of Array.from(document.querySelectorAll("video"))) {
285
+ const saved = (video as any)["__playwrightTestingVideoState"];
286
+ if (!saved) {
287
+ continue;
288
+ }
289
+ delete (video as any)["__playwrightTestingVideoState"];
290
+ video.autoplay = saved.autoplay;
291
+ try {
292
+ if (video.currentTime !== saved.currentTime) {
293
+ video.currentTime = saved.currentTime;
294
+ }
295
+ } catch {
296
+ // Not seekable; it was not seekable on the way in either.
297
+ }
298
+ if (!saved.paused && video.paused) {
299
+ const played = video.play();
300
+ if (played && typeof played.catch === "function") {
301
+ // Autoplay policy can refuse this. Restoring playback is best effort:
302
+ // the capture is already taken, and throwing here would fail a test for
303
+ // something it never asked for.
304
+ played.catch(() => {});
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Wait for every visible video to be ready, composited, and paused at its
312
+ * first frame.
313
+ *
314
+ * See settleVideos() for what "ready" means, why playback is pinned before the
315
+ * video is scrolled into view, why the readiness poll comes after that scroll,
316
+ * and why this is not built on `requestVideoFrameCallback()`.
317
+ *
318
+ * This runs in every frame, not just the main one: media embeds commonly render
319
+ * inside iframes, and a
320
+ * `<video>` in one of those is no less able to break a comparison.
321
+ *
322
+ * @param page
323
+ * @param options A timeout or settling options.
324
+ * @returns The sources of the videos that never became ready. Empty when they
325
+ * all did.
326
+ */
327
+ export interface WaitForVideosOptions {
328
+ /** How long to wait for each video to produce a frame. */
329
+ timeoutMs?: number;
330
+ /** Optional adapter hook run after video scrolling has been restored. */
331
+ afterScroll?: (page: Page) => Promise<void>;
332
+ }
333
+
334
+ export async function waitForVideos(
335
+ page: Page,
336
+ options: number | WaitForVideosOptions = {},
337
+ ): Promise<string[]> {
338
+ const timeoutMs = typeof options === "number" ? options : options.timeoutMs ?? 5000;
339
+ const afterScroll = typeof options === "number" ? undefined : options.afterScroll;
340
+ const notReady: string[] = [];
341
+ let scrolled = false;
342
+
343
+ for (const frame of page.frames()) {
344
+ try {
345
+ const result = await frame.evaluate(settleVideos, {timeoutMs});
346
+ notReady.push(...result.notReady);
347
+ scrolled = scrolled || result.scrolled;
348
+ } catch {
349
+ // A frame that navigated or detached while we were working through the
350
+ // list has nothing left to settle.
351
+ }
352
+ }
353
+
354
+ if (scrolled && afterScroll) {
355
+ try {
356
+ await afterScroll(page);
357
+ } catch (error) {
358
+ await restoreVideoPlayback(page);
359
+ throw error;
360
+ }
361
+ }
362
+
363
+ if (notReady.length > 0) {
364
+ console.warn(
365
+ `waitForVideos: ${notReady.length} video(s) had no frame available within ${timeoutMs}ms and may be captured as an empty box: ${notReady.join(', ')}`
366
+ );
367
+ }
368
+ return notReady;
369
+ }
370
+
371
+ /**
372
+ * Restore the playback state waitForVideos() pinned.
373
+ *
374
+ * Settling a video means pausing it, clearing `autoplay` and rewinding it, and
375
+ * a test that takes a screenshot has not asked for any of that to outlive the
376
+ * capture -- it may well go on to assert that the video is playing. Call this
377
+ * once the screenshot is taken to hand the page back as it was found.
378
+ *
379
+ * @param page
380
+ */
381
+ export async function restoreVideoPlayback(page: Page): Promise<void> {
382
+ for (const frame of page.frames()) {
383
+ try {
384
+ await frame.evaluate(restorePlayback);
385
+ } catch {
386
+ // Same as waitForVideos(): a frame that has gone away needs nothing.
387
+ }
388
+ }
389
+ }