@lullabot/playwright-testing 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/github-a11y-summary +2 -2
- package/bin/github-failure-summary +4 -4
- package/lib/accessibility-baseline-file.d.ts +6 -6
- package/lib/accessibility-baseline-file.js +29 -23
- package/lib/accessibility-baseline.js +5 -3
- package/lib/accessible-screenshot.d.ts +4 -4
- package/lib/accessible-screenshot.js +110 -88
- package/lib/focus.js +4 -1
- package/lib/frames.js +4 -4
- package/lib/github/a11y-summary.js +74 -79
- package/lib/github/attachments.d.ts +1 -1
- package/lib/github/attachments.js +36 -34
- package/lib/github/failure-summary.d.ts +4 -4
- package/lib/github/failure-summary.js +140 -108
- package/lib/github/index.d.ts +6 -6
- package/lib/github/report-paths.js +17 -9
- package/lib/hover.js +17 -17
- package/lib/images.js +15 -12
- package/lib/index.d.ts +13 -13
- package/lib/interaction-states.d.ts +2 -2
- package/lib/interaction-states.js +7 -7
- package/lib/mock/index.d.ts +1 -1
- package/lib/mock/youtube.d.ts +1 -1
- package/lib/mock/youtube.js +1 -1
- package/lib/pseudo-state.d.ts +2 -2
- package/lib/pseudo-state.js +15 -7
- package/lib/videos.js +21 -10
- package/lib/visualdiff.d.ts +4 -4
- package/lib/visualdiff.js +35 -27
- package/package.json +3 -3
- package/src/accessibility-baseline-file.test.ts +190 -154
- package/src/accessibility-baseline-file.ts +107 -83
- package/src/accessibility-baseline.test.ts +869 -433
- package/src/accessibility-baseline.ts +41 -21
- package/src/accessible-screenshot.test.ts +551 -423
- package/src/accessible-screenshot.ts +358 -251
- package/src/focus.test.ts +23 -23
- package/src/focus.ts +7 -2
- package/src/fonts.test.ts +13 -13
- package/src/fonts.ts +1 -1
- package/src/frames.test.ts +10 -8
- package/src/frames.ts +5 -5
- package/src/github/a11y-summary.test.ts +436 -332
- package/src/github/a11y-summary.ts +189 -179
- package/src/github/attachments.test.ts +228 -167
- package/src/github/attachments.ts +144 -121
- package/src/github/failure-summary.test.ts +660 -445
- package/src/github/failure-summary.ts +376 -274
- package/src/github/index.test.ts +14 -14
- package/src/github/index.ts +6 -6
- package/src/github/report-paths.test.ts +211 -156
- package/src/github/report-paths.ts +75 -61
- package/src/hover.test.ts +50 -46
- package/src/hover.ts +18 -18
- package/src/images.test.ts +251 -206
- package/src/images.ts +52 -43
- package/src/index.ts +13 -13
- package/src/interaction-states.test.ts +72 -42
- package/src/interaction-states.ts +43 -29
- package/src/mock/index.ts +1 -1
- package/src/mock/youtube.test.ts +26 -26
- package/src/mock/youtube.ts +4 -6
- package/src/pseudo-state.test.ts +58 -51
- package/src/pseudo-state.ts +32 -27
- package/src/videos.test.ts +559 -399
- package/src/videos.ts +68 -44
- package/src/visualdiff.test.ts +416 -315
- package/src/visualdiff.ts +140 -99
|
@@ -29,16 +29,19 @@ let a11yActionHintShown = false;
|
|
|
29
29
|
* @param options Accessibility options to customise the scan.
|
|
30
30
|
*/
|
|
31
31
|
async function checkAccessibility(page, testInfo, options) {
|
|
32
|
-
const { wcagTags = [
|
|
32
|
+
const { wcagTags = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"], exclude = [], bestPracticeExclude = [], wcagExclude = [], bestPracticeMode = "soft", rules, baseline, screenshotViolations = true, } = options ?? {};
|
|
33
33
|
if (process.env.CI && !a11yActionHintShown) {
|
|
34
|
-
console.log(
|
|
34
|
+
console.log("Tip: Use @lullabot/playwright-testing/github to surface accessibility violations in pull requests.");
|
|
35
35
|
a11yActionHintShown = true;
|
|
36
36
|
}
|
|
37
37
|
// Add @a11y annotation (deduplicated).
|
|
38
|
-
if (!testInfo.annotations.some(a => a.type ===
|
|
39
|
-
testInfo.annotations.push({ type:
|
|
38
|
+
if (!testInfo.annotations.some((a) => a.type === "@a11y")) {
|
|
39
|
+
testInfo.annotations.push({ type: "@a11y" });
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
// An in-code baseline is shared by both scans. Track matches across this
|
|
42
|
+
// check so that a rule detected by either scan is not reported as stale.
|
|
43
|
+
const sharedBaselineMatches = new Set();
|
|
44
|
+
if (bestPracticeMode !== "off") {
|
|
42
45
|
const bpResults = await runBestPracticeScan(page, testInfo, {
|
|
43
46
|
exclude: [...exclude, ...bestPracticeExclude],
|
|
44
47
|
rules,
|
|
@@ -49,10 +52,10 @@ async function checkAccessibility(page, testInfo, options) {
|
|
|
49
52
|
await dispatchAssertion({
|
|
50
53
|
testInfo,
|
|
51
54
|
results: bpResults,
|
|
52
|
-
scan:
|
|
55
|
+
scan: "best-practice",
|
|
53
56
|
expectFn: test_1.expect.soft,
|
|
54
|
-
scanLabel:
|
|
55
|
-
}, baseline);
|
|
57
|
+
scanLabel: "Best-practice scan",
|
|
58
|
+
}, baseline, sharedBaselineMatches);
|
|
56
59
|
}
|
|
57
60
|
const wcagScanResults = await runWcagScan(page, testInfo, {
|
|
58
61
|
wcagTags,
|
|
@@ -65,10 +68,10 @@ async function checkAccessibility(page, testInfo, options) {
|
|
|
65
68
|
await dispatchAssertion({
|
|
66
69
|
testInfo,
|
|
67
70
|
results: wcagScanResults,
|
|
68
|
-
scan:
|
|
71
|
+
scan: "wcag",
|
|
69
72
|
expectFn: test_1.expect,
|
|
70
|
-
scanLabel:
|
|
71
|
-
}, baseline);
|
|
73
|
+
scanLabel: "WCAG scan",
|
|
74
|
+
}, baseline, sharedBaselineMatches);
|
|
72
75
|
}
|
|
73
76
|
/**
|
|
74
77
|
* Dispatch the assertion for one scan to the correct mode:
|
|
@@ -85,15 +88,15 @@ async function checkAccessibility(page, testInfo, options) {
|
|
|
85
88
|
* test (matching Playwright's missing-snapshot behaviour); locally,
|
|
86
89
|
* seeding passes so the first run is green.
|
|
87
90
|
*/
|
|
88
|
-
async function dispatchAssertion(ctx, inCodeBaseline) {
|
|
91
|
+
async function dispatchAssertion(ctx, inCodeBaseline, sharedBaselineMatches) {
|
|
89
92
|
if (inCodeBaseline) {
|
|
90
|
-
return assertBaseline(ctx, inCodeBaseline);
|
|
93
|
+
return assertBaseline(ctx, inCodeBaseline, sharedBaselineMatches);
|
|
91
94
|
}
|
|
92
95
|
if (await (0, accessibility_baseline_file_js_1.snapshotExists)(ctx.testInfo)) {
|
|
93
96
|
return assertSnapshot(ctx);
|
|
94
97
|
}
|
|
95
98
|
const update = ctx.testInfo.config?.updateSnapshots;
|
|
96
|
-
if (update ===
|
|
99
|
+
if (update === "all" || update === "changed") {
|
|
97
100
|
return assertSnapshot(ctx);
|
|
98
101
|
}
|
|
99
102
|
const callCount = (0, accessibility_baseline_file_js_1.nextAccessibilityScanCount)(ctx.testInfo, ctx.scan);
|
|
@@ -104,25 +107,25 @@ async function dispatchAssertion(ctx, inCodeBaseline) {
|
|
|
104
107
|
}
|
|
105
108
|
// Seed and either pass (local) or fail (CI).
|
|
106
109
|
const normalized = extractNormalizedViolations(ctx.results);
|
|
107
|
-
const seedViolations = normalized.map(v => ({
|
|
110
|
+
const seedViolations = normalized.map((v) => ({
|
|
108
111
|
rule: v.rule,
|
|
109
112
|
targets: v.targets,
|
|
110
|
-
reason:
|
|
111
|
-
willBeFixedIn:
|
|
113
|
+
reason: "TODO",
|
|
114
|
+
willBeFixedIn: "TODO",
|
|
112
115
|
}));
|
|
113
116
|
const seed = (0, accessibility_baseline_file_js_1.buildSeed)(seedViolations);
|
|
114
117
|
await (0, accessibility_baseline_file_js_1.writeBaselineFile)(filePath, seed);
|
|
115
118
|
await ctx.testInfo.attach(`a11y-${ctx.scan}-baseline-seed`, {
|
|
116
119
|
path: filePath,
|
|
117
|
-
contentType:
|
|
120
|
+
contentType: "application/json",
|
|
118
121
|
});
|
|
119
122
|
if (process.env.CI) {
|
|
120
123
|
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(
|
|
124
|
+
ctx.expectFn(null, message).toBe("a11y baseline file present");
|
|
122
125
|
return;
|
|
123
126
|
}
|
|
124
127
|
ctx.testInfo.annotations.push({
|
|
125
|
-
type:
|
|
128
|
+
type: "Accessibility",
|
|
126
129
|
description: seed.violations.length === 0
|
|
127
130
|
? `${ctx.scanLabel}: a11y baseline seeded at ${filePath} (no violations).`
|
|
128
131
|
: `${ctx.scanLabel}: a11y baseline seeded at ${filePath} with ${seed.violations.length} entries — fill in reason/willBeFixedIn before committing.`,
|
|
@@ -137,8 +140,7 @@ async function dispatchAssertion(ctx, inCodeBaseline) {
|
|
|
137
140
|
* option drives soft- vs hard-failure behaviour.
|
|
138
141
|
*/
|
|
139
142
|
async function runBestPracticeScan(page, testInfo, opts) {
|
|
140
|
-
const builder = new playwright_1.default({ page })
|
|
141
|
-
.withTags(['best-practice']);
|
|
143
|
+
const builder = new playwright_1.default({ page }).withTags(["best-practice"]);
|
|
142
144
|
for (const selector of opts.exclude) {
|
|
143
145
|
builder.exclude(selector);
|
|
144
146
|
}
|
|
@@ -146,13 +148,13 @@ async function runBestPracticeScan(page, testInfo, opts) {
|
|
|
146
148
|
builder.options({ rules: opts.rules });
|
|
147
149
|
}
|
|
148
150
|
const results = await builder.analyze();
|
|
149
|
-
await testInfo.attach(
|
|
151
|
+
await testInfo.attach("a11y-best-practice-scan-results", {
|
|
150
152
|
body: JSON.stringify(results, null, 2),
|
|
151
|
-
contentType:
|
|
153
|
+
contentType: "application/json",
|
|
152
154
|
});
|
|
153
155
|
testInfo.annotations.push({
|
|
154
|
-
type:
|
|
155
|
-
description: `Best-practice scan: ${results.violations.length} violations (${results.passes.length} rules passed)
|
|
156
|
+
type: "Accessibility",
|
|
157
|
+
description: `Best-practice scan: ${results.violations.length} violations (${results.passes.length} rules passed)`,
|
|
156
158
|
});
|
|
157
159
|
return results;
|
|
158
160
|
}
|
|
@@ -160,8 +162,7 @@ async function runBestPracticeScan(page, testInfo, opts) {
|
|
|
160
162
|
* Run the WCAG axe scan, attach results, and return them for assertion.
|
|
161
163
|
*/
|
|
162
164
|
async function runWcagScan(page, testInfo, opts) {
|
|
163
|
-
const builder = new playwright_1.default({ page })
|
|
164
|
-
.withTags(opts.wcagTags);
|
|
165
|
+
const builder = new playwright_1.default({ page }).withTags(opts.wcagTags);
|
|
165
166
|
for (const selector of opts.exclude) {
|
|
166
167
|
builder.exclude(selector);
|
|
167
168
|
}
|
|
@@ -169,9 +170,9 @@ async function runWcagScan(page, testInfo, opts) {
|
|
|
169
170
|
builder.options({ rules: opts.rules });
|
|
170
171
|
}
|
|
171
172
|
const results = await builder.analyze();
|
|
172
|
-
await testInfo.attach(
|
|
173
|
+
await testInfo.attach("a11y-wcag-scan-results", {
|
|
173
174
|
body: JSON.stringify(results, null, 2),
|
|
174
|
-
contentType:
|
|
175
|
+
contentType: "application/json",
|
|
175
176
|
});
|
|
176
177
|
return results;
|
|
177
178
|
}
|
|
@@ -182,39 +183,41 @@ async function runWcagScan(page, testInfo, opts) {
|
|
|
182
183
|
async function screenshotViolatingElements(page, testInfo, results) {
|
|
183
184
|
// Collect all raw CSS selectors from violation nodes.
|
|
184
185
|
const selectors = results.violations
|
|
185
|
-
.flatMap(v => v.nodes)
|
|
186
|
-
.flatMap(n => n.target)
|
|
187
|
-
.filter((t) => typeof t ===
|
|
186
|
+
.flatMap((v) => v.nodes)
|
|
187
|
+
.flatMap((n) => n.target)
|
|
188
|
+
.filter((t) => typeof t === "string");
|
|
188
189
|
if (selectors.length === 0)
|
|
189
190
|
return;
|
|
190
191
|
// Inject highlight outlines on all violating elements.
|
|
191
192
|
await page.evaluate((sels) => {
|
|
192
|
-
const style = document.createElement(
|
|
193
|
-
style.setAttribute(
|
|
193
|
+
const style = document.createElement("style");
|
|
194
|
+
style.setAttribute("data-a11y-highlight", "true");
|
|
194
195
|
// Use a CSS rule for each selector so the outline persists even if
|
|
195
196
|
// elements are repositioned during the screenshot.
|
|
196
|
-
const rules = sels
|
|
197
|
+
const rules = sels
|
|
198
|
+
.map((s) => `${s} { outline: 3px solid #e53e3e !important; outline-offset: 2px !important; }`)
|
|
199
|
+
.join("\n");
|
|
197
200
|
style.textContent = rules;
|
|
198
201
|
document.head.appendChild(style);
|
|
199
202
|
}, selectors);
|
|
200
203
|
try {
|
|
201
204
|
const screenshot = await page.screenshot({ fullPage: true });
|
|
202
|
-
await testInfo.attach(
|
|
205
|
+
await testInfo.attach("a11y-violation-screenshot", {
|
|
203
206
|
body: screenshot,
|
|
204
|
-
contentType:
|
|
207
|
+
contentType: "image/png",
|
|
205
208
|
});
|
|
206
209
|
}
|
|
207
210
|
finally {
|
|
208
211
|
// Remove the injected styles even if capture or report attachment fails.
|
|
209
212
|
await page.evaluate(() => {
|
|
210
|
-
document.querySelector(
|
|
213
|
+
document.querySelector("style[data-a11y-highlight]")?.remove();
|
|
211
214
|
});
|
|
212
215
|
}
|
|
213
216
|
}
|
|
214
217
|
/**
|
|
215
218
|
* Assert violations against a baseline allowlist (in-code or on-disk).
|
|
216
219
|
*/
|
|
217
|
-
function assertBaseline(ctx, baseline) {
|
|
220
|
+
function assertBaseline(ctx, baseline, sharedBaselineMatches) {
|
|
218
221
|
(0, accessibility_baseline_js_1.validateAccessibilityBaseline)(baseline);
|
|
219
222
|
const { testInfo, results, scanLabel, expectFn } = ctx;
|
|
220
223
|
const allViolations = extractNormalizedViolations(results);
|
|
@@ -225,13 +228,14 @@ function assertBaseline(ctx, baseline) {
|
|
|
225
228
|
if (entry.rule !== violation.rule)
|
|
226
229
|
return false;
|
|
227
230
|
// Check for at least one overlapping normalized target.
|
|
228
|
-
return entry.targets.some(baselineTarget => violation.targets.some(violationTarget => violationTarget === baselineTarget));
|
|
231
|
+
return entry.targets.some((baselineTarget) => violation.targets.some((violationTarget) => violationTarget === baselineTarget));
|
|
229
232
|
});
|
|
230
233
|
if (baselineIndex >= 0) {
|
|
231
234
|
matchedBaselineIndices.add(baselineIndex);
|
|
235
|
+
sharedBaselineMatches?.add(baselineIndex);
|
|
232
236
|
const entry = baseline[baselineIndex];
|
|
233
237
|
testInfo.annotations.push({
|
|
234
|
-
type:
|
|
238
|
+
type: "Baselined a11y violation",
|
|
235
239
|
description: `${entry.rule}: ${entry.reason} — ${entry.willBeFixedIn}`,
|
|
236
240
|
});
|
|
237
241
|
}
|
|
@@ -239,25 +243,31 @@ function assertBaseline(ctx, baseline) {
|
|
|
239
243
|
unmatchedViolations.push(violation);
|
|
240
244
|
}
|
|
241
245
|
}
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
246
|
+
// The WCAG scan always runs last, even when best-practice is disabled.
|
|
247
|
+
// Report shared entries once after both scans have contributed matches,
|
|
248
|
+
// before the hard assertion can throw. On-disk baselines belong to one
|
|
249
|
+
// scan and continue to report stale entries independently.
|
|
250
|
+
if (!sharedBaselineMatches || ctx.scan === "wcag") {
|
|
251
|
+
const matches = sharedBaselineMatches ?? matchedBaselineIndices;
|
|
252
|
+
baseline.forEach((entry, idx) => {
|
|
253
|
+
if (!matches.has(idx)) {
|
|
254
|
+
testInfo.annotations.push({
|
|
255
|
+
type: "Stale a11y baseline entry",
|
|
256
|
+
description: `${entry.rule} on ${entry.targets.join(", ")} — no longer detected`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
251
261
|
// Summary annotation for baseline mode.
|
|
252
262
|
const baselinedCount = matchedBaselineIndices.size;
|
|
253
263
|
testInfo.annotations.push({
|
|
254
|
-
type:
|
|
264
|
+
type: "Accessibility",
|
|
255
265
|
description: `${scanLabel}: ${unmatchedViolations.length} new violations (${baselinedCount} baselined)`,
|
|
256
266
|
});
|
|
257
267
|
// Fail on unmatched violations with detailed output.
|
|
258
268
|
if (unmatchedViolations.length > 0) {
|
|
259
269
|
const details = formatViolationDetails(results, unmatchedViolations);
|
|
260
|
-
expectFn(null, details).toBe(
|
|
270
|
+
expectFn(null, details).toBe("no accessibility violations");
|
|
261
271
|
}
|
|
262
272
|
}
|
|
263
273
|
/**
|
|
@@ -267,22 +277,24 @@ async function assertSnapshot(ctx) {
|
|
|
267
277
|
const { testInfo, results, scan, expectFn } = ctx;
|
|
268
278
|
// Match the legacy summary annotation phrasing for WCAG; best-practice's
|
|
269
279
|
// pre-existing summary annotation is emitted in runBestPracticeScan.
|
|
270
|
-
if (scan ===
|
|
280
|
+
if (scan === "wcag") {
|
|
271
281
|
testInfo.annotations.push({
|
|
272
|
-
type:
|
|
273
|
-
description: `WCAG scan: ${results.violations.length} violations (${results.passes.length} rules passed)
|
|
282
|
+
type: "Accessibility",
|
|
283
|
+
description: `WCAG scan: ${results.violations.length} violations (${results.passes.length} rules passed)`,
|
|
274
284
|
});
|
|
275
285
|
// If there are violations, attach baseline suggestions and push annotation.
|
|
276
286
|
if (results.violations.length > 0) {
|
|
277
287
|
const allViolations = extractNormalizedViolations(results);
|
|
278
|
-
const suggestions = allViolations
|
|
279
|
-
|
|
288
|
+
const suggestions = allViolations
|
|
289
|
+
.map((v) => formatBaselineSuggestion(v))
|
|
290
|
+
.join("\n");
|
|
291
|
+
await testInfo.attach("a11y-baseline-suggestions", {
|
|
280
292
|
body: suggestions,
|
|
281
|
-
contentType:
|
|
293
|
+
contentType: "text/plain",
|
|
282
294
|
});
|
|
283
295
|
testInfo.annotations.push({
|
|
284
|
-
type:
|
|
285
|
-
description:
|
|
296
|
+
type: "Accessibility",
|
|
297
|
+
description: "To manage violations explicitly, switch to baseline mode. See a11y-baseline-suggestions attachment.",
|
|
286
298
|
});
|
|
287
299
|
}
|
|
288
300
|
}
|
|
@@ -299,11 +311,13 @@ async function assertSnapshot(ctx) {
|
|
|
299
311
|
*/
|
|
300
312
|
async function takeAccessibleScreenshot(page, testInfo, options, scrollLocator, locator) {
|
|
301
313
|
const screenshotOptions = options ?? {};
|
|
302
|
-
if (screenshotOptions.clipLocator &&
|
|
303
|
-
|
|
314
|
+
if (screenshotOptions.clipLocator &&
|
|
315
|
+
(screenshotOptions.clip || (locator && locator !== page))) {
|
|
316
|
+
throw new Error("clipLocator cannot be combined with clip or a locator screenshot target.");
|
|
304
317
|
}
|
|
305
|
-
if (screenshotOptions.clipLocator &&
|
|
306
|
-
|
|
318
|
+
if (screenshotOptions.clipLocator &&
|
|
319
|
+
screenshotOptions.clipLocator.page() !== page) {
|
|
320
|
+
throw new Error("clipLocator must belong to the screenshot page.");
|
|
307
321
|
}
|
|
308
322
|
// The default is 5 seconds. However, even on a fast machine it can take
|
|
309
323
|
// longer than 5 seconds for large pages like node forms to stabilize. This
|
|
@@ -347,7 +361,7 @@ async function takeAccessibleScreenshot(page, testInfo, options, scrollLocator,
|
|
|
347
361
|
// A hover action cannot hit its target through the transparent shield used
|
|
348
362
|
// to clear incidental hover. Remove it only after all stability waits, then
|
|
349
363
|
// apply real hover before real focus so focusing cannot disturb the pointer.
|
|
350
|
-
const hasHover = interactionStates.some(({ states }) => states.includes(
|
|
364
|
+
const hasHover = interactionStates.some(({ states }) => states.includes("hover"));
|
|
351
365
|
if (hasHover) {
|
|
352
366
|
await removeHoverShield?.();
|
|
353
367
|
removeHoverShield = undefined;
|
|
@@ -369,15 +383,17 @@ async function takeAccessibleScreenshot(page, testInfo, options, scrollLocator,
|
|
|
369
383
|
// Scrolling can expose lazy content. Await fonts and two paint frames
|
|
370
384
|
// before measuring; the existing waits have already loaded page media.
|
|
371
385
|
await (0, fonts_js_1.waitForFonts)(page);
|
|
372
|
-
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
|
386
|
+
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
|
373
387
|
const bounds = await screenshotOptions.clipLocator.boundingBox();
|
|
374
388
|
if (!bounds)
|
|
375
|
-
throw new Error(
|
|
389
|
+
throw new Error("clipLocator has no visible bounding box.");
|
|
376
390
|
// boundingBox is in main-frame viewport coordinates, including for
|
|
377
391
|
// locators inside frames. Read scroll offsets from the main page.
|
|
378
392
|
const geometry = await page.evaluate(() => ({
|
|
379
|
-
x: window.scrollX,
|
|
380
|
-
|
|
393
|
+
x: window.scrollX,
|
|
394
|
+
y: window.scrollY,
|
|
395
|
+
width: window.innerWidth,
|
|
396
|
+
height: window.innerHeight,
|
|
381
397
|
}));
|
|
382
398
|
const clip = {
|
|
383
399
|
x: Math.round(bounds.x + (screenshotOptions.fullPage ? geometry.x : 0)),
|
|
@@ -386,18 +402,24 @@ async function takeAccessibleScreenshot(page, testInfo, options, scrollLocator,
|
|
|
386
402
|
height: Math.round(bounds.height),
|
|
387
403
|
};
|
|
388
404
|
if (clip.width <= 0 || clip.height <= 0) {
|
|
389
|
-
throw new Error(
|
|
405
|
+
throw new Error("clipLocator must have positive rounded dimensions.");
|
|
390
406
|
}
|
|
391
|
-
if (!screenshotOptions.fullPage &&
|
|
392
|
-
|
|
407
|
+
if (!screenshotOptions.fullPage &&
|
|
408
|
+
(clip.x < 0 ||
|
|
409
|
+
clip.y < 0 ||
|
|
410
|
+
clip.x + clip.width > geometry.width ||
|
|
411
|
+
clip.y + clip.height > geometry.height)) {
|
|
412
|
+
throw new Error("clipLocator does not fit in the viewport; use fullPage: true to capture the whole target.");
|
|
393
413
|
}
|
|
394
414
|
if (clip.x < 0 || clip.y < 0) {
|
|
395
|
-
throw new Error(
|
|
415
|
+
throw new Error("clipLocator must have non-negative document coordinates.");
|
|
396
416
|
}
|
|
397
417
|
playwrightScreenshotOptions.clip = clip;
|
|
398
418
|
}
|
|
399
419
|
// Soft failure here so we can get accessibility violations too.
|
|
400
|
-
await test_1.expect
|
|
420
|
+
await test_1.expect
|
|
421
|
+
.soft(locatorToScreenshot)
|
|
422
|
+
.toHaveScreenshot(playwrightScreenshotOptions);
|
|
401
423
|
// Settling a video pauses it, clears `autoplay` and rewinds it. That is only
|
|
402
424
|
// wanted for the duration of the capture: a test that screenshots a page and
|
|
403
425
|
// then asserts that a video is playing should still pass.
|
|
@@ -435,9 +457,9 @@ async function takeAccessibleScreenshot(page, testInfo, options, scrollLocator,
|
|
|
435
457
|
function normalizeTarget(target) {
|
|
436
458
|
const uniqueHtmlID = /(#[^#]*)--\d+/;
|
|
437
459
|
const ariaLabelledById = /(aria-labelledby="[^"]+)--\d+"/;
|
|
438
|
-
if (typeof target ===
|
|
460
|
+
if (typeof target === "string") {
|
|
439
461
|
return target
|
|
440
|
-
.replace(uniqueHtmlID,
|
|
462
|
+
.replace(uniqueHtmlID, "$1--UNIQUE-ID")
|
|
441
463
|
.replace(ariaLabelledById, '$1--UNIQUE-ID"');
|
|
442
464
|
}
|
|
443
465
|
return target;
|
|
@@ -447,12 +469,12 @@ function normalizeTarget(target) {
|
|
|
447
469
|
* flat, deduplicated CSS selector strings.
|
|
448
470
|
*/
|
|
449
471
|
function extractNormalizedViolations(results) {
|
|
450
|
-
return results.violations.map(violation => {
|
|
472
|
+
return results.violations.map((violation) => {
|
|
451
473
|
const flatTargets = [];
|
|
452
474
|
for (const node of violation.nodes) {
|
|
453
475
|
for (const target of node.target) {
|
|
454
476
|
const normalized = normalizeTarget(target);
|
|
455
|
-
const str = typeof normalized ===
|
|
477
|
+
const str = typeof normalized === "string" ? normalized : normalized.join(" ");
|
|
456
478
|
if (!flatTargets.includes(str)) {
|
|
457
479
|
flatTargets.push(str);
|
|
458
480
|
}
|
|
@@ -462,7 +484,7 @@ function extractNormalizedViolations(results) {
|
|
|
462
484
|
rule: violation.id,
|
|
463
485
|
targets: flatTargets,
|
|
464
486
|
description: violation.description,
|
|
465
|
-
impact: violation.impact ??
|
|
487
|
+
impact: violation.impact ?? "unknown",
|
|
466
488
|
helpUrl: violation.helpUrl,
|
|
467
489
|
};
|
|
468
490
|
});
|
|
@@ -471,7 +493,7 @@ function extractNormalizedViolations(results) {
|
|
|
471
493
|
* Format a single violation as a copy-pasteable baseline entry.
|
|
472
494
|
*/
|
|
473
495
|
function formatBaselineSuggestion(violation) {
|
|
474
|
-
const targetsStr = violation.targets.map(t => `'${t}'`).join(
|
|
496
|
+
const targetsStr = violation.targets.map((t) => `'${t}'`).join(", ");
|
|
475
497
|
return `{
|
|
476
498
|
rule: '${violation.rule}',
|
|
477
499
|
targets: [${targetsStr}],
|
|
@@ -491,12 +513,12 @@ function formatViolationDetails(results, violations) {
|
|
|
491
513
|
lines.push(` ${v.description}`);
|
|
492
514
|
lines.push(` Help: ${v.helpUrl}`);
|
|
493
515
|
lines.push(` Targets: ${targetsStr}`);
|
|
494
|
-
lines.push(
|
|
495
|
-
lines.push(
|
|
496
|
-
lines.push(
|
|
497
|
-
lines.push(
|
|
516
|
+
lines.push("");
|
|
517
|
+
lines.push(" Add to your baseline to accept this violation:");
|
|
518
|
+
lines.push(" " + formatBaselineSuggestion(v).split("\n").join("\n "));
|
|
519
|
+
lines.push("");
|
|
498
520
|
}
|
|
499
|
-
return lines.join(
|
|
521
|
+
return lines.join("\n");
|
|
500
522
|
}
|
|
501
523
|
/**
|
|
502
524
|
* Filter violations down to stable elements.
|
|
@@ -507,11 +529,11 @@ function formatViolationDetails(results, violations) {
|
|
|
507
529
|
* @param accessibilityScanResults
|
|
508
530
|
*/
|
|
509
531
|
function violationFingerprints(accessibilityScanResults) {
|
|
510
|
-
const violationFps = accessibilityScanResults.violations.map(violation => ({
|
|
532
|
+
const violationFps = accessibilityScanResults.violations.map((violation) => ({
|
|
511
533
|
rule: violation.id,
|
|
512
534
|
// These are CSS selectors which uniquely identify each element with
|
|
513
535
|
// a violation of the rule in question.
|
|
514
|
-
targets: violation.nodes.map(node => node.target.map((target) => {
|
|
536
|
+
targets: violation.nodes.map((node) => node.target.map((target) => {
|
|
515
537
|
return normalizeTarget(target);
|
|
516
538
|
})),
|
|
517
539
|
}));
|
package/lib/focus.js
CHANGED
|
@@ -19,7 +19,10 @@ async function blurActiveElement(page) {
|
|
|
19
19
|
// `document.activeElement` falls back to `<body>`/`<html>` when nothing is
|
|
20
20
|
// focused. Blurring that paints no focus ring but can still fire `focusout`
|
|
21
21
|
// handlers, so skip it and only blur a genuinely focused control.
|
|
22
|
-
if (!el ||
|
|
22
|
+
if (!el ||
|
|
23
|
+
el === document.body ||
|
|
24
|
+
el === document.documentElement ||
|
|
25
|
+
typeof el.blur !== "function") {
|
|
23
26
|
return false;
|
|
24
27
|
}
|
|
25
28
|
el.blur();
|
package/lib/frames.js
CHANGED
|
@@ -7,20 +7,20 @@ exports.waitForFrames = waitForFrames;
|
|
|
7
7
|
* @param page
|
|
8
8
|
*/
|
|
9
9
|
async function waitForFrames(page) {
|
|
10
|
-
const locators = await page.locator(
|
|
10
|
+
const locators = await page.locator("iframe");
|
|
11
11
|
// Trigger lazy-loading iframes. Since this should be fast, and we don't want to
|
|
12
12
|
// have to deal with concurrency bugs, we do this in serial.
|
|
13
13
|
for (const locator of await locators.all()) {
|
|
14
14
|
// Ensure iframes are connected to the DOM before trying to scroll to them.
|
|
15
15
|
// https://github.com/microsoft/playwright/issues/23758
|
|
16
|
-
if (await locator.evaluate(iframe => iframe.isConnected)) {
|
|
16
|
+
if (await locator.evaluate((iframe) => iframe.isConnected)) {
|
|
17
17
|
if (await locator.isVisible()) {
|
|
18
18
|
await locator.scrollIntoViewIfNeeded();
|
|
19
19
|
await locator.scrollIntoViewIfNeeded();
|
|
20
20
|
const element = await locator.elementHandle();
|
|
21
21
|
const frame = await element?.contentFrame();
|
|
22
|
-
await frame?.waitForURL(new RegExp(
|
|
23
|
-
await frame?.waitForLoadState(
|
|
22
|
+
await frame?.waitForURL(new RegExp(".*/.*", "i"));
|
|
23
|
+
await frame?.waitForLoadState("load");
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
}
|