Custom browser commands security considerations
Custom browser commands run in the Vitest Node process. If a command exposes filesystem, process, network, database, or shell access based on browser-provided input, the input must be validated and restricted inside the command. Built-in file commands apply Vite server.fs checks and write-access checks, but custom commands are solely responsible for their own security protections.
browser.commands configuration property
The browser.commands configuration property is of type Record<string, BrowserCommand>. It is used to define custom commands that can be imported during browser tests from vitest/browser. The default value includes built-in commands such as readFile and writeFile.
Recording trace markers example
import type { BrowserCommand } from 'vitest/node'
export const uploadFixture: BrowserCommand<[name: string]> = async (
context,
name,
) => {
await context.mark(`upload start: ${name}`, { kind: 'action' })
// ... do server-side work
await context.mark(`upload done: ${name}`, { kind: 'action' })
}
Playwright-specific command context properties
Vitest exposes several playwright-specific properties on the command context: page (references the full page that contains the test iframe, the orchestrator HTML); frame (an async method that resolves to a Playwright Frame, has similar API to page but does not support certain methods); iframe (a FrameLocator that should be used to query other elements on the page, more stable and faster than frame for querying elements); context (refers to the unique BrowserContext).
CDP session access via vitest/browser
Vitest exposes access to raw Chrome DevTools Protocol via the cdp method exported from vitest/browser. It is mostly useful to library authors to build tools on top of it. CDP session works only with playwright provider and only when using chromium browser. CDP is a privileged debugging API available only when browser API write and exec operations are enabled through api.allowWrite and api.allowExec configuration options.
Playwright custom command example with page and iframe
import { BrowserCommand } from 'vitest/node'
export const myCommand: BrowserCommand<[string, number]> = async (
ctx,
arg1: string,
arg2: number
) => {
if (ctx.provider.name === 'playwright') {
const element = await ctx.iframe.findByRole('alert')
const screenshot = await element.screenshot()
// do something with the screenshot
return difference
}
}
CDP session usage example
import { cdp } from 'vitest/browser'
const input = document.createElement('input')
document.body.appendChild(input)
input.focus()
await cdp().send('Input.dispatchKeyEvent', {
type: 'keyDown',
text: 'a',
})
expect(input).toHaveValue('a')
Custom browser commands via browser.commands config
You can add custom commands via browser.commands config option. If you develop a library, you can provide them via a config hook inside a plugin. Custom commands are functions that run in the Vitest Node process and are callable from browser test code through Vitest's browser RPC connection. They have access to testPath and provider properties in their context.
readFile, writeFile, removeFile usage example
import { server } from 'vitest/browser'
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)
})
Custom file-writing command with security checks example
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { normalizePath } from 'vite'
import { isFileLoadingAllowed } from 'vitest/node'
import type { BrowserCommand } from 'vitest/node'
function assertFileAccess(path: string, project: any) {
if (
!isFileLoadingAllowed(project.vite.config, path)
&& !isFileLoadingAllowed(project.vitest.vite.config, path)
) {
throw new Error(`Access denied to "${path}".`)
}
}
function assertWrite(project: any) {
if (!project.config.browser.api.allowWrite || !project.vitest.config.api.allowWrite) {
throw new Error('Writing files is disabled.')
}
}
export const myWriteFileCommand: BrowserCommand<[path: string, content: string]> = async (
{ project },
path,
content,
) => {
assertWrite(project)
const file = resolve(project.config.root, path)
assertFileAccess(normalizePath(file), project)
await mkdir(dirname(file), { recursive: true })
await writeFile(file, content)
}
Security considerations for custom browser commands
Custom commands run in the Vitest Node process and are callable from browser test code through Vitest's browser RPC connection. They can access local files, environment variables, network services, databases, shell commands, and other Node APIs. Custom commands do not automatically inherit the protections that Vitest's built-in file commands have (validation against Vite's server.fs restrictions). If a custom command accepts browser-provided input and uses it to read, write, delete, execute, or expose local resources, validate that input before using it. For file reads or fixture loading, use isFileLoadingAllowed from vitest/node or an explicit allowlist. For writes and deletes, also require an explicit mutation policy such as api.allowWrite and a command-specific allowed directory. For commands that execute code, shell commands, or project scripts, also check api.allowExec.
Recording trace markers in custom commands
Custom commands can record trace markers for the test that triggered them through context.mark. This is the server-side equivalent of page.mark and helps annotate the trace view with custom actions performed inside a command. context.mark is a no-op when browser tracing is not enabled or no test is currently running in the session. Unlike page.mark, it does not accept a callback form.
WebdriverIO-specific command context properties
Vitest exposes a browser property on the command context for webdriverio-specific commands. The browser property is the WebdriverIO.Browser API. Vitest automatically switches the webdriver context to the test iframe by calling browser.switchFrame before the command is called, so $ and $$ methods refer to elements inside the iframe, not in the orchestrator. However, non-webdriver APIs will still refer to the parent frame context.
Custom commands override built-in commands with same name
Custom functions will override built-in ones if they have the same name.
Custom browser command definition and usage example
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,
}
}
}
}
}
}
}
// Usage in test:
import { commands } from 'vitest/browser'
import { expect, test } from 'vitest'
test('custom command works correctly', async () => {
const result = await commands.myCustomCommand('test1', 'test2')
expect(result).toEqual({ someValue: true })
})
// TypeScript module augmentation:
declare module 'vitest/browser' {
interface BrowserCommands {
myCustomCommand: (arg1: string, arg2: string) => Promise<{
someValue: true
}>
}
}
readFile, writeFile, removeFile APIs in browser tests
Vitest exposes readFile, writeFile, and removeFile APIs to handle files in browser tests. Since Vitest 3.2, all paths are resolved relative to the project root (which is process.cwd(), unless overridden manually). Previously, paths were resolved relative to the test file. By default, Vitest uses utf-8 encoding but you can override it with options. These commands follow Vite's server.fs restrictions for security reasons. writeFile and removeFile require write access through api.allowWrite configuration.
toBeRequired assertion
toBeRequired() checks if a form element is currently required. An element is required if it has a 'required' or 'aria-required="true"' attribute.
toContainElement assertion
toContainElement(element: HTMLElement | SVGElement | Locator | null) asserts whether an element contains another element as a descendant or not. Can accept locators or null.
toBeInViewport assertion with ratio option
toBeInViewport(options: { ratio?: number }) checks if an element is currently in viewport using the IntersectionObserver API. The ratio argument (0~1) specifies the minimal ratio of the element that should be in viewport.
toBeVisible assertion
toBeVisible() checks if an element is currently visible to the user. An element is considered visible when it has a non-empty bounding box and does not have 'visibility:hidden' computed style. Elements of zero size are not visible. Elements with 'display:none' are not visible. Elements with 'opacity:0' are considered visible.
pixelmatch comparator options for toMatchScreenshot
pixelmatch comparator options: allowedMismatchedPixelRatio (number, 0-1) - maximum allowed ratio of differing pixels; allowedMismatchedPixels (number) - maximum number of pixels allowed to differ; threshold (number, default 0.1, 0-1) - acceptable perceived color difference; includeAA (boolean, default false) - if true, disables detection of anti-aliased pixels; alpha (number, default 0.1, 0-1) - blending level of unchanged pixels in diff image; aaColor (RGB array, default [255, 255, 0]) - color for anti-aliased pixels; diffColor (RGB array, default [255, 0, 0]) - color for differing pixels; diffColorAlt (RGB array, optional) - alternative color for dark-on-light differences; diffMask (boolean, default false) - if true, shows only diff as mask on transparent background. When both allowedMismatchedPixels and allowedMismatchedPixelRatio are set, the more restrictive value is used.
toMatchScreenshot options
toMatchScreenshot options: comparatorName (string, default 'pixelmatch') - the algorithm used for comparing images; comparatorOptions (object) - options for the comparator; screenshotOptions (object) - same options as locator.screenshot() except for 'base64', 'path', 'save', 'type'; timeout (number, default 5000) - time to wait for a stable screenshot in milliseconds, setting to 0 disables the timeout.
toBeValid assertion
toBeValid() checks if the value of an element is currently valid. An element is valid if it has no aria-invalid attribute or an attribute value of 'false'. The result of checkValidity() must also be true if it's a form element.
TypeScript support for browser assertions
To get correct type hints for expect when using browser mode, ensure 'vitest/browser' is referenced. If you never import from there, add a reference comment in any file covered by tsconfig.json: /// <reference types="vitest/browser" />
Use expect.element() instead of regular expect() for locators
Always use expect.element() when working with page.getBy* locators to reduce test flakiness. Regular expect() will fail immediately if the assertion does not pass, while expect.element() retries until the condition is met or timeout is reached.
ExpectPollOptions configuration for expect.element()
expect.element() accepts an options object with: interval (number, optional) - the interval to retry the assertion for in milliseconds, defaults to the 'expect.poll.interval' config option; timeout (number, optional) - time to retry the assertion for in milliseconds, defaults to the 'expect.poll.timeout' config option; message (string, optional) - the message printed when the assertion fails.
toBeInvalid assertion
toBeInvalid() checks if an element is currently invalid. An element is invalid if it has an aria-invalid attribute with no value or a value of 'true', or if the result of checkValidity() is false.
toContainHTML assertion
toContainHTML(htmlText: string) asserts whether a string representing an HTML element is contained in another element. The string must contain valid HTML, not incomplete HTML. This matcher tests DOM structure and is not recommended; use toContainElement() instead for elements you control.
toHaveAccessibleDescription assertion
toHaveAccessibleDescription(description?: string | RegExp) asserts that an element has the expected accessible description. Can pass an exact string, regular expression, expect.stringContaining(), or expect.stringMatching() for partial matches.
toHaveAccessibleErrorMessage assertion
toHaveAccessibleErrorMessage(message?: string | RegExp) asserts that an element has the expected accessible error message. Can pass an exact string, regular expression, expect.stringContaining(), or expect.stringMatching() for partial matches.
toBePartiallyChecked assertion
toBePartiallyChecked() checks whether an element is partially checked. Accepts input of type checkbox with aria-checked="mixed" or indeterminate set to true, and elements with role checkbox with aria-checked="mixed".
toBeInTheDocument assertion
toBeInTheDocument() asserts whether an element is present in the document or not. This matcher does not find detached elements; the element must be added to the document to be found. If you need to search in a detached element, use toContainElement() instead.
toHaveDisplayValue assertion
toHaveDisplayValue(value: string | RegExp | (string | RegExp)[]) checks whether a form element has the specified displayed value (what the user sees). Accepts input, select, and textarea elements (except input type="checkbox" and type="radio"). Can use strings, regular expressions, or arrays of mixed strings and regular expressions.
toHaveValue assertion
toHaveValue(value: string | string[] | number | null) checks whether a form element has the specified value. Accepts input, select, and textarea elements (except input type="checkbox" and type="radio", which use toBeChecked() or toHaveFormValues()). Also accepts elements with roles meter, progressbar, slider, or spinbutton (checks aria-valuenow as number). Uses the same value matching algorithm as toHaveFormValues().
toMatchTextContent assertion
toMatchTextContent(text: string | number | RegExp, options?: { normalizeWhitespace: boolean }) checks whether an element has text content matching the provided value. Supports elements, text nodes, and fragments. With a string argument, performs partial case-sensitive match. Use RegExp with /i flag for case-insensitive matching. Use RegExp to match the whole content or toHaveTextContent() instead.
toBeEmptyDOMElement assertion
toBeEmptyDOMElement() asserts whether an element has no visible content for the user. It ignores comments but will fail if the element contains white-space.
toHaveTextContent assertion
toHaveTextContent(text: string | number, options?: { normalizeWhitespace: boolean }) validates that an element's text matches the provided string exactly. Supports elements, text nodes, and fragments. For partial checks or case-insensitive matching, use toMatchTextContent() instead.
toHaveFormValues assertion
toHaveFormValues(expectedValues: Record<string, unknown>) checks if a form or fieldset contains form controls for each given name with specified values. Must be invoked on a form or fieldset element to leverage the .elements property. Handles different form control types: input type="number" returns value as number; checkbox with single name returns boolean; multiple checkboxes with same name return array; radio elements return string; input type="text" returns string; select without multiple returns string or undefined; select multiple returns array; textarea returns string.
toHaveStyle assertion
toHaveStyle(css: string | Partial<CSSStyleDeclaration>) checks if an element has specific CSS properties with specific values applied. Matches only if the element has ALL the expected properties. Works with inline styles and rules applied via classes in active stylesheets.
toBeEnabled assertion
toBeEnabled() checks whether an element is not disabled from the user's perspective. It works like not.toBeDisabled() and should be used to avoid double negation in tests.
toHaveFocus assertion
toHaveFocus() asserts whether an element has focus or not.
toBeChecked assertion
toBeChecked() checks whether an element is checked. Accepts input of type checkbox or radio, and elements with role checkbox, radio, or switch with a valid aria-checked attribute of 'true' or 'false'.
toHaveClass assertion with exact option
toHaveClass(...classNames: string[], options?: { exact: boolean }) checks whether an element has certain classes. Accepts strings and regular expressions. Regular expressions are matched against each individual class, not the full class attribute. The exact: true option requires the element to have EXACTLY the specified set of classes in any order. Cannot use exact: true with only regular expressions.
toHaveRole assertion
toHaveRole(role: ARIARole) asserts that an element has the expected ARIA role. Matches either an explicit role via the role attribute or an implicit one via implicit ARIA semantics. Roles are matched literally by string equality without inheriting from the ARIA role hierarchy. Vitest ignores all custom roles except the first valid one, following Playwright's behaviour.
toHaveSelection assertion
toHaveSelection(selection?: string) asserts that an element has text selection. Works with input type="text", textarea, or any element containing text. The expected selection is a string and does not allow checking for selection range indices.
toMatchScreenshot assertion for visual regression testing
toMatchScreenshot(options?: ScreenshotMatcherOptions) or toMatchScreenshot(name?: string, options?: ScreenshotMatcherOptions) performs visual regression testing by comparing screenshots against stored reference images. When differences are detected beyond the configured threshold, the test fails and generates the actual screenshot, expected reference screenshot, and a diff image. The assertion automatically retries taking screenshots until two consecutive captures yield the same result.
toHaveAttribute assertion
toHaveAttribute(attribute: string, value?: unknown) checks whether the given element has an attribute. Can optionally check that the attribute has a specific expected value using expect.stringContaining(), expect.stringMatching(), or regular comparison.
toHaveAccessibleName assertion
toHaveAccessibleName(name?: string | RegExp) asserts that an element has the expected accessible name. Can pass an exact string, regular expression, expect.stringContaining(), or expect.stringMatching() for partial matches. Useful for asserting that form elements and buttons are properly labelled.
toBeDisabled assertion
toBeDisabled() checks whether an element is disabled from the user's perspective. It matches if the element is a form control and the 'disabled' attribute is specified on it, or if the element is a descendant of a form element with a 'disabled' attribute. Only native control elements such as HTML button, input, select, textarea, option, optgroup can be disabled by setting the 'disabled' attribute. The 'disabled' attribute on other elements is ignored unless it's a custom element.
Browser tests may fail inconsistently due to asynchronous nature
Tests in the browser might fail inconsistently due to their asynchronous nature (timeouts, network requests, animations). Use expect.element() and expect.poll() to guarantee assertions succeed even if conditions are delayed.
expect.element() for browser assertions with retry-ability
expect.element() is used for DOM assertions in browser mode with built-in retry-ability. It automatically retries assertions until they pass or timeout is reached. When receiving a locator, Vitest resolves it with locator.findElement() before running the DOM assertion. The timeout option applies to the whole retry operation, while the interval option controls how often failed DOM assertions are retried. Regular expect() without expect.element() will fail immediately if the assertion does not pass.
frameLocator cross-origin iframe limitations
By default, frameLocator does not support querying elements with expect.element() in cross-origin iframes. Interactive methods such as click() work fine. This is different from Playwright's behavior. To work with cross-origin iframes, pass args: ["--disable-web-security"] in launchOptions, or create a custom browser command that accesses the iframe on the server side where it's available.
page.frameLocator() for iframe element interaction
The page.frameLocator(iframeElement: Locator) method returns a FrameLocator instance that can be used to find and interact with elements inside an iframe. The frame locator refers to the iframe's document, not the iframe HTML element itself. It supports interactive methods like click() and fill(), but by default does not support querying elements with expect.element() in cross-origin iframes (unless --disable-web-security is passed in launchOptions). The frameLocator method is currently only supported by the playwright provider.
page.mark() usage example
import { page } from 'vitest/browser'
await page.mark('before submit')
await page.getByRole('button', { name: 'Submit' }).click()
await page.mark('after submit')
await page.mark('submit flow', async () => {
await page.getByRole('textbox', { name: 'Email' }).fill('john@example.com')
await page.getByRole('button', { name: 'Submit' }).click()
}, { kind: 'action' })
page.mark() for trace markers
The page.mark() method adds a named marker to the trace timeline for the current test. It has two forms: mark(name: string, options?: { stack?: string; kind?: BrowserTraceEntryKind }) adds a simple marker, and mark<T>(name: string, body: () => T | Promise<T>, options?: { stack?: string; kind?: BrowserTraceEntryKind }) creates a trace group, runs the callback, and closes the group automatically. The options.stack parameter overrides the callsite location in trace metadata (useful for wrapper libraries), and options.kind categorizes the marker as a specific type like 'action'. This method is only useful when browser.trace is enabled.
page.screenshot() method options
The page.screenshot() method has two overloads: screenshot(options?: ScreenshotOptions) returns Promise<string> (path to the screenshot file), and screenshot(options: Omit<ScreenshotOptions, 'base64'> & { base64: true }) returns Promise<{ path: string; base64: string }>. As of version 3.2.0, if save is set to false, screenshot will always return a base64 string and the path is ignored in that case.
page.viewport() method for browser tests
The page.viewport(width: number, height: number) method changes the size of the iframe's viewport in browser tests and returns a Promise<void>.
page.frameLocator() usage example
const frame = page.frameLocator(
page.getByTestId('iframe')
)
await frame.getByText('Hello World').click() // ✅
await frame.click() // ❌ Not available
userEvent API methods for browser testing
The userEvent context API provides the following methods for simulating user interactions in browser tests: setup(), cleanup(), click(element, options?), dblClick(element, options?), tripleClick(element, options?), selectOptions(element, values, options?), keyboard(text), type(element, text, options?), clear(element), tab(options?), hover(element, options?), unhover(element, options?), fill(element, text, options?), and dragAndDrop(source, target, options?). All methods except setup() return Promise<void>. The API is marked as experimental and support is implemented by the browser provider (playwright or webdriverio), with fallback to simulated events via @testing-library/user-event when using the preview provider.
utils.getElementLocatorSelectors() for locator selectors
The utils.getElementLocatorSelectors(element: Element) function is similar to calling page.elementLocator but returns only locator selectors instead of a Locator object.