diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md index 6c0dc155639c..6ef310c78e9e 100644 --- a/goldens/public-api/angular/build/index.api.md +++ b/goldens/public-api/angular/build/index.api.md @@ -242,6 +242,7 @@ export type UnitTestBuilderOptions = { quiet?: boolean; reporters?: SchemaReporter[]; runner?: Runner; + runnerArgs?: string[]; runnerConfig?: RunnerConfig; setupFiles?: string[]; splitting?: boolean; diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index d0ab55750e98..ac02fb717399 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -155,6 +155,7 @@ ts_project( ":node_modules/@babel/core", ":node_modules/@parcel/watcher", ":node_modules/oxc-parser", + ":node_modules/vitest", "//:node_modules/@angular/compiler-cli", "//:node_modules/@angular/router", "//:node_modules/@types/jasmine", diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts index f841670c072f..7a70d95ecde2 100644 --- a/packages/angular/build/src/builders/unit-test/options.ts +++ b/packages/angular/build/src/builders/unit-test/options.ts @@ -79,6 +79,7 @@ export async function normalizeOptions( browserViewport, ui, runnerConfig, + runnerArgs, isolate, splitting = true, polyfills, @@ -92,6 +93,10 @@ export async function normalizeOptions( throw new Error('The "isolate" option is only available for the "vitest" runner.'); } + if (runnerArgs?.length && (runner ?? Runner.Vitest) !== Runner.Vitest) { + throw new Error('The "runnerArgs" option is only available for the "vitest" runner.'); + } + const [width, height] = browserViewport?.split('x').map(Number) ?? []; let tsConfig = options.tsConfig; @@ -170,6 +175,7 @@ export async function normalizeOptions( ? true : path.resolve(workspaceRoot, runnerConfig) : runnerConfig, + runnerArgs: runnerArgs?.length ? runnerArgs : undefined, }; } diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/cli-options.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/cli-options.ts new file mode 100644 index 000000000000..ce428f36515a --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/cli-options.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { CliOptions } from 'vitest/node'; +import type { NormalizedUnitTestBuilderOptions } from '../../options'; + +/** + * The subset of the normalized builder options that are used to create the Vitest CLI options. + */ +export interface VitestCliOptionsSource extends Pick< + NormalizedUnitTestBuilderOptions, + 'workspaceRoot' | 'watch' | 'outputFile' | 'filter' | 'ui' | 'debug' +> { + cacheOptions: Pick; +} + +/** + * Creates the Vitest CLI level options from the normalized builder options. + * These are the options that Vitest would otherwise receive from its own command line. + * + * @param options The normalized builder options to convert. + * @param project The name of the Vitest project to execute. + * @param configPath The path to an external Vitest configuration file, if any. + * @param runnerArgOptions The Vitest CLI options from the `runnerArgs` builder option, if any. + * The options created from the other builder options take precedence over these. + * @returns The Vitest CLI options. + */ +export function createVitestCliOptions( + options: VitestCliOptionsSource, + project: string, + configPath: string | false | undefined, + runnerArgOptions?: CliOptions, +): CliOptions { + const { workspaceRoot, watch, outputFile, cacheOptions, filter, ui, debug } = options; + + return { + ...runnerArgOptions, + config: configPath, + root: workspaceRoot, + project, + watch, + ...(outputFile !== undefined ? { outputFile } : {}), + ...(cacheOptions.enabled ? {} : { cache: false as const }), + ...(filter !== undefined ? { testNamePattern: filter } : {}), + ...(typeof ui === 'boolean' ? { ui } : {}), + ...(debug ? { inspectBrk: true, isolate: false, fileParallelism: false } : {}), + }; +} diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/cli-options_spec.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/cli-options_spec.ts new file mode 100644 index 000000000000..c940e7695138 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/cli-options_spec.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { type VitestCliOptionsSource, createVitestCliOptions } from './cli-options'; + +describe('createVitestCliOptions', () => { + const baseOptions: VitestCliOptionsSource = { + workspaceRoot: '/workspace', + watch: false, + outputFile: undefined, + cacheOptions: { enabled: true }, + filter: undefined, + ui: undefined, + debug: false, + }; + + it('should provide the root, project, watch mode, and configuration file', () => { + expect(createVitestCliOptions(baseOptions, 'app', '/workspace/vitest-base.config.mts')).toEqual( + { + config: '/workspace/vitest-base.config.mts', + root: '/workspace', + project: 'app', + watch: false, + }, + ); + }); + + it('should disable configuration file lookup when no configuration file is used', () => { + expect(createVitestCliOptions(baseOptions, 'app', false).config).toBeFalse(); + }); + + it('should only include the output file when it is defined', () => { + expect('outputFile' in createVitestCliOptions(baseOptions, 'app', false)).toBeFalse(); + expect( + createVitestCliOptions({ ...baseOptions, outputFile: 'results.json' }, 'app', false) + .outputFile, + ).toBe('results.json'); + }); + + it('should disable the Vitest cache when the Angular CLI cache is disabled', () => { + expect('cache' in createVitestCliOptions(baseOptions, 'app', false)).toBeFalse(); + expect( + createVitestCliOptions({ ...baseOptions, cacheOptions: { enabled: false } }, 'app', false) + .cache, + ).toBeFalse(); + }); + + it('should map the filter option to the test name pattern', () => { + expect('testNamePattern' in createVitestCliOptions(baseOptions, 'app', false)).toBeFalse(); + expect( + createVitestCliOptions({ ...baseOptions, filter: 'should work' }, 'app', false) + .testNamePattern, + ).toBe('should work'); + }); + + it('should only include the UI option when it is a boolean', () => { + expect('ui' in createVitestCliOptions(baseOptions, 'app', false)).toBeFalse(); + expect(createVitestCliOptions({ ...baseOptions, ui: false }, 'app', false).ui).toBeFalse(); + expect(createVitestCliOptions({ ...baseOptions, ui: true }, 'app', false).ui).toBeTrue(); + }); + + it('should configure the inspector and disable parallelism when debugging', () => { + const options = createVitestCliOptions({ ...baseOptions, debug: true }, 'app', false); + + expect(options.inspectBrk).toBeTrue(); + expect(options.isolate).toBeFalse(); + expect(options.fileParallelism).toBeFalse(); + }); + + it('should include the options from the runner arguments', () => { + const options = createVitestCliOptions(baseOptions, 'app', false, { update: true, bail: 1 }); + + expect(options.update).toBeTrue(); + expect(options.bail).toBe(1); + }); + + it('should prefer the builder options over the options from the runner arguments', () => { + const options = createVitestCliOptions({ ...baseOptions, filter: 'builder' }, 'app', false, { + root: '/other', + watch: true, + testNamePattern: 'runner', + }); + + expect(options.root).toBe('/workspace'); + expect(options.watch).toBeFalse(); + expect(options.testNamePattern).toBe('builder'); + }); + + it('should not configure the inspector when not debugging', () => { + const options = createVitestCliOptions(baseOptions, 'app', false); + + expect('inspectBrk' in options).toBeFalse(); + expect('isolate' in options).toBeFalse(); + expect('fileParallelism' in options).toBeFalse(); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts index 6af8b0a13111..7b3c53c313e9 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts @@ -29,8 +29,10 @@ import { import { NormalizedUnitTestBuilderOptions } from '../../options'; import type { TestExecutor } from '../api'; import { setupBrowserConfiguration } from './browser-provider'; +import { createVitestCliOptions } from './cli-options'; import { findVitestBaseConfig } from './configuration'; import { createVitestConfigPlugin, createVitestPlugins } from './plugins'; +import type { RunnerArgOptions } from './runner-args'; enum DebugLogLevel { Info = 1, @@ -43,6 +45,7 @@ export class VitestExecutor implements TestExecutor { private readonly projectName: string; private readonly options: NormalizedUnitTestBuilderOptions; private readonly logger: BuilderContext['logger']; + private readonly runnerArgOptions: RunnerArgOptions | undefined; private readonly buildResultFiles = new Map(); private readonly externalMetadata: DevServerExternalResultMetadata = { implicitBrowser: [], @@ -64,6 +67,7 @@ export class VitestExecutor implements TestExecutor { options: NormalizedUnitTestBuilderOptions, testEntryPointMappings: Map | undefined, logger: BuilderContext['logger'], + runnerArgOptions?: RunnerArgOptions, ) { const level = parseInt(process.env['NG_TEST_LOG'] ?? '0', 10); this.debugLevel = isNaN(level) ? 0 : level; @@ -71,6 +75,7 @@ export class VitestExecutor implements TestExecutor { this.projectName = projectName; this.options = options; this.logger = logger; + this.runnerArgOptions = runnerArgOptions; this.debugLog(DebugLogLevel.Info, 'VitestExecutor instantiated.'); this.debugLog(DebugLogLevel.Verbose, 'NormalizedUnitTestBuilderOptions:', options); @@ -266,13 +271,11 @@ export class VitestExecutor implements TestExecutor { const { coverage, reporters, - outputFile, workspaceRoot, browsers, debug, watch, browserViewport, - ui, projectRoot, runnerConfig, projectSourceRoot, @@ -342,14 +345,6 @@ export class VitestExecutor implements TestExecutor { setupFiles: testSetupFiles, }); - const debugOptions = debug - ? { - inspectBrk: true, - isolate: false, - fileParallelism: false, - } - : {}; - const externalConfigPath = runnerConfig === true ? await findVitestBaseConfig([projectRoot, workspaceRoot]) @@ -389,17 +384,12 @@ export class VitestExecutor implements TestExecutor { } this.debugLog(DebugLogLevel.Verbose, 'Included test files (after filtering):', include); - const vitestConfig = { - config: externalConfigPath, - root: workspaceRoot, + const vitestConfig = createVitestCliOptions( + this.options, project, - watch, - ...(outputFile !== undefined ? { outputFile } : {}), - ...(cacheOptions.enabled ? {} : { cache: false as const }), - ...(this.options.filter !== undefined ? { testNamePattern: this.options.filter } : {}), - ...(typeof ui === 'boolean' ? { ui } : {}), - ...debugOptions, - }; + externalConfigPath, + this.runnerArgOptions?.cli, + ); const vitestServerConfig = { // Note `.vitest` is auto appended to the path. cacheDir: cacheOptions.path, @@ -418,6 +408,7 @@ export class VitestExecutor implements TestExecutor { watch, isolate: this.options.isolate, preserveSymlinks: this.options.preserveSymlinks, + runnerArgOptions: this.runnerArgOptions?.project, }), ], }; diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts index f879628a2462..2823072d6dbc 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts @@ -11,6 +11,7 @@ import type { TestRunner } from '../api'; import { DependencyChecker } from '../dependency-checker'; import { getVitestBuildOptions } from './build-options'; import { VitestExecutor } from './executor'; +import { type RunnerArgOptions, parseRunnerArgs } from './runner-args'; /** * A declarative definition of the Vitest test runner. @@ -79,7 +80,20 @@ const VitestTestRunner: TestRunner = { context.logger.info('Automatically searching for and using Vitest configuration file.'); } - return new VitestExecutor(projectName, options, testEntryPointMappings, context.logger); + // Parse and validate any additional Vitest arguments prior to the build to fail fast. + let runnerArgOptions: RunnerArgOptions | undefined; + if (options.runnerArgs) { + const { parseCLI } = await import('vitest/node'); + runnerArgOptions = parseRunnerArgs(options.runnerArgs, parseCLI); + } + + return new VitestExecutor( + projectName, + options, + testEntryPointMappings, + context.logger, + runnerArgOptions, + ); }, }; diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts index f430c3094b10..02817ad00031 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -57,6 +57,9 @@ interface VitestConfigPluginOptions { watch: boolean; isolate: boolean | undefined; preserveSymlinks?: boolean; + + /** Vitest project options from the `runnerArgs` builder option. */ + runnerArgOptions?: Record; } async function findTestEnvironment( @@ -307,8 +310,13 @@ export async function createVitestConfigPlugin( resolve, }; + // The order of precedence from lowest to highest is the builder defaults, the Vitest + // configuration file, the `runnerArgs` builder option, and then the other builder options. const projectBase = mergeConfig(projectDefaults, testConfig ? { test: testConfig } : {}); - const projectConfig = mergeConfig(projectBase, projectOverrides); + const projectWithRunnerArgs = options.runnerArgOptions + ? mergeConfig(projectBase, { test: options.runnerArgOptions }) + : projectBase; + const projectConfig = mergeConfig(projectWithRunnerArgs, projectOverrides); return { test: { diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/runner-args.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/runner-args.ts new file mode 100644 index 000000000000..306e480bb6e1 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/runner-args.ts @@ -0,0 +1,289 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview + * Parses and validates the Vitest command line arguments provided via the builder's + * `runnerArgs` option. + * + * Vitest's own command line parser is used to parse the arguments so that the value coercion and + * dot-notation behavior matches the Vitest documentation. However, that parser accepts unknown + * options, command names, and test file filters without error. The parsed result is therefore + * validated against an explicit list of supported options. + */ + +import type { CliOptions } from 'vitest/node'; + +/** + * The portion of the `parseCLI` function from `vitest/node` used to parse the arguments. + */ +export type VitestCliParser = (argv: string[]) => { filter: string[]; options: object }; + +/** + * The Vitest options created from the builder's `runnerArgs` option. + */ +export interface RunnerArgOptions { + /** Options for the Vitest CLI level configuration. */ + cli: CliOptions; + + /** + * Options for the Vitest project configuration. + * Vitest only forwards a version specific subset of CLI options to a configured project. + * The project applicable options are provided directly to the project to avoid relying + * on that subset. + */ + project: Record; +} + +interface SupportedOption { + /** A description of the accepted values for use in error messages. */ + readonly expects: string; + + /** Determines if the parsed value is valid for the option. */ + readonly isValid: (value: unknown) => boolean; + + /** Indicates the option only applies to the root Vitest configuration. */ + readonly rootOnly?: boolean; +} + +const isBoolean = (value: unknown): boolean => typeof value === 'boolean'; +const isCount = (value: unknown): boolean => + typeof value === 'number' && Number.isInteger(value) && value >= 0; + +const BOOLEAN_OPTION: SupportedOption = { expects: 'a boolean', isValid: isBoolean }; +const COUNT_OPTION: SupportedOption = { expects: 'a non-negative integer', isValid: isCount }; + +const SUPPORTED_SEQUENCE_OPTIONS: ReadonlyMap = new Map([ + ['shuffle', BOOLEAN_OPTION], + ['seed', COUNT_OPTION], +]); + +/** + * The Vitest CLI options that can be provided via the `runnerArgs` option. + * The keys are the camel case option names as provided by the Vitest command line parser. + */ +const SUPPORTED_OPTIONS: ReadonlyMap = new Map([ + ['allowOnly', BOOLEAN_OPTION], + ['bail', COUNT_OPTION], + ['disableConsoleIntercept', BOOLEAN_OPTION], + ['expandSnapshotDiff', BOOLEAN_OPTION], + [ + 'maxWorkers', + { + expects: 'a positive integer or a percentage', + isValid: (value) => + (typeof value === 'number' && Number.isInteger(value) && value > 0) || + (typeof value === 'string' && /^\d+%$/.test(value)), + }, + ], + ['printConsoleTrace', BOOLEAN_OPTION], + ['retry', COUNT_OPTION], + [ + 'sequence', + { + expects: `only the following options: ${[...SUPPORTED_SEQUENCE_OPTIONS.keys()] + .map((name) => `"--sequence.${name}"`) + .join(', ')}`, + isValid: (value) => + typeof value === 'object' && + value !== null && + Object.entries(value).every( + ([name, nestedValue]) => + SUPPORTED_SEQUENCE_OPTIONS.get(name)?.isValid(nestedValue) === true, + ), + }, + ], + [ + 'shard', + { + expects: 'a value in the form of /', + isValid: (value) => typeof value === 'string' && /^\d+\/\d+$/.test(value), + rootOnly: true, + }, + ], + ['testTimeout', COUNT_OPTION], + [ + 'update', + { + expects: 'a boolean, "all", "new", or "none"', + isValid: (value) => + typeof value === 'boolean' || value === 'all' || value === 'new' || value === 'none', + }, + ], +]); + +/** + * Vitest CLI options that are intentionally not supported along with the reason or alternative. + */ +const UNSUPPORTED_OPTIONS: ReadonlyMap = new Map([ + ['browser', 'Use the "browsers" and "headless" options instead.'], + [ + 'changed', + 'Tests are executed from bundled output which prevents Vitest from determining the affected tests.', + ], + ['config', 'Use the "runnerConfig" option instead.'], + ['coverage', 'Use the "coverage" options instead.'], + [ + 'dir', + 'Test discovery is managed by the builder. Use the "include" and "exclude" options instead.', + ], + ['exclude', 'Use the "exclude" option instead.'], + ['globals', 'The option is managed by the builder.'], + ['inspect', 'Use the "debug" option instead.'], + ['inspectBrk', 'Use the "debug" option instead.'], + ['isolate', 'Use the "isolate" option instead.'], + ['mode', 'The option is managed by the builder.'], + ['outputFile', 'Use the "outputFile" option instead.'], + ['passWithNoTests', 'The builder requires at least one test file.'], + ['project', 'The option is managed by the builder.'], + [ + 'related', + 'Tests are executed from bundled output which prevents Vitest from determining the affected tests.', + ], + ['reporter', 'Use the "reporters" option instead.'], + ['root', 'The option is managed by the builder.'], + ['run', 'Use the "watch" option instead.'], + ['testNamePattern', 'Use the "filter" option instead.'], + ['ui', 'Use the "ui" option instead.'], + ['watch', 'Use the "watch" option instead.'], +]); + +/** + * The single character aliases of the Vitest CLI options and their full option names. + * The Vitest command line parser provides both the alias and the full name within its result. + */ +const OPTION_ALIASES: ReadonlyMap = new Map([ + ['c', 'config'], + ['p', 'project'], + ['r', 'root'], + ['t', 'testNamePattern'], + ['u', 'update'], + ['w', 'watch'], +]); + +/** + * The names of the Vitest CLI commands. The Vitest command line parser consumes + * a command name without including it in the parsed result. + */ +const VITEST_COMMANDS: ReadonlySet = new Set([ + 'bench', + 'dev', + 'init', + 'list', + 'related', + 'run', + 'watch', +]); + +function toFlag(name: string): string { + return `--${name}`; +} + +/** + * Parses and validates the Vitest command line arguments provided via the `runnerArgs` option. + * + * @param runnerArgs The Vitest command line arguments. + * @param parseCLI The `parseCLI` function from the project's `vitest/node` package. + * @returns The validated Vitest options. + * @throws An error describing each argument that is not supported. + */ +export function parseRunnerArgs( + runnerArgs: readonly string[], + parseCLI: VitestCliParser, +): RunnerArgOptions { + const errors: string[] = []; + + // Arguments following a `--` separator are not interpreted by the parser and cannot be a command. + const separatorIndex = runnerArgs.indexOf('--'); + const interpretedArgs = separatorIndex === -1 ? runnerArgs : runnerArgs.slice(0, separatorIndex); + const commands = new Set(interpretedArgs.filter((arg) => VITEST_COMMANDS.has(arg))); + for (const command of commands) { + errors.push(`The Vitest "${command}" command is not supported.`); + } + + let parsed: ReturnType; + try { + // The parser requires the executable name as the first element and modifies the provided array. + parsed = parseCLI(['vitest', ...runnerArgs]); + } catch (e) { + throw new Error( + `Vitest was unable to parse the "runnerArgs" option: ${e instanceof Error ? e.message : String(e)}`, + { cause: e }, + ); + } + const { filter, options } = parsed; + + if (filter.length > 0) { + errors.push( + `Test file filters are not supported (${filter.map((value) => `"${value}"`).join(', ')}). ` + + 'Use the "include" option instead.', + ); + } + + const cli: Record = {}; + const project: Record = {}; + const parsedOptions = options as Record; + + for (const [name, value] of Object.entries(parsedOptions)) { + if (name === '--') { + if (Array.isArray(value) && value.length > 0) { + errors.push( + `Arguments following "--" are not supported (${value.map((arg) => `"${String(arg)}"`).join(', ')}).`, + ); + } + + continue; + } + + // `color` is always present with a default value of `true`. + if (name === 'color' && value === true) { + continue; + } + + // Some commands are also represented as an option. The command has already been reported. + if (commands.has(name) && value === true) { + continue; + } + + // Skip an alias when the full option name is also present. + const aliasedName = OPTION_ALIASES.get(name); + if (aliasedName !== undefined && aliasedName in parsedOptions) { + continue; + } + + const supported = SUPPORTED_OPTIONS.get(name); + if (supported) { + if (supported.isValid(value)) { + cli[name] = value; + if (!supported.rootOnly) { + project[name] = value; + } + } else { + errors.push(`The Vitest "${toFlag(name)}" option requires ${supported.expects}.`); + } + + continue; + } + + const unsupportedReason = UNSUPPORTED_OPTIONS.get(name); + errors.push( + `The Vitest "${toFlag(name)}" option is not supported.` + + (unsupportedReason ? ` ${unsupportedReason}` : ''), + ); + } + + if (errors.length > 0) { + throw new Error( + 'The "runnerArgs" option contains unsupported values:\n' + + errors.map((error) => ` - ${error}`).join('\n') + + `\nSupported Vitest options: ${[...SUPPORTED_OPTIONS.keys()].map(toFlag).join(', ')}`, + ); + } + + return { cli: cli as unknown as CliOptions, project }; +} diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/runner-args_spec.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/runner-args_spec.ts new file mode 100644 index 000000000000..fe472dab8275 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/runner-args_spec.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { type VitestCliParser, parseRunnerArgs } from './runner-args'; + +describe('parseRunnerArgs', () => { + let parseCLI: VitestCliParser; + + beforeAll(async () => { + // Use the actual Vitest parser to ensure the validation matches its behavior. + ({ parseCLI } = await import('vitest/node')); + }); + + function parse(...runnerArgs: string[]) { + return parseRunnerArgs(runnerArgs, parseCLI); + } + + describe('supported options', () => { + it('should return no options when no arguments are provided', () => { + expect(parse()).toEqual({ cli: {}, project: {} }); + }); + + it('should support a boolean option', () => { + expect(parse('--update')).toEqual({ cli: { update: true }, project: { update: true } }); + }); + + it('should support a negated boolean option', () => { + expect(parse('--no-update').cli).toEqual({ update: false }); + }); + + it('should support the alias of an option', () => { + expect(parse('-u')).toEqual({ cli: { update: true }, project: { update: true } }); + }); + + it('should support a kebab case option name', () => { + expect(parse('--disable-console-intercept').cli).toEqual({ disableConsoleIntercept: true }); + }); + + it('should support the named update modes', () => { + expect(parse('--update=all').cli).toEqual({ update: 'all' }); + expect(parse('--update=new').cli).toEqual({ update: 'new' }); + expect(parse('--update=none').cli).toEqual({ update: 'none' }); + }); + + it('should support a numeric value provided with an equals sign', () => { + expect(parse('--bail=1').cli).toEqual({ bail: 1 }); + }); + + it('should support a numeric value provided as a separate argument', () => { + expect(parse('--bail', '1').cli).toEqual({ bail: 1 }); + }); + + it('should support multiple options', () => { + expect(parse('--retry=2', '--testTimeout=10000', '--allowOnly').cli).toEqual({ + retry: 2, + testTimeout: 10000, + allowOnly: true, + }); + }); + + it('should support a worker count or percentage', () => { + expect(parse('--maxWorkers=2').cli).toEqual({ maxWorkers: 2 }); + expect(parse('--maxWorkers=50%').cli).toEqual({ maxWorkers: '50%' }); + }); + + it('should support the sequence shuffle and seed options', () => { + const expected = { sequence: { shuffle: true, seed: 42 } }; + + expect(parse('--sequence.shuffle', '--sequence.seed=42')).toEqual({ + cli: expected, + project: expected, + }); + }); + + it('should only provide the shard option to the CLI options', () => { + expect(parse('--shard=1/3')).toEqual({ cli: { shard: '1/3' }, project: {} }); + }); + + it('should not modify the provided arguments', () => { + const runnerArgs = Object.freeze(['--update', '--bail=1']); + + expect(parseRunnerArgs(runnerArgs, parseCLI).cli).toEqual({ update: true, bail: 1 }); + expect(runnerArgs).toEqual(['--update', '--bail=1']); + }); + }); + + describe('unsupported values', () => { + it('should reject an unknown option', () => { + expect(() => parse('--updat')).toThrowError(/The Vitest "--updat" option is not supported\./); + }); + + it('should list the supported options', () => { + expect(() => parse('--unknown')).toThrowError(/Supported Vitest options: .*--update/); + }); + + it('should reject an option name that is an inherited object property', () => { + expect(() => parse('--toString')).toThrowError(/"runnerArgs" option/); + }); + + it('should reject an invalid value for a supported option', () => { + expect(() => parse('--bail=abc')).toThrowError( + /The Vitest "--bail" option requires a non-negative integer\./, + ); + expect(() => parse('--bail')).toThrowError( + /The Vitest "--bail" option requires a non-negative integer\./, + ); + expect(() => parse('--update=other')).toThrowError( + /The Vitest "--update" option requires a boolean, "all", "new", or "none"\./, + ); + expect(() => parse('--maxWorkers=0')).toThrowError( + /The Vitest "--maxWorkers" option requires a positive integer or a percentage\./, + ); + expect(() => parse('--shard=1')).toThrowError( + /The Vitest "--shard" option requires a value in the form of \/\./, + ); + }); + + it('should reject an unsupported nested sequence option', () => { + expect(() => parse('--sequence.concurrent')).toThrowError( + /The Vitest "--sequence" option requires only the following options: "--sequence.shuffle", "--sequence.seed"\./, + ); + }); + + it('should direct options with a builder equivalent to the builder option', () => { + expect(() => parse('--reporter=dot')).toThrowError( + /The Vitest "--reporter" option is not supported\. Use the "reporters" option instead\./, + ); + expect(() => parse('--coverage.enabled')).toThrowError( + /The Vitest "--coverage" option is not supported\. Use the "coverage" options instead\./, + ); + expect(() => parse('--ui')).toThrowError(/Use the "ui" option instead\./); + expect(() => parse('--isolate')).toThrowError(/Use the "isolate" option instead\./); + expect(() => parse('--watch')).toThrowError(/Use the "watch" option instead\./); + expect(() => parse('--config=vitest.config.ts')).toThrowError( + /Use the "runnerConfig" option instead\./, + ); + }); + + it('should report the full option name when an unsupported alias is used', () => { + expect(() => parse('-t', 'should work')).toThrowError( + /The Vitest "--testNamePattern" option is not supported\. Use the "filter" option instead\./, + ); + }); + + it('should reject options that are managed by the builder', () => { + expect(() => parse('--root=/other')).toThrowError( + /The Vitest "--root" option is not supported\. The option is managed by the builder\./, + ); + expect(() => parse('--project=other')).toThrowError( + /The Vitest "--project" option is not supported\. The option is managed by the builder\./, + ); + }); + + it('should reject options that select tests based on changed files', () => { + expect(() => parse('--changed')).toThrowError( + /The Vitest "--changed" option is not supported\. Tests are executed from bundled output/, + ); + expect(() => parse('--related=src/app/app.ts')).toThrowError( + /The Vitest "--related" option is not supported\. Tests are executed from bundled output/, + ); + }); + + it('should reject test file filters', () => { + expect(() => parse('src/app/app.spec.ts')).toThrowError( + /Test file filters are not supported \("src\/app\/app\.spec\.ts"\)\. Use the "include" option instead\./, + ); + }); + + it('should reject arguments following a "--" separator', () => { + expect(() => parse('--update', '--', 'src/app/app.spec.ts')).toThrowError( + /Arguments following "--" are not supported \("src\/app\/app\.spec\.ts"\)\./, + ); + }); + + it('should reject Vitest commands', () => { + expect(() => parse('bench')).toThrowError(/The Vitest "bench" command is not supported\./); + expect(() => parse('--update', 'list')).toThrowError( + /The Vitest "list" command is not supported\./, + ); + }); + + it('should not report a command name that follows a "--" separator as a command', () => { + let message = ''; + try { + parse('--update', '--', 'run'); + } catch (e) { + message = (e as Error).message; + } + + expect(message).toContain('Arguments following "--" are not supported ("run").'); + expect(message).not.toContain('command is not supported'); + }); + + it('should only report a command once when it is also represented as an option', () => { + let message = ''; + try { + parse('run'); + } catch (e) { + message = (e as Error).message; + } + + expect(message).toContain('The Vitest "run" command is not supported.'); + expect(message).not.toContain('"--run"'); + }); + + it('should report all unsupported values together', () => { + let message = ''; + try { + parse('--unknown', '--reporter=dot', 'src/app/app.spec.ts'); + } catch (e) { + message = (e as Error).message; + } + + expect(message).toContain('The "runnerArgs" option contains unsupported values:'); + expect(message).toContain('The Vitest "--unknown" option is not supported.'); + expect(message).toContain('The Vitest "--reporter" option is not supported.'); + expect(message).toContain('Test file filters are not supported'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/schema.json b/packages/angular/build/src/builders/unit-test/schema.json index 4a7d6521a157..d004e7024831 100644 --- a/packages/angular/build/src/builders/unit-test/schema.json +++ b/packages/angular/build/src/builders/unit-test/schema.json @@ -24,6 +24,13 @@ "description": "Specifies the configuration file for the selected test runner. If a string is provided, it will be used as the path to the configuration file. If `true`, the builder will search for a default configuration file (e.g., `vitest-base.config.mts` or `karma.conf.js`). If `false`, no external configuration file will be used.\\nFor Vitest, this enables advanced options and the use of custom plugins. Please note that while the file is loaded, the Angular team does not provide direct support for its specific contents or any third-party plugins used within it.", "default": false }, + "runnerArgs": { + "type": "array", + "description": "Additional command line arguments to provide to the test runner. Each argument must be a separate value, for example `--runner-args=--update --runner-args=--bail=1`. This option is only available for the Vitest runner and only a subset of the Vitest command line options is supported. Options that have an equivalent builder option, such as `--reporter` or `--coverage`, must be configured with the builder option.", + "items": { + "type": "string" + } + }, "browsers": { "description": "Specifies the browsers to use for test execution. When not specified, the Vitest runner runs tests in a Node.js environment using `happy-dom` if it is installed, otherwise `jsdom`, and the Karma runner uses the browsers of its configuration. Browser names ending with `Headless` enable headless mode (e.g., `ChromiumHeadless` for Vitest with the Playwright provider, or `ChromeHeadless` for Karma).", "type": "array", diff --git a/packages/angular/build/src/builders/unit-test/tests/options/runner-args_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/runner-args_spec.ts new file mode 100644 index 000000000000..d4adf9f3096c --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/runner-args_spec.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + UNIT_TEST_BUILDER_INFO, + describeBuilder, + expectLog, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "runnerArgs"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should fail when used with the karma runner', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runner: 'karma' as any, + runnerArgs: ['--update'], + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + expectLog(logs, /The "runnerArgs" option is only available for the "vitest" runner/); + }); + + it('should fail when an unsupported Vitest option is provided', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerArgs: ['--reporter=dot'], + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + expectLog( + logs, + /The Vitest "--reporter" option is not supported\. Use the "reporters" option instead\./, + ); + }); + + describe('with a test that passes on the second attempt', () => { + beforeEach(async () => { + await harness.writeFile( + 'src/app/retry.spec.ts', + ` + let attempts = 0; + + it('passes on the second attempt', () => { + attempts++; + expect(attempts).toBe(2); + }); + `, + ); + }); + + it('should fail when the test is not retried', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + }); + + it('should provide a supported Vitest option to the test project', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerArgs: ['--retry=1'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should take precedence over a Vitest configuration file', async () => { + await harness.writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + + export default defineConfig({ + test: { + retry: 0, + }, + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + runnerArgs: ['--retry=1'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); + + describe('with an outdated snapshot', () => { + const snapshotFile = 'src/app/__snapshots__/snapshot.spec.ts.snap'; + + beforeEach(async () => { + await harness.writeFile( + 'src/app/snapshot.spec.ts', + ` + import { expect, it } from 'vitest'; + + it('matches the snapshot', () => { + expect('current value').toMatchSnapshot(); + }); + `, + ); + + await harness.writeFile( + snapshotFile, + '// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\n' + + 'exports[`matches the snapshot 1`] = `"outdated value"`;\n', + ); + }); + + it('should fail when the snapshot is not updated', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + harness.expectFile(snapshotFile).content.toContain('"outdated value"'); + }); + + it('should update the snapshot when the Vitest update option is provided', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerArgs: ['--update'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile(snapshotFile).content.toContain('"current value"'); + harness.expectFile(snapshotFile).content.not.toContain('"outdated value"'); + }); + }); + }); +});