@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,597 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } 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 — use vi.hoisted to define values before the hoisted vi.mock call
|
|
26
|
+
const { mockToMatchSnapshot, mockToHaveScreenshot, mockExpectSoft, mockExpectHard } = vi.hoisted(() => {
|
|
27
|
+
const mockToMatchSnapshot = vi.fn()
|
|
28
|
+
const mockToHaveScreenshot = vi.fn()
|
|
29
|
+
const mockExpectSoft = vi.fn(() => ({
|
|
30
|
+
toMatchSnapshot: mockToMatchSnapshot,
|
|
31
|
+
toHaveScreenshot: mockToHaveScreenshot,
|
|
32
|
+
}))
|
|
33
|
+
const mockExpectHard = vi.fn(() => ({
|
|
34
|
+
toMatchSnapshot: mockToMatchSnapshot,
|
|
35
|
+
}))
|
|
36
|
+
return { mockToMatchSnapshot, mockToHaveScreenshot, mockExpectSoft, mockExpectHard }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
vi.mock('@playwright/test', () => {
|
|
40
|
+
const expectFn = Object.assign(mockExpectHard, {
|
|
41
|
+
soft: mockExpectSoft,
|
|
42
|
+
})
|
|
43
|
+
return {
|
|
44
|
+
expect: expectFn,
|
|
45
|
+
Locator: {},
|
|
46
|
+
Page: {},
|
|
47
|
+
TestInfo: {},
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const {
|
|
52
|
+
mockWaitForAllImages,
|
|
53
|
+
mockWaitForFrames,
|
|
54
|
+
mockWaitForFonts,
|
|
55
|
+
mockWaitForVideos,
|
|
56
|
+
mockRestoreVideoPlayback,
|
|
57
|
+
mockBlurActiveElement,
|
|
58
|
+
mockClearHover,
|
|
59
|
+
mockRemoveHoverShield,
|
|
60
|
+
} = vi.hoisted(() => ({
|
|
61
|
+
mockWaitForAllImages: vi.fn(),
|
|
62
|
+
mockWaitForFrames: vi.fn(),
|
|
63
|
+
mockWaitForFonts: vi.fn(),
|
|
64
|
+
mockWaitForVideos: vi.fn(),
|
|
65
|
+
mockRestoreVideoPlayback: vi.fn(),
|
|
66
|
+
mockBlurActiveElement: vi.fn(),
|
|
67
|
+
mockClearHover: vi.fn(),
|
|
68
|
+
mockRemoveHoverShield: vi.fn(),
|
|
69
|
+
}))
|
|
70
|
+
|
|
71
|
+
vi.mock('./images', () => ({ waitForAllImages: mockWaitForAllImages }))
|
|
72
|
+
vi.mock('./frames', () => ({ waitForFrames: mockWaitForFrames }))
|
|
73
|
+
vi.mock('./fonts', () => ({ waitForFonts: mockWaitForFonts }))
|
|
74
|
+
vi.mock('./videos', () => ({
|
|
75
|
+
waitForVideos: mockWaitForVideos,
|
|
76
|
+
restoreVideoPlayback: mockRestoreVideoPlayback,
|
|
77
|
+
}))
|
|
78
|
+
vi.mock('./focus', () => ({ blurActiveElement: mockBlurActiveElement }))
|
|
79
|
+
vi.mock('./hover', () => ({ clearHover: mockClearHover }))
|
|
80
|
+
|
|
81
|
+
import { checkAccessibility, takeAccessibleScreenshot } from './accessible-screenshot'
|
|
82
|
+
import AxeBuilder from '@axe-core/playwright'
|
|
83
|
+
|
|
84
|
+
function makeAxeResults(overrides?: Partial<{ violations: any[], passes: any[] }>) {
|
|
85
|
+
return {
|
|
86
|
+
violations: [],
|
|
87
|
+
passes: [],
|
|
88
|
+
incomplete: [],
|
|
89
|
+
inapplicable: [],
|
|
90
|
+
...overrides,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function makeTestInfo() {
|
|
95
|
+
return {
|
|
96
|
+
testId: `mocked-${Math.random()}`,
|
|
97
|
+
title: 'mocked test',
|
|
98
|
+
titlePath: ['file.spec.ts', 'mocked test'],
|
|
99
|
+
annotations: [] as Array<{ type: string, description?: string }>,
|
|
100
|
+
attach: vi.fn().mockResolvedValue(undefined),
|
|
101
|
+
snapshotPath: (...segs: string[]) => `/tmp/__a11y_test_snapshots__/${segs.join('/')}`,
|
|
102
|
+
config: { updateSnapshots: 'all' as const },
|
|
103
|
+
project: { name: 'desktop chromium' },
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('checkAccessibility', () => {
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
vi.clearAllMocks()
|
|
110
|
+
mockAnalyze.mockResolvedValue(makeAxeResults())
|
|
111
|
+
mockClearHover.mockResolvedValue(mockRemoveHoverShield)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('uses default WCAG tags when none provided', async () => {
|
|
115
|
+
const testInfo = makeTestInfo()
|
|
116
|
+
await checkAccessibility({} as any, testInfo as any)
|
|
117
|
+
|
|
118
|
+
expect(mockWithTags).toHaveBeenCalledWith(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('respects custom wcagTags', async () => {
|
|
122
|
+
const testInfo = makeTestInfo()
|
|
123
|
+
await checkAccessibility({} as any, testInfo as any, {
|
|
124
|
+
wcagTags: ['wcag22aa'],
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
expect(mockWithTags).toHaveBeenCalledWith(['wcag22aa'])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('respects exclude option — adds extra .exclude() calls', async () => {
|
|
131
|
+
const testInfo = makeTestInfo()
|
|
132
|
+
await checkAccessibility({} as any, testInfo as any, {
|
|
133
|
+
exclude: ['.my-selector', '#another'],
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
expect(mockExclude).toHaveBeenCalledWith('.my-selector')
|
|
137
|
+
expect(mockExclude).toHaveBeenCalledWith('#another')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('skips best-practice scan when bestPracticeMode is off', async () => {
|
|
141
|
+
const testInfo = makeTestInfo()
|
|
142
|
+
await checkAccessibility({} as any, testInfo as any, {
|
|
143
|
+
bestPracticeMode: 'off',
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// withTags should only be called once (for the WCAG scan, not best-practice)
|
|
147
|
+
expect(mockWithTags).toHaveBeenCalledTimes(1)
|
|
148
|
+
expect(mockWithTags).not.toHaveBeenCalledWith(['best-practice'])
|
|
149
|
+
expect(mockWithTags).toHaveBeenCalledWith(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('always uses expect.soft() for best-practice so WCAG scan runs even in hard mode', async () => {
|
|
153
|
+
const testInfo = makeTestInfo()
|
|
154
|
+
await checkAccessibility({} as any, testInfo as any, {
|
|
155
|
+
bestPracticeMode: 'hard',
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Best-practice always uses expect.soft() so the WCAG scan is never blocked
|
|
159
|
+
expect(mockExpectSoft).toHaveBeenCalled()
|
|
160
|
+
// WCAG uses expect() (hard)
|
|
161
|
+
expect(mockExpectHard).toHaveBeenCalled()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('uses expect.soft() when bestPracticeMode is soft (default)', async () => {
|
|
165
|
+
const testInfo = makeTestInfo()
|
|
166
|
+
await checkAccessibility({} as any, testInfo as any)
|
|
167
|
+
|
|
168
|
+
// Best-practice scan should use expect.soft()
|
|
169
|
+
expect(mockExpectSoft).toHaveBeenCalled()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('has no framework-specific exclusions by default', async () => {
|
|
173
|
+
const testInfo = makeTestInfo()
|
|
174
|
+
await checkAccessibility({} as any, testInfo as any)
|
|
175
|
+
|
|
176
|
+
expect(mockExclude).not.toHaveBeenCalled()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('supports scan-specific explicit exclusions', async () => {
|
|
180
|
+
const testInfo = makeTestInfo()
|
|
181
|
+
await checkAccessibility({} as any, testInfo as any, {
|
|
182
|
+
bestPracticeExclude: ['.best-practice-only'],
|
|
183
|
+
wcagExclude: ['.wcag-only'],
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
expect(mockExclude).toHaveBeenCalledWith('.best-practice-only')
|
|
187
|
+
expect(mockExclude).toHaveBeenCalledWith('.wcag-only')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('passes rules to AxeBuilder .options()', async () => {
|
|
191
|
+
const testInfo = makeTestInfo()
|
|
192
|
+
const rules = { 'color-contrast': { enabled: false } }
|
|
193
|
+
await checkAccessibility({} as any, testInfo as any, { rules })
|
|
194
|
+
|
|
195
|
+
expect(mockOptions).toHaveBeenCalledWith({ rules })
|
|
196
|
+
// Called twice: once for best-practice, once for WCAG
|
|
197
|
+
expect(mockOptions).toHaveBeenCalledTimes(2)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('pushes @a11y annotation and deduplicates on second call', async () => {
|
|
201
|
+
const testInfo = makeTestInfo()
|
|
202
|
+
|
|
203
|
+
await checkAccessibility({} as any, testInfo as any)
|
|
204
|
+
const a11yAnnotations = testInfo.annotations.filter((a: any) => a.type === '@a11y')
|
|
205
|
+
expect(a11yAnnotations).toHaveLength(1)
|
|
206
|
+
|
|
207
|
+
// Call again — should not duplicate
|
|
208
|
+
await checkAccessibility({} as any, testInfo as any)
|
|
209
|
+
const a11yAnnotationsAfter = testInfo.annotations.filter((a: any) => a.type === '@a11y')
|
|
210
|
+
expect(a11yAnnotationsAfter).toHaveLength(1)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('pushes summary annotations after each scan', async () => {
|
|
214
|
+
const bestPracticeResults = makeAxeResults({
|
|
215
|
+
violations: [{ id: 'rule1', nodes: [] }],
|
|
216
|
+
passes: [{ id: 'rule2' }, { id: 'rule3' }],
|
|
217
|
+
})
|
|
218
|
+
const wcagResults = makeAxeResults({
|
|
219
|
+
violations: [],
|
|
220
|
+
passes: [{ id: 'rule4' }],
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
mockAnalyze
|
|
224
|
+
.mockResolvedValueOnce(bestPracticeResults)
|
|
225
|
+
.mockResolvedValueOnce(wcagResults)
|
|
226
|
+
|
|
227
|
+
const testInfo = makeTestInfo()
|
|
228
|
+
await checkAccessibility({} as any, testInfo as any)
|
|
229
|
+
|
|
230
|
+
const accessibilityAnnotations = testInfo.annotations.filter((a: any) => a.type === 'Accessibility')
|
|
231
|
+
expect(accessibilityAnnotations).toHaveLength(2)
|
|
232
|
+
expect(accessibilityAnnotations[0].description).toBe('Best-practice scan: 1 violations (2 rules passed)')
|
|
233
|
+
expect(accessibilityAnnotations[1].description).toBe('WCAG scan: 0 violations (1 rules passed)')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('skips best-practice summary annotation when bestPracticeMode is off', async () => {
|
|
237
|
+
const testInfo = makeTestInfo()
|
|
238
|
+
await checkAccessibility({} as any, testInfo as any, {
|
|
239
|
+
bestPracticeMode: 'off',
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const accessibilityAnnotations = testInfo.annotations.filter((a: any) => a.type === 'Accessibility')
|
|
243
|
+
expect(accessibilityAnnotations).toHaveLength(1)
|
|
244
|
+
expect(accessibilityAnnotations[0].description).toContain('WCAG scan')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('attaches a violation screenshot when screenshotViolations is true and violations exist', async () => {
|
|
248
|
+
const wcagResults = makeAxeResults({
|
|
249
|
+
violations: [{
|
|
250
|
+
id: 'color-contrast',
|
|
251
|
+
description: 'test',
|
|
252
|
+
impact: 'serious',
|
|
253
|
+
helpUrl: 'https://example.com',
|
|
254
|
+
nodes: [{ target: ['.bad-element'] }],
|
|
255
|
+
}],
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
mockAnalyze
|
|
259
|
+
.mockResolvedValueOnce(makeAxeResults()) // best-practice
|
|
260
|
+
.mockResolvedValueOnce(wcagResults) // WCAG
|
|
261
|
+
|
|
262
|
+
const mockPage = {
|
|
263
|
+
evaluate: vi.fn().mockResolvedValue(undefined),
|
|
264
|
+
screenshot: vi.fn().mockResolvedValue(Buffer.from('fake-png')),
|
|
265
|
+
}
|
|
266
|
+
const testInfo = makeTestInfo()
|
|
267
|
+
|
|
268
|
+
await checkAccessibility(mockPage as any, testInfo as any, {
|
|
269
|
+
screenshotViolations: true,
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
// Should have injected highlight styles and then removed them.
|
|
273
|
+
expect(mockPage.evaluate).toHaveBeenCalledTimes(2)
|
|
274
|
+
// Should have taken a full-page screenshot.
|
|
275
|
+
expect(mockPage.screenshot).toHaveBeenCalledWith({ fullPage: true })
|
|
276
|
+
// Should have attached the screenshot.
|
|
277
|
+
expect(testInfo.attach).toHaveBeenCalledWith('a11y-violation-screenshot', {
|
|
278
|
+
body: Buffer.from('fake-png'),
|
|
279
|
+
contentType: 'image/png',
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('removes violation highlights when attaching the screenshot fails', async () => {
|
|
284
|
+
const wcagResults = makeAxeResults({
|
|
285
|
+
violations: [{
|
|
286
|
+
id: 'image-alt',
|
|
287
|
+
description: 'test',
|
|
288
|
+
impact: 'serious',
|
|
289
|
+
helpUrl: 'https://example.com',
|
|
290
|
+
nodes: [{target: ['img']}],
|
|
291
|
+
}],
|
|
292
|
+
})
|
|
293
|
+
mockAnalyze
|
|
294
|
+
.mockResolvedValueOnce(makeAxeResults())
|
|
295
|
+
.mockResolvedValueOnce(wcagResults)
|
|
296
|
+
const page = {
|
|
297
|
+
evaluate: vi.fn().mockResolvedValue(undefined),
|
|
298
|
+
screenshot: vi.fn().mockResolvedValue(Buffer.from('fake-png')),
|
|
299
|
+
}
|
|
300
|
+
const testInfo = makeTestInfo()
|
|
301
|
+
testInfo.attach.mockImplementation((name: string) =>
|
|
302
|
+
name === 'a11y-violation-screenshot'
|
|
303
|
+
? Promise.reject(new Error('attach failed'))
|
|
304
|
+
: Promise.resolve()
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
await expect(checkAccessibility(page as any, testInfo as any)).rejects.toThrow('attach failed')
|
|
308
|
+
|
|
309
|
+
expect(page.evaluate).toHaveBeenCalledTimes(2)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('does not take a screenshot when screenshotViolations is true but no violations', async () => {
|
|
313
|
+
const mockPage = {
|
|
314
|
+
evaluate: vi.fn(),
|
|
315
|
+
screenshot: vi.fn(),
|
|
316
|
+
}
|
|
317
|
+
const testInfo = makeTestInfo()
|
|
318
|
+
|
|
319
|
+
await checkAccessibility(mockPage as any, testInfo as any, {
|
|
320
|
+
screenshotViolations: true,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
expect(mockPage.screenshot).not.toHaveBeenCalled()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('takes a screenshot by default when violations exist', async () => {
|
|
327
|
+
const wcagResults = makeAxeResults({
|
|
328
|
+
violations: [{
|
|
329
|
+
id: 'color-contrast',
|
|
330
|
+
description: 'test',
|
|
331
|
+
impact: 'serious',
|
|
332
|
+
helpUrl: 'https://example.com',
|
|
333
|
+
nodes: [{ target: ['.bad-element'] }],
|
|
334
|
+
}],
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
mockAnalyze
|
|
338
|
+
.mockResolvedValueOnce(makeAxeResults()) // best-practice
|
|
339
|
+
.mockResolvedValueOnce(wcagResults) // WCAG
|
|
340
|
+
|
|
341
|
+
const mockPage = {
|
|
342
|
+
evaluate: vi.fn().mockResolvedValue(undefined),
|
|
343
|
+
screenshot: vi.fn().mockResolvedValue(Buffer.from('fake-png')),
|
|
344
|
+
}
|
|
345
|
+
const testInfo = makeTestInfo()
|
|
346
|
+
|
|
347
|
+
await checkAccessibility(mockPage as any, testInfo as any)
|
|
348
|
+
|
|
349
|
+
expect(mockPage.screenshot).toHaveBeenCalledWith({ fullPage: true })
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('does not take a screenshot when screenshotViolations is explicitly false', async () => {
|
|
353
|
+
const wcagResults = makeAxeResults({
|
|
354
|
+
violations: [{
|
|
355
|
+
id: 'color-contrast',
|
|
356
|
+
description: 'test',
|
|
357
|
+
impact: 'serious',
|
|
358
|
+
helpUrl: 'https://example.com',
|
|
359
|
+
nodes: [{ target: ['.bad-element'] }],
|
|
360
|
+
}],
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
mockAnalyze
|
|
364
|
+
.mockResolvedValueOnce(makeAxeResults()) // best-practice
|
|
365
|
+
.mockResolvedValueOnce(wcagResults) // WCAG
|
|
366
|
+
|
|
367
|
+
const mockPage = {
|
|
368
|
+
evaluate: vi.fn(),
|
|
369
|
+
screenshot: vi.fn(),
|
|
370
|
+
}
|
|
371
|
+
const testInfo = makeTestInfo()
|
|
372
|
+
|
|
373
|
+
await checkAccessibility(mockPage as any, testInfo as any, {
|
|
374
|
+
screenshotViolations: false,
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
expect(mockPage.screenshot).not.toHaveBeenCalled()
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
describe('takeAccessibleScreenshot hover handling', () => {
|
|
382
|
+
beforeEach(() => {
|
|
383
|
+
vi.clearAllMocks()
|
|
384
|
+
mockAnalyze.mockResolvedValue(makeAxeResults())
|
|
385
|
+
mockClearHover.mockResolvedValue(mockRemoveHoverShield)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('clears hover by default and removes the shield after capture', async () => {
|
|
389
|
+
const page = {} as any
|
|
390
|
+
const testInfo = makeTestInfo()
|
|
391
|
+
|
|
392
|
+
await takeAccessibleScreenshot(page, testInfo as any)
|
|
393
|
+
|
|
394
|
+
expect(mockClearHover).toHaveBeenCalledWith(page)
|
|
395
|
+
expect(mockToHaveScreenshot).toHaveBeenCalledWith({ timeout: 10000 })
|
|
396
|
+
expect(mockRemoveHoverShield).toHaveBeenCalledOnce()
|
|
397
|
+
expect(mockRestoreVideoPlayback).toHaveBeenCalledWith(page)
|
|
398
|
+
expect(mockRemoveHoverShield.mock.invocationCallOrder[0])
|
|
399
|
+
.toBeGreaterThan(mockToHaveScreenshot.mock.invocationCallOrder[0])
|
|
400
|
+
expect(mockRemoveHoverShield.mock.invocationCallOrder[0])
|
|
401
|
+
.toBeLessThan(mockAnalyze.mock.invocationCallOrder[0])
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('preserves an intentional hover state when clearHover is false', async () => {
|
|
405
|
+
const page = {} as any
|
|
406
|
+
const testInfo = makeTestInfo()
|
|
407
|
+
|
|
408
|
+
await takeAccessibleScreenshot(page, testInfo as any, { clearHover: false })
|
|
409
|
+
|
|
410
|
+
expect(mockClearHover).not.toHaveBeenCalled()
|
|
411
|
+
expect(mockRemoveHoverShield).not.toHaveBeenCalled()
|
|
412
|
+
expect(mockToHaveScreenshot).toHaveBeenCalledWith({ timeout: 10000 })
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('does not infer screenshot thresholds from project names', async () => {
|
|
416
|
+
const testInfo = makeTestInfo()
|
|
417
|
+
testInfo.project.name = 'desktop safari'
|
|
418
|
+
|
|
419
|
+
await takeAccessibleScreenshot({} as any, testInfo as any)
|
|
420
|
+
|
|
421
|
+
expect(mockToHaveScreenshot).toHaveBeenCalledWith({timeout: 10000})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('passes explicit stabilization configuration to image and video waits', async () => {
|
|
425
|
+
const page = {} as any
|
|
426
|
+
const images = {recoverErroredImages: true}
|
|
427
|
+
const videos = {timeoutMs: 1234}
|
|
428
|
+
|
|
429
|
+
await takeAccessibleScreenshot(page, makeTestInfo() as any, {
|
|
430
|
+
stabilization: {images, videos},
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
expect(mockWaitForAllImages).toHaveBeenCalledWith(page, images)
|
|
434
|
+
expect(mockWaitForVideos).toHaveBeenCalledWith(page, videos)
|
|
435
|
+
expect(mockToHaveScreenshot).toHaveBeenCalledWith({timeout: 10000})
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('removes the shield when capture throws', async () => {
|
|
439
|
+
const page = {} as any
|
|
440
|
+
const testInfo = makeTestInfo()
|
|
441
|
+
mockToHaveScreenshot.mockRejectedValueOnce(new Error('capture failed'))
|
|
442
|
+
|
|
443
|
+
await expect(takeAccessibleScreenshot(page, testInfo as any)).rejects.toThrow('capture failed')
|
|
444
|
+
|
|
445
|
+
expect(mockRemoveHoverShield).toHaveBeenCalledOnce()
|
|
446
|
+
expect(mockRestoreVideoPlayback).toHaveBeenCalledWith(page)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('applies real hover and focus after settling and keeps them through the accessibility scan', async () => {
|
|
450
|
+
const initialShieldCleanup = vi.fn()
|
|
451
|
+
const interactionShieldCleanup = vi.fn()
|
|
452
|
+
mockClearHover
|
|
453
|
+
.mockResolvedValueOnce(initialShieldCleanup)
|
|
454
|
+
.mockResolvedValueOnce(interactionShieldCleanup)
|
|
455
|
+
const hoverLocator = {hover: vi.fn()}
|
|
456
|
+
const focusLocator = {focus: vi.fn(), blur: vi.fn()}
|
|
457
|
+
const page = {} as any
|
|
458
|
+
const testInfo = makeTestInfo()
|
|
459
|
+
|
|
460
|
+
await takeAccessibleScreenshot(page, testInfo as any, {
|
|
461
|
+
interactionStates: [
|
|
462
|
+
{locator: hoverLocator as any, states: ['hover']},
|
|
463
|
+
{locator: focusLocator as any, states: ['focus']},
|
|
464
|
+
],
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
expect(initialShieldCleanup).toHaveBeenCalledOnce()
|
|
468
|
+
expect(hoverLocator.hover).toHaveBeenCalledOnce()
|
|
469
|
+
expect(focusLocator.focus).toHaveBeenCalledOnce()
|
|
470
|
+
expect(mockToHaveScreenshot).toHaveBeenCalledWith({timeout: 10000})
|
|
471
|
+
expect(initialShieldCleanup.mock.invocationCallOrder[0])
|
|
472
|
+
.toBeGreaterThan(mockWaitForVideos.mock.invocationCallOrder[0])
|
|
473
|
+
expect(hoverLocator.hover.mock.invocationCallOrder[0])
|
|
474
|
+
.toBeLessThan(focusLocator.focus.mock.invocationCallOrder[0])
|
|
475
|
+
expect(focusLocator.focus.mock.invocationCallOrder[0])
|
|
476
|
+
.toBeLessThan(mockToHaveScreenshot.mock.invocationCallOrder[0])
|
|
477
|
+
expect(mockBlurActiveElement).toHaveBeenCalledOnce()
|
|
478
|
+
expect(focusLocator.blur).toHaveBeenCalledOnce()
|
|
479
|
+
expect(focusLocator.blur.mock.invocationCallOrder[0])
|
|
480
|
+
.toBeGreaterThan(mockAnalyze.mock.invocationCallOrder.at(-1)!)
|
|
481
|
+
expect(interactionShieldCleanup.mock.invocationCallOrder[0])
|
|
482
|
+
.toBeGreaterThan(mockAnalyze.mock.invocationCallOrder.at(-1)!)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('cleans up real states when the accessibility scan throws', async () => {
|
|
486
|
+
const initialShieldCleanup = vi.fn()
|
|
487
|
+
const interactionShieldCleanup = vi.fn()
|
|
488
|
+
mockClearHover
|
|
489
|
+
.mockResolvedValueOnce(initialShieldCleanup)
|
|
490
|
+
.mockResolvedValueOnce(interactionShieldCleanup)
|
|
491
|
+
const locator = {hover: vi.fn(), focus: vi.fn(), blur: vi.fn()}
|
|
492
|
+
mockAnalyze.mockRejectedValueOnce(new Error('scan failed'))
|
|
493
|
+
|
|
494
|
+
await expect(takeAccessibleScreenshot({} as any, makeTestInfo() as any, {
|
|
495
|
+
interactionStates: [{locator: locator as any, states: ['hover', 'focus']}],
|
|
496
|
+
})).rejects.toThrow('scan failed')
|
|
497
|
+
|
|
498
|
+
expect(mockBlurActiveElement).toHaveBeenCalledOnce()
|
|
499
|
+
expect(locator.blur).toHaveBeenCalledOnce()
|
|
500
|
+
expect(interactionShieldCleanup).toHaveBeenCalledOnce()
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('cleans up when applying a real interaction state throws', async () => {
|
|
504
|
+
const initialShieldCleanup = vi.fn()
|
|
505
|
+
const interactionShieldCleanup = vi.fn()
|
|
506
|
+
mockClearHover
|
|
507
|
+
.mockResolvedValueOnce(initialShieldCleanup)
|
|
508
|
+
.mockResolvedValueOnce(interactionShieldCleanup)
|
|
509
|
+
const locator = {
|
|
510
|
+
hover: vi.fn().mockRejectedValue(new Error('hover failed')),
|
|
511
|
+
focus: vi.fn(),
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
await expect(takeAccessibleScreenshot({} as any, makeTestInfo() as any, {
|
|
515
|
+
interactionStates: [{locator: locator as any, states: ['hover', 'focus']}],
|
|
516
|
+
})).rejects.toThrow('hover failed')
|
|
517
|
+
|
|
518
|
+
expect(locator.focus).not.toHaveBeenCalled()
|
|
519
|
+
expect(interactionShieldCleanup).toHaveBeenCalledOnce()
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('rejects interaction states that a real pointer cannot hold simultaneously', async () => {
|
|
523
|
+
const first = {hover: vi.fn()}
|
|
524
|
+
const second = {hover: vi.fn()}
|
|
525
|
+
|
|
526
|
+
await expect(takeAccessibleScreenshot({} as any, makeTestInfo() as any, {
|
|
527
|
+
interactionStates: [
|
|
528
|
+
{locator: first as any, states: ['hover']},
|
|
529
|
+
{locator: second as any, states: ['hover']},
|
|
530
|
+
],
|
|
531
|
+
})).rejects.toThrow('can hover at most one locator')
|
|
532
|
+
|
|
533
|
+
expect(mockClearHover).not.toHaveBeenCalled()
|
|
534
|
+
expect(first.hover).not.toHaveBeenCalled()
|
|
535
|
+
expect(second.hover).not.toHaveBeenCalled()
|
|
536
|
+
})
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
describe('locator-derived clip', () => {
|
|
540
|
+
beforeEach(() => {
|
|
541
|
+
vi.clearAllMocks()
|
|
542
|
+
mockAnalyze.mockResolvedValue(makeAxeResults())
|
|
543
|
+
mockClearHover.mockResolvedValue(mockRemoveHoverShield)
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
function fixture() {
|
|
547
|
+
const page = {evaluate: vi.fn().mockResolvedValueOnce(undefined).mockResolvedValue({x: 100, y: 800, width: 640, height: 480})}
|
|
548
|
+
const locator = {page: () => page, scrollIntoViewIfNeeded: vi.fn(), boundingBox: vi.fn().mockResolvedValue({x: 10.2, y: 20.3, width: 200.4, height: 100.2})}
|
|
549
|
+
return {page, locator}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
for (const fullPage of [false, true]) {
|
|
553
|
+
it(`measures after readiness and scroll, with fullPage=${fullPage}`, async () => {
|
|
554
|
+
const {page, locator} = fixture()
|
|
555
|
+
await takeAccessibleScreenshot(page as any, makeTestInfo() as any, {clipLocator: locator as any, fullPage})
|
|
556
|
+
expect(mockToHaveScreenshot).toHaveBeenCalledWith({timeout: 10000, fullPage, clip: {x: fullPage ? 110 : 10, y: fullPage ? 820 : 20, width: 200, height: 100}})
|
|
557
|
+
expect(mockExpectSoft).toHaveBeenCalledWith(page)
|
|
558
|
+
expect(locator.scrollIntoViewIfNeeded.mock.invocationCallOrder[0]).toBeGreaterThan(mockWaitForVideos.mock.invocationCallOrder[0])
|
|
559
|
+
expect(locator.boundingBox.mock.invocationCallOrder[0]).toBeGreaterThan(locator.scrollIntoViewIfNeeded.mock.invocationCallOrder[0])
|
|
560
|
+
expect(mockAnalyze).toHaveBeenCalledTimes(2)
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
it('rejects conflicting capture modes before readiness', async () => {
|
|
565
|
+
const {page, locator} = fixture()
|
|
566
|
+
await expect(takeAccessibleScreenshot(page as any, makeTestInfo() as any, {clipLocator: locator as any, clip: {x: 0, y: 0, width: 1, height: 1}})).rejects.toThrow('cannot be combined')
|
|
567
|
+
await expect(takeAccessibleScreenshot(page as any, makeTestInfo() as any, {clipLocator: locator as any}, undefined, locator as any)).rejects.toThrow('cannot be combined')
|
|
568
|
+
expect(mockWaitForFrames).not.toHaveBeenCalled()
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('preserves existing raw clip and locator screenshot paths', async () => {
|
|
572
|
+
const page = {} as any
|
|
573
|
+
const locator = {} as any
|
|
574
|
+
const clip = {x: 5, y: 10, width: 100, height: 80}
|
|
575
|
+
await takeAccessibleScreenshot(page, makeTestInfo() as any, {clip})
|
|
576
|
+
expect(mockToHaveScreenshot).toHaveBeenLastCalledWith({timeout: 10000, clip})
|
|
577
|
+
await takeAccessibleScreenshot(page, makeTestInfo() as any, {}, undefined, locator)
|
|
578
|
+
expect(mockExpectSoft).toHaveBeenCalledWith(locator)
|
|
579
|
+
expect(mockToHaveScreenshot).toHaveBeenLastCalledWith({timeout: 10000})
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
it('rejects a clip locator from a different page', async () => {
|
|
583
|
+
const {locator} = fixture()
|
|
584
|
+
await expect(takeAccessibleScreenshot({} as any, makeTestInfo() as any, {clipLocator: locator as any})).rejects.toThrow('must belong')
|
|
585
|
+
expect(mockWaitForFrames).not.toHaveBeenCalled()
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
it('rejects missing and zero-sized bounds and restores capture state', async () => {
|
|
589
|
+
for (const bounds of [null, {x: 0, y: 0, width: 0.2, height: 1}]) {
|
|
590
|
+
const {page, locator} = fixture()
|
|
591
|
+
locator.boundingBox.mockResolvedValue(bounds as any)
|
|
592
|
+
await expect(takeAccessibleScreenshot(page as any, makeTestInfo() as any, {clipLocator: locator as any})).rejects.toThrow(/bounding box|positive rounded/)
|
|
593
|
+
}
|
|
594
|
+
expect(mockRestoreVideoPlayback).toHaveBeenCalledTimes(2)
|
|
595
|
+
expect(mockToHaveScreenshot).not.toHaveBeenCalled()
|
|
596
|
+
})
|
|
597
|
+
})
|