diff --git a/docs/config/ui.md b/docs/config/ui.md
index 8e02db39d..0c96551aa 100644
--- a/docs/config/ui.md
+++ b/docs/config/ui.md
@@ -5,11 +5,11 @@ outline: deep
# ui
-- **Type:** `boolean`
+- **Type:** `boolean | object`
- **Default:** `false`
- **CLI:** `--ui`, `--ui=false`
-Enable [Vitest UI](/guide/ui).
+Enable [Vitest UI](/guide/ui). Setting `ui: true` is the same as `ui: { enabled: true }`.
::: warning
This features requires a [`@vitest/ui`](https://npmx.dev/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.
@@ -18,3 +18,37 @@ This features requires a [`@vitest/ui`](https://npmx.dev/package/@vitest/ui) pac
::: 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.
:::
+
+## ui.enabled 5.1.0 {#ui-enabled}
+
+- **Type:** `boolean`
+- **Default:** `false`
+
+Enable [Vitest UI](/guide/ui).
+
+## ui.theme 5.1.0 {#ui-theme}
+
+- **Type:** `'auto' | 'light' | 'dark'`
+- **Default:** `'auto'`
+
+The color theme of Vitest UI and of the [Browser Mode UI](/config/browser/ui). With `'auto'`, the UI follows the system preference and remembers the theme you toggle in the UI. With `'light'` or `'dark'`, the UI opens with this theme on every page load, and the toggle changes it only until the page reloads.
+
+This option does not enable Vitest UI, so you can set it together with [`browser.ui`](/config/browser/ui):
+
+```ts
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ ui: {
+ theme: 'dark',
+ },
+ browser: {
+ enabled: true,
+ instances: [
+ { browser: 'chromium' },
+ ],
+ }
+ },
+})
+```
diff --git a/packages/browser/src/node/serverOrchestrator.ts b/packages/browser/src/node/serverOrchestrator.ts
index 575843e11..b367a05d9 100644
--- a/packages/browser/src/node/serverOrchestrator.ts
+++ b/packages/browser/src/node/serverOrchestrator.ts
@@ -78,6 +78,7 @@ export async function resolveOrchestrator(
.replace(
'',
[
+ ``,
'{__VITEST_INJECTOR__}',
'{__VITEST_ERROR_CATCHER__}',
'{__VITEST_SCRIPTS__}',
diff --git a/packages/ui/client/components/Navigation.vue b/packages/ui/client/components/Navigation.vue
index 41cf54538..94bd0106c 100644
--- a/packages/ui/client/components/Navigation.vue
+++ b/packages/ui/client/components/Navigation.vue
@@ -156,6 +156,7 @@ function getRerunTooltip(filteredFiles: RunnerTestFile[] | undefined) {
diff --git a/packages/ui/client/composables/dark.ts b/packages/ui/client/composables/dark.ts
index 5467599bd..b3a3ae1aa 100644
--- a/packages/ui/client/composables/dark.ts
+++ b/packages/ui/client/composables/dark.ts
@@ -1,4 +1,10 @@
import { useDark, useToggle } from '@vueuse/core'
-export const isDark = useDark()
+const theme: 'dark' | 'light' | 'auto' = (window as any).VITEST_UI_THEME || 'auto'
+
+export const isDark = useDark({
+ initialValue: theme,
+ // the configured theme applies on every page load, so the toggled theme is not stored
+ storageKey: theme === 'auto' ? undefined : null,
+})
export const toggleDark = useToggle(isDark)
diff --git a/packages/ui/index.html b/packages/ui/index.html
index 6c037edf1..04780ad6b 100644
--- a/packages/ui/index.html
+++ b/packages/ui/index.html
@@ -12,16 +12,18 @@
href="https://fonts.googleapis.com/css2?family=Readex+Pro:wght@300;400&display=swap"
rel="stylesheet"
/>
+
-
diff --git a/packages/ui/node/index.ts b/packages/ui/node/index.ts
index 1cd031864..0d0ddd918 100644
--- a/packages/ui/node/index.ts
+++ b/packages/ui/node/index.ts
@@ -153,7 +153,7 @@ export default (harness: PluginHarness): Vite.Plugin => {
}
const html = clientIndexHtml.replace(
'',
- ``,
+ ``,
)
res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate')
res.setHeader('Referrer-Policy', 'no-referrer')
diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts
index 7223bb5f0..c2d5e77c5 100644
--- a/packages/vitest/src/node/cli/cli-config.ts
+++ b/packages/vitest/src/node/cli/cli-config.ts
@@ -101,6 +101,8 @@ export const cliOptionsConfig: VitestCLIOptions = {
},
ui: {
description: 'Enable UI',
+ argument: '', // empty string means boolean
+ subcommands: null, // don't support custom objects
},
open: {
description: 'Open UI automatically (default: `!process.env.CI`)',
diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts
index 30bf6f2bb..9b41728ed 100644
--- a/packages/vitest/src/node/config/resolveConfig.ts
+++ b/packages/vitest/src/node/config/resolveConfig.ts
@@ -29,7 +29,7 @@ import { BrowserLoaderPlugin } from '../plugins/browserLoader'
import { ViteConfigPlugin } from '../plugins/config'
import { VitestCorePlugin } from '../plugins/index'
import { TestConfigPlugin } from '../plugins/testConfig'
-import { resolveFsAllow } from '../plugins/utils'
+import { normalizeUIOptions, resolveFsAllow } from '../plugins/utils'
import { resolveProjectEntries } from '../projects/resolveProjects'
import { withLabel } from '../reporters/renderers/utils'
import { BaseSequencer } from '../sequencers/BaseSequencer'
@@ -92,7 +92,7 @@ export function resolveApiServerConfig(
let api: ApiConfig | undefined
- if (config.ui && !config.api) {
+ if (normalizeUIOptions(config.ui).enabled && !config.api) {
api = { port: defaultPort }
} else if (config.api === true) {
api = { port: defaultPort }
@@ -224,6 +224,10 @@ export function resolveTestConfig(
resolved.root = viteConfig.root
resolved.providedOptions = providedOptions
+ const uiOptions = normalizeUIOptions(options.ui)
+ resolved.ui = !!uiOptions.enabled
+ resolved.uiOptions = { theme: uiOptions.theme ?? 'auto' }
+
// These options are resolved once for the whole run using the root config.
// Coverage is shared by reference: each project's setup/test/config files are
// appended to the same exclude list below, keeping them out of the report.
diff --git a/packages/vitest/src/node/plugins/index.ts b/packages/vitest/src/node/plugins/index.ts
index cf0cb6d3f..ef5c2afb5 100644
--- a/packages/vitest/src/node/plugins/index.ts
+++ b/packages/vitest/src/node/plugins/index.ts
@@ -8,7 +8,7 @@ import { MetaEnvReplacerPlugin } from './metaEnvReplacer'
import { MocksPlugins } from './mocks'
import { NormalizeURLPlugin } from './normalizeURL'
import { SsrRunnerFixerPlugin } from './ssrRunnerFixer'
-import { resolveTestCacheDir } from './utils'
+import { normalizeUIOptions, resolveTestCacheDir } from './utils'
import { VitestCoreResolver } from './vitestResolver'
export function VitestCorePlugin(harness: PluginHarness): VitePlugin[] {
@@ -68,7 +68,7 @@ export function VitestCorePlugin(harness: PluginHarness): VitePlugin[] {
name: 'vitest:ui-injector',
enforce: 'post',
async configResolved(config) {
- if (config.test.ui) {
+ if (normalizeUIOptions(config.test.ui).enabled) {
await harness.packageInstaller.ensureInstalled(
'@vitest/ui',
resolve(config.root),
diff --git a/packages/vitest/src/node/plugins/testConfig.ts b/packages/vitest/src/node/plugins/testConfig.ts
index 5ff05e475..bf6921bdc 100644
--- a/packages/vitest/src/node/plugins/testConfig.ts
+++ b/packages/vitest/src/node/plugins/testConfig.ts
@@ -20,7 +20,7 @@ import { mergeConfig } from 'vite'
import { defaultPort } from '../../constants'
import { escapeRegExp } from '../../utils/base'
import { resolveApiServerConfig } from '../config/resolveConfig'
-import { deleteDefineConfig } from './utils'
+import { deleteDefineConfig, normalizeUIOptions } from './utils'
// `name` must stay unique per project, `projects` would redefine the whole
// workspace, and `root` would re-root the project onto the declaring config
@@ -74,10 +74,15 @@ export function TestConfigPlugin(
order: 'pre',
handler(config) {
const { browser, ...options } = cliOptions
+ const configUI = config.test?.ui
// We don't want to use Vite's merge because we want to OVERRIDE options
// By default, Vite extends arrays, for example, but CLI options should have the priority
config.test = deepMerge({}, config.test ?? {}, options)
+ // `deepMerge` cannot merge a boolean `ui` with an object
+ if (configUI != null && options.ui != null) {
+ config.test.ui = { ...normalizeUIOptions(configUI), ...normalizeUIOptions(options.ui) }
+ }
// apply browser CLI options only if the config already has the browser config and not disabled manually,
// but let the API create it for a root config without projects (the CLI can't set `provider` or `instances`)
@@ -169,7 +174,7 @@ export function TestConfigPlugin(
resolvedTestConfig._scriptDefines = scriptDefines
}
- const apiRequested = !!(testConfig.ui || testConfig.api)
+ const apiRequested = !!(normalizeUIOptions(testConfig.ui).enabled || testConfig.api)
const api = resolveApiServerConfig(
testConfig,
isBrowserEnabled ? harness._browserLastPort++ : defaultPort,
diff --git a/packages/vitest/src/node/plugins/utils.ts b/packages/vitest/src/node/plugins/utils.ts
index 2ca0ce816..92c2c19fa 100644
--- a/packages/vitest/src/node/plugins/utils.ts
+++ b/packages/vitest/src/node/plugins/utils.ts
@@ -1,5 +1,5 @@
import type { DepOptimizationOptions, UserConfig as ViteConfig } from 'vite'
-import type { DepsOptimizationOptions, UserConfig } from '../types/config'
+import type { DepsOptimizationOptions, UIOptions, UserConfig } from '../types/config'
import { slash } from '@vitest/utils/helpers'
import { dirname, resolve } from 'pathe'
import { searchForWorkspaceRoot, version as viteVersion } from 'vite'
@@ -18,6 +18,10 @@ export function resolveTestCacheDir(
return resolve(root, baseDir, 'vitest', hash('sha1', label, 'hex'))
}
+export function normalizeUIOptions(ui: UserConfig['ui']): UIOptions {
+ return typeof ui === 'object' ? ui : { enabled: ui }
+}
+
export function resolveOptimizerConfig(
testOptions_: DepsOptimizationOptions | undefined,
viteOptions: DepOptimizationOptions | undefined,
diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts
index 2d7801b4f..310b23809 100644
--- a/packages/vitest/src/node/types/config.ts
+++ b/packages/vitest/src/node/types/config.ts
@@ -68,6 +68,22 @@ export type ApiConfig = Pick {
name: ProjectName['label']
color?: ProjectName['color']
@@ -1277,6 +1294,8 @@ export interface ResolvedConfig extends Omit<
_scriptDefines?: Record
api: ResolvedApiConfig
+ ui: boolean
+ uiOptions: Required>
cliExclude?: string[]
project: string[]
diff --git a/packages/vitest/src/public/node.ts b/packages/vitest/src/public/node.ts
index 4c8c8be8d..6f80d5db1 100644
--- a/packages/vitest/src/public/node.ts
+++ b/packages/vitest/src/public/node.ts
@@ -146,6 +146,7 @@ export type {
SequenceSetupFiles,
UserConfig as TestUserConfig,
TypecheckConfig,
+ UIOptions,
UserWorkspaceConfig,
VitestEnvironment,
VitestRunMode,
diff --git a/test/e2e/test/override.test.ts b/test/e2e/test/override.test.ts
index c425177af..6f3e9328b 100644
--- a/test/e2e/test/override.test.ts
+++ b/test/e2e/test/override.test.ts
@@ -89,6 +89,42 @@ describe('correctly defines api flag', () => {
})
})
+describe('correctly resolves ui option', () => {
+ it('theme does not enable UI', async () => {
+ const c = await config({ ui: { theme: 'dark' } })
+ expect(c.ui).toBe(false)
+ expect(c.uiOptions).toEqual({ theme: 'dark' })
+ expect(c.api.port).toBeUndefined()
+ })
+
+ it('CLI enables UI with configured theme', async () => {
+ const c = await config({
+ $cliOptions: { ui: true },
+ ui: { theme: 'dark' },
+ })
+ expect(c.ui).toBe(true)
+ expect(c.uiOptions).toEqual({ theme: 'dark' })
+ })
+
+ it('CLI disables UI', async () => {
+ const c = await config({
+ $cliOptions: { ui: false },
+ ui: { enabled: true, theme: 'light' },
+ })
+ expect(c.ui).toBe(false)
+ expect(c.uiOptions).toEqual({ theme: 'light' })
+ })
+
+ it('CLI theme overrides boolean config', async () => {
+ const c = await config({
+ $cliOptions: { ui: { theme: 'dark' } },
+ ui: true,
+ })
+ expect(c.ui).toBe(true)
+ expect(c.uiOptions).toEqual({ theme: 'dark' })
+ })
+})
+
describe.each(['--inspect', '--inspect-brk'])('correctly parses %s flags', (inspectFlagName) => {
it.each([
['', { enabled: true }],
diff --git a/test/ui/test/browser-preview.spec.ts b/test/ui/test/browser-preview.spec.ts
index 67ce88e2e..b4f73aba5 100644
--- a/test/ui/test/browser-preview.spec.ts
+++ b/test/ui/test/browser-preview.spec.ts
@@ -43,4 +43,28 @@ test.describe('orchestrator UI on preview provider', () => {
await vitest.close()
})
+
+ test('theme', async ({ page }) => {
+ globalThis.__hackOpenBrowser = async (url: string) => {
+ await page.goto(url)
+ }
+ const vitest = await startVitest(
+ undefined,
+ {
+ root: './fixtures/browser-preview',
+ watch: true,
+ ui: { theme: 'dark' },
+ },
+ undefined,
+ {
+ stdout: new Writable({ write: (_, __, callback) => callback() }),
+ stderr: new Writable({ write: (_, __, callback) => callback() }),
+ },
+ )
+
+ await assertTestCounts(page, { pass: 1, fail: 0 })
+ await expect(page.locator('html')).toContainClass('dark')
+
+ await vitest.close()
+ })
})
diff --git a/test/ui/test/ui.spec.ts b/test/ui/test/ui.spec.ts
index 45202e530..01df52c75 100644
--- a/test/ui/test/ui.spec.ts
+++ b/test/ui/test/ui.spec.ts
@@ -943,3 +943,43 @@ test.describe('security', () => {
await testExecute(page, { mode: 'ui-disallow' })
})
})
+
+test.describe('theme', () => {
+ let vitest: Vitest | undefined
+ let pageUrl: string
+
+ test.beforeAll(async () => {
+ const server = await startVitestUi({
+ root: './fixtures/main',
+ watch: true,
+ ui: { enabled: true, theme: 'dark' },
+ standalone: true,
+ open: false,
+ reporters: [],
+ })
+ vitest = server.vitest
+ pageUrl = server.url
+ })
+
+ test.afterAll(async () => {
+ await vitest?.close()
+ })
+
+ test('configured theme overrides stored and system theme', async ({ page }) => {
+ await page.emulateMedia({ colorScheme: 'light' })
+ await page.addInitScript(() => localStorage.setItem('vueuse-color-scheme', 'light'))
+ await page.goto(pageUrl)
+
+ const html = page.locator('html')
+ const toggle = page.getByTestId('btn-toggle-dark')
+ await expect(toggle).toBeVisible()
+ await expect(html).toContainClass('dark')
+
+ await toggle.click()
+ await expect(html).not.toContainClass('dark')
+
+ await page.reload()
+ await expect(toggle).toBeVisible()
+ await expect(html).toContainClass('dark')
+ })
+})