diff --git a/docs/config/index.md b/docs/config/index.md
index 63d7a2e61..7c8ae5ef7 100644
--- a/docs/config/index.md
+++ b/docs/config/index.md
@@ -1679,6 +1679,13 @@ Custom scripts that should be injected into the tester HTML before the tests env
The script `src` and `content` will be processed by Vite plugins.
+#### browser.commands 2.0.0 {#browser-commands}
+
+- **Type:** `Record`
+- **Default:** `{ readFile, writeFile, ... }`
+
+Custom [commands](/guide/browser#commands) that can be import during browser tests from `@vitest/browser/commands`.
+
### clearMocks
- **Type:** `boolean`
diff --git a/docs/guide/browser.md b/docs/guide/browser.md
index e19dba6cc..c49eb8c06 100644
--- a/docs/guide/browser.md
+++ b/docs/guide/browser.md
@@ -119,6 +119,165 @@ npx vitest --browser.name=chrome --browser.headless
In this case, Vitest will run in headless mode using the Chrome browser.
+## Context 2.0.0 {#context}
+
+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.
+
+```ts
+export const server: {
+ /**
+ * Platform the Vitest server is running on.
+ * The same as calling `process.platform` on the server.
+ */
+ platform: Platform
+ /**
+ * Runtime version of the Vitest server.
+ * The same as calling `process.version` on the server.
+ */
+ version: string
+ /**
+ * Available commands for the browser.
+ * @see {@link https://vitest.dev/guide/browser#commands}
+ */
+ commands: BrowserCommands
+}
+
+/**
+ * Available commands for the browser.
+ * A shortcut to `server.commands`.
+ * @see {@link https://vitest.dev/guide/browser#commands}
+ */
+export const commands: BrowserCommands
+
+export const page: {
+ /**
+ * Serialized test config.
+ */
+ config: ResolvedConfig
+}
+```
+
+## Commands 2.0.0 {#commands}
+
+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.
+
+## Built-in Commands
+
+### Files Handling
+
+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.
+
+By default, Vitest uses `utf-8` encoding but you can override it with options.
+
+::: tip
+This API follows [`server.fs`](https://vitejs.dev/config/server-options.html#server-fs-allow) limitations for security reasons.
+:::
+
+```ts
+import { server } from '@vitest/browser/context'
+
+const { readFile, writeFile, removeFile } = server.commands
+
+it('handles files', async () => {
+ const file = './test.txt'
+
+ await writeFile(file, 'hello world')
+ const content = await readFile(file)
+
+ expect(content).toBe('hello world')
+
+ await removeFile(file)
+})
+```
+
+### Keyboard Interactions
+
+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:
+
+- `type` - types a sequence of characters, this API _is not_ affected by modifier keys, so having `Shift` won't make letters uppercase
+- `press` - presses a single key, this API _is_ affected by modifier keys, so having `Shift` will make subsequent characters uppercase
+- `up` - holds down a key (supported only with `playwright` provider)
+- `down` - releases a key (supported only with `playwright` provider)
+
+```ts
+interface TypePayload { type: string }
+interface PressPayload { press: string }
+interface DownPayload { down: string }
+interface UpPayload { up: string }
+
+type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload
+
+declare function sendKeys(payload: SendKeysPayload): Promise
+```
+
+This is just a simple wrapper around providers APIs. Please refer to their respective documentations for details:
+
+- [Playwright Keyboard API](https://playwright.dev/docs/api/class-keyboard)
+- [Webdriver Keyboard API](https://webdriver.io/docs/api/browser/keys/)
+
+## Custom Commands
+
+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:
+
+```ts
+import type { Plugin } from 'vitest/config'
+import type { BrowserCommand } from 'vitest/node'
+
+const myCustomCommand: BrowserCommand<[arg1: string, arg2: string]> = ({
+ testPath,
+ provider
+}, arg1, arg2) => {
+ if (provider.name === 'playwright') {
+ console.log(testPath, arg1, arg2)
+ return { someValue: true }
+ }
+
+ throw new Error(`provider ${provider.name} is not supported`)
+}
+
+export default function BrowserCommands(): Plugin {
+ return {
+ name: 'vitest:custom-commands',
+ config() {
+ return {
+ test: {
+ browser: {
+ commands: {
+ myCustomCommand,
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+Then you can call it inside your test by importing it from `@vitest/browser/context`:
+
+```ts
+import { commands } from '@vitest/browser/context'
+import { expect, test } from 'vitest'
+
+test('custom command works correctly', async () => {
+ const result = await commands.myCustomCommand('test1', 'test2')
+ expect(result).toEqual({ someValue: true })
+})
+
+// if you are using TypeScript, you can augment the module
+declare module '@vitest/browser/context' {
+ interface BrowserCommands {
+ myCustomCommand: (arg1: string, arg2: string) => Promise<{
+ someValue: true
+ }>
+ }
+}
+```
+
+::: warning
+Custom functions will override built-in ones if they have the same name.
+:::
+
## Limitations
### Thread Blocking Dialogs
diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts
new file mode 100644
index 000000000..b9ba9fbc9
--- /dev/null
+++ b/packages/browser/context.d.ts
@@ -0,0 +1,83 @@
+import type { ResolvedConfig } from 'vitest'
+
+export type BufferEncoding =
+ | 'ascii'
+ | 'utf8'
+ | 'utf-8'
+ | 'utf16le'
+ | 'utf-16le'
+ | 'ucs2'
+ | 'ucs-2'
+ | 'base64'
+ | 'base64url'
+ | 'latin1'
+ | 'binary'
+ | 'hex'
+
+export interface FsOptions {
+ encoding?: BufferEncoding
+ flag?: string | number
+}
+
+export interface TypePayload { type: string }
+export interface PressPayload { press: string }
+export interface DownPayload { down: string }
+export interface UpPayload { up: string }
+
+export type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload
+
+export interface BrowserCommands {
+ readFile: (path: string, options?: BufferEncoding | FsOptions) => Promise
+ writeFile: (path: string, content: string, options?: BufferEncoding | FsOptions & { mode?: number | string }) => Promise
+ removeFile: (path: string) => Promise
+ sendKeys: (payload: SendKeysPayload) => Promise
+}
+
+type Platform =
+ | 'aix'
+ | 'android'
+ | 'darwin'
+ | 'freebsd'
+ | 'haiku'
+ | 'linux'
+ | 'openbsd'
+ | 'sunos'
+ | 'win32'
+ | 'cygwin'
+ | 'netbsd'
+
+export const server: {
+ /**
+ * Platform the Vitest server is running on.
+ * The same as calling `process.platform` on the server.
+ */
+ platform: Platform
+ /**
+ * Runtime version of the Vitest server.
+ * The same as calling `process.version` on the server.
+ */
+ version: string
+ /**
+ * Name of the browser provider.
+ */
+ provider: string
+ /**
+ * Available commands for the browser.
+ * @see {@link https://vitest.dev/guide/browser#commands}
+ */
+ commands: BrowserCommands
+}
+
+/**
+ * Available commands for the browser.
+ * A shortcut to `server.commands`.
+ * @see {@link https://vitest.dev/guide/browser#commands}
+ */
+export const commands: BrowserCommands
+
+export const page: {
+ /**
+ * Serialized test config.
+ */
+ config: ResolvedConfig
+}
diff --git a/packages/browser/context.js b/packages/browser/context.js
new file mode 100644
index 000000000..97779fdf5
--- /dev/null
+++ b/packages/browser/context.js
@@ -0,0 +1,2 @@
+// empty file to not break bundling
+// Vitest resolves "@vitest/browser/context" as a virtual module instead
diff --git a/packages/browser/package.json b/packages/browser/package.json
index 24da788ca..180603142 100644
--- a/packages/browser/package.json
+++ b/packages/browser/package.json
@@ -24,6 +24,10 @@
"types": "./providers.d.ts",
"default": "./dist/providers.js"
},
+ "./context": {
+ "types": "./context.d.ts",
+ "default": "./context.js"
+ },
"./providers/webdriverio": {
"types": "./providers/webdriverio.d.ts"
},
diff --git a/packages/browser/src/node/commands/fs.ts b/packages/browser/src/node/commands/fs.ts
new file mode 100644
index 000000000..fc10e7a91
--- /dev/null
+++ b/packages/browser/src/node/commands/fs.ts
@@ -0,0 +1,34 @@
+import fs, { promises as fsp } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { isFileServingAllowed } from 'vitest/node'
+import type { BrowserCommand, WorkspaceProject } from 'vitest/node'
+import type { BrowserCommands } from '../../../context'
+
+function assertFileAccess(path: string, project: WorkspaceProject) {
+ if (!isFileServingAllowed(path, project.server) && !isFileServingAllowed(path, project.ctx.server))
+ throw new Error(`Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`)
+}
+
+export const readFile: BrowserCommand> = async ({ project, testPath = process.cwd() }, path, options = {}) => {
+ const filepath = resolve(dirname(testPath), path)
+ assertFileAccess(filepath, project)
+ // never return a Buffer
+ if (typeof options === 'object' && !options.encoding)
+ options.encoding = 'utf-8'
+ return fsp.readFile(filepath, options)
+}
+
+export const writeFile: BrowserCommand> = async ({ project, testPath = process.cwd() }, path, data, options) => {
+ const filepath = resolve(dirname(testPath), path)
+ assertFileAccess(filepath, project)
+ const dir = dirname(filepath)
+ if (!fs.existsSync(dir))
+ await fsp.mkdir(dir, { recursive: true })
+ await fsp.writeFile(filepath, data, options)
+}
+
+export const removeFile: BrowserCommand> = async ({ project, testPath = process.cwd() }, path) => {
+ const filepath = resolve(dirname(testPath), path)
+ assertFileAccess(filepath, project)
+ await fsp.rm(filepath)
+}
diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts
new file mode 100644
index 000000000..bd42c06fc
--- /dev/null
+++ b/packages/browser/src/node/commands/index.ts
@@ -0,0 +1,13 @@
+import {
+ readFile,
+ removeFile,
+ writeFile,
+} from './fs'
+import { sendKeys } from './keyboard'
+
+export default {
+ readFile,
+ removeFile,
+ writeFile,
+ sendKeys,
+}
diff --git a/packages/browser/src/node/commands/keyboard.ts b/packages/browser/src/node/commands/keyboard.ts
new file mode 100644
index 000000000..5f9dba3f2
--- /dev/null
+++ b/packages/browser/src/node/commands/keyboard.ts
@@ -0,0 +1,92 @@
+// based on https://github.com/modernweb-dev/web/blob/f7fcf29cb79e82ad5622665d76da3f6b23d0ef43/packages/test-runner-commands/src/sendKeysPlugin.ts
+
+import type { Page } from 'playwright'
+import type { BrowserCommand } from 'vitest/node'
+import type {
+ BrowserCommands,
+ DownPayload,
+ PressPayload,
+ SendKeysPayload,
+ TypePayload,
+ UpPayload,
+} from '../../../context'
+
+function isObject(payload: unknown): payload is Record {
+ return payload != null && typeof payload === 'object'
+}
+
+function isSendKeysPayload(payload: unknown): boolean {
+ const validOptions = ['type', 'press', 'down', 'up']
+
+ if (!isObject(payload))
+ throw new Error('You must provide a `SendKeysPayload` object')
+
+ const numberOfValidOptions = Object.keys(payload).filter(key =>
+ validOptions.includes(key),
+ ).length
+ const unknownOptions = Object.keys(payload).filter(key => !validOptions.includes(key))
+
+ if (numberOfValidOptions > 1) {
+ throw new Error(
+ `You must provide ONLY one of the following properties to pass to the browser runner: ${validOptions.join(
+ ', ',
+ )}.`,
+ )
+ }
+ if (numberOfValidOptions === 0) {
+ throw new Error(
+ `You must provide one of the following properties to pass to the browser runner: ${validOptions.join(
+ ', ',
+ )}.`,
+ )
+ }
+ if (unknownOptions.length > 0)
+ throw new Error(`Unknown options \`${unknownOptions.join(', ')}\` present.`)
+
+ return true
+}
+
+function isTypePayload(payload: SendKeysPayload): payload is TypePayload {
+ return 'type' in payload
+}
+
+function isPressPayload(payload: SendKeysPayload): payload is PressPayload {
+ return 'press' in payload
+}
+
+function isDownPayload(payload: SendKeysPayload): payload is DownPayload {
+ return 'down' in payload
+}
+
+function isUpPayload(payload: SendKeysPayload): payload is UpPayload {
+ return 'up' in payload
+}
+
+export const sendKeys: BrowserCommand> = async ({ provider }, payload) => {
+ if (!isSendKeysPayload(payload) || !payload)
+ throw new Error('You must provide a `SendKeysPayload` object')
+
+ if (provider.name === 'playwright') {
+ const page = (provider as any).page as Page
+ if (isTypePayload(payload))
+ await page.keyboard.type(payload.type)
+ else if (isPressPayload(payload))
+ await page.keyboard.press(payload.press)
+ else if (isDownPayload(payload))
+ await page.keyboard.down(payload.down)
+ else if (isUpPayload(payload))
+ await page.keyboard.up(payload.up)
+ }
+ else if (provider.name === 'webdriverio') {
+ const browser = (provider as any).browser as WebdriverIO.Browser
+ if (isTypePayload(payload))
+ await browser.keys(payload.type.split(''))
+ else if (isPressPayload(payload))
+ await browser.keys([payload.press])
+ else
+ throw new Error('Only "press" and "type" are supported by webdriverio.')
+ }
+ else {
+ throw new Error(`"sendKeys" is not supported for ${provider.name} browser provider.`)
+ }
+}
diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts
index c4465f438..cb5332870 100644
--- a/packages/browser/src/node/index.ts
+++ b/packages/browser/src/node/index.ts
@@ -2,12 +2,15 @@ import { fileURLToPath } from 'node:url'
import { readFile } from 'node:fs/promises'
import { basename, join, resolve } from 'pathe'
import sirv from 'sirv'
-import type { Plugin, ViteDevServer } from 'vite'
+import type { ViteDevServer } from 'vite'
import type { ResolvedConfig } from 'vitest'
import type { BrowserScript, WorkspaceProject } from 'vitest/node'
-import { coverageConfigDefaults } from 'vitest/config'
+import { type Plugin, coverageConfigDefaults } from 'vitest/config'
import { slash } from '@vitest/utils'
import { injectVitestModule } from './esmInjector'
+import BrowserContext from './plugins/context'
+
+export type { BrowserCommand } from 'vitest/node'
export default (project: WorkspaceProject, base = '/'): Plugin[] => {
const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..')
@@ -187,6 +190,7 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
return useId
},
},
+ BrowserContext(project),
{
name: 'vitest:browser:esm-injector',
enforce: 'post',
diff --git a/packages/browser/src/node/plugins/context.ts b/packages/browser/src/node/plugins/context.ts
new file mode 100644
index 000000000..2eee16e33
--- /dev/null
+++ b/packages/browser/src/node/plugins/context.ts
@@ -0,0 +1,59 @@
+import type { Plugin } from 'vitest/config'
+import type { WorkspaceProject } from 'vitest/node'
+import builtinCommands from '../commands/index'
+
+const VIRTUAL_ID_CONTEXT = '\0@vitest/browser/context'
+const ID_CONTEXT = '@vitest/browser/context'
+
+export default function BrowserContext(project: WorkspaceProject): Plugin {
+ project.config.browser.commands ??= {}
+ for (const [name, command] of Object.entries(builtinCommands))
+ project.config.browser.commands[name] ??= command
+
+ // validate names because they can't be used as identifiers
+ for (const command in project.config.browser.commands) {
+ if (!/^[a-z_$][\w$]*$/i.test(command))
+ throw new Error(`Invalid command name "${command}". Only alphanumeric characters, $ and _ are allowed.`)
+ }
+
+ return {
+ name: 'vitest:browser:virtual-module:context',
+ enforce: 'pre',
+ resolveId(id) {
+ if (id === ID_CONTEXT)
+ return VIRTUAL_ID_CONTEXT
+ },
+ load(id) {
+ if (id === VIRTUAL_ID_CONTEXT)
+ return generateContextFile(project)
+ },
+ }
+}
+
+function generateContextFile(project: WorkspaceProject) {
+ const commands = Object.keys(project.config.browser.commands ?? {})
+ const filepathCode = '__vitest_worker__.filepath || __vitest_worker__.current?.file?.filepath || undefined'
+
+ const commandsCode = commands.map((command) => {
+ return ` ["${command}"]: (...args) => rpc().triggerCommand("${command}", ${filepathCode}, args),`
+ }).join('\n')
+
+ return `
+const rpc = () => __vitest_worker__.rpc
+
+export const server = {
+ platform: ${JSON.stringify(process.platform)},
+ version: ${JSON.stringify(process.version)},
+ provider: ${JSON.stringify(project.browserProvider!.name)},
+ commands: {
+ ${commandsCode}
+ }
+}
+export const commands = server.commands
+export const page = {
+ get config() {
+ return __vitest_browser_runner__.config
+ }
+}
+`
+}
diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts
index e6cf5c048..d7c19d724 100644
--- a/packages/browser/src/node/providers/playwright.ts
+++ b/packages/browser/src/node/providers/playwright.ts
@@ -11,9 +11,10 @@ export interface PlaywrightProviderOptions extends BrowserProviderInitialization
export class PlaywrightBrowserProvider implements BrowserProvider {
public name = 'playwright'
- private cachedBrowser: Browser | null = null
- private cachedPage: Page | null = null
- private browser!: PlaywrightBrowser
+ public browser: Browser | null = null
+ public page: Page | null = null
+
+ private browserName!: PlaywrightBrowser
private ctx!: WorkspaceProject
private options?: {
@@ -27,26 +28,26 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
initialize(project: WorkspaceProject, { browser, options }: PlaywrightProviderOptions) {
this.ctx = project
- this.browser = browser
+ this.browserName = browser
this.options = options as any
}
private async openBrowserPage() {
- if (this.cachedPage)
- return this.cachedPage
+ if (this.page)
+ return this.page
const options = this.ctx.config.browser
const playwright = await import('playwright')
- const browser = await playwright[this.browser].launch({
+ const browser = await playwright[this.browserName].launch({
...this.options?.launch,
headless: options.headless,
})
- this.cachedBrowser = browser
- this.cachedPage = await browser.newPage(this.options?.page)
+ this.browser = browser
+ this.page = await browser.newPage(this.options?.page)
- return this.cachedPage
+ return this.page
}
async openPage(url: string) {
@@ -55,10 +56,10 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
}
async close() {
- const page = this.cachedPage
- this.cachedPage = null
- const browser = this.cachedBrowser
- this.cachedBrowser = null
+ const page = this.page
+ this.page = null
+ const browser = this.browser
+ this.browser = null
await page?.close()
await browser?.close()
}
diff --git a/packages/browser/src/node/providers/webdriver.ts b/packages/browser/src/node/providers/webdriver.ts
index 5a63411ae..3a768430e 100644
--- a/packages/browser/src/node/providers/webdriver.ts
+++ b/packages/browser/src/node/providers/webdriver.ts
@@ -11,8 +11,9 @@ interface WebdriverProviderOptions extends BrowserProviderInitializationOptions
export class WebdriverBrowserProvider implements BrowserProvider {
public name = 'webdriverio'
- private cachedBrowser: WebdriverIO.Browser | null = null
- private browser!: WebdriverBrowser
+ public browser: WebdriverIO.Browser | null = null
+
+ private browserName!: WebdriverBrowser
private ctx!: WorkspaceProject
private options?: RemoteOptions
@@ -23,17 +24,17 @@ export class WebdriverBrowserProvider implements BrowserProvider {
async initialize(ctx: WorkspaceProject, { browser, options }: WebdriverProviderOptions) {
this.ctx = ctx
- this.browser = browser
+ this.browserName = browser
this.options = options as RemoteOptions
}
async openBrowser() {
- if (this.cachedBrowser)
- return this.cachedBrowser
+ if (this.browser)
+ return this.browser
const options = this.ctx.config.browser
- if (this.browser === 'safari') {
+ if (this.browserName === 'safari') {
if (options.headless)
throw new Error('You\'ve enabled headless mode for Safari but it doesn\'t currently support it.')
}
@@ -41,19 +42,19 @@ export class WebdriverBrowserProvider implements BrowserProvider {
const { remote } = await import('webdriverio')
// TODO: close everything, if browser is closed from the outside
- this.cachedBrowser = await remote({
+ this.browser = await remote({
...this.options,
logLevel: 'error',
capabilities: this.buildCapabilities(),
})
- return this.cachedBrowser
+ return this.browser
}
private buildCapabilities() {
const capabilities: RemoteOptions['capabilities'] = {
...this.options?.capabilities,
- browserName: this.browser,
+ browserName: this.browserName,
}
const headlessMap = {
@@ -63,7 +64,7 @@ export class WebdriverBrowserProvider implements BrowserProvider {
} as const
const options = this.ctx.config.browser
- const browser = this.browser
+ const browser = this.browserName
if (browser !== 'safari' && options.headless) {
const [key, args] = headlessMap[browser]
const currentValues = (this.options?.capabilities as any)?.[key] || {}
@@ -81,7 +82,7 @@ export class WebdriverBrowserProvider implements BrowserProvider {
async close() {
await Promise.all([
- this.cachedBrowser?.sessionId ? this.cachedBrowser?.deleteSession?.() : null,
+ this.browser?.sessionId ? this.browser?.deleteSession?.() : null,
])
// TODO: right now process can only exit with timeout, if we use browser
// needs investigating
diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts
index 40d533409..484ce3ebb 100644
--- a/packages/vitest/src/api/setup.ts
+++ b/packages/vitest/src/api/setup.ts
@@ -151,6 +151,18 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi
},
// TODO: have a separate websocket conection for private browser API
+ triggerCommand(command: string, testPath: string | undefined, payload: unknown[]) {
+ if (!('ctx' in vitestOrWorkspace) || !vitestOrWorkspace.browserProvider)
+ throw new Error('Commands are only available for browser tests.')
+ const commands = vitestOrWorkspace.config.browser?.commands
+ if (!commands || !commands[command])
+ throw new Error(`Unknown command "${command}".`)
+ return commands[command]({
+ testPath,
+ project: vitestOrWorkspace,
+ provider: vitestOrWorkspace.browserProvider,
+ }, ...payload)
+ },
getBrowserFiles() {
if (!('ctx' in vitestOrWorkspace))
throw new Error('`getBrowserTestFiles` is only available in the browser API')
diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts
index 5a72b2a4c..273e5337f 100644
--- a/packages/vitest/src/api/types.ts
+++ b/packages/vitest/src/api/types.ts
@@ -37,6 +37,7 @@ export interface WebSocketHandlers {
finishBrowserTests: () => void
getBrowserFiles: () => string[]
debug: (...args: string[]) => void
+ triggerCommand: (command: string, testPath: string | undefined, payload: unknown[]) => Promise
}
export interface WebSocketEvents extends Pick {
diff --git a/packages/vitest/src/config.ts b/packages/vitest/src/config.ts
index e207476f3..76d07b1a6 100644
--- a/packages/vitest/src/config.ts
+++ b/packages/vitest/src/config.ts
@@ -9,6 +9,7 @@ export interface UserWorkspaceConfig extends ViteUserConfig {
export { configDefaults, defaultInclude, defaultExclude, coverageConfigDefaults } from './defaults'
export { mergeConfig } from 'vite'
export { extraInlineDeps } from './constants'
+export type { Plugin } from 'vite'
export type { ConfigEnv, ViteUserConfig as UserConfig }
export type UserConfigFnObject = (env: ConfigEnv) => ViteUserConfig
diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts
index 6acc96b4a..f32baed7e 100644
--- a/packages/vitest/src/node/cli/cli-config.ts
+++ b/packages/vitest/src/node/cli/cli-config.ts
@@ -352,6 +352,7 @@ export const cliOptionsConfig: VitestCLIOptions = {
},
indexScripts: null,
testerScripts: null,
+ commands: null,
},
},
pool: {
diff --git a/packages/vitest/src/node/index.ts b/packages/vitest/src/node/index.ts
index 92bacfeae..7781136ab 100644
--- a/packages/vitest/src/node/index.ts
+++ b/packages/vitest/src/node/index.ts
@@ -18,7 +18,10 @@ export type {
BrowserProvider,
BrowserProviderOptions,
BrowserScript,
+ BrowserCommand,
} from '../types/browser'
export type { JsonOptions } from './reporters/json'
export type { JUnitOptions } from './reporters/junit'
export type { HTMLOptions } from './reporters/html'
+
+export { isFileServingAllowed } from 'vite'
diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts
index 4d92596e3..697c8b547 100644
--- a/packages/vitest/src/node/workspace.ts
+++ b/packages/vitest/src/node/workspace.ts
@@ -398,6 +398,10 @@ export class WorkspaceProject {
...this.server?.config.env,
...this.config.env,
},
+ browser: {
+ ...this.ctx.config.browser,
+ commands: {},
+ },
}, this.ctx.configOverride || {} as any) as ResolvedConfig
}
diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts
index 5beb46cd6..f7987cf7b 100644
--- a/packages/vitest/src/types/browser.ts
+++ b/packages/vitest/src/types/browser.ts
@@ -103,6 +103,23 @@ export interface BrowserConfigOptions {
* Scripts injected into the main window.
*/
indexScripts?: BrowserScript[]
+
+ /**
+ * Commands that will be executed on the server
+ * via the browser `import("@vitest/browser/context").commands` API.
+ * @see {@link https://vitest.dev/guide/browser#commands}
+ */
+ commands?: Record>
+}
+
+export interface BrowserCommandContext {
+ testPath: string | undefined
+ provider: BrowserProvider
+ project: WorkspaceProject
+}
+
+export interface BrowserCommand {
+ (context: BrowserCommandContext, ...payload: Payload): Awaitable
}
export interface BrowserScript {
diff --git a/test/browser/specs/runner.test.ts b/test/browser/specs/runner.test.ts
index 680cd1f0d..99085ca1a 100644
--- a/test/browser/specs/runner.test.ts
+++ b/test/browser/specs/runner.test.ts
@@ -30,8 +30,8 @@ describe.each([
console.error(stderr)
})
- expect(browserResultJson.testResults).toHaveLength(15)
- expect(passedTests).toHaveLength(13)
+ expect(browserResultJson.testResults).toHaveLength(16)
+ expect(passedTests).toHaveLength(14)
expect(failedTests).toHaveLength(2)
expect(stderr).not.toContain('has been externalized for browser compatibility')
diff --git a/test/browser/test/commands.test.ts b/test/browser/test/commands.test.ts
new file mode 100644
index 000000000..a14c7c1da
--- /dev/null
+++ b/test/browser/test/commands.test.ts
@@ -0,0 +1,147 @@
+import { commands, server } from '@vitest/browser/context'
+import { expect, it } from 'vitest'
+
+const { readFile, writeFile, removeFile, sendKeys, myCustomCommand } = server.commands
+
+it('can manipulate files', async () => {
+ const file = './test.txt'
+
+ try {
+ await readFile(file)
+ expect.unreachable()
+ }
+ catch (err) {
+ expect(err.message).toMatch(`ENOENT: no such file or directory, open`)
+ if (server.platform === 'win32')
+ expect(err.message).toMatch('test\\browser\\test\\test.txt')
+ else
+ expect(err.message).toMatch('test/browser/test/test.txt')
+ }
+
+ await writeFile(file, 'hello world')
+ const content = await readFile(file)
+
+ expect(content).toBe('hello world')
+
+ await removeFile(file)
+
+ try {
+ await readFile(file)
+ expect.unreachable()
+ }
+ catch (err) {
+ expect(err.message).toMatch(`ENOENT: no such file or directory, open`)
+ if (server.platform === 'win32')
+ expect(err.message).toMatch('test\\browser\\test\\test.txt')
+ else
+ expect(err.message).toMatch('test/browser/test/test.txt')
+ }
+})
+
+// Test Cases from https://modern-web.dev/docs/test-runner/commands/#writing-and-reading-files
+it('natively types into an input', async () => {
+ const keys = 'abc123'
+ const input = document.createElement('input')
+ document.body.append(input)
+ input.focus()
+
+ await commands.sendKeys({
+ type: keys,
+ })
+
+ expect(input.value).to.equal(keys)
+ input.remove()
+})
+
+it('natively presses `Tab`', async () => {
+ const input1 = document.createElement('input')
+ const input2 = document.createElement('input')
+ document.body.append(input1, input2)
+ input1.focus()
+ expect(document.activeElement).to.equal(input1)
+
+ await commands.sendKeys({
+ press: 'Tab',
+ })
+
+ expect(document.activeElement).to.equal(input2)
+ input1.remove()
+ input2.remove()
+})
+
+it.skipIf(server.provider === 'webdriverio')('natively presses `Shift+Tab`', async () => {
+ const input1 = document.createElement('input')
+ const input2 = document.createElement('input')
+ document.body.append(input1, input2)
+ input2.focus()
+ expect(document.activeElement).to.equal(input2)
+
+ await sendKeys({
+ down: 'Shift',
+ })
+ await sendKeys({
+ press: 'Tab',
+ })
+ await sendKeys({
+ up: 'Shift',
+ })
+
+ expect(document.activeElement).to.equal(input1)
+ input1.remove()
+ input2.remove()
+})
+
+it.skipIf(server.provider === 'webdriverio')('natively holds and then releases a key', async () => {
+ const input = document.createElement('input')
+ document.body.append(input)
+ input.focus()
+
+ await sendKeys({
+ down: 'Shift',
+ })
+ // Note that pressed modifier keys are only respected when using `press` or
+ // `down`, and only when using the `Key...` variants.
+ await sendKeys({
+ press: 'KeyA',
+ })
+ await sendKeys({
+ press: 'KeyB',
+ })
+ await sendKeys({
+ press: 'KeyC',
+ })
+ await sendKeys({
+ up: 'Shift',
+ })
+ await sendKeys({
+ press: 'KeyA',
+ })
+ await sendKeys({
+ press: 'KeyB',
+ })
+ await sendKeys({
+ press: 'KeyC',
+ })
+
+ expect(input.value).to.equal('ABCabc')
+ input.remove()
+})
+
+it('can run custom commands', async () => {
+ const result = await myCustomCommand('arg1', 'arg2')
+ expect(result).toEqual({
+ testPath: expect.stringMatching('test/browser/test/commands.test.ts'),
+ arg1: 'arg1',
+ arg2: 'arg2',
+ })
+})
+
+declare module '@vitest/browser/context' {
+ interface BrowserCommands {
+ myCustomCommand: (arg1: string, arg2: string) => Promise<{
+ testPath: string
+ arg1: string
+ arg2: string
+ }>
+ }
+}
diff --git a/test/browser/tsconfig.json b/test/browser/tsconfig.json
index 183104f03..dd26cb7f7 100644
--- a/test/browser/tsconfig.json
+++ b/test/browser/tsconfig.json
@@ -5,6 +5,7 @@
"paths": {
"#src/*": ["./src/*"]
},
+ "types": ["vite/client"],
"esModuleInterop": true
}
}
diff --git a/test/browser/vitest.config.mts b/test/browser/vitest.config.mts
index 75f3435ad..2308d6c4c 100644
--- a/test/browser/vitest.config.mts
+++ b/test/browser/vitest.config.mts
@@ -1,6 +1,7 @@
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
+import type { BrowserCommand } from 'vitest/node'
const dir = dirname(fileURLToPath(import.meta.url))
@@ -9,6 +10,10 @@ function noop() {}
const provider = process.env.PROVIDER || 'playwright'
const browser = process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome')
+const myCustomCommand: BrowserCommand<[arg1: string, arg2: string]> = ({ testPath }, arg1, arg2) => {
+ return { testPath, arg1, arg2 }
+}
+
export default defineConfig({
server: {
headers: {
@@ -60,6 +65,9 @@ export default defineConfig({
content: 'if(__injected[0] !== 3) throw new Error("injected not working")',
},
],
+ commands: {
+ myCustomCommand,
+ },
},
alias: {
'#src': resolve(dir, './src'),