Skip to content

Commit aa431f4

Browse files
authored
feat(browser): add commands to communicate betweens server and the browser (#5097)
1 parent 222ce44 commit aa431f4

23 files changed

+683
-29
lines changed

docs/config/index.md

+7
Original file line numberDiff line numberDiff line change
@@ -1679,6 +1679,13 @@ Custom scripts that should be injected into the tester HTML before the tests env
16791679

16801680
The script `src` and `content` will be processed by Vite plugins.
16811681

1682+
#### browser.commands <Version>2.0.0</Version> {#browser-commands}
1683+
1684+
- **Type:** `Record<string, BrowserCommand>`
1685+
- **Default:** `{ readFile, writeFile, ... }`
1686+
1687+
Custom [commands](/guide/browser#commands) that can be import during browser tests from `@vitest/browser/commands`.
1688+
16821689
### clearMocks
16831690

16841691
- **Type:** `boolean`

docs/guide/browser.md

+159
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,165 @@ npx vitest --browser.name=chrome --browser.headless
119119

120120
In this case, Vitest will run in headless mode using the Chrome browser.
121121

122+
## Context <Version>2.0.0</Version> {#context}
123+
124+
Vitest exposes a context module via `@vitest/browser/context` entry point. As of 2.0, it exposes a small set of utilities that might be useful to you in tests.
125+
126+
```ts
127+
export const server: {
128+
/**
129+
* Platform the Vitest server is running on.
130+
* The same as calling `process.platform` on the server.
131+
*/
132+
platform: Platform
133+
/**
134+
* Runtime version of the Vitest server.
135+
* The same as calling `process.version` on the server.
136+
*/
137+
version: string
138+
/**
139+
* Available commands for the browser.
140+
* @see {@link https://vitest.dev/guide/browser#commands}
141+
*/
142+
commands: BrowserCommands
143+
}
144+
145+
/**
146+
* Available commands for the browser.
147+
* A shortcut to `server.commands`.
148+
* @see {@link https://vitest.dev/guide/browser#commands}
149+
*/
150+
export const commands: BrowserCommands
151+
152+
export const page: {
153+
/**
154+
* Serialized test config.
155+
*/
156+
config: ResolvedConfig
157+
}
158+
```
159+
160+
## Commands <Version>2.0.0</Version> {#commands}
161+
162+
Command is a function that invokes another function on the server and passes down the result back to the browser. Vitest exposes several built-in commands you can use in your browser tests.
163+
164+
## Built-in Commands
165+
166+
### Files Handling
167+
168+
You can use `readFile`, `writeFile` and `removeFile` API to handle files inside your browser tests. All paths are resolved relative to the test file even if they are called in a helper function located in another file.
169+
170+
By default, Vitest uses `utf-8` encoding but you can override it with options.
171+
172+
::: tip
173+
This API follows [`server.fs`](https://vitejs.dev/config/server-options.html#server-fs-allow) limitations for security reasons.
174+
:::
175+
176+
```ts
177+
import { server } from '@vitest/browser/context'
178+
179+
const { readFile, writeFile, removeFile } = server.commands
180+
181+
it('handles files', async () => {
182+
const file = './test.txt'
183+
184+
await writeFile(file, 'hello world')
185+
const content = await readFile(file)
186+
187+
expect(content).toBe('hello world')
188+
189+
await removeFile(file)
190+
})
191+
```
192+
193+
### Keyboard Interactions
194+
195+
Vitest also implements Web Test Runner's [`sendKeys` API](https://modern-web.dev/docs/test-runner/commands/#send-keys). It accepts an object with a single property:
196+
197+
- `type` - types a sequence of characters, this API _is not_ affected by modifier keys, so having `Shift` won't make letters uppercase
198+
- `press` - presses a single key, this API _is_ affected by modifier keys, so having `Shift` will make subsequent characters uppercase
199+
- `up` - holds down a key (supported only with `playwright` provider)
200+
- `down` - releases a key (supported only with `playwright` provider)
201+
202+
```ts
203+
interface TypePayload { type: string }
204+
interface PressPayload { press: string }
205+
interface DownPayload { down: string }
206+
interface UpPayload { up: string }
207+
208+
type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload
209+
210+
declare function sendKeys(payload: SendKeysPayload): Promise<void>
211+
```
212+
213+
This is just a simple wrapper around providers APIs. Please refer to their respective documentations for details:
214+
215+
- [Playwright Keyboard API](https://playwright.dev/docs/api/class-keyboard)
216+
- [Webdriver Keyboard API](https://webdriver.io/docs/api/browser/keys/)
217+
218+
## Custom Commands
219+
220+
You can also add your own commands via [`browser.commands`](/config/#browser-commands) config option. If you develop a library, you can provide them via a `config` hook inside a plugin:
221+
222+
```ts
223+
import type { Plugin } from 'vitest/config'
224+
import type { BrowserCommand } from 'vitest/node'
225+
226+
const myCustomCommand: BrowserCommand<[arg1: string, arg2: string]> = ({
227+
testPath,
228+
provider
229+
}, arg1, arg2) => {
230+
if (provider.name === 'playwright') {
231+
console.log(testPath, arg1, arg2)
232+
return { someValue: true }
233+
}
234+
235+
throw new Error(`provider ${provider.name} is not supported`)
236+
}
237+
238+
export default function BrowserCommands(): Plugin {
239+
return {
240+
name: 'vitest:custom-commands',
241+
config() {
242+
return {
243+
test: {
244+
browser: {
245+
commands: {
246+
myCustomCommand,
247+
}
248+
}
249+
}
250+
}
251+
}
252+
}
253+
}
254+
```
255+
256+
Then you can call it inside your test by importing it from `@vitest/browser/context`:
257+
258+
```ts
259+
import { commands } from '@vitest/browser/context'
260+
import { expect, test } from 'vitest'
261+
262+
test('custom command works correctly', async () => {
263+
const result = await commands.myCustomCommand('test1', 'test2')
264+
expect(result).toEqual({ someValue: true })
265+
})
266+
267+
// if you are using TypeScript, you can augment the module
268+
declare module '@vitest/browser/context' {
269+
interface BrowserCommands {
270+
myCustomCommand: (arg1: string, arg2: string) => Promise<{
271+
someValue: true
272+
}>
273+
}
274+
}
275+
```
276+
277+
::: warning
278+
Custom functions will override built-in ones if they have the same name.
279+
:::
280+
122281
## Limitations
123282

124283
### Thread Blocking Dialogs

packages/browser/context.d.ts

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { ResolvedConfig } from 'vitest'
2+
3+
export type BufferEncoding =
4+
| 'ascii'
5+
| 'utf8'
6+
| 'utf-8'
7+
| 'utf16le'
8+
| 'utf-16le'
9+
| 'ucs2'
10+
| 'ucs-2'
11+
| 'base64'
12+
| 'base64url'
13+
| 'latin1'
14+
| 'binary'
15+
| 'hex'
16+
17+
export interface FsOptions {
18+
encoding?: BufferEncoding
19+
flag?: string | number
20+
}
21+
22+
export interface TypePayload { type: string }
23+
export interface PressPayload { press: string }
24+
export interface DownPayload { down: string }
25+
export interface UpPayload { up: string }
26+
27+
export type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload
28+
29+
export interface BrowserCommands {
30+
readFile: (path: string, options?: BufferEncoding | FsOptions) => Promise<string>
31+
writeFile: (path: string, content: string, options?: BufferEncoding | FsOptions & { mode?: number | string }) => Promise<void>
32+
removeFile: (path: string) => Promise<void>
33+
sendKeys: (payload: SendKeysPayload) => Promise<void>
34+
}
35+
36+
type Platform =
37+
| 'aix'
38+
| 'android'
39+
| 'darwin'
40+
| 'freebsd'
41+
| 'haiku'
42+
| 'linux'
43+
| 'openbsd'
44+
| 'sunos'
45+
| 'win32'
46+
| 'cygwin'
47+
| 'netbsd'
48+
49+
export const server: {
50+
/**
51+
* Platform the Vitest server is running on.
52+
* The same as calling `process.platform` on the server.
53+
*/
54+
platform: Platform
55+
/**
56+
* Runtime version of the Vitest server.
57+
* The same as calling `process.version` on the server.
58+
*/
59+
version: string
60+
/**
61+
* Name of the browser provider.
62+
*/
63+
provider: string
64+
/**
65+
* Available commands for the browser.
66+
* @see {@link https://vitest.dev/guide/browser#commands}
67+
*/
68+
commands: BrowserCommands
69+
}
70+
71+
/**
72+
* Available commands for the browser.
73+
* A shortcut to `server.commands`.
74+
* @see {@link https://vitest.dev/guide/browser#commands}
75+
*/
76+
export const commands: BrowserCommands
77+
78+
export const page: {
79+
/**
80+
* Serialized test config.
81+
*/
82+
config: ResolvedConfig
83+
}

packages/browser/context.js

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// empty file to not break bundling
2+
// Vitest resolves "@vitest/browser/context" as a virtual module instead

packages/browser/package.json

+4
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
"types": "./providers.d.ts",
2525
"default": "./dist/providers.js"
2626
},
27+
"./context": {
28+
"types": "./context.d.ts",
29+
"default": "./context.js"
30+
},
2731
"./providers/webdriverio": {
2832
"types": "./providers/webdriverio.d.ts"
2933
},
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import fs, { promises as fsp } from 'node:fs'
2+
import { dirname, resolve } from 'node:path'
3+
import { isFileServingAllowed } from 'vitest/node'
4+
import type { BrowserCommand, WorkspaceProject } from 'vitest/node'
5+
import type { BrowserCommands } from '../../../context'
6+
7+
function assertFileAccess(path: string, project: WorkspaceProject) {
8+
if (!isFileServingAllowed(path, project.server) && !isFileServingAllowed(path, project.ctx.server))
9+
throw new Error(`Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`)
10+
}
11+
12+
export const readFile: BrowserCommand<Parameters<BrowserCommands['readFile']>> = async ({ project, testPath = process.cwd() }, path, options = {}) => {
13+
const filepath = resolve(dirname(testPath), path)
14+
assertFileAccess(filepath, project)
15+
// never return a Buffer
16+
if (typeof options === 'object' && !options.encoding)
17+
options.encoding = 'utf-8'
18+
return fsp.readFile(filepath, options)
19+
}
20+
21+
export const writeFile: BrowserCommand<Parameters<BrowserCommands['writeFile']>> = async ({ project, testPath = process.cwd() }, path, data, options) => {
22+
const filepath = resolve(dirname(testPath), path)
23+
assertFileAccess(filepath, project)
24+
const dir = dirname(filepath)
25+
if (!fs.existsSync(dir))
26+
await fsp.mkdir(dir, { recursive: true })
27+
await fsp.writeFile(filepath, data, options)
28+
}
29+
30+
export const removeFile: BrowserCommand<Parameters<BrowserCommands['removeFile']>> = async ({ project, testPath = process.cwd() }, path) => {
31+
const filepath = resolve(dirname(testPath), path)
32+
assertFileAccess(filepath, project)
33+
await fsp.rm(filepath)
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {
2+
readFile,
3+
removeFile,
4+
writeFile,
5+
} from './fs'
6+
import { sendKeys } from './keyboard'
7+
8+
export default {
9+
readFile,
10+
removeFile,
11+
writeFile,
12+
sendKeys,
13+
}

0 commit comments

Comments
 (0)