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