[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Vladimir and committed by
GitHub
(Jan 29, 2026, 2:00 PM +0100) 20e00ef7 76c4397b

+502 -674
+5
netlify.toml
··· 26 26 status = 301 27 27 28 28 [[redirects]] 29 + from = "/config/browser" 30 + to = "/config/browser/enabled" 31 + status = 301 32 + 33 + [[redirects]] 29 34 from = "/guide/workspace" 30 35 to = "/guide/projects" 31 36 status = 301
-3
.github/workflows/ci.yml
··· 120 120 - name: Test Examples 121 121 run: pnpm run test:examples 122 122 123 - - name: Unit Test UI 124 - run: pnpm run -C packages/ui test:ui 125 - 126 123 - uses: actions/upload-artifact@v6 127 124 if: ${{ !cancelled() }} 128 125 with:
+21 -1
docs/config/api.md
··· 5 5 6 6 # api 7 7 8 - - **Type:** `boolean | number` 8 + - **Type:** `boolean | number | object` 9 9 - **Default:** `false` 10 10 - **CLI:** `--api`, `--api.port`, `--api.host`, `--api.strictPort` 11 11 12 12 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`. 13 + 14 + ## api.allowWrite <Version>4.1.0</Version> {#api-allowwrite} 15 + 16 + - **Type:** `boolean` 17 + - **Default:** `true` if not exposed to the network, `false` otherwise 18 + 19 + 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. 20 + 21 + ::: danger SECURITY ADVICE 22 + 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`. 23 + 24 + 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. 25 + ::: 26 + 27 + ## api.allowExec <Version>4.1.0</Version> {#api-allowexec} 28 + 29 + - **Type:** `boolean` 30 + - **Default:** `true` if not exposed to the network, `false` otherwise 31 + 32 + Allows running any test file via the API. See the security advice in [`api.allowWrite`](#api-allowwrite).
-626
docs/config/browser.md
··· 1 - --- 2 - title: Browser Config Reference | Config 3 - outline: deep 4 - --- 5 - 6 - # Browser Config Reference 7 - 8 - You can change the browser configuration by updating the `test.browser` field in your [config file](/config/). An example of a simple config file: 9 - 10 - ```ts [vitest.config.ts] 11 - import { defineConfig } from 'vitest/config' 12 - import { playwright } from '@vitest/browser-playwright' 13 - 14 - export default defineConfig({ 15 - test: { 16 - browser: { 17 - enabled: true, 18 - provider: playwright(), 19 - instances: [ 20 - { 21 - browser: 'chromium', 22 - setupFile: './chromium-setup.js', 23 - }, 24 - ], 25 - }, 26 - }, 27 - }) 28 - ``` 29 - 30 - Please, refer to the ["Config Reference"](/config/) article for different config examples. 31 - 32 - ::: warning 33 - _All listed options_ on this page are located within a `test` property inside the configuration: 34 - 35 - ```ts [vitest.config.js] 36 - export default defineConfig({ 37 - test: { 38 - browser: {}, 39 - }, 40 - }) 41 - ``` 42 - ::: 43 - 44 - ## browser.enabled 45 - 46 - - **Type:** `boolean` 47 - - **Default:** `false` 48 - - **CLI:** `--browser`, `--browser.enabled=false` 49 - 50 - Run all tests inside a browser by default. Note that `--browser` only works if you have at least one [`browser.instances`](#browser-instances) item. 51 - 52 - ## browser.instances 53 - 54 - - **Type:** `BrowserConfig` 55 - - **Default:** `[]` 56 - 57 - Defines multiple browser setups. Every config has to have at least a `browser` field. 58 - 59 - You can specify most of the [project options](/config/) (not marked with a <CRoot /> icon) and some of the `browser` options like `browser.testerHtmlPath`. 60 - 61 - ::: warning 62 - Every browser config inherits options from the root config: 63 - 64 - ```ts{3,9} [vitest.config.ts] 65 - export default defineConfig({ 66 - test: { 67 - setupFile: ['./root-setup-file.js'], 68 - browser: { 69 - enabled: true, 70 - testerHtmlPath: './custom-path.html', 71 - instances: [ 72 - { 73 - // will have both setup files: "root" and "browser" 74 - setupFile: ['./browser-setup-file.js'], 75 - // implicitly has "testerHtmlPath" from the root config // [!code warning] 76 - // testerHtmlPath: './custom-path.html', // [!code warning] 77 - }, 78 - ], 79 - }, 80 - }, 81 - }) 82 - ``` 83 - 84 - For more examples, refer to the ["Multiple Setups" guide](/guide/browser/multiple-setups). 85 - ::: 86 - 87 - List of available `browser` options: 88 - 89 - - [`browser.headless`](#browser-headless) 90 - - [`browser.locators`](#browser-locators) 91 - - [`browser.viewport`](#browser-viewport) 92 - - [`browser.testerHtmlPath`](#browser-testerhtmlpath) 93 - - [`browser.screenshotDirectory`](#browser-screenshotdirectory) 94 - - [`browser.screenshotFailures`](#browser-screenshotfailures) 95 - - [`browser.provider`](#browser-provider) 96 - - [`browser.detailsPanelPosition`](#browser-detailspanelposition) 97 - 98 - Under the hood, Vitest transforms these instances into separate [test projects](/api/advanced/test-project) sharing a single Vite server for better caching performance. 99 - 100 - ## browser.headless 101 - 102 - - **Type:** `boolean` 103 - - **Default:** `process.env.CI` 104 - - **CLI:** `--browser.headless`, `--browser.headless=false` 105 - 106 - Run the browser in a `headless` mode. If you are running Vitest in CI, it will be enabled by default. 107 - 108 - ## browser.isolate <Deprecated /> 109 - 110 - - **Type:** `boolean` 111 - - **Default:** the same as [`--isolate`](/config/#isolate) 112 - - **CLI:** `--browser.isolate`, `--browser.isolate=false` 113 - 114 - Run every test in a separate iframe. 115 - 116 - ::: danger DEPRECATED 117 - This option is deprecated. Use [`isolate`](/config/#isolate) instead. 118 - ::: 119 - 120 - ## browser.testerHtmlPath 121 - 122 - - **Type:** `string` 123 - 124 - 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. 125 - 126 - ## browser.api 127 - 128 - - **Type:** `number | { port?, strictPort?, host? }` 129 - - **Default:** `63315` 130 - - **CLI:** `--browser.api=63315`, `--browser.api.port=1234, --browser.api.host=example.com` 131 - 132 - 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. 133 - 134 - ## browser.provider {#browser-provider} 135 - 136 - - **Type:** `BrowserProviderOption` 137 - - **Default:** `'preview'` 138 - - **CLI:** `--browser.provider=playwright` 139 - 140 - The return value of the provider factory. You can import the factory from `@vitest/browser-<provider-name>` or make your own provider: 141 - 142 - ```ts{8-10} 143 - import { playwright } from '@vitest/browser-playwright' 144 - import { webdriverio } from '@vitest/browser-webdriverio' 145 - import { preview } from '@vitest/browser-preview' 146 - 147 - export default defineConfig({ 148 - test: { 149 - browser: { 150 - provider: playwright(), 151 - provider: webdriverio(), 152 - provider: preview(), // default 153 - }, 154 - }, 155 - }) 156 - ``` 157 - 158 - To configure how provider initializes the browser, you can pass down options to the factory function: 159 - 160 - ```ts{7-13,20-26} 161 - import { playwright } from '@vitest/browser-playwright' 162 - 163 - export default defineConfig({ 164 - test: { 165 - browser: { 166 - // shared provider options between all instances 167 - provider: playwright({ 168 - launchOptions: { 169 - slowMo: 50, 170 - channel: 'chrome-beta', 171 - }, 172 - actionTimeout: 5_000, 173 - }), 174 - instances: [ 175 - { browser: 'chromium' }, 176 - { 177 - browser: 'firefox', 178 - // overriding options only for a single instance 179 - // this will NOT merge options with the parent one 180 - provider: playwright({ 181 - launchOptions: { 182 - firefoxUserPrefs: { 183 - 'browser.startup.homepage': 'https://example.com', 184 - }, 185 - }, 186 - }) 187 - } 188 - ], 189 - }, 190 - }, 191 - }) 192 - ``` 193 - 194 - ### Custom Provider <Badge type="danger">advanced</Badge> 195 - 196 - ::: danger ADVANCED API 197 - 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. 198 - ::: 199 - 200 - ```ts 201 - export interface BrowserProvider { 202 - name: string 203 - mocker?: BrowserModuleMocker 204 - readonly initScripts?: string[] 205 - /** 206 - * @experimental opt-in into file parallelisation 207 - */ 208 - supportsParallelism: boolean 209 - getCommandsContext: (sessionId: string) => Record<string, unknown> 210 - openPage: (sessionId: string, url: string) => Promise<void> 211 - getCDPSession?: (sessionId: string) => Promise<CDPSession> 212 - close: () => Awaitable<void> 213 - } 214 - ``` 215 - 216 - ## browser.ui 217 - 218 - - **Type:** `boolean` 219 - - **Default:** `!isCI` 220 - - **CLI:** `--browser.ui=false` 221 - 222 - Should Vitest UI be injected into the page. By default, injects UI iframe during development. 223 - 224 - ## browser.detailsPanelPosition 225 - 226 - - **Type:** `'right' | 'bottom'` 227 - - **Default:** `'right'` 228 - - **CLI:** `--browser.detailsPanelPosition=bottom`, `--browser.detailsPanelPosition=right` 229 - 230 - 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. 231 - 232 - ## browser.viewport 233 - 234 - - **Type:** `{ width, height }` 235 - - **Default:** `414x896` 236 - 237 - Default iframe's viewport. 238 - 239 - ## browser.locators 240 - 241 - Options for built-in [browser locators](/api/browser/locators). 242 - 243 - ### browser.locators.testIdAttribute 244 - 245 - - **Type:** `string` 246 - - **Default:** `data-testid` 247 - 248 - Attribute used to find elements with `getByTestId` locator. 249 - 250 - ## browser.screenshotDirectory 251 - 252 - - **Type:** `string` 253 - - **Default:** `__screenshots__` in the test file directory 254 - 255 - Path to the screenshots directory relative to the `root`. 256 - 257 - ## browser.screenshotFailures 258 - 259 - - **Type:** `boolean` 260 - - **Default:** `!browser.ui` 261 - 262 - Should Vitest take screenshots if the test fails. 263 - 264 - ## browser.orchestratorScripts 265 - 266 - - **Type:** `BrowserScript[]` 267 - - **Default:** `[]` 268 - 269 - 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. 270 - 271 - The script `src` and `content` will be processed by Vite plugins. Script should be provided in the following shape: 272 - 273 - ```ts 274 - export interface BrowserScript { 275 - /** 276 - * If "content" is provided and type is "module", this will be its identifier. 277 - * 278 - * If you are using TypeScript, you can add `.ts` extension here for example. 279 - * @default `injected-${index}.js` 280 - */ 281 - id?: string 282 - /** 283 - * JavaScript content to be injected. This string is processed by Vite plugins if type is "module". 284 - * 285 - * You can use `id` to give Vite a hint about the file extension. 286 - */ 287 - content?: string 288 - /** 289 - * Path to the script. This value is resolved by Vite so it can be a node module or a file path. 290 - */ 291 - src?: string 292 - /** 293 - * If the script should be loaded asynchronously. 294 - */ 295 - async?: boolean 296 - /** 297 - * Script type. 298 - * @default 'module' 299 - */ 300 - type?: string 301 - } 302 - ``` 303 - 304 - ## browser.commands 305 - 306 - - **Type:** `Record<string, BrowserCommand>` 307 - - **Default:** `{ readFile, writeFile, ... }` 308 - 309 - Custom [commands](/api/browser/commands) that can be imported during browser tests from `vitest/browser`. 310 - 311 - ## browser.connectTimeout 312 - 313 - - **Type:** `number` 314 - - **Default:** `60_000` 315 - 316 - The timeout in milliseconds. If connection to the browser takes longer, the test suite will fail. 317 - 318 - ::: info 319 - 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. 320 - ::: 321 - 322 - ## browser.trace 323 - 324 - - **Type:** `'on' | 'off' | 'on-first-retry' | 'on-all-retries' | 'retain-on-failure' | object` 325 - - **CLI:** `--browser.trace=on`, `--browser.trace=retain-on-failure` 326 - - **Default:** `'off'` 327 - 328 - Capture a trace of your browser test runs. You can preview traces with [Playwright Trace Viewer](https://trace.playwright.dev/). 329 - 330 - This options supports the following values: 331 - 332 - - `'on'` - capture trace for all tests. (not recommended as it's performance heavy) 333 - - `'off'` - do not capture traces. 334 - - `'on-first-retry'` - capture trace only when retrying the test for the first time. 335 - - `'on-all-retries'` - capture trace on every retry of the test. 336 - - `'retain-on-failure'` - capture trace only for tests that fail. This will automatically delete traces for tests that pass. 337 - - `object` - an object with the following shape: 338 - 339 - ```ts 340 - interface TraceOptions { 341 - mode: 'on' | 'off' | 'on-first-retry' | 'on-all-retries' | 'retain-on-failure' 342 - /** 343 - * The directory where all traces will be stored. By default, Vitest 344 - * stores all traces in `__traces__` folder close to the test file. 345 - */ 346 - tracesDir?: string 347 - /** 348 - * Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview. 349 - * @default true 350 - */ 351 - screenshots?: boolean 352 - /** 353 - * If this option is true tracing will 354 - * - capture DOM snapshot on every action 355 - * - record network activity 356 - * @default true 357 - */ 358 - snapshots?: boolean 359 - } 360 - ``` 361 - 362 - ::: danger WARNING 363 - This option is supported only by the [**playwright**](/config/browser/playwright) provider. 364 - ::: 365 - 366 - ## browser.trackUnhandledErrors 367 - 368 - - **Type:** `boolean` 369 - - **Default:** `true` 370 - 371 - Enables tracking uncaught errors and exceptions so they can be reported by Vitest. 372 - 373 - If you need to hide certain errors, it is recommended to use [`onUnhandledError`](/config/#onunhandlederror) option instead. 374 - 375 - Disabling this will completely remove all Vitest error handlers, which can help debugging with the "Pause on exceptions" checkbox turned on. 376 - 377 - ## browser.expect 378 - 379 - - **Type:** `ExpectOptions` 380 - 381 - ### browser.expect.toMatchScreenshot 382 - 383 - Default options for the 384 - [`toMatchScreenshot` assertion](/api/browser/assertions.html#tomatchscreenshot). 385 - These options will be applied to all screenshot assertions. 386 - 387 - ::: tip 388 - Setting global defaults for screenshot assertions helps maintain consistency 389 - across your test suite and reduces repetition in individual tests. You can still 390 - override these defaults at the assertion level when needed for specific test cases. 391 - ::: 392 - 393 - ```ts 394 - import { defineConfig } from 'vitest/config' 395 - 396 - export default defineConfig({ 397 - test: { 398 - browser: { 399 - enabled: true, 400 - expect: { 401 - toMatchScreenshot: { 402 - comparatorName: 'pixelmatch', 403 - comparatorOptions: { 404 - threshold: 0.2, 405 - allowedMismatchedPixels: 100, 406 - }, 407 - resolveScreenshotPath: ({ arg, browserName, ext, testFileName }) => 408 - `custom-screenshots/${testFileName}/${arg}-${browserName}${ext}`, 409 - }, 410 - }, 411 - }, 412 - }, 413 - }) 414 - ``` 415 - 416 - [All options available in the `toMatchScreenshot` assertion](/api/browser/assertions#options) 417 - can be configured here. Additionally, two path resolution functions are 418 - available: `resolveScreenshotPath` and `resolveDiffPath`. 419 - 420 - #### browser.expect.toMatchScreenshot.resolveScreenshotPath 421 - 422 - - **Type:** `(data: PathResolveData) => string` 423 - - **Default output:** `` `${root}/${testFileDirectory}/${screenshotDirectory}/${testFileName}/${arg}-${browserName}-${platform}${ext}` `` 424 - 425 - A function to customize where reference screenshots are stored. The function 426 - receives an object with the following properties: 427 - 428 - - `arg: string` 429 - 430 - Path **without** extension, sanitized and relative to the test file. 431 - 432 - This comes from the arguments passed to `toMatchScreenshot`; if called 433 - without arguments this will be the auto-generated name. 434 - 435 - ```ts 436 - test('calls `onClick`', () => { 437 - expect(locator).toMatchScreenshot() 438 - // arg = "calls-onclick-1" 439 - }) 440 - 441 - expect(locator).toMatchScreenshot('foo/bar/baz.png') 442 - // arg = "foo/bar/baz" 443 - 444 - expect(locator).toMatchScreenshot('../foo/bar/baz.png') 445 - // arg = "foo/bar/baz" 446 - ``` 447 - 448 - - `ext: string` 449 - 450 - Screenshot extension, with leading dot. 451 - 452 - This can be set through the arguments passed to `toMatchScreenshot`, but 453 - the value will fall back to `'.png'` if an unsupported extension is used. 454 - 455 - - `browserName: string` 456 - 457 - The instance's browser name. 458 - 459 - - `platform: NodeJS.Platform` 460 - 461 - The value of 462 - [`process.platform`](https://nodejs.org/docs/v22.16.0/api/process.html#processplatform). 463 - 464 - - `screenshotDirectory: string` 465 - 466 - The value provided to 467 - [`browser.screenshotDirectory`](/config/browser/screenshotdirectory), 468 - if none is provided, its default value. 469 - 470 - - `root: string` 471 - 472 - Absolute path to the project's [`root`](/config/#root). 473 - 474 - - `testFileDirectory: string` 475 - 476 - Path to the test file, relative to the project's [`root`](/config/#root). 477 - 478 - - `testFileName: string` 479 - 480 - The test's filename. 481 - 482 - - `testName: string` 483 - 484 - The [`test`](/api/test)'s name, including parent 485 - [`describe`](/api/describe), sanitized. 486 - 487 - - `attachmentsDir: string` 488 - 489 - The value provided to [`attachmentsDir`](/config/#attachmentsdir), if none is 490 - provided, its default value. 491 - 492 - For example, to group screenshots by browser: 493 - 494 - ```ts 495 - resolveScreenshotPath: ({ arg, browserName, ext, root, testFileName }) => 496 - `${root}/screenshots/${browserName}/${testFileName}/${arg}${ext}` 497 - ``` 498 - 499 - #### browser.expect.toMatchScreenshot.resolveDiffPath 500 - 501 - - **Type:** `(data: PathResolveData) => string` 502 - - **Default output:** `` `${root}/${attachmentsDir}/${testFileDirectory}/${testFileName}/${arg}-${browserName}-${platform}${ext}` `` 503 - 504 - A function to customize where diff images are stored when screenshot comparisons 505 - fail. Receives the same data object as 506 - [`resolveScreenshotPath`](#browser-expect-tomatchscreenshot-resolvescreenshotpath). 507 - 508 - For example, to store diffs in a subdirectory of attachments: 509 - 510 - ```ts 511 - resolveDiffPath: ({ arg, attachmentsDir, browserName, ext, root, testFileName }) => 512 - `${root}/${attachmentsDir}/screenshot-diffs/${testFileName}/${arg}-${browserName}${ext}` 513 - ``` 514 - 515 - #### browser.expect.toMatchScreenshot.comparators 516 - 517 - - **Type:** `Record<string, Comparator>` 518 - 519 - Register custom screenshot comparison algorithms, like [SSIM](https://en.wikipedia.org/wiki/Structural_similarity_index_measure) or other perceptual similarity metrics. 520 - 521 - To create a custom comparator, you need to register it in your config. If using TypeScript, declare its options in the `ScreenshotComparatorRegistry` interface. 522 - 523 - ```ts 524 - import { defineConfig } from 'vitest/config' 525 - 526 - // 1. Declare the comparator's options type 527 - declare module 'vitest/browser' { 528 - interface ScreenshotComparatorRegistry { 529 - myCustomComparator: { 530 - sensitivity?: number 531 - ignoreColors?: boolean 532 - } 533 - } 534 - } 535 - 536 - // 2. Implement the comparator 537 - export default defineConfig({ 538 - test: { 539 - browser: { 540 - expect: { 541 - toMatchScreenshot: { 542 - comparators: { 543 - myCustomComparator: async ( 544 - reference, 545 - actual, 546 - { 547 - createDiff, // always provided by Vitest 548 - sensitivity = 0.01, 549 - ignoreColors = false, 550 - } 551 - ) => { 552 - // ...algorithm implementation 553 - return { pass, diff, message } 554 - }, 555 - }, 556 - }, 557 - }, 558 - }, 559 - }, 560 - }) 561 - ``` 562 - 563 - Then use it in your tests: 564 - 565 - ```ts 566 - await expect(locator).toMatchScreenshot({ 567 - comparatorName: 'myCustomComparator', 568 - comparatorOptions: { 569 - sensitivity: 0.08, 570 - ignoreColors: true, 571 - }, 572 - }) 573 - ``` 574 - 575 - **Comparator Function Signature:** 576 - 577 - ```ts 578 - type Comparator<Options> = ( 579 - reference: { 580 - metadata: { height: number; width: number } 581 - data: TypedArray 582 - }, 583 - actual: { 584 - metadata: { height: number; width: number } 585 - data: TypedArray 586 - }, 587 - options: { 588 - createDiff: boolean 589 - } & Options 590 - ) => Promise<{ 591 - pass: boolean 592 - diff: TypedArray | null 593 - message: string | null 594 - }> | { 595 - pass: boolean 596 - diff: TypedArray | null 597 - message: string | null 598 - } 599 - ``` 600 - 601 - 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: 602 - 603 - - **4 bytes per pixel**: red, green, blue, alpha (from `0` to `255` each) 604 - - **Row-major order**: pixels are stored left-to-right, top-to-bottom 605 - - **Total length**: `width × height × 4` bytes 606 - - **Alpha channel**: always present. Images without transparency have alpha values set to `255` (fully opaque) 607 - 608 - ::: tip Performance Considerations 609 - 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. 610 - 611 - **Respect this flag to keep your tests fast**. 612 - ::: 613 - 614 - ::: warning Handle Missing Options 615 - The `options` parameter in `toMatchScreenshot()` is optional, so users might not provide all your comparator options. Always make them optional with default values: 616 - 617 - ```ts 618 - myCustomComparator: ( 619 - reference, 620 - actual, 621 - { createDiff, threshold = 0.1, maxDiff = 100 }, 622 - ) => { 623 - // ...comparison logic 624 - } 625 - ``` 626 - :::
+4
docs/config/ui.md
··· 14 14 ::: warning 15 15 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. 16 16 ::: 17 + 18 + ::: danger SECURITY ADVICE 19 + 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. 20 + :::
+28
docs/guide/cli-generated.md
··· 70 70 71 71 Set to true to exit if port is already in use, instead of automatically trying the next available port 72 72 73 + ### api.allowExec 74 + 75 + - **CLI:** `--api.allowExec` 76 + - **Config:** [api.allowExec](/config/api#api-allowexec) 77 + 78 + Allow API to execute code. (Be careful when enabling this option in untrusted environments) 79 + 80 + ### api.allowWrite 81 + 82 + - **CLI:** `--api.allowWrite` 83 + - **Config:** [api.allowWrite](/config/api#api-allowwrite) 84 + 85 + Allow API to edit files. (Be careful when enabling this option in untrusted environments) 86 + 73 87 ### silent 74 88 75 89 - **CLI:** `--silent [value]` ··· 331 345 - **Config:** [browser.api.strictPort](/config/browser/api#api-strictport) 332 346 333 347 Set to true to exit if port is already in use, instead of automatically trying the next available port 348 + 349 + ### browser.api.allowExec 350 + 351 + - **CLI:** `--browser.api.allowExec` 352 + - **Config:** [browser.api.allowExec](/config/browser/api#api-allowexec) 353 + 354 + Allow API to execute code. (Be careful when enabling this option in untrusted environments) 355 + 356 + ### browser.api.allowWrite 357 + 358 + - **CLI:** `--browser.api.allowWrite` 359 + - **Config:** [browser.api.allowWrite](/config/browser/api#api-allowwrite) 360 + 361 + Allow API to edit files. (Be careful when enabling this option in untrusted environments) 334 362 335 363 ### browser.isolate 336 364
+2
test/core/vite.config.ts
··· 65 65 test: { 66 66 api: { 67 67 port: 3023, 68 + allowExec: false, 69 + allowWrite: false, 68 70 }, 69 71 name: 'core', 70 72 includeSource: [
+2
docs/api/browser/commands.md
··· 17 17 18 18 ::: tip 19 19 This API follows [`server.fs`](https://vitejs.dev/config/server-options.html#server-fs-allow) limitations for security reasons. 20 + 21 + If [`browser.api.allowWrite`](/config/browser/api) or [`api.allowWrite`](/config/api#api-allowwrite) are disabled, `writeFile` and `removeFile` functions won't do anything. 20 22 ::: 21 23 22 24 ```ts
+1 -1
docs/api/browser/locators.md
··· 7 7 8 8 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. 9 9 10 - 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. 10 + 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. 11 11 12 12 ::: tip 13 13 This page covers API usage. To better understand locators and their usage, read [Playwright's "Locators" documentation](https://playwright.dev/docs/locators).
+17 -1
docs/config/browser/api.md
··· 5 5 6 6 # browser.api 7 7 8 - - **Type:** `number | { port?, strictPort?, host? }` 8 + - **Type:** `number | object` 9 9 - **Default:** `63315` 10 10 - **CLI:** `--browser.api=63315`, `--browser.api.port=1234, --browser.api.host=example.com` 11 11 12 12 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. 13 + 14 + ## api.allowWrite <Version>4.1.0</Version> {#api-allowwrite} 15 + 16 + - **Type:** `boolean` 17 + - **Default:** `true` if not exposed to the network, `false` otherwise 18 + 19 + 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)). 20 + 21 + 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. 22 + 23 + ## api.allowExec <Version>4.1.0</Version> {#api-allowexec} 24 + 25 + - **Type:** `boolean` 26 + - **Default:** `true` if not exposed to the network, `false` otherwise 27 + 28 + 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.
+1
packages/utils/src/source-map.ts
··· 35 35 /node:\w+/, 36 36 /__vitest_test__/, 37 37 /__vitest_browser__/, 38 + '/@id/__x00__vitest/browser', 38 39 /\/deps\/vitest_/, 39 40 ] 40 41
+70
test/browser/specs/errors.test.ts
··· 66 66 `The iframe for "${fs.resolveFile('./iframe-reload.test.ts')}" was reloaded during a test.`, 67 67 ) 68 68 }) 69 + 70 + test('cannot use fs commands if write is disabled', async () => { 71 + const { stderr, fs } = await runInlineBrowserTests({ 72 + 'fs-commands.test.ts': ` 73 + import { test, expect, recordArtifact } from 'vitest' 74 + import { commands } from 'vitest/browser' 75 + 76 + test.describe('fs security', () => { 77 + test('fs writeFile throws an error', async () => { 78 + await commands.writeFile('/test-file.txt', 'Hello World') 79 + }) 80 + 81 + test('fs removeFile throws an error', async () => { 82 + await commands.removeFile('/test-file.txt') 83 + }) 84 + 85 + test('doesnt write attachment to disk', async ({ annotate }) => { 86 + await annotate('test-attachment', { data: 'Test Attachment', path: '/test-attachment.txt' }) 87 + }) 88 + 89 + test('cannot record attachments inside artifact', async ({ task }) => { 90 + await recordArtifact(task, { 91 + attachments: [{ data: 'Artifact Attachment', path: '/artifact-attachment.txt' }], 92 + type: 'my-custom', 93 + }) 94 + }) 95 + 96 + test('snapshot saves are not saved', () => { 97 + expect('snapshot content').toMatchSnapshot() 98 + }) 99 + }) 100 + `, 101 + './__snapshots__/basic.test.js.snap': `// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html`, 102 + 'basic.test.js': ` 103 + import { test } from 'vitest' 104 + 105 + test('basic test', () => { 106 + expect(1 + 1).toBe(2) 107 + }) 108 + `, 109 + }, { 110 + browser: { 111 + api: { 112 + allowExec: false, 113 + allowWrite: false, 114 + }, 115 + }, 116 + $cliOptions: { 117 + update: true, 118 + }, 119 + }) 120 + 121 + const errors = stderr.split('\n').filter(line => line.includes('Cannot modify file "/test-file.txt".')) 122 + expect(errors).toHaveLength(2 * instances.length) 123 + 124 + expect(stderr).toContain( 125 + `Cannot save snapshot file "${fs.resolveFile('./__snapshots__/fs-commands.test.ts.snap')}". File writing is disabled because server is exposed to the internet`, 126 + ) 127 + expect(stderr).toContain( 128 + `Cannot remove snapshot file "${fs.resolveFile('./__snapshots__/basic.test.js.snap')}". File writing is disabled because server is exposed to the internet`, 129 + ) 130 + 131 + // we don't throw an error if cannot write attachment, just warn 132 + expect(stderr).toContain( 133 + 'Cannot record annotation attachment because file writing is disabled', 134 + ) 135 + expect(stderr).toContain( 136 + 'Cannot record attachments ("/artifact-attachment.txt") because file writing is disabled, removing attachments from artifact "my-custom".', 137 + ) 138 + })
+59
test/config/test/override.test.ts
··· 27 27 }) 28 28 expect(c.vite.config.server.middlewareMode).toBe(true) 29 29 expect(c.config.api).toEqual({ 30 + allowExec: true, 31 + allowWrite: true, 30 32 middlewareMode: true, 31 33 token: expect.any(String), 32 34 }) ··· 42 44 expect(c.vite.config.server.port).toBe(4321) 43 45 expect(c.config.api).toEqual({ 44 46 port: 4321, 47 + allowWrite: true, 48 + allowExec: true, 45 49 token: expect.any(String), 46 50 }) 47 51 }) ··· 50 54 const c = await vitest({ isolate: false }, {}) 51 55 expect(c.config.isolate).toBe(false) 52 56 expect(c.config.browser.isolate).toBe(false) 57 + }) 58 + 59 + it('allowWrite and allowExec default to true when not exposed to network', async () => { 60 + const c = await config({ api: { port: 5555 } }, {}) 61 + expect(c.api.allowWrite).toBe(true) 62 + expect(c.api.allowExec).toBe(true) 63 + }) 64 + 65 + it('allowWrite and allowExec default to true for localhost', async () => { 66 + const c = await config({ api: { port: 5555, host: 'localhost' } }, {}) 67 + expect(c.api.allowWrite).toBe(true) 68 + expect(c.api.allowExec).toBe(true) 69 + }) 70 + 71 + it('allowWrite and allowExec default to true for 127.0.0.1', async () => { 72 + const c = await config({ api: { port: 5555, host: '127.0.0.1' } }, {}) 73 + expect(c.api.allowWrite).toBe(true) 74 + expect(c.api.allowExec).toBe(true) 75 + }) 76 + 77 + it('allowWrite and allowExec default to false when exposed to network', async () => { 78 + const c = await config({ api: { port: 5555, host: '0.0.0.0' } }, {}) 79 + expect(c.api.allowWrite).toBe(false) 80 + expect(c.api.allowExec).toBe(false) 81 + }) 82 + 83 + it('allowWrite and allowExec can be explicitly overridden when exposed to network', async () => { 84 + const c = await config({ api: { port: 5555, host: '0.0.0.0', allowWrite: true, allowExec: true } }, {}) 85 + expect(c.api.allowWrite).toBe(true) 86 + expect(c.api.allowExec).toBe(true) 87 + }) 88 + 89 + it('allowWrite and allowExec can be explicitly disabled', async () => { 90 + const c = await config({ api: { port: 5555, allowWrite: false, allowExec: false } }, {}) 91 + expect(c.api.allowWrite).toBe(false) 92 + expect(c.api.allowExec).toBe(false) 93 + }) 94 + 95 + it('browser.api inherits allowWrite and allowExec from api', async () => { 96 + const c = await config({ api: { port: 5555, allowWrite: false, allowExec: false } }, {}) 97 + expect(c.browser.api.allowWrite).toBe(false) 98 + expect(c.browser.api.allowExec).toBe(false) 99 + }) 100 + 101 + it('browser.api can override inherited allowWrite and allowExec', async () => { 102 + const c = await config({ 103 + api: { port: 5555, allowWrite: false, allowExec: false }, 104 + browser: { api: { allowWrite: true, allowExec: true } }, 105 + }, { 106 + browser: {}, 107 + }) 108 + expect(c.api.allowWrite).toBe(false) 109 + expect(c.api.allowExec).toBe(false) 110 + expect(c.browser.api.allowWrite).toBe(true) 111 + expect(c.browser.api.allowExec).toBe(true) 53 112 }) 54 113 }) 55 114
+5
test/ui/fixtures/snapshot.test.ts
··· 1 + import { expect, test } from 'vitest'; 2 + 3 + test('wrong snapshot', () => { 4 + expect(1).toMatchInlineSnapshot(`2`) 5 + })
+1 -1
test/ui/test/html-report.spec.ts
··· 66 66 await page.goto(pageUrl) 67 67 68 68 // dashboard 69 - await expect(page.locator('[aria-labelledby=tests]')).toContainText('15 Pass 1 Fail 16 Total') 69 + await expect(page.locator('[aria-labelledby=tests]')).toContainText('15 Pass 2 Fail 17 Total') 70 70 71 71 // unhandled errors 72 72 await expect(page.getByTestId('unhandled-errors')).toContainText(
+70
test/ui/test/ui-security.spec.ts
··· 1 + import type { Vitest } from 'vitest/node' 2 + import { Writable } from 'node:stream' 3 + import { expect, test } from '@playwright/test' 4 + import { startVitest } from 'vitest/node' 5 + 6 + const port = 9002 7 + const pageUrl = `http://localhost:${port}/__vitest__/` 8 + 9 + test.describe('ui', () => { 10 + let vitest: Vitest | undefined 11 + 12 + test.beforeAll(async () => { 13 + // silence Vitest logs 14 + const stdout = new Writable({ write: (_, __, callback) => callback() }) 15 + const stderr = new Writable({ write: (_, __, callback) => callback() }) 16 + vitest = await startVitest('test', [], { 17 + watch: true, 18 + ui: true, 19 + open: false, 20 + api: { 21 + port, 22 + allowExec: false, 23 + allowWrite: false, 24 + }, 25 + reporters: [], 26 + }, {}, { 27 + stdout, 28 + stderr, 29 + }) 30 + expect(vitest).toBeDefined() 31 + }) 32 + 33 + test.afterAll(async () => { 34 + await vitest?.close() 35 + }) 36 + 37 + test('cannot execute files from the ui', async ({ page }) => { 38 + await page.goto(pageUrl) 39 + 40 + await expect(page.getByTestId('btn-run-all')).toBeDisabled() 41 + 42 + const item = page.getByTestId('explorer-item').nth(0) 43 + await item.hover() 44 + await expect(item.getByTestId('btn-run-test')).toBeDisabled() 45 + 46 + await page.getByPlaceholder('Search...').fill('snapshot') 47 + 48 + const snapshotItem = page.getByTestId('explorer-item').filter({ hasText: 'snapshot.test.ts' }) 49 + await snapshotItem.hover() 50 + await expect(snapshotItem.getByTestId('btn-fix-snapshot')).not.toBeVisible() 51 + }) 52 + 53 + test('cannot write files', async ({ page }) => { 54 + await page.goto(pageUrl) 55 + 56 + const item = page.getByTestId('explorer-item').nth(0) 57 + await item.hover() 58 + await item.getByTestId('btn-open-details').click() 59 + 60 + await page.getByText('Code').click() 61 + 62 + const editor = page.getByTestId('btn-code') 63 + await expect(editor).toBeVisible() 64 + 65 + await editor.click() 66 + await page.keyboard.type('\n// some comment') 67 + 68 + await expect(editor).not.toContainText('// some comment') 69 + }) 70 + })
+2 -2
test/ui/test/ui.spec.ts
··· 70 70 await page.goto(pageUrl) 71 71 72 72 // dashboard 73 - await expect(page.locator('[aria-labelledby=tests]')).toContainText('15 Pass 1 Fail 16 Total') 73 + await expect(page.locator('[aria-labelledby=tests]')).toContainText('15 Pass 2 Fail 17 Total') 74 74 75 75 // unhandled errors 76 76 await expect(page.getByTestId('unhandled-errors')).toContainText( ··· 227 227 // match only failing files when fail filter applied 228 228 await page.getByPlaceholder('Search...').fill('') 229 229 await page.getByText(/^Fail$/, { exact: true }).click() 230 - await page.getByText('FAIL (1)').click() 230 + await page.getByText('FAIL (2)').click() 231 231 await expect(page.getByTestId('results-panel').getByText('fixtures/error.test.ts', { exact: true })).toBeVisible() 232 232 await expect(page.getByTestId('results-panel').getByText('fixtures/sample.test.ts', { exact: true })).toBeHidden() 233 233
+42 -4
packages/browser/src/node/rpc.ts
··· 12 12 import { createBirpc } from 'birpc' 13 13 import { parse, stringify } from 'flatted' 14 14 import { dirname, join, resolve } from 'pathe' 15 - import { createDebugger, isFileServingAllowed, isValidApiRequest } from 'vitest/node' 15 + import { createDebugger, isFileLoadingAllowed, isValidApiRequest } from 'vitest/node' 16 16 import { WebSocketServer } from 'ws' 17 17 18 18 const debug = createDebugger('vitest:browser:api') ··· 113 113 } 114 114 115 115 function checkFileAccess(path: string) { 116 - if (!isFileServingAllowed(path, vite)) { 116 + if (!isFileLoadingAllowed(vite.config, path)) { 117 117 throw new Error( 118 118 `Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`, 119 119 ) 120 120 } 121 + } 122 + 123 + function canWrite(project: TestProject) { 124 + return ( 125 + project.config.browser.api.allowWrite 126 + && project.vitest.config.browser.api.allowWrite 127 + && project.config.api.allowWrite 128 + && project.vitest.config.api.allowWrite 129 + ) 121 130 } 122 131 123 132 function setupClient(project: TestProject, rpcId: string, ws: WebSocket) { ··· 152 161 } 153 162 }, 154 163 async onTaskArtifactRecord(id, artifact) { 164 + if (!canWrite(project)) { 165 + if (artifact.type === 'internal:annotation' && artifact.annotation.attachment) { 166 + artifact.annotation.attachment = undefined 167 + vitest.logger.error( 168 + `[vitest] Cannot record annotation attachment because file writing is disabled. See https://vitest.dev/config/browser/api.`, 169 + ) 170 + } 171 + // remove attachments if cannot write 172 + if (artifact.attachments?.length) { 173 + const attachments = artifact.attachments.map(n => n.path).filter(r => !!r).join('", "') 174 + artifact.attachments = [] 175 + vitest.logger.error( 176 + `[vitest] Cannot record attachments ("${attachments}") because file writing is disabled, removing attachments from artifact "${artifact.type}". See https://vitest.dev/config/browser/api.`, 177 + ) 178 + } 179 + } 180 + 155 181 return vitest._testRun.recordArtifact(id, artifact) 156 182 }, 157 183 async onTaskUpdate(method, packs, events) { ··· 193 219 }, 194 220 async saveSnapshotFile(id, content) { 195 221 checkFileAccess(id) 222 + if (!canWrite(project)) { 223 + vitest.logger.error( 224 + `[vitest] Cannot save snapshot file "${id}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/browser/api.`, 225 + ) 226 + return 227 + } 196 228 await fs.mkdir(dirname(id), { recursive: true }) 197 - return fs.writeFile(id, content, 'utf-8') 229 + await fs.writeFile(id, content, 'utf-8') 198 230 }, 199 231 async removeSnapshotFile(id) { 200 232 checkFileAccess(id) 233 + if (!canWrite(project)) { 234 + vitest.logger.error( 235 + `[vitest] Cannot remove snapshot file "${id}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/browser/api.`, 236 + ) 237 + return 238 + } 201 239 if (!existsSync(id)) { 202 240 throw new Error(`Snapshot file "${id}" does not exist.`) 203 241 } 204 - return fs.unlink(id) 242 + await fs.unlink(id) 205 243 }, 206 244 getBrowserFileSourceMap(id) { 207 245 const mod = globalServer.vite.moduleGraph.getModuleById(id)
+2 -2
packages/ui/client/components/ModuleGraphImportBreakdown.vue
··· 25 25 const imports = computed(() => { 26 26 const file = currentModule.value 27 27 const importDurations = file?.importDurations 28 - if (!importDurations) { 28 + const root = config.value.root 29 + if (!importDurations || !root) { 29 30 return [] 30 31 } 31 32 32 - const root = config.value.root 33 33 const allImports: ImportEntry[] = [] 34 34 for (const filePath in importDurations) { 35 35 const duration = importDurations[filePath]
+8 -1
packages/ui/client/components/ModuleTransformResultView.vue
··· 96 96 function buildShadowImportsHtml(imports: Experimental.UntrackedModuleDefinitionDiagnostic[]) { 97 97 const shadowImportsDiv = document.createElement('div') 98 98 shadowImportsDiv.classList.add('mb-5') 99 + const root = config.value.root 100 + if (!root) { 101 + return 102 + } 99 103 100 104 imports.forEach(({ resolvedId, totalTime, external }) => { 101 105 const importDiv = document.createElement('div') 102 106 importDiv.append(document.createTextNode('import ')) 103 107 104 108 const sourceDiv = document.createElement('span') 105 - const url = relative(config.value.root, resolvedId) 109 + const url = relative(root, resolvedId) 106 110 sourceDiv.textContent = `"/${url}"` 107 111 sourceDiv.className = 'hover:underline decoration-gray cursor-pointer select-none' 108 112 importDiv.append(sourceDiv) ··· 152 156 153 157 if (untrackedModules?.length) { 154 158 const importDiv = buildShadowImportsHtml(untrackedModules) 159 + if (!importDiv) { 160 + return 161 + } 155 162 widgetElements.push(importDiv) 156 163 lineWidgets.push(codemirror.addLineWidget(0, importDiv, { above: true })) 157 164 }
+16 -4
packages/ui/client/components/Navigation.vue
··· 3 3 import { Tooltip as VueTooltip } from 'floating-vue' 4 4 import { computed, nextTick } from 'vue' 5 5 import { isDark, toggleDark } from '~/composables' 6 - import { client, isReport, runAll, runFiles } from '~/composables/client' 6 + import { client, config, isReport, runAll, runFiles } from '~/composables/client' 7 7 import { explorerTree } from '~/composables/explorer' 8 8 import { initialized, shouldShowExpandAll } from '~/composables/explorer/state' 9 9 import { ··· 26 26 const toggleMode = computed(() => isDark.value ? 'light' : 'dark') 27 27 28 28 async function onRunAll(files?: RunnerTestFile[]) { 29 + if (config.value.api?.allowExec === false) { 30 + return 31 + } 32 + 29 33 if (coverageEnabled.value) { 30 34 disableCoverage.value = true 31 35 await nextTick() ··· 48 52 49 53 function expandTests() { 50 54 explorerTree.expandAllNodes() 55 + } 56 + 57 + function getRerunTooltip(filteredFiles: RunnerTestFile[] | undefined) { 58 + if (config.value.api?.allowExec === false) { 59 + return 'Cannot run tests when `api.allowExec` is `false`. Did you expose UI to the internet?' 60 + } 61 + return filteredFiles ? (filteredFiles.length === 0 ? 'No test to run (clear filter)' : 'Rerun filtered') : 'Rerun all' 51 62 } 52 63 </script> 53 64 ··· 113 124 @click="showCoverage()" 114 125 /> 115 126 <IconButton 116 - v-if="(explorerTree.summary.failedSnapshot && !isReport)" 127 + v-if="(explorerTree.summary.failedSnapshot && !isReport && config.api?.allowExec && config.api?.allowWrite)" 117 128 v-tooltip.bottom="'Update all failed snapshot(s)'" 118 129 icon="i-carbon:result-old" 119 130 :disabled="!explorerTree.summary.failedSnapshotEnabled" ··· 121 132 /> 122 133 <IconButton 123 134 v-if="!isReport" 124 - v-tooltip.bottom="filteredFiles ? (filteredFiles.length === 0 ? 'No test to run (clear filter)' : 'Rerun filtered') : 'Rerun all'" 125 - :disabled="filteredFiles?.length === 0" 135 + v-tooltip.bottom="getRerunTooltip(filteredFiles)" 136 + :disabled="filteredFiles?.length === 0 || !config.api?.allowExec" 126 137 icon="i-carbon:play" 138 + data-testid="btn-run-all" 127 139 @click="onRunAll(filteredFiles)" 128 140 /> 129 141 <IconButton
+2 -2
packages/ui/client/composables/module-graph.ts
··· 65 65 return defineGraph({}) 66 66 } 67 67 68 - const externalizedNodes = !config.value.experimental.viteModuleRunner 68 + const externalizedNodes = !config.value.experimental?.viteModuleRunner 69 69 ? defineExternalModuleNodes([...data.inlined, ...data.externalized]) 70 70 : defineExternalModuleNodes(data.externalized) 71 71 const inlinedNodes 72 - = !config.value.experimental.viteModuleRunner 72 + = !config.value.experimental?.viteModuleRunner 73 73 ? [] 74 74 : data.inlined.map(module => 75 75 defineInlineModuleNode(module, module === rootPath),
+1 -1
packages/ui/client/composables/navigation.ts
··· 17 17 export const coverageEnabled = computed(() => { 18 18 return ( 19 19 coverageConfigured.value 20 - && !!coverage.value.htmlReporter 20 + && !!coverage.value?.htmlReporter 21 21 ) 22 22 }) 23 23 export const mainSizes = useLocalStorage<[left: number, right: number]>(
+17 -3
packages/vitest/src/api/setup.ts
··· 59 59 function setupClient(ws: WebSocket) { 60 60 const rpc = createBirpc<WebSocketEvents, WebSocketHandlers>( 61 61 { 62 - async onTaskUpdate(packs, events) { 63 - await ctx._testRun.updated(packs, events) 64 - }, 65 62 getFiles() { 66 63 return ctx.state.getFiles() 67 64 }, ··· 80 77 `Test file "${id}" was not registered, so it cannot be updated using the API.`, 81 78 ) 82 79 } 80 + // silently ignore write attempts if not allowed 81 + if (!ctx.config.api.allowWrite) { 82 + return 83 + } 83 84 return fs.writeFile(id, content, 'utf-8') 84 85 }, 85 86 async rerun(files, resetTestNamePattern) { 87 + // silently ignore exec attempts if not allowed 88 + if (!ctx.config.api.allowExec) { 89 + return 90 + } 86 91 await ctx.rerunFiles(files, undefined, true, resetTestNamePattern) 87 92 }, 88 93 async rerunTask(id) { 94 + // silently ignore exec attempts if not allowed 95 + if (!ctx.config.api.allowExec) { 96 + return 97 + } 89 98 await ctx.rerunTask(id) 90 99 }, 91 100 getConfig() { ··· 150 159 return getModuleGraph(ctx, project, id, browser) 151 160 }, 152 161 async updateSnapshot(file?: File) { 162 + // silently ignore exec/write attempts if not allowed 163 + // this function both executes the code and write snapshots 164 + if (!ctx.config.api.allowExec || !ctx.config.api.allowWrite) { 165 + return 166 + } 153 167 if (!file) { 154 168 await ctx.updateSnapshot() 155 169 }
-1
packages/vitest/src/api/types.ts
··· 36 36 } 37 37 38 38 export interface WebSocketHandlers { 39 - onTaskUpdate: (packs: TaskResultPack[], events: TaskEventPack[]) => void 40 39 getFiles: () => File[] 41 40 getTestFiles: () => Promise<SerializedTestSpecification[]> 42 41 getPaths: () => string[]
+4
packages/vitest/src/runtime/config.ts
··· 76 76 showDiff?: boolean 77 77 truncateThreshold?: number 78 78 } | undefined 79 + api: { 80 + allowExec: boolean | undefined 81 + allowWrite: boolean | undefined 82 + } 79 83 diff: string | SerializedDiffOptions | undefined 80 84 retry: SerializableRetry 81 85 includeTaskLocation: boolean | undefined
+5
test/cli/fixtures/basic/basic.test.ts
··· 1 + import { expect, test } from 'vitest'; 2 + 3 + test('basic test', () => { 4 + expect(1 + 1).toBe(2) 5 + })
+22
test/cli/test/config/browser-configs.test.ts
··· 1061 1061 expect(stdout).toContain('✓ |chromium| browser-custom.test.ts') 1062 1062 expect(exitCode).toBe(0) 1063 1063 }) 1064 + 1065 + test('show a warning if host is exposed', async () => { 1066 + const { stderr } = await runVitest({ 1067 + config: false, 1068 + root: './fixtures/basic', 1069 + reporters: [ 1070 + { 1071 + onInit() { 1072 + throw new Error('stop') 1073 + }, 1074 + }, 1075 + ], 1076 + browser: { 1077 + api: { 1078 + host: 'custom-host', 1079 + }, 1080 + }, 1081 + }) 1082 + expect(stderr).toContain( 1083 + '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.', 1084 + ) 1085 + })
+16 -7
packages/browser/src/node/commands/fs.ts
··· 3 3 import fs, { promises as fsp } from 'node:fs' 4 4 import { basename, dirname, resolve } from 'node:path' 5 5 import mime from 'mime/lite' 6 - import { isFileServingAllowed } from 'vitest/node' 6 + import { isFileLoadingAllowed } from 'vitest/node' 7 + import { slash } from '../utils' 7 8 8 9 function assertFileAccess(path: string, project: TestProject) { 9 10 if ( 10 - !isFileServingAllowed(path, project.vite) 11 - && !isFileServingAllowed(path, project.vitest.vite) 11 + !isFileLoadingAllowed(project.vite.config, path) 12 + && !isFileLoadingAllowed(project.vitest.vite.config, path) 12 13 ) { 13 14 throw new Error( 14 15 `Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`, ··· 16 17 } 17 18 } 18 19 20 + function assertWrite(path: string, project: TestProject) { 21 + if (!project.config.browser.api.allowWrite || !project.vitest.config.api.allowWrite) { 22 + 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.`) 23 + } 24 + } 25 + 19 26 export const readFile: BrowserCommand< 20 27 Parameters<BrowserCommands['readFile']> 21 28 > = async ({ project }, path, options = {}) => { 22 29 const filepath = resolve(project.config.root, path) 23 - assertFileAccess(filepath, project) 30 + assertFileAccess(slash(filepath), project) 24 31 // never return a Buffer 25 32 if (typeof options === 'object' && !options.encoding) { 26 33 options.encoding = 'utf-8' ··· 31 38 export const writeFile: BrowserCommand< 32 39 Parameters<BrowserCommands['writeFile']> 33 40 > = async ({ project }, path, data, options) => { 41 + assertWrite(path, project) 34 42 const filepath = resolve(project.config.root, path) 35 - assertFileAccess(filepath, project) 43 + assertFileAccess(slash(filepath), project) 36 44 const dir = dirname(filepath) 37 45 if (!fs.existsSync(dir)) { 38 46 await fsp.mkdir(dir, { recursive: true }) ··· 43 51 export const removeFile: BrowserCommand< 44 52 Parameters<BrowserCommands['removeFile']> 45 53 > = async ({ project }, path) => { 54 + assertWrite(path, project) 46 55 const filepath = resolve(project.config.root, path) 47 - assertFileAccess(filepath, project) 56 + assertFileAccess(slash(filepath), project) 48 57 await fsp.rm(filepath) 49 58 } 50 59 51 60 export const _fileInfo: BrowserCommand<[path: string, encoding: BufferEncoding]> = async ({ project }, path, encoding) => { 52 61 const filepath = resolve(project.config.root, path) 53 - assertFileAccess(filepath, project) 62 + assertFileAccess(slash(filepath), project) 54 63 const content = await fsp.readFile(filepath, encoding || 'base64') 55 64 return { 56 65 content,
+6 -2
packages/ui/client/components/explorer/ExplorerItem.vue
··· 3 3 import type { TaskTreeNodeType } from '~/composables/explorer/types' 4 4 import { Tooltip as VueTooltip } from 'floating-vue' 5 5 import { computed, nextTick } from 'vue' 6 - import { client, isReport, runFiles, runTask } from '~/composables/client' 6 + import { client, config, isReport, runFiles, runTask } from '~/composables/client' 7 7 import { showTaskSource } from '~/composables/codemirror' 8 8 import { explorerTree } from '~/composables/explorer' 9 9 import { hasFailedSnapshot } from '~/composables/explorer/collector' ··· 121 121 }) 122 122 123 123 const runButtonTitle = computed(() => { 124 + if (config.value.api?.allowExec === false) { 125 + return 'Cannot run tests when `api.allowExec` is `false`. Did you expose UI to the internet?' 126 + } 124 127 return type === 'file' 125 128 ? 'Run current file' 126 129 : type === 'suite' ··· 237 240 <div :style="{ background: tagsBgGradient }" class="w-[0.9rem] h-[0.9rem]" rounded-full /> 238 241 </div> --> 239 242 <IconAction 240 - v-if="!isReport && failedSnapshot" 243 + v-if="!isReport && failedSnapshot && config.api?.allowExec && config.api?.allowWrite" 241 244 v-tooltip.bottom="'Fix failed snapshot(s)'" 242 245 data-testid="btn-fix-snapshot" 243 246 title="Fix failed snapshot(s)" ··· 274 277 :title="runButtonTitle" 275 278 icon="i-carbon:play-filled-alt" 276 279 text-green5 280 + :disabled="config.api?.allowExec === false" 277 281 @click.prevent.stop="onRun(task)" 278 282 /> 279 283 </div>
+2 -2
packages/ui/client/components/views/ViewEditor.vue
··· 6 6 import { createTooltip, destroyTooltip } from 'floating-vue' 7 7 import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue' 8 8 import { getAttachmentUrl, sanitizeFilePath } from '~/composables/attachments' 9 - import { client, isReport } from '~/composables/client' 9 + import { client, config, isReport } from '~/composables/client' 10 10 import { finished } from '~/composables/client/state' 11 11 import { codemirrorRef } from '~/composables/codemirror' 12 12 import { openInEditor } from '~/composables/error' ··· 385 385 ref="editor" 386 386 v-model="code" 387 387 h-full 388 - v-bind="{ lineNumbers: true, readOnly: isReport, saving }" 388 + v-bind="{ lineNumbers: true, readOnly: isReport || !config.api?.allowWrite, saving }" 389 389 :mode="ext" 390 390 data-testid="code-mirror" 391 391 @save="onSave"
+2 -2
packages/ui/client/components/views/ViewModuleGraph.vue
··· 44 44 const breakdownIconClass = computed(() => { 45 45 let textClass = '' 46 46 const importDurations = currentModule.value?.importDurations 47 - if (!importDurations) { 47 + const thresholds = config.value.experimental?.importDurations.thresholds 48 + if (!importDurations || !thresholds) { 48 49 return textClass 49 50 } 50 - const thresholds = config.value.experimental.importDurations.thresholds 51 51 for (const moduleId in importDurations) { 52 52 const { totalTime } = importDurations[moduleId] 53 53 if (totalTime >= thresholds.danger) {
+1 -1
packages/ui/client/components/views/ViewReport.vue
··· 111 111 > 112 112 <pre v-html="task.result.htmlError" /> 113 113 </div> 114 - <template v-else-if="task.result?.errors"> 114 + <template v-else-if="task.result?.errors && config.root"> 115 115 <ViewReportError 116 116 v-for="(error, idx) of task.result.errors" 117 117 :key="idx"
+3 -2
packages/ui/client/components/views/ViewTestReport.vue
··· 39 39 } = useScreenshot() 40 40 41 41 function getLocationString(location: TestArtifactLocation) { 42 - const path = relative(config.value.root, location.file) 42 + const root = config.value.root 43 + const path = root ? relative(root, location.file) : location.file 43 44 return `${path}:${location.line}:${location.column}` 44 45 } 45 46 ··· 90 91 > 91 92 <pre v-html="test.result.htmlError" /> 92 93 </div> 93 - <template v-else-if="test.result?.errors"> 94 + <template v-else-if="test.result?.errors && config.root"> 94 95 <ViewReportError 95 96 v-for="(error, idx) of test.result.errors" 96 97 :key="idx"
+1 -1
packages/ui/client/composables/client/index.ts
··· 67 67 } 68 68 })() 69 69 70 - export const config = shallowRef<SerializedConfig>({} as any) 70 + export const config = shallowRef<Partial<SerializedConfig>>({} as any) 71 71 export const status = ref<WebSocketStatus>('CONNECTING') 72 72 export const availableProjects = shallowRef<string[]>([]) 73 73
-1
packages/ui/client/composables/client/static.ts
··· 64 64 getExternalResult: asyncNoop, 65 65 getTransformResult: asyncNoop, 66 66 onDone: noop, 67 - onTaskUpdate: noop, 68 67 writeFile: asyncNoop, 69 68 rerun: asyncNoop, 70 69 rerunTask: asyncNoop,
+1 -1
packages/ui/client/composables/explorer/state.ts
··· 67 67 return { matcher: () => true } 68 68 } 69 69 try { 70 - return { matcher: createTagsFilter([query], config.value.tags) } 70 + return { matcher: createTagsFilter([query], config.value.tags || []) } 71 71 } 72 72 catch (error: any) { 73 73 return { matcher: () => false, error: error.message }
+12
packages/vitest/src/node/cli/cli-config.ts
··· 46 46 description: 47 47 'Set to true to exit if port is already in use, instead of automatically trying the next available port', 48 48 }, 49 + allowExec: { 50 + description: 'Allow API to execute code. (Be careful when enabling this option in untrusted environments)', 51 + }, 52 + allowWrite: { 53 + description: 'Allow API to edit files. (Be careful when enabling this option in untrusted environments)', 54 + }, 49 55 middlewareMode: null, 50 56 }) 51 57 ··· 106 112 argument: '[port]', 107 113 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}`, 108 114 subcommands: apiConfig(defaultPort), 115 + transform(portOrOptions) { 116 + if (typeof portOrOptions === 'number') { 117 + return { port: portOrOptions } 118 + } 119 + return portOrOptions 120 + }, 109 121 }, 110 122 silent: { 111 123 description: 'Silent console output from tests. Use `\'passed-only\'` to see logs from failing tests only.',
+28
packages/vitest/src/node/config/resolveConfig.ts
··· 1 1 import type { ResolvedConfig as ResolvedViteConfig } from 'vite' 2 2 import type { Vitest } from '../core' 3 + import type { Logger } from '../logger' 3 4 import type { BenchmarkBuiltinReporters } from '../reporters' 4 5 import type { ResolvedBrowserOptions } from '../types/browser' 5 6 import type { ··· 55 56 return { host, port: Number(port) || defaultInspectPort } 56 57 } 57 58 59 + /** 60 + * @deprecated Internal function 61 + */ 58 62 export function resolveApiServerConfig<Options extends ApiConfig & Omit<UserConfig, 'expect'>>( 59 63 options: Options, 60 64 defaultPort: number, 65 + parentApi?: ApiConfig, 66 + logger?: Logger, 61 67 ): ApiConfig | undefined { 62 68 let api: ApiConfig | undefined 63 69 ··· 95 101 } 96 102 else { 97 103 api = { middlewareMode: true } 104 + } 105 + 106 + // if the API server is exposed to network, disable write operations by default 107 + if (!api.middlewareMode && api.host && api.host !== 'localhost' && api.host !== '127.0.0.1') { 108 + // assigned to browser 109 + if (parentApi) { 110 + if (api.allowWrite == null && api.allowExec == null) { 111 + logger?.error( 112 + c.yellow( 113 + `${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.`, 114 + ), 115 + ) 116 + } 117 + } 118 + api.allowWrite ??= parentApi?.allowWrite ?? false 119 + api.allowExec ??= parentApi?.allowExec ?? false 120 + } 121 + else { 122 + api.allowWrite ??= parentApi?.allowWrite ?? true 123 + api.allowExec ??= parentApi?.allowExec ?? true 98 124 } 99 125 100 126 return api ··· 801 827 resolved.browser.api = resolveApiServerConfig( 802 828 resolved.browser, 803 829 defaultBrowserPort, 830 + resolved.api, 831 + logger, 804 832 ) || { 805 833 port: defaultBrowserPort, 806 834 }
+7 -1
packages/vitest/src/node/config/serializeConfig.ts
··· 1 1 import type { TestProject } from '../project' 2 - import type { SerializedConfig } from '../types/config' 2 + import type { ApiConfig, SerializedConfig } from '../types/config' 3 3 4 4 export function serializeConfig(project: TestProject): SerializedConfig { 5 5 const { config, globalConfig } = project ··· 32 32 pool: config.pool, 33 33 expect: config.expect, 34 34 snapshotSerializers: config.snapshotSerializers, 35 + api: ((api: ApiConfig | undefined) => { 36 + return { 37 + allowExec: api?.allowExec, 38 + allowWrite: api?.allowWrite, 39 + } 40 + })(project.isBrowserEnabled() ? config.browser.api : config.api), 35 41 // TODO: non serializable function? 36 42 diff: config.diff, 37 43 retry: config.retry,
+16 -1
packages/vitest/src/node/types/config.ts
··· 44 44 export type ApiConfig = Pick< 45 45 ServerOptions, 46 46 'port' | 'strictPort' | 'host' | 'middlewareMode' 47 - > 47 + > & { 48 + /** 49 + * Allow any write operations from the API server. 50 + * 51 + * @default true if `api.host` is exposed to network, false otherwise 52 + */ 53 + allowWrite?: boolean 54 + /** 55 + * Allow running test files via the API. 56 + * If `api.host` is exposed to network and `allowWrite` is true, 57 + * anyone connected to the API server can run arbitrary code on your machine. 58 + * 59 + * @default true if `api.host` is exposed to network, false otherwise 60 + */ 61 + allowExec?: boolean 62 + } 48 63 49 64 export interface EnvironmentOptions { 50 65 /**