@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,381 @@
1
+ import {
2
+ test,
3
+ type BrowserContext,
4
+ type Page,
5
+ type TestInfo,
6
+ type WebError,
7
+ } from '@playwright/test';
8
+
9
+ import {
10
+ takeAccessibleScreenshot,
11
+ type ScreenshotOptions,
12
+ } from "./accessible-screenshot.js";
13
+ import type {AccessibilityBaseline} from './accessibility-baseline.js'
14
+ import {forcePseudoState, type ForcedPseudoState} from './pseudo-state.js'
15
+ import type {InteractionState} from './interaction-states.js'
16
+
17
+ export interface VisualDiffExecutionContext {
18
+ page: Page
19
+ testInfo: TestInfo
20
+ testCase: VisualDiff
21
+ group: VisualDiffGroup
22
+ config?: VisualDiffUrlConfig
23
+ }
24
+
25
+ export interface VisualDiffPreset {
26
+ /**
27
+ * Neutral adapter seam for screenshot defaults such as exclusions, browser
28
+ * thresholds, or post-scroll settling hooks.
29
+ */
30
+ screenshotOptions?: ScreenshotOptions | (
31
+ (context: VisualDiffExecutionContext) => ScreenshotOptions | Promise<ScreenshotOptions>
32
+ )
33
+ }
34
+
35
+ export function defineVisualDiffConfig(cases: VisualDiffUrlConfig, preset?: VisualDiffPreset) {
36
+ return new VisualDiffTestCases(cases, preset);
37
+ }
38
+
39
+ export function defaultTestFunction(
40
+ testCase: VisualDiff,
41
+ group: VisualDiffGroup,
42
+ config?: VisualDiffUrlConfig,
43
+ preset?: VisualDiffPreset,
44
+ ) {
45
+ return async (
46
+ {page, context}: {page: Page, context: BrowserContext},
47
+ testInfo: TestInfo,
48
+ ) => {
49
+ const MockClass = testCase.mockClass ?? group.mockClass ?? config?.mockClass
50
+ if (MockClass) {
51
+ const mock = new MockClass();
52
+ await mock.mock(page);
53
+ }
54
+ // Log any errors to the Playwright console too.
55
+
56
+ context.on('weberror', (webError: WebError) => console.log(webError.error()));
57
+ testInfo.annotations.push({
58
+ type: 'Description',
59
+ description: testCase.description,
60
+ })
61
+
62
+ const representativeUrl = testCase.representativeUrl
63
+ ?? group.representativeUrl
64
+ ?? config?.representativeUrl;
65
+
66
+ if (representativeUrl) {
67
+ testInfo.annotations.push({
68
+ type: 'Representative URL',
69
+ description: representativeUrl,
70
+ });
71
+ }
72
+
73
+ const path = `${config?.pathPrefix ?? ''}${group.pathPrefix ?? ''}${testCase.path}`;
74
+
75
+ await page.goto(path);
76
+
77
+ const configuredScreenshotOptions = typeof preset?.screenshotOptions === 'function'
78
+ ? await preset.screenshotOptions({page, testInfo, testCase, group, config})
79
+ : preset?.screenshotOptions ?? {}
80
+
81
+ // Merge masks from all three levels: config, group, and testCase.
82
+ const maskSelectors: string[] = [
83
+ ...(config?.mask ?? []),
84
+ ...(group.mask ?? []),
85
+ ...(testCase.mask ?? []),
86
+ ];
87
+ const maskLocators = [
88
+ ...(configuredScreenshotOptions.mask ?? []),
89
+ ...maskSelectors.map(selector => page.locator(selector)),
90
+ ];
91
+
92
+ // Most-specific-wins for maskColor: testCase > group > config.
93
+ const maskColor = testCase.maskColor
94
+ ?? group.maskColor
95
+ ?? config?.maskColor
96
+ ?? configuredScreenshotOptions.maskColor;
97
+
98
+ const screenshotOptions: ScreenshotOptions = {
99
+ ...configuredScreenshotOptions,
100
+ fullPage: true,
101
+ };
102
+ if (maskLocators.length > 0) {
103
+ screenshotOptions.mask = maskLocators;
104
+ }
105
+ if (maskColor) {
106
+ screenshotOptions.maskColor = maskColor;
107
+ }
108
+ const a11yBaseline = testCase.a11yBaseline
109
+ ?? group.a11yBaseline
110
+ ?? config?.a11yBaseline
111
+ ?? configuredScreenshotOptions.accessibility?.baseline
112
+ if (a11yBaseline || configuredScreenshotOptions.accessibility) {
113
+ screenshotOptions.accessibility = {
114
+ ...configuredScreenshotOptions.accessibility,
115
+ ...(a11yBaseline ? {baseline: a11yBaseline} : {}),
116
+ }
117
+ }
118
+
119
+ const interactionStates: VisualDiffInteractionState[] = [
120
+ ...(config?.interactionStates ?? []),
121
+ ...(group.interactionStates ?? []),
122
+ ...(testCase.interactionStates ?? []),
123
+ ];
124
+ if (interactionStates.length > 0 || configuredScreenshotOptions.interactionStates) {
125
+ screenshotOptions.interactionStates = [
126
+ ...(configuredScreenshotOptions.interactionStates ?? []),
127
+ ...interactionStates.map(({selector, states}) => ({
128
+ locator: page.locator(selector),
129
+ states,
130
+ })),
131
+ ];
132
+ }
133
+
134
+ // Force declared pseudo-states after navigation and keep them active
135
+ // through both the screenshot and the accessibility scan. These synthetic
136
+ // states are unaffected by takeAccessibleScreenshot() clearing incidental
137
+ // pointer hover and DOM focus.
138
+ const pseudoStates: ForcedPseudoState[] = [
139
+ ...(config?.pseudoStates ?? []),
140
+ ...(group.pseudoStates ?? []),
141
+ ...(testCase.pseudoStates ?? []),
142
+ ];
143
+ const clearPseudoStates: Array<() => Promise<void>> = [];
144
+ let operationFailed = false
145
+ let operationError: unknown
146
+ try {
147
+ for (const pseudoState of pseudoStates) {
148
+ clearPseudoStates.push(await forcePseudoState(
149
+ page,
150
+ pseudoState.selector,
151
+ pseudoState.pseudoClasses,
152
+ ));
153
+ }
154
+ await takeAccessibleScreenshot(page, testInfo, screenshotOptions);
155
+ } catch (error) {
156
+ operationFailed = true
157
+ operationError = error
158
+ }
159
+
160
+ const cleanupErrors: unknown[] = []
161
+ for (const clearPseudoState of clearPseudoStates.reverse()) {
162
+ try {
163
+ await clearPseudoState();
164
+ } catch (error) {
165
+ cleanupErrors.push(error)
166
+ }
167
+ }
168
+
169
+ if (operationFailed) {
170
+ if (cleanupErrors.length > 0) {
171
+ throw new AggregateError([operationError, ...cleanupErrors], 'Visual diff capture and pseudo-state cleanup failed.')
172
+ }
173
+ throw operationError
174
+ }
175
+ if (cleanupErrors.length === 1) {
176
+ throw cleanupErrors[0]
177
+ }
178
+ if (cleanupErrors.length > 1) {
179
+ throw new AggregateError(cleanupErrors, 'Multiple pseudo-state cleanup operations failed.')
180
+ }
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Execute a set of visual diffs against groups of test cases.
186
+ */
187
+ export class VisualDiffTestCases {
188
+ /**
189
+ * The configuration object containing all visual diff test cases.
190
+ * @private
191
+ */
192
+ private config: VisualDiffUrlConfig;
193
+ private preset?: VisualDiffPreset;
194
+
195
+ /**
196
+ * Construct a new set of VisualDiffTestCases
197
+ *
198
+ * @param config The config that has been imported via "import ..."
199
+ */
200
+ constructor(config: VisualDiffUrlConfig, preset?: VisualDiffPreset) {
201
+ this.config = config;
202
+ this.preset = preset;
203
+ }
204
+
205
+ /**
206
+ * Describe, execute, and skip test cases
207
+ *
208
+ * @param overriddenTestFunction An optional custom test function. Note: when
209
+ * using a custom test function, automatic mask, interaction-state, and
210
+ * pseudo-state handling is bypassed. You must apply them yourself.
211
+ */
212
+ public describe(overriddenTestFunction?: (testCase: VisualDiff, group: VisualDiffGroup) => Function | void) {
213
+ // Handle skipping of test cases, either based on a simple boolean or a callback.
214
+ function shouldSkip(testCase: BaseVisualDiff): boolean {
215
+ return testCase.skip !== undefined
216
+ && (testCase.skip.callback === undefined || testCase.skip.callback(testCase))
217
+ }
218
+
219
+ function registerSkip(testCase: BaseVisualDiff): void {
220
+ const skip = testCase.skip!
221
+ test.skip(`${testCase.name}: ${skip.reason} <${skip.willBeFixedIn}>`, async () => {});
222
+ }
223
+
224
+ this.config.groups.forEach((group: VisualDiffGroup) => {
225
+ // Allow skipping of entire groups of tests.
226
+ if (shouldSkip(group)) {
227
+ registerSkip(group);
228
+ return;
229
+ }
230
+
231
+ // Actually describe the group.
232
+ test.describe(group.name, () => {
233
+ group.testCases.forEach((testCase => {
234
+ // Allow skipping of individual test cases.
235
+ if (shouldSkip(testCase)) {
236
+ registerSkip(testCase);
237
+ return;
238
+ }
239
+
240
+ // Define a default function for test cases.
241
+ let testFunction: any;
242
+ if (typeof overriddenTestFunction != 'function') {
243
+ testFunction = defaultTestFunction(testCase, group, this.config, this.preset)
244
+ } else {
245
+ testFunction = overriddenTestFunction(testCase, group);
246
+ }
247
+
248
+ test(`${testCase.name}: ${testCase.path}`, testFunction);
249
+ }));
250
+ });
251
+ });
252
+
253
+ }
254
+ }
255
+
256
+
257
+ /**
258
+ * The top level configuration object.
259
+ */
260
+ export type VisualDiffUrlConfig = {
261
+ // The name of the visual diff configuration, such as "Example Site Visual Diffs".
262
+ name: string,
263
+ // A further description of the configuration.
264
+ description?: string,
265
+ // An array of groups of visual diffs. Good groups include by content type, site
266
+ // section, or common feature.
267
+ groups: VisualDiffGroup[],
268
+ /** Prefix prepended before group and case paths. */
269
+ pathPrefix?: string,
270
+ /** Fallback representative URL for every case. */
271
+ representativeUrl?: string,
272
+ /** Fallback network/page mock for every case. */
273
+ mockClass?: MockableConstructor,
274
+ /**
275
+ * CSS selectors for elements to mask globally across all test cases.
276
+ * Useful for dynamic content like copyright years that change over time.
277
+ * These are merged with any group-level and test-case-level masks.
278
+ */
279
+ mask?: string[],
280
+ /**
281
+ * The color of the overlay box for masked elements, in CSS color format.
282
+ * Can be overridden at the group or test-case level.
283
+ */
284
+ maskColor?: string,
285
+ /**
286
+ * Accessibility baseline for managing known violations.
287
+ * When provided, violations matching the baseline are suppressed and
288
+ * toMatchSnapshot() is skipped in favour of baseline-driven assertions.
289
+ */
290
+ a11yBaseline?: AccessibilityBaseline,
291
+ /**
292
+ * Cross-browser hover and focus states to apply for every test case. These
293
+ * are merged with group-level and test-case-level interaction states.
294
+ */
295
+ interactionStates?: VisualDiffInteractionState[],
296
+ /**
297
+ * Chromium-only pseudo-states to force for every test case. These are merged
298
+ * with group-level and test-case-level pseudo-states.
299
+ */
300
+ pseudoStates?: ForcedPseudoState[],
301
+ }
302
+
303
+ /**
304
+ * A group of Visual Diff test cases.
305
+ */
306
+ export type VisualDiffGroup = BaseVisualDiff & {
307
+ pathPrefix?: string,
308
+ // An array of test cases.
309
+ testCases: VisualDiff[],
310
+ }
311
+
312
+ export interface MockableConstructor {
313
+ new (): Mockable;
314
+ }
315
+ export interface Mockable {
316
+ mock(page: Page): Promise<void>
317
+ }
318
+
319
+ /** A selector and real cross-browser interaction states to apply to it. */
320
+ export interface VisualDiffInteractionState {
321
+ selector: string
322
+ states: InteractionState[]
323
+ }
324
+
325
+ /**
326
+ * An individual test case.
327
+ */
328
+ export type VisualDiff = BaseVisualDiff & {
329
+ // A relative path for the test case.
330
+ path: string,
331
+ }
332
+
333
+ export type BaseVisualDiff = {
334
+ // The name of the test case, such as "Alert (White Background)".
335
+ name: string,
336
+ // An optional description of the test case.
337
+ description?: string,
338
+ // An optional representative URL for this test.
339
+ representativeUrl?: string,
340
+ // Allow skipping of this test.
341
+ skip?: SkipTest,
342
+ mockClass?: MockableConstructor,
343
+ /** Accessibility baseline overriding less-specific configuration. */
344
+ a11yBaseline?: AccessibilityBaseline,
345
+ /**
346
+ * CSS selectors for elements to mask when taking screenshots.
347
+ * These are merged with any config-level and (for test cases) group-level masks.
348
+ */
349
+ mask?: string[],
350
+ /**
351
+ * The color of the overlay box for masked elements, in CSS color format.
352
+ * Overrides the mask color set at less-specific levels (config or group).
353
+ */
354
+ maskColor?: string,
355
+ /**
356
+ * Cross-browser hover and focus states to apply while capturing the
357
+ * screenshot and running its accessibility scan.
358
+ */
359
+ interactionStates?: VisualDiffInteractionState[],
360
+ /**
361
+ * Chromium-only pseudo-states to force while capturing the screenshot and
362
+ * running its accessibility scan.
363
+ */
364
+ pseudoStates?: ForcedPseudoState[],
365
+ }
366
+
367
+ /**
368
+ * A declaration that a test should be skipped.
369
+ *
370
+ * Nothing prevents calling test.skip() in a custom test function, but this
371
+ * type ensures that every skip has both a reason and a link to a ticket.
372
+ */
373
+ export type SkipTest = {
374
+ // The reason why this test should be skipped, such as "The News listing has undefined ordering".
375
+ reason: string,
376
+ // A link to the ticket or issue that will allow this test to be re-enabled.
377
+ willBeFixedIn: string,
378
+ // An optional callback to control if this test is skipped. For example, a skip
379
+ // callback could check the VisualDiff.path property.
380
+ callback?: (testCase: BaseVisualDiff) => boolean
381
+ }