@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,809 @@
1
+ import AxeBuilder from '@axe-core/playwright';
2
+ import {expect, type Locator, type Page, type TestInfo} from "@playwright/test";
3
+ import {waitForAllImages, type WaitForImagesOptions} from "./images.js";
4
+ import {waitForFrames} from "./frames.js"
5
+ import {waitForFonts} from "./fonts.js";
6
+ import {restoreVideoPlayback, waitForVideos, type WaitForVideosOptions} from "./videos.js";
7
+ import {blurActiveElement} from "./focus.js";
8
+ import {clearHover} from "./hover.js";
9
+ import {
10
+ applyInteractionStates,
11
+ validateInteractionStates,
12
+ type ScreenshotInteractionState,
13
+ } from './interaction-states.js'
14
+ import axe from 'axe-core';
15
+ import {
16
+ type AccessibilityBaseline,
17
+ type AccessibilityBaselineEntry,
18
+ validateAccessibilityBaseline,
19
+ } from './accessibility-baseline.js'
20
+ import {
21
+ baselineFilePath,
22
+ buildSeed,
23
+ nextAccessibilityScanCount,
24
+ readBaselineFile,
25
+ ScanKind,
26
+ snapshotExists,
27
+ writeBaselineFile,
28
+ } from './accessibility-baseline-file.js'
29
+
30
+ let a11yActionHintShown = false;
31
+
32
+ export type {InteractionState, ScreenshotInteractionState} from './interaction-states.js'
33
+
34
+ export interface ScreenshotStabilizationOptions {
35
+ images?: WaitForImagesOptions
36
+ videos?: WaitForVideosOptions
37
+ }
38
+
39
+ export interface ScreenshotOptions {
40
+ /**
41
+ * When set to `"disabled"`, stops CSS animations, CSS transitions and Web Animations. Animations get different
42
+ * treatment depending on their duration:
43
+ * - finite animations are fast-forwarded to completion, so they'll fire `transitionend` event.
44
+ * - infinite animations are canceled to initial state, and then played over after the screenshot.
45
+ *
46
+ * Defaults to `"disabled"` that disables animations.
47
+ */
48
+ animations?: "disabled" | "allow";
49
+
50
+ /**
51
+ * When set to `"hide"`, screenshot will hide text caret. When set to `"initial"`, text caret behavior will not be
52
+ * changed. Defaults to `"hide"`.
53
+ */
54
+ caret?: "hide" | "initial";
55
+
56
+ /**
57
+ * When `true` (the default), blurs the active element before capturing so a
58
+ * stray focus ring left over from earlier test interactions does not appear in
59
+ * only some runs (an invisible-to-a-human diff that still trips the pixel
60
+ * budget). Set to `false` when the screenshot intentionally captures a focused
61
+ * state.
62
+ */
63
+ blur?: boolean;
64
+
65
+ /**
66
+ * When `true` (the default), moves the pointer onto a temporary transparent
67
+ * shield before capturing so stale pointer activity does not leave an
68
+ * unrelated element in its hover state. Set to `false` when the screenshot
69
+ * intentionally captures a hovered state.
70
+ */
71
+ clearHover?: boolean;
72
+
73
+ /**
74
+ * Real hover and focus states to apply after the page has settled. These are
75
+ * supported in Chromium, Firefox, and WebKit and remain active through both
76
+ * the screenshot and accessibility scan. At most one locator may receive
77
+ * each state, matching what a real pointer and keyboard can do.
78
+ */
79
+ interactionStates?: ScreenshotInteractionState[];
80
+
81
+ /** Configuration for image and video settling. */
82
+ stabilization?: ScreenshotStabilizationOptions;
83
+
84
+ /**
85
+ * An object specifying the page area to capture, in CSS pixels.
86
+ */
87
+ clip?: {
88
+ x: number;
89
+ y: number;
90
+ width: number;
91
+ height: number;
92
+ };
93
+
94
+ /**
95
+ * When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Defaults to
96
+ * `false`.
97
+ */
98
+ fullPage?: boolean;
99
+
100
+ /**
101
+ * Derive a page screenshot clip from this locator after readiness waits and
102
+ * scrolling. Round x, y, width and height to integer CSS pixels without
103
+ * modifying rendering. Mutually exclusive with clip and a locator target.
104
+ * Use fullPage for targets larger than the viewport.
105
+ */
106
+ clipLocator?: Locator;
107
+
108
+ /**
109
+ * Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink
110
+ * box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box.
111
+ */
112
+ mask?: Array<Locator>;
113
+
114
+ /**
115
+ * Specify the color of the overlay box for masked elements, in
116
+ * [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`.
117
+ */
118
+ maskColor?: string;
119
+
120
+ /**
121
+ * An acceptable ratio of pixels that are different to the total amount of pixels, between `0` and `1`. Default is
122
+ * configurable with `TestConfig.expect`. Unset by default.
123
+ */
124
+ maxDiffPixelRatio?: number;
125
+
126
+ /**
127
+ * An acceptable amount of pixels that could be different. Default is configurable with `TestConfig.expect`. Unset by
128
+ * default.
129
+ */
130
+ maxDiffPixels?: number;
131
+
132
+ /**
133
+ * Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images.
134
+ * Defaults to `false`.
135
+ */
136
+ omitBackground?: boolean;
137
+
138
+ /**
139
+ * When set to `"css"`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this
140
+ * will keep screenshots small. Using `"device"` option will produce a single pixel per each device pixel, so
141
+ * screenshots of high-dpi devices will be twice as large or even larger.
142
+ *
143
+ * Defaults to `"css"`.
144
+ */
145
+ scale?: "css" | "device";
146
+
147
+ /**
148
+ * A stylesheet, or list of stylesheets, to apply while taking the screenshot.
149
+ * The styles pierce Shadow DOM and apply to inner frames.
150
+ */
151
+ stylePath?: string | string[];
152
+
153
+ /**
154
+ * An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the
155
+ * same pixel in compared images, between zero (strict) and one (lax), default is configurable with
156
+ * `TestConfig.expect`. Defaults to `0.2`.
157
+ */
158
+ threshold?: number;
159
+
160
+ /**
161
+ * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
162
+ */
163
+ timeout?: number;
164
+
165
+ /**
166
+ * Accessibility options passed through to checkAccessibility().
167
+ */
168
+ accessibility?: AccessibilityOptions;
169
+ }
170
+
171
+ export interface AccessibilityOptions {
172
+ /** axe tags for WCAG scan. Default: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] */
173
+ wcagTags?: string[]
174
+
175
+ /** Additional CSS selectors to exclude from both scans. */
176
+ exclude?: string[]
177
+
178
+ /** CSS selectors to exclude only from the best-practice scan. */
179
+ bestPracticeExclude?: string[]
180
+
181
+ /** CSS selectors to exclude only from the WCAG scan. */
182
+ wcagExclude?: string[]
183
+
184
+ /**
185
+ * Best-practice scan mode.
186
+ * - 'soft': uses expect.soft() (default, current behaviour)
187
+ * - 'hard': uses expect() — test fails immediately on violations
188
+ * - 'off': skips best-practice scan entirely
189
+ */
190
+ bestPracticeMode?: 'soft' | 'hard' | 'off'
191
+
192
+ /** Additional axe rules to enable/disable. */
193
+ rules?: Record<string, { enabled: boolean }>
194
+
195
+ /** Baseline of known violations. When provided, violations matching the baseline are suppressed and toMatchSnapshot() is skipped. */
196
+ baseline?: AccessibilityBaseline
197
+
198
+ /**
199
+ * When true, captures a full-page screenshot with violating elements
200
+ * highlighted (red outline) and attaches it to the test report.
201
+ * Default: true.
202
+ */
203
+ screenshotViolations?: boolean
204
+ }
205
+
206
+ /**
207
+ * Run accessibility checks on the current page using axe-core.
208
+ *
209
+ * Runs a best-practice scan (unless bestPracticeMode is 'off') and a WCAG scan,
210
+ * attaching JSON results and asserting on violations via snapshots.
211
+ *
212
+ * @param page The Page fixture from the test.
213
+ * @param testInfo The testInfo object from the test.
214
+ * @param options Accessibility options to customise the scan.
215
+ */
216
+ export async function checkAccessibility(page: Page, testInfo: TestInfo, options?: AccessibilityOptions) {
217
+ const {
218
+ wcagTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
219
+ exclude = [],
220
+ bestPracticeExclude = [],
221
+ wcagExclude = [],
222
+ bestPracticeMode = 'soft',
223
+ rules,
224
+ baseline,
225
+ screenshotViolations = true,
226
+ } = options ?? {}
227
+
228
+ if (process.env.CI && !a11yActionHintShown) {
229
+ console.log('Tip: Use @lullabot/playwright-testing/github to surface accessibility violations in pull requests.')
230
+ a11yActionHintShown = true
231
+ }
232
+
233
+ // Add @a11y annotation (deduplicated).
234
+ if (!testInfo.annotations.some(a => a.type === '@a11y')) {
235
+ testInfo.annotations.push({ type: '@a11y' })
236
+ }
237
+
238
+ if (bestPracticeMode !== 'off') {
239
+ const bpResults = await runBestPracticeScan(page, testInfo, {
240
+ exclude: [...exclude, ...bestPracticeExclude],
241
+ rules,
242
+ })
243
+ // Best-practice always uses expect.soft() so the WCAG scan below runs
244
+ // even when best-practice violations exist. `bestPracticeMode === 'hard'`
245
+ // is preserved as a marker but does not change soft-vs-hard here.
246
+ await dispatchAssertion({
247
+ testInfo,
248
+ results: bpResults,
249
+ scan: 'best-practice',
250
+ expectFn: expect.soft,
251
+ scanLabel: 'Best-practice scan',
252
+ }, baseline)
253
+ }
254
+
255
+ const wcagScanResults = await runWcagScan(page, testInfo, {
256
+ wcagTags,
257
+ exclude: [...exclude, ...wcagExclude],
258
+ rules,
259
+ })
260
+
261
+ if (screenshotViolations && wcagScanResults.violations.length > 0) {
262
+ await screenshotViolatingElements(page, testInfo, wcagScanResults)
263
+ }
264
+
265
+ await dispatchAssertion({
266
+ testInfo,
267
+ results: wcagScanResults,
268
+ scan: 'wcag',
269
+ expectFn: expect,
270
+ scanLabel: 'WCAG scan',
271
+ }, baseline)
272
+ }
273
+
274
+ interface ScanContext {
275
+ testInfo: TestInfo
276
+ results: axe.AxeResults
277
+ scan: ScanKind
278
+ expectFn: typeof expect | typeof expect.soft
279
+ scanLabel: string
280
+ }
281
+
282
+ /**
283
+ * Dispatch the assertion for one scan to the correct mode:
284
+ *
285
+ * 1. Explicit in-code `baseline` option -> baseline mode (existing behaviour).
286
+ * 2. A snapshot file already exists on disk for this test -> snapshot mode
287
+ * (existing behaviour, preserves all previously committed snapshots).
288
+ * 3. Playwright is in explicit snapshot-update mode (`all`/`changed`, set via
289
+ * `--update-snapshots`) -> snapshot mode (Playwright will create the
290
+ * snapshot). `'missing'` is Playwright's default config value and does NOT
291
+ * indicate explicit user intent, so it falls through to baseline mode.
292
+ * 4. Otherwise -> on-disk baseline mode. If the JSON file exists, load and
293
+ * match against it. If it does not, seed it. On CI seeding fails the
294
+ * test (matching Playwright's missing-snapshot behaviour); locally,
295
+ * seeding passes so the first run is green.
296
+ */
297
+ async function dispatchAssertion(ctx: ScanContext, inCodeBaseline?: AccessibilityBaseline): Promise<void> {
298
+ if (inCodeBaseline) {
299
+ return assertBaseline(ctx, inCodeBaseline)
300
+ }
301
+
302
+ if (await snapshotExists(ctx.testInfo)) {
303
+ return assertSnapshot(ctx)
304
+ }
305
+
306
+ const update = ctx.testInfo.config?.updateSnapshots
307
+ if (update === 'all' || update === 'changed') {
308
+ return assertSnapshot(ctx)
309
+ }
310
+
311
+ const callCount = nextAccessibilityScanCount(ctx.testInfo, ctx.scan)
312
+ const filePath = baselineFilePath(ctx.testInfo, ctx.scan, callCount)
313
+
314
+ const existing = await readBaselineFile(filePath)
315
+ if (existing) {
316
+ return assertBaseline(ctx, existing.violations)
317
+ }
318
+
319
+ // Seed and either pass (local) or fail (CI).
320
+ const normalized = extractNormalizedViolations(ctx.results)
321
+ const seedViolations: AccessibilityBaselineEntry[] = normalized.map(v => ({
322
+ rule: v.rule,
323
+ targets: v.targets,
324
+ reason: 'TODO',
325
+ willBeFixedIn: 'TODO',
326
+ }))
327
+ const seed = buildSeed(seedViolations)
328
+ await writeBaselineFile(filePath, seed)
329
+ await ctx.testInfo.attach(`a11y-${ctx.scan}-baseline-seed`, {
330
+ path: filePath,
331
+ contentType: 'application/json',
332
+ })
333
+
334
+ if (process.env.CI) {
335
+ const message = `${ctx.scanLabel}: a11y baseline file was missing for this test. Seeded to ${filePath} — download the attached file from CI artifacts (or re-run locally) and commit it before merging.`
336
+ ctx.expectFn(null, message).toBe('a11y baseline file present')
337
+ return
338
+ }
339
+
340
+ ctx.testInfo.annotations.push({
341
+ type: 'Accessibility',
342
+ description: seed.violations.length === 0
343
+ ? `${ctx.scanLabel}: a11y baseline seeded at ${filePath} (no violations).`
344
+ : `${ctx.scanLabel}: a11y baseline seeded at ${filePath} with ${seed.violations.length} entries — fill in reason/willBeFixedIn before committing.`,
345
+ })
346
+ // A local seed is deliberately permissive and contains TODO metadata. The
347
+ // next run validates the committed file before treating entries as waivers.
348
+ }
349
+
350
+ /**
351
+ * Run the best-practice axe scan, attach results, and return them for
352
+ * dispatch. The assertion (snapshot vs baseline) is decided by the
353
+ * dispatcher based on what's already on disk and the `bestPracticeMode`
354
+ * option drives soft- vs hard-failure behaviour.
355
+ */
356
+ async function runBestPracticeScan(
357
+ page: Page,
358
+ testInfo: TestInfo,
359
+ opts: {
360
+ exclude: string[]
361
+ rules?: Record<string, { enabled: boolean }>
362
+ },
363
+ ): Promise<axe.AxeResults> {
364
+ const builder = new AxeBuilder({ page })
365
+ .withTags(['best-practice'])
366
+
367
+ for (const selector of opts.exclude) {
368
+ builder.exclude(selector)
369
+ }
370
+
371
+ if (opts.rules) {
372
+ builder.options({ rules: opts.rules })
373
+ }
374
+
375
+ const results = await builder.analyze()
376
+
377
+ await testInfo.attach('a11y-best-practice-scan-results', {
378
+ body: JSON.stringify(results, null, 2),
379
+ contentType: 'application/json'
380
+ })
381
+
382
+ testInfo.annotations.push({
383
+ type: 'Accessibility',
384
+ description: `Best-practice scan: ${results.violations.length} violations (${results.passes.length} rules passed)`
385
+ })
386
+
387
+ return results
388
+ }
389
+
390
+ /**
391
+ * Run the WCAG axe scan, attach results, and return them for assertion.
392
+ */
393
+ async function runWcagScan(
394
+ page: Page,
395
+ testInfo: TestInfo,
396
+ opts: {
397
+ wcagTags: string[]
398
+ exclude: string[]
399
+ rules?: Record<string, { enabled: boolean }>
400
+ },
401
+ ): Promise<axe.AxeResults> {
402
+ const builder = new AxeBuilder({ page })
403
+ .withTags(opts.wcagTags)
404
+
405
+ for (const selector of opts.exclude) {
406
+ builder.exclude(selector)
407
+ }
408
+
409
+ if (opts.rules) {
410
+ builder.options({ rules: opts.rules })
411
+ }
412
+
413
+ const results = await builder.analyze()
414
+
415
+ await testInfo.attach('a11y-wcag-scan-results', {
416
+ body: JSON.stringify(results, null, 2),
417
+ contentType: 'application/json'
418
+ })
419
+
420
+ return results
421
+ }
422
+
423
+ /**
424
+ * Take a full-page screenshot with violating elements highlighted and
425
+ * attach it to the test report.
426
+ */
427
+ async function screenshotViolatingElements(page: Page, testInfo: TestInfo, results: axe.AxeResults) {
428
+ // Collect all raw CSS selectors from violation nodes.
429
+ const selectors = results.violations
430
+ .flatMap(v => v.nodes)
431
+ .flatMap(n => n.target)
432
+ .filter((t): t is string => typeof t === 'string')
433
+
434
+ if (selectors.length === 0) return
435
+
436
+ // Inject highlight outlines on all violating elements.
437
+ await page.evaluate((sels) => {
438
+ const style = document.createElement('style')
439
+ style.setAttribute('data-a11y-highlight', 'true')
440
+ // Use a CSS rule for each selector so the outline persists even if
441
+ // elements are repositioned during the screenshot.
442
+ const rules = sels.map(s => `${s} { outline: 3px solid #e53e3e !important; outline-offset: 2px !important; }`).join('\n')
443
+ style.textContent = rules
444
+ document.head.appendChild(style)
445
+ }, selectors)
446
+
447
+ try {
448
+ const screenshot = await page.screenshot({ fullPage: true })
449
+
450
+ await testInfo.attach('a11y-violation-screenshot', {
451
+ body: screenshot,
452
+ contentType: 'image/png',
453
+ })
454
+ } finally {
455
+ // Remove the injected styles even if capture or report attachment fails.
456
+ await page.evaluate(() => {
457
+ document.querySelector('style[data-a11y-highlight]')?.remove()
458
+ })
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Assert violations against a baseline allowlist (in-code or on-disk).
464
+ */
465
+ function assertBaseline(ctx: ScanContext, baseline: AccessibilityBaseline) {
466
+ validateAccessibilityBaseline(baseline)
467
+ const { testInfo, results, scanLabel, expectFn } = ctx
468
+ const allViolations = extractNormalizedViolations(results)
469
+ const matchedBaselineIndices = new Set<number>()
470
+ const unmatchedViolations: typeof allViolations = []
471
+
472
+ for (const violation of allViolations) {
473
+ const baselineIndex = baseline.findIndex((entry) => {
474
+ if (entry.rule !== violation.rule) return false
475
+ // Check for at least one overlapping normalized target.
476
+ return entry.targets.some(baselineTarget =>
477
+ violation.targets.some(violationTarget => violationTarget === baselineTarget)
478
+ )
479
+ })
480
+
481
+ if (baselineIndex >= 0) {
482
+ matchedBaselineIndices.add(baselineIndex)
483
+ const entry = baseline[baselineIndex]
484
+ testInfo.annotations.push({
485
+ type: 'Baselined a11y violation',
486
+ description: `${entry.rule}: ${entry.reason} — ${entry.willBeFixedIn}`,
487
+ })
488
+ } else {
489
+ unmatchedViolations.push(violation)
490
+ }
491
+ }
492
+
493
+ // Report stale baseline entries.
494
+ baseline.forEach((entry, idx) => {
495
+ if (!matchedBaselineIndices.has(idx)) {
496
+ testInfo.annotations.push({
497
+ type: 'Stale a11y baseline entry',
498
+ description: `${entry.rule} on ${entry.targets.join(', ')} — no longer detected`,
499
+ })
500
+ }
501
+ })
502
+
503
+ // Summary annotation for baseline mode.
504
+ const baselinedCount = matchedBaselineIndices.size
505
+ testInfo.annotations.push({
506
+ type: 'Accessibility',
507
+ description: `${scanLabel}: ${unmatchedViolations.length} new violations (${baselinedCount} baselined)`,
508
+ })
509
+
510
+ // Fail on unmatched violations with detailed output.
511
+ if (unmatchedViolations.length > 0) {
512
+ const details = formatViolationDetails(results, unmatchedViolations)
513
+ expectFn(null, details).toBe('no accessibility violations')
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Assert via snapshot comparison (legacy mode for tests with committed snapshots).
519
+ */
520
+ async function assertSnapshot(ctx: ScanContext) {
521
+ const { testInfo, results, scan, expectFn } = ctx
522
+
523
+ // Match the legacy summary annotation phrasing for WCAG; best-practice's
524
+ // pre-existing summary annotation is emitted in runBestPracticeScan.
525
+ if (scan === 'wcag') {
526
+ testInfo.annotations.push({
527
+ type: 'Accessibility',
528
+ description: `WCAG scan: ${results.violations.length} violations (${results.passes.length} rules passed)`
529
+ })
530
+
531
+ // If there are violations, attach baseline suggestions and push annotation.
532
+ if (results.violations.length > 0) {
533
+ const allViolations = extractNormalizedViolations(results)
534
+ const suggestions = allViolations.map(v => formatBaselineSuggestion(v)).join('\n')
535
+ await testInfo.attach('a11y-baseline-suggestions', {
536
+ body: suggestions,
537
+ contentType: 'text/plain',
538
+ })
539
+ testInfo.annotations.push({
540
+ type: 'Accessibility',
541
+ description: 'To manage violations explicitly, switch to baseline mode. See a11y-baseline-suggestions attachment.',
542
+ })
543
+ }
544
+ }
545
+
546
+ return expectFn(violationFingerprints(results)).toMatchSnapshot()
547
+ }
548
+
549
+ /**
550
+ * Take a visual comparison, and also ensure there's no accessibility issues.
551
+ *
552
+ * @param page The Page fixture from the test.
553
+ * @param testInfo The testInfo object from the test.
554
+ * @param options Screenshot options from toHaveScreenshot().
555
+ * @param scrollLocator A locator to ensure is visible before taking the screenshot.
556
+ * @param locator A specific locator to take the screenshot of. aXe still checks the whole page.
557
+ */
558
+ export async function takeAccessibleScreenshot(page: Page, testInfo: TestInfo, options?: ScreenshotOptions, scrollLocator?: Locator, locator?: Locator|Page) {
559
+ const screenshotOptions = options ?? {}
560
+
561
+ if (screenshotOptions.clipLocator && (screenshotOptions.clip || (locator && locator !== page))) {
562
+ throw new Error('clipLocator cannot be combined with clip or a locator screenshot target.')
563
+ }
564
+ if (screenshotOptions.clipLocator && screenshotOptions.clipLocator.page() !== page) {
565
+ throw new Error('clipLocator must belong to the screenshot page.')
566
+ }
567
+
568
+ // The default is 5 seconds. However, even on a fast machine it can take
569
+ // longer than 5 seconds for large pages like node forms to stabilize. This
570
+ // doesn't affect end users because the page still being rendered is
571
+ // typically below the viewport, and it's loaded by the time they scroll.
572
+ // So, we set this to at least 10 seconds, unless it's already larger.
573
+ // To test changing this, try running this command and see if it times out:
574
+ const interactionStates = screenshotOptions.interactionStates ?? []
575
+ validateInteractionStates(interactionStates)
576
+
577
+ // Do not pass package-specific orchestration options to Playwright's matcher.
578
+ const {
579
+ clipLocator: _clipLocator,
580
+ accessibility: _accessibility,
581
+ blur: _blur,
582
+ clearHover: _clearHover,
583
+ interactionStates: _interactionStates,
584
+ stabilization,
585
+ ...nativeScreenshotOptions
586
+ } = screenshotOptions
587
+ const playwrightScreenshotOptions = {
588
+ ...nativeScreenshotOptions,
589
+ timeout: Math.max(nativeScreenshotOptions.timeout ?? 0, 10000),
590
+ }
591
+
592
+ // Blur any focused element so a stray focus ring does not make the screenshot
593
+ // non-deterministic, unless the caller is intentionally capturing focus. Do
594
+ // this before the load/stability waits so that any layout change a blur
595
+ // handler triggers (a closing dropdown, injected validation markup) settles
596
+ // before we capture.
597
+ if (screenshotOptions.blur !== false) {
598
+ await blurActiveElement(page);
599
+ }
600
+
601
+ let removeHoverShield = screenshotOptions.clearHover === false ? undefined : await clearHover(page);
602
+ let videoPlaybackRestored = false
603
+ let cleanupInteractionStates: (() => Promise<void>) | undefined
604
+
605
+ try {
606
+ await waitForFrames(page);
607
+ // Loading lazy frames can scroll the page. Load images afterwards so that
608
+ // any images exposed by that scrolling are settled and waitForAllImages()
609
+ // restores the viewport to the top before capture.
610
+ await waitForAllImages(page, stabilization?.images);
611
+ await waitForFonts(page);
612
+ // Last of the waits, so the video frames it composites are as fresh as
613
+ // possible when the capture happens. It restores the scroll position it
614
+ // found, so it does not disturb where the waits above leave the page.
615
+ await waitForVideos(page, stabilization?.videos);
616
+
617
+ if (scrollLocator) {
618
+ await scrollLocator.scrollIntoViewIfNeeded();
619
+ }
620
+
621
+ // A hover action cannot hit its target through the transparent shield used
622
+ // to clear incidental hover. Remove it only after all stability waits, then
623
+ // apply real hover before real focus so focusing cannot disturb the pointer.
624
+ const hasHover = interactionStates.some(({states}) => states.includes('hover'))
625
+ if (hasHover) {
626
+ await removeHoverShield?.()
627
+ removeHoverShield = undefined
628
+ }
629
+ if (interactionStates.length > 0) {
630
+ cleanupInteractionStates = await applyInteractionStates(interactionStates, {
631
+ clearHover: async () => {
632
+ const removeInteractionShield = await clearHover(page)
633
+ await removeInteractionShield()
634
+ },
635
+ })
636
+ }
637
+
638
+ let locatorToScreenshot: Page|Locator = page;
639
+ if (locator) {
640
+ locatorToScreenshot = locator;
641
+ }
642
+ if (screenshotOptions.clipLocator) {
643
+ await screenshotOptions.clipLocator.scrollIntoViewIfNeeded()
644
+ // Scrolling can expose lazy content. Await fonts and two paint frames
645
+ // before measuring; the existing waits have already loaded page media.
646
+ await waitForFonts(page)
647
+ await page.evaluate(() => new Promise<void>(resolve =>
648
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
649
+ const bounds = await screenshotOptions.clipLocator.boundingBox()
650
+ if (!bounds) throw new Error('clipLocator has no visible bounding box.')
651
+ // boundingBox is in main-frame viewport coordinates, including for
652
+ // locators inside frames. Read scroll offsets from the main page.
653
+ const geometry = await page.evaluate(() => ({
654
+ x: window.scrollX, y: window.scrollY,
655
+ width: window.innerWidth, height: window.innerHeight,
656
+ }))
657
+ const clip = {
658
+ x: Math.round(bounds.x + (screenshotOptions.fullPage ? geometry.x : 0)),
659
+ y: Math.round(bounds.y + (screenshotOptions.fullPage ? geometry.y : 0)),
660
+ width: Math.round(bounds.width),
661
+ height: Math.round(bounds.height),
662
+ }
663
+ if (clip.width <= 0 || clip.height <= 0) {
664
+ throw new Error('clipLocator must have positive rounded dimensions.')
665
+ }
666
+ if (!screenshotOptions.fullPage && (clip.x < 0 || clip.y < 0 || clip.x + clip.width > geometry.width || clip.y + clip.height > geometry.height)) {
667
+ throw new Error('clipLocator does not fit in the viewport; use fullPage: true to capture the whole target.')
668
+ }
669
+ if (clip.x < 0 || clip.y < 0) {
670
+ throw new Error('clipLocator must have non-negative document coordinates.')
671
+ }
672
+ playwrightScreenshotOptions.clip = clip
673
+ }
674
+ // Soft failure here so we can get accessibility violations too.
675
+ await expect.soft(locatorToScreenshot).toHaveScreenshot(playwrightScreenshotOptions);
676
+
677
+ // Settling a video pauses it, clears `autoplay` and rewinds it. That is only
678
+ // wanted for the duration of the capture: a test that screenshots a page and
679
+ // then asserts that a video is playing should still pass.
680
+ await restoreVideoPlayback(page);
681
+ videoPlaybackRestored = true
682
+
683
+ await removeHoverShield?.()
684
+ removeHoverShield = undefined
685
+
686
+ return await checkAccessibility(page, testInfo, screenshotOptions.accessibility)
687
+ } finally {
688
+ // Nest cleanup so a failure in one operation cannot prevent the remaining
689
+ // browser state from being restored.
690
+ try {
691
+ await removeHoverShield?.();
692
+ } finally {
693
+ try {
694
+ if (!videoPlaybackRestored) {
695
+ await restoreVideoPlayback(page);
696
+ }
697
+ } finally {
698
+ await cleanupInteractionStates?.()
699
+ }
700
+ }
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Normalize a single CSS selector target for stable comparison.
706
+ *
707
+ * Replaces unique numeric HTML IDs and aria-labelledby suffixes with
708
+ * a stable placeholder so that snapshots and baseline matching are
709
+ * deterministic across runs.
710
+ */
711
+ export function normalizeTarget(target: string | string[]): string | string[] {
712
+ const uniqueHtmlID = /(#[^#]*)--\d+/
713
+ const ariaLabelledById = /(aria-labelledby="[^"]+)--\d+"/
714
+ if (typeof target === 'string') {
715
+ return target
716
+ .replace(uniqueHtmlID, '$1--UNIQUE-ID')
717
+ .replace(ariaLabelledById, '$1--UNIQUE-ID"')
718
+ }
719
+ return target
720
+ }
721
+
722
+ interface NormalizedViolation {
723
+ rule: string
724
+ targets: string[]
725
+ description: string
726
+ impact: string
727
+ helpUrl: string
728
+ }
729
+
730
+ /**
731
+ * Extract violations from axe results and normalize their targets into
732
+ * flat, deduplicated CSS selector strings.
733
+ */
734
+ function extractNormalizedViolations(results: axe.AxeResults): NormalizedViolation[] {
735
+ return results.violations.map(violation => {
736
+ const flatTargets: string[] = []
737
+ for (const node of violation.nodes) {
738
+ for (const target of node.target) {
739
+ const normalized = normalizeTarget(target)
740
+ const str = typeof normalized === 'string' ? normalized : normalized.join(' ')
741
+ if (!flatTargets.includes(str)) {
742
+ flatTargets.push(str)
743
+ }
744
+ }
745
+ }
746
+ return {
747
+ rule: violation.id,
748
+ targets: flatTargets,
749
+ description: violation.description,
750
+ impact: violation.impact ?? 'unknown',
751
+ helpUrl: violation.helpUrl,
752
+ }
753
+ })
754
+ }
755
+
756
+ /**
757
+ * Format a single violation as a copy-pasteable baseline entry.
758
+ */
759
+ function formatBaselineSuggestion(violation: NormalizedViolation): string {
760
+ const targetsStr = violation.targets.map(t => `'${t}'`).join(', ')
761
+ return `{
762
+ rule: '${violation.rule}',
763
+ targets: [${targetsStr}],
764
+ reason: '', // TODO: explain why this is accepted
765
+ willBeFixedIn: '', // TODO: link to tracking ticket
766
+ },`
767
+ }
768
+
769
+ /**
770
+ * Format detailed failure output for unmatched violations, including
771
+ * copy-pasteable baseline entries.
772
+ */
773
+ function formatViolationDetails(results: axe.AxeResults, violations: NormalizedViolation[]): string {
774
+ const lines: string[] = []
775
+ for (const v of violations) {
776
+ const targetsStr = JSON.stringify(v.targets)
777
+ lines.push(`Accessibility violation (${v.impact}): ${v.rule}`)
778
+ lines.push(` ${v.description}`)
779
+ lines.push(` Help: ${v.helpUrl}`)
780
+ lines.push(` Targets: ${targetsStr}`)
781
+ lines.push('')
782
+ lines.push(' Add to your baseline to accept this violation:')
783
+ lines.push(' ' + formatBaselineSuggestion(v).split('\n').join('\n '))
784
+ lines.push('')
785
+ }
786
+ return lines.join('\n')
787
+ }
788
+
789
+ /**
790
+ * Filter violations down to stable elements.
791
+ *
792
+ * If we try to create a snapshot of the entire report, it will fail on random
793
+ * unique HTML IDs.
794
+ *
795
+ * @param accessibilityScanResults
796
+ */
797
+ function violationFingerprints(accessibilityScanResults: axe.AxeResults) {
798
+ const violationFps = accessibilityScanResults.violations.map(violation => ({
799
+ rule: violation.id,
800
+ // These are CSS selectors which uniquely identify each element with
801
+ // a violation of the rule in question.
802
+ targets: violation.nodes.map(node => node.target.map((target) => {
803
+ return normalizeTarget(target)
804
+ })),
805
+ }));
806
+
807
+ return JSON.stringify(violationFps, null, 2);
808
+
809
+ }