Skip to content

Commit aa431f4

Browse files
authoredMay 14, 2024··
feat(browser): add commands to communicate betweens server and the browser (#5097)
1 parent 222ce44 commit aa431f4

File tree

23 files changed

+683
-29
lines changed

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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// based on https://github.com/modernweb-dev/web/blob/f7fcf29cb79e82ad5622665d76da3f6b23d0ef43/packages/test-runner-commands/src/sendKeysPlugin.ts
2+
3+
import type { Page } from 'playwright'
4+
import type { BrowserCommand } from 'vitest/node'
5+
import type {
6+
BrowserCommands,
7+
DownPayload,
8+
PressPayload,
9+
SendKeysPayload,
10+
TypePayload,
11+
UpPayload,
12+
} from '../../../context'
13+
14+
function isObject(payload: unknown): payload is Record<string, unknown> {
15+
return payload != null && typeof payload === 'object'
16+
}
17+
18+
function isSendKeysPayload(payload: unknown): boolean {
19+
const validOptions = ['type', 'press', 'down', 'up']
20+
21+
if (!isObject(payload))
22+
throw new Error('You must provide a `SendKeysPayload` object')
23+
24+
const numberOfValidOptions = Object.keys(payload).filter(key =>
25+
validOptions.includes(key),
26+
).length
27+
const unknownOptions = Object.keys(payload).filter(key => !validOptions.includes(key))
28+
29+
if (numberOfValidOptions > 1) {
30+
throw new Error(
31+
`You must provide ONLY one of the following properties to pass to the browser runner: ${validOptions.join(
32+
', ',
33+
)}.`,
34+
)
35+
}
36+
if (numberOfValidOptions === 0) {
37+
throw new Error(
38+
`You must provide one of the following properties to pass to the browser runner: ${validOptions.join(
39+
', ',
40+
)}.`,
41+
)
42+
}
43+
if (unknownOptions.length > 0)
44+
throw new Error(`Unknown options \`${unknownOptions.join(', ')}\` present.`)
45+
46+
return true
47+
}
48+
49+
function isTypePayload(payload: SendKeysPayload): payload is TypePayload {
50+
return 'type' in payload
51+
}
52+
53+
function isPressPayload(payload: SendKeysPayload): payload is PressPayload {
54+
return 'press' in payload
55+
}
56+
57+
function isDownPayload(payload: SendKeysPayload): payload is DownPayload {
58+
return 'down' in payload
59+
}
60+
61+
function isUpPayload(payload: SendKeysPayload): payload is UpPayload {
62+
return 'up' in payload
63+
}
64+
65+
export const sendKeys: BrowserCommand<Parameters<BrowserCommands['sendKeys']>> = async ({ provider }, payload) => {
66+
if (!isSendKeysPayload(payload) || !payload)
67+
throw new Error('You must provide a `SendKeysPayload` object')
68+
69+
if (provider.name === 'playwright') {
70+
const page = (provider as any).page as Page
71+
if (isTypePayload(payload))
72+
await page.keyboard.type(payload.type)
73+
else if (isPressPayload(payload))
74+
await page.keyboard.press(payload.press)
75+
else if (isDownPayload(payload))
76+
await page.keyboard.down(payload.down)
77+
else if (isUpPayload(payload))
78+
await page.keyboard.up(payload.up)
79+
}
80+
else if (provider.name === 'webdriverio') {
81+
const browser = (provider as any).browser as WebdriverIO.Browser
82+
if (isTypePayload(payload))
83+
await browser.keys(payload.type.split(''))
84+
else if (isPressPayload(payload))
85+
await browser.keys([payload.press])
86+
else
87+
throw new Error('Only "press" and "type" are supported by webdriverio.')
88+
}
89+
else {
90+
throw new Error(`"sendKeys" is not supported for ${provider.name} browser provider.`)
91+
}
92+
}

‎packages/browser/src/node/index.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ import { fileURLToPath } from 'node:url'
22
import { readFile } from 'node:fs/promises'
33
import { basename, join, resolve } from 'pathe'
44
import sirv from 'sirv'
5-
import type { Plugin, ViteDevServer } from 'vite'
5+
import type { ViteDevServer } from 'vite'
66
import type { ResolvedConfig } from 'vitest'
77
import type { BrowserScript, WorkspaceProject } from 'vitest/node'
8-
import { coverageConfigDefaults } from 'vitest/config'
8+
import { type Plugin, coverageConfigDefaults } from 'vitest/config'
99
import { slash } from '@vitest/utils'
1010
import { injectVitestModule } from './esmInjector'
11+
import BrowserContext from './plugins/context'
12+
13+
export type { BrowserCommand } from 'vitest/node'
1114

1215
export default (project: WorkspaceProject, base = '/'): Plugin[] => {
1316
const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..')
@@ -187,6 +190,7 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
187190
return useId
188191
},
189192
},
193+
BrowserContext(project),
190194
{
191195
name: 'vitest:browser:esm-injector',
192196
enforce: 'post',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { Plugin } from 'vitest/config'
2+
import type { WorkspaceProject } from 'vitest/node'
3+
import builtinCommands from '../commands/index'
4+
5+
const VIRTUAL_ID_CONTEXT = '\0@vitest/browser/context'
6+
const ID_CONTEXT = '@vitest/browser/context'
7+
8+
export default function BrowserContext(project: WorkspaceProject): Plugin {
9+
project.config.browser.commands ??= {}
10+
for (const [name, command] of Object.entries(builtinCommands))
11+
project.config.browser.commands[name] ??= command
12+
13+
// validate names because they can't be used as identifiers
14+
for (const command in project.config.browser.commands) {
15+
if (!/^[a-z_$][\w$]*$/i.test(command))
16+
throw new Error(`Invalid command name "${command}". Only alphanumeric characters, $ and _ are allowed.`)
17+
}
18+
19+
return {
20+
name: 'vitest:browser:virtual-module:context',
21+
enforce: 'pre',
22+
resolveId(id) {
23+
if (id === ID_CONTEXT)
24+
return VIRTUAL_ID_CONTEXT
25+
},
26+
load(id) {
27+
if (id === VIRTUAL_ID_CONTEXT)
28+
return generateContextFile(project)
29+
},
30+
}
31+
}
32+
33+
function generateContextFile(project: WorkspaceProject) {
34+
const commands = Object.keys(project.config.browser.commands ?? {})
35+
const filepathCode = '__vitest_worker__.filepath || __vitest_worker__.current?.file?.filepath || undefined'
36+
37+
const commandsCode = commands.map((command) => {
38+
return ` ["${command}"]: (...args) => rpc().triggerCommand("${command}", ${filepathCode}, args),`
39+
}).join('\n')
40+
41+
return `
42+
const rpc = () => __vitest_worker__.rpc
43+
44+
export const server = {
45+
platform: ${JSON.stringify(process.platform)},
46+
version: ${JSON.stringify(process.version)},
47+
provider: ${JSON.stringify(project.browserProvider!.name)},
48+
commands: {
49+
${commandsCode}
50+
}
51+
}
52+
export const commands = server.commands
53+
export const page = {
54+
get config() {
55+
return __vitest_browser_runner__.config
56+
}
57+
}
58+
`
59+
}

‎packages/browser/src/node/providers/playwright.ts

+15-14
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ export interface PlaywrightProviderOptions extends BrowserProviderInitialization
1111
export class PlaywrightBrowserProvider implements BrowserProvider {
1212
public name = 'playwright'
1313

14-
private cachedBrowser: Browser | null = null
15-
private cachedPage: Page | null = null
16-
private browser!: PlaywrightBrowser
14+
public browser: Browser | null = null
15+
public page: Page | null = null
16+
17+
private browserName!: PlaywrightBrowser
1718
private ctx!: WorkspaceProject
1819

1920
private options?: {
@@ -27,26 +28,26 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
2728

2829
initialize(project: WorkspaceProject, { browser, options }: PlaywrightProviderOptions) {
2930
this.ctx = project
30-
this.browser = browser
31+
this.browserName = browser
3132
this.options = options as any
3233
}
3334

3435
private async openBrowserPage() {
35-
if (this.cachedPage)
36-
return this.cachedPage
36+
if (this.page)
37+
return this.page
3738

3839
const options = this.ctx.config.browser
3940

4041
const playwright = await import('playwright')
4142

42-
const browser = await playwright[this.browser].launch({
43+
const browser = await playwright[this.browserName].launch({
4344
...this.options?.launch,
4445
headless: options.headless,
4546
})
46-
this.cachedBrowser = browser
47-
this.cachedPage = await browser.newPage(this.options?.page)
47+
this.browser = browser
48+
this.page = await browser.newPage(this.options?.page)
4849

49-
return this.cachedPage
50+
return this.page
5051
}
5152

5253
async openPage(url: string) {
@@ -55,10 +56,10 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
5556
}
5657

5758
async close() {
58-
const page = this.cachedPage
59-
this.cachedPage = null
60-
const browser = this.cachedBrowser
61-
this.cachedBrowser = null
59+
const page = this.page
60+
this.page = null
61+
const browser = this.browser
62+
this.browser = null
6263
await page?.close()
6364
await browser?.close()
6465
}

‎packages/browser/src/node/providers/webdriver.ts

+12-11
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ interface WebdriverProviderOptions extends BrowserProviderInitializationOptions
1111
export class WebdriverBrowserProvider implements BrowserProvider {
1212
public name = 'webdriverio'
1313

14-
private cachedBrowser: WebdriverIO.Browser | null = null
15-
private browser!: WebdriverBrowser
14+
public browser: WebdriverIO.Browser | null = null
15+
16+
private browserName!: WebdriverBrowser
1617
private ctx!: WorkspaceProject
1718

1819
private options?: RemoteOptions
@@ -23,37 +24,37 @@ export class WebdriverBrowserProvider implements BrowserProvider {
2324

2425
async initialize(ctx: WorkspaceProject, { browser, options }: WebdriverProviderOptions) {
2526
this.ctx = ctx
26-
this.browser = browser
27+
this.browserName = browser
2728
this.options = options as RemoteOptions
2829
}
2930

3031
async openBrowser() {
31-
if (this.cachedBrowser)
32-
return this.cachedBrowser
32+
if (this.browser)
33+
return this.browser
3334

3435
const options = this.ctx.config.browser
3536

36-
if (this.browser === 'safari') {
37+
if (this.browserName === 'safari') {
3738
if (options.headless)
3839
throw new Error('You\'ve enabled headless mode for Safari but it doesn\'t currently support it.')
3940
}
4041

4142
const { remote } = await import('webdriverio')
4243

4344
// TODO: close everything, if browser is closed from the outside
44-
this.cachedBrowser = await remote({
45+
this.browser = await remote({
4546
...this.options,
4647
logLevel: 'error',
4748
capabilities: this.buildCapabilities(),
4849
})
4950

50-
return this.cachedBrowser
51+
return this.browser
5152
}
5253

5354
private buildCapabilities() {
5455
const capabilities: RemoteOptions['capabilities'] = {
5556
...this.options?.capabilities,
56-
browserName: this.browser,
57+
browserName: this.browserName,
5758
}
5859

5960
const headlessMap = {
@@ -63,7 +64,7 @@ export class WebdriverBrowserProvider implements BrowserProvider {
6364
} as const
6465

6566
const options = this.ctx.config.browser
66-
const browser = this.browser
67+
const browser = this.browserName
6768
if (browser !== 'safari' && options.headless) {
6869
const [key, args] = headlessMap[browser]
6970
const currentValues = (this.options?.capabilities as any)?.[key] || {}
@@ -81,7 +82,7 @@ export class WebdriverBrowserProvider implements BrowserProvider {
8182

8283
async close() {
8384
await Promise.all([
84-
this.cachedBrowser?.sessionId ? this.cachedBrowser?.deleteSession?.() : null,
85+
this.browser?.sessionId ? this.browser?.deleteSession?.() : null,
8586
])
8687
// TODO: right now process can only exit with timeout, if we use browser
8788
// needs investigating

‎packages/vitest/src/api/setup.ts

+12
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,18 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi
151151
},
152152

153153
// TODO: have a separate websocket conection for private browser API
154+
triggerCommand(command: string, testPath: string | undefined, payload: unknown[]) {
155+
if (!('ctx' in vitestOrWorkspace) || !vitestOrWorkspace.browserProvider)
156+
throw new Error('Commands are only available for browser tests.')
157+
const commands = vitestOrWorkspace.config.browser?.commands
158+
if (!commands || !commands[command])
159+
throw new Error(`Unknown command "${command}".`)
160+
return commands[command]({
161+
testPath,
162+
project: vitestOrWorkspace,
163+
provider: vitestOrWorkspace.browserProvider,
164+
}, ...payload)
165+
},
154166
getBrowserFiles() {
155167
if (!('ctx' in vitestOrWorkspace))
156168
throw new Error('`getBrowserTestFiles` is only available in the browser API')

‎packages/vitest/src/api/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface WebSocketHandlers {
3737
finishBrowserTests: () => void
3838
getBrowserFiles: () => string[]
3939
debug: (...args: string[]) => void
40+
triggerCommand: (command: string, testPath: string | undefined, payload: unknown[]) => Promise<void>
4041
}
4142

4243
export interface WebSocketEvents extends Pick<Reporter, 'onCollected' | 'onFinished' | 'onTaskUpdate' | 'onUserConsoleLog' | 'onPathsCollected' | 'onSpecsCollected'> {

‎packages/vitest/src/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface UserWorkspaceConfig extends ViteUserConfig {
99
export { configDefaults, defaultInclude, defaultExclude, coverageConfigDefaults } from './defaults'
1010
export { mergeConfig } from 'vite'
1111
export { extraInlineDeps } from './constants'
12+
export type { Plugin } from 'vite'
1213

1314
export type { ConfigEnv, ViteUserConfig as UserConfig }
1415
export type UserConfigFnObject = (env: ConfigEnv) => ViteUserConfig

‎packages/vitest/src/node/cli/cli-config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ export const cliOptionsConfig: VitestCLIOptions = {
352352
},
353353
indexScripts: null,
354354
testerScripts: null,
355+
commands: null,
355356
},
356357
},
357358
pool: {

‎packages/vitest/src/node/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ export type {
1818
BrowserProvider,
1919
BrowserProviderOptions,
2020
BrowserScript,
21+
BrowserCommand,
2122
} from '../types/browser'
2223
export type { JsonOptions } from './reporters/json'
2324
export type { JUnitOptions } from './reporters/junit'
2425
export type { HTMLOptions } from './reporters/html'
26+
27+
export { isFileServingAllowed } from 'vite'

‎packages/vitest/src/node/workspace.ts

+4
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,10 @@ export class WorkspaceProject {
398398
...this.server?.config.env,
399399
...this.config.env,
400400
},
401+
browser: {
402+
...this.ctx.config.browser,
403+
commands: {},
404+
},
401405
}, this.ctx.configOverride || {} as any) as ResolvedConfig
402406
}
403407

‎packages/vitest/src/types/browser.ts

+17
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,23 @@ export interface BrowserConfigOptions {
103103
* Scripts injected into the main window.
104104
*/
105105
indexScripts?: BrowserScript[]
106+
107+
/**
108+
* Commands that will be executed on the server
109+
* via the browser `import("@vitest/browser/context").commands` API.
110+
* @see {@link https://vitest.dev/guide/browser#commands}
111+
*/
112+
commands?: Record<string, BrowserCommand<any>>
113+
}
114+
115+
export interface BrowserCommandContext {
116+
testPath: string | undefined
117+
provider: BrowserProvider
118+
project: WorkspaceProject
119+
}
120+
121+
export interface BrowserCommand<Payload extends unknown[]> {
122+
(context: BrowserCommandContext, ...payload: Payload): Awaitable<any>
106123
}
107124

108125
export interface BrowserScript {

‎test/browser/specs/runner.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ describe.each([
3030
console.error(stderr)
3131
})
3232

33-
expect(browserResultJson.testResults).toHaveLength(15)
34-
expect(passedTests).toHaveLength(13)
33+
expect(browserResultJson.testResults).toHaveLength(16)
34+
expect(passedTests).toHaveLength(14)
3535
expect(failedTests).toHaveLength(2)
3636

3737
expect(stderr).not.toContain('has been externalized for browser compatibility')

‎test/browser/test/commands.test.ts

+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { commands, server } from '@vitest/browser/context'
2+
import { expect, it } from 'vitest'
3+
4+
const { readFile, writeFile, removeFile, sendKeys, myCustomCommand } = server.commands
5+
6+
it('can manipulate files', async () => {
7+
const file = './test.txt'
8+
9+
try {
10+
await readFile(file)
11+
expect.unreachable()
12+
}
13+
catch (err) {
14+
expect(err.message).toMatch(`ENOENT: no such file or directory, open`)
15+
if (server.platform === 'win32')
16+
expect(err.message).toMatch('test\\browser\\test\\test.txt')
17+
else
18+
expect(err.message).toMatch('test/browser/test/test.txt')
19+
}
20+
21+
await writeFile(file, 'hello world')
22+
const content = await readFile(file)
23+
24+
expect(content).toBe('hello world')
25+
26+
await removeFile(file)
27+
28+
try {
29+
await readFile(file)
30+
expect.unreachable()
31+
}
32+
catch (err) {
33+
expect(err.message).toMatch(`ENOENT: no such file or directory, open`)
34+
if (server.platform === 'win32')
35+
expect(err.message).toMatch('test\\browser\\test\\test.txt')
36+
else
37+
expect(err.message).toMatch('test/browser/test/test.txt')
38+
}
39+
})
40+
41+
// Test Cases from https://modern-web.dev/docs/test-runner/commands/#writing-and-reading-files
42+
it('natively types into an input', async () => {
43+
const keys = 'abc123'
44+
const input = document.createElement('input')
45+
document.body.append(input)
46+
input.focus()
47+
48+
await commands.sendKeys({
49+
type: keys,
50+
})
51+
52+
expect(input.value).to.equal(keys)
53+
input.remove()
54+
})
55+
56+
it('natively presses `Tab`', async () => {
57+
const input1 = document.createElement('input')
58+
const input2 = document.createElement('input')
59+
document.body.append(input1, input2)
60+
input1.focus()
61+
expect(document.activeElement).to.equal(input1)
62+
63+
await commands.sendKeys({
64+
press: 'Tab',
65+
})
66+
67+
expect(document.activeElement).to.equal(input2)
68+
input1.remove()
69+
input2.remove()
70+
})
71+
72+
it.skipIf(server.provider === 'webdriverio')('natively presses `Shift+Tab`', async () => {
73+
const input1 = document.createElement('input')
74+
const input2 = document.createElement('input')
75+
document.body.append(input1, input2)
76+
input2.focus()
77+
expect(document.activeElement).to.equal(input2)
78+
79+
await sendKeys({
80+
down: 'Shift',
81+
})
82+
await sendKeys({
83+
press: 'Tab',
84+
})
85+
await sendKeys({
86+
up: 'Shift',
87+
})
88+
89+
expect(document.activeElement).to.equal(input1)
90+
input1.remove()
91+
input2.remove()
92+
})
93+
94+
it.skipIf(server.provider === 'webdriverio')('natively holds and then releases a key', async () => {
95+
const input = document.createElement('input')
96+
document.body.append(input)
97+
input.focus()
98+
99+
await sendKeys({
100+
down: 'Shift',
101+
})
102+
// Note that pressed modifier keys are only respected when using `press` or
103+
// `down`, and only when using the `Key...` variants.
104+
await sendKeys({
105+
press: 'KeyA',
106+
})
107+
await sendKeys({
108+
press: 'KeyB',
109+
})
110+
await sendKeys({
111+
press: 'KeyC',
112+
})
113+
await sendKeys({
114+
up: 'Shift',
115+
})
116+
await sendKeys({
117+
press: 'KeyA',
118+
})
119+
await sendKeys({
120+
press: 'KeyB',
121+
})
122+
await sendKeys({
123+
press: 'KeyC',
124+
})
125+
126+
expect(input.value).to.equal('ABCabc')
127+
input.remove()
128+
})
129+
130+
it('can run custom commands', async () => {
131+
const result = await myCustomCommand('arg1', 'arg2')
132+
expect(result).toEqual({
133+
testPath: expect.stringMatching('test/browser/test/commands.test.ts'),
134+
arg1: 'arg1',
135+
arg2: 'arg2',
136+
})
137+
})
138+
139+
declare module '@vitest/browser/context' {
140+
interface BrowserCommands {
141+
myCustomCommand: (arg1: string, arg2: string) => Promise<{
142+
testPath: string
143+
arg1: string
144+
arg2: string
145+
}>
146+
}
147+
}

‎test/browser/tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"paths": {
66
"#src/*": ["./src/*"]
77
},
8+
"types": ["vite/client"],
89
"esModuleInterop": true
910
}
1011
}

‎test/browser/vitest.config.mts

+8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { dirname, resolve } from 'node:path'
22
import { fileURLToPath } from 'node:url'
33
import { defineConfig } from 'vitest/config'
4+
import type { BrowserCommand } from 'vitest/node'
45

56
const dir = dirname(fileURLToPath(import.meta.url))
67

@@ -9,6 +10,10 @@ function noop() {}
910
const provider = process.env.PROVIDER || 'playwright'
1011
const browser = process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome')
1112

13+
const myCustomCommand: BrowserCommand<[arg1: string, arg2: string]> = ({ testPath }, arg1, arg2) => {
14+
return { testPath, arg1, arg2 }
15+
}
16+
1217
export default defineConfig({
1318
server: {
1419
headers: {
@@ -60,6 +65,9 @@ export default defineConfig({
6065
content: 'if(__injected[0] !== 3) throw new Error("injected not working")',
6166
},
6267
],
68+
commands: {
69+
myCustomCommand,
70+
},
6371
},
6472
alias: {
6573
'#src': resolve(dir, './src'),

0 commit comments

Comments
 (0)
Please sign in to comment.