Skip to content

Commit

Permalink
feat(cli): Support specifying a line number when filtering tests (#6411)
Browse files Browse the repository at this point in the history
Co-authored-by: Vladimir <[email protected]>
  • Loading branch information
mzhubail and sheremet-va authored Dec 1, 2024
1 parent bf7b36a commit 4d94b95
Show file tree
Hide file tree
Showing 30 changed files with 584 additions and 76 deletions.
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

0 comments on commit 4d94b95

Please sign in to comment.