@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,48 @@
|
|
|
1
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
2
|
+
import {applyInteractionStates, validateInteractionStates} from './interaction-states'
|
|
3
|
+
|
|
4
|
+
describe('validateInteractionStates', () => {
|
|
5
|
+
it('rejects states that real input cannot hold simultaneously', () => {
|
|
6
|
+
const locator = {} as never
|
|
7
|
+
expect(() => validateInteractionStates([
|
|
8
|
+
{locator, states: ['hover']},
|
|
9
|
+
{locator, states: ['hover']},
|
|
10
|
+
])).toThrow('at most one')
|
|
11
|
+
expect(() => validateInteractionStates([
|
|
12
|
+
{locator, states: ['focus', 'focus']},
|
|
13
|
+
])).toThrow('cannot repeat')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('applyInteractionStates', () => {
|
|
18
|
+
it('applies hover before focus and cleans both states up', async () => {
|
|
19
|
+
const hover = vi.fn().mockResolvedValue(undefined)
|
|
20
|
+
const focus = vi.fn().mockResolvedValue(undefined)
|
|
21
|
+
const blur = vi.fn().mockResolvedValue(undefined)
|
|
22
|
+
const clearHover = vi.fn().mockResolvedValue(undefined)
|
|
23
|
+
|
|
24
|
+
const cleanup = await applyInteractionStates([
|
|
25
|
+
{locator: {hover, focus: vi.fn(), blur: vi.fn()} as never, states: ['hover']},
|
|
26
|
+
{locator: {hover: vi.fn(), focus, blur} as never, states: ['focus']},
|
|
27
|
+
], {clearHover})
|
|
28
|
+
|
|
29
|
+
expect(hover.mock.invocationCallOrder[0]).toBeLessThan(focus.mock.invocationCallOrder[0])
|
|
30
|
+
await cleanup()
|
|
31
|
+
await cleanup()
|
|
32
|
+
expect(blur).toHaveBeenCalledOnce()
|
|
33
|
+
expect(clearHover).toHaveBeenCalledOnce()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('cleans already-applied state when a later interaction fails', async () => {
|
|
37
|
+
const clearHover = vi.fn().mockResolvedValue(undefined)
|
|
38
|
+
const blur = vi.fn().mockResolvedValue(undefined)
|
|
39
|
+
const failure = new Error('focus failed')
|
|
40
|
+
|
|
41
|
+
await expect(applyInteractionStates([
|
|
42
|
+
{locator: {hover: vi.fn().mockResolvedValue(undefined)} as never, states: ['hover']},
|
|
43
|
+
{locator: {focus: vi.fn().mockRejectedValue(failure), blur} as never, states: ['focus']},
|
|
44
|
+
], {clearHover})).rejects.toBe(failure)
|
|
45
|
+
expect(blur).toHaveBeenCalledOnce()
|
|
46
|
+
expect(clearHover).toHaveBeenCalledOnce()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type {Locator} from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
/** Real browser interaction states supported consistently by Playwright. */
|
|
4
|
+
export type InteractionState = 'hover' | 'focus'
|
|
5
|
+
|
|
6
|
+
/** A locator and the real browser interaction states to apply to it. */
|
|
7
|
+
export interface ScreenshotInteractionState {
|
|
8
|
+
locator: Locator
|
|
9
|
+
states: InteractionState[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface InteractionStateCleanupOptions {
|
|
13
|
+
/** Move the pointer away from the hovered locator during cleanup. */
|
|
14
|
+
clearHover?: () => Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Validate that the requested states are possible with real browser input. */
|
|
18
|
+
export function validateInteractionStates(
|
|
19
|
+
interactionStates: ScreenshotInteractionState[],
|
|
20
|
+
): void {
|
|
21
|
+
const hoverTargets = interactionStates.filter(({states}) => states.includes('hover'))
|
|
22
|
+
const focusTargets = interactionStates.filter(({states}) => states.includes('focus'))
|
|
23
|
+
|
|
24
|
+
if (hoverTargets.length > 1) {
|
|
25
|
+
throw new Error('interactionStates can hover at most one locator at a time.')
|
|
26
|
+
}
|
|
27
|
+
if (focusTargets.length > 1) {
|
|
28
|
+
throw new Error('interactionStates can focus at most one locator at a time.')
|
|
29
|
+
}
|
|
30
|
+
for (const {states} of interactionStates) {
|
|
31
|
+
if (new Set(states).size !== states.length) {
|
|
32
|
+
throw new Error('interactionStates cannot repeat a state for the same locator.')
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Apply real hover and focus, returning an idempotent cleanup function.
|
|
39
|
+
*
|
|
40
|
+
* Hover is applied before focus so focusing cannot disturb the pointer. If an
|
|
41
|
+
* interaction fails, any state already applied is cleaned before the original
|
|
42
|
+
* error is rethrown.
|
|
43
|
+
*/
|
|
44
|
+
export async function applyInteractionStates(
|
|
45
|
+
interactionStates: ScreenshotInteractionState[],
|
|
46
|
+
cleanupOptions: InteractionStateCleanupOptions = {},
|
|
47
|
+
): Promise<() => Promise<void>> {
|
|
48
|
+
validateInteractionStates(interactionStates)
|
|
49
|
+
|
|
50
|
+
const hoverTarget = interactionStates.find(({states}) => states.includes('hover'))?.locator
|
|
51
|
+
const focusTarget = interactionStates.find(({states}) => states.includes('focus'))?.locator
|
|
52
|
+
let hoverApplied = false
|
|
53
|
+
let focusApplied = false
|
|
54
|
+
let cleaned = false
|
|
55
|
+
|
|
56
|
+
const cleanup = async () => {
|
|
57
|
+
if (cleaned) {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
cleaned = true
|
|
61
|
+
try {
|
|
62
|
+
if (focusApplied) {
|
|
63
|
+
await focusTarget?.blur()
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
if (hoverApplied) {
|
|
67
|
+
await cleanupOptions.clearHover?.()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
if (hoverTarget) {
|
|
74
|
+
// Mark the state first: Playwright can move the pointer and then fail
|
|
75
|
+
// while waiting for actionability, and that partial effect still needs
|
|
76
|
+
// cleanup.
|
|
77
|
+
hoverApplied = true
|
|
78
|
+
await hoverTarget.hover()
|
|
79
|
+
}
|
|
80
|
+
if (focusTarget) {
|
|
81
|
+
focusApplied = true
|
|
82
|
+
await focusTarget.focus()
|
|
83
|
+
}
|
|
84
|
+
return cleanup
|
|
85
|
+
} catch (error) {
|
|
86
|
+
try {
|
|
87
|
+
await cleanup()
|
|
88
|
+
} catch {
|
|
89
|
+
// Preserve the interaction failure. Cleanup is best effort on this path;
|
|
90
|
+
// callers cannot act on a cleanup error if applying the state failed.
|
|
91
|
+
}
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { YoutubeMock } from './youtube.js'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { YoutubeMock } from './youtube'
|
|
3
|
+
|
|
4
|
+
describe('YoutubeMock', () => {
|
|
5
|
+
it('registers a route handler for youtube.com', async () => {
|
|
6
|
+
const mockPage = {
|
|
7
|
+
route: vi.fn(),
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const mock = new YoutubeMock()
|
|
11
|
+
await mock.mock(mockPage as any)
|
|
12
|
+
|
|
13
|
+
expect(mockPage.route).toHaveBeenCalledOnce()
|
|
14
|
+
const [pattern] = mockPage.route.mock.calls[0]
|
|
15
|
+
expect(pattern).toBeInstanceOf(RegExp)
|
|
16
|
+
expect(pattern.test('https://www.youtube.com/embed/abc123')).toBe(true)
|
|
17
|
+
expect(pattern.test('https://www.notyoutube.com')).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('fulfills with HTML mock content', async () => {
|
|
21
|
+
const mockFulfill = vi.fn()
|
|
22
|
+
const mockPage = {
|
|
23
|
+
route: vi.fn(),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const mock = new YoutubeMock()
|
|
27
|
+
await mock.mock(mockPage as any)
|
|
28
|
+
|
|
29
|
+
// Call the route handler
|
|
30
|
+
const handler = mockPage.route.mock.calls[0][1]
|
|
31
|
+
await handler({ fulfill: mockFulfill })
|
|
32
|
+
|
|
33
|
+
expect(mockFulfill).toHaveBeenCalledOnce()
|
|
34
|
+
const fulfillArg = mockFulfill.mock.calls[0][0]
|
|
35
|
+
expect(fulfillArg.contentType).toBe('text/html')
|
|
36
|
+
expect(fulfillArg.body).toContain('Youtube Video Mock')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type {Page} from '@playwright/test';
|
|
2
|
+
import type {Mockable} from "../visualdiff.js";
|
|
3
|
+
|
|
4
|
+
export class YoutubeMock implements Mockable {
|
|
5
|
+
|
|
6
|
+
public async mock(page: Page): Promise<void> {
|
|
7
|
+
await page.route(/www\.youtube\.com/i, async route => {
|
|
8
|
+
await route.fulfill({
|
|
9
|
+
contentType: 'text/html',
|
|
10
|
+
body: `
|
|
11
|
+
<html>
|
|
12
|
+
<head>
|
|
13
|
+
<title>Youtube Video Mock</title>
|
|
14
|
+
<style>
|
|
15
|
+
body {
|
|
16
|
+
color: white;
|
|
17
|
+
background-color: darkred;
|
|
18
|
+
}
|
|
19
|
+
div {
|
|
20
|
+
position: absolute;
|
|
21
|
+
top: 50%;
|
|
22
|
+
left: 50%;
|
|
23
|
+
margin: 0;
|
|
24
|
+
transform: translate(-50%, -50%);
|
|
25
|
+
font-size: xxx-large;
|
|
26
|
+
text-align: center;
|
|
27
|
+
}
|
|
28
|
+
</style>
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<div>Youtube Video Mock</div>
|
|
32
|
+
</body>
|
|
33
|
+
</html>
|
|
34
|
+
`,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
2
|
+
import {forcePseudoState} from './pseudo-state'
|
|
3
|
+
|
|
4
|
+
function fakePage(nodeId = 2) {
|
|
5
|
+
const session = {
|
|
6
|
+
send: vi.fn(async (method: string) => {
|
|
7
|
+
if (method === 'DOM.getDocument') {
|
|
8
|
+
return {root: {nodeId: 1}}
|
|
9
|
+
}
|
|
10
|
+
if (method === 'DOM.querySelector') {
|
|
11
|
+
return {nodeId}
|
|
12
|
+
}
|
|
13
|
+
return {}
|
|
14
|
+
}),
|
|
15
|
+
detach: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
}
|
|
17
|
+
const context = {
|
|
18
|
+
newCDPSession: vi.fn().mockResolvedValue(session),
|
|
19
|
+
}
|
|
20
|
+
const page = {
|
|
21
|
+
context: vi.fn(() => context),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {context, page, session}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('forcePseudoState', () => {
|
|
28
|
+
it('forces pseudo-classes on the first selector match and clears them', async () => {
|
|
29
|
+
const {context, page, session} = fakePage()
|
|
30
|
+
|
|
31
|
+
const cleanup = await forcePseudoState(page as any, '.menu-trigger', ['hover', 'focus-visible'])
|
|
32
|
+
|
|
33
|
+
expect(context.newCDPSession).toHaveBeenCalledWith(page)
|
|
34
|
+
expect(session.send).toHaveBeenNthCalledWith(1, 'DOM.enable')
|
|
35
|
+
expect(session.send).toHaveBeenNthCalledWith(2, 'CSS.enable')
|
|
36
|
+
expect(session.send).toHaveBeenNthCalledWith(3, 'DOM.getDocument')
|
|
37
|
+
expect(session.send).toHaveBeenNthCalledWith(4, 'DOM.querySelector', {
|
|
38
|
+
nodeId: 1,
|
|
39
|
+
selector: '.menu-trigger',
|
|
40
|
+
})
|
|
41
|
+
expect(session.send).toHaveBeenNthCalledWith(5, 'CSS.forcePseudoState', {
|
|
42
|
+
nodeId: 2,
|
|
43
|
+
forcedPseudoClasses: ['hover', 'focus-visible'],
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
await cleanup()
|
|
47
|
+
await cleanup()
|
|
48
|
+
|
|
49
|
+
expect(session.send).toHaveBeenNthCalledWith(6, 'CSS.forcePseudoState', {
|
|
50
|
+
nodeId: 2,
|
|
51
|
+
forcedPseudoClasses: [],
|
|
52
|
+
})
|
|
53
|
+
expect(session.detach).toHaveBeenCalledOnce()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('fails clearly and detaches when the selector does not match', async () => {
|
|
57
|
+
const {page, session} = fakePage(0)
|
|
58
|
+
|
|
59
|
+
await expect(forcePseudoState(page as any, '.missing', ['hover']))
|
|
60
|
+
.rejects.toThrow('could not find an element matching ".missing"')
|
|
61
|
+
expect(session.detach).toHaveBeenCalledOnce()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('reports that CDP requires Chromium', async () => {
|
|
65
|
+
const page = {
|
|
66
|
+
context: () => ({
|
|
67
|
+
newCDPSession: vi.fn().mockRejectedValue(new Error('CDP only supported in Chromium')),
|
|
68
|
+
}),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await expect(forcePseudoState(page as any, 'button', ['focus']))
|
|
72
|
+
.rejects.toThrow('requires a Chromium browser project')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('detaches even when clearing the state fails', async () => {
|
|
76
|
+
const {page, session} = fakePage()
|
|
77
|
+
const cleanup = await forcePseudoState(page as any, 'button', ['focus'])
|
|
78
|
+
session.send.mockRejectedValueOnce(new Error('node disappeared'))
|
|
79
|
+
|
|
80
|
+
await expect(cleanup()).rejects.toThrow('node disappeared')
|
|
81
|
+
expect(session.detach).toHaveBeenCalledOnce()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {Page} from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
/** Pseudo-classes supported by Chromium's CSS.forcePseudoState command. */
|
|
4
|
+
export type ForcedPseudoClass =
|
|
5
|
+
| 'active'
|
|
6
|
+
| 'focus'
|
|
7
|
+
| 'focus-visible'
|
|
8
|
+
| 'focus-within'
|
|
9
|
+
| 'hover'
|
|
10
|
+
| 'target'
|
|
11
|
+
|
|
12
|
+
/** A CSS selector and the pseudo-classes to force on its first match. */
|
|
13
|
+
export interface ForcedPseudoState {
|
|
14
|
+
selector: string
|
|
15
|
+
pseudoClasses: ForcedPseudoClass[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Force CSS pseudo-classes on the first element matching a selector.
|
|
20
|
+
*
|
|
21
|
+
* This uses Chromium's DevTools Protocol and therefore only works in Chromium
|
|
22
|
+
* projects. The state is synthetic: moving the pointer or blurring the active
|
|
23
|
+
* element does not clear it. Call the returned idempotent cleanup function when
|
|
24
|
+
* the screenshot and accessibility scan are complete.
|
|
25
|
+
*/
|
|
26
|
+
export async function forcePseudoState(
|
|
27
|
+
page: Page,
|
|
28
|
+
selector: string,
|
|
29
|
+
pseudoClasses: ForcedPseudoClass[],
|
|
30
|
+
): Promise<() => Promise<void>> {
|
|
31
|
+
let session
|
|
32
|
+
try {
|
|
33
|
+
session = await page.context().newCDPSession(page)
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new Error('forcePseudoState() requires a Chromium browser project.', {cause: error})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await session.send('DOM.enable')
|
|
40
|
+
await session.send('CSS.enable')
|
|
41
|
+
const {root} = await session.send('DOM.getDocument')
|
|
42
|
+
const {nodeId} = await session.send('DOM.querySelector', {
|
|
43
|
+
nodeId: root.nodeId,
|
|
44
|
+
selector,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
if (!nodeId) {
|
|
48
|
+
throw new Error(`forcePseudoState() could not find an element matching "${selector}".`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await session.send('CSS.forcePseudoState', {nodeId, forcedPseudoClasses: pseudoClasses})
|
|
52
|
+
|
|
53
|
+
let cleared = false
|
|
54
|
+
return async () => {
|
|
55
|
+
if (cleared) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
cleared = true
|
|
59
|
+
try {
|
|
60
|
+
await session.send('CSS.forcePseudoState', {nodeId, forcedPseudoClasses: []})
|
|
61
|
+
} finally {
|
|
62
|
+
await session.detach()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
await session.detach()
|
|
67
|
+
throw error
|
|
68
|
+
}
|
|
69
|
+
}
|