@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.
Files changed (82) hide show
  1. package/README.md +61 -0
  2. package/bin/github-a11y-summary +5 -0
  3. package/bin/github-failure-summary +8 -0
  4. package/lib/accessibility-baseline-file.d.ts +47 -0
  5. package/lib/accessibility-baseline-file.js +205 -0
  6. package/lib/accessibility-baseline.d.ts +19 -0
  7. package/lib/accessibility-baseline.js +38 -0
  8. package/lib/accessible-screenshot.d.ts +181 -0
  9. package/lib/accessible-screenshot.js +519 -0
  10. package/lib/focus.d.ts +14 -0
  11. package/lib/focus.js +28 -0
  12. package/lib/fonts.d.ts +18 -0
  13. package/lib/fonts.js +24 -0
  14. package/lib/frames.d.ts +7 -0
  15. package/lib/frames.js +27 -0
  16. package/lib/github/a11y-summary.d.ts +55 -0
  17. package/lib/github/a11y-summary.js +383 -0
  18. package/lib/github/attachments.d.ts +98 -0
  19. package/lib/github/attachments.js +297 -0
  20. package/lib/github/failure-summary.d.ts +144 -0
  21. package/lib/github/failure-summary.js +567 -0
  22. package/lib/github/index.d.ts +6 -0
  23. package/lib/github/index.js +35 -0
  24. package/lib/github/report-paths.d.ts +38 -0
  25. package/lib/github/report-paths.js +200 -0
  26. package/lib/hover.d.ts +13 -0
  27. package/lib/hover.js +61 -0
  28. package/lib/images.d.ts +118 -0
  29. package/lib/images.js +260 -0
  30. package/lib/index.d.ts +13 -0
  31. package/lib/index.js +29 -0
  32. package/lib/interaction-states.d.ts +22 -0
  33. package/lib/interaction-states.js +75 -0
  34. package/lib/mock/index.d.ts +1 -0
  35. package/lib/mock/index.js +5 -0
  36. package/lib/mock/youtube.d.ts +5 -0
  37. package/lib/mock/youtube.js +38 -0
  38. package/lib/pseudo-state.d.ts +17 -0
  39. package/lib/pseudo-state.js +50 -0
  40. package/lib/videos.d.ts +134 -0
  41. package/lib/videos.js +349 -0
  42. package/lib/visualdiff.d.ts +154 -0
  43. package/lib/visualdiff.js +197 -0
  44. package/package.json +47 -0
  45. package/src/accessibility-baseline-file.test.ts +181 -0
  46. package/src/accessibility-baseline-file.ts +208 -0
  47. package/src/accessibility-baseline.test.ts +601 -0
  48. package/src/accessibility-baseline.ts +50 -0
  49. package/src/accessible-screenshot.test.ts +597 -0
  50. package/src/accessible-screenshot.ts +809 -0
  51. package/src/focus.test.ts +34 -0
  52. package/src/focus.ts +27 -0
  53. package/src/fonts.test.ts +17 -0
  54. package/src/fonts.ts +23 -0
  55. package/src/frames.test.ts +75 -0
  56. package/src/frames.ts +26 -0
  57. package/src/github/a11y-summary.test.ts +439 -0
  58. package/src/github/a11y-summary.ts +421 -0
  59. package/src/github/attachments.test.ts +248 -0
  60. package/src/github/attachments.ts +328 -0
  61. package/src/github/failure-summary.test.ts +636 -0
  62. package/src/github/failure-summary.ts +720 -0
  63. package/src/github/index.test.ts +24 -0
  64. package/src/github/index.ts +35 -0
  65. package/src/github/report-paths.test.ts +222 -0
  66. package/src/github/report-paths.ts +208 -0
  67. package/src/hover.test.ts +76 -0
  68. package/src/hover.ts +64 -0
  69. package/src/images.test.ts +355 -0
  70. package/src/images.ts +299 -0
  71. package/src/index.ts +13 -0
  72. package/src/interaction-states.test.ts +48 -0
  73. package/src/interaction-states.ts +94 -0
  74. package/src/mock/index.ts +1 -0
  75. package/src/mock/youtube.test.ts +38 -0
  76. package/src/mock/youtube.ts +39 -0
  77. package/src/pseudo-state.test.ts +83 -0
  78. package/src/pseudo-state.ts +69 -0
  79. package/src/videos.test.ts +637 -0
  80. package/src/videos.ts +389 -0
  81. package/src/visualdiff.test.ts +452 -0
  82. 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
+ })