From 9f218b275d6663ee05ddc00ec79c14301b900aa0 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 5 Oct 2026 20:06:28 +0200 Subject: [PATCH] feat: support `test.ui.theme` option Closes #8947 --- docs/config/ui.md | 38 +++++++++++++++++- .../browser/src/node/serverOrchestrator.ts | 1 + packages/ui/client/components/Navigation.vue | 1 + packages/ui/client/composables/dark.ts | 8 +++- packages/ui/index.html | 6 ++- packages/ui/node/index.ts | 2 +- packages/vitest/src/node/cli/cli-config.ts | 2 + .../vitest/src/node/config/resolveConfig.ts | 8 +++- packages/vitest/src/node/plugins/index.ts | 4 +- .../vitest/src/node/plugins/testConfig.ts | 9 ++++- packages/vitest/src/node/plugins/utils.ts | 6 ++- packages/vitest/src/node/types/config.ts | 21 +++++++++- packages/vitest/src/public/node.ts | 1 + test/e2e/test/override.test.ts | 36 +++++++++++++++++ test/ui/test/browser-preview.spec.ts | 24 +++++++++++ test/ui/test/ui.spec.ts | 40 +++++++++++++++++++ 16 files changed, 193 insertions(+), 14 deletions(-) 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') + }) +}) -- 2.51.2