···4343- **`benchmark.outputJson` config and the `--outputJson` CLI flag** are removed. Use `--reporter=json --outputFile=<path>` to capture benchmark results; the JSON reporter now includes a `benchmarks` field on each test case.
4444- **`Vitest` instance `mode` property** is now always `'test'`. The previous `'benchmark'` value is no longer used; benchmarks run inside a dedicated project of the same `Vitest` instance.
45454646+### Vitest UI Requires an Authenticated URL
4747+4848+Vitest UI now requires token authentication for the HTML page and API access. The `/__vitest__/` URL will show an error until the browser is authenticated. To authenticate, open the URL with a token printed by Vitest, as shown below. Once authenticated, the direct `/__vitest__/` URL will work correctly.
4949+5050+```bash
5151+vitest --ui
5252+# UI started at http://localhost:51204/__vitest__/?token=...
5353+```
5454+4655### Removed `test.sequential`, `describe.sequential`, and `sequential` Options
47564857Vitest 5.0 removes the deprecated `test.sequential`, `describe.sequential`, and `sequential` test options. Use `concurrent: false` when you need a test or suite to opt out of inherited or globally configured concurrency.
+4
docs/guide/ui.md
···18181919Then you can visit the Vitest UI at <a href="http://localhost:51204/__vitest__/">`http://localhost:51204/__vitest__/`</a>
20202121+::: tip
2222+Vitest UI access is protected. If the direct URL shows an error, open the URL with a token printed by Vitest in the terminal, for example `http://localhost:51204/__vitest__/?token=...`.
2323+:::
2424+2125::: warning
2226The UI is interactive and requires a running Vite server, so make sure to run Vitest in `watch` mode (the default). Alternatively, you can generate a static HTML report that looks identical to the Vitest UI by specifying `html` in config's `reporters` option.
2327:::
+3
packages/browser/src/node/plugin.ts
···1717 rolldownVersion,
1818 distDir as vitestDist,
1919} from 'vitest/node'
2020+import { API_TOKEN_FILE } from '../../../vitest/src/node/config/apiToken'
2021import { distRoot } from './constants'
2122import { createOrchestratorMiddleware } from './middlewares/orchestratorMiddleware'
2223import { createTesterMiddleware } from './middlewares/testerMiddleware'
···402403 }
403404 viteConfig.server.fs ??= {}
404405 viteConfig.server.fs.allow = viteConfig.server.fs.allow || []
406406+ viteConfig.server.fs.deny ??= []
407407+ viteConfig.server.fs.deny.push(API_TOKEN_FILE)
405408 viteConfig.server.fs.allow.push(
406409 ...resolveFsAllow(
407410 parentServer.vitest.config.root,
+21
packages/ui/node/index.ts
···11import type { Vite, Vitest } from 'vitest/node'
22import fs from 'node:fs'
33+import { parse as parseCookie, serialize as serializeCookie } from 'cookie'
34import { join, resolve } from 'pathe'
45import sirv from 'sirv'
56import c from 'tinyrainbow'
···89import { distClientRoot } from './paths'
9101011export { distClientRoot }
1212+1313+const UI_TOKEN_COOKIE = 'vitest-ui-token'
11141215export default (ctx: Vitest): Vite.Plugin => {
1316 if (ctx.version !== version) {
···9497 if (req.url) {
9598 const url = new URL(req.url, 'http://localhost')
9699 if (url.pathname === base) {
100100+ if (isValidApiRequest(ctx.config, req)) {
101101+ res.statusCode = 302
102102+ res.setHeader('Set-Cookie', serializeCookie(UI_TOKEN_COOKIE, ctx.config.api.token, {
103103+ path: base,
104104+ httpOnly: true,
105105+ sameSite: 'strict',
106106+ }))
107107+ res.setHeader('Location', base)
108108+ res.end()
109109+ return
110110+ }
111111+ const cookieToken = parseCookie(req.headers.cookie ?? '')[UI_TOKEN_COOKIE]
112112+ if (cookieToken !== ctx.config.api.token) {
113113+ res.statusCode = 403
114114+ res.end('Vitest UI requires authentication. Open the URL with the token printed in the terminal, e.g. http://localhost:51204/__vitest__/?token=...')
115115+ return
116116+ }
97117 const html = clientIndexHtml.replace(
98118 '<!-- !LOAD_METADATA! -->',
99119 `<script>window.VITEST_API_TOKEN = ${JSON.stringify(ctx.config.api.token)}</script>`,
100120 )
101121 res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate')
122122+ res.setHeader('Referrer-Policy', 'no-referrer')
102123 res.setHeader('Content-Type', 'text/html; charset=utf-8')
103124 res.write(html)
104125 res.end()
···88 UserConfig,
99} from '../types/config'
1010import type { CoverageOptions, CoverageReporterWithOptions } from '../types/coverage'
1111-import crypto from 'node:crypto'
1211import { existsSync, statSync } from 'node:fs'
1312import { pathToFileURL } from 'node:url'
1413import { slash, toArray } from '@vitest/utils/helpers'
···2928import { withLabel } from '../reporters/renderers/utils'
3029import { BaseSequencer } from '../sequencers/BaseSequencer'
3130import { RandomSequencer } from '../sequencers/RandomSequencer'
3131+import { resolveApiToken } from './apiToken'
32323333function resolvePath(path: string, root: string) {
3434 // local-pkg (mlly)'s resolveModule("./file", { paths: ["/some/root"] }) tries
···659659660660 // the server has been created, we don't need to override vite.server options
661661 const api = resolveApiServerConfig(options, defaultPort)
662662- resolved.api = { ...api, token: crypto.randomUUID() }
662662+ const { token, tokenCreated } = resolveApiToken(resolved.root)
663663+ resolved.api = { ...api, token, tokenCreated }
663664664665 if (options.related) {
665666 resolved.related = toArray(options.related).map(file =>
+26
packages/vitest/src/node/create.ts
···75757676 if (ctx.config.api?.port) {
7777 await server.listen()
7878+ if (ctx.config.ui && ctx.config.open) {
7979+ // Note: `tokenCreated` is only an approximation of "the browser is not
8080+ // authenticated yet". If the user clears cookies while the token file
8181+ // persists, the clean URL will block until they re-open the `?token=`
8282+ // URL printed in the terminal.
8383+ if (ctx.config.api.tokenCreated) {
8484+ // First run that generated the token: no browser holds the auth
8585+ // cookie yet, so open the authenticated URL to set it. A new tab
8686+ // here is fine since no clean-URL tab exists to reuse.
8787+ const url = new URL(ctx.config.uiBase, 'http://localhost')
8888+ url.searchParams.set('token', ctx.config.api.token)
8989+ server.config.server.open = `${url.pathname}${url.search}`
9090+ }
9191+ else {
9292+ // Subsequent runs: open the clean UI base URL (without `?token=`)
9393+ // rather than the authenticated URL printed by the logger. On macOS,
9494+ // `openBrowser` reuses an existing tab whose URL matches via substring
9595+ // and reloads it (Vite's `bin/openChrome.js`). Since the 302 redirect
9696+ // strips the token, an already-authenticated tab lives at the clean
9797+ // URL, so opening the clean URL matches and reloads it; opening the
9898+ // token URL would never match and would spawn a new tab on every
9999+ // restart.
100100+ server.config.server.open = ctx.config.uiBase
101101+ }
102102+ server.openBrowser()
103103+ }
78104 }
7910580106 return ctx
+3-2
packages/vitest/src/node/logger.ts
···243243 if (this.ctx.config.ui) {
244244 const host = this.ctx.config.api?.host || 'localhost'
245245 const port = this.ctx.vite.config.server.port
246246- const base = this.ctx.config.uiBase
246246+ const url = new URL(this.ctx.config.uiBase, `http://${host}:${port}`)
247247+ url.searchParams.set('token', this.ctx.config.api.token)
247248248248- this.log(PAD + c.dim(c.green(`UI started at http://${host}:${c.bold(port)}${base}`)))
249249+ this.log(PAD + c.dim(c.green(`UI started at ${url}`)))
249250 }
250251 else if (this.ctx.config.api?.port) {
251252 const resolvedUrls = this.ctx.vite.resolvedUrls
+4-7
packages/vitest/src/node/plugins/index.ts
···66import { defaultPort } from '../../constants'
77import { configDefaults } from '../../defaults'
88import { generateScopedClassName } from '../../integrations/css/css-modules'
99+import { API_TOKEN_FILE } from '../config/apiToken'
910import { resolveApiServerConfig } from '../config/resolveConfig'
1011import { Vitest } from '../core'
1112import { createViteLogger, silenceImportViteIgnoreWarning } from '../viteLogger'
···6768 ;(options as unknown as ResolvedConfig).defines = defines
6869 ;(options as unknown as ResolvedConfig).viteDefine = originalDefine
69707070- let open: string | boolean | undefined = false
7171-7272- if (testConfig.ui && testConfig.open) {
7373- open = testConfig.uiBase ?? '/__vitest__/'
7474- }
7575-7671 const resolveOptions = getDefaultResolveOptions()
77727873 let config: ViteConfig = {
···8883 },
8984 server: {
9085 ...testConfig.api,
9191- open,
8686+ // auto open UI via `vite.openBrowser` manually later
8787+ open: false,
9288 hmr: false,
9389 ws: testConfig.api?.middlewareMode ? false : undefined,
9490 preTransformRequests: false,
9591 fs: {
9692 allow: resolveFsAllow(options.root || process.cwd(), testConfig.config),
9393+ deny: [API_TOKEN_FILE],
9794 },
9895 },
9996 build: {
+2
packages/vitest/src/node/plugins/workspace.ts
···77import * as vite from 'vite'
88import { configDefaults } from '../../defaults'
99import { generateScopedClassName } from '../../integrations/css/css-modules'
1010+import { API_TOKEN_FILE } from '../config/apiToken'
1011import { VitestFilteredOutProjectError } from '../errors'
1112import { createViteLogger, silenceImportViteIgnoreWarning } from '../viteLogger'
1213import { CoverageTransform } from './coverageTransform'
···175176 project.vitest.config.root,
176177 project.vitest.vite.config.configFile,
177178 ),
179179+ deny: [API_TOKEN_FILE],
178180 },
179181 },
180182 // eslint-disable-next-line ts/ban-ts-comment