@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,601 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// Mock AxeBuilder — must be hoisted since vi.mock is hoisted
|
|
4
|
+
const { mockWithTags, mockExclude, mockOptions, mockAnalyze, MockAxeBuilder } = vi.hoisted(() => {
|
|
5
|
+
const mockWithTags = vi.fn().mockReturnThis()
|
|
6
|
+
const mockExclude = vi.fn().mockReturnThis()
|
|
7
|
+
const mockOptions = vi.fn().mockReturnThis()
|
|
8
|
+
const mockAnalyze = vi.fn()
|
|
9
|
+
|
|
10
|
+
class MockAxeBuilder {
|
|
11
|
+
withTags = mockWithTags
|
|
12
|
+
exclude = mockExclude
|
|
13
|
+
options = mockOptions
|
|
14
|
+
analyze = mockAnalyze
|
|
15
|
+
constructor(_args: any) {}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return { mockWithTags, mockExclude, mockOptions, mockAnalyze, MockAxeBuilder }
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
vi.mock('@axe-core/playwright', () => ({
|
|
22
|
+
default: MockAxeBuilder,
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
// Mock @playwright/test
|
|
26
|
+
const { mockToMatchSnapshot, mockToBe, mockExpectSoft, mockExpectHard } = vi.hoisted(() => {
|
|
27
|
+
const mockToMatchSnapshot = vi.fn()
|
|
28
|
+
const mockToBe = vi.fn()
|
|
29
|
+
const mockExpectSoft = vi.fn(() => ({
|
|
30
|
+
toMatchSnapshot: mockToMatchSnapshot,
|
|
31
|
+
toHaveScreenshot: vi.fn(),
|
|
32
|
+
}))
|
|
33
|
+
const mockExpectHard = vi.fn((_val?: any, _msg?: string) => ({
|
|
34
|
+
toMatchSnapshot: mockToMatchSnapshot,
|
|
35
|
+
toBe: mockToBe,
|
|
36
|
+
}))
|
|
37
|
+
return { mockToMatchSnapshot, mockToBe, mockExpectSoft, mockExpectHard }
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
vi.mock('@playwright/test', () => {
|
|
41
|
+
const expectFn = Object.assign(mockExpectHard, {
|
|
42
|
+
soft: mockExpectSoft,
|
|
43
|
+
})
|
|
44
|
+
return {
|
|
45
|
+
expect: expectFn,
|
|
46
|
+
Locator: {},
|
|
47
|
+
Page: {},
|
|
48
|
+
TestInfo: {},
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
import { checkAccessibility } from './accessible-screenshot'
|
|
53
|
+
import { defineAccessibilityBaseline, AccessibilityBaseline } from './accessibility-baseline'
|
|
54
|
+
|
|
55
|
+
function makeAxeResults(overrides?: Partial<{ violations: any[], passes: any[] }>) {
|
|
56
|
+
return {
|
|
57
|
+
violations: [],
|
|
58
|
+
passes: [],
|
|
59
|
+
incomplete: [],
|
|
60
|
+
inapplicable: [],
|
|
61
|
+
...overrides,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeTestInfo(opts?: { updateSnapshots?: 'all' | 'changed' | 'missing' | 'none', snapshotsDir?: string, title?: string }) {
|
|
66
|
+
// Default to `updateSnapshots: 'all'` so that, in the absence of an
|
|
67
|
+
// explicit `baseline` option, the dispatch routes to snapshot mode (the
|
|
68
|
+
// legacy behavior these tests were originally written against). Tests
|
|
69
|
+
// that want to exercise on-disk baseline mode override this.
|
|
70
|
+
const dir = opts?.snapshotsDir ?? '/tmp/__a11y_test_snapshots__'
|
|
71
|
+
const title = opts?.title ?? 'mocked test'
|
|
72
|
+
return {
|
|
73
|
+
testId: `mocked-${Math.random()}`,
|
|
74
|
+
title,
|
|
75
|
+
titlePath: ['file.spec.ts', title],
|
|
76
|
+
annotations: [] as Array<{ type: string, description?: string }>,
|
|
77
|
+
attach: vi.fn().mockResolvedValue(undefined),
|
|
78
|
+
snapshotPath: (...segs: string[]) => `${dir}/${segs.join('/')}`,
|
|
79
|
+
config: { updateSnapshots: opts?.updateSnapshots ?? 'all' },
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function makePage() {
|
|
84
|
+
return {
|
|
85
|
+
evaluate: vi.fn().mockResolvedValue(undefined),
|
|
86
|
+
screenshot: vi.fn().mockResolvedValue(Buffer.from('fake-png')),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function makeViolation(rule: string, targets: string[][], extras?: { description?: string, impact?: string, helpUrl?: string }) {
|
|
91
|
+
return {
|
|
92
|
+
id: rule,
|
|
93
|
+
description: extras?.description ?? `Description for ${rule}`,
|
|
94
|
+
impact: extras?.impact ?? 'serious',
|
|
95
|
+
helpUrl: extras?.helpUrl ?? `https://dequeuniversity.com/rules/axe/4.8/${rule}`,
|
|
96
|
+
nodes: targets.map(targetArr => ({
|
|
97
|
+
target: targetArr,
|
|
98
|
+
})),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('accessibility baseline', () => {
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
vi.clearAllMocks()
|
|
105
|
+
mockAnalyze.mockResolvedValue(makeAxeResults())
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('defineAccessibilityBaseline', () => {
|
|
109
|
+
it('returns the entries unchanged', () => {
|
|
110
|
+
const entries: AccessibilityBaseline = [
|
|
111
|
+
{ rule: 'color-contrast', targets: ['#sidebar .tag'], reason: 'Known issue', willBeFixedIn: 'PROJ-123' },
|
|
112
|
+
]
|
|
113
|
+
expect(defineAccessibilityBaseline(entries)).toBe(entries)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('requires a substantive reason and tracking reference for every waiver', () => {
|
|
117
|
+
expect(() => defineAccessibilityBaseline([
|
|
118
|
+
{rule: 'image-alt', targets: ['img'], reason: '', willBeFixedIn: 'PROJ-1'},
|
|
119
|
+
])).toThrow('reason')
|
|
120
|
+
expect(() => defineAccessibilityBaseline([
|
|
121
|
+
{rule: 'image-alt', targets: ['img'], reason: 'Known issue', willBeFixedIn: 'TODO'},
|
|
122
|
+
])).toThrow('willBeFixedIn')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('rejects duplicate waivers for the same rule and target', () => {
|
|
126
|
+
expect(() => defineAccessibilityBaseline([
|
|
127
|
+
{rule: 'image-alt', targets: ['img.hero'], reason: 'First', willBeFixedIn: 'PROJ-1'},
|
|
128
|
+
{rule: 'image-alt', targets: ['img.hero'], reason: 'Second', willBeFixedIn: 'PROJ-2'},
|
|
129
|
+
])).toThrow('duplicate')
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('baseline matching', () => {
|
|
134
|
+
it('suppresses violations that match a baseline entry (exact rule + target)', async () => {
|
|
135
|
+
const wcagResults = makeAxeResults({
|
|
136
|
+
violations: [makeViolation('color-contrast', [['#sidebar .tag']])],
|
|
137
|
+
})
|
|
138
|
+
mockAnalyze.mockResolvedValue(wcagResults)
|
|
139
|
+
|
|
140
|
+
const testInfo = makeTestInfo()
|
|
141
|
+
const baseline: AccessibilityBaseline = [
|
|
142
|
+
{ rule: 'color-contrast', targets: ['#sidebar .tag'], reason: 'Known issue', willBeFixedIn: 'PROJ-123' },
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
146
|
+
bestPracticeMode: 'off',
|
|
147
|
+
baseline,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Should not call toBe (no failure)
|
|
151
|
+
expect(mockToBe).not.toHaveBeenCalled()
|
|
152
|
+
// Should not call toMatchSnapshot (baseline mode skips it)
|
|
153
|
+
expect(mockToMatchSnapshot).not.toHaveBeenCalled()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('suppresses violations with normalized ID match (--UNIQUE-ID replacement)', async () => {
|
|
157
|
+
const wcagResults = makeAxeResults({
|
|
158
|
+
violations: [makeViolation('color-contrast', [['#edit-field--42']])],
|
|
159
|
+
})
|
|
160
|
+
mockAnalyze.mockResolvedValue(wcagResults)
|
|
161
|
+
|
|
162
|
+
const testInfo = makeTestInfo()
|
|
163
|
+
const baseline: AccessibilityBaseline = [
|
|
164
|
+
{ rule: 'color-contrast', targets: ['#edit-field--UNIQUE-ID'], reason: 'Dynamic ID', willBeFixedIn: 'PROJ-456' },
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
168
|
+
bestPracticeMode: 'off',
|
|
169
|
+
baseline,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
expect(mockToBe).not.toHaveBeenCalled()
|
|
173
|
+
expect(mockToMatchSnapshot).not.toHaveBeenCalled()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('fails on unmatched violations with full details', async () => {
|
|
177
|
+
const wcagResults = makeAxeResults({
|
|
178
|
+
violations: [makeViolation('color-contrast', [['#sidebar .tag', '.footer__copyright']], {
|
|
179
|
+
description: 'Elements must meet minimum color contrast ratio thresholds',
|
|
180
|
+
impact: 'serious',
|
|
181
|
+
helpUrl: 'https://dequeuniversity.com/rules/axe/4.8/color-contrast',
|
|
182
|
+
})],
|
|
183
|
+
})
|
|
184
|
+
mockAnalyze.mockResolvedValue(wcagResults)
|
|
185
|
+
|
|
186
|
+
const testInfo = makeTestInfo()
|
|
187
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
188
|
+
bestPracticeMode: 'off',
|
|
189
|
+
baseline: [],
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
expect(mockToBe).toHaveBeenCalled()
|
|
193
|
+
// First arg is null, second is the message string
|
|
194
|
+
const failureMessage = mockExpectHard.mock.calls.find(
|
|
195
|
+
(call: any[]) => call.length >= 2 && typeof call[1] === 'string'
|
|
196
|
+
)?.[1] as string
|
|
197
|
+
|
|
198
|
+
expect(failureMessage).toContain('color-contrast')
|
|
199
|
+
expect(failureMessage).toContain('serious')
|
|
200
|
+
expect(failureMessage).toContain('Elements must meet minimum color contrast ratio thresholds')
|
|
201
|
+
expect(failureMessage).toContain('https://dequeuniversity.com/rules/axe/4.8/color-contrast')
|
|
202
|
+
expect(failureMessage).toContain('#sidebar .tag')
|
|
203
|
+
expect(failureMessage).toContain('.footer__copyright')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('stale detection', () => {
|
|
208
|
+
it('reports stale baseline entries with annotations', async () => {
|
|
209
|
+
const wcagResults = makeAxeResults({ violations: [] })
|
|
210
|
+
mockAnalyze.mockResolvedValue(wcagResults)
|
|
211
|
+
|
|
212
|
+
const testInfo = makeTestInfo()
|
|
213
|
+
const baseline: AccessibilityBaseline = [
|
|
214
|
+
{ rule: 'color-contrast', targets: ['#old-element'], reason: 'Was broken', willBeFixedIn: 'PROJ-789' },
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
218
|
+
bestPracticeMode: 'off',
|
|
219
|
+
baseline,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const staleAnnotations = testInfo.annotations.filter(a => a.type === 'Stale a11y baseline entry')
|
|
223
|
+
expect(staleAnnotations).toHaveLength(1)
|
|
224
|
+
expect(staleAnnotations[0].description).toContain('color-contrast')
|
|
225
|
+
expect(staleAnnotations[0].description).toContain('#old-element')
|
|
226
|
+
expect(staleAnnotations[0].description).toContain('no longer detected')
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe('snapshot bypass', () => {
|
|
231
|
+
it('does NOT call toMatchSnapshot when baseline is active', async () => {
|
|
232
|
+
const wcagResults = makeAxeResults({ violations: [] })
|
|
233
|
+
mockAnalyze.mockResolvedValue(wcagResults)
|
|
234
|
+
|
|
235
|
+
const testInfo = makeTestInfo()
|
|
236
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
237
|
+
bestPracticeMode: 'off',
|
|
238
|
+
baseline: [],
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
expect(mockToMatchSnapshot).not.toHaveBeenCalled()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('calls toMatchSnapshot when no baseline is provided', async () => {
|
|
245
|
+
const testInfo = makeTestInfo()
|
|
246
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
247
|
+
bestPracticeMode: 'off',
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
expect(mockToMatchSnapshot).toHaveBeenCalled()
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe('copy-pasteable baseline entry in failure message', () => {
|
|
255
|
+
it('includes a copy-pasteable entry for unmatched violations', async () => {
|
|
256
|
+
const wcagResults = makeAxeResults({
|
|
257
|
+
violations: [makeViolation('image-alt', [['img.hero']])],
|
|
258
|
+
})
|
|
259
|
+
mockAnalyze.mockResolvedValue(wcagResults)
|
|
260
|
+
|
|
261
|
+
const testInfo = makeTestInfo()
|
|
262
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
263
|
+
bestPracticeMode: 'off',
|
|
264
|
+
baseline: [],
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const failureMessage = mockExpectHard.mock.calls.find(
|
|
268
|
+
(call: any[]) => call.length >= 2 && typeof call[1] === 'string'
|
|
269
|
+
)?.[1] as string
|
|
270
|
+
|
|
271
|
+
expect(failureMessage).toContain("rule: 'image-alt'")
|
|
272
|
+
expect(failureMessage).toContain("targets: ['img.hero']")
|
|
273
|
+
expect(failureMessage).toContain('reason:')
|
|
274
|
+
expect(failureMessage).toContain('willBeFixedIn:')
|
|
275
|
+
expect(failureMessage).toContain('Add to your baseline to accept this violation:')
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
describe('baseline annotations', () => {
|
|
280
|
+
it('pushes Baselined a11y violation annotations for matched violations', async () => {
|
|
281
|
+
const wcagResults = makeAxeResults({
|
|
282
|
+
violations: [makeViolation('color-contrast', [['#sidebar .tag']])],
|
|
283
|
+
})
|
|
284
|
+
mockAnalyze.mockResolvedValue(wcagResults)
|
|
285
|
+
|
|
286
|
+
const testInfo = makeTestInfo()
|
|
287
|
+
const baseline: AccessibilityBaseline = [
|
|
288
|
+
{ rule: 'color-contrast', targets: ['#sidebar .tag'], reason: 'Known issue', willBeFixedIn: 'PROJ-123' },
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
292
|
+
bestPracticeMode: 'off',
|
|
293
|
+
baseline,
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
const baselinedAnnotations = testInfo.annotations.filter(a => a.type === 'Baselined a11y violation')
|
|
297
|
+
expect(baselinedAnnotations).toHaveLength(1)
|
|
298
|
+
expect(baselinedAnnotations[0].description).toBe('color-contrast: Known issue — PROJ-123')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('pushes Stale a11y baseline entry annotations for unmatched baseline entries', async () => {
|
|
302
|
+
const wcagResults = makeAxeResults({ violations: [] })
|
|
303
|
+
mockAnalyze.mockResolvedValue(wcagResults)
|
|
304
|
+
|
|
305
|
+
const testInfo = makeTestInfo()
|
|
306
|
+
const baseline: AccessibilityBaseline = [
|
|
307
|
+
{ rule: 'old-rule', targets: ['.gone'], reason: 'Was broken', willBeFixedIn: 'PROJ-999' },
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
311
|
+
bestPracticeMode: 'off',
|
|
312
|
+
baseline,
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const staleAnnotations = testInfo.annotations.filter(a => a.type === 'Stale a11y baseline entry')
|
|
316
|
+
expect(staleAnnotations).toHaveLength(1)
|
|
317
|
+
expect(staleAnnotations[0].description).toBe('old-rule on .gone — no longer detected')
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
describe('baseline summary annotation', () => {
|
|
322
|
+
it('includes correct counts in summary annotation', async () => {
|
|
323
|
+
const wcagResults = makeAxeResults({
|
|
324
|
+
violations: [
|
|
325
|
+
makeViolation('color-contrast', [['#sidebar .tag']]),
|
|
326
|
+
makeViolation('image-alt', [['img.hero']]),
|
|
327
|
+
],
|
|
328
|
+
})
|
|
329
|
+
mockAnalyze.mockResolvedValue(wcagResults)
|
|
330
|
+
|
|
331
|
+
const testInfo = makeTestInfo()
|
|
332
|
+
const baseline: AccessibilityBaseline = [
|
|
333
|
+
{ rule: 'color-contrast', targets: ['#sidebar .tag'], reason: 'Known', willBeFixedIn: 'PROJ-1' },
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
337
|
+
bestPracticeMode: 'off',
|
|
338
|
+
baseline,
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
const summaryAnnotations = testInfo.annotations.filter(
|
|
342
|
+
a => a.type === 'Accessibility' && a.description?.includes('baselined')
|
|
343
|
+
)
|
|
344
|
+
expect(summaryAnnotations).toHaveLength(1)
|
|
345
|
+
expect(summaryAnnotations[0].description).toBe('WCAG scan: 1 new violations (1 baselined)')
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
describe('snapshot-mode baseline suggestions', () => {
|
|
350
|
+
it('attaches baseline suggestions when violations exist in snapshot mode', async () => {
|
|
351
|
+
const wcagResults = makeAxeResults({
|
|
352
|
+
violations: [makeViolation('color-contrast', [['#sidebar .tag']])],
|
|
353
|
+
})
|
|
354
|
+
mockAnalyze.mockResolvedValue(wcagResults)
|
|
355
|
+
|
|
356
|
+
const testInfo = makeTestInfo()
|
|
357
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
358
|
+
bestPracticeMode: 'off',
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
// Should attach a11y-baseline-suggestions
|
|
362
|
+
expect(testInfo.attach).toHaveBeenCalledWith(
|
|
363
|
+
'a11y-baseline-suggestions',
|
|
364
|
+
expect.objectContaining({
|
|
365
|
+
contentType: 'text/plain',
|
|
366
|
+
})
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
// Check the attachment body contains the suggestion
|
|
370
|
+
const attachCall = testInfo.attach.mock.calls.find(
|
|
371
|
+
(call: any[]) => call[0] === 'a11y-baseline-suggestions'
|
|
372
|
+
)
|
|
373
|
+
expect(attachCall).toBeTruthy()
|
|
374
|
+
const body = attachCall![1].body as string
|
|
375
|
+
expect(body).toContain("rule: 'color-contrast'")
|
|
376
|
+
expect(body).toContain("targets: ['#sidebar .tag']")
|
|
377
|
+
|
|
378
|
+
// Should push annotation about switching to baseline mode
|
|
379
|
+
const baselineHintAnnotation = testInfo.annotations.find(
|
|
380
|
+
a => a.description?.includes('switch to baseline mode')
|
|
381
|
+
)
|
|
382
|
+
expect(baselineHintAnnotation).toBeTruthy()
|
|
383
|
+
expect(baselineHintAnnotation!.type).toBe('Accessibility')
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('does not attach baseline suggestions when no violations in snapshot mode', async () => {
|
|
387
|
+
const wcagResults = makeAxeResults({ violations: [] })
|
|
388
|
+
mockAnalyze.mockResolvedValue(wcagResults)
|
|
389
|
+
|
|
390
|
+
const testInfo = makeTestInfo()
|
|
391
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
392
|
+
bestPracticeMode: 'off',
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
expect(testInfo.attach).not.toHaveBeenCalledWith(
|
|
396
|
+
'a11y-baseline-suggestions',
|
|
397
|
+
expect.anything()
|
|
398
|
+
)
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
describe('on-disk baseline (new default for snapshotless tests)', () => {
|
|
403
|
+
let tmpDir: string
|
|
404
|
+
let originalCI: string | undefined
|
|
405
|
+
|
|
406
|
+
beforeEach(async () => {
|
|
407
|
+
const fs = await import('fs')
|
|
408
|
+
const os = await import('os')
|
|
409
|
+
const path = await import('path')
|
|
410
|
+
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'a11y-disp-'))
|
|
411
|
+
originalCI = process.env.CI
|
|
412
|
+
delete process.env.CI
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
afterEach(async () => {
|
|
416
|
+
const fs = await import('fs')
|
|
417
|
+
await fs.promises.rm(tmpDir, { recursive: true, force: true })
|
|
418
|
+
if (originalCI === undefined) delete process.env.CI
|
|
419
|
+
else process.env.CI = originalCI
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('seeds an empty baseline file when no violations and no snapshot exist (local)', async () => {
|
|
423
|
+
mockAnalyze.mockResolvedValue(makeAxeResults({ violations: [] }))
|
|
424
|
+
|
|
425
|
+
const testInfo = makeTestInfo({ updateSnapshots: 'none', snapshotsDir: tmpDir, title: 'clean test' })
|
|
426
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
427
|
+
bestPracticeMode: 'off',
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
const fs = await import('fs')
|
|
431
|
+
const path = await import('path')
|
|
432
|
+
const file = path.join(tmpDir, 'clean-test-1.a11y-baseline.json')
|
|
433
|
+
const written = JSON.parse(await fs.promises.readFile(file, 'utf8'))
|
|
434
|
+
expect(written.note).toBe('No accessibility violations found')
|
|
435
|
+
expect(written.violations).toEqual([])
|
|
436
|
+
|
|
437
|
+
// Snapshot mode is NOT used; on-disk baseline mode used assertBaseline.
|
|
438
|
+
expect(mockToMatchSnapshot).not.toHaveBeenCalled()
|
|
439
|
+
// Test does not fail (no toBe call against a "baseline file present" sentinel).
|
|
440
|
+
expect(mockToBe).not.toHaveBeenCalled()
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('seeds a TODO-prompted baseline file when violations exist and no snapshot (local)', async () => {
|
|
444
|
+
mockAnalyze.mockResolvedValue(makeAxeResults({
|
|
445
|
+
violations: [makeViolation('color-contrast', [['#footer .legal']])],
|
|
446
|
+
}))
|
|
447
|
+
|
|
448
|
+
const testInfo = makeTestInfo({ updateSnapshots: 'none', snapshotsDir: tmpDir, title: 'with violations' })
|
|
449
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
450
|
+
bestPracticeMode: 'off',
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
const fs = await import('fs')
|
|
454
|
+
const path = await import('path')
|
|
455
|
+
const file = path.join(tmpDir, 'with-violations-1.a11y-baseline.json')
|
|
456
|
+
const written = JSON.parse(await fs.promises.readFile(file, 'utf8'))
|
|
457
|
+
expect(written.note).toMatch(/TODO/)
|
|
458
|
+
expect(written.violations).toEqual([
|
|
459
|
+
{ rule: 'color-contrast', targets: ['#footer .legal'], reason: 'TODO', willBeFixedIn: 'TODO' },
|
|
460
|
+
])
|
|
461
|
+
expect(mockToBe).not.toHaveBeenCalled()
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it('fails the test on CI when seeding (mirrors Playwright missing-snapshot behaviour)', async () => {
|
|
465
|
+
process.env.CI = '1'
|
|
466
|
+
mockAnalyze.mockResolvedValue(makeAxeResults({
|
|
467
|
+
violations: [makeViolation('color-contrast', [['#x']])],
|
|
468
|
+
}))
|
|
469
|
+
|
|
470
|
+
const testInfo = makeTestInfo({ updateSnapshots: 'none', snapshotsDir: tmpDir, title: 'on ci' })
|
|
471
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
472
|
+
bestPracticeMode: 'off',
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
// File is still seeded.
|
|
476
|
+
const fs = await import('fs')
|
|
477
|
+
const path = await import('path')
|
|
478
|
+
const file = path.join(tmpDir, 'on-ci-1.a11y-baseline.json')
|
|
479
|
+
expect(fs.existsSync(file)).toBe(true)
|
|
480
|
+
|
|
481
|
+
// Test fails with the seed-and-commit directive.
|
|
482
|
+
expect(mockToBe).toHaveBeenCalled()
|
|
483
|
+
const failureMessage = mockExpectHard.mock.calls.find(
|
|
484
|
+
(call: any[]) => call.length >= 2 && typeof call[1] === 'string' && call[1].includes('a11y baseline file was missing')
|
|
485
|
+
)?.[1] as string
|
|
486
|
+
expect(failureMessage).toContain(file)
|
|
487
|
+
expect(failureMessage).toMatch(/commit it/i)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('loads an existing on-disk baseline JSON and matches violations against it', async () => {
|
|
491
|
+
mockAnalyze.mockResolvedValue(makeAxeResults({
|
|
492
|
+
violations: [makeViolation('color-contrast', [['#known']])],
|
|
493
|
+
}))
|
|
494
|
+
|
|
495
|
+
// Pre-write a baseline.
|
|
496
|
+
const fs = await import('fs')
|
|
497
|
+
const path = await import('path')
|
|
498
|
+
const file = path.join(tmpDir, 'known-test-1.a11y-baseline.json')
|
|
499
|
+
await fs.promises.writeFile(file, JSON.stringify({
|
|
500
|
+
note: 'Known issue tracked in PROJ-1.',
|
|
501
|
+
violations: [{ rule: 'color-contrast', targets: ['#known'], reason: 'Known', willBeFixedIn: 'PROJ-1' }],
|
|
502
|
+
}))
|
|
503
|
+
|
|
504
|
+
const testInfo = makeTestInfo({ updateSnapshots: 'none', snapshotsDir: tmpDir, title: 'known test' })
|
|
505
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
506
|
+
bestPracticeMode: 'off',
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
// Violation matched -> no failure.
|
|
510
|
+
expect(mockToBe).not.toHaveBeenCalled()
|
|
511
|
+
const baselined = testInfo.annotations.filter(a => a.type === 'Baselined a11y violation')
|
|
512
|
+
expect(baselined).toHaveLength(1)
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it('rejects a persisted waiver whose metadata is still TODO', async () => {
|
|
516
|
+
mockAnalyze.mockResolvedValue(makeAxeResults({
|
|
517
|
+
violations: [makeViolation('color-contrast', [['#known']])],
|
|
518
|
+
}))
|
|
519
|
+
|
|
520
|
+
const fs = await import('fs')
|
|
521
|
+
const path = await import('path')
|
|
522
|
+
const file = path.join(tmpDir, 'unfinished-test-1.a11y-baseline.json')
|
|
523
|
+
await fs.promises.writeFile(file, JSON.stringify({
|
|
524
|
+
note: 'Seeded',
|
|
525
|
+
violations: [{rule: 'color-contrast', targets: ['#known'], reason: 'TODO', willBeFixedIn: 'TODO'}],
|
|
526
|
+
}))
|
|
527
|
+
|
|
528
|
+
const testInfo = makeTestInfo({updateSnapshots: 'none', snapshotsDir: tmpDir, title: 'unfinished test'})
|
|
529
|
+
await expect(checkAccessibility(makePage() as any, testInfo as any, {
|
|
530
|
+
bestPracticeMode: 'off',
|
|
531
|
+
})).rejects.toThrow('requires a reason')
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('produces per-call counter files for multi-call tests', async () => {
|
|
535
|
+
mockAnalyze.mockResolvedValue(makeAxeResults({ violations: [] }))
|
|
536
|
+
|
|
537
|
+
const testInfo = makeTestInfo({ updateSnapshots: 'none', snapshotsDir: tmpDir, title: 'multi call' })
|
|
538
|
+
await checkAccessibility(makePage() as any, testInfo as any, { bestPracticeMode: 'off' })
|
|
539
|
+
await checkAccessibility(makePage() as any, testInfo as any, { bestPracticeMode: 'off' })
|
|
540
|
+
|
|
541
|
+
const fs = await import('fs')
|
|
542
|
+
const path = await import('path')
|
|
543
|
+
const f1 = path.join(tmpDir, 'multi-call-1.a11y-baseline.json')
|
|
544
|
+
const f2 = path.join(tmpDir, 'multi-call-2.a11y-baseline.json')
|
|
545
|
+
expect(fs.existsSync(f1)).toBe(true)
|
|
546
|
+
expect(fs.existsSync(f2)).toBe(true)
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('still uses snapshot mode when a legacy snapshot file exists for the test', async () => {
|
|
550
|
+
mockAnalyze.mockResolvedValue(makeAxeResults({ violations: [] }))
|
|
551
|
+
|
|
552
|
+
// Pre-create a Playwright-style snapshot file in the snapshots dir.
|
|
553
|
+
const fs = await import('fs')
|
|
554
|
+
const path = await import('path')
|
|
555
|
+
await fs.promises.writeFile(
|
|
556
|
+
path.join(tmpDir, 'legacy-test-1-chromium-linux.txt'),
|
|
557
|
+
'[]\n',
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
const testInfo = makeTestInfo({ updateSnapshots: 'none', snapshotsDir: tmpDir, title: 'legacy test' })
|
|
561
|
+
await checkAccessibility(makePage() as any, testInfo as any, { bestPracticeMode: 'off' })
|
|
562
|
+
|
|
563
|
+
// Snapshot mode -> toMatchSnapshot was invoked.
|
|
564
|
+
expect(mockToMatchSnapshot).toHaveBeenCalled()
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
it("uses on-disk baseline mode when updateSnapshots is 'missing' (Playwright's default)", async () => {
|
|
568
|
+
mockAnalyze.mockResolvedValue(makeAxeResults({ violations: [] }))
|
|
569
|
+
|
|
570
|
+
const testInfo = makeTestInfo({ updateSnapshots: 'missing', snapshotsDir: tmpDir, title: 'default config' })
|
|
571
|
+
await checkAccessibility(makePage() as any, testInfo as any, { bestPracticeMode: 'off' })
|
|
572
|
+
|
|
573
|
+
// Playwright's default config value is 'missing' — not an explicit user
|
|
574
|
+
// opt-in via --update-snapshots. It must route to baseline mode so new
|
|
575
|
+
// a11y tests default to baseline mode as documented.
|
|
576
|
+
const fs = await import('fs')
|
|
577
|
+
const path = await import('path')
|
|
578
|
+
const file = path.join(tmpDir, 'default-config-1.a11y-baseline.json')
|
|
579
|
+
expect(fs.existsSync(file)).toBe(true)
|
|
580
|
+
expect(mockToMatchSnapshot).not.toHaveBeenCalled()
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it("uses snapshot mode when updateSnapshots is 'changed' (explicit --update-snapshots)", async () => {
|
|
584
|
+
mockAnalyze.mockResolvedValue(makeAxeResults({ violations: [] }))
|
|
585
|
+
|
|
586
|
+
const testInfo = makeTestInfo({ updateSnapshots: 'changed', snapshotsDir: tmpDir, title: 'changed mode' })
|
|
587
|
+
await checkAccessibility(makePage() as any, testInfo as any, { bestPracticeMode: 'off' })
|
|
588
|
+
|
|
589
|
+
expect(mockToMatchSnapshot).toHaveBeenCalled()
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it("uses snapshot mode when updateSnapshots is 'all' (explicit --update-snapshots=all)", async () => {
|
|
593
|
+
mockAnalyze.mockResolvedValue(makeAxeResults({ violations: [] }))
|
|
594
|
+
|
|
595
|
+
const testInfo = makeTestInfo({ updateSnapshots: 'all', snapshotsDir: tmpDir, title: 'all mode' })
|
|
596
|
+
await checkAccessibility(makePage() as any, testInfo as any, { bestPracticeMode: 'off' })
|
|
597
|
+
|
|
598
|
+
expect(mockToMatchSnapshot).toHaveBeenCalled()
|
|
599
|
+
})
|
|
600
|
+
})
|
|
601
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface AccessibilityBaselineEntry {
|
|
2
|
+
/** axe rule ID (e.g. 'color-contrast') */
|
|
3
|
+
rule: string
|
|
4
|
+
/** CSS selectors for the elements with this violation */
|
|
5
|
+
targets: string[]
|
|
6
|
+
/** Why this violation is accepted */
|
|
7
|
+
reason: string
|
|
8
|
+
/** Link to tracking ticket */
|
|
9
|
+
willBeFixedIn: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type AccessibilityBaseline = AccessibilityBaselineEntry[]
|
|
13
|
+
|
|
14
|
+
export function defineAccessibilityBaseline(entries: AccessibilityBaseline): AccessibilityBaseline {
|
|
15
|
+
validateAccessibilityBaseline(entries)
|
|
16
|
+
return entries
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate waiver metadata and reject ambiguous duplicate entries.
|
|
21
|
+
*
|
|
22
|
+
* Seed files deliberately contain TODO placeholders, so callers validate only
|
|
23
|
+
* committed/in-code baselines, not a seed during the local creation run.
|
|
24
|
+
*/
|
|
25
|
+
export function validateAccessibilityBaseline(entries: AccessibilityBaseline): void {
|
|
26
|
+
const seen = new Set<string>()
|
|
27
|
+
|
|
28
|
+
entries.forEach((entry, index) => {
|
|
29
|
+
if (!entry.rule.trim()) {
|
|
30
|
+
throw new Error(`Accessibility baseline entry ${index + 1} requires a rule.`)
|
|
31
|
+
}
|
|
32
|
+
if (entry.targets.length === 0 || entry.targets.some(target => !target.trim())) {
|
|
33
|
+
throw new Error(`Accessibility baseline entry ${index + 1} requires at least one non-empty target.`)
|
|
34
|
+
}
|
|
35
|
+
if (!entry.reason.trim() || entry.reason.trim().toUpperCase() === 'TODO') {
|
|
36
|
+
throw new Error(`Accessibility baseline entry ${index + 1} requires a reason.`)
|
|
37
|
+
}
|
|
38
|
+
if (!entry.willBeFixedIn.trim() || entry.willBeFixedIn.trim().toUpperCase() === 'TODO') {
|
|
39
|
+
throw new Error(`Accessibility baseline entry ${index + 1} requires a willBeFixedIn tracking reference.`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const target of new Set(entry.targets)) {
|
|
43
|
+
const key = JSON.stringify([entry.rule, target])
|
|
44
|
+
if (seen.has(key)) {
|
|
45
|
+
throw new Error(`Accessibility baseline contains a duplicate waiver for ${entry.rule} on ${target}.`)
|
|
46
|
+
}
|
|
47
|
+
seen.add(key)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
}
|