Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(browser): add commands to communicate betweens server and the browser #5097

Merged
merged 15 commits into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/.vitepress/components.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}

/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Contributors: typeof import('./components/Contributors.vue')['default']
Expand Down
7 changes: 7 additions & 0 deletions docs/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Version>2.0.0</Version> {#browser-commands}

- **Type:** `Record<string, BrowserCommand>`
- **Default:** `{ readFile, writeFile, ... }`

Custom [commands](/guide/browser#commands) that can be import during browser tests from `@vitest/browser/commands`.

### clearMocks

- **Type:** `boolean`
Expand Down
159 changes: 159 additions & 0 deletions docs/guide/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Version>2.0.0</Version> {#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 <Version>2.0.0</Version> {#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<void>
```

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
Expand Down
83 changes: 83 additions & 0 deletions packages/browser/context.d.ts
Original file line number Diff line number Diff line change
@@ -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<string>
writeFile: (path: string, content: string, options?: BufferEncoding | FsOptions & { mode?: number | string }) => Promise<void>
removeFile: (path: string) => Promise<void>
sendKeys: (payload: SendKeysPayload) => Promise<void>
}

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
}
2 changes: 2 additions & 0 deletions packages/browser/context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// empty file to not break bundling
// Vitest resolves "@vitest/browser/context" as a virtual module instead
4 changes: 4 additions & 0 deletions packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
34 changes: 34 additions & 0 deletions packages/browser/src/node/commands/fs.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<BrowserCommands['readFile']>> = 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<Parameters<BrowserCommands['writeFile']>> = 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<Parameters<BrowserCommands['removeFile']>> = async ({ project, testPath = process.cwd() }, path) => {
const filepath = resolve(dirname(testPath), path)
assertFileAccess(filepath, project)
await fsp.rm(filepath)
}
13 changes: 13 additions & 0 deletions packages/browser/src/node/commands/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {
readFile,
removeFile,
writeFile,
} from './fs'
import { sendKeys } from './keyboard'

export default {
readFile,
removeFile,
writeFile,
sendKeys,
}
Loading
Loading