Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): Support specifying a line number when filtering tests #6411

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
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
15 changes: 15 additions & 0 deletions docs/guide/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ basic/foo.test.ts

You can also use the `-t, --testNamePattern <pattern>` option to filter tests by full name. This can be helpful when you want to filter by the name defined within a file rather than the filename itself.

Since Vitest 2.2, you can also specify the test by filename and line number:

```bash
$ vitest basic/foo.test.ts:10
```

::: warning
Note that you have to specify the full filename, and specify the exact line number, i.e. you can't do

```bash
$ vitest foo:10
$ vitest basic/foo.test.ts:10-25
```
:::

## Specifying a Timeout

You can optionally pass a timeout in milliseconds as a third argument to tests. The default is [5 seconds](/config/#testtimeout).
Expand Down
10 changes: 7 additions & 3 deletions packages/runner/src/collect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { VitestRunner } from './types/runner'
import type { FileSpec, VitestRunner } from './types/runner'
import type { File, SuiteHooks } from './types/tasks'
import { toArray } from '@vitest/utils'
import { processError } from '@vitest/utils/error'
Expand All @@ -20,14 +20,17 @@ import {
const now = globalThis.performance ? globalThis.performance.now.bind(globalThis.performance) : Date.now

export async function collectTests(
paths: string[],
specs: string[] | FileSpec[],
runner: VitestRunner,
): Promise<File[]> {
const files: File[] = []

const config = runner.config

for (const filepath of paths) {
for (const spec of specs) {
const filepath = typeof spec === 'string' ? spec : spec.filepath
const testLocations = typeof spec === 'string' ? undefined : spec.testLocations

const file = createFileTask(filepath, config.root, config.name, runner.pool)

runner.onCollectStart?.(file)
Expand Down Expand Up @@ -97,6 +100,7 @@ export async function collectTests(
interpretTaskModes(
file,
config.testNamePattern,
testLocations,
hasOnlyTasks,
false,
config.allowOnly,
Expand Down
13 changes: 8 additions & 5 deletions packages/runner/src/run.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Awaitable } from '@vitest/utils'
import type { DiffOptions } from '@vitest/utils/diff'
import type { VitestRunner } from './types/runner'
import type { FileSpec, VitestRunner } from './types/runner'
import type {
Custom,
File,
Expand Down Expand Up @@ -498,10 +498,11 @@ export async function runFiles(files: File[], runner: VitestRunner): Promise<voi
}
}

export async function startTests(paths: string[], runner: VitestRunner): Promise<File[]> {
export async function startTests(specs: string[] | FileSpec[], runner: VitestRunner): Promise<File[]> {
const paths = specs.map(f => typeof f === 'string' ? f : f.filepath)
await runner.onBeforeCollect?.(paths)

const files = await collectTests(paths, runner)
const files = await collectTests(specs, runner)

await runner.onCollected?.(files)
await runner.onBeforeRunFiles?.(files)
Expand All @@ -515,10 +516,12 @@ export async function startTests(paths: string[], runner: VitestRunner): Promise
return files
}

async function publicCollect(paths: string[], runner: VitestRunner): Promise<File[]> {
async function publicCollect(specs: string[] | FileSpec[], runner: VitestRunner): Promise<File[]> {
const paths = specs.map(f => typeof f === 'string' ? f : f.filepath)

await runner.onBeforeCollect?.(paths)

const files = await collectTests(paths, runner)
const files = await collectTests(specs, runner)

await runner.onCollected?.(files)
return files
Expand Down
1 change: 1 addition & 0 deletions packages/runner/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type {
CancelReason,
FileSpec,
VitestRunner,
VitestRunnerConfig,
VitestRunnerConstructor,
Expand Down
5 changes: 5 additions & 0 deletions packages/runner/src/types/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export interface VitestRunnerConfig {
diffOptions?: DiffOptions
}

export interface FileSpec {
filepath: string
testLocations: number[] | undefined
}

export type VitestRunnerImportSource = 'collect' | 'setup'

export interface VitestRunnerConstructor {
Expand Down
101 changes: 71 additions & 30 deletions packages/runner/src/utils/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,53 +6,94 @@ import { relative } from 'pathe'
* If any tasks been marked as `only`, mark all other tasks as `skip`.
*/
export function interpretTaskModes(
suite: Suite,
file: Suite,
namePattern?: string | RegExp,
testLocations?: number[] | undefined,
onlyMode?: boolean,
parentIsOnly?: boolean,
allowOnly?: boolean,
): void {
const suiteIsOnly = parentIsOnly || suite.mode === 'only'
const matchedLocations: number[] = []

suite.tasks.forEach((t) => {
// Check if either the parent suite or the task itself are marked as included
const includeTask = suiteIsOnly || t.mode === 'only'
if (onlyMode) {
if (t.type === 'suite' && (includeTask || someTasksAreOnly(t))) {
// Don't skip this suite
if (t.mode === 'only') {
const traverseSuite = (suite: Suite, parentIsOnly?: boolean) => {
const suiteIsOnly = parentIsOnly || suite.mode === 'only'

suite.tasks.forEach((t) => {
// Check if either the parent suite or the task itself are marked as included
const includeTask = suiteIsOnly || t.mode === 'only'
if (onlyMode) {
if (t.type === 'suite' && (includeTask || someTasksAreOnly(t))) {
// Don't skip this suite
if (t.mode === 'only') {
checkAllowOnly(t, allowOnly)
t.mode = 'run'
}
}
else if (t.mode === 'run' && !includeTask) {
t.mode = 'skip'
}
else if (t.mode === 'only') {
checkAllowOnly(t, allowOnly)
t.mode = 'run'
}
}
else if (t.mode === 'run' && !includeTask) {
t.mode = 'skip'
if (t.type === 'test') {
if (namePattern && !getTaskFullName(t).match(namePattern)) {
t.mode = 'skip'
}

// Match test location against provided locations, only run if present
// in `testLocations`. Note: if `includeTaskLocations` is not enabled,
// all test will be skipped.
if (testLocations !== undefined && testLocations.length !== 0) {
if (t.location && testLocations?.includes(t.location.line)) {
t.mode = 'run'
matchedLocations.push(t.location.line)
}
else {
t.mode = 'skip'
}
}
}
else if (t.mode === 'only') {
checkAllowOnly(t, allowOnly)
t.mode = 'run'
else if (t.type === 'suite') {
if (t.mode === 'skip') {
skipAllTasks(t)
}
else {
traverseSuite(t, includeTask)
}
}
}
if (t.type === 'test') {
if (namePattern && !getTaskFullName(t).match(namePattern)) {
t.mode = 'skip'
})

// if all subtasks are skipped, mark as skip
if (suite.mode === 'run') {
if (suite.tasks.length && suite.tasks.every(i => i.mode !== 'run')) {
suite.mode = 'skip'
}
}
else if (t.type === 'suite') {
if (t.mode === 'skip') {
skipAllTasks(t)
}
else {
interpretTaskModes(t, namePattern, onlyMode, includeTask, allowOnly)
}

traverseSuite(file, parentIsOnly)

const nonMatching = testLocations?.filter(loc => !matchedLocations.includes(loc))
if (nonMatching && nonMatching.length !== 0) {
const message = nonMatching.length === 1
? `line ${nonMatching[0]}`
: `lines ${nonMatching.join(', ')}`

if (file.result === undefined) {
file.result = {
state: 'fail',
errors: [],
}
}
})

// if all subtasks are skipped, mark as skip
if (suite.mode === 'run') {
if (suite.tasks.length && suite.tasks.every(i => i.mode !== 'run')) {
suite.mode = 'skip'
if (file.result.errors === undefined) {
file.result.errors = []
}

file.result.errors.push(
processError(new Error(`No test found in ${file.name} in ${message}`)),
)
}
}

Expand Down
11 changes: 10 additions & 1 deletion packages/vitest/src/node/cli/cli-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getNames, getTests } from '@vitest/runner/utils'
import { dirname, relative, resolve } from 'pathe'
import { CoverageProviderMap } from '../../integrations/coverage'
import { createVitest } from '../create'
import { FilesNotFoundError, GitNotFoundError } from '../errors'
import { FilesNotFoundError, GitNotFoundError, IncludeTaskLocationDisabledError, LocationFilterFileNotFoundError, RangeLocationFilterProvidedError } from '../errors'
import { registerConsoleShortcuts } from '../stdin'

export interface CliOptions extends UserConfig {
Expand Down Expand Up @@ -103,6 +103,15 @@ export async function startVitest(
return ctx
}

if (
e instanceof IncludeTaskLocationDisabledError
|| e instanceof RangeLocationFilterProvidedError
|| e instanceof LocationFilterFileNotFoundError
) {
ctx.logger.printError(e, { verbose: false })
return ctx
}

process.exitCode = 1
ctx.logger.printError(e, { fullStack: true, type: 'Unhandled Error' })
ctx.logger.error('\n\n')
Expand Down
49 changes: 49 additions & 0 deletions packages/vitest/src/node/cli/filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { groupBy } from '../../utils/base'
import { RangeLocationFilterProvidedError } from '../errors'

export function parseFilter(filter: string): Filter {
const colonIndex = filter.lastIndexOf(':')
if (colonIndex === -1) {
return { filename: filter }
}

const [parsedFilename, lineNumber] = [
filter.substring(0, colonIndex),
filter.substring(colonIndex + 1),
]

if (lineNumber.match(/^\d+$/)) {
return {
filename: parsedFilename,
lineNumber: Number.parseInt(lineNumber),
}
}
else if (lineNumber.match(/^\d+-\d+$/)) {
throw new RangeLocationFilterProvidedError(filter)
}
else {
return { filename: filter }
}
}

interface Filter {
filename: string
lineNumber?: undefined | number
}

export function groupFilters(filters: Filter[]) {
const groupedFilters_ = groupBy(filters, f => f.filename)
const groupedFilters = Object.fromEntries(Object.entries(groupedFilters_)
.map((entry) => {
const [filename, filters] = entry
const testLocations = filters.map(f => f.lineNumber)

return [
filename,
testLocations.filter(l => l !== undefined) as number[],
]
}),
)

return groupedFilters
}
46 changes: 42 additions & 4 deletions packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { ResolvedConfig, UserConfig, VitestRunMode } from './types/config'
import type { CoverageProvider } from './types/coverage'
import type { Reporter } from './types/reporter'
import { existsSync, promises as fs, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { getTasks, hasFailed } from '@vitest/runner/utils'
import { SnapshotManager } from '@vitest/snapshot/manager'
import { noop, slash, toArray } from '@vitest/utils'
Expand All @@ -25,8 +26,9 @@ import { getCoverageProvider } from '../integrations/coverage'
import { distDir } from '../paths'
import { wildcardPatternToRegExp } from '../utils/base'
import { VitestCache } from './cache'
import { groupFilters, parseFilter } from './cli/filter'
import { resolveConfig } from './config/resolveConfig'
import { FilesNotFoundError, GitNotFoundError } from './errors'
import { FilesNotFoundError, GitNotFoundError, IncludeTaskLocationDisabledError, LocationFilterFileNotFoundError } from './errors'
import { Logger } from './logger'
import { VitestPackageInstaller } from './packageInstaller'
import { createPool } from './pool'
Expand Down Expand Up @@ -1144,19 +1146,55 @@ export class Vitest {

public async globTestSpecs(filters: string[] = []) {
const files: TestSpecification[] = []
const dir = process.cwd()
const parsedFilters = filters.map(f => parseFilter(f))

// Require includeTaskLocation when a location filter is passed
if (
!this.config.includeTaskLocation
&& parsedFilters.some(f => f.lineNumber !== undefined)
) {
throw new IncludeTaskLocationDisabledError()
}

const testLocations = groupFilters(parsedFilters.map(
f => ({ ...f, filename: slash(resolve(dir, f.filename)) }),
))

// Key is file and val sepcifies whether we have matched this file with testLocation
const testLocHasMatch: { [f: string]: boolean } = {}

await Promise.all(this.projects.map(async (project) => {
const { testFiles, typecheckTestFiles } = await project.globTestFiles(filters)
const { testFiles, typecheckTestFiles } = await project.globTestFiles(
parsedFilters.map(f => f.filename),
)

testFiles.forEach((file) => {
const spec = project.createSpecification(file)
const loc = testLocations[file]
testLocHasMatch[file] = true

const spec = project.createSpecification(file, undefined, loc)
this.ensureSpecCached(spec)
files.push(spec)
})
typecheckTestFiles.forEach((file) => {
const spec = project.createSpecification(file, 'typescript')
const loc = testLocations[file]
testLocHasMatch[file] = true

const spec = project.createSpecification(file, 'typescript', loc)
this.ensureSpecCached(spec)
files.push(spec)
})
}))

Object.entries(testLocations).forEach(([filepath, loc]) => {
if (loc.length !== 0 && !testLocHasMatch[filepath]) {
throw new LocationFilterFileNotFoundError(
relative(dir, filepath),
)
}
})

return files as WorkspaceSpec[]
}

Expand Down
Loading
Loading