@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.
- package/README.md +61 -0
- package/bin/github-a11y-summary +5 -0
- package/bin/github-failure-summary +8 -0
- package/lib/accessibility-baseline-file.d.ts +47 -0
- package/lib/accessibility-baseline-file.js +205 -0
- package/lib/accessibility-baseline.d.ts +19 -0
- package/lib/accessibility-baseline.js +38 -0
- package/lib/accessible-screenshot.d.ts +181 -0
- package/lib/accessible-screenshot.js +519 -0
- package/lib/focus.d.ts +14 -0
- package/lib/focus.js +28 -0
- package/lib/fonts.d.ts +18 -0
- package/lib/fonts.js +24 -0
- package/lib/frames.d.ts +7 -0
- package/lib/frames.js +27 -0
- package/lib/github/a11y-summary.d.ts +55 -0
- package/lib/github/a11y-summary.js +383 -0
- package/lib/github/attachments.d.ts +98 -0
- package/lib/github/attachments.js +297 -0
- package/lib/github/failure-summary.d.ts +144 -0
- package/lib/github/failure-summary.js +567 -0
- package/lib/github/index.d.ts +6 -0
- package/lib/github/index.js +35 -0
- package/lib/github/report-paths.d.ts +38 -0
- package/lib/github/report-paths.js +200 -0
- package/lib/hover.d.ts +13 -0
- package/lib/hover.js +61 -0
- package/lib/images.d.ts +118 -0
- package/lib/images.js +260 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +29 -0
- package/lib/interaction-states.d.ts +22 -0
- package/lib/interaction-states.js +75 -0
- package/lib/mock/index.d.ts +1 -0
- package/lib/mock/index.js +5 -0
- package/lib/mock/youtube.d.ts +5 -0
- package/lib/mock/youtube.js +38 -0
- package/lib/pseudo-state.d.ts +17 -0
- package/lib/pseudo-state.js +50 -0
- package/lib/videos.d.ts +134 -0
- package/lib/videos.js +349 -0
- package/lib/visualdiff.d.ts +154 -0
- package/lib/visualdiff.js +197 -0
- package/package.json +47 -0
- package/src/accessibility-baseline-file.test.ts +181 -0
- package/src/accessibility-baseline-file.ts +208 -0
- package/src/accessibility-baseline.test.ts +601 -0
- package/src/accessibility-baseline.ts +50 -0
- package/src/accessible-screenshot.test.ts +597 -0
- package/src/accessible-screenshot.ts +809 -0
- package/src/focus.test.ts +34 -0
- package/src/focus.ts +27 -0
- package/src/fonts.test.ts +17 -0
- package/src/fonts.ts +23 -0
- package/src/frames.test.ts +75 -0
- package/src/frames.ts +26 -0
- package/src/github/a11y-summary.test.ts +439 -0
- package/src/github/a11y-summary.ts +421 -0
- package/src/github/attachments.test.ts +248 -0
- package/src/github/attachments.ts +328 -0
- package/src/github/failure-summary.test.ts +636 -0
- package/src/github/failure-summary.ts +720 -0
- package/src/github/index.test.ts +24 -0
- package/src/github/index.ts +35 -0
- package/src/github/report-paths.test.ts +222 -0
- package/src/github/report-paths.ts +208 -0
- package/src/hover.test.ts +76 -0
- package/src/hover.ts +64 -0
- package/src/images.test.ts +355 -0
- package/src/images.ts +299 -0
- package/src/index.ts +13 -0
- package/src/interaction-states.test.ts +48 -0
- package/src/interaction-states.ts +94 -0
- package/src/mock/index.ts +1 -0
- package/src/mock/youtube.test.ts +38 -0
- package/src/mock/youtube.ts +39 -0
- package/src/pseudo-state.test.ts +83 -0
- package/src/pseudo-state.ts +69 -0
- package/src/videos.test.ts +637 -0
- package/src/videos.ts +389 -0
- package/src/visualdiff.test.ts +452 -0
- 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
|
+
}
|
package/lib/frames.d.ts
ADDED
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
|
+
}
|