Skip to content

Commit

Permalink
feat(browser): run tests in parallel in headless mode, add `page.scre…
Browse files Browse the repository at this point in the history
…enshot` method (#5853)
  • Loading branch information
sheremet-va committed Jun 12, 2024
1 parent 0a71594 commit 81c42fc
Show file tree
Hide file tree
Showing 49 changed files with 1,402 additions and 364 deletions.
62 changes: 62 additions & 0 deletions docs/guide/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ export const page: {
* Change the size of iframe's viewport.
*/
viewport: (width: number | string, height: number | string) => Promise<void>
/**
* Make a screenshot of the test iframe or a specific element.
* @returns Path to the screenshot file.
*/
screenshot: (options?: ScreenshotOptions) => Promise<string>
}
```
Expand Down Expand Up @@ -360,6 +365,63 @@ declare module '@vitest/browser/context' {
Custom functions will override built-in ones if they have the same name.
:::

### Custom `playwright` commands

Vitest exposes several `playwright` specific properties on the command context.

- `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.
- `tester` is the iframe locator. The API is pretty limited here, but you can chain it further to access your HTML elements.
- `body` is the iframe's `body` locator that exposes more Playwright APIs.

```ts
import { defineCommand } from '@vitest/browser'
export const myCommand = defineCommand(async (ctx, arg1, arg2) => {
if (ctx.provider.name === 'playwright') {
const element = await ctx.tester.findByRole('alert')
const screenshot = await element.screenshot()
// do something with the screenshot
return difference
}
})
```

::: tip
If you are using TypeScript, don't forget to add `@vitest/browser/providers/playwright` to your `tsconfig` "compilerOptions.types" field to get autocompletion:

```json
{
"compilerOptions": {
"types": [
"@vitest/browser/providers/playwright"
]
}
}
```
:::

### Custom `webdriverio` commands

Vitest exposes some `webdriverio` specific properties on the context object.

- `browser` is the `WebdriverIO.Browser` API.

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.

::: tip
If you are using TypeScript, don't forget to add `@vitest/browser/providers/webdriverio` to your `tsconfig` "compilerOptions.types" field to get autocompletion:

```json
{
"compilerOptions": {
"types": [
"@vitest/browser/providers/webdriverio"
]
}
}
```
:::

## Limitations

### Thread Blocking Dialogs
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default antfu(
// let TypeScript handle this
'no-undef': 'off',
'ts/no-invalid-this': 'off',
'eslint-comments/no-unlimited-disable': 'off',

// TODO: migrate and turn it back on
'ts/ban-types': 'off',
Expand Down
17 changes: 16 additions & 1 deletion packages/browser/context.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ export interface UpPayload { up: string }

export type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload

export interface ScreenshotOptions {
element?: Element
/**
* Path relative to the `screenshotDirectory` in the test config.
*/
path?: string
}

export interface BrowserCommands {
readFile: (path: string, options?: BufferEncoding | FsOptions) => Promise<string>
writeFile: (path: string, content: string, options?: BufferEncoding | FsOptions & { mode?: number | string }) => Promise<void>
Expand Down Expand Up @@ -100,7 +108,7 @@ export const userEvent: UserEvent
*/
export const commands: BrowserCommands

export const page: {
export interface BrowserPage {
/**
* Serialized test config.
*/
Expand All @@ -109,4 +117,11 @@ export const page: {
* Change the size of iframe's viewport.
*/
viewport: (width: number, height: number) => Promise<void>
/**
* Make a screenshot of the test iframe or a specific element.
* @returns Path to the screenshot file.
*/
screenshot: (options?: ScreenshotOptions) => Promise<string>
}

export const page: BrowserPage
2 changes: 2 additions & 0 deletions packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"@testing-library/user-event": "^14.5.2",
"@vitest/utils": "workspace:*",
"magic-string": "^0.30.10",
"msw": "^2.3.1",
"sirv": "^2.0.4"
},
"devDependencies": {
Expand All @@ -83,6 +84,7 @@
"@wdio/protocols": "^8.38.0",
"birpc": "0.2.17",
"flatted": "^3.3.1",
"pathe": "^1.1.2",
"periscopic": "^4.0.2",
"playwright": "^1.44.1",
"playwright-core": "^1.44.1",
Expand Down
16 changes: 14 additions & 2 deletions packages/browser/providers/playwright.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import type { Browser, LaunchOptions } from 'playwright'
import type {
BrowserContextOptions,
FrameLocator,
LaunchOptions,
Locator,
Page,
} from 'playwright'

declare module 'vitest/node' {
interface BrowserProviderOptions {
launch?: LaunchOptions
page?: Parameters<Browser['newPage']>[0]
context?: Omit<BrowserContextOptions, 'ignoreHTTPSErrors' | 'serviceWorkers'>
}

export interface BrowserCommandContext {
page: Page
tester: FrameLocator
body: Locator
}
}
4 changes: 4 additions & 0 deletions packages/browser/providers/webdriverio.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@ import type { RemoteOptions } from 'webdriverio'

declare module 'vitest/node' {
interface BrowserProviderOptions extends RemoteOptions {}

export interface BrowserCommandContext {
browser: WebdriverIO.Browser
}
}
17 changes: 15 additions & 2 deletions packages/browser/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import dts from 'rollup-plugin-dts'
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import json from '@rollup/plugin-json'
import { defineConfig } from 'rollup'

const require = createRequire(import.meta.url)
const pkg = require('./package.json')
Expand Down Expand Up @@ -32,7 +33,7 @@ const input = {
providers: './src/node/providers/index.ts',
}

export default () => [
export default () => defineConfig([
{
input,
output: {
Expand All @@ -42,6 +43,18 @@ export default () => [
external,
plugins,
},
{
input: './src/client/context.ts',
output: {
file: 'dist/context.js',
format: 'esm',
},
plugins: [
esbuild({
target: 'node18',
}),
],
},
{
input: input.index,
output: {
Expand All @@ -51,4 +64,4 @@ export default () => [
external,
plugins: [dts()],
},
]
])
90 changes: 90 additions & 0 deletions packages/browser/src/client/channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { getBrowserState } from './utils'

export interface IframeDoneEvent {
type: 'done'
filenames: string[]
id: string
}

export interface IframeErrorEvent {
type: 'error'
error: any
errorType: string
files: string[]
id: string
}

export interface IframeViewportEvent {
type: 'viewport'
width: number
height: number
id: string
}

export interface IframeMockEvent {
type: 'mock'
paths: string[]
mock: string | undefined | null
}

export interface IframeUnmockEvent {
type: 'unmock'
paths: string[]
}

export interface IframeMockingDoneEvent {
type: 'mock:done' | 'unmock:done'
}

export interface IframeMockFactoryRequestEvent {
type: 'mock-factory:request'
id: string
}

export interface IframeMockFactoryResponseEvent {
type: 'mock-factory:response'
exports: string[]
}

export interface IframeMockFactoryErrorEvent {
type: 'mock-factory:error'
error: any
}

export interface IframeViewportChannelEvent {
type: 'viewport:done' | 'viewport:fail'
}

export interface IframeMockInvalidateEvent {
type: 'mock:invalidate'
}

export type IframeChannelIncomingEvent =
| IframeViewportEvent
| IframeErrorEvent
| IframeDoneEvent
| IframeMockEvent
| IframeUnmockEvent
| IframeMockFactoryResponseEvent
| IframeMockFactoryErrorEvent
| IframeMockInvalidateEvent

export type IframeChannelOutgoingEvent =
| IframeMockFactoryRequestEvent
| IframeViewportChannelEvent
| IframeMockingDoneEvent

export type IframeChannelEvent =
| IframeChannelIncomingEvent
| IframeChannelOutgoingEvent

export const channel = new BroadcastChannel(`vitest:${getBrowserState().contextId}`)

export function waitForChannel(event: IframeChannelOutgoingEvent['type']) {
return new Promise<void>((resolve) => {
channel.addEventListener('message', (e) => {
if (e.data?.type === event)
resolve()
}, { once: true })
})
}
9 changes: 6 additions & 3 deletions packages/browser/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ const PAGE_TYPE = getBrowserState().type

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

type BrowserRPC = BirpcReturn<WebSocketBrowserHandlers, WebSocketBrowserEvents>
export type BrowserRPC = BirpcReturn<WebSocketBrowserHandlers, WebSocketBrowserEvents>

function createClient() {
const autoReconnect = true
Expand Down Expand Up @@ -120,4 +122,5 @@ function createClient() {
}

export const client = createClient()
export const channel = new BroadcastChannel('vitest')

export { channel, waitForChannel } from './channel'
Loading

0 comments on commit 81c42fc

Please sign in to comment.