feat(api): add allowWrite and allowExec options to api (#9350)

This commit is contained in:
Vladimir
2026-01-29 14:00:07 +01:00
committed by GitHub
parent 397c9c5c99
commit a2e2942b2e
41 changed files with 502 additions and 674 deletions
-3
View File
@@ -120,9 +120,6 @@ jobs:
- name: Test Examples
run: pnpm run test:examples
- name: Unit Test UI
run: pnpm run -C packages/ui test:ui
- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
+2
View File
@@ -17,6 +17,8 @@ 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.
If [`browser.api.allowWrite`](/config/browser/api) or [`api.allowWrite`](/config/api#api-allowwrite) are disabled, `writeFile` and `removeFile` functions won't do anything.
:::
```ts
+1 -1
View File
@@ -7,7 +7,7 @@ outline: [2, 3]
A locator is a representation of an element or a number of elements. Every locator is defined by a string called a selector. Vitest abstracts this selector by providing convenient methods that generate them behind the scenes.
The locator API uses a fork of [Playwright's locators](https://playwright.dev/docs/api/class-locator) called [Ivya](https://npmjs.com/ivya). However, Vitest provides this API to every [provider](/config/browser#browser-provider), not just playwright.
The locator API uses a fork of [Playwright's locators](https://playwright.dev/docs/api/class-locator) called [Ivya](https://npmjs.com/ivya). However, Vitest provides this API to every [provider](/config/browser/provider), not just playwright.
::: tip
This page covers API usage. To better understand locators and their usage, read [Playwright's "Locators" documentation](https://playwright.dev/docs/locators).
+21 -1
View File
@@ -5,8 +5,28 @@ outline: deep
# api
- **Type:** `boolean | number`
- **Type:** `boolean | number | object`
- **Default:** `false`
- **CLI:** `--api`, `--api.port`, `--api.host`, `--api.strictPort`
Listen to port and serve API for [the UI](/guide/ui) or [browser server](/guide/browser/). When set to `true`, the default port is `51204`.
## api.allowWrite <Version>4.1.0</Version> {#api-allowwrite}
- **Type:** `boolean`
- **Default:** `true` if not exposed to the network, `false` otherwise
Vitest server can save test files or snapshot files via the API. This allows anyone who can connect to the API the ability to run any arbitary code on your machine.
::: danger SECURITY ADVICE
Vitest does not expose the API to the internet by default and only listens on `localhost`. However if `host` is manually exposed to the network, anyone who connects to it can run arbitrary code on your machine, unless `api.allowWrite` and `api.allowExec` are set to `false`.
If the host is set to anything other than `localhost` or `127.0.0.1`, Vitest will set `api.allowWrite` and `api.allowExec` to `false` by default. This means that any write operations (like changing the code in the UI) will not work. However, if you understand the security implications, you can override them.
:::
## api.allowExec <Version>4.1.0</Version> {#api-allowexec}
- **Type:** `boolean`
- **Default:** `true` if not exposed to the network, `false` otherwise
Allows running any test file via the API. See the security advice in [`api.allowWrite`](#api-allowwrite).
-626
View File
@@ -1,626 +0,0 @@
---
title: Browser Config Reference | Config
outline: deep
---
# Browser Config Reference
You can change the browser configuration by updating the `test.browser` field in your [config file](/config/). An example of a simple config file:
```ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [
{
browser: 'chromium',
setupFile: './chromium-setup.js',
},
],
},
},
})
```
Please, refer to the ["Config Reference"](/config/) article for different config examples.
::: warning
_All listed options_ on this page are located within a `test` property inside the configuration:
```ts [vitest.config.js]
export default defineConfig({
test: {
browser: {},
},
})
```
:::
## browser.enabled
- **Type:** `boolean`
- **Default:** `false`
- **CLI:** `--browser`, `--browser.enabled=false`
Run all tests inside a browser by default. Note that `--browser` only works if you have at least one [`browser.instances`](#browser-instances) item.
## browser.instances
- **Type:** `BrowserConfig`
- **Default:** `[]`
Defines multiple browser setups. Every config has to have at least a `browser` field.
You can specify most of the [project options](/config/) (not marked with a <CRoot /> icon) and some of the `browser` options like `browser.testerHtmlPath`.
::: warning
Every browser config inherits options from the root config:
```ts{3,9} [vitest.config.ts]
export default defineConfig({
test: {
setupFile: ['./root-setup-file.js'],
browser: {
enabled: true,
testerHtmlPath: './custom-path.html',
instances: [
{
// will have both setup files: "root" and "browser"
setupFile: ['./browser-setup-file.js'],
// implicitly has "testerHtmlPath" from the root config // [!code warning]
// testerHtmlPath: './custom-path.html', // [!code warning]
},
],
},
},
})
```
For more examples, refer to the ["Multiple Setups" guide](/guide/browser/multiple-setups).
:::
List of available `browser` options:
- [`browser.headless`](#browser-headless)
- [`browser.locators`](#browser-locators)
- [`browser.viewport`](#browser-viewport)
- [`browser.testerHtmlPath`](#browser-testerhtmlpath)
- [`browser.screenshotDirectory`](#browser-screenshotdirectory)
- [`browser.screenshotFailures`](#browser-screenshotfailures)
- [`browser.provider`](#browser-provider)
- [`browser.detailsPanelPosition`](#browser-detailspanelposition)
Under the hood, Vitest transforms these instances into separate [test projects](/api/advanced/test-project) sharing a single Vite server for better caching performance.
## browser.headless
- **Type:** `boolean`
- **Default:** `process.env.CI`
- **CLI:** `--browser.headless`, `--browser.headless=false`
Run the browser in a `headless` mode. If you are running Vitest in CI, it will be enabled by default.
## browser.isolate <Deprecated />
- **Type:** `boolean`
- **Default:** the same as [`--isolate`](/config/#isolate)
- **CLI:** `--browser.isolate`, `--browser.isolate=false`
Run every test in a separate iframe.
::: danger DEPRECATED
This option is deprecated. Use [`isolate`](/config/#isolate) instead.
:::
## browser.testerHtmlPath
- **Type:** `string`
A path to the HTML entry point. Can be relative to the root of the project. This file will be processed with [`transformIndexHtml`](https://vite.dev/guide/api-plugin#transformindexhtml) hook.
## browser.api
- **Type:** `number | { port?, strictPort?, host? }`
- **Default:** `63315`
- **CLI:** `--browser.api=63315`, `--browser.api.port=1234, --browser.api.host=example.com`
Configure options for Vite server that serves code in the browser. Does not affect [`test.api`](#api) option. By default, Vitest assigns port `63315` to avoid conflicts with the development server, allowing you to run both in parallel.
## browser.provider {#browser-provider}
- **Type:** `BrowserProviderOption`
- **Default:** `'preview'`
- **CLI:** `--browser.provider=playwright`
The return value of the provider factory. You can import the factory from `@vitest/browser-<provider-name>` or make your own provider:
```ts{8-10}
import { playwright } from '@vitest/browser-playwright'
import { webdriverio } from '@vitest/browser-webdriverio'
import { preview } from '@vitest/browser-preview'
export default defineConfig({
test: {
browser: {
provider: playwright(),
provider: webdriverio(),
provider: preview(), // default
},
},
})
```
To configure how provider initializes the browser, you can pass down options to the factory function:
```ts{7-13,20-26}
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
browser: {
// shared provider options between all instances
provider: playwright({
launchOptions: {
slowMo: 50,
channel: 'chrome-beta',
},
actionTimeout: 5_000,
}),
instances: [
{ browser: 'chromium' },
{
browser: 'firefox',
// overriding options only for a single instance
// this will NOT merge options with the parent one
provider: playwright({
launchOptions: {
firefoxUserPrefs: {
'browser.startup.homepage': 'https://example.com',
},
},
})
}
],
},
},
})
```
### Custom Provider <Badge type="danger">advanced</Badge>
::: danger ADVANCED API
The custom provider API is highly experimental and can change between patches. If you just need to run tests in a browser, use the [`browser.instances`](#browser-instances) option instead.
:::
```ts
export interface BrowserProvider {
name: string
mocker?: BrowserModuleMocker
readonly initScripts?: string[]
/**
* @experimental opt-in into file parallelisation
*/
supportsParallelism: boolean
getCommandsContext: (sessionId: string) => Record<string, unknown>
openPage: (sessionId: string, url: string) => Promise<void>
getCDPSession?: (sessionId: string) => Promise<CDPSession>
close: () => Awaitable<void>
}
```
## browser.ui
- **Type:** `boolean`
- **Default:** `!isCI`
- **CLI:** `--browser.ui=false`
Should Vitest UI be injected into the page. By default, injects UI iframe during development.
## browser.detailsPanelPosition
- **Type:** `'right' | 'bottom'`
- **Default:** `'right'`
- **CLI:** `--browser.detailsPanelPosition=bottom`, `--browser.detailsPanelPosition=right`
Controls the default position of the details panel in the Vitest UI when running browser tests. See [`browser.detailsPanelPosition`](/config/browser/detailspanelposition) for more details.
## browser.viewport
- **Type:** `{ width, height }`
- **Default:** `414x896`
Default iframe's viewport.
## browser.locators
Options for built-in [browser locators](/api/browser/locators).
### browser.locators.testIdAttribute
- **Type:** `string`
- **Default:** `data-testid`
Attribute used to find elements with `getByTestId` locator.
## browser.screenshotDirectory
- **Type:** `string`
- **Default:** `__screenshots__` in the test file directory
Path to the screenshots directory relative to the `root`.
## browser.screenshotFailures
- **Type:** `boolean`
- **Default:** `!browser.ui`
Should Vitest take screenshots if the test fails.
## browser.orchestratorScripts
- **Type:** `BrowserScript[]`
- **Default:** `[]`
Custom scripts that should be injected into the orchestrator HTML before test iframes are initiated. This HTML document only sets up iframes and doesn't actually import your code.
The script `src` and `content` will be processed by Vite plugins. Script should be provided in the following shape:
```ts
export interface BrowserScript {
/**
* If "content" is provided and type is "module", this will be its identifier.
*
* If you are using TypeScript, you can add `.ts` extension here for example.
* @default `injected-${index}.js`
*/
id?: string
/**
* JavaScript content to be injected. This string is processed by Vite plugins if type is "module".
*
* You can use `id` to give Vite a hint about the file extension.
*/
content?: string
/**
* Path to the script. This value is resolved by Vite so it can be a node module or a file path.
*/
src?: string
/**
* If the script should be loaded asynchronously.
*/
async?: boolean
/**
* Script type.
* @default 'module'
*/
type?: string
}
```
## browser.commands
- **Type:** `Record<string, BrowserCommand>`
- **Default:** `{ readFile, writeFile, ... }`
Custom [commands](/api/browser/commands) that can be imported during browser tests from `vitest/browser`.
## browser.connectTimeout
- **Type:** `number`
- **Default:** `60_000`
The timeout in milliseconds. If connection to the browser takes longer, the test suite will fail.
::: info
This is the time it should take for the browser to establish the WebSocket connection with the Vitest server. In normal circumstances, this timeout should never be reached.
:::
## browser.trace
- **Type:** `'on' | 'off' | 'on-first-retry' | 'on-all-retries' | 'retain-on-failure' | object`
- **CLI:** `--browser.trace=on`, `--browser.trace=retain-on-failure`
- **Default:** `'off'`
Capture a trace of your browser test runs. You can preview traces with [Playwright Trace Viewer](https://trace.playwright.dev/).
This options supports the following values:
- `'on'` - capture trace for all tests. (not recommended as it's performance heavy)
- `'off'` - do not capture traces.
- `'on-first-retry'` - capture trace only when retrying the test for the first time.
- `'on-all-retries'` - capture trace on every retry of the test.
- `'retain-on-failure'` - capture trace only for tests that fail. This will automatically delete traces for tests that pass.
- `object` - an object with the following shape:
```ts
interface TraceOptions {
mode: 'on' | 'off' | 'on-first-retry' | 'on-all-retries' | 'retain-on-failure'
/**
* The directory where all traces will be stored. By default, Vitest
* stores all traces in `__traces__` folder close to the test file.
*/
tracesDir?: string
/**
* Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview.
* @default true
*/
screenshots?: boolean
/**
* If this option is true tracing will
* - capture DOM snapshot on every action
* - record network activity
* @default true
*/
snapshots?: boolean
}
```
::: danger WARNING
This option is supported only by the [**playwright**](/config/browser/playwright) provider.
:::
## browser.trackUnhandledErrors
- **Type:** `boolean`
- **Default:** `true`
Enables tracking uncaught errors and exceptions so they can be reported by Vitest.
If you need to hide certain errors, it is recommended to use [`onUnhandledError`](/config/#onunhandlederror) option instead.
Disabling this will completely remove all Vitest error handlers, which can help debugging with the "Pause on exceptions" checkbox turned on.
## browser.expect
- **Type:** `ExpectOptions`
### browser.expect.toMatchScreenshot
Default options for the
[`toMatchScreenshot` assertion](/api/browser/assertions.html#tomatchscreenshot).
These options will be applied to all screenshot assertions.
::: tip
Setting global defaults for screenshot assertions helps maintain consistency
across your test suite and reduces repetition in individual tests. You can still
override these defaults at the assertion level when needed for specific test cases.
:::
```ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
browser: {
enabled: true,
expect: {
toMatchScreenshot: {
comparatorName: 'pixelmatch',
comparatorOptions: {
threshold: 0.2,
allowedMismatchedPixels: 100,
},
resolveScreenshotPath: ({ arg, browserName, ext, testFileName }) =>
`custom-screenshots/${testFileName}/${arg}-${browserName}${ext}`,
},
},
},
},
})
```
[All options available in the `toMatchScreenshot` assertion](/api/browser/assertions#options)
can be configured here. Additionally, two path resolution functions are
available: `resolveScreenshotPath` and `resolveDiffPath`.
#### browser.expect.toMatchScreenshot.resolveScreenshotPath
- **Type:** `(data: PathResolveData) => string`
- **Default output:** `` `${root}/${testFileDirectory}/${screenshotDirectory}/${testFileName}/${arg}-${browserName}-${platform}${ext}` ``
A function to customize where reference screenshots are stored. The function
receives an object with the following properties:
- `arg: string`
Path **without** extension, sanitized and relative to the test file.
This comes from the arguments passed to `toMatchScreenshot`; if called
without arguments this will be the auto-generated name.
```ts
test('calls `onClick`', () => {
expect(locator).toMatchScreenshot()
// arg = "calls-onclick-1"
})
expect(locator).toMatchScreenshot('foo/bar/baz.png')
// arg = "foo/bar/baz"
expect(locator).toMatchScreenshot('../foo/bar/baz.png')
// arg = "foo/bar/baz"
```
- `ext: string`
Screenshot extension, with leading dot.
This can be set through the arguments passed to `toMatchScreenshot`, but
the value will fall back to `'.png'` if an unsupported extension is used.
- `browserName: string`
The instance's browser name.
- `platform: NodeJS.Platform`
The value of
[`process.platform`](https://nodejs.org/docs/v22.16.0/api/process.html#processplatform).
- `screenshotDirectory: string`
The value provided to
[`browser.screenshotDirectory`](/config/browser/screenshotdirectory),
if none is provided, its default value.
- `root: string`
Absolute path to the project's [`root`](/config/#root).
- `testFileDirectory: string`
Path to the test file, relative to the project's [`root`](/config/#root).
- `testFileName: string`
The test's filename.
- `testName: string`
The [`test`](/api/test)'s name, including parent
[`describe`](/api/describe), sanitized.
- `attachmentsDir: string`
The value provided to [`attachmentsDir`](/config/#attachmentsdir), if none is
provided, its default value.
For example, to group screenshots by browser:
```ts
resolveScreenshotPath: ({ arg, browserName, ext, root, testFileName }) =>
`${root}/screenshots/${browserName}/${testFileName}/${arg}${ext}`
```
#### browser.expect.toMatchScreenshot.resolveDiffPath
- **Type:** `(data: PathResolveData) => string`
- **Default output:** `` `${root}/${attachmentsDir}/${testFileDirectory}/${testFileName}/${arg}-${browserName}-${platform}${ext}` ``
A function to customize where diff images are stored when screenshot comparisons
fail. Receives the same data object as
[`resolveScreenshotPath`](#browser-expect-tomatchscreenshot-resolvescreenshotpath).
For example, to store diffs in a subdirectory of attachments:
```ts
resolveDiffPath: ({ arg, attachmentsDir, browserName, ext, root, testFileName }) =>
`${root}/${attachmentsDir}/screenshot-diffs/${testFileName}/${arg}-${browserName}${ext}`
```
#### browser.expect.toMatchScreenshot.comparators
- **Type:** `Record<string, Comparator>`
Register custom screenshot comparison algorithms, like [SSIM](https://en.wikipedia.org/wiki/Structural_similarity_index_measure) or other perceptual similarity metrics.
To create a custom comparator, you need to register it in your config. If using TypeScript, declare its options in the `ScreenshotComparatorRegistry` interface.
```ts
import { defineConfig } from 'vitest/config'
// 1. Declare the comparator's options type
declare module 'vitest/browser' {
interface ScreenshotComparatorRegistry {
myCustomComparator: {
sensitivity?: number
ignoreColors?: boolean
}
}
}
// 2. Implement the comparator
export default defineConfig({
test: {
browser: {
expect: {
toMatchScreenshot: {
comparators: {
myCustomComparator: async (
reference,
actual,
{
createDiff, // always provided by Vitest
sensitivity = 0.01,
ignoreColors = false,
}
) => {
// ...algorithm implementation
return { pass, diff, message }
},
},
},
},
},
},
})
```
Then use it in your tests:
```ts
await expect(locator).toMatchScreenshot({
comparatorName: 'myCustomComparator',
comparatorOptions: {
sensitivity: 0.08,
ignoreColors: true,
},
})
```
**Comparator Function Signature:**
```ts
type Comparator<Options> = (
reference: {
metadata: { height: number; width: number }
data: TypedArray
},
actual: {
metadata: { height: number; width: number }
data: TypedArray
},
options: {
createDiff: boolean
} & Options
) => Promise<{
pass: boolean
diff: TypedArray | null
message: string | null
}> | {
pass: boolean
diff: TypedArray | null
message: string | null
}
```
The `reference` and `actual` images are decoded using the appropriate codec (currently only PNG). The `data` property is a flat `TypedArray` (`Buffer`, `Uint8Array`, or `Uint8ClampedArray`) containing pixel data in RGBA format:
- **4 bytes per pixel**: red, green, blue, alpha (from `0` to `255` each)
- **Row-major order**: pixels are stored left-to-right, top-to-bottom
- **Total length**: `width × height × 4` bytes
- **Alpha channel**: always present. Images without transparency have alpha values set to `255` (fully opaque)
::: tip Performance Considerations
The `createDiff` option indicates whether a diff image is needed. During [stable screenshot detection](/guide/browser/visual-regression-testing#how-visual-tests-work), Vitest calls comparators with `createDiff: false` to avoid unnecessary work.
**Respect this flag to keep your tests fast**.
:::
::: warning Handle Missing Options
The `options` parameter in `toMatchScreenshot()` is optional, so users might not provide all your comparator options. Always make them optional with default values:
```ts
myCustomComparator: (
reference,
actual,
{ createDiff, threshold = 0.1, maxDiff = 100 },
) => {
// ...comparison logic
}
```
:::
+17 -1
View File
@@ -5,8 +5,24 @@ outline: deep
# browser.api
- **Type:** `number | { port?, strictPort?, host? }`
- **Type:** `number | object`
- **Default:** `63315`
- **CLI:** `--browser.api=63315`, `--browser.api.port=1234, --browser.api.host=example.com`
Configure options for Vite server that serves code in the browser. Does not affect [`test.api`](#api) option. By default, Vitest assigns port `63315` to avoid conflicts with the development server, allowing you to run both in parallel.
## api.allowWrite <Version>4.1.0</Version> {#api-allowwrite}
- **Type:** `boolean`
- **Default:** `true` if not exposed to the network, `false` otherwise
Vitest saves [annotation attachments](/guide/test-annotations), [artifacts](/api/advanced/artifacts) and [snapshots](/guide/snapshot) by receiving a WebSocket connection from the browser. This allows anyone who can connect to the API write any arbitary code on your machine within the root of your project (configured by [`fs.allow`](https://vite.dev/config/server-options#server-fs-allow)).
If browser server is not exposed to the internet (the host is `localhost`), this should not be a problem, so the default value in that case is `true`. If you override the host, Vitest will set `allowWrite` to `false` by default to prevent potentially harmful writes.
## api.allowExec <Version>4.1.0</Version> {#api-allowexec}
- **Type:** `boolean`
- **Default:** `true` if not exposed to the network, `false` otherwise
Allows running any test file via the UI. This only applies to the interactive elements (and the server code behind them) in the [UI](/guide/ui) that can run the code. If UI is disabled, this has no effect. See [`api.allowExec`](/config/api#api-allowexec) for more information.
+4
View File
@@ -14,3 +14,7 @@ Enable [Vitest UI](/guide/ui).
::: warning
This features requires a [`@vitest/ui`](https://www.npmjs.com/package/@vitest/ui) package to be installed. If you do not have it already, Vitest will install it when you run the test command for the first time.
:::
::: danger SECURITY ADVICE
Make sure that your UI server is not exposed to the network. Since Vitest 4.1 setting [`api.host`](/config/api) to anything other than `localhost` will disable the buttons to save the code or run any tests for security reasons, effectively making UI a readonly reporter.
:::
+28
View File
@@ -70,6 +70,20 @@ Specify which IP addresses the server should listen on. Set this to `0.0.0.0` or
Set to true to exit if port is already in use, instead of automatically trying the next available port
### api.allowExec
- **CLI:** `--api.allowExec`
- **Config:** [api.allowExec](/config/api#api-allowexec)
Allow API to execute code. (Be careful when enabling this option in untrusted environments)
### api.allowWrite
- **CLI:** `--api.allowWrite`
- **Config:** [api.allowWrite](/config/api#api-allowwrite)
Allow API to edit files. (Be careful when enabling this option in untrusted environments)
### silent
- **CLI:** `--silent [value]`
@@ -332,6 +346,20 @@ Specify which IP addresses the server should listen on. Set this to `0.0.0.0` or
Set to true to exit if port is already in use, instead of automatically trying the next available port
### browser.api.allowExec
- **CLI:** `--browser.api.allowExec`
- **Config:** [browser.api.allowExec](/config/browser/api#api-allowexec)
Allow API to execute code. (Be careful when enabling this option in untrusted environments)
### browser.api.allowWrite
- **CLI:** `--browser.api.allowWrite`
- **Config:** [browser.api.allowWrite](/config/browser/api#api-allowwrite)
Allow API to edit files. (Be careful when enabling this option in untrusted environments)
### browser.isolate
- **CLI:** `--browser.isolate`
+5
View File
@@ -25,6 +25,11 @@ from = "/config/file"
to = "/config/"
status = 301
[[redirects]]
from = "/config/browser"
to = "/config/browser/enabled"
status = 301
[[redirects]]
from = "/guide/workspace"
to = "/guide/projects"
+16 -7
View File
@@ -3,12 +3,13 @@ import type { BrowserCommand, TestProject } from 'vitest/node'
import fs, { promises as fsp } from 'node:fs'
import { basename, dirname, resolve } from 'node:path'
import mime from 'mime/lite'
import { isFileServingAllowed } from 'vitest/node'
import { isFileLoadingAllowed } from 'vitest/node'
import { slash } from '../utils'
function assertFileAccess(path: string, project: TestProject) {
if (
!isFileServingAllowed(path, project.vite)
&& !isFileServingAllowed(path, project.vitest.vite)
!isFileLoadingAllowed(project.vite.config, path)
&& !isFileLoadingAllowed(project.vitest.vite.config, path)
) {
throw new Error(
`Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`,
@@ -16,11 +17,17 @@ function assertFileAccess(path: string, project: TestProject) {
}
}
function assertWrite(path: string, project: TestProject) {
if (!project.config.browser.api.allowWrite || !project.vitest.config.api.allowWrite) {
throw new Error(`Cannot modify file "${path}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/browser/api.`)
}
}
export const readFile: BrowserCommand<
Parameters<BrowserCommands['readFile']>
> = async ({ project }, path, options = {}) => {
const filepath = resolve(project.config.root, path)
assertFileAccess(filepath, project)
assertFileAccess(slash(filepath), project)
// never return a Buffer
if (typeof options === 'object' && !options.encoding) {
options.encoding = 'utf-8'
@@ -31,8 +38,9 @@ export const readFile: BrowserCommand<
export const writeFile: BrowserCommand<
Parameters<BrowserCommands['writeFile']>
> = async ({ project }, path, data, options) => {
assertWrite(path, project)
const filepath = resolve(project.config.root, path)
assertFileAccess(filepath, project)
assertFileAccess(slash(filepath), project)
const dir = dirname(filepath)
if (!fs.existsSync(dir)) {
await fsp.mkdir(dir, { recursive: true })
@@ -43,14 +51,15 @@ export const writeFile: BrowserCommand<
export const removeFile: BrowserCommand<
Parameters<BrowserCommands['removeFile']>
> = async ({ project }, path) => {
assertWrite(path, project)
const filepath = resolve(project.config.root, path)
assertFileAccess(filepath, project)
assertFileAccess(slash(filepath), project)
await fsp.rm(filepath)
}
export const _fileInfo: BrowserCommand<[path: string, encoding: BufferEncoding]> = async ({ project }, path, encoding) => {
const filepath = resolve(project.config.root, path)
assertFileAccess(filepath, project)
assertFileAccess(slash(filepath), project)
const content = await fsp.readFile(filepath, encoding || 'base64')
return {
content,
+42 -4
View File
@@ -12,7 +12,7 @@ import { ServerMockResolver } from '@vitest/mocker/node'
import { createBirpc } from 'birpc'
import { parse, stringify } from 'flatted'
import { dirname, join, resolve } from 'pathe'
import { createDebugger, isFileServingAllowed, isValidApiRequest } from 'vitest/node'
import { createDebugger, isFileLoadingAllowed, isValidApiRequest } from 'vitest/node'
import { WebSocketServer } from 'ws'
const debug = createDebugger('vitest:browser:api')
@@ -113,13 +113,22 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke
}
function checkFileAccess(path: string) {
if (!isFileServingAllowed(path, vite)) {
if (!isFileLoadingAllowed(vite.config, path)) {
throw new Error(
`Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`,
)
}
}
function canWrite(project: TestProject) {
return (
project.config.browser.api.allowWrite
&& project.vitest.config.browser.api.allowWrite
&& project.config.api.allowWrite
&& project.vitest.config.api.allowWrite
)
}
function setupClient(project: TestProject, rpcId: string, ws: WebSocket) {
const mockResolver = new ServerMockResolver(globalServer.vite, {
moduleDirectories: project.config?.deps?.moduleDirectories,
@@ -152,6 +161,23 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke
}
},
async onTaskArtifactRecord(id, artifact) {
if (!canWrite(project)) {
if (artifact.type === 'internal:annotation' && artifact.annotation.attachment) {
artifact.annotation.attachment = undefined
vitest.logger.error(
`[vitest] Cannot record annotation attachment because file writing is disabled. See https://vitest.dev/config/browser/api.`,
)
}
// remove attachments if cannot write
if (artifact.attachments?.length) {
const attachments = artifact.attachments.map(n => n.path).filter(r => !!r).join('", "')
artifact.attachments = []
vitest.logger.error(
`[vitest] Cannot record attachments ("${attachments}") because file writing is disabled, removing attachments from artifact "${artifact.type}". See https://vitest.dev/config/browser/api.`,
)
}
}
return vitest._testRun.recordArtifact(id, artifact)
},
async onTaskUpdate(method, packs, events) {
@@ -193,15 +219,27 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke
},
async saveSnapshotFile(id, content) {
checkFileAccess(id)
if (!canWrite(project)) {
vitest.logger.error(
`[vitest] Cannot save snapshot file "${id}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/browser/api.`,
)
return
}
await fs.mkdir(dirname(id), { recursive: true })
return fs.writeFile(id, content, 'utf-8')
await fs.writeFile(id, content, 'utf-8')
},
async removeSnapshotFile(id) {
checkFileAccess(id)
if (!canWrite(project)) {
vitest.logger.error(
`[vitest] Cannot remove snapshot file "${id}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/browser/api.`,
)
return
}
if (!existsSync(id)) {
throw new Error(`Snapshot file "${id}" does not exist.`)
}
return fs.unlink(id)
await fs.unlink(id)
},
getBrowserFileSourceMap(id) {
const mod = globalServer.vite.moduleGraph.getModuleById(id)
@@ -25,11 +25,11 @@ const emit = defineEmits<{
const imports = computed(() => {
const file = currentModule.value
const importDurations = file?.importDurations
if (!importDurations) {
const root = config.value.root
if (!importDurations || !root) {
return []
}
const root = config.value.root
const allImports: ImportEntry[] = []
for (const filePath in importDurations) {
const duration = importDurations[filePath]
@@ -96,13 +96,17 @@ function onMousedown(editor: Editor, e: MouseEvent) {
function buildShadowImportsHtml(imports: Experimental.UntrackedModuleDefinitionDiagnostic[]) {
const shadowImportsDiv = document.createElement('div')
shadowImportsDiv.classList.add('mb-5')
const root = config.value.root
if (!root) {
return
}
imports.forEach(({ resolvedId, totalTime, external }) => {
const importDiv = document.createElement('div')
importDiv.append(document.createTextNode('import '))
const sourceDiv = document.createElement('span')
const url = relative(config.value.root, resolvedId)
const url = relative(root, resolvedId)
sourceDiv.textContent = `"/${url}"`
sourceDiv.className = 'hover:underline decoration-gray cursor-pointer select-none'
importDiv.append(sourceDiv)
@@ -152,6 +156,9 @@ function markImportDurations(codemirror: EditorFromTextArea) {
if (untrackedModules?.length) {
const importDiv = buildShadowImportsHtml(untrackedModules)
if (!importDiv) {
return
}
widgetElements.push(importDiv)
lineWidgets.push(codemirror.addLineWidget(0, importDiv, { above: true }))
}
+16 -4
View File
@@ -3,7 +3,7 @@ import type { RunnerTestFile } from 'vitest'
import { Tooltip as VueTooltip } from 'floating-vue'
import { computed, nextTick } from 'vue'
import { isDark, toggleDark } from '~/composables'
import { client, isReport, runAll, runFiles } from '~/composables/client'
import { client, config, isReport, runAll, runFiles } from '~/composables/client'
import { explorerTree } from '~/composables/explorer'
import { initialized, shouldShowExpandAll } from '~/composables/explorer/state'
import {
@@ -26,6 +26,10 @@ function updateSnapshot() {
const toggleMode = computed(() => isDark.value ? 'light' : 'dark')
async function onRunAll(files?: RunnerTestFile[]) {
if (config.value.api?.allowExec === false) {
return
}
if (coverageEnabled.value) {
disableCoverage.value = true
await nextTick()
@@ -49,6 +53,13 @@ function collapseTests() {
function expandTests() {
explorerTree.expandAllNodes()
}
function getRerunTooltip(filteredFiles: RunnerTestFile[] | undefined) {
if (config.value.api?.allowExec === false) {
return 'Cannot run tests when `api.allowExec` is `false`. Did you expose UI to the internet?'
}
return filteredFiles ? (filteredFiles.length === 0 ? 'No test to run (clear filter)' : 'Rerun filtered') : 'Rerun all'
}
</script>
<template>
@@ -113,7 +124,7 @@ function expandTests() {
@click="showCoverage()"
/>
<IconButton
v-if="(explorerTree.summary.failedSnapshot && !isReport)"
v-if="(explorerTree.summary.failedSnapshot && !isReport && config.api?.allowExec && config.api?.allowWrite)"
v-tooltip.bottom="'Update all failed snapshot(s)'"
icon="i-carbon:result-old"
:disabled="!explorerTree.summary.failedSnapshotEnabled"
@@ -121,9 +132,10 @@ function expandTests() {
/>
<IconButton
v-if="!isReport"
v-tooltip.bottom="filteredFiles ? (filteredFiles.length === 0 ? 'No test to run (clear filter)' : 'Rerun filtered') : 'Rerun all'"
:disabled="filteredFiles?.length === 0"
v-tooltip.bottom="getRerunTooltip(filteredFiles)"
:disabled="filteredFiles?.length === 0 || !config.api?.allowExec"
icon="i-carbon:play"
data-testid="btn-run-all"
@click="onRunAll(filteredFiles)"
/>
<IconButton
@@ -3,7 +3,7 @@ import type { Task, TaskState } from '@vitest/runner'
import type { TaskTreeNodeType } from '~/composables/explorer/types'
import { Tooltip as VueTooltip } from 'floating-vue'
import { computed, nextTick } from 'vue'
import { client, isReport, runFiles, runTask } from '~/composables/client'
import { client, config, isReport, runFiles, runTask } from '~/composables/client'
import { showTaskSource } from '~/composables/codemirror'
import { explorerTree } from '~/composables/explorer'
import { hasFailedSnapshot } from '~/composables/explorer/collector'
@@ -121,6 +121,9 @@ const gridStyles = computed(() => {
})
const runButtonTitle = computed(() => {
if (config.value.api?.allowExec === false) {
return 'Cannot run tests when `api.allowExec` is `false`. Did you expose UI to the internet?'
}
return type === 'file'
? 'Run current file'
: type === 'suite'
@@ -237,7 +240,7 @@ const tagsBgGradient = computed(() => {
<div :style="{ background: tagsBgGradient }" class="w-[0.9rem] h-[0.9rem]" rounded-full />
</div> -->
<IconAction
v-if="!isReport && failedSnapshot"
v-if="!isReport && failedSnapshot && config.api?.allowExec && config.api?.allowWrite"
v-tooltip.bottom="'Fix failed snapshot(s)'"
data-testid="btn-fix-snapshot"
title="Fix failed snapshot(s)"
@@ -274,6 +277,7 @@ const tagsBgGradient = computed(() => {
:title="runButtonTitle"
icon="i-carbon:play-filled-alt"
text-green5
:disabled="config.api?.allowExec === false"
@click.prevent.stop="onRun(task)"
/>
</div>
@@ -6,7 +6,7 @@ import { until, useResizeObserver, watchDebounced } from '@vueuse/core'
import { createTooltip, destroyTooltip } from 'floating-vue'
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { getAttachmentUrl, sanitizeFilePath } from '~/composables/attachments'
import { client, isReport } from '~/composables/client'
import { client, config, isReport } from '~/composables/client'
import { finished } from '~/composables/client/state'
import { codemirrorRef } from '~/composables/codemirror'
import { openInEditor } from '~/composables/error'
@@ -385,7 +385,7 @@ onBeforeUnmount(clearListeners)
ref="editor"
v-model="code"
h-full
v-bind="{ lineNumbers: true, readOnly: isReport, saving }"
v-bind="{ lineNumbers: true, readOnly: isReport || !config.api?.allowWrite, saving }"
:mode="ext"
data-testid="code-mirror"
@save="onSave"
@@ -44,10 +44,10 @@ const filteredGraph = shallowRef<ModuleGraph>(graph.value)
const breakdownIconClass = computed(() => {
let textClass = ''
const importDurations = currentModule.value?.importDurations
if (!importDurations) {
const thresholds = config.value.experimental?.importDurations.thresholds
if (!importDurations || !thresholds) {
return textClass
}
const thresholds = config.value.experimental.importDurations.thresholds
for (const moduleId in importDurations) {
const { totalTime } = importDurations[moduleId]
if (totalTime >= thresholds.danger) {
@@ -111,7 +111,7 @@ const {
>
<pre v-html="task.result.htmlError" />
</div>
<template v-else-if="task.result?.errors">
<template v-else-if="task.result?.errors && config.root">
<ViewReportError
v-for="(error, idx) of task.result.errors"
:key="idx"
@@ -39,7 +39,8 @@ const {
} = useScreenshot()
function getLocationString(location: TestArtifactLocation) {
const path = relative(config.value.root, location.file)
const root = config.value.root
const path = root ? relative(root, location.file) : location.file
return `${path}:${location.line}:${location.column}`
}
@@ -90,7 +91,7 @@ const meta = computed(() => {
>
<pre v-html="test.result.htmlError" />
</div>
<template v-else-if="test.result?.errors">
<template v-else-if="test.result?.errors && config.root">
<ViewReportError
v-for="(error, idx) of test.result.errors"
:key="idx"
@@ -67,7 +67,7 @@ export const client = (function createVitestClient() {
}
})()
export const config = shallowRef<SerializedConfig>({} as any)
export const config = shallowRef<Partial<SerializedConfig>>({} as any)
export const status = ref<WebSocketStatus>('CONNECTING')
export const availableProjects = shallowRef<string[]>([])
@@ -64,7 +64,6 @@ export function createStaticClient(): VitestClient {
getExternalResult: asyncNoop,
getTransformResult: asyncNoop,
onDone: noop,
onTaskUpdate: noop,
writeFile: asyncNoop,
rerun: asyncNoop,
rerunTask: asyncNoop,
@@ -67,7 +67,7 @@ function createSafeFilter(
return { matcher: () => true }
}
try {
return { matcher: createTagsFilter([query], config.value.tags) }
return { matcher: createTagsFilter([query], config.value.tags || []) }
}
catch (error: any) {
return { matcher: () => false, error: error.message }
@@ -65,11 +65,11 @@ export function getModuleGraph(
return defineGraph({})
}
const externalizedNodes = !config.value.experimental.viteModuleRunner
const externalizedNodes = !config.value.experimental?.viteModuleRunner
? defineExternalModuleNodes([...data.inlined, ...data.externalized])
: defineExternalModuleNodes(data.externalized)
const inlinedNodes
= !config.value.experimental.viteModuleRunner
= !config.value.experimental?.viteModuleRunner
? []
: data.inlined.map(module =>
defineInlineModuleNode(module, module === rootPath),
+1 -1
View File
@@ -17,7 +17,7 @@ export const coverageConfigured = computed(() => coverage.value?.enabled)
export const coverageEnabled = computed(() => {
return (
coverageConfigured.value
&& !!coverage.value.htmlReporter
&& !!coverage.value?.htmlReporter
)
})
export const mainSizes = useLocalStorage<[left: number, right: number]>(
+1
View File
@@ -35,6 +35,7 @@ const stackIgnorePatterns: (string | RegExp)[] = [
/node:\w+/,
/__vitest_test__/,
/__vitest_browser__/,
'/@id/__x00__vitest/browser',
/\/deps\/vitest_/,
]
+17 -3
View File
@@ -59,9 +59,6 @@ export function setup(ctx: Vitest, _server?: ViteDevServer): void {
function setupClient(ws: WebSocket) {
const rpc = createBirpc<WebSocketEvents, WebSocketHandlers>(
{
async onTaskUpdate(packs, events) {
await ctx._testRun.updated(packs, events)
},
getFiles() {
return ctx.state.getFiles()
},
@@ -80,12 +77,24 @@ export function setup(ctx: Vitest, _server?: ViteDevServer): void {
`Test file "${id}" was not registered, so it cannot be updated using the API.`,
)
}
// silently ignore write attempts if not allowed
if (!ctx.config.api.allowWrite) {
return
}
return fs.writeFile(id, content, 'utf-8')
},
async rerun(files, resetTestNamePattern) {
// silently ignore exec attempts if not allowed
if (!ctx.config.api.allowExec) {
return
}
await ctx.rerunFiles(files, undefined, true, resetTestNamePattern)
},
async rerunTask(id) {
// silently ignore exec attempts if not allowed
if (!ctx.config.api.allowExec) {
return
}
await ctx.rerunTask(id)
},
getConfig() {
@@ -150,6 +159,11 @@ export function setup(ctx: Vitest, _server?: ViteDevServer): void {
return getModuleGraph(ctx, project, id, browser)
},
async updateSnapshot(file?: File) {
// silently ignore exec/write attempts if not allowed
// this function both executes the code and write snapshots
if (!ctx.config.api.allowExec || !ctx.config.api.allowWrite) {
return
}
if (!file) {
await ctx.updateSnapshot()
}
-1
View File
@@ -36,7 +36,6 @@ export interface TransformResultWithSource {
}
export interface WebSocketHandlers {
onTaskUpdate: (packs: TaskResultPack[], events: TaskEventPack[]) => void
getFiles: () => File[]
getTestFiles: () => Promise<SerializedTestSpecification[]>
getPaths: () => string[]
@@ -46,6 +46,12 @@ const apiConfig: (port: number) => CLIOptions<ApiConfig> = (port: number) => ({
description:
'Set to true to exit if port is already in use, instead of automatically trying the next available port',
},
allowExec: {
description: 'Allow API to execute code. (Be careful when enabling this option in untrusted environments)',
},
allowWrite: {
description: 'Allow API to edit files. (Be careful when enabling this option in untrusted environments)',
},
middlewareMode: null,
})
@@ -106,6 +112,12 @@ export const cliOptionsConfig: VitestCLIOptions = {
argument: '[port]',
description: `Specify server port. Note if the port is already being used, Vite will automatically try the next available port so this may not be the actual port the server ends up listening on. If true will be set to ${defaultPort}`,
subcommands: apiConfig(defaultPort),
transform(portOrOptions) {
if (typeof portOrOptions === 'number') {
return { port: portOrOptions }
}
return portOrOptions
},
},
silent: {
description: 'Silent console output from tests. Use `\'passed-only\'` to see logs from failing tests only.',
@@ -1,5 +1,6 @@
import type { ResolvedConfig as ResolvedViteConfig } from 'vite'
import type { Vitest } from '../core'
import type { Logger } from '../logger'
import type { BenchmarkBuiltinReporters } from '../reporters'
import type { ResolvedBrowserOptions } from '../types/browser'
import type {
@@ -55,9 +56,14 @@ function parseInspector(inspect: string | undefined | boolean | number) {
return { host, port: Number(port) || defaultInspectPort }
}
/**
* @deprecated Internal function
*/
export function resolveApiServerConfig<Options extends ApiConfig & Omit<UserConfig, 'expect'>>(
options: Options,
defaultPort: number,
parentApi?: ApiConfig,
logger?: Logger,
): ApiConfig | undefined {
let api: ApiConfig | undefined
@@ -97,6 +103,26 @@ export function resolveApiServerConfig<Options extends ApiConfig & Omit<UserConf
api = { middlewareMode: true }
}
// if the API server is exposed to network, disable write operations by default
if (!api.middlewareMode && api.host && api.host !== 'localhost' && api.host !== '127.0.0.1') {
// assigned to browser
if (parentApi) {
if (api.allowWrite == null && api.allowExec == null) {
logger?.error(
c.yellow(
`${c.yellowBright(' WARNING ')} API server is exposed to network, disabling write and exec operations by default for security reasons. This can cause some APIs to not work as expected. Set \`browser.api.allowExec\` manually to hide this warning. See https://vitest.dev/config/browser/api for more details.`,
),
)
}
}
api.allowWrite ??= parentApi?.allowWrite ?? false
api.allowExec ??= parentApi?.allowExec ?? false
}
else {
api.allowWrite ??= parentApi?.allowWrite ?? true
api.allowExec ??= parentApi?.allowExec ?? true
}
return api
}
@@ -801,6 +827,8 @@ export function resolveConfig(
resolved.browser.api = resolveApiServerConfig(
resolved.browser,
defaultBrowserPort,
resolved.api,
logger,
) || {
port: defaultBrowserPort,
}
@@ -1,5 +1,5 @@
import type { TestProject } from '../project'
import type { SerializedConfig } from '../types/config'
import type { ApiConfig, SerializedConfig } from '../types/config'
export function serializeConfig(project: TestProject): SerializedConfig {
const { config, globalConfig } = project
@@ -32,6 +32,12 @@ export function serializeConfig(project: TestProject): SerializedConfig {
pool: config.pool,
expect: config.expect,
snapshotSerializers: config.snapshotSerializers,
api: ((api: ApiConfig | undefined) => {
return {
allowExec: api?.allowExec,
allowWrite: api?.allowWrite,
}
})(project.isBrowserEnabled() ? config.browser.api : config.api),
// TODO: non serializable function?
diff: config.diff,
retry: config.retry,
+16 -1
View File
@@ -44,7 +44,22 @@ export type CSSModuleScopeStrategy = 'stable' | 'scoped' | 'non-scoped'
export type ApiConfig = Pick<
ServerOptions,
'port' | 'strictPort' | 'host' | 'middlewareMode'
>
> & {
/**
* Allow any write operations from the API server.
*
* @default true if `api.host` is exposed to network, false otherwise
*/
allowWrite?: boolean
/**
* Allow running test files via the API.
* If `api.host` is exposed to network and `allowWrite` is true,
* anyone connected to the API server can run arbitrary code on your machine.
*
* @default true if `api.host` is exposed to network, false otherwise
*/
allowExec?: boolean
}
export interface EnvironmentOptions {
/**
+4
View File
@@ -76,6 +76,10 @@ export interface SerializedConfig {
showDiff?: boolean
truncateThreshold?: number
} | undefined
api: {
allowExec: boolean | undefined
allowWrite: boolean | undefined
}
diff: string | SerializedDiffOptions | undefined
retry: SerializableRetry
includeTaskLocation: boolean | undefined
+70
View File
@@ -66,3 +66,73 @@ test('throws an error if test reloads the iframe during a test run', async () =>
`The iframe for "${fs.resolveFile('./iframe-reload.test.ts')}" was reloaded during a test.`,
)
})
test('cannot use fs commands if write is disabled', async () => {
const { stderr, fs } = await runInlineBrowserTests({
'fs-commands.test.ts': `
import { test, expect, recordArtifact } from 'vitest'
import { commands } from 'vitest/browser'
test.describe('fs security', () => {
test('fs writeFile throws an error', async () => {
await commands.writeFile('/test-file.txt', 'Hello World')
})
test('fs removeFile throws an error', async () => {
await commands.removeFile('/test-file.txt')
})
test('doesnt write attachment to disk', async ({ annotate }) => {
await annotate('test-attachment', { data: 'Test Attachment', path: '/test-attachment.txt' })
})
test('cannot record attachments inside artifact', async ({ task }) => {
await recordArtifact(task, {
attachments: [{ data: 'Artifact Attachment', path: '/artifact-attachment.txt' }],
type: 'my-custom',
})
})
test('snapshot saves are not saved', () => {
expect('snapshot content').toMatchSnapshot()
})
})
`,
'./__snapshots__/basic.test.js.snap': `// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html`,
'basic.test.js': `
import { test } from 'vitest'
test('basic test', () => {
expect(1 + 1).toBe(2)
})
`,
}, {
browser: {
api: {
allowExec: false,
allowWrite: false,
},
},
$cliOptions: {
update: true,
},
})
const errors = stderr.split('\n').filter(line => line.includes('Cannot modify file "/test-file.txt".'))
expect(errors).toHaveLength(2 * instances.length)
expect(stderr).toContain(
`Cannot save snapshot file "${fs.resolveFile('./__snapshots__/fs-commands.test.ts.snap')}". File writing is disabled because server is exposed to the internet`,
)
expect(stderr).toContain(
`Cannot remove snapshot file "${fs.resolveFile('./__snapshots__/basic.test.js.snap')}". File writing is disabled because server is exposed to the internet`,
)
// we don't throw an error if cannot write attachment, just warn
expect(stderr).toContain(
'Cannot record annotation attachment because file writing is disabled',
)
expect(stderr).toContain(
'Cannot record attachments ("/artifact-attachment.txt") because file writing is disabled, removing attachments from artifact "my-custom".',
)
})
+5
View File
@@ -0,0 +1,5 @@
import { expect, test } from 'vitest';
test('basic test', () => {
expect(1 + 1).toBe(2)
})
@@ -1061,3 +1061,25 @@ test('allows custom transformIndexHtml without custom html file', async () => {
expect(stdout).toContain('✓ |chromium| browser-custom.test.ts')
expect(exitCode).toBe(0)
})
test('show a warning if host is exposed', async () => {
const { stderr } = await runVitest({
config: false,
root: './fixtures/basic',
reporters: [
{
onInit() {
throw new Error('stop')
},
},
],
browser: {
api: {
host: 'custom-host',
},
},
})
expect(stderr).toContain(
'API server is exposed to network, disabling write and exec operations by default for security reasons. This can cause some APIs to not work as expected. Set `browser.api.allowExec` manually to hide this warning. See https://vitest.dev/config/browser/api for more details.',
)
})
+59
View File
@@ -27,6 +27,8 @@ describe('correctly defines api flag', () => {
})
expect(c.vite.config.server.middlewareMode).toBe(true)
expect(c.config.api).toEqual({
allowExec: true,
allowWrite: true,
middlewareMode: true,
token: expect.any(String),
})
@@ -42,6 +44,8 @@ describe('correctly defines api flag', () => {
expect(c.vite.config.server.port).toBe(4321)
expect(c.config.api).toEqual({
port: 4321,
allowWrite: true,
allowExec: true,
token: expect.any(String),
})
})
@@ -51,6 +55,61 @@ describe('correctly defines api flag', () => {
expect(c.config.isolate).toBe(false)
expect(c.config.browser.isolate).toBe(false)
})
it('allowWrite and allowExec default to true when not exposed to network', async () => {
const c = await config({ api: { port: 5555 } }, {})
expect(c.api.allowWrite).toBe(true)
expect(c.api.allowExec).toBe(true)
})
it('allowWrite and allowExec default to true for localhost', async () => {
const c = await config({ api: { port: 5555, host: 'localhost' } }, {})
expect(c.api.allowWrite).toBe(true)
expect(c.api.allowExec).toBe(true)
})
it('allowWrite and allowExec default to true for 127.0.0.1', async () => {
const c = await config({ api: { port: 5555, host: '127.0.0.1' } }, {})
expect(c.api.allowWrite).toBe(true)
expect(c.api.allowExec).toBe(true)
})
it('allowWrite and allowExec default to false when exposed to network', async () => {
const c = await config({ api: { port: 5555, host: '0.0.0.0' } }, {})
expect(c.api.allowWrite).toBe(false)
expect(c.api.allowExec).toBe(false)
})
it('allowWrite and allowExec can be explicitly overridden when exposed to network', async () => {
const c = await config({ api: { port: 5555, host: '0.0.0.0', allowWrite: true, allowExec: true } }, {})
expect(c.api.allowWrite).toBe(true)
expect(c.api.allowExec).toBe(true)
})
it('allowWrite and allowExec can be explicitly disabled', async () => {
const c = await config({ api: { port: 5555, allowWrite: false, allowExec: false } }, {})
expect(c.api.allowWrite).toBe(false)
expect(c.api.allowExec).toBe(false)
})
it('browser.api inherits allowWrite and allowExec from api', async () => {
const c = await config({ api: { port: 5555, allowWrite: false, allowExec: false } }, {})
expect(c.browser.api.allowWrite).toBe(false)
expect(c.browser.api.allowExec).toBe(false)
})
it('browser.api can override inherited allowWrite and allowExec', async () => {
const c = await config({
api: { port: 5555, allowWrite: false, allowExec: false },
browser: { api: { allowWrite: true, allowExec: true } },
}, {
browser: {},
})
expect(c.api.allowWrite).toBe(false)
expect(c.api.allowExec).toBe(false)
expect(c.browser.api.allowWrite).toBe(true)
expect(c.browser.api.allowExec).toBe(true)
})
})
describe.each([
+2
View File
@@ -65,6 +65,8 @@ export default defineConfig({
test: {
api: {
port: 3023,
allowExec: false,
allowWrite: false,
},
name: 'core',
includeSource: [
+5
View File
@@ -0,0 +1,5 @@
import { expect, test } from 'vitest';
test('wrong snapshot', () => {
expect(1).toMatchInlineSnapshot(`2`)
})
+1 -1
View File
@@ -66,7 +66,7 @@ test.describe('html report', () => {
await page.goto(pageUrl)
// dashboard
await expect(page.locator('[aria-labelledby=tests]')).toContainText('15 Pass 1 Fail 16 Total')
await expect(page.locator('[aria-labelledby=tests]')).toContainText('15 Pass 2 Fail 17 Total')
// unhandled errors
await expect(page.getByTestId('unhandled-errors')).toContainText(
+70
View File
@@ -0,0 +1,70 @@
import type { Vitest } from 'vitest/node'
import { Writable } from 'node:stream'
import { expect, test } from '@playwright/test'
import { startVitest } from 'vitest/node'
const port = 9002
const pageUrl = `http://localhost:${port}/__vitest__/`
test.describe('ui', () => {
let vitest: Vitest | undefined
test.beforeAll(async () => {
// silence Vitest logs
const stdout = new Writable({ write: (_, __, callback) => callback() })
const stderr = new Writable({ write: (_, __, callback) => callback() })
vitest = await startVitest('test', [], {
watch: true,
ui: true,
open: false,
api: {
port,
allowExec: false,
allowWrite: false,
},
reporters: [],
}, {}, {
stdout,
stderr,
})
expect(vitest).toBeDefined()
})
test.afterAll(async () => {
await vitest?.close()
})
test('cannot execute files from the ui', async ({ page }) => {
await page.goto(pageUrl)
await expect(page.getByTestId('btn-run-all')).toBeDisabled()
const item = page.getByTestId('explorer-item').nth(0)
await item.hover()
await expect(item.getByTestId('btn-run-test')).toBeDisabled()
await page.getByPlaceholder('Search...').fill('snapshot')
const snapshotItem = page.getByTestId('explorer-item').filter({ hasText: 'snapshot.test.ts' })
await snapshotItem.hover()
await expect(snapshotItem.getByTestId('btn-fix-snapshot')).not.toBeVisible()
})
test('cannot write files', async ({ page }) => {
await page.goto(pageUrl)
const item = page.getByTestId('explorer-item').nth(0)
await item.hover()
await item.getByTestId('btn-open-details').click()
await page.getByText('Code').click()
const editor = page.getByTestId('btn-code')
await expect(editor).toBeVisible()
await editor.click()
await page.keyboard.type('\n// some comment')
await expect(editor).not.toContainText('// some comment')
})
})
+2 -2
View File
@@ -70,7 +70,7 @@ test.describe('ui', () => {
await page.goto(pageUrl)
// dashboard
await expect(page.locator('[aria-labelledby=tests]')).toContainText('15 Pass 1 Fail 16 Total')
await expect(page.locator('[aria-labelledby=tests]')).toContainText('15 Pass 2 Fail 17 Total')
// unhandled errors
await expect(page.getByTestId('unhandled-errors')).toContainText(
@@ -227,7 +227,7 @@ test.describe('ui', () => {
// match only failing files when fail filter applied
await page.getByPlaceholder('Search...').fill('')
await page.getByText(/^Fail$/, { exact: true }).click()
await page.getByText('FAIL (1)').click()
await page.getByText('FAIL (2)').click()
await expect(page.getByTestId('results-panel').getByText('fixtures/error.test.ts', { exact: true })).toBeVisible()
await expect(page.getByTestId('results-panel').getByText('fixtures/sample.test.ts', { exact: true })).toBeHidden()