@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,519 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.checkAccessibility = checkAccessibility;
7
+ exports.takeAccessibleScreenshot = takeAccessibleScreenshot;
8
+ exports.normalizeTarget = normalizeTarget;
9
+ const playwright_1 = __importDefault(require("@axe-core/playwright"));
10
+ const test_1 = require("@playwright/test");
11
+ const images_js_1 = require("./images.js");
12
+ const frames_js_1 = require("./frames.js");
13
+ const fonts_js_1 = require("./fonts.js");
14
+ const videos_js_1 = require("./videos.js");
15
+ const focus_js_1 = require("./focus.js");
16
+ const hover_js_1 = require("./hover.js");
17
+ const interaction_states_js_1 = require("./interaction-states.js");
18
+ const accessibility_baseline_js_1 = require("./accessibility-baseline.js");
19
+ const accessibility_baseline_file_js_1 = require("./accessibility-baseline-file.js");
20
+ let a11yActionHintShown = false;
21
+ /**
22
+ * Run accessibility checks on the current page using axe-core.
23
+ *
24
+ * Runs a best-practice scan (unless bestPracticeMode is 'off') and a WCAG scan,
25
+ * attaching JSON results and asserting on violations via snapshots.
26
+ *
27
+ * @param page The Page fixture from the test.
28
+ * @param testInfo The testInfo object from the test.
29
+ * @param options Accessibility options to customise the scan.
30
+ */
31
+ async function checkAccessibility(page, testInfo, options) {
32
+ const { wcagTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'], exclude = [], bestPracticeExclude = [], wcagExclude = [], bestPracticeMode = 'soft', rules, baseline, screenshotViolations = true, } = options ?? {};
33
+ if (process.env.CI && !a11yActionHintShown) {
34
+ console.log('Tip: Use @lullabot/playwright-testing/github to surface accessibility violations in pull requests.');
35
+ a11yActionHintShown = true;
36
+ }
37
+ // Add @a11y annotation (deduplicated).
38
+ if (!testInfo.annotations.some(a => a.type === '@a11y')) {
39
+ testInfo.annotations.push({ type: '@a11y' });
40
+ }
41
+ if (bestPracticeMode !== 'off') {
42
+ const bpResults = await runBestPracticeScan(page, testInfo, {
43
+ exclude: [...exclude, ...bestPracticeExclude],
44
+ rules,
45
+ });
46
+ // Best-practice always uses expect.soft() so the WCAG scan below runs
47
+ // even when best-practice violations exist. `bestPracticeMode === 'hard'`
48
+ // is preserved as a marker but does not change soft-vs-hard here.
49
+ await dispatchAssertion({
50
+ testInfo,
51
+ results: bpResults,
52
+ scan: 'best-practice',
53
+ expectFn: test_1.expect.soft,
54
+ scanLabel: 'Best-practice scan',
55
+ }, baseline);
56
+ }
57
+ const wcagScanResults = await runWcagScan(page, testInfo, {
58
+ wcagTags,
59
+ exclude: [...exclude, ...wcagExclude],
60
+ rules,
61
+ });
62
+ if (screenshotViolations && wcagScanResults.violations.length > 0) {
63
+ await screenshotViolatingElements(page, testInfo, wcagScanResults);
64
+ }
65
+ await dispatchAssertion({
66
+ testInfo,
67
+ results: wcagScanResults,
68
+ scan: 'wcag',
69
+ expectFn: test_1.expect,
70
+ scanLabel: 'WCAG scan',
71
+ }, baseline);
72
+ }
73
+ /**
74
+ * Dispatch the assertion for one scan to the correct mode:
75
+ *
76
+ * 1. Explicit in-code `baseline` option -> baseline mode (existing behaviour).
77
+ * 2. A snapshot file already exists on disk for this test -> snapshot mode
78
+ * (existing behaviour, preserves all previously committed snapshots).
79
+ * 3. Playwright is in explicit snapshot-update mode (`all`/`changed`, set via
80
+ * `--update-snapshots`) -> snapshot mode (Playwright will create the
81
+ * snapshot). `'missing'` is Playwright's default config value and does NOT
82
+ * indicate explicit user intent, so it falls through to baseline mode.
83
+ * 4. Otherwise -> on-disk baseline mode. If the JSON file exists, load and
84
+ * match against it. If it does not, seed it. On CI seeding fails the
85
+ * test (matching Playwright's missing-snapshot behaviour); locally,
86
+ * seeding passes so the first run is green.
87
+ */
88
+ async function dispatchAssertion(ctx, inCodeBaseline) {
89
+ if (inCodeBaseline) {
90
+ return assertBaseline(ctx, inCodeBaseline);
91
+ }
92
+ if (await (0, accessibility_baseline_file_js_1.snapshotExists)(ctx.testInfo)) {
93
+ return assertSnapshot(ctx);
94
+ }
95
+ const update = ctx.testInfo.config?.updateSnapshots;
96
+ if (update === 'all' || update === 'changed') {
97
+ return assertSnapshot(ctx);
98
+ }
99
+ const callCount = (0, accessibility_baseline_file_js_1.nextAccessibilityScanCount)(ctx.testInfo, ctx.scan);
100
+ const filePath = (0, accessibility_baseline_file_js_1.baselineFilePath)(ctx.testInfo, ctx.scan, callCount);
101
+ const existing = await (0, accessibility_baseline_file_js_1.readBaselineFile)(filePath);
102
+ if (existing) {
103
+ return assertBaseline(ctx, existing.violations);
104
+ }
105
+ // Seed and either pass (local) or fail (CI).
106
+ const normalized = extractNormalizedViolations(ctx.results);
107
+ const seedViolations = normalized.map(v => ({
108
+ rule: v.rule,
109
+ targets: v.targets,
110
+ reason: 'TODO',
111
+ willBeFixedIn: 'TODO',
112
+ }));
113
+ const seed = (0, accessibility_baseline_file_js_1.buildSeed)(seedViolations);
114
+ await (0, accessibility_baseline_file_js_1.writeBaselineFile)(filePath, seed);
115
+ await ctx.testInfo.attach(`a11y-${ctx.scan}-baseline-seed`, {
116
+ path: filePath,
117
+ contentType: 'application/json',
118
+ });
119
+ if (process.env.CI) {
120
+ 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.`;
121
+ ctx.expectFn(null, message).toBe('a11y baseline file present');
122
+ return;
123
+ }
124
+ ctx.testInfo.annotations.push({
125
+ type: 'Accessibility',
126
+ description: seed.violations.length === 0
127
+ ? `${ctx.scanLabel}: a11y baseline seeded at ${filePath} (no violations).`
128
+ : `${ctx.scanLabel}: a11y baseline seeded at ${filePath} with ${seed.violations.length} entries — fill in reason/willBeFixedIn before committing.`,
129
+ });
130
+ // A local seed is deliberately permissive and contains TODO metadata. The
131
+ // next run validates the committed file before treating entries as waivers.
132
+ }
133
+ /**
134
+ * Run the best-practice axe scan, attach results, and return them for
135
+ * dispatch. The assertion (snapshot vs baseline) is decided by the
136
+ * dispatcher based on what's already on disk and the `bestPracticeMode`
137
+ * option drives soft- vs hard-failure behaviour.
138
+ */
139
+ async function runBestPracticeScan(page, testInfo, opts) {
140
+ const builder = new playwright_1.default({ page })
141
+ .withTags(['best-practice']);
142
+ for (const selector of opts.exclude) {
143
+ builder.exclude(selector);
144
+ }
145
+ if (opts.rules) {
146
+ builder.options({ rules: opts.rules });
147
+ }
148
+ const results = await builder.analyze();
149
+ await testInfo.attach('a11y-best-practice-scan-results', {
150
+ body: JSON.stringify(results, null, 2),
151
+ contentType: 'application/json'
152
+ });
153
+ testInfo.annotations.push({
154
+ type: 'Accessibility',
155
+ description: `Best-practice scan: ${results.violations.length} violations (${results.passes.length} rules passed)`
156
+ });
157
+ return results;
158
+ }
159
+ /**
160
+ * Run the WCAG axe scan, attach results, and return them for assertion.
161
+ */
162
+ async function runWcagScan(page, testInfo, opts) {
163
+ const builder = new playwright_1.default({ page })
164
+ .withTags(opts.wcagTags);
165
+ for (const selector of opts.exclude) {
166
+ builder.exclude(selector);
167
+ }
168
+ if (opts.rules) {
169
+ builder.options({ rules: opts.rules });
170
+ }
171
+ const results = await builder.analyze();
172
+ await testInfo.attach('a11y-wcag-scan-results', {
173
+ body: JSON.stringify(results, null, 2),
174
+ contentType: 'application/json'
175
+ });
176
+ return results;
177
+ }
178
+ /**
179
+ * Take a full-page screenshot with violating elements highlighted and
180
+ * attach it to the test report.
181
+ */
182
+ async function screenshotViolatingElements(page, testInfo, results) {
183
+ // Collect all raw CSS selectors from violation nodes.
184
+ const selectors = results.violations
185
+ .flatMap(v => v.nodes)
186
+ .flatMap(n => n.target)
187
+ .filter((t) => typeof t === 'string');
188
+ if (selectors.length === 0)
189
+ return;
190
+ // Inject highlight outlines on all violating elements.
191
+ await page.evaluate((sels) => {
192
+ const style = document.createElement('style');
193
+ style.setAttribute('data-a11y-highlight', 'true');
194
+ // Use a CSS rule for each selector so the outline persists even if
195
+ // elements are repositioned during the screenshot.
196
+ const rules = sels.map(s => `${s} { outline: 3px solid #e53e3e !important; outline-offset: 2px !important; }`).join('\n');
197
+ style.textContent = rules;
198
+ document.head.appendChild(style);
199
+ }, selectors);
200
+ try {
201
+ const screenshot = await page.screenshot({ fullPage: true });
202
+ await testInfo.attach('a11y-violation-screenshot', {
203
+ body: screenshot,
204
+ contentType: 'image/png',
205
+ });
206
+ }
207
+ finally {
208
+ // Remove the injected styles even if capture or report attachment fails.
209
+ await page.evaluate(() => {
210
+ document.querySelector('style[data-a11y-highlight]')?.remove();
211
+ });
212
+ }
213
+ }
214
+ /**
215
+ * Assert violations against a baseline allowlist (in-code or on-disk).
216
+ */
217
+ function assertBaseline(ctx, baseline) {
218
+ (0, accessibility_baseline_js_1.validateAccessibilityBaseline)(baseline);
219
+ const { testInfo, results, scanLabel, expectFn } = ctx;
220
+ const allViolations = extractNormalizedViolations(results);
221
+ const matchedBaselineIndices = new Set();
222
+ const unmatchedViolations = [];
223
+ for (const violation of allViolations) {
224
+ const baselineIndex = baseline.findIndex((entry) => {
225
+ if (entry.rule !== violation.rule)
226
+ return false;
227
+ // Check for at least one overlapping normalized target.
228
+ return entry.targets.some(baselineTarget => violation.targets.some(violationTarget => violationTarget === baselineTarget));
229
+ });
230
+ if (baselineIndex >= 0) {
231
+ matchedBaselineIndices.add(baselineIndex);
232
+ const entry = baseline[baselineIndex];
233
+ testInfo.annotations.push({
234
+ type: 'Baselined a11y violation',
235
+ description: `${entry.rule}: ${entry.reason} — ${entry.willBeFixedIn}`,
236
+ });
237
+ }
238
+ else {
239
+ unmatchedViolations.push(violation);
240
+ }
241
+ }
242
+ // Report stale baseline entries.
243
+ baseline.forEach((entry, idx) => {
244
+ if (!matchedBaselineIndices.has(idx)) {
245
+ testInfo.annotations.push({
246
+ type: 'Stale a11y baseline entry',
247
+ description: `${entry.rule} on ${entry.targets.join(', ')} — no longer detected`,
248
+ });
249
+ }
250
+ });
251
+ // Summary annotation for baseline mode.
252
+ const baselinedCount = matchedBaselineIndices.size;
253
+ testInfo.annotations.push({
254
+ type: 'Accessibility',
255
+ description: `${scanLabel}: ${unmatchedViolations.length} new violations (${baselinedCount} baselined)`,
256
+ });
257
+ // Fail on unmatched violations with detailed output.
258
+ if (unmatchedViolations.length > 0) {
259
+ const details = formatViolationDetails(results, unmatchedViolations);
260
+ expectFn(null, details).toBe('no accessibility violations');
261
+ }
262
+ }
263
+ /**
264
+ * Assert via snapshot comparison (legacy mode for tests with committed snapshots).
265
+ */
266
+ async function assertSnapshot(ctx) {
267
+ const { testInfo, results, scan, expectFn } = ctx;
268
+ // Match the legacy summary annotation phrasing for WCAG; best-practice's
269
+ // pre-existing summary annotation is emitted in runBestPracticeScan.
270
+ if (scan === 'wcag') {
271
+ testInfo.annotations.push({
272
+ type: 'Accessibility',
273
+ description: `WCAG scan: ${results.violations.length} violations (${results.passes.length} rules passed)`
274
+ });
275
+ // If there are violations, attach baseline suggestions and push annotation.
276
+ if (results.violations.length > 0) {
277
+ const allViolations = extractNormalizedViolations(results);
278
+ const suggestions = allViolations.map(v => formatBaselineSuggestion(v)).join('\n');
279
+ await testInfo.attach('a11y-baseline-suggestions', {
280
+ body: suggestions,
281
+ contentType: 'text/plain',
282
+ });
283
+ testInfo.annotations.push({
284
+ type: 'Accessibility',
285
+ description: 'To manage violations explicitly, switch to baseline mode. See a11y-baseline-suggestions attachment.',
286
+ });
287
+ }
288
+ }
289
+ return expectFn(violationFingerprints(results)).toMatchSnapshot();
290
+ }
291
+ /**
292
+ * Take a visual comparison, and also ensure there's no accessibility issues.
293
+ *
294
+ * @param page The Page fixture from the test.
295
+ * @param testInfo The testInfo object from the test.
296
+ * @param options Screenshot options from toHaveScreenshot().
297
+ * @param scrollLocator A locator to ensure is visible before taking the screenshot.
298
+ * @param locator A specific locator to take the screenshot of. aXe still checks the whole page.
299
+ */
300
+ async function takeAccessibleScreenshot(page, testInfo, options, scrollLocator, locator) {
301
+ const screenshotOptions = options ?? {};
302
+ if (screenshotOptions.clipLocator && (screenshotOptions.clip || (locator && locator !== page))) {
303
+ throw new Error('clipLocator cannot be combined with clip or a locator screenshot target.');
304
+ }
305
+ if (screenshotOptions.clipLocator && screenshotOptions.clipLocator.page() !== page) {
306
+ throw new Error('clipLocator must belong to the screenshot page.');
307
+ }
308
+ // The default is 5 seconds. However, even on a fast machine it can take
309
+ // longer than 5 seconds for large pages like node forms to stabilize. This
310
+ // doesn't affect end users because the page still being rendered is
311
+ // typically below the viewport, and it's loaded by the time they scroll.
312
+ // So, we set this to at least 10 seconds, unless it's already larger.
313
+ // To test changing this, try running this command and see if it times out:
314
+ const interactionStates = screenshotOptions.interactionStates ?? [];
315
+ (0, interaction_states_js_1.validateInteractionStates)(interactionStates);
316
+ // Do not pass package-specific orchestration options to Playwright's matcher.
317
+ const { clipLocator: _clipLocator, accessibility: _accessibility, blur: _blur, clearHover: _clearHover, interactionStates: _interactionStates, stabilization, ...nativeScreenshotOptions } = screenshotOptions;
318
+ const playwrightScreenshotOptions = {
319
+ ...nativeScreenshotOptions,
320
+ timeout: Math.max(nativeScreenshotOptions.timeout ?? 0, 10000),
321
+ };
322
+ // Blur any focused element so a stray focus ring does not make the screenshot
323
+ // non-deterministic, unless the caller is intentionally capturing focus. Do
324
+ // this before the load/stability waits so that any layout change a blur
325
+ // handler triggers (a closing dropdown, injected validation markup) settles
326
+ // before we capture.
327
+ if (screenshotOptions.blur !== false) {
328
+ await (0, focus_js_1.blurActiveElement)(page);
329
+ }
330
+ let removeHoverShield = screenshotOptions.clearHover === false ? undefined : await (0, hover_js_1.clearHover)(page);
331
+ let videoPlaybackRestored = false;
332
+ let cleanupInteractionStates;
333
+ try {
334
+ await (0, frames_js_1.waitForFrames)(page);
335
+ // Loading lazy frames can scroll the page. Load images afterwards so that
336
+ // any images exposed by that scrolling are settled and waitForAllImages()
337
+ // restores the viewport to the top before capture.
338
+ await (0, images_js_1.waitForAllImages)(page, stabilization?.images);
339
+ await (0, fonts_js_1.waitForFonts)(page);
340
+ // Last of the waits, so the video frames it composites are as fresh as
341
+ // possible when the capture happens. It restores the scroll position it
342
+ // found, so it does not disturb where the waits above leave the page.
343
+ await (0, videos_js_1.waitForVideos)(page, stabilization?.videos);
344
+ if (scrollLocator) {
345
+ await scrollLocator.scrollIntoViewIfNeeded();
346
+ }
347
+ // A hover action cannot hit its target through the transparent shield used
348
+ // to clear incidental hover. Remove it only after all stability waits, then
349
+ // apply real hover before real focus so focusing cannot disturb the pointer.
350
+ const hasHover = interactionStates.some(({ states }) => states.includes('hover'));
351
+ if (hasHover) {
352
+ await removeHoverShield?.();
353
+ removeHoverShield = undefined;
354
+ }
355
+ if (interactionStates.length > 0) {
356
+ cleanupInteractionStates = await (0, interaction_states_js_1.applyInteractionStates)(interactionStates, {
357
+ clearHover: async () => {
358
+ const removeInteractionShield = await (0, hover_js_1.clearHover)(page);
359
+ await removeInteractionShield();
360
+ },
361
+ });
362
+ }
363
+ let locatorToScreenshot = page;
364
+ if (locator) {
365
+ locatorToScreenshot = locator;
366
+ }
367
+ if (screenshotOptions.clipLocator) {
368
+ await screenshotOptions.clipLocator.scrollIntoViewIfNeeded();
369
+ // Scrolling can expose lazy content. Await fonts and two paint frames
370
+ // before measuring; the existing waits have already loaded page media.
371
+ await (0, fonts_js_1.waitForFonts)(page);
372
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
373
+ const bounds = await screenshotOptions.clipLocator.boundingBox();
374
+ if (!bounds)
375
+ throw new Error('clipLocator has no visible bounding box.');
376
+ // boundingBox is in main-frame viewport coordinates, including for
377
+ // locators inside frames. Read scroll offsets from the main page.
378
+ const geometry = await page.evaluate(() => ({
379
+ x: window.scrollX, y: window.scrollY,
380
+ width: window.innerWidth, height: window.innerHeight,
381
+ }));
382
+ const clip = {
383
+ x: Math.round(bounds.x + (screenshotOptions.fullPage ? geometry.x : 0)),
384
+ y: Math.round(bounds.y + (screenshotOptions.fullPage ? geometry.y : 0)),
385
+ width: Math.round(bounds.width),
386
+ height: Math.round(bounds.height),
387
+ };
388
+ if (clip.width <= 0 || clip.height <= 0) {
389
+ throw new Error('clipLocator must have positive rounded dimensions.');
390
+ }
391
+ if (!screenshotOptions.fullPage && (clip.x < 0 || clip.y < 0 || clip.x + clip.width > geometry.width || clip.y + clip.height > geometry.height)) {
392
+ throw new Error('clipLocator does not fit in the viewport; use fullPage: true to capture the whole target.');
393
+ }
394
+ if (clip.x < 0 || clip.y < 0) {
395
+ throw new Error('clipLocator must have non-negative document coordinates.');
396
+ }
397
+ playwrightScreenshotOptions.clip = clip;
398
+ }
399
+ // Soft failure here so we can get accessibility violations too.
400
+ await test_1.expect.soft(locatorToScreenshot).toHaveScreenshot(playwrightScreenshotOptions);
401
+ // Settling a video pauses it, clears `autoplay` and rewinds it. That is only
402
+ // wanted for the duration of the capture: a test that screenshots a page and
403
+ // then asserts that a video is playing should still pass.
404
+ await (0, videos_js_1.restoreVideoPlayback)(page);
405
+ videoPlaybackRestored = true;
406
+ await removeHoverShield?.();
407
+ removeHoverShield = undefined;
408
+ return await checkAccessibility(page, testInfo, screenshotOptions.accessibility);
409
+ }
410
+ finally {
411
+ // Nest cleanup so a failure in one operation cannot prevent the remaining
412
+ // browser state from being restored.
413
+ try {
414
+ await removeHoverShield?.();
415
+ }
416
+ finally {
417
+ try {
418
+ if (!videoPlaybackRestored) {
419
+ await (0, videos_js_1.restoreVideoPlayback)(page);
420
+ }
421
+ }
422
+ finally {
423
+ await cleanupInteractionStates?.();
424
+ }
425
+ }
426
+ }
427
+ }
428
+ /**
429
+ * Normalize a single CSS selector target for stable comparison.
430
+ *
431
+ * Replaces unique numeric HTML IDs and aria-labelledby suffixes with
432
+ * a stable placeholder so that snapshots and baseline matching are
433
+ * deterministic across runs.
434
+ */
435
+ function normalizeTarget(target) {
436
+ const uniqueHtmlID = /(#[^#]*)--\d+/;
437
+ const ariaLabelledById = /(aria-labelledby="[^"]+)--\d+"/;
438
+ if (typeof target === 'string') {
439
+ return target
440
+ .replace(uniqueHtmlID, '$1--UNIQUE-ID')
441
+ .replace(ariaLabelledById, '$1--UNIQUE-ID"');
442
+ }
443
+ return target;
444
+ }
445
+ /**
446
+ * Extract violations from axe results and normalize their targets into
447
+ * flat, deduplicated CSS selector strings.
448
+ */
449
+ function extractNormalizedViolations(results) {
450
+ return results.violations.map(violation => {
451
+ const flatTargets = [];
452
+ for (const node of violation.nodes) {
453
+ for (const target of node.target) {
454
+ const normalized = normalizeTarget(target);
455
+ const str = typeof normalized === 'string' ? normalized : normalized.join(' ');
456
+ if (!flatTargets.includes(str)) {
457
+ flatTargets.push(str);
458
+ }
459
+ }
460
+ }
461
+ return {
462
+ rule: violation.id,
463
+ targets: flatTargets,
464
+ description: violation.description,
465
+ impact: violation.impact ?? 'unknown',
466
+ helpUrl: violation.helpUrl,
467
+ };
468
+ });
469
+ }
470
+ /**
471
+ * Format a single violation as a copy-pasteable baseline entry.
472
+ */
473
+ function formatBaselineSuggestion(violation) {
474
+ const targetsStr = violation.targets.map(t => `'${t}'`).join(', ');
475
+ return `{
476
+ rule: '${violation.rule}',
477
+ targets: [${targetsStr}],
478
+ reason: '', // TODO: explain why this is accepted
479
+ willBeFixedIn: '', // TODO: link to tracking ticket
480
+ },`;
481
+ }
482
+ /**
483
+ * Format detailed failure output for unmatched violations, including
484
+ * copy-pasteable baseline entries.
485
+ */
486
+ function formatViolationDetails(results, violations) {
487
+ const lines = [];
488
+ for (const v of violations) {
489
+ const targetsStr = JSON.stringify(v.targets);
490
+ lines.push(`Accessibility violation (${v.impact}): ${v.rule}`);
491
+ lines.push(` ${v.description}`);
492
+ lines.push(` Help: ${v.helpUrl}`);
493
+ lines.push(` Targets: ${targetsStr}`);
494
+ lines.push('');
495
+ lines.push(' Add to your baseline to accept this violation:');
496
+ lines.push(' ' + formatBaselineSuggestion(v).split('\n').join('\n '));
497
+ lines.push('');
498
+ }
499
+ return lines.join('\n');
500
+ }
501
+ /**
502
+ * Filter violations down to stable elements.
503
+ *
504
+ * If we try to create a snapshot of the entire report, it will fail on random
505
+ * unique HTML IDs.
506
+ *
507
+ * @param accessibilityScanResults
508
+ */
509
+ function violationFingerprints(accessibilityScanResults) {
510
+ const violationFps = accessibilityScanResults.violations.map(violation => ({
511
+ rule: violation.id,
512
+ // These are CSS selectors which uniquely identify each element with
513
+ // a violation of the rule in question.
514
+ targets: violation.nodes.map(node => node.target.map((target) => {
515
+ return normalizeTarget(target);
516
+ })),
517
+ }));
518
+ return JSON.stringify(violationFps, null, 2);
519
+ }
package/lib/focus.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { Page } from "@playwright/test";
2
+ /**
3
+ * Remove keyboard focus from the active element.
4
+ *
5
+ * A focused form control paints a focus outline/ring. Whether an element still
6
+ * has focus when the screenshot is taken depends on test timing, so that ring
7
+ * appears in some runs and not others, producing a visual diff that is invisible
8
+ * to a human but trips the strict pixel budget. Blurring the active element
9
+ * first makes the focus state deterministic.
10
+ *
11
+ * @param page
12
+ * @returns `true` if an element was blurred, `false` if nothing was focused.
13
+ */
14
+ export declare function blurActiveElement(page: Page): Promise<boolean>;
package/lib/focus.js ADDED
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.blurActiveElement = blurActiveElement;
4
+ /**
5
+ * Remove keyboard focus from the active element.
6
+ *
7
+ * A focused form control paints a focus outline/ring. Whether an element still
8
+ * has focus when the screenshot is taken depends on test timing, so that ring
9
+ * appears in some runs and not others, producing a visual diff that is invisible
10
+ * to a human but trips the strict pixel budget. Blurring the active element
11
+ * first makes the focus state deterministic.
12
+ *
13
+ * @param page
14
+ * @returns `true` if an element was blurred, `false` if nothing was focused.
15
+ */
16
+ async function blurActiveElement(page) {
17
+ return page.evaluate(() => {
18
+ const el = document.activeElement;
19
+ // `document.activeElement` falls back to `<body>`/`<html>` when nothing is
20
+ // focused. Blurring that paints no focus ring but can still fire `focusout`
21
+ // handlers, so skip it and only blur a genuinely focused control.
22
+ if (!el || el === document.body || el === document.documentElement || typeof el.blur !== "function") {
23
+ return false;
24
+ }
25
+ el.blur();
26
+ return true;
27
+ });
28
+ }
package/lib/fonts.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { Page } from "@playwright/test";
2
+ /**
3
+ * Wait for all web fonts to finish loading.
4
+ *
5
+ * Visual snapshots can flake when text is first painted with fallback font
6
+ * metrics before the web fonts finish loading. The fallback glyphs have
7
+ * different widths, so text wraps onto a different number of lines and the page
8
+ * renders at a slightly different height than it does once the real fonts are
9
+ * applied. By the time Playwright retries the layout has settled, which is why
10
+ * these failures usually only show up on the first attempt.
11
+ *
12
+ * Awaiting `document.fonts.ready` blocks until every font face the page has
13
+ * requested has finished loading (or failed), so the screenshot always captures
14
+ * the settled, web-font layout.
15
+ *
16
+ * @param page
17
+ */
18
+ export declare function waitForFonts(page: Page): Promise<void>;
package/lib/fonts.js ADDED
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.waitForFonts = waitForFonts;
4
+ /**
5
+ * Wait for all web fonts to finish loading.
6
+ *
7
+ * Visual snapshots can flake when text is first painted with fallback font
8
+ * metrics before the web fonts finish loading. The fallback glyphs have
9
+ * different widths, so text wraps onto a different number of lines and the page
10
+ * renders at a slightly different height than it does once the real fonts are
11
+ * applied. By the time Playwright retries the layout has settled, which is why
12
+ * these failures usually only show up on the first attempt.
13
+ *
14
+ * Awaiting `document.fonts.ready` blocks until every font face the page has
15
+ * requested has finished loading (or failed), so the screenshot always captures
16
+ * the settled, web-font layout.
17
+ *
18
+ * @param page
19
+ */
20
+ async function waitForFonts(page) {
21
+ await page.evaluate(async () => {
22
+ await document.fonts.ready;
23
+ });
24
+ }
@@ -0,0 +1,7 @@
1
+ import { Page } from "@playwright/test";
2
+ /**
3
+ * Wait for all frames to load.
4
+ *
5
+ * @param page
6
+ */
7
+ export declare function waitForFrames(page: Page): Promise<void>;
package/lib/frames.js ADDED
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.waitForFrames = waitForFrames;
4
+ /**
5
+ * Wait for all frames to load.
6
+ *
7
+ * @param page
8
+ */
9
+ async function waitForFrames(page) {
10
+ const locators = await page.locator('iframe');
11
+ // Trigger lazy-loading iframes. Since this should be fast, and we don't want to
12
+ // have to deal with concurrency bugs, we do this in serial.
13
+ for (const locator of await locators.all()) {
14
+ // Ensure iframes are connected to the DOM before trying to scroll to them.
15
+ // https://github.com/microsoft/playwright/issues/23758
16
+ if (await locator.evaluate(iframe => iframe.isConnected)) {
17
+ if (await locator.isVisible()) {
18
+ await locator.scrollIntoViewIfNeeded();
19
+ await locator.scrollIntoViewIfNeeded();
20
+ const element = await locator.elementHandle();
21
+ const frame = await element?.contentFrame();
22
+ await frame?.waitForURL(new RegExp('.*/.*', 'i'));
23
+ await frame?.waitForLoadState('load');
24
+ }
25
+ }
26
+ }
27
+ }