@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/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
+ };