@lullabot/playwright-testing 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +88 -76
- 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 +139 -107
- 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 +633 -433
- package/src/accessibility-baseline.ts +41 -21
- package/src/accessible-screenshot.test.ts +551 -423
- package/src/accessible-screenshot.ts +334 -245
- 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 +659 -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,16 @@ 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
|
-
if (bestPracticeMode !==
|
|
41
|
+
if (bestPracticeMode !== "off") {
|
|
42
42
|
const bpResults = await runBestPracticeScan(page, testInfo, {
|
|
43
43
|
exclude: [...exclude, ...bestPracticeExclude],
|
|
44
44
|
rules,
|
|
@@ -49,9 +49,9 @@ async function checkAccessibility(page, testInfo, options) {
|
|
|
49
49
|
await dispatchAssertion({
|
|
50
50
|
testInfo,
|
|
51
51
|
results: bpResults,
|
|
52
|
-
scan:
|
|
52
|
+
scan: "best-practice",
|
|
53
53
|
expectFn: test_1.expect.soft,
|
|
54
|
-
scanLabel:
|
|
54
|
+
scanLabel: "Best-practice scan",
|
|
55
55
|
}, baseline);
|
|
56
56
|
}
|
|
57
57
|
const wcagScanResults = await runWcagScan(page, testInfo, {
|
|
@@ -65,9 +65,9 @@ async function checkAccessibility(page, testInfo, options) {
|
|
|
65
65
|
await dispatchAssertion({
|
|
66
66
|
testInfo,
|
|
67
67
|
results: wcagScanResults,
|
|
68
|
-
scan:
|
|
68
|
+
scan: "wcag",
|
|
69
69
|
expectFn: test_1.expect,
|
|
70
|
-
scanLabel:
|
|
70
|
+
scanLabel: "WCAG scan",
|
|
71
71
|
}, baseline);
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
@@ -93,7 +93,7 @@ async function dispatchAssertion(ctx, inCodeBaseline) {
|
|
|
93
93
|
return assertSnapshot(ctx);
|
|
94
94
|
}
|
|
95
95
|
const update = ctx.testInfo.config?.updateSnapshots;
|
|
96
|
-
if (update ===
|
|
96
|
+
if (update === "all" || update === "changed") {
|
|
97
97
|
return assertSnapshot(ctx);
|
|
98
98
|
}
|
|
99
99
|
const callCount = (0, accessibility_baseline_file_js_1.nextAccessibilityScanCount)(ctx.testInfo, ctx.scan);
|
|
@@ -104,25 +104,25 @@ async function dispatchAssertion(ctx, inCodeBaseline) {
|
|
|
104
104
|
}
|
|
105
105
|
// Seed and either pass (local) or fail (CI).
|
|
106
106
|
const normalized = extractNormalizedViolations(ctx.results);
|
|
107
|
-
const seedViolations = normalized.map(v => ({
|
|
107
|
+
const seedViolations = normalized.map((v) => ({
|
|
108
108
|
rule: v.rule,
|
|
109
109
|
targets: v.targets,
|
|
110
|
-
reason:
|
|
111
|
-
willBeFixedIn:
|
|
110
|
+
reason: "TODO",
|
|
111
|
+
willBeFixedIn: "TODO",
|
|
112
112
|
}));
|
|
113
113
|
const seed = (0, accessibility_baseline_file_js_1.buildSeed)(seedViolations);
|
|
114
114
|
await (0, accessibility_baseline_file_js_1.writeBaselineFile)(filePath, seed);
|
|
115
115
|
await ctx.testInfo.attach(`a11y-${ctx.scan}-baseline-seed`, {
|
|
116
116
|
path: filePath,
|
|
117
|
-
contentType:
|
|
117
|
+
contentType: "application/json",
|
|
118
118
|
});
|
|
119
119
|
if (process.env.CI) {
|
|
120
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(
|
|
121
|
+
ctx.expectFn(null, message).toBe("a11y baseline file present");
|
|
122
122
|
return;
|
|
123
123
|
}
|
|
124
124
|
ctx.testInfo.annotations.push({
|
|
125
|
-
type:
|
|
125
|
+
type: "Accessibility",
|
|
126
126
|
description: seed.violations.length === 0
|
|
127
127
|
? `${ctx.scanLabel}: a11y baseline seeded at ${filePath} (no violations).`
|
|
128
128
|
: `${ctx.scanLabel}: a11y baseline seeded at ${filePath} with ${seed.violations.length} entries — fill in reason/willBeFixedIn before committing.`,
|
|
@@ -137,8 +137,7 @@ async function dispatchAssertion(ctx, inCodeBaseline) {
|
|
|
137
137
|
* option drives soft- vs hard-failure behaviour.
|
|
138
138
|
*/
|
|
139
139
|
async function runBestPracticeScan(page, testInfo, opts) {
|
|
140
|
-
const builder = new playwright_1.default({ page })
|
|
141
|
-
.withTags(['best-practice']);
|
|
140
|
+
const builder = new playwright_1.default({ page }).withTags(["best-practice"]);
|
|
142
141
|
for (const selector of opts.exclude) {
|
|
143
142
|
builder.exclude(selector);
|
|
144
143
|
}
|
|
@@ -146,13 +145,13 @@ async function runBestPracticeScan(page, testInfo, opts) {
|
|
|
146
145
|
builder.options({ rules: opts.rules });
|
|
147
146
|
}
|
|
148
147
|
const results = await builder.analyze();
|
|
149
|
-
await testInfo.attach(
|
|
148
|
+
await testInfo.attach("a11y-best-practice-scan-results", {
|
|
150
149
|
body: JSON.stringify(results, null, 2),
|
|
151
|
-
contentType:
|
|
150
|
+
contentType: "application/json",
|
|
152
151
|
});
|
|
153
152
|
testInfo.annotations.push({
|
|
154
|
-
type:
|
|
155
|
-
description: `Best-practice scan: ${results.violations.length} violations (${results.passes.length} rules passed)
|
|
153
|
+
type: "Accessibility",
|
|
154
|
+
description: `Best-practice scan: ${results.violations.length} violations (${results.passes.length} rules passed)`,
|
|
156
155
|
});
|
|
157
156
|
return results;
|
|
158
157
|
}
|
|
@@ -160,8 +159,7 @@ async function runBestPracticeScan(page, testInfo, opts) {
|
|
|
160
159
|
* Run the WCAG axe scan, attach results, and return them for assertion.
|
|
161
160
|
*/
|
|
162
161
|
async function runWcagScan(page, testInfo, opts) {
|
|
163
|
-
const builder = new playwright_1.default({ page })
|
|
164
|
-
.withTags(opts.wcagTags);
|
|
162
|
+
const builder = new playwright_1.default({ page }).withTags(opts.wcagTags);
|
|
165
163
|
for (const selector of opts.exclude) {
|
|
166
164
|
builder.exclude(selector);
|
|
167
165
|
}
|
|
@@ -169,9 +167,9 @@ async function runWcagScan(page, testInfo, opts) {
|
|
|
169
167
|
builder.options({ rules: opts.rules });
|
|
170
168
|
}
|
|
171
169
|
const results = await builder.analyze();
|
|
172
|
-
await testInfo.attach(
|
|
170
|
+
await testInfo.attach("a11y-wcag-scan-results", {
|
|
173
171
|
body: JSON.stringify(results, null, 2),
|
|
174
|
-
contentType:
|
|
172
|
+
contentType: "application/json",
|
|
175
173
|
});
|
|
176
174
|
return results;
|
|
177
175
|
}
|
|
@@ -182,32 +180,34 @@ async function runWcagScan(page, testInfo, opts) {
|
|
|
182
180
|
async function screenshotViolatingElements(page, testInfo, results) {
|
|
183
181
|
// Collect all raw CSS selectors from violation nodes.
|
|
184
182
|
const selectors = results.violations
|
|
185
|
-
.flatMap(v => v.nodes)
|
|
186
|
-
.flatMap(n => n.target)
|
|
187
|
-
.filter((t) => typeof t ===
|
|
183
|
+
.flatMap((v) => v.nodes)
|
|
184
|
+
.flatMap((n) => n.target)
|
|
185
|
+
.filter((t) => typeof t === "string");
|
|
188
186
|
if (selectors.length === 0)
|
|
189
187
|
return;
|
|
190
188
|
// Inject highlight outlines on all violating elements.
|
|
191
189
|
await page.evaluate((sels) => {
|
|
192
|
-
const style = document.createElement(
|
|
193
|
-
style.setAttribute(
|
|
190
|
+
const style = document.createElement("style");
|
|
191
|
+
style.setAttribute("data-a11y-highlight", "true");
|
|
194
192
|
// Use a CSS rule for each selector so the outline persists even if
|
|
195
193
|
// elements are repositioned during the screenshot.
|
|
196
|
-
const rules = sels
|
|
194
|
+
const rules = sels
|
|
195
|
+
.map((s) => `${s} { outline: 3px solid #e53e3e !important; outline-offset: 2px !important; }`)
|
|
196
|
+
.join("\n");
|
|
197
197
|
style.textContent = rules;
|
|
198
198
|
document.head.appendChild(style);
|
|
199
199
|
}, selectors);
|
|
200
200
|
try {
|
|
201
201
|
const screenshot = await page.screenshot({ fullPage: true });
|
|
202
|
-
await testInfo.attach(
|
|
202
|
+
await testInfo.attach("a11y-violation-screenshot", {
|
|
203
203
|
body: screenshot,
|
|
204
|
-
contentType:
|
|
204
|
+
contentType: "image/png",
|
|
205
205
|
});
|
|
206
206
|
}
|
|
207
207
|
finally {
|
|
208
208
|
// Remove the injected styles even if capture or report attachment fails.
|
|
209
209
|
await page.evaluate(() => {
|
|
210
|
-
document.querySelector(
|
|
210
|
+
document.querySelector("style[data-a11y-highlight]")?.remove();
|
|
211
211
|
});
|
|
212
212
|
}
|
|
213
213
|
}
|
|
@@ -225,13 +225,13 @@ function assertBaseline(ctx, baseline) {
|
|
|
225
225
|
if (entry.rule !== violation.rule)
|
|
226
226
|
return false;
|
|
227
227
|
// Check for at least one overlapping normalized target.
|
|
228
|
-
return entry.targets.some(baselineTarget => violation.targets.some(violationTarget => violationTarget === baselineTarget));
|
|
228
|
+
return entry.targets.some((baselineTarget) => violation.targets.some((violationTarget) => violationTarget === baselineTarget));
|
|
229
229
|
});
|
|
230
230
|
if (baselineIndex >= 0) {
|
|
231
231
|
matchedBaselineIndices.add(baselineIndex);
|
|
232
232
|
const entry = baseline[baselineIndex];
|
|
233
233
|
testInfo.annotations.push({
|
|
234
|
-
type:
|
|
234
|
+
type: "Baselined a11y violation",
|
|
235
235
|
description: `${entry.rule}: ${entry.reason} — ${entry.willBeFixedIn}`,
|
|
236
236
|
});
|
|
237
237
|
}
|
|
@@ -243,21 +243,21 @@ function assertBaseline(ctx, baseline) {
|
|
|
243
243
|
baseline.forEach((entry, idx) => {
|
|
244
244
|
if (!matchedBaselineIndices.has(idx)) {
|
|
245
245
|
testInfo.annotations.push({
|
|
246
|
-
type:
|
|
247
|
-
description: `${entry.rule} on ${entry.targets.join(
|
|
246
|
+
type: "Stale a11y baseline entry",
|
|
247
|
+
description: `${entry.rule} on ${entry.targets.join(", ")} — no longer detected`,
|
|
248
248
|
});
|
|
249
249
|
}
|
|
250
250
|
});
|
|
251
251
|
// Summary annotation for baseline mode.
|
|
252
252
|
const baselinedCount = matchedBaselineIndices.size;
|
|
253
253
|
testInfo.annotations.push({
|
|
254
|
-
type:
|
|
254
|
+
type: "Accessibility",
|
|
255
255
|
description: `${scanLabel}: ${unmatchedViolations.length} new violations (${baselinedCount} baselined)`,
|
|
256
256
|
});
|
|
257
257
|
// Fail on unmatched violations with detailed output.
|
|
258
258
|
if (unmatchedViolations.length > 0) {
|
|
259
259
|
const details = formatViolationDetails(results, unmatchedViolations);
|
|
260
|
-
expectFn(null, details).toBe(
|
|
260
|
+
expectFn(null, details).toBe("no accessibility violations");
|
|
261
261
|
}
|
|
262
262
|
}
|
|
263
263
|
/**
|
|
@@ -267,22 +267,24 @@ async function assertSnapshot(ctx) {
|
|
|
267
267
|
const { testInfo, results, scan, expectFn } = ctx;
|
|
268
268
|
// Match the legacy summary annotation phrasing for WCAG; best-practice's
|
|
269
269
|
// pre-existing summary annotation is emitted in runBestPracticeScan.
|
|
270
|
-
if (scan ===
|
|
270
|
+
if (scan === "wcag") {
|
|
271
271
|
testInfo.annotations.push({
|
|
272
|
-
type:
|
|
273
|
-
description: `WCAG scan: ${results.violations.length} violations (${results.passes.length} rules passed)
|
|
272
|
+
type: "Accessibility",
|
|
273
|
+
description: `WCAG scan: ${results.violations.length} violations (${results.passes.length} rules passed)`,
|
|
274
274
|
});
|
|
275
275
|
// If there are violations, attach baseline suggestions and push annotation.
|
|
276
276
|
if (results.violations.length > 0) {
|
|
277
277
|
const allViolations = extractNormalizedViolations(results);
|
|
278
|
-
const suggestions = allViolations
|
|
279
|
-
|
|
278
|
+
const suggestions = allViolations
|
|
279
|
+
.map((v) => formatBaselineSuggestion(v))
|
|
280
|
+
.join("\n");
|
|
281
|
+
await testInfo.attach("a11y-baseline-suggestions", {
|
|
280
282
|
body: suggestions,
|
|
281
|
-
contentType:
|
|
283
|
+
contentType: "text/plain",
|
|
282
284
|
});
|
|
283
285
|
testInfo.annotations.push({
|
|
284
|
-
type:
|
|
285
|
-
description:
|
|
286
|
+
type: "Accessibility",
|
|
287
|
+
description: "To manage violations explicitly, switch to baseline mode. See a11y-baseline-suggestions attachment.",
|
|
286
288
|
});
|
|
287
289
|
}
|
|
288
290
|
}
|
|
@@ -299,11 +301,13 @@ async function assertSnapshot(ctx) {
|
|
|
299
301
|
*/
|
|
300
302
|
async function takeAccessibleScreenshot(page, testInfo, options, scrollLocator, locator) {
|
|
301
303
|
const screenshotOptions = options ?? {};
|
|
302
|
-
if (screenshotOptions.clipLocator &&
|
|
303
|
-
|
|
304
|
+
if (screenshotOptions.clipLocator &&
|
|
305
|
+
(screenshotOptions.clip || (locator && locator !== page))) {
|
|
306
|
+
throw new Error("clipLocator cannot be combined with clip or a locator screenshot target.");
|
|
304
307
|
}
|
|
305
|
-
if (screenshotOptions.clipLocator &&
|
|
306
|
-
|
|
308
|
+
if (screenshotOptions.clipLocator &&
|
|
309
|
+
screenshotOptions.clipLocator.page() !== page) {
|
|
310
|
+
throw new Error("clipLocator must belong to the screenshot page.");
|
|
307
311
|
}
|
|
308
312
|
// The default is 5 seconds. However, even on a fast machine it can take
|
|
309
313
|
// longer than 5 seconds for large pages like node forms to stabilize. This
|
|
@@ -347,7 +351,7 @@ async function takeAccessibleScreenshot(page, testInfo, options, scrollLocator,
|
|
|
347
351
|
// A hover action cannot hit its target through the transparent shield used
|
|
348
352
|
// to clear incidental hover. Remove it only after all stability waits, then
|
|
349
353
|
// apply real hover before real focus so focusing cannot disturb the pointer.
|
|
350
|
-
const hasHover = interactionStates.some(({ states }) => states.includes(
|
|
354
|
+
const hasHover = interactionStates.some(({ states }) => states.includes("hover"));
|
|
351
355
|
if (hasHover) {
|
|
352
356
|
await removeHoverShield?.();
|
|
353
357
|
removeHoverShield = undefined;
|
|
@@ -369,15 +373,17 @@ async function takeAccessibleScreenshot(page, testInfo, options, scrollLocator,
|
|
|
369
373
|
// Scrolling can expose lazy content. Await fonts and two paint frames
|
|
370
374
|
// before measuring; the existing waits have already loaded page media.
|
|
371
375
|
await (0, fonts_js_1.waitForFonts)(page);
|
|
372
|
-
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
|
376
|
+
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
|
373
377
|
const bounds = await screenshotOptions.clipLocator.boundingBox();
|
|
374
378
|
if (!bounds)
|
|
375
|
-
throw new Error(
|
|
379
|
+
throw new Error("clipLocator has no visible bounding box.");
|
|
376
380
|
// boundingBox is in main-frame viewport coordinates, including for
|
|
377
381
|
// locators inside frames. Read scroll offsets from the main page.
|
|
378
382
|
const geometry = await page.evaluate(() => ({
|
|
379
|
-
x: window.scrollX,
|
|
380
|
-
|
|
383
|
+
x: window.scrollX,
|
|
384
|
+
y: window.scrollY,
|
|
385
|
+
width: window.innerWidth,
|
|
386
|
+
height: window.innerHeight,
|
|
381
387
|
}));
|
|
382
388
|
const clip = {
|
|
383
389
|
x: Math.round(bounds.x + (screenshotOptions.fullPage ? geometry.x : 0)),
|
|
@@ -386,18 +392,24 @@ async function takeAccessibleScreenshot(page, testInfo, options, scrollLocator,
|
|
|
386
392
|
height: Math.round(bounds.height),
|
|
387
393
|
};
|
|
388
394
|
if (clip.width <= 0 || clip.height <= 0) {
|
|
389
|
-
throw new Error(
|
|
395
|
+
throw new Error("clipLocator must have positive rounded dimensions.");
|
|
390
396
|
}
|
|
391
|
-
if (!screenshotOptions.fullPage &&
|
|
392
|
-
|
|
397
|
+
if (!screenshotOptions.fullPage &&
|
|
398
|
+
(clip.x < 0 ||
|
|
399
|
+
clip.y < 0 ||
|
|
400
|
+
clip.x + clip.width > geometry.width ||
|
|
401
|
+
clip.y + clip.height > geometry.height)) {
|
|
402
|
+
throw new Error("clipLocator does not fit in the viewport; use fullPage: true to capture the whole target.");
|
|
393
403
|
}
|
|
394
404
|
if (clip.x < 0 || clip.y < 0) {
|
|
395
|
-
throw new Error(
|
|
405
|
+
throw new Error("clipLocator must have non-negative document coordinates.");
|
|
396
406
|
}
|
|
397
407
|
playwrightScreenshotOptions.clip = clip;
|
|
398
408
|
}
|
|
399
409
|
// Soft failure here so we can get accessibility violations too.
|
|
400
|
-
await test_1.expect
|
|
410
|
+
await test_1.expect
|
|
411
|
+
.soft(locatorToScreenshot)
|
|
412
|
+
.toHaveScreenshot(playwrightScreenshotOptions);
|
|
401
413
|
// Settling a video pauses it, clears `autoplay` and rewinds it. That is only
|
|
402
414
|
// wanted for the duration of the capture: a test that screenshots a page and
|
|
403
415
|
// then asserts that a video is playing should still pass.
|
|
@@ -435,9 +447,9 @@ async function takeAccessibleScreenshot(page, testInfo, options, scrollLocator,
|
|
|
435
447
|
function normalizeTarget(target) {
|
|
436
448
|
const uniqueHtmlID = /(#[^#]*)--\d+/;
|
|
437
449
|
const ariaLabelledById = /(aria-labelledby="[^"]+)--\d+"/;
|
|
438
|
-
if (typeof target ===
|
|
450
|
+
if (typeof target === "string") {
|
|
439
451
|
return target
|
|
440
|
-
.replace(uniqueHtmlID,
|
|
452
|
+
.replace(uniqueHtmlID, "$1--UNIQUE-ID")
|
|
441
453
|
.replace(ariaLabelledById, '$1--UNIQUE-ID"');
|
|
442
454
|
}
|
|
443
455
|
return target;
|
|
@@ -447,12 +459,12 @@ function normalizeTarget(target) {
|
|
|
447
459
|
* flat, deduplicated CSS selector strings.
|
|
448
460
|
*/
|
|
449
461
|
function extractNormalizedViolations(results) {
|
|
450
|
-
return results.violations.map(violation => {
|
|
462
|
+
return results.violations.map((violation) => {
|
|
451
463
|
const flatTargets = [];
|
|
452
464
|
for (const node of violation.nodes) {
|
|
453
465
|
for (const target of node.target) {
|
|
454
466
|
const normalized = normalizeTarget(target);
|
|
455
|
-
const str = typeof normalized ===
|
|
467
|
+
const str = typeof normalized === "string" ? normalized : normalized.join(" ");
|
|
456
468
|
if (!flatTargets.includes(str)) {
|
|
457
469
|
flatTargets.push(str);
|
|
458
470
|
}
|
|
@@ -462,7 +474,7 @@ function extractNormalizedViolations(results) {
|
|
|
462
474
|
rule: violation.id,
|
|
463
475
|
targets: flatTargets,
|
|
464
476
|
description: violation.description,
|
|
465
|
-
impact: violation.impact ??
|
|
477
|
+
impact: violation.impact ?? "unknown",
|
|
466
478
|
helpUrl: violation.helpUrl,
|
|
467
479
|
};
|
|
468
480
|
});
|
|
@@ -471,7 +483,7 @@ function extractNormalizedViolations(results) {
|
|
|
471
483
|
* Format a single violation as a copy-pasteable baseline entry.
|
|
472
484
|
*/
|
|
473
485
|
function formatBaselineSuggestion(violation) {
|
|
474
|
-
const targetsStr = violation.targets.map(t => `'${t}'`).join(
|
|
486
|
+
const targetsStr = violation.targets.map((t) => `'${t}'`).join(", ");
|
|
475
487
|
return `{
|
|
476
488
|
rule: '${violation.rule}',
|
|
477
489
|
targets: [${targetsStr}],
|
|
@@ -491,12 +503,12 @@ function formatViolationDetails(results, violations) {
|
|
|
491
503
|
lines.push(` ${v.description}`);
|
|
492
504
|
lines.push(` Help: ${v.helpUrl}`);
|
|
493
505
|
lines.push(` Targets: ${targetsStr}`);
|
|
494
|
-
lines.push(
|
|
495
|
-
lines.push(
|
|
496
|
-
lines.push(
|
|
497
|
-
lines.push(
|
|
506
|
+
lines.push("");
|
|
507
|
+
lines.push(" Add to your baseline to accept this violation:");
|
|
508
|
+
lines.push(" " + formatBaselineSuggestion(v).split("\n").join("\n "));
|
|
509
|
+
lines.push("");
|
|
498
510
|
}
|
|
499
|
-
return lines.join(
|
|
511
|
+
return lines.join("\n");
|
|
500
512
|
}
|
|
501
513
|
/**
|
|
502
514
|
* Filter violations down to stable elements.
|
|
@@ -507,11 +519,11 @@ function formatViolationDetails(results, violations) {
|
|
|
507
519
|
* @param accessibilityScanResults
|
|
508
520
|
*/
|
|
509
521
|
function violationFingerprints(accessibilityScanResults) {
|
|
510
|
-
const violationFps = accessibilityScanResults.violations.map(violation => ({
|
|
522
|
+
const violationFps = accessibilityScanResults.violations.map((violation) => ({
|
|
511
523
|
rule: violation.id,
|
|
512
524
|
// These are CSS selectors which uniquely identify each element with
|
|
513
525
|
// a violation of the rule in question.
|
|
514
|
-
targets: violation.nodes.map(node => node.target.map((target) => {
|
|
526
|
+
targets: violation.nodes.map((node) => node.target.map((target) => {
|
|
515
527
|
return normalizeTarget(target);
|
|
516
528
|
})),
|
|
517
529
|
}));
|
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
|
}
|