@lullabot/playwright-testing 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -0
- package/bin/github-a11y-summary +5 -0
- package/bin/github-failure-summary +8 -0
- package/lib/accessibility-baseline-file.d.ts +47 -0
- package/lib/accessibility-baseline-file.js +205 -0
- package/lib/accessibility-baseline.d.ts +19 -0
- package/lib/accessibility-baseline.js +38 -0
- package/lib/accessible-screenshot.d.ts +181 -0
- package/lib/accessible-screenshot.js +519 -0
- package/lib/focus.d.ts +14 -0
- package/lib/focus.js +28 -0
- package/lib/fonts.d.ts +18 -0
- package/lib/fonts.js +24 -0
- package/lib/frames.d.ts +7 -0
- package/lib/frames.js +27 -0
- package/lib/github/a11y-summary.d.ts +55 -0
- package/lib/github/a11y-summary.js +383 -0
- package/lib/github/attachments.d.ts +98 -0
- package/lib/github/attachments.js +297 -0
- package/lib/github/failure-summary.d.ts +144 -0
- package/lib/github/failure-summary.js +567 -0
- package/lib/github/index.d.ts +6 -0
- package/lib/github/index.js +35 -0
- package/lib/github/report-paths.d.ts +38 -0
- package/lib/github/report-paths.js +200 -0
- package/lib/hover.d.ts +13 -0
- package/lib/hover.js +61 -0
- package/lib/images.d.ts +118 -0
- package/lib/images.js +260 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +29 -0
- package/lib/interaction-states.d.ts +22 -0
- package/lib/interaction-states.js +75 -0
- package/lib/mock/index.d.ts +1 -0
- package/lib/mock/index.js +5 -0
- package/lib/mock/youtube.d.ts +5 -0
- package/lib/mock/youtube.js +38 -0
- package/lib/pseudo-state.d.ts +17 -0
- package/lib/pseudo-state.js +50 -0
- package/lib/videos.d.ts +134 -0
- package/lib/videos.js +349 -0
- package/lib/visualdiff.d.ts +154 -0
- package/lib/visualdiff.js +197 -0
- package/package.json +47 -0
- package/src/accessibility-baseline-file.test.ts +181 -0
- package/src/accessibility-baseline-file.ts +208 -0
- package/src/accessibility-baseline.test.ts +601 -0
- package/src/accessibility-baseline.ts +50 -0
- package/src/accessible-screenshot.test.ts +597 -0
- package/src/accessible-screenshot.ts +809 -0
- package/src/focus.test.ts +34 -0
- package/src/focus.ts +27 -0
- package/src/fonts.test.ts +17 -0
- package/src/fonts.ts +23 -0
- package/src/frames.test.ts +75 -0
- package/src/frames.ts +26 -0
- package/src/github/a11y-summary.test.ts +439 -0
- package/src/github/a11y-summary.ts +421 -0
- package/src/github/attachments.test.ts +248 -0
- package/src/github/attachments.ts +328 -0
- package/src/github/failure-summary.test.ts +636 -0
- package/src/github/failure-summary.ts +720 -0
- package/src/github/index.test.ts +24 -0
- package/src/github/index.ts +35 -0
- package/src/github/report-paths.test.ts +222 -0
- package/src/github/report-paths.ts +208 -0
- package/src/hover.test.ts +76 -0
- package/src/hover.ts +64 -0
- package/src/images.test.ts +355 -0
- package/src/images.ts +299 -0
- package/src/index.ts +13 -0
- package/src/interaction-states.test.ts +48 -0
- package/src/interaction-states.ts +94 -0
- package/src/mock/index.ts +1 -0
- package/src/mock/youtube.test.ts +38 -0
- package/src/mock/youtube.ts +39 -0
- package/src/pseudo-state.test.ts +83 -0
- package/src/pseudo-state.ts +69 -0
- package/src/videos.test.ts +637 -0
- package/src/videos.ts +389 -0
- package/src/visualdiff.test.ts +452 -0
- package/src/visualdiff.ts +381 -0
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
import AxeBuilder from '@axe-core/playwright';
|
|
2
|
+
import {expect, type Locator, type Page, type TestInfo} from "@playwright/test";
|
|
3
|
+
import {waitForAllImages, type WaitForImagesOptions} from "./images.js";
|
|
4
|
+
import {waitForFrames} from "./frames.js"
|
|
5
|
+
import {waitForFonts} from "./fonts.js";
|
|
6
|
+
import {restoreVideoPlayback, waitForVideos, type WaitForVideosOptions} from "./videos.js";
|
|
7
|
+
import {blurActiveElement} from "./focus.js";
|
|
8
|
+
import {clearHover} from "./hover.js";
|
|
9
|
+
import {
|
|
10
|
+
applyInteractionStates,
|
|
11
|
+
validateInteractionStates,
|
|
12
|
+
type ScreenshotInteractionState,
|
|
13
|
+
} from './interaction-states.js'
|
|
14
|
+
import axe from 'axe-core';
|
|
15
|
+
import {
|
|
16
|
+
type AccessibilityBaseline,
|
|
17
|
+
type AccessibilityBaselineEntry,
|
|
18
|
+
validateAccessibilityBaseline,
|
|
19
|
+
} from './accessibility-baseline.js'
|
|
20
|
+
import {
|
|
21
|
+
baselineFilePath,
|
|
22
|
+
buildSeed,
|
|
23
|
+
nextAccessibilityScanCount,
|
|
24
|
+
readBaselineFile,
|
|
25
|
+
ScanKind,
|
|
26
|
+
snapshotExists,
|
|
27
|
+
writeBaselineFile,
|
|
28
|
+
} from './accessibility-baseline-file.js'
|
|
29
|
+
|
|
30
|
+
let a11yActionHintShown = false;
|
|
31
|
+
|
|
32
|
+
export type {InteractionState, ScreenshotInteractionState} from './interaction-states.js'
|
|
33
|
+
|
|
34
|
+
export interface ScreenshotStabilizationOptions {
|
|
35
|
+
images?: WaitForImagesOptions
|
|
36
|
+
videos?: WaitForVideosOptions
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ScreenshotOptions {
|
|
40
|
+
/**
|
|
41
|
+
* When set to `"disabled"`, stops CSS animations, CSS transitions and Web Animations. Animations get different
|
|
42
|
+
* treatment depending on their duration:
|
|
43
|
+
* - finite animations are fast-forwarded to completion, so they'll fire `transitionend` event.
|
|
44
|
+
* - infinite animations are canceled to initial state, and then played over after the screenshot.
|
|
45
|
+
*
|
|
46
|
+
* Defaults to `"disabled"` that disables animations.
|
|
47
|
+
*/
|
|
48
|
+
animations?: "disabled" | "allow";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* When set to `"hide"`, screenshot will hide text caret. When set to `"initial"`, text caret behavior will not be
|
|
52
|
+
* changed. Defaults to `"hide"`.
|
|
53
|
+
*/
|
|
54
|
+
caret?: "hide" | "initial";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* When `true` (the default), blurs the active element before capturing so a
|
|
58
|
+
* stray focus ring left over from earlier test interactions does not appear in
|
|
59
|
+
* only some runs (an invisible-to-a-human diff that still trips the pixel
|
|
60
|
+
* budget). Set to `false` when the screenshot intentionally captures a focused
|
|
61
|
+
* state.
|
|
62
|
+
*/
|
|
63
|
+
blur?: boolean;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* When `true` (the default), moves the pointer onto a temporary transparent
|
|
67
|
+
* shield before capturing so stale pointer activity does not leave an
|
|
68
|
+
* unrelated element in its hover state. Set to `false` when the screenshot
|
|
69
|
+
* intentionally captures a hovered state.
|
|
70
|
+
*/
|
|
71
|
+
clearHover?: boolean;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Real hover and focus states to apply after the page has settled. These are
|
|
75
|
+
* supported in Chromium, Firefox, and WebKit and remain active through both
|
|
76
|
+
* the screenshot and accessibility scan. At most one locator may receive
|
|
77
|
+
* each state, matching what a real pointer and keyboard can do.
|
|
78
|
+
*/
|
|
79
|
+
interactionStates?: ScreenshotInteractionState[];
|
|
80
|
+
|
|
81
|
+
/** Configuration for image and video settling. */
|
|
82
|
+
stabilization?: ScreenshotStabilizationOptions;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* An object specifying the page area to capture, in CSS pixels.
|
|
86
|
+
*/
|
|
87
|
+
clip?: {
|
|
88
|
+
x: number;
|
|
89
|
+
y: number;
|
|
90
|
+
width: number;
|
|
91
|
+
height: number;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Defaults to
|
|
96
|
+
* `false`.
|
|
97
|
+
*/
|
|
98
|
+
fullPage?: boolean;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Derive a page screenshot clip from this locator after readiness waits and
|
|
102
|
+
* scrolling. Round x, y, width and height to integer CSS pixels without
|
|
103
|
+
* modifying rendering. Mutually exclusive with clip and a locator target.
|
|
104
|
+
* Use fullPage for targets larger than the viewport.
|
|
105
|
+
*/
|
|
106
|
+
clipLocator?: Locator;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink
|
|
110
|
+
* box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box.
|
|
111
|
+
*/
|
|
112
|
+
mask?: Array<Locator>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Specify the color of the overlay box for masked elements, in
|
|
116
|
+
* [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`.
|
|
117
|
+
*/
|
|
118
|
+
maskColor?: string;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* An acceptable ratio of pixels that are different to the total amount of pixels, between `0` and `1`. Default is
|
|
122
|
+
* configurable with `TestConfig.expect`. Unset by default.
|
|
123
|
+
*/
|
|
124
|
+
maxDiffPixelRatio?: number;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* An acceptable amount of pixels that could be different. Default is configurable with `TestConfig.expect`. Unset by
|
|
128
|
+
* default.
|
|
129
|
+
*/
|
|
130
|
+
maxDiffPixels?: number;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images.
|
|
134
|
+
* Defaults to `false`.
|
|
135
|
+
*/
|
|
136
|
+
omitBackground?: boolean;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* When set to `"css"`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this
|
|
140
|
+
* will keep screenshots small. Using `"device"` option will produce a single pixel per each device pixel, so
|
|
141
|
+
* screenshots of high-dpi devices will be twice as large or even larger.
|
|
142
|
+
*
|
|
143
|
+
* Defaults to `"css"`.
|
|
144
|
+
*/
|
|
145
|
+
scale?: "css" | "device";
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A stylesheet, or list of stylesheets, to apply while taking the screenshot.
|
|
149
|
+
* The styles pierce Shadow DOM and apply to inner frames.
|
|
150
|
+
*/
|
|
151
|
+
stylePath?: string | string[];
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the
|
|
155
|
+
* same pixel in compared images, between zero (strict) and one (lax), default is configurable with
|
|
156
|
+
* `TestConfig.expect`. Defaults to `0.2`.
|
|
157
|
+
*/
|
|
158
|
+
threshold?: number;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
|
|
162
|
+
*/
|
|
163
|
+
timeout?: number;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Accessibility options passed through to checkAccessibility().
|
|
167
|
+
*/
|
|
168
|
+
accessibility?: AccessibilityOptions;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface AccessibilityOptions {
|
|
172
|
+
/** axe tags for WCAG scan. Default: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] */
|
|
173
|
+
wcagTags?: string[]
|
|
174
|
+
|
|
175
|
+
/** Additional CSS selectors to exclude from both scans. */
|
|
176
|
+
exclude?: string[]
|
|
177
|
+
|
|
178
|
+
/** CSS selectors to exclude only from the best-practice scan. */
|
|
179
|
+
bestPracticeExclude?: string[]
|
|
180
|
+
|
|
181
|
+
/** CSS selectors to exclude only from the WCAG scan. */
|
|
182
|
+
wcagExclude?: string[]
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Best-practice scan mode.
|
|
186
|
+
* - 'soft': uses expect.soft() (default, current behaviour)
|
|
187
|
+
* - 'hard': uses expect() — test fails immediately on violations
|
|
188
|
+
* - 'off': skips best-practice scan entirely
|
|
189
|
+
*/
|
|
190
|
+
bestPracticeMode?: 'soft' | 'hard' | 'off'
|
|
191
|
+
|
|
192
|
+
/** Additional axe rules to enable/disable. */
|
|
193
|
+
rules?: Record<string, { enabled: boolean }>
|
|
194
|
+
|
|
195
|
+
/** Baseline of known violations. When provided, violations matching the baseline are suppressed and toMatchSnapshot() is skipped. */
|
|
196
|
+
baseline?: AccessibilityBaseline
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* When true, captures a full-page screenshot with violating elements
|
|
200
|
+
* highlighted (red outline) and attaches it to the test report.
|
|
201
|
+
* Default: true.
|
|
202
|
+
*/
|
|
203
|
+
screenshotViolations?: boolean
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Run accessibility checks on the current page using axe-core.
|
|
208
|
+
*
|
|
209
|
+
* Runs a best-practice scan (unless bestPracticeMode is 'off') and a WCAG scan,
|
|
210
|
+
* attaching JSON results and asserting on violations via snapshots.
|
|
211
|
+
*
|
|
212
|
+
* @param page The Page fixture from the test.
|
|
213
|
+
* @param testInfo The testInfo object from the test.
|
|
214
|
+
* @param options Accessibility options to customise the scan.
|
|
215
|
+
*/
|
|
216
|
+
export async function checkAccessibility(page: Page, testInfo: TestInfo, options?: AccessibilityOptions) {
|
|
217
|
+
const {
|
|
218
|
+
wcagTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
|
|
219
|
+
exclude = [],
|
|
220
|
+
bestPracticeExclude = [],
|
|
221
|
+
wcagExclude = [],
|
|
222
|
+
bestPracticeMode = 'soft',
|
|
223
|
+
rules,
|
|
224
|
+
baseline,
|
|
225
|
+
screenshotViolations = true,
|
|
226
|
+
} = options ?? {}
|
|
227
|
+
|
|
228
|
+
if (process.env.CI && !a11yActionHintShown) {
|
|
229
|
+
console.log('Tip: Use @lullabot/playwright-testing/github to surface accessibility violations in pull requests.')
|
|
230
|
+
a11yActionHintShown = true
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Add @a11y annotation (deduplicated).
|
|
234
|
+
if (!testInfo.annotations.some(a => a.type === '@a11y')) {
|
|
235
|
+
testInfo.annotations.push({ type: '@a11y' })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (bestPracticeMode !== 'off') {
|
|
239
|
+
const bpResults = await runBestPracticeScan(page, testInfo, {
|
|
240
|
+
exclude: [...exclude, ...bestPracticeExclude],
|
|
241
|
+
rules,
|
|
242
|
+
})
|
|
243
|
+
// Best-practice always uses expect.soft() so the WCAG scan below runs
|
|
244
|
+
// even when best-practice violations exist. `bestPracticeMode === 'hard'`
|
|
245
|
+
// is preserved as a marker but does not change soft-vs-hard here.
|
|
246
|
+
await dispatchAssertion({
|
|
247
|
+
testInfo,
|
|
248
|
+
results: bpResults,
|
|
249
|
+
scan: 'best-practice',
|
|
250
|
+
expectFn: expect.soft,
|
|
251
|
+
scanLabel: 'Best-practice scan',
|
|
252
|
+
}, baseline)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const wcagScanResults = await runWcagScan(page, testInfo, {
|
|
256
|
+
wcagTags,
|
|
257
|
+
exclude: [...exclude, ...wcagExclude],
|
|
258
|
+
rules,
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
if (screenshotViolations && wcagScanResults.violations.length > 0) {
|
|
262
|
+
await screenshotViolatingElements(page, testInfo, wcagScanResults)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
await dispatchAssertion({
|
|
266
|
+
testInfo,
|
|
267
|
+
results: wcagScanResults,
|
|
268
|
+
scan: 'wcag',
|
|
269
|
+
expectFn: expect,
|
|
270
|
+
scanLabel: 'WCAG scan',
|
|
271
|
+
}, baseline)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
interface ScanContext {
|
|
275
|
+
testInfo: TestInfo
|
|
276
|
+
results: axe.AxeResults
|
|
277
|
+
scan: ScanKind
|
|
278
|
+
expectFn: typeof expect | typeof expect.soft
|
|
279
|
+
scanLabel: string
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Dispatch the assertion for one scan to the correct mode:
|
|
284
|
+
*
|
|
285
|
+
* 1. Explicit in-code `baseline` option -> baseline mode (existing behaviour).
|
|
286
|
+
* 2. A snapshot file already exists on disk for this test -> snapshot mode
|
|
287
|
+
* (existing behaviour, preserves all previously committed snapshots).
|
|
288
|
+
* 3. Playwright is in explicit snapshot-update mode (`all`/`changed`, set via
|
|
289
|
+
* `--update-snapshots`) -> snapshot mode (Playwright will create the
|
|
290
|
+
* snapshot). `'missing'` is Playwright's default config value and does NOT
|
|
291
|
+
* indicate explicit user intent, so it falls through to baseline mode.
|
|
292
|
+
* 4. Otherwise -> on-disk baseline mode. If the JSON file exists, load and
|
|
293
|
+
* match against it. If it does not, seed it. On CI seeding fails the
|
|
294
|
+
* test (matching Playwright's missing-snapshot behaviour); locally,
|
|
295
|
+
* seeding passes so the first run is green.
|
|
296
|
+
*/
|
|
297
|
+
async function dispatchAssertion(ctx: ScanContext, inCodeBaseline?: AccessibilityBaseline): Promise<void> {
|
|
298
|
+
if (inCodeBaseline) {
|
|
299
|
+
return assertBaseline(ctx, inCodeBaseline)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (await snapshotExists(ctx.testInfo)) {
|
|
303
|
+
return assertSnapshot(ctx)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const update = ctx.testInfo.config?.updateSnapshots
|
|
307
|
+
if (update === 'all' || update === 'changed') {
|
|
308
|
+
return assertSnapshot(ctx)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const callCount = nextAccessibilityScanCount(ctx.testInfo, ctx.scan)
|
|
312
|
+
const filePath = baselineFilePath(ctx.testInfo, ctx.scan, callCount)
|
|
313
|
+
|
|
314
|
+
const existing = await readBaselineFile(filePath)
|
|
315
|
+
if (existing) {
|
|
316
|
+
return assertBaseline(ctx, existing.violations)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Seed and either pass (local) or fail (CI).
|
|
320
|
+
const normalized = extractNormalizedViolations(ctx.results)
|
|
321
|
+
const seedViolations: AccessibilityBaselineEntry[] = normalized.map(v => ({
|
|
322
|
+
rule: v.rule,
|
|
323
|
+
targets: v.targets,
|
|
324
|
+
reason: 'TODO',
|
|
325
|
+
willBeFixedIn: 'TODO',
|
|
326
|
+
}))
|
|
327
|
+
const seed = buildSeed(seedViolations)
|
|
328
|
+
await writeBaselineFile(filePath, seed)
|
|
329
|
+
await ctx.testInfo.attach(`a11y-${ctx.scan}-baseline-seed`, {
|
|
330
|
+
path: filePath,
|
|
331
|
+
contentType: 'application/json',
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
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('a11y baseline file present')
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
ctx.testInfo.annotations.push({
|
|
341
|
+
type: 'Accessibility',
|
|
342
|
+
description: seed.violations.length === 0
|
|
343
|
+
? `${ctx.scanLabel}: a11y baseline seeded at ${filePath} (no violations).`
|
|
344
|
+
: `${ctx.scanLabel}: a11y baseline seeded at ${filePath} with ${seed.violations.length} entries — fill in reason/willBeFixedIn before committing.`,
|
|
345
|
+
})
|
|
346
|
+
// A local seed is deliberately permissive and contains TODO metadata. The
|
|
347
|
+
// next run validates the committed file before treating entries as waivers.
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Run the best-practice axe scan, attach results, and return them for
|
|
352
|
+
* dispatch. The assertion (snapshot vs baseline) is decided by the
|
|
353
|
+
* dispatcher based on what's already on disk and the `bestPracticeMode`
|
|
354
|
+
* option drives soft- vs hard-failure behaviour.
|
|
355
|
+
*/
|
|
356
|
+
async function runBestPracticeScan(
|
|
357
|
+
page: Page,
|
|
358
|
+
testInfo: TestInfo,
|
|
359
|
+
opts: {
|
|
360
|
+
exclude: string[]
|
|
361
|
+
rules?: Record<string, { enabled: boolean }>
|
|
362
|
+
},
|
|
363
|
+
): Promise<axe.AxeResults> {
|
|
364
|
+
const builder = new AxeBuilder({ page })
|
|
365
|
+
.withTags(['best-practice'])
|
|
366
|
+
|
|
367
|
+
for (const selector of opts.exclude) {
|
|
368
|
+
builder.exclude(selector)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (opts.rules) {
|
|
372
|
+
builder.options({ rules: opts.rules })
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const results = await builder.analyze()
|
|
376
|
+
|
|
377
|
+
await testInfo.attach('a11y-best-practice-scan-results', {
|
|
378
|
+
body: JSON.stringify(results, null, 2),
|
|
379
|
+
contentType: 'application/json'
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
testInfo.annotations.push({
|
|
383
|
+
type: 'Accessibility',
|
|
384
|
+
description: `Best-practice scan: ${results.violations.length} violations (${results.passes.length} rules passed)`
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
return results
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Run the WCAG axe scan, attach results, and return them for assertion.
|
|
392
|
+
*/
|
|
393
|
+
async function runWcagScan(
|
|
394
|
+
page: Page,
|
|
395
|
+
testInfo: TestInfo,
|
|
396
|
+
opts: {
|
|
397
|
+
wcagTags: string[]
|
|
398
|
+
exclude: string[]
|
|
399
|
+
rules?: Record<string, { enabled: boolean }>
|
|
400
|
+
},
|
|
401
|
+
): Promise<axe.AxeResults> {
|
|
402
|
+
const builder = new AxeBuilder({ page })
|
|
403
|
+
.withTags(opts.wcagTags)
|
|
404
|
+
|
|
405
|
+
for (const selector of opts.exclude) {
|
|
406
|
+
builder.exclude(selector)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (opts.rules) {
|
|
410
|
+
builder.options({ rules: opts.rules })
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const results = await builder.analyze()
|
|
414
|
+
|
|
415
|
+
await testInfo.attach('a11y-wcag-scan-results', {
|
|
416
|
+
body: JSON.stringify(results, null, 2),
|
|
417
|
+
contentType: 'application/json'
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
return results
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Take a full-page screenshot with violating elements highlighted and
|
|
425
|
+
* attach it to the test report.
|
|
426
|
+
*/
|
|
427
|
+
async function screenshotViolatingElements(page: Page, testInfo: TestInfo, results: axe.AxeResults) {
|
|
428
|
+
// Collect all raw CSS selectors from violation nodes.
|
|
429
|
+
const selectors = results.violations
|
|
430
|
+
.flatMap(v => v.nodes)
|
|
431
|
+
.flatMap(n => n.target)
|
|
432
|
+
.filter((t): t is string => typeof t === 'string')
|
|
433
|
+
|
|
434
|
+
if (selectors.length === 0) return
|
|
435
|
+
|
|
436
|
+
// Inject highlight outlines on all violating elements.
|
|
437
|
+
await page.evaluate((sels) => {
|
|
438
|
+
const style = document.createElement('style')
|
|
439
|
+
style.setAttribute('data-a11y-highlight', 'true')
|
|
440
|
+
// Use a CSS rule for each selector so the outline persists even if
|
|
441
|
+
// elements are repositioned during the screenshot.
|
|
442
|
+
const rules = sels.map(s => `${s} { outline: 3px solid #e53e3e !important; outline-offset: 2px !important; }`).join('\n')
|
|
443
|
+
style.textContent = rules
|
|
444
|
+
document.head.appendChild(style)
|
|
445
|
+
}, selectors)
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
const screenshot = await page.screenshot({ fullPage: true })
|
|
449
|
+
|
|
450
|
+
await testInfo.attach('a11y-violation-screenshot', {
|
|
451
|
+
body: screenshot,
|
|
452
|
+
contentType: 'image/png',
|
|
453
|
+
})
|
|
454
|
+
} finally {
|
|
455
|
+
// Remove the injected styles even if capture or report attachment fails.
|
|
456
|
+
await page.evaluate(() => {
|
|
457
|
+
document.querySelector('style[data-a11y-highlight]')?.remove()
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Assert violations against a baseline allowlist (in-code or on-disk).
|
|
464
|
+
*/
|
|
465
|
+
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 = []
|
|
471
|
+
|
|
472
|
+
for (const violation of allViolations) {
|
|
473
|
+
const baselineIndex = baseline.findIndex((entry) => {
|
|
474
|
+
if (entry.rule !== violation.rule) return false
|
|
475
|
+
// Check for at least one overlapping normalized target.
|
|
476
|
+
return entry.targets.some(baselineTarget =>
|
|
477
|
+
violation.targets.some(violationTarget => violationTarget === baselineTarget)
|
|
478
|
+
)
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
if (baselineIndex >= 0) {
|
|
482
|
+
matchedBaselineIndices.add(baselineIndex)
|
|
483
|
+
const entry = baseline[baselineIndex]
|
|
484
|
+
testInfo.annotations.push({
|
|
485
|
+
type: 'Baselined a11y violation',
|
|
486
|
+
description: `${entry.rule}: ${entry.reason} — ${entry.willBeFixedIn}`,
|
|
487
|
+
})
|
|
488
|
+
} else {
|
|
489
|
+
unmatchedViolations.push(violation)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Report stale baseline entries.
|
|
494
|
+
baseline.forEach((entry, idx) => {
|
|
495
|
+
if (!matchedBaselineIndices.has(idx)) {
|
|
496
|
+
testInfo.annotations.push({
|
|
497
|
+
type: 'Stale a11y baseline entry',
|
|
498
|
+
description: `${entry.rule} on ${entry.targets.join(', ')} — no longer detected`,
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
// Summary annotation for baseline mode.
|
|
504
|
+
const baselinedCount = matchedBaselineIndices.size
|
|
505
|
+
testInfo.annotations.push({
|
|
506
|
+
type: 'Accessibility',
|
|
507
|
+
description: `${scanLabel}: ${unmatchedViolations.length} new violations (${baselinedCount} baselined)`,
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
// Fail on unmatched violations with detailed output.
|
|
511
|
+
if (unmatchedViolations.length > 0) {
|
|
512
|
+
const details = formatViolationDetails(results, unmatchedViolations)
|
|
513
|
+
expectFn(null, details).toBe('no accessibility violations')
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Assert via snapshot comparison (legacy mode for tests with committed snapshots).
|
|
519
|
+
*/
|
|
520
|
+
async function assertSnapshot(ctx: ScanContext) {
|
|
521
|
+
const { testInfo, results, scan, expectFn } = ctx
|
|
522
|
+
|
|
523
|
+
// Match the legacy summary annotation phrasing for WCAG; best-practice's
|
|
524
|
+
// pre-existing summary annotation is emitted in runBestPracticeScan.
|
|
525
|
+
if (scan === 'wcag') {
|
|
526
|
+
testInfo.annotations.push({
|
|
527
|
+
type: 'Accessibility',
|
|
528
|
+
description: `WCAG scan: ${results.violations.length} violations (${results.passes.length} rules passed)`
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
// If there are violations, attach baseline suggestions and push annotation.
|
|
532
|
+
if (results.violations.length > 0) {
|
|
533
|
+
const allViolations = extractNormalizedViolations(results)
|
|
534
|
+
const suggestions = allViolations.map(v => formatBaselineSuggestion(v)).join('\n')
|
|
535
|
+
await testInfo.attach('a11y-baseline-suggestions', {
|
|
536
|
+
body: suggestions,
|
|
537
|
+
contentType: 'text/plain',
|
|
538
|
+
})
|
|
539
|
+
testInfo.annotations.push({
|
|
540
|
+
type: 'Accessibility',
|
|
541
|
+
description: 'To manage violations explicitly, switch to baseline mode. See a11y-baseline-suggestions attachment.',
|
|
542
|
+
})
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return expectFn(violationFingerprints(results)).toMatchSnapshot()
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Take a visual comparison, and also ensure there's no accessibility issues.
|
|
551
|
+
*
|
|
552
|
+
* @param page The Page fixture from the test.
|
|
553
|
+
* @param testInfo The testInfo object from the test.
|
|
554
|
+
* @param options Screenshot options from toHaveScreenshot().
|
|
555
|
+
* @param scrollLocator A locator to ensure is visible before taking the screenshot.
|
|
556
|
+
* @param locator A specific locator to take the screenshot of. aXe still checks the whole page.
|
|
557
|
+
*/
|
|
558
|
+
export async function takeAccessibleScreenshot(page: Page, testInfo: TestInfo, options?: ScreenshotOptions, scrollLocator?: Locator, locator?: Locator|Page) {
|
|
559
|
+
const screenshotOptions = options ?? {}
|
|
560
|
+
|
|
561
|
+
if (screenshotOptions.clipLocator && (screenshotOptions.clip || (locator && locator !== page))) {
|
|
562
|
+
throw new Error('clipLocator cannot be combined with clip or a locator screenshot target.')
|
|
563
|
+
}
|
|
564
|
+
if (screenshotOptions.clipLocator && screenshotOptions.clipLocator.page() !== page) {
|
|
565
|
+
throw new Error('clipLocator must belong to the screenshot page.')
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// The default is 5 seconds. However, even on a fast machine it can take
|
|
569
|
+
// longer than 5 seconds for large pages like node forms to stabilize. This
|
|
570
|
+
// doesn't affect end users because the page still being rendered is
|
|
571
|
+
// typically below the viewport, and it's loaded by the time they scroll.
|
|
572
|
+
// So, we set this to at least 10 seconds, unless it's already larger.
|
|
573
|
+
// To test changing this, try running this command and see if it times out:
|
|
574
|
+
const interactionStates = screenshotOptions.interactionStates ?? []
|
|
575
|
+
validateInteractionStates(interactionStates)
|
|
576
|
+
|
|
577
|
+
// Do not pass package-specific orchestration options to Playwright's matcher.
|
|
578
|
+
const {
|
|
579
|
+
clipLocator: _clipLocator,
|
|
580
|
+
accessibility: _accessibility,
|
|
581
|
+
blur: _blur,
|
|
582
|
+
clearHover: _clearHover,
|
|
583
|
+
interactionStates: _interactionStates,
|
|
584
|
+
stabilization,
|
|
585
|
+
...nativeScreenshotOptions
|
|
586
|
+
} = screenshotOptions
|
|
587
|
+
const playwrightScreenshotOptions = {
|
|
588
|
+
...nativeScreenshotOptions,
|
|
589
|
+
timeout: Math.max(nativeScreenshotOptions.timeout ?? 0, 10000),
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Blur any focused element so a stray focus ring does not make the screenshot
|
|
593
|
+
// non-deterministic, unless the caller is intentionally capturing focus. Do
|
|
594
|
+
// this before the load/stability waits so that any layout change a blur
|
|
595
|
+
// handler triggers (a closing dropdown, injected validation markup) settles
|
|
596
|
+
// before we capture.
|
|
597
|
+
if (screenshotOptions.blur !== false) {
|
|
598
|
+
await blurActiveElement(page);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let removeHoverShield = screenshotOptions.clearHover === false ? undefined : await clearHover(page);
|
|
602
|
+
let videoPlaybackRestored = false
|
|
603
|
+
let cleanupInteractionStates: (() => Promise<void>) | undefined
|
|
604
|
+
|
|
605
|
+
try {
|
|
606
|
+
await waitForFrames(page);
|
|
607
|
+
// Loading lazy frames can scroll the page. Load images afterwards so that
|
|
608
|
+
// any images exposed by that scrolling are settled and waitForAllImages()
|
|
609
|
+
// restores the viewport to the top before capture.
|
|
610
|
+
await waitForAllImages(page, stabilization?.images);
|
|
611
|
+
await waitForFonts(page);
|
|
612
|
+
// Last of the waits, so the video frames it composites are as fresh as
|
|
613
|
+
// possible when the capture happens. It restores the scroll position it
|
|
614
|
+
// found, so it does not disturb where the waits above leave the page.
|
|
615
|
+
await waitForVideos(page, stabilization?.videos);
|
|
616
|
+
|
|
617
|
+
if (scrollLocator) {
|
|
618
|
+
await scrollLocator.scrollIntoViewIfNeeded();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// A hover action cannot hit its target through the transparent shield used
|
|
622
|
+
// to clear incidental hover. Remove it only after all stability waits, then
|
|
623
|
+
// apply real hover before real focus so focusing cannot disturb the pointer.
|
|
624
|
+
const hasHover = interactionStates.some(({states}) => states.includes('hover'))
|
|
625
|
+
if (hasHover) {
|
|
626
|
+
await removeHoverShield?.()
|
|
627
|
+
removeHoverShield = undefined
|
|
628
|
+
}
|
|
629
|
+
if (interactionStates.length > 0) {
|
|
630
|
+
cleanupInteractionStates = await applyInteractionStates(interactionStates, {
|
|
631
|
+
clearHover: async () => {
|
|
632
|
+
const removeInteractionShield = await clearHover(page)
|
|
633
|
+
await removeInteractionShield()
|
|
634
|
+
},
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
let locatorToScreenshot: Page|Locator = page;
|
|
639
|
+
if (locator) {
|
|
640
|
+
locatorToScreenshot = locator;
|
|
641
|
+
}
|
|
642
|
+
if (screenshotOptions.clipLocator) {
|
|
643
|
+
await screenshotOptions.clipLocator.scrollIntoViewIfNeeded()
|
|
644
|
+
// Scrolling can expose lazy content. Await fonts and two paint frames
|
|
645
|
+
// before measuring; the existing waits have already loaded page media.
|
|
646
|
+
await waitForFonts(page)
|
|
647
|
+
await page.evaluate(() => new Promise<void>(resolve =>
|
|
648
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
|
|
649
|
+
const bounds = await screenshotOptions.clipLocator.boundingBox()
|
|
650
|
+
if (!bounds) throw new Error('clipLocator has no visible bounding box.')
|
|
651
|
+
// boundingBox is in main-frame viewport coordinates, including for
|
|
652
|
+
// locators inside frames. Read scroll offsets from the main page.
|
|
653
|
+
const geometry = await page.evaluate(() => ({
|
|
654
|
+
x: window.scrollX, y: window.scrollY,
|
|
655
|
+
width: window.innerWidth, height: window.innerHeight,
|
|
656
|
+
}))
|
|
657
|
+
const clip = {
|
|
658
|
+
x: Math.round(bounds.x + (screenshotOptions.fullPage ? geometry.x : 0)),
|
|
659
|
+
y: Math.round(bounds.y + (screenshotOptions.fullPage ? geometry.y : 0)),
|
|
660
|
+
width: Math.round(bounds.width),
|
|
661
|
+
height: Math.round(bounds.height),
|
|
662
|
+
}
|
|
663
|
+
if (clip.width <= 0 || clip.height <= 0) {
|
|
664
|
+
throw new Error('clipLocator must have positive rounded dimensions.')
|
|
665
|
+
}
|
|
666
|
+
if (!screenshotOptions.fullPage && (clip.x < 0 || clip.y < 0 || clip.x + clip.width > geometry.width || clip.y + clip.height > geometry.height)) {
|
|
667
|
+
throw new Error('clipLocator does not fit in the viewport; use fullPage: true to capture the whole target.')
|
|
668
|
+
}
|
|
669
|
+
if (clip.x < 0 || clip.y < 0) {
|
|
670
|
+
throw new Error('clipLocator must have non-negative document coordinates.')
|
|
671
|
+
}
|
|
672
|
+
playwrightScreenshotOptions.clip = clip
|
|
673
|
+
}
|
|
674
|
+
// Soft failure here so we can get accessibility violations too.
|
|
675
|
+
await expect.soft(locatorToScreenshot).toHaveScreenshot(playwrightScreenshotOptions);
|
|
676
|
+
|
|
677
|
+
// Settling a video pauses it, clears `autoplay` and rewinds it. That is only
|
|
678
|
+
// wanted for the duration of the capture: a test that screenshots a page and
|
|
679
|
+
// then asserts that a video is playing should still pass.
|
|
680
|
+
await restoreVideoPlayback(page);
|
|
681
|
+
videoPlaybackRestored = true
|
|
682
|
+
|
|
683
|
+
await removeHoverShield?.()
|
|
684
|
+
removeHoverShield = undefined
|
|
685
|
+
|
|
686
|
+
return await checkAccessibility(page, testInfo, screenshotOptions.accessibility)
|
|
687
|
+
} finally {
|
|
688
|
+
// Nest cleanup so a failure in one operation cannot prevent the remaining
|
|
689
|
+
// browser state from being restored.
|
|
690
|
+
try {
|
|
691
|
+
await removeHoverShield?.();
|
|
692
|
+
} finally {
|
|
693
|
+
try {
|
|
694
|
+
if (!videoPlaybackRestored) {
|
|
695
|
+
await restoreVideoPlayback(page);
|
|
696
|
+
}
|
|
697
|
+
} finally {
|
|
698
|
+
await cleanupInteractionStates?.()
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Normalize a single CSS selector target for stable comparison.
|
|
706
|
+
*
|
|
707
|
+
* Replaces unique numeric HTML IDs and aria-labelledby suffixes with
|
|
708
|
+
* a stable placeholder so that snapshots and baseline matching are
|
|
709
|
+
* deterministic across runs.
|
|
710
|
+
*/
|
|
711
|
+
export function normalizeTarget(target: string | string[]): string | string[] {
|
|
712
|
+
const uniqueHtmlID = /(#[^#]*)--\d+/
|
|
713
|
+
const ariaLabelledById = /(aria-labelledby="[^"]+)--\d+"/
|
|
714
|
+
if (typeof target === 'string') {
|
|
715
|
+
return target
|
|
716
|
+
.replace(uniqueHtmlID, '$1--UNIQUE-ID')
|
|
717
|
+
.replace(ariaLabelledById, '$1--UNIQUE-ID"')
|
|
718
|
+
}
|
|
719
|
+
return target
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
interface NormalizedViolation {
|
|
723
|
+
rule: string
|
|
724
|
+
targets: string[]
|
|
725
|
+
description: string
|
|
726
|
+
impact: string
|
|
727
|
+
helpUrl: string
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Extract violations from axe results and normalize their targets into
|
|
732
|
+
* flat, deduplicated CSS selector strings.
|
|
733
|
+
*/
|
|
734
|
+
function extractNormalizedViolations(results: axe.AxeResults): NormalizedViolation[] {
|
|
735
|
+
return results.violations.map(violation => {
|
|
736
|
+
const flatTargets: string[] = []
|
|
737
|
+
for (const node of violation.nodes) {
|
|
738
|
+
for (const target of node.target) {
|
|
739
|
+
const normalized = normalizeTarget(target)
|
|
740
|
+
const str = typeof normalized === 'string' ? normalized : normalized.join(' ')
|
|
741
|
+
if (!flatTargets.includes(str)) {
|
|
742
|
+
flatTargets.push(str)
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
rule: violation.id,
|
|
748
|
+
targets: flatTargets,
|
|
749
|
+
description: violation.description,
|
|
750
|
+
impact: violation.impact ?? 'unknown',
|
|
751
|
+
helpUrl: violation.helpUrl,
|
|
752
|
+
}
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Format a single violation as a copy-pasteable baseline entry.
|
|
758
|
+
*/
|
|
759
|
+
function formatBaselineSuggestion(violation: NormalizedViolation): string {
|
|
760
|
+
const targetsStr = violation.targets.map(t => `'${t}'`).join(', ')
|
|
761
|
+
return `{
|
|
762
|
+
rule: '${violation.rule}',
|
|
763
|
+
targets: [${targetsStr}],
|
|
764
|
+
reason: '', // TODO: explain why this is accepted
|
|
765
|
+
willBeFixedIn: '', // TODO: link to tracking ticket
|
|
766
|
+
},`
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Format detailed failure output for unmatched violations, including
|
|
771
|
+
* copy-pasteable baseline entries.
|
|
772
|
+
*/
|
|
773
|
+
function formatViolationDetails(results: axe.AxeResults, violations: NormalizedViolation[]): string {
|
|
774
|
+
const lines: string[] = []
|
|
775
|
+
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(' Add to your baseline to accept this violation:')
|
|
783
|
+
lines.push(' ' + formatBaselineSuggestion(v).split('\n').join('\n '))
|
|
784
|
+
lines.push('')
|
|
785
|
+
}
|
|
786
|
+
return lines.join('\n')
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Filter violations down to stable elements.
|
|
791
|
+
*
|
|
792
|
+
* If we try to create a snapshot of the entire report, it will fail on random
|
|
793
|
+
* unique HTML IDs.
|
|
794
|
+
*
|
|
795
|
+
* @param accessibilityScanResults
|
|
796
|
+
*/
|
|
797
|
+
function violationFingerprints(accessibilityScanResults: axe.AxeResults) {
|
|
798
|
+
const violationFps = accessibilityScanResults.violations.map(violation => ({
|
|
799
|
+
rule: violation.id,
|
|
800
|
+
// These are CSS selectors which uniquely identify each element with
|
|
801
|
+
// a violation of the rule in question.
|
|
802
|
+
targets: violation.nodes.map(node => node.target.map((target) => {
|
|
803
|
+
return normalizeTarget(target)
|
|
804
|
+
})),
|
|
805
|
+
}));
|
|
806
|
+
|
|
807
|
+
return JSON.stringify(violationFps, null, 2);
|
|
808
|
+
|
|
809
|
+
}
|