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