@lullabot/playwright-testing 1.0.0 → 1.0.1

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