[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.

fix(browser): correctly inherit CLI options (#7858)

authored by

Vladimir and committed by
GitHub
(Apr 29, 2025, 5:41 PM +0200) 03660f9d f2ce53c2

+403 -88
+3 -1
test/test-utils/index.ts
··· 269 269 return resolve(dirname(filename), path) 270 270 } 271 271 272 - export function useFS(root: string, structure: Record<string, string | ViteUserConfig | WorkspaceProjectConfiguration[]>) { 272 + export type TestFsStructure = Record<string, string | ViteUserConfig | WorkspaceProjectConfiguration[]> 273 + 274 + export function useFS(root: string, structure: TestFsStructure) { 273 275 const files = new Set<string>() 274 276 const hasConfig = Object.keys(structure).some(file => file.includes('.config.')) 275 277 if (!hasConfig) {
+233 -1
test/config/test/browser-configs.test.ts
··· 1 1 import type { ViteUserConfig } from 'vitest/config' 2 2 import type { UserConfig, VitestOptions } from 'vitest/node' 3 - import { expect, onTestFinished, test } from 'vitest' 3 + import type { TestFsStructure } from '../../test-utils' 4 + import crypto from 'node:crypto' 5 + import { resolve } from 'pathe' 6 + import { describe, expect, onTestFinished, test } from 'vitest' 4 7 import { createVitest } from 'vitest/node' 8 + import { runVitestCli, useFS } from '../../test-utils' 5 9 6 10 async function vitest(cliOptions: UserConfig, configValue: UserConfig = {}, viteConfig: ViteUserConfig = {}, vitestOptions: VitestOptions = {}) { 7 11 const vitest = await createVitest('test', { ...cliOptions, watch: false }, { ...viteConfig, test: configValue as any }, vitestOptions) ··· 302 306 // browser config 303 307 expect(projects[1].config.browser.enabled).toBe(true) 304 308 expect(projects[1].config.browser.headless).toBe(true) 309 + }) 310 + 311 + function getCliConfig(options: UserConfig, cli: string[], fs: TestFsStructure = {}) { 312 + const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`) 313 + useFS(root, { 314 + ...fs, 315 + 'basic.test.ts': /* ts */` 316 + import { test } from 'vitest' 317 + test('basic', () => { 318 + expect(1).toBe(1) 319 + }) 320 + `, 321 + 'vitest.config.ts': /* ts */ ` 322 + export default { 323 + test: { 324 + reporters: [ 325 + { 326 + onInit(vitest) { 327 + const browser = vitest.config.browser 328 + const workspace = (p) => ({ 329 + name: p.name, 330 + headless: p.config.browser.headless, 331 + browser: p.config.browser.enabled, 332 + ui: p.config.browser.ui, 333 + }) 334 + console.log(JSON.stringify({ 335 + browser: { 336 + headless: browser.headless, 337 + browser: browser.enabled, 338 + ui: browser.ui, 339 + }, 340 + workspace: vitest.projects.map(p => { 341 + return { 342 + ...workspace(p), 343 + parent: p._parent ? workspace(p._parent) : null, 344 + } 345 + }) 346 + })) 347 + // throw an error to avoid running tests 348 + throw new Error('stop') 349 + }, 350 + }, 351 + ], 352 + ...${JSON.stringify(options)} 353 + } 354 + } 355 + `, 356 + }) 357 + return runVitestCli( 358 + { 359 + nodeOptions: { 360 + env: { 361 + CI: 'false', 362 + GITHUB_ACTIONS: undefined, 363 + }, 364 + }, 365 + }, 366 + '--root', 367 + root, 368 + '--no-watch', 369 + ...cli, 370 + ) 371 + } 372 + 373 + describe('[e2e] workspace configs are affected by the CLI options', () => { 374 + test('UI is not enabled by default in headless config', async () => { 375 + const vitest = await getCliConfig({ 376 + workspace: [ 377 + { 378 + test: { 379 + name: 'unit', 380 + }, 381 + }, 382 + { 383 + test: { 384 + name: 'browser', 385 + browser: { 386 + enabled: true, 387 + headless: true, 388 + provider: 'playwright', 389 + instances: [ 390 + { 391 + browser: 'chromium', 392 + }, 393 + ], 394 + }, 395 + }, 396 + }, 397 + ], 398 + }, []) 399 + 400 + const config = JSON.parse(vitest.stdout) 401 + 402 + expect(config.workspace).toHaveLength(2) 403 + expect(config.workspace[0]).toEqual({ 404 + name: 'unit', 405 + headless: false, 406 + browser: false, 407 + ui: true, 408 + parent: null, 409 + }) 410 + 411 + expect(config.workspace[1]).toEqual({ 412 + name: 'browser (chromium)', 413 + // headless was set in the config 414 + headless: true, 415 + browser: true, 416 + // UI is false because headless is enabled 417 + ui: false, 418 + parent: { 419 + name: 'browser', 420 + headless: true, 421 + browser: true, 422 + ui: false, 423 + }, 424 + }) 425 + }) 426 + 427 + test('CLI options correctly override inline workspace options', async () => { 428 + const vitest = await getCliConfig({ 429 + workspace: [ 430 + { 431 + test: { 432 + name: 'unit', 433 + }, 434 + }, 435 + { 436 + test: { 437 + name: 'browser', 438 + browser: { 439 + enabled: true, 440 + headless: true, 441 + provider: 'playwright', 442 + instances: [ 443 + { 444 + browser: 'chromium', 445 + }, 446 + ], 447 + }, 448 + }, 449 + }, 450 + ], 451 + }, ['--browser.headless=false']) 452 + 453 + const config = JSON.parse(vitest.stdout) 454 + 455 + expect(config.workspace).toHaveLength(2) 456 + expect(config.workspace[0]).toEqual({ 457 + name: 'unit', 458 + headless: false, 459 + browser: false, 460 + ui: true, 461 + parent: null, 462 + }) 463 + 464 + expect(config.workspace[1]).toEqual({ 465 + name: 'browser (chromium)', 466 + // headless was overriden by CLI options 467 + headless: false, 468 + browser: true, 469 + // UI should be true because we always set CI to false, 470 + // if headless was `true`, ui would be `false` 471 + ui: true, 472 + parent: { 473 + name: 'browser', 474 + headless: false, 475 + browser: true, 476 + ui: true, 477 + }, 478 + }) 479 + }) 480 + 481 + test('CLI options correctly override config file workspace options', async () => { 482 + const vitest = await getCliConfig( 483 + { 484 + workspace: [ 485 + { 486 + test: { 487 + name: 'unit', 488 + }, 489 + }, 490 + './vitest.browser.config.ts', 491 + ], 492 + }, 493 + ['--browser.headless=false'], 494 + { 495 + 'vitest.browser.config.ts': { 496 + test: { 497 + name: 'browser', 498 + browser: { 499 + enabled: true, 500 + headless: true, 501 + provider: 'playwright', 502 + instances: [ 503 + { 504 + browser: 'chromium', 505 + }, 506 + ], 507 + }, 508 + }, 509 + }, 510 + }, 511 + ) 512 + 513 + const config = JSON.parse(vitest.stdout) 514 + 515 + expect(config.workspace).toHaveLength(2) 516 + expect(config.workspace[0]).toEqual({ 517 + name: 'unit', 518 + headless: false, 519 + browser: false, 520 + ui: true, 521 + parent: null, 522 + }) 523 + 524 + expect(config.workspace[1]).toEqual({ 525 + name: 'browser (chromium)', 526 + headless: false, 527 + browser: true, 528 + ui: true, 529 + parent: { 530 + name: 'browser', 531 + headless: false, 532 + browser: true, 533 + ui: true, 534 + }, 535 + }) 536 + }) 305 537 })
+49 -24
test/config/test/failures.test.ts
··· 1 + import type { UserConfig as ViteUserConfig } from 'vite' 1 2 import type { UserConfig } from 'vitest/node' 2 3 import type { VitestRunnerCLIOptions } from '../../test-utils' 3 4 import { normalize, resolve } from 'pathe' ··· 10 11 const names = ['edge', 'chromium', 'webkit', 'chrome', 'firefox', 'safari'] as const 11 12 const browsers = providers.map(provider => names.map(name => ({ name, provider }))).flat() 12 13 13 - function runVitest(config: NonNullable<UserConfig> & { shard?: any }, runnerOptions?: VitestRunnerCLIOptions) { 14 - return testUtils.runVitest({ root: './fixtures/test', ...config }, [], undefined, {}, runnerOptions) 14 + function runVitest(config: NonNullable<UserConfig> & { shard?: any }, viteOverrides: ViteUserConfig = {}, runnerOptions?: VitestRunnerCLIOptions) { 15 + return testUtils.runVitest({ root: './fixtures/test', ...config }, [], undefined, viteOverrides, runnerOptions) 15 16 } 16 17 17 18 function runVitestCli(...cliArgs: string[]) { ··· 290 291 const { stderr } = await runVitest({ 291 292 coverage: { enabled: true }, 292 293 workspace: './fixtures/workspace/browser/workspace-with-browser.ts', 293 - }, { fails: true }) 294 + }, {}, { fails: true }) 294 295 expect(stderr).toMatch( 295 296 `Error: @vitest/coverage-v8 does not work with 296 297 { ··· 416 417 }) 417 418 418 419 test('browser.instances is empty', async () => { 419 - const { stderr } = await runVitest({ 420 - browser: { 421 - enabled: true, 422 - provider: 'playwright', 423 - instances: [], 420 + const { stderr } = await runVitest({}, { 421 + test: { 422 + browser: { 423 + enabled: true, 424 + provider: 'playwright', 425 + instances: [], 426 + }, 424 427 }, 425 428 }) 426 429 expect(stderr).toMatch('"browser.instances" was set in the config, but the array is empty. Define at least one browser config.') 427 430 }) 428 431 432 + test('browser.name or browser.instances are required', async () => { 433 + const { stderr, exitCode } = await runVitestCli('--browser.enabled', '--root=./fixtures/browser-no-config') 434 + expect(exitCode).toBe(1) 435 + expect(stderr).toMatch('Vitest Browser Mode requires "browser.name" (deprecated) or "browser.instances" options, none were set.') 436 + }) 437 + 438 + test('--browser flag without browser configuration throws an error', async () => { 439 + const { stderr, exitCode } = await runVitestCli('--browser.enabled') 440 + expect(exitCode).toBe(1) 441 + expect(stderr).toMatch('Vitest received --browser flag, but no project had a browser configuration.') 442 + }) 443 + 444 + test('--browser flag without browser configuration in workspaces throws an error', async () => { 445 + const { stderr, exitCode } = await runVitestCli('--browser.enabled', '--root=./fixtures/no-browser-workspace') 446 + expect(exitCode).toBe(1) 447 + expect(stderr).toMatch('Vitest received --browser flag, but no project had a browser configuration.') 448 + }) 449 + 429 450 test('browser.name filters all browser.instances are required', async () => { 430 - const { stderr } = await runVitest({ 431 - browser: { 432 - enabled: true, 433 - name: 'chromium', 434 - provider: 'playwright', 435 - instances: [ 436 - { browser: 'firefox' }, 437 - ], 451 + const { stderr } = await runVitest({}, { 452 + test: { 453 + browser: { 454 + enabled: true, 455 + name: 'chromium', 456 + provider: 'playwright', 457 + instances: [ 458 + { browser: 'firefox' }, 459 + ], 460 + }, 438 461 }, 439 462 }) 440 463 expect(stderr).toMatch('"browser.instances" was set in the config, but the array is empty. Define at least one browser config. The "browser.name" was set to "chromium" which filtered all configs (firefox). Did you mean to use another name?') 441 464 }) 442 465 443 466 test('browser.instances throws an error if no custom name is provided', async () => { 444 - const { stderr } = await runVitest({ 445 - browser: { 446 - enabled: true, 447 - provider: 'playwright', 448 - instances: [ 449 - { browser: 'firefox' }, 450 - { browser: 'firefox' }, 451 - ], 467 + const { stderr } = await runVitest({}, { 468 + test: { 469 + browser: { 470 + enabled: true, 471 + provider: 'playwright', 472 + instances: [ 473 + { browser: 'firefox' }, 474 + { browser: 'firefox' }, 475 + ], 476 + }, 452 477 }, 453 478 }) 454 479 expect(stderr).toMatch('Cannot define a nested project for a firefox browser. The project name "firefox" was already defined. If you have multiple instances for the same browser, make sure to define a custom "name". All projects in a workspace should have unique names. Make sure your configuration is correct.')
+24 -8
packages/browser/src/client/client.ts
··· 2 2 import type { CancelReason } from '@vitest/runner' 3 3 import type { BirpcReturn } from 'birpc' 4 4 import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from '../node/types' 5 + import type { IframeOrchestrator } from './orchestrator' 5 6 import { createBirpc } from 'birpc' 6 7 import { parse, stringify } from 'flatted' 7 8 import { getBrowserState } from './utils' ··· 35 36 WebSocketBrowserEvents 36 37 > 37 38 39 + // ws connection can be established before the orchestrator is fully loaded 40 + // in very rare cases in the preview provider 41 + function waitForOrchestrator() { 42 + return new Promise<IframeOrchestrator>((resolve, reject) => { 43 + const type = getBrowserState().type 44 + if (type !== 'orchestrator') { 45 + reject(new TypeError('Only orchestrator can create testers.')) 46 + return 47 + } 48 + 49 + function check() { 50 + const orchestrator = getBrowserState().orchestrator 51 + if (orchestrator) { 52 + return resolve(orchestrator) 53 + } 54 + setTimeout(check) 55 + } 56 + check() 57 + }) 58 + } 59 + 38 60 function createClient() { 39 61 const autoReconnect = true 40 62 const reconnectInterval = 2000 ··· 54 76 { 55 77 onCancel: setCancel, 56 78 async createTesters(options) { 57 - const orchestrator = getBrowserState().orchestrator 58 - if (!orchestrator) { 59 - throw new TypeError('Only orchestrator can create testers.') 60 - } 79 + const orchestrator = await waitForOrchestrator() 61 80 return orchestrator.createTesters(options) 62 81 }, 63 82 async cleanupTesters() { 64 - const orchestrator = getBrowserState().orchestrator 65 - if (!orchestrator) { 66 - throw new TypeError('Only orchestrator can cleanup testers.') 67 - } 83 + const orchestrator = await waitForOrchestrator() 68 84 return orchestrator.cleanupTesters() 69 85 }, 70 86 cdpEvent(event: string, payload: unknown) {
+15 -2
packages/vitest/src/node/core.ts
··· 287 287 })) 288 288 })) 289 289 290 - if (!this.projects.length) { 291 - throw new Error(`No projects matched the filter "${toArray(resolved.project).join('", "')}".`) 290 + if (options.browser?.enabled) { 291 + const browserProjects = this.projects.filter(p => p.config.browser.enabled) 292 + if (!browserProjects.length) { 293 + throw new Error(`Vitest received --browser flag, but no project had a browser configuration.`) 294 + } 292 295 } 296 + if (!this.projects.length) { 297 + const filter = toArray(resolved.project).join('", "') 298 + if (filter) { 299 + throw new Error(`No projects matched the filter "${filter}".`) 300 + } 301 + else { 302 + throw new Error(`Vitest wasn't able to resolve any project.`) 303 + } 304 + } 305 + 293 306 if (!this.coreWorkspaceProject) { 294 307 this.coreWorkspaceProject = TestProject._createBasicProject(this) 295 308 }
+9
test/config/fixtures/browser-no-config/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config'; 2 + 3 + export default defineConfig({ 4 + test: { 5 + browser: { 6 + enabled: false, 7 + }, 8 + }, 9 + })
+13
test/config/fixtures/no-browser-workspace/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config'; 2 + 3 + export default defineConfig({ 4 + test: { 5 + workspace: [ 6 + { 7 + test: { 8 + name: 'unit', 9 + }, 10 + }, 11 + ], 12 + }, 13 + })
+14 -17
packages/vitest/src/node/config/resolveConfig.ts
··· 234 234 235 235 const browser = resolved.browser 236 236 237 - if (browser.enabled) { 237 + // if browser was enabled via CLI and it's configured by the user, then validate the input 238 + if (browser.enabled && viteConfig.test?.browser) { 238 239 if (!browser.name && !browser.instances) { 239 - // CLI can enable `--browser.*` flag to change config of workspace projects 240 - // the same flag will be applied to the root config that doesn't have to have "name" or "instances" 241 - // in this case we just disable the browser mode 242 - browser.enabled = false 240 + throw new Error(`Vitest Browser Mode requires "browser.name" (deprecated) or "browser.instances" options, none were set.`) 243 241 } 244 - else { 245 - const instances = browser.instances 246 - if (browser.name && browser.instances) { 247 - // --browser=chromium filters configs to a single one 248 - browser.instances = browser.instances.filter(instance => instance.browser === browser.name) 249 - } 250 242 251 - if (browser.instances && !browser.instances.length) { 252 - throw new Error([ 253 - `"browser.instances" was set in the config, but the array is empty. Define at least one browser config.`, 254 - browser.name && instances?.length ? ` The "browser.name" was set to "${browser.name}" which filtered all configs (${instances.map(c => c.browser).join(', ')}). Did you mean to use another name?` : '', 255 - ].join('')) 256 - } 243 + const instances = browser.instances 244 + if (browser.name && browser.instances) { 245 + // --browser=chromium filters configs to a single one 246 + browser.instances = browser.instances.filter(instance => instance.browser === browser.name) 247 + } 248 + 249 + if (browser.instances && !browser.instances.length) { 250 + throw new Error([ 251 + `"browser.instances" was set in the config, but the array is empty. Define at least one browser config.`, 252 + browser.name && instances?.length ? ` The "browser.name" was set to "${browser.name}" which filtered all configs (${instances.map(c => c.browser).join(', ')}). Did you mean to use another name?` : '', 253 + ].join('')) 257 254 } 258 255 } 259 256
+40 -30
packages/vitest/src/node/plugins/workspace.ts
··· 4 4 import { existsSync, readFileSync } from 'node:fs' 5 5 import { deepMerge } from '@vitest/utils' 6 6 import { basename, dirname, relative, resolve } from 'pathe' 7 + import { mergeConfig } from 'vite' 7 8 import { configDefaults } from '../../defaults' 8 9 import { generateScopedClassName } from '../../integrations/css/css-modules' 9 10 import { VitestFilteredOutProjectError } from '../errors' ··· 63 64 } 64 65 } 65 66 66 - // keep project names to potentially filter it out 67 - const workspaceNames = [name] 68 - if (viteConfig.test?.browser?.enabled) { 69 - if (viteConfig.test.browser.name) { 70 - const browser = viteConfig.test.browser.name 71 - // vitest injects `instances` in this case later on 72 - workspaceNames.push(name ? `${name} (${browser})` : browser) 73 - } 74 - 75 - viteConfig.test.browser.instances?.forEach((instance) => { 76 - // every instance is a potential project 77 - instance.name ??= name ? `${name} (${instance.browser})` : instance.browser 78 - workspaceNames.push(instance.name) 79 - }) 80 - } 81 - 82 - const filters = project.vitest.config.project 83 - // if there is `--project=...` filter, check if any of the potential projects match 84 - // if projects don't match, we ignore the test project altogether 85 - // if some of them match, they will later be filtered again by `resolveWorkspace` 86 - if (filters.length) { 87 - const hasProject = workspaceNames.some((name) => { 88 - return project.vitest.matchesProjectFilter(name) 89 - }) 90 - if (!hasProject) { 91 - throw new VitestFilteredOutProjectError() 92 - } 93 - } 94 - 95 67 const resolveOptions = getDefaultResolveOptions() 96 68 const config: ViteConfig = { 97 69 root, ··· 138 110 test: { 139 111 name, 140 112 }, 141 - }; 113 + } 114 + 115 + // if this project defines a browser configuration, respect --browser flag 116 + // otherwise if we always override the configuration, every project will run in browser mode 117 + if (project.vitest._options.browser && viteConfig.test?.browser) { 118 + viteConfig.test.browser = mergeConfig( 119 + viteConfig.test.browser, 120 + project.vitest._options.browser, 121 + ) 122 + } 142 123 143 124 (config.test as ResolvedConfig).defines = defines 125 + 126 + // keep project names to potentially filter it out 127 + const workspaceNames = [name] 128 + if (viteConfig.test?.browser?.enabled) { 129 + if (viteConfig.test.browser.name && !viteConfig.test.browser.instances?.length) { 130 + const browser = viteConfig.test.browser.name 131 + // vitest injects `instances` in this case later on 132 + workspaceNames.push(name ? `${name} (${browser})` : browser) 133 + } 134 + 135 + viteConfig.test.browser.instances?.forEach((instance) => { 136 + // every instance is a potential project 137 + instance.name ??= name ? `${name} (${instance.browser})` : instance.browser 138 + workspaceNames.push(instance.name) 139 + }) 140 + } 141 + 142 + const filters = project.vitest.config.project 143 + // if there is `--project=...` filter, check if any of the potential projects match 144 + // if projects don't match, we ignore the test project altogether 145 + // if some of them match, they will later be filtered again by `resolveWorkspace` 146 + if (filters.length) { 147 + const hasProject = workspaceNames.some((name) => { 148 + return project.vitest.matchesProjectFilter(name) 149 + }) 150 + if (!hasProject) { 151 + throw new VitestFilteredOutProjectError() 152 + } 153 + } 144 154 145 155 const classNameStrategy 146 156 = (typeof testConfig.css !== 'boolean'
+3 -5
packages/vitest/src/node/workspace/resolveWorkspace.ts
··· 178 178 return 179 179 } 180 180 const instances = project.config.browser.instances || [] 181 - if (instances.length === 0) { 182 - const browser = project.config.browser.name 183 - // browser.name should be defined, otherwise the config fails in "resolveConfig" 181 + const browser = project.config.browser.name 182 + if (instances.length === 0 && browser) { 184 183 instances.push({ 185 184 browser, 186 185 name: project.name ? `${project.name} (${browser})` : browser, ··· 311 310 testerHtmlPath: testerHtmlPath ?? currentConfig.testerHtmlPath, 312 311 screenshotDirectory: screenshotDirectory ?? currentConfig.screenshotDirectory, 313 312 screenshotFailures: screenshotFailures ?? currentConfig.screenshotFailures, 314 - // TODO: test that CLI arg is preferred over the local config 315 - headless: project.vitest._options?.browser?.headless ?? headless ?? currentConfig.headless, 313 + headless: headless ?? currentConfig.headless, 316 314 name: browser, 317 315 providerOptions: config, 318 316 instances: undefined, // projects cannot spawn more configs