Skip to content

Commit 81c42fc

Browse files
authored
feat(browser): run tests in parallel in headless mode, add page.screenshot method (#5853)
1 parent 0a71594 commit 81c42fc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1402
-364
lines changed

docs/guide/browser.md

+62
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@ export const page: {
236236
* Change the size of iframe's viewport.
237237
*/
238238
viewport: (width: number | string, height: number | string) => Promise<void>
239+
/**
240+
* Make a screenshot of the test iframe or a specific element.
241+
* @returns Path to the screenshot file.
242+
*/
243+
screenshot: (options?: ScreenshotOptions) => Promise<string>
239244
}
240245
```
241246
@@ -360,6 +365,63 @@ declare module '@vitest/browser/context' {
360365
Custom functions will override built-in ones if they have the same name.
361366
:::
362367

368+
### Custom `playwright` commands
369+
370+
Vitest exposes several `playwright` specific properties on the command context.
371+
372+
- `page` references the full page that contains the test iframe. This is the orchestrator HTML and you most likely shouldn't touch it to not break things.
373+
- `tester` is the iframe locator. The API is pretty limited here, but you can chain it further to access your HTML elements.
374+
- `body` is the iframe's `body` locator that exposes more Playwright APIs.
375+
376+
```ts
377+
import { defineCommand } from '@vitest/browser'
378+
379+
export const myCommand = defineCommand(async (ctx, arg1, arg2) => {
380+
if (ctx.provider.name === 'playwright') {
381+
const element = await ctx.tester.findByRole('alert')
382+
const screenshot = await element.screenshot()
383+
// do something with the screenshot
384+
return difference
385+
}
386+
})
387+
```
388+
389+
::: tip
390+
If you are using TypeScript, don't forget to add `@vitest/browser/providers/playwright` to your `tsconfig` "compilerOptions.types" field to get autocompletion:
391+
392+
```json
393+
{
394+
"compilerOptions": {
395+
"types": [
396+
"@vitest/browser/providers/playwright"
397+
]
398+
}
399+
}
400+
```
401+
:::
402+
403+
### Custom `webdriverio` commands
404+
405+
Vitest exposes some `webdriverio` specific properties on the context object.
406+
407+
- `browser` is the `WebdriverIO.Browser` API.
408+
409+
Vitest automatically switches the `webdriver` context to the test iframe by calling `browser.switchToFrame` before the command is called, so `$` and `$$` methods refer to the elements inside the iframe, not in the orchestrator, but non-webdriver APIs will still refer to the parent frame context.
410+
411+
::: tip
412+
If you are using TypeScript, don't forget to add `@vitest/browser/providers/webdriverio` to your `tsconfig` "compilerOptions.types" field to get autocompletion:
413+
414+
```json
415+
{
416+
"compilerOptions": {
417+
"types": [
418+
"@vitest/browser/providers/webdriverio"
419+
]
420+
}
421+
}
422+
```
423+
:::
424+
363425
## Limitations
364426

365427
### Thread Blocking Dialogs

eslint.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export default antfu(
4444
// let TypeScript handle this
4545
'no-undef': 'off',
4646
'ts/no-invalid-this': 'off',
47+
'eslint-comments/no-unlimited-disable': 'off',
4748

4849
// TODO: migrate and turn it back on
4950
'ts/ban-types': 'off',

packages/browser/context.d.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ export interface UpPayload { up: string }
2626

2727
export type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload
2828

29+
export interface ScreenshotOptions {
30+
element?: Element
31+
/**
32+
* Path relative to the `screenshotDirectory` in the test config.
33+
*/
34+
path?: string
35+
}
36+
2937
export interface BrowserCommands {
3038
readFile: (path: string, options?: BufferEncoding | FsOptions) => Promise<string>
3139
writeFile: (path: string, content: string, options?: BufferEncoding | FsOptions & { mode?: number | string }) => Promise<void>
@@ -100,7 +108,7 @@ export const userEvent: UserEvent
100108
*/
101109
export const commands: BrowserCommands
102110

103-
export const page: {
111+
export interface BrowserPage {
104112
/**
105113
* Serialized test config.
106114
*/
@@ -109,4 +117,11 @@ export const page: {
109117
* Change the size of iframe's viewport.
110118
*/
111119
viewport: (width: number, height: number) => Promise<void>
120+
/**
121+
* Make a screenshot of the test iframe or a specific element.
122+
* @returns Path to the screenshot file.
123+
*/
124+
screenshot: (options?: ScreenshotOptions) => Promise<string>
112125
}
126+
127+
export const page: BrowserPage

packages/browser/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"@testing-library/user-event": "^14.5.2",
7474
"@vitest/utils": "workspace:*",
7575
"magic-string": "^0.30.10",
76+
"msw": "^2.3.1",
7677
"sirv": "^2.0.4"
7778
},
7879
"devDependencies": {
@@ -83,6 +84,7 @@
8384
"@wdio/protocols": "^8.38.0",
8485
"birpc": "0.2.17",
8586
"flatted": "^3.3.1",
87+
"pathe": "^1.1.2",
8688
"periscopic": "^4.0.2",
8789
"playwright": "^1.44.1",
8890
"playwright-core": "^1.44.1",
+14-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
1-
import type { Browser, LaunchOptions } from 'playwright'
1+
import type {
2+
BrowserContextOptions,
3+
FrameLocator,
4+
LaunchOptions,
5+
Locator,
6+
Page,
7+
} from 'playwright'
28

39
declare module 'vitest/node' {
410
interface BrowserProviderOptions {
511
launch?: LaunchOptions
6-
page?: Parameters<Browser['newPage']>[0]
12+
context?: Omit<BrowserContextOptions, 'ignoreHTTPSErrors' | 'serviceWorkers'>
13+
}
14+
15+
export interface BrowserCommandContext {
16+
page: Page
17+
tester: FrameLocator
18+
body: Locator
719
}
820
}

packages/browser/providers/webdriverio.d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,8 @@ import type { RemoteOptions } from 'webdriverio'
22

33
declare module 'vitest/node' {
44
interface BrowserProviderOptions extends RemoteOptions {}
5+
6+
export interface BrowserCommandContext {
7+
browser: WebdriverIO.Browser
8+
}
59
}

packages/browser/rollup.config.js

+15-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import dts from 'rollup-plugin-dts'
44
import resolve from '@rollup/plugin-node-resolve'
55
import commonjs from '@rollup/plugin-commonjs'
66
import json from '@rollup/plugin-json'
7+
import { defineConfig } from 'rollup'
78

89
const require = createRequire(import.meta.url)
910
const pkg = require('./package.json')
@@ -32,7 +33,7 @@ const input = {
3233
providers: './src/node/providers/index.ts',
3334
}
3435

35-
export default () => [
36+
export default () => defineConfig([
3637
{
3738
input,
3839
output: {
@@ -42,6 +43,18 @@ export default () => [
4243
external,
4344
plugins,
4445
},
46+
{
47+
input: './src/client/context.ts',
48+
output: {
49+
file: 'dist/context.js',
50+
format: 'esm',
51+
},
52+
plugins: [
53+
esbuild({
54+
target: 'node18',
55+
}),
56+
],
57+
},
4558
{
4659
input: input.index,
4760
output: {
@@ -51,4 +64,4 @@ export default () => [
5164
external,
5265
plugins: [dts()],
5366
},
54-
]
67+
])
+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { getBrowserState } from './utils'
2+
3+
export interface IframeDoneEvent {
4+
type: 'done'
5+
filenames: string[]
6+
id: string
7+
}
8+
9+
export interface IframeErrorEvent {
10+
type: 'error'
11+
error: any
12+
errorType: string
13+
files: string[]
14+
id: string
15+
}
16+
17+
export interface IframeViewportEvent {
18+
type: 'viewport'
19+
width: number
20+
height: number
21+
id: string
22+
}
23+
24+
export interface IframeMockEvent {
25+
type: 'mock'
26+
paths: string[]
27+
mock: string | undefined | null
28+
}
29+
30+
export interface IframeUnmockEvent {
31+
type: 'unmock'
32+
paths: string[]
33+
}
34+
35+
export interface IframeMockingDoneEvent {
36+
type: 'mock:done' | 'unmock:done'
37+
}
38+
39+
export interface IframeMockFactoryRequestEvent {
40+
type: 'mock-factory:request'
41+
id: string
42+
}
43+
44+
export interface IframeMockFactoryResponseEvent {
45+
type: 'mock-factory:response'
46+
exports: string[]
47+
}
48+
49+
export interface IframeMockFactoryErrorEvent {
50+
type: 'mock-factory:error'
51+
error: any
52+
}
53+
54+
export interface IframeViewportChannelEvent {
55+
type: 'viewport:done' | 'viewport:fail'
56+
}
57+
58+
export interface IframeMockInvalidateEvent {
59+
type: 'mock:invalidate'
60+
}
61+
62+
export type IframeChannelIncomingEvent =
63+
| IframeViewportEvent
64+
| IframeErrorEvent
65+
| IframeDoneEvent
66+
| IframeMockEvent
67+
| IframeUnmockEvent
68+
| IframeMockFactoryResponseEvent
69+
| IframeMockFactoryErrorEvent
70+
| IframeMockInvalidateEvent
71+
72+
export type IframeChannelOutgoingEvent =
73+
| IframeMockFactoryRequestEvent
74+
| IframeViewportChannelEvent
75+
| IframeMockingDoneEvent
76+
77+
export type IframeChannelEvent =
78+
| IframeChannelIncomingEvent
79+
| IframeChannelOutgoingEvent
80+
81+
export const channel = new BroadcastChannel(`vitest:${getBrowserState().contextId}`)
82+
83+
export function waitForChannel(event: IframeChannelOutgoingEvent['type']) {
84+
return new Promise<void>((resolve) => {
85+
channel.addEventListener('message', (e) => {
86+
if (e.data?.type === event)
87+
resolve()
88+
}, { once: true })
89+
})
90+
}

packages/browser/src/client/client.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ const PAGE_TYPE = getBrowserState().type
99

1010
export const PORT = import.meta.hot ? '51204' : location.port
1111
export const HOST = [location.hostname, PORT].filter(Boolean).join(':')
12-
export const SESSION_ID = crypto.randomUUID()
12+
export const SESSION_ID = PAGE_TYPE === 'orchestrator'
13+
? getBrowserState().contextId
14+
: crypto.randomUUID()
1315
export const ENTRY_URL = `${
1416
location.protocol === 'https:' ? 'wss:' : 'ws:'
1517
}//${HOST}/__vitest_browser_api__?type=${PAGE_TYPE}&sessionId=${SESSION_ID}`
@@ -25,7 +27,7 @@ export interface VitestBrowserClient {
2527
waitForConnection: () => Promise<void>
2628
}
2729

28-
type BrowserRPC = BirpcReturn<WebSocketBrowserHandlers, WebSocketBrowserEvents>
30+
export type BrowserRPC = BirpcReturn<WebSocketBrowserHandlers, WebSocketBrowserEvents>
2931

3032
function createClient() {
3133
const autoReconnect = true
@@ -120,4 +122,5 @@ function createClient() {
120122
}
121123

122124
export const client = createClient()
123-
export const channel = new BroadcastChannel('vitest')
125+
126+
export { channel, waitForChannel } from './channel'

0 commit comments

Comments
 (0)