Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions packages/vitest/src/node/cli/cac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import cac from 'cac'
import { normalize } from 'pathe'
import c from 'tinyrainbow'
import { version } from '../../../package.json' with { type: 'json' }
import { validateNestedOptions } from '../../utils/validate-nested-options'
import { benchCliOptionsConfig, cliOptionsConfig, collectCliOptionsConfig } from './cli-config'

function addCommand(cli: CAC | Command, name: string, option: CLIOption<any>) {
Expand Down Expand Up @@ -187,13 +188,19 @@ export function createCLI(options: CliParseOptions = {}): CAC {
addCliOptions(
cli
.command('list [...filters]', undefined, options)
.action((filters, options) => collect('test', filters, options)),
.action((filters, options) => {
validateNestedOptions(options, cliOptionsConfig)
collect('test', filters, options)
}),
collectCliOptionsConfig,
)

cli
.command('[...filters]', undefined, options)
.action((filters, options) => start('test', filters, options))
.action((filters, options) => {
validateNestedOptions(options, cliOptionsConfig)
start('test', filters, options)
})

return cli
}
Expand Down Expand Up @@ -236,6 +243,10 @@ export function parseCLI(argv: string | string[], config: CliParseOptions = {}):
let { args, options } = createCLI(config).parse(arrayArgs, {
run: false,
})

// Validate nested options (e.g., experimental.*) against their defined subcommands
validateNestedOptions(options, cliOptionsConfig)

if (arrayArgs[2] === 'watch' || arrayArgs[2] === 'dev') {
options.watch = true
}
Expand All @@ -254,24 +265,28 @@ export function parseCLI(argv: string | string[], config: CliParseOptions = {}):
}

async function runRelated(relatedFiles: string[] | string, argv: CliOptions): Promise<void> {
validateNestedOptions(argv, cliOptionsConfig)
argv.related = relatedFiles
argv.passWithNoTests ??= true
await start('test', [], argv)
}

async function watch(cliFilters: string[], options: CliOptions): Promise<void> {
validateNestedOptions(options, cliOptionsConfig)
options.watch = true
await start('test', cliFilters, options)
}

async function run(cliFilters: string[], options: CliOptions): Promise<void> {
validateNestedOptions(options, cliOptionsConfig)
// "vitest run --watch" should still be watch mode
options.run = !options.watch

await start('test', cliFilters, options)
}

async function benchmark(cliFilters: string[], options: CliOptions): Promise<void> {
validateNestedOptions(options, cliOptionsConfig)
console.warn(c.yellow('Benchmarking is an experimental feature.\nBreaking changes might not follow SemVer, please pin Vitest\'s version when using it.'))
await start('benchmark', cliFilters, options)
}
Expand Down
73 changes: 73 additions & 0 deletions packages/vitest/src/utils/validate-nested-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { CliOptions } from '../node/cli/cli-api'
import type { CLIOptions as CLIOptionsConfig } from '../node/cli/cli-config'

function throwUnknownOptionError(path: string, key: string, validSubcommands: Set<string>): never {
const validOptionsList = Array.from(validSubcommands).map(opt => `"${path}.${opt}"`).join(', ')
const suggestions = validOptionsList || 'none'
throw new Error(
`Unknown option "${path}.${key}". `
+ `Did you mean one of: ${suggestions}? `
+ `Use '--help --${path.split('.')[0]}' for more info.`,
)
}

function validateOptionValue(
value: any,
key: string,
subcommandConfig: any,
currentPath: string,
): void {
if (subcommandConfig && 'subcommands' in subcommandConfig && subcommandConfig.subcommands) {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
const nestedPath = `${currentPath}.${key}`
validateNestedOptions(value as CliOptions, subcommandConfig.subcommands!, nestedPath)
}
}
}

function validateOptionsAgainstConfig(
options: CliOptions,
config: CLIOptionsConfig<any>,
path: string,
): void {
const validSubcommands = new Set(Object.keys(config))

for (const key in options) {
if (!validSubcommands.has(key)) {
throwUnknownOptionError(path, key, validSubcommands)
}

const subcommandConfig = config[key]
const optionValue = (options as any)[key]
validateOptionValue(optionValue, key, subcommandConfig, path)
}
}

export function validateNestedOptions(options: CliOptions, config: CLIOptionsConfig<any>, path: string = ''): void {
// If path is provided, we're in a recursive call and should validate all keys in options
// against the config. Otherwise, iterate over config entries to find nested options.
if (path) {
// Recursive case: validate all keys in options against config
validateOptionsAgainstConfig(options, config, path)
}
else {
// Top-level case: iterate over config entries to find nested options
for (const [optionName, option] of Object.entries(config)) {
if (!option) {
continue
}

const hasSubcommands = 'subcommands' in option && option.subcommands
if (!hasSubcommands) {
continue
}

const optionValue = (options as any)[optionName]
if (optionValue == null || typeof optionValue !== 'object' || Array.isArray(optionValue)) {
continue
}

validateOptionsAgainstConfig(optionValue as CliOptions, option.subcommands!, optionName)
}
}
}
Loading
Loading