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