@lullabot/playwright-testing 1.0.0 → 1.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 (68) hide show
  1. package/bin/github-a11y-summary +2 -2
  2. package/bin/github-failure-summary +4 -4
  3. package/lib/accessibility-baseline-file.d.ts +6 -6
  4. package/lib/accessibility-baseline-file.js +29 -23
  5. package/lib/accessibility-baseline.js +5 -3
  6. package/lib/accessible-screenshot.d.ts +4 -4
  7. package/lib/accessible-screenshot.js +110 -88
  8. package/lib/focus.js +4 -1
  9. package/lib/frames.js +4 -4
  10. package/lib/github/a11y-summary.js +74 -79
  11. package/lib/github/attachments.d.ts +1 -1
  12. package/lib/github/attachments.js +36 -34
  13. package/lib/github/failure-summary.d.ts +4 -4
  14. package/lib/github/failure-summary.js +140 -108
  15. package/lib/github/index.d.ts +6 -6
  16. package/lib/github/report-paths.js +17 -9
  17. package/lib/hover.js +17 -17
  18. package/lib/images.js +15 -12
  19. package/lib/index.d.ts +13 -13
  20. package/lib/interaction-states.d.ts +2 -2
  21. package/lib/interaction-states.js +7 -7
  22. package/lib/mock/index.d.ts +1 -1
  23. package/lib/mock/youtube.d.ts +1 -1
  24. package/lib/mock/youtube.js +1 -1
  25. package/lib/pseudo-state.d.ts +2 -2
  26. package/lib/pseudo-state.js +15 -7
  27. package/lib/videos.js +21 -10
  28. package/lib/visualdiff.d.ts +4 -4
  29. package/lib/visualdiff.js +35 -27
  30. package/package.json +3 -3
  31. package/src/accessibility-baseline-file.test.ts +190 -154
  32. package/src/accessibility-baseline-file.ts +107 -83
  33. package/src/accessibility-baseline.test.ts +869 -433
  34. package/src/accessibility-baseline.ts +41 -21
  35. package/src/accessible-screenshot.test.ts +551 -423
  36. package/src/accessible-screenshot.ts +358 -251
  37. package/src/focus.test.ts +23 -23
  38. package/src/focus.ts +7 -2
  39. package/src/fonts.test.ts +13 -13
  40. package/src/fonts.ts +1 -1
  41. package/src/frames.test.ts +10 -8
  42. package/src/frames.ts +5 -5
  43. package/src/github/a11y-summary.test.ts +436 -332
  44. package/src/github/a11y-summary.ts +189 -179
  45. package/src/github/attachments.test.ts +228 -167
  46. package/src/github/attachments.ts +144 -121
  47. package/src/github/failure-summary.test.ts +660 -445
  48. package/src/github/failure-summary.ts +376 -274
  49. package/src/github/index.test.ts +14 -14
  50. package/src/github/index.ts +6 -6
  51. package/src/github/report-paths.test.ts +211 -156
  52. package/src/github/report-paths.ts +75 -61
  53. package/src/hover.test.ts +50 -46
  54. package/src/hover.ts +18 -18
  55. package/src/images.test.ts +251 -206
  56. package/src/images.ts +52 -43
  57. package/src/index.ts +13 -13
  58. package/src/interaction-states.test.ts +72 -42
  59. package/src/interaction-states.ts +43 -29
  60. package/src/mock/index.ts +1 -1
  61. package/src/mock/youtube.test.ts +26 -26
  62. package/src/mock/youtube.ts +4 -6
  63. package/src/pseudo-state.test.ts +58 -51
  64. package/src/pseudo-state.ts +32 -27
  65. package/src/videos.test.ts +559 -399
  66. package/src/videos.ts +68 -44
  67. package/src/visualdiff.test.ts +416 -315
  68. package/src/visualdiff.ts +140 -99
@@ -1,22 +1,31 @@
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";
1
+ import AxeBuilder from "@axe-core/playwright";
2
+ import {
3
+ expect,
4
+ type Locator,
5
+ type Page,
6
+ type TestInfo,
7
+ } from "@playwright/test";
8
+ import { waitForAllImages, type WaitForImagesOptions } from "./images.js";
9
+ import { waitForFrames } from "./frames.js";
10
+ import { waitForFonts } from "./fonts.js";
11
+ import {
12
+ restoreVideoPlayback,
13
+ waitForVideos,
14
+ type WaitForVideosOptions,
15
+ } from "./videos.js";
16
+ import { blurActiveElement } from "./focus.js";
17
+ import { clearHover } from "./hover.js";
9
18
  import {
10
19
  applyInteractionStates,
11
20
  validateInteractionStates,
12
21
  type ScreenshotInteractionState,
13
- } from './interaction-states.js'
14
- import axe from 'axe-core';
22
+ } from "./interaction-states.js";
23
+ import axe from "axe-core";
15
24
  import {
16
25
  type AccessibilityBaseline,
17
26
  type AccessibilityBaselineEntry,
18
27
  validateAccessibilityBaseline,
19
- } from './accessibility-baseline.js'
28
+ } from "./accessibility-baseline.js";
20
29
  import {
21
30
  baselineFilePath,
22
31
  buildSeed,
@@ -25,15 +34,18 @@ import {
25
34
  ScanKind,
26
35
  snapshotExists,
27
36
  writeBaselineFile,
28
- } from './accessibility-baseline-file.js'
37
+ } from "./accessibility-baseline-file.js";
29
38
 
30
39
  let a11yActionHintShown = false;
31
40
 
32
- export type {InteractionState, ScreenshotInteractionState} from './interaction-states.js'
41
+ export type {
42
+ InteractionState,
43
+ ScreenshotInteractionState,
44
+ } from "./interaction-states.js";
33
45
 
34
46
  export interface ScreenshotStabilizationOptions {
35
- images?: WaitForImagesOptions
36
- videos?: WaitForVideosOptions
47
+ images?: WaitForImagesOptions;
48
+ videos?: WaitForVideosOptions;
37
49
  }
38
50
 
39
51
  export interface ScreenshotOptions {
@@ -170,16 +182,16 @@ export interface ScreenshotOptions {
170
182
 
171
183
  export interface AccessibilityOptions {
172
184
  /** axe tags for WCAG scan. Default: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] */
173
- wcagTags?: string[]
185
+ wcagTags?: string[];
174
186
 
175
187
  /** Additional CSS selectors to exclude from both scans. */
176
- exclude?: string[]
188
+ exclude?: string[];
177
189
 
178
190
  /** CSS selectors to exclude only from the best-practice scan. */
179
- bestPracticeExclude?: string[]
191
+ bestPracticeExclude?: string[];
180
192
 
181
193
  /** CSS selectors to exclude only from the WCAG scan. */
182
- wcagExclude?: string[]
194
+ wcagExclude?: string[];
183
195
 
184
196
  /**
185
197
  * Best-practice scan mode.
@@ -187,20 +199,20 @@ export interface AccessibilityOptions {
187
199
  * - 'hard': uses expect() — test fails immediately on violations
188
200
  * - 'off': skips best-practice scan entirely
189
201
  */
190
- bestPracticeMode?: 'soft' | 'hard' | 'off'
202
+ bestPracticeMode?: "soft" | "hard" | "off";
191
203
 
192
204
  /** Additional axe rules to enable/disable. */
193
- rules?: Record<string, { enabled: boolean }>
205
+ rules?: Record<string, { enabled: boolean }>;
194
206
 
195
207
  /** Baseline of known violations. When provided, violations matching the baseline are suppressed and toMatchSnapshot() is skipped. */
196
- baseline?: AccessibilityBaseline
208
+ baseline?: AccessibilityBaseline;
197
209
 
198
210
  /**
199
211
  * When true, captures a full-page screenshot with violating elements
200
212
  * highlighted (red outline) and attaches it to the test report.
201
213
  * Default: true.
202
214
  */
203
- screenshotViolations?: boolean
215
+ screenshotViolations?: boolean;
204
216
  }
205
217
 
206
218
  /**
@@ -213,70 +225,88 @@ export interface AccessibilityOptions {
213
225
  * @param testInfo The testInfo object from the test.
214
226
  * @param options Accessibility options to customise the scan.
215
227
  */
216
- export async function checkAccessibility(page: Page, testInfo: TestInfo, options?: AccessibilityOptions) {
228
+ export async function checkAccessibility(
229
+ page: Page,
230
+ testInfo: TestInfo,
231
+ options?: AccessibilityOptions,
232
+ ) {
217
233
  const {
218
- wcagTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
234
+ wcagTags = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"],
219
235
  exclude = [],
220
236
  bestPracticeExclude = [],
221
237
  wcagExclude = [],
222
- bestPracticeMode = 'soft',
238
+ bestPracticeMode = "soft",
223
239
  rules,
224
240
  baseline,
225
241
  screenshotViolations = true,
226
- } = options ?? {}
242
+ } = options ?? {};
227
243
 
228
244
  if (process.env.CI && !a11yActionHintShown) {
229
- console.log('Tip: Use @lullabot/playwright-testing/github to surface accessibility violations in pull requests.')
230
- a11yActionHintShown = true
245
+ console.log(
246
+ "Tip: Use @lullabot/playwright-testing/github to surface accessibility violations in pull requests.",
247
+ );
248
+ a11yActionHintShown = true;
231
249
  }
232
250
 
233
251
  // Add @a11y annotation (deduplicated).
234
- if (!testInfo.annotations.some(a => a.type === '@a11y')) {
235
- testInfo.annotations.push({ type: '@a11y' })
252
+ if (!testInfo.annotations.some((a) => a.type === "@a11y")) {
253
+ testInfo.annotations.push({ type: "@a11y" });
236
254
  }
237
255
 
238
- if (bestPracticeMode !== 'off') {
256
+ // An in-code baseline is shared by both scans. Track matches across this
257
+ // check so that a rule detected by either scan is not reported as stale.
258
+ const sharedBaselineMatches = new Set<number>();
259
+
260
+ if (bestPracticeMode !== "off") {
239
261
  const bpResults = await runBestPracticeScan(page, testInfo, {
240
262
  exclude: [...exclude, ...bestPracticeExclude],
241
263
  rules,
242
- })
264
+ });
243
265
  // Best-practice always uses expect.soft() so the WCAG scan below runs
244
266
  // even when best-practice violations exist. `bestPracticeMode === 'hard'`
245
267
  // 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)
268
+ await dispatchAssertion(
269
+ {
270
+ testInfo,
271
+ results: bpResults,
272
+ scan: "best-practice",
273
+ expectFn: expect.soft,
274
+ scanLabel: "Best-practice scan",
275
+ },
276
+ baseline,
277
+ sharedBaselineMatches,
278
+ );
253
279
  }
254
280
 
255
281
  const wcagScanResults = await runWcagScan(page, testInfo, {
256
282
  wcagTags,
257
283
  exclude: [...exclude, ...wcagExclude],
258
284
  rules,
259
- })
285
+ });
260
286
 
261
287
  if (screenshotViolations && wcagScanResults.violations.length > 0) {
262
- await screenshotViolatingElements(page, testInfo, wcagScanResults)
288
+ await screenshotViolatingElements(page, testInfo, wcagScanResults);
263
289
  }
264
290
 
265
- await dispatchAssertion({
266
- testInfo,
267
- results: wcagScanResults,
268
- scan: 'wcag',
269
- expectFn: expect,
270
- scanLabel: 'WCAG scan',
271
- }, baseline)
291
+ await dispatchAssertion(
292
+ {
293
+ testInfo,
294
+ results: wcagScanResults,
295
+ scan: "wcag",
296
+ expectFn: expect,
297
+ scanLabel: "WCAG scan",
298
+ },
299
+ baseline,
300
+ sharedBaselineMatches,
301
+ );
272
302
  }
273
303
 
274
304
  interface ScanContext {
275
- testInfo: TestInfo
276
- results: axe.AxeResults
277
- scan: ScanKind
278
- expectFn: typeof expect | typeof expect.soft
279
- scanLabel: string
305
+ testInfo: TestInfo;
306
+ results: axe.AxeResults;
307
+ scan: ScanKind;
308
+ expectFn: typeof expect | typeof expect.soft;
309
+ scanLabel: string;
280
310
  }
281
311
 
282
312
  /**
@@ -294,55 +324,60 @@ interface ScanContext {
294
324
  * test (matching Playwright's missing-snapshot behaviour); locally,
295
325
  * seeding passes so the first run is green.
296
326
  */
297
- async function dispatchAssertion(ctx: ScanContext, inCodeBaseline?: AccessibilityBaseline): Promise<void> {
327
+ async function dispatchAssertion(
328
+ ctx: ScanContext,
329
+ inCodeBaseline?: AccessibilityBaseline,
330
+ sharedBaselineMatches?: Set<number>,
331
+ ): Promise<void> {
298
332
  if (inCodeBaseline) {
299
- return assertBaseline(ctx, inCodeBaseline)
333
+ return assertBaseline(ctx, inCodeBaseline, sharedBaselineMatches);
300
334
  }
301
335
 
302
336
  if (await snapshotExists(ctx.testInfo)) {
303
- return assertSnapshot(ctx)
337
+ return assertSnapshot(ctx);
304
338
  }
305
339
 
306
- const update = ctx.testInfo.config?.updateSnapshots
307
- if (update === 'all' || update === 'changed') {
308
- return assertSnapshot(ctx)
340
+ const update = ctx.testInfo.config?.updateSnapshots;
341
+ if (update === "all" || update === "changed") {
342
+ return assertSnapshot(ctx);
309
343
  }
310
344
 
311
- const callCount = nextAccessibilityScanCount(ctx.testInfo, ctx.scan)
312
- const filePath = baselineFilePath(ctx.testInfo, ctx.scan, callCount)
345
+ const callCount = nextAccessibilityScanCount(ctx.testInfo, ctx.scan);
346
+ const filePath = baselineFilePath(ctx.testInfo, ctx.scan, callCount);
313
347
 
314
- const existing = await readBaselineFile(filePath)
348
+ const existing = await readBaselineFile(filePath);
315
349
  if (existing) {
316
- return assertBaseline(ctx, existing.violations)
350
+ return assertBaseline(ctx, existing.violations);
317
351
  }
318
352
 
319
353
  // Seed and either pass (local) or fail (CI).
320
- const normalized = extractNormalizedViolations(ctx.results)
321
- const seedViolations: AccessibilityBaselineEntry[] = normalized.map(v => ({
354
+ const normalized = extractNormalizedViolations(ctx.results);
355
+ const seedViolations: AccessibilityBaselineEntry[] = normalized.map((v) => ({
322
356
  rule: v.rule,
323
357
  targets: v.targets,
324
- reason: 'TODO',
325
- willBeFixedIn: 'TODO',
326
- }))
327
- const seed = buildSeed(seedViolations)
328
- await writeBaselineFile(filePath, seed)
358
+ reason: "TODO",
359
+ willBeFixedIn: "TODO",
360
+ }));
361
+ const seed = buildSeed(seedViolations);
362
+ await writeBaselineFile(filePath, seed);
329
363
  await ctx.testInfo.attach(`a11y-${ctx.scan}-baseline-seed`, {
330
364
  path: filePath,
331
- contentType: 'application/json',
332
- })
365
+ contentType: "application/json",
366
+ });
333
367
 
334
368
  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
369
+ 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.`;
370
+ ctx.expectFn(null, message).toBe("a11y baseline file present");
371
+ return;
338
372
  }
339
373
 
340
374
  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
- })
375
+ type: "Accessibility",
376
+ description:
377
+ seed.violations.length === 0
378
+ ? `${ctx.scanLabel}: a11y baseline seeded at ${filePath} (no violations).`
379
+ : `${ctx.scanLabel}: a11y baseline seeded at ${filePath} with ${seed.violations.length} entries — fill in reason/willBeFixedIn before committing.`,
380
+ });
346
381
  // A local seed is deliberately permissive and contains TODO metadata. The
347
382
  // next run validates the committed file before treating entries as waivers.
348
383
  }
@@ -357,34 +392,33 @@ async function runBestPracticeScan(
357
392
  page: Page,
358
393
  testInfo: TestInfo,
359
394
  opts: {
360
- exclude: string[]
361
- rules?: Record<string, { enabled: boolean }>
395
+ exclude: string[];
396
+ rules?: Record<string, { enabled: boolean }>;
362
397
  },
363
398
  ): Promise<axe.AxeResults> {
364
- const builder = new AxeBuilder({ page })
365
- .withTags(['best-practice'])
399
+ const builder = new AxeBuilder({ page }).withTags(["best-practice"]);
366
400
 
367
401
  for (const selector of opts.exclude) {
368
- builder.exclude(selector)
402
+ builder.exclude(selector);
369
403
  }
370
404
 
371
405
  if (opts.rules) {
372
- builder.options({ rules: opts.rules })
406
+ builder.options({ rules: opts.rules });
373
407
  }
374
408
 
375
- const results = await builder.analyze()
409
+ const results = await builder.analyze();
376
410
 
377
- await testInfo.attach('a11y-best-practice-scan-results', {
411
+ await testInfo.attach("a11y-best-practice-scan-results", {
378
412
  body: JSON.stringify(results, null, 2),
379
- contentType: 'application/json'
380
- })
413
+ contentType: "application/json",
414
+ });
381
415
 
382
416
  testInfo.annotations.push({
383
- type: 'Accessibility',
384
- description: `Best-practice scan: ${results.violations.length} violations (${results.passes.length} rules passed)`
385
- })
417
+ type: "Accessibility",
418
+ description: `Best-practice scan: ${results.violations.length} violations (${results.passes.length} rules passed)`,
419
+ });
386
420
 
387
- return results
421
+ return results;
388
422
  }
389
423
 
390
424
  /**
@@ -394,123 +428,144 @@ async function runWcagScan(
394
428
  page: Page,
395
429
  testInfo: TestInfo,
396
430
  opts: {
397
- wcagTags: string[]
398
- exclude: string[]
399
- rules?: Record<string, { enabled: boolean }>
431
+ wcagTags: string[];
432
+ exclude: string[];
433
+ rules?: Record<string, { enabled: boolean }>;
400
434
  },
401
435
  ): Promise<axe.AxeResults> {
402
- const builder = new AxeBuilder({ page })
403
- .withTags(opts.wcagTags)
436
+ const builder = new AxeBuilder({ page }).withTags(opts.wcagTags);
404
437
 
405
438
  for (const selector of opts.exclude) {
406
- builder.exclude(selector)
439
+ builder.exclude(selector);
407
440
  }
408
441
 
409
442
  if (opts.rules) {
410
- builder.options({ rules: opts.rules })
443
+ builder.options({ rules: opts.rules });
411
444
  }
412
445
 
413
- const results = await builder.analyze()
446
+ const results = await builder.analyze();
414
447
 
415
- await testInfo.attach('a11y-wcag-scan-results', {
448
+ await testInfo.attach("a11y-wcag-scan-results", {
416
449
  body: JSON.stringify(results, null, 2),
417
- contentType: 'application/json'
418
- })
450
+ contentType: "application/json",
451
+ });
419
452
 
420
- return results
453
+ return results;
421
454
  }
422
455
 
423
456
  /**
424
457
  * Take a full-page screenshot with violating elements highlighted and
425
458
  * attach it to the test report.
426
459
  */
427
- async function screenshotViolatingElements(page: Page, testInfo: TestInfo, results: axe.AxeResults) {
460
+ async function screenshotViolatingElements(
461
+ page: Page,
462
+ testInfo: TestInfo,
463
+ results: axe.AxeResults,
464
+ ) {
428
465
  // Collect all raw CSS selectors from violation nodes.
429
466
  const selectors = results.violations
430
- .flatMap(v => v.nodes)
431
- .flatMap(n => n.target)
432
- .filter((t): t is string => typeof t === 'string')
467
+ .flatMap((v) => v.nodes)
468
+ .flatMap((n) => n.target)
469
+ .filter((t): t is string => typeof t === "string");
433
470
 
434
- if (selectors.length === 0) return
471
+ if (selectors.length === 0) return;
435
472
 
436
473
  // Inject highlight outlines on all violating elements.
437
474
  await page.evaluate((sels) => {
438
- const style = document.createElement('style')
439
- style.setAttribute('data-a11y-highlight', 'true')
475
+ const style = document.createElement("style");
476
+ style.setAttribute("data-a11y-highlight", "true");
440
477
  // Use a CSS rule for each selector so the outline persists even if
441
478
  // 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)
479
+ const rules = sels
480
+ .map(
481
+ (s) =>
482
+ `${s} { outline: 3px solid #e53e3e !important; outline-offset: 2px !important; }`,
483
+ )
484
+ .join("\n");
485
+ style.textContent = rules;
486
+ document.head.appendChild(style);
487
+ }, selectors);
446
488
 
447
489
  try {
448
- const screenshot = await page.screenshot({ fullPage: true })
490
+ const screenshot = await page.screenshot({ fullPage: true });
449
491
 
450
- await testInfo.attach('a11y-violation-screenshot', {
492
+ await testInfo.attach("a11y-violation-screenshot", {
451
493
  body: screenshot,
452
- contentType: 'image/png',
453
- })
494
+ contentType: "image/png",
495
+ });
454
496
  } finally {
455
497
  // Remove the injected styles even if capture or report attachment fails.
456
498
  await page.evaluate(() => {
457
- document.querySelector('style[data-a11y-highlight]')?.remove()
458
- })
499
+ document.querySelector("style[data-a11y-highlight]")?.remove();
500
+ });
459
501
  }
460
502
  }
461
503
 
462
504
  /**
463
505
  * Assert violations against a baseline allowlist (in-code or on-disk).
464
506
  */
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 = []
507
+ function assertBaseline(
508
+ ctx: ScanContext,
509
+ baseline: AccessibilityBaseline,
510
+ sharedBaselineMatches?: Set<number>,
511
+ ) {
512
+ validateAccessibilityBaseline(baseline);
513
+ const { testInfo, results, scanLabel, expectFn } = ctx;
514
+ const allViolations = extractNormalizedViolations(results);
515
+ const matchedBaselineIndices = new Set<number>();
516
+ const unmatchedViolations: typeof allViolations = [];
471
517
 
472
518
  for (const violation of allViolations) {
473
519
  const baselineIndex = baseline.findIndex((entry) => {
474
- if (entry.rule !== violation.rule) return false
520
+ if (entry.rule !== violation.rule) return false;
475
521
  // Check for at least one overlapping normalized target.
476
- return entry.targets.some(baselineTarget =>
477
- violation.targets.some(violationTarget => violationTarget === baselineTarget)
478
- )
479
- })
522
+ return entry.targets.some((baselineTarget) =>
523
+ violation.targets.some(
524
+ (violationTarget) => violationTarget === baselineTarget,
525
+ ),
526
+ );
527
+ });
480
528
 
481
529
  if (baselineIndex >= 0) {
482
- matchedBaselineIndices.add(baselineIndex)
483
- const entry = baseline[baselineIndex]
530
+ matchedBaselineIndices.add(baselineIndex);
531
+ sharedBaselineMatches?.add(baselineIndex);
532
+ const entry = baseline[baselineIndex];
484
533
  testInfo.annotations.push({
485
- type: 'Baselined a11y violation',
534
+ type: "Baselined a11y violation",
486
535
  description: `${entry.rule}: ${entry.reason} — ${entry.willBeFixedIn}`,
487
- })
536
+ });
488
537
  } else {
489
- unmatchedViolations.push(violation)
538
+ unmatchedViolations.push(violation);
490
539
  }
491
540
  }
492
541
 
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
- })
542
+ // The WCAG scan always runs last, even when best-practice is disabled.
543
+ // Report shared entries once after both scans have contributed matches,
544
+ // before the hard assertion can throw. On-disk baselines belong to one
545
+ // scan and continue to report stale entries independently.
546
+ if (!sharedBaselineMatches || ctx.scan === "wcag") {
547
+ const matches = sharedBaselineMatches ?? matchedBaselineIndices;
548
+ baseline.forEach((entry, idx) => {
549
+ if (!matches.has(idx)) {
550
+ testInfo.annotations.push({
551
+ type: "Stale a11y baseline entry",
552
+ description: `${entry.rule} on ${entry.targets.join(", ")} — no longer detected`,
553
+ });
554
+ }
555
+ });
556
+ }
502
557
 
503
558
  // Summary annotation for baseline mode.
504
- const baselinedCount = matchedBaselineIndices.size
559
+ const baselinedCount = matchedBaselineIndices.size;
505
560
  testInfo.annotations.push({
506
- type: 'Accessibility',
561
+ type: "Accessibility",
507
562
  description: `${scanLabel}: ${unmatchedViolations.length} new violations (${baselinedCount} baselined)`,
508
- })
563
+ });
509
564
 
510
565
  // Fail on unmatched violations with detailed output.
511
566
  if (unmatchedViolations.length > 0) {
512
- const details = formatViolationDetails(results, unmatchedViolations)
513
- expectFn(null, details).toBe('no accessibility violations')
567
+ const details = formatViolationDetails(results, unmatchedViolations);
568
+ expectFn(null, details).toBe("no accessibility violations");
514
569
  }
515
570
  }
516
571
 
@@ -518,32 +573,35 @@ function assertBaseline(ctx: ScanContext, baseline: AccessibilityBaseline) {
518
573
  * Assert via snapshot comparison (legacy mode for tests with committed snapshots).
519
574
  */
520
575
  async function assertSnapshot(ctx: ScanContext) {
521
- const { testInfo, results, scan, expectFn } = ctx
576
+ const { testInfo, results, scan, expectFn } = ctx;
522
577
 
523
578
  // Match the legacy summary annotation phrasing for WCAG; best-practice's
524
579
  // pre-existing summary annotation is emitted in runBestPracticeScan.
525
- if (scan === 'wcag') {
580
+ if (scan === "wcag") {
526
581
  testInfo.annotations.push({
527
- type: 'Accessibility',
528
- description: `WCAG scan: ${results.violations.length} violations (${results.passes.length} rules passed)`
529
- })
582
+ type: "Accessibility",
583
+ description: `WCAG scan: ${results.violations.length} violations (${results.passes.length} rules passed)`,
584
+ });
530
585
 
531
586
  // If there are violations, attach baseline suggestions and push annotation.
532
587
  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', {
588
+ const allViolations = extractNormalizedViolations(results);
589
+ const suggestions = allViolations
590
+ .map((v) => formatBaselineSuggestion(v))
591
+ .join("\n");
592
+ await testInfo.attach("a11y-baseline-suggestions", {
536
593
  body: suggestions,
537
- contentType: 'text/plain',
538
- })
594
+ contentType: "text/plain",
595
+ });
539
596
  testInfo.annotations.push({
540
- type: 'Accessibility',
541
- description: 'To manage violations explicitly, switch to baseline mode. See a11y-baseline-suggestions attachment.',
542
- })
597
+ type: "Accessibility",
598
+ description:
599
+ "To manage violations explicitly, switch to baseline mode. See a11y-baseline-suggestions attachment.",
600
+ });
543
601
  }
544
602
  }
545
603
 
546
- return expectFn(violationFingerprints(results)).toMatchSnapshot()
604
+ return expectFn(violationFingerprints(results)).toMatchSnapshot();
547
605
  }
548
606
 
549
607
  /**
@@ -555,14 +613,28 @@ async function assertSnapshot(ctx: ScanContext) {
555
613
  * @param scrollLocator A locator to ensure is visible before taking the screenshot.
556
614
  * @param locator A specific locator to take the screenshot of. aXe still checks the whole page.
557
615
  */
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.')
616
+ export async function takeAccessibleScreenshot(
617
+ page: Page,
618
+ testInfo: TestInfo,
619
+ options?: ScreenshotOptions,
620
+ scrollLocator?: Locator,
621
+ locator?: Locator | Page,
622
+ ) {
623
+ const screenshotOptions = options ?? {};
624
+
625
+ if (
626
+ screenshotOptions.clipLocator &&
627
+ (screenshotOptions.clip || (locator && locator !== page))
628
+ ) {
629
+ throw new Error(
630
+ "clipLocator cannot be combined with clip or a locator screenshot target.",
631
+ );
563
632
  }
564
- if (screenshotOptions.clipLocator && screenshotOptions.clipLocator.page() !== page) {
565
- throw new Error('clipLocator must belong to the screenshot page.')
633
+ if (
634
+ screenshotOptions.clipLocator &&
635
+ screenshotOptions.clipLocator.page() !== page
636
+ ) {
637
+ throw new Error("clipLocator must belong to the screenshot page.");
566
638
  }
567
639
 
568
640
  // The default is 5 seconds. However, even on a fast machine it can take
@@ -571,8 +643,8 @@ export async function takeAccessibleScreenshot(page: Page, testInfo: TestInfo, o
571
643
  // typically below the viewport, and it's loaded by the time they scroll.
572
644
  // So, we set this to at least 10 seconds, unless it's already larger.
573
645
  // To test changing this, try running this command and see if it times out:
574
- const interactionStates = screenshotOptions.interactionStates ?? []
575
- validateInteractionStates(interactionStates)
646
+ const interactionStates = screenshotOptions.interactionStates ?? [];
647
+ validateInteractionStates(interactionStates);
576
648
 
577
649
  // Do not pass package-specific orchestration options to Playwright's matcher.
578
650
  const {
@@ -583,11 +655,11 @@ export async function takeAccessibleScreenshot(page: Page, testInfo: TestInfo, o
583
655
  interactionStates: _interactionStates,
584
656
  stabilization,
585
657
  ...nativeScreenshotOptions
586
- } = screenshotOptions
658
+ } = screenshotOptions;
587
659
  const playwrightScreenshotOptions = {
588
660
  ...nativeScreenshotOptions,
589
661
  timeout: Math.max(nativeScreenshotOptions.timeout ?? 0, 10000),
590
- }
662
+ };
591
663
 
592
664
  // Blur any focused element so a stray focus ring does not make the screenshot
593
665
  // non-deterministic, unless the caller is intentionally capturing focus. Do
@@ -598,9 +670,10 @@ export async function takeAccessibleScreenshot(page: Page, testInfo: TestInfo, o
598
670
  await blurActiveElement(page);
599
671
  }
600
672
 
601
- let removeHoverShield = screenshotOptions.clearHover === false ? undefined : await clearHover(page);
602
- let videoPlaybackRestored = false
603
- let cleanupInteractionStates: (() => Promise<void>) | undefined
673
+ let removeHoverShield =
674
+ screenshotOptions.clearHover === false ? undefined : await clearHover(page);
675
+ let videoPlaybackRestored = false;
676
+ let cleanupInteractionStates: (() => Promise<void>) | undefined;
604
677
 
605
678
  try {
606
679
  await waitForFrames(page);
@@ -621,69 +694,96 @@ export async function takeAccessibleScreenshot(page: Page, testInfo: TestInfo, o
621
694
  // A hover action cannot hit its target through the transparent shield used
622
695
  // to clear incidental hover. Remove it only after all stability waits, then
623
696
  // apply real hover before real focus so focusing cannot disturb the pointer.
624
- const hasHover = interactionStates.some(({states}) => states.includes('hover'))
697
+ const hasHover = interactionStates.some(({ states }) =>
698
+ states.includes("hover"),
699
+ );
625
700
  if (hasHover) {
626
- await removeHoverShield?.()
627
- removeHoverShield = undefined
701
+ await removeHoverShield?.();
702
+ removeHoverShield = undefined;
628
703
  }
629
704
  if (interactionStates.length > 0) {
630
- cleanupInteractionStates = await applyInteractionStates(interactionStates, {
631
- clearHover: async () => {
632
- const removeInteractionShield = await clearHover(page)
633
- await removeInteractionShield()
705
+ cleanupInteractionStates = await applyInteractionStates(
706
+ interactionStates,
707
+ {
708
+ clearHover: async () => {
709
+ const removeInteractionShield = await clearHover(page);
710
+ await removeInteractionShield();
711
+ },
634
712
  },
635
- })
713
+ );
636
714
  }
637
715
 
638
- let locatorToScreenshot: Page|Locator = page;
716
+ let locatorToScreenshot: Page | Locator = page;
639
717
  if (locator) {
640
718
  locatorToScreenshot = locator;
641
719
  }
642
720
  if (screenshotOptions.clipLocator) {
643
- await screenshotOptions.clipLocator.scrollIntoViewIfNeeded()
721
+ await screenshotOptions.clipLocator.scrollIntoViewIfNeeded();
644
722
  // Scrolling can expose lazy content. Await fonts and two paint frames
645
723
  // 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.')
724
+ await waitForFonts(page);
725
+ await page.evaluate(
726
+ () =>
727
+ new Promise<void>((resolve) =>
728
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
729
+ ),
730
+ );
731
+ const bounds = await screenshotOptions.clipLocator.boundingBox();
732
+ if (!bounds) throw new Error("clipLocator has no visible bounding box.");
651
733
  // boundingBox is in main-frame viewport coordinates, including for
652
734
  // locators inside frames. Read scroll offsets from the main page.
653
735
  const geometry = await page.evaluate(() => ({
654
- x: window.scrollX, y: window.scrollY,
655
- width: window.innerWidth, height: window.innerHeight,
656
- }))
736
+ x: window.scrollX,
737
+ y: window.scrollY,
738
+ width: window.innerWidth,
739
+ height: window.innerHeight,
740
+ }));
657
741
  const clip = {
658
742
  x: Math.round(bounds.x + (screenshotOptions.fullPage ? geometry.x : 0)),
659
743
  y: Math.round(bounds.y + (screenshotOptions.fullPage ? geometry.y : 0)),
660
744
  width: Math.round(bounds.width),
661
745
  height: Math.round(bounds.height),
662
- }
746
+ };
663
747
  if (clip.width <= 0 || clip.height <= 0) {
664
- throw new Error('clipLocator must have positive rounded dimensions.')
748
+ throw new Error("clipLocator must have positive rounded dimensions.");
665
749
  }
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.')
750
+ if (
751
+ !screenshotOptions.fullPage &&
752
+ (clip.x < 0 ||
753
+ clip.y < 0 ||
754
+ clip.x + clip.width > geometry.width ||
755
+ clip.y + clip.height > geometry.height)
756
+ ) {
757
+ throw new Error(
758
+ "clipLocator does not fit in the viewport; use fullPage: true to capture the whole target.",
759
+ );
668
760
  }
669
761
  if (clip.x < 0 || clip.y < 0) {
670
- throw new Error('clipLocator must have non-negative document coordinates.')
762
+ throw new Error(
763
+ "clipLocator must have non-negative document coordinates.",
764
+ );
671
765
  }
672
- playwrightScreenshotOptions.clip = clip
766
+ playwrightScreenshotOptions.clip = clip;
673
767
  }
674
768
  // Soft failure here so we can get accessibility violations too.
675
- await expect.soft(locatorToScreenshot).toHaveScreenshot(playwrightScreenshotOptions);
769
+ await expect
770
+ .soft(locatorToScreenshot)
771
+ .toHaveScreenshot(playwrightScreenshotOptions);
676
772
 
677
773
  // Settling a video pauses it, clears `autoplay` and rewinds it. That is only
678
774
  // wanted for the duration of the capture: a test that screenshots a page and
679
775
  // then asserts that a video is playing should still pass.
680
776
  await restoreVideoPlayback(page);
681
- videoPlaybackRestored = true
777
+ videoPlaybackRestored = true;
682
778
 
683
- await removeHoverShield?.()
684
- removeHoverShield = undefined
779
+ await removeHoverShield?.();
780
+ removeHoverShield = undefined;
685
781
 
686
- return await checkAccessibility(page, testInfo, screenshotOptions.accessibility)
782
+ return await checkAccessibility(
783
+ page,
784
+ testInfo,
785
+ screenshotOptions.accessibility,
786
+ );
687
787
  } finally {
688
788
  // Nest cleanup so a failure in one operation cannot prevent the remaining
689
789
  // browser state from being restored.
@@ -695,7 +795,7 @@ export async function takeAccessibleScreenshot(page: Page, testInfo: TestInfo, o
695
795
  await restoreVideoPlayback(page);
696
796
  }
697
797
  } finally {
698
- await cleanupInteractionStates?.()
798
+ await cleanupInteractionStates?.();
699
799
  }
700
800
  }
701
801
  }
@@ -709,37 +809,40 @@ export async function takeAccessibleScreenshot(page: Page, testInfo: TestInfo, o
709
809
  * deterministic across runs.
710
810
  */
711
811
  export function normalizeTarget(target: string | string[]): string | string[] {
712
- const uniqueHtmlID = /(#[^#]*)--\d+/
713
- const ariaLabelledById = /(aria-labelledby="[^"]+)--\d+"/
714
- if (typeof target === 'string') {
812
+ const uniqueHtmlID = /(#[^#]*)--\d+/;
813
+ const ariaLabelledById = /(aria-labelledby="[^"]+)--\d+"/;
814
+ if (typeof target === "string") {
715
815
  return target
716
- .replace(uniqueHtmlID, '$1--UNIQUE-ID')
717
- .replace(ariaLabelledById, '$1--UNIQUE-ID"')
816
+ .replace(uniqueHtmlID, "$1--UNIQUE-ID")
817
+ .replace(ariaLabelledById, '$1--UNIQUE-ID"');
718
818
  }
719
- return target
819
+ return target;
720
820
  }
721
821
 
722
822
  interface NormalizedViolation {
723
- rule: string
724
- targets: string[]
725
- description: string
726
- impact: string
727
- helpUrl: string
823
+ rule: string;
824
+ targets: string[];
825
+ description: string;
826
+ impact: string;
827
+ helpUrl: string;
728
828
  }
729
829
 
730
830
  /**
731
831
  * Extract violations from axe results and normalize their targets into
732
832
  * flat, deduplicated CSS selector strings.
733
833
  */
734
- function extractNormalizedViolations(results: axe.AxeResults): NormalizedViolation[] {
735
- return results.violations.map(violation => {
736
- const flatTargets: string[] = []
834
+ function extractNormalizedViolations(
835
+ results: axe.AxeResults,
836
+ ): NormalizedViolation[] {
837
+ return results.violations.map((violation) => {
838
+ const flatTargets: string[] = [];
737
839
  for (const node of violation.nodes) {
738
840
  for (const target of node.target) {
739
- const normalized = normalizeTarget(target)
740
- const str = typeof normalized === 'string' ? normalized : normalized.join(' ')
841
+ const normalized = normalizeTarget(target);
842
+ const str =
843
+ typeof normalized === "string" ? normalized : normalized.join(" ");
741
844
  if (!flatTargets.includes(str)) {
742
- flatTargets.push(str)
845
+ flatTargets.push(str);
743
846
  }
744
847
  }
745
848
  }
@@ -747,43 +850,46 @@ function extractNormalizedViolations(results: axe.AxeResults): NormalizedViolati
747
850
  rule: violation.id,
748
851
  targets: flatTargets,
749
852
  description: violation.description,
750
- impact: violation.impact ?? 'unknown',
853
+ impact: violation.impact ?? "unknown",
751
854
  helpUrl: violation.helpUrl,
752
- }
753
- })
855
+ };
856
+ });
754
857
  }
755
858
 
756
859
  /**
757
860
  * Format a single violation as a copy-pasteable baseline entry.
758
861
  */
759
862
  function formatBaselineSuggestion(violation: NormalizedViolation): string {
760
- const targetsStr = violation.targets.map(t => `'${t}'`).join(', ')
863
+ const targetsStr = violation.targets.map((t) => `'${t}'`).join(", ");
761
864
  return `{
762
865
  rule: '${violation.rule}',
763
866
  targets: [${targetsStr}],
764
867
  reason: '', // TODO: explain why this is accepted
765
868
  willBeFixedIn: '', // TODO: link to tracking ticket
766
- },`
869
+ },`;
767
870
  }
768
871
 
769
872
  /**
770
873
  * Format detailed failure output for unmatched violations, including
771
874
  * copy-pasteable baseline entries.
772
875
  */
773
- function formatViolationDetails(results: axe.AxeResults, violations: NormalizedViolation[]): string {
774
- const lines: string[] = []
876
+ function formatViolationDetails(
877
+ results: axe.AxeResults,
878
+ violations: NormalizedViolation[],
879
+ ): string {
880
+ const lines: string[] = [];
775
881
  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('')
882
+ const targetsStr = JSON.stringify(v.targets);
883
+ lines.push(`Accessibility violation (${v.impact}): ${v.rule}`);
884
+ lines.push(` ${v.description}`);
885
+ lines.push(` Help: ${v.helpUrl}`);
886
+ lines.push(` Targets: ${targetsStr}`);
887
+ lines.push("");
888
+ lines.push(" Add to your baseline to accept this violation:");
889
+ lines.push(" " + formatBaselineSuggestion(v).split("\n").join("\n "));
890
+ lines.push("");
785
891
  }
786
- return lines.join('\n')
892
+ return lines.join("\n");
787
893
  }
788
894
 
789
895
  /**
@@ -795,15 +901,16 @@ function formatViolationDetails(results: axe.AxeResults, violations: NormalizedV
795
901
  * @param accessibilityScanResults
796
902
  */
797
903
  function violationFingerprints(accessibilityScanResults: axe.AxeResults) {
798
- const violationFps = accessibilityScanResults.violations.map(violation => ({
904
+ const violationFps = accessibilityScanResults.violations.map((violation) => ({
799
905
  rule: violation.id,
800
906
  // These are CSS selectors which uniquely identify each element with
801
907
  // a violation of the rule in question.
802
- targets: violation.nodes.map(node => node.target.map((target) => {
803
- return normalizeTarget(target)
804
- })),
908
+ targets: violation.nodes.map((node) =>
909
+ node.target.map((target) => {
910
+ return normalizeTarget(target);
911
+ }),
912
+ ),
805
913
  }));
806
914
 
807
915
  return JSON.stringify(violationFps, null, 2);
808
-
809
916
  }