Skip to content

Commit 4d94b95

Browse files
feat(cli): Support specifying a line number when filtering tests (#6411)
Co-authored-by: Vladimir <[email protected]>
1 parent bf7b36a commit 4d94b95

30 files changed

+584
-76
lines changed

docs/guide/filtering.md

+15
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,21 @@ basic/foo.test.ts
2424

2525
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.
2626

27+
Since Vitest 2.2, you can also specify the test by filename and line number:
28+
29+
```bash
30+
$ vitest basic/foo.test.ts:10
31+
```
32+
33+
::: warning
34+
Note that you have to specify the full filename, and specify the exact line number, i.e. you can't do
35+
36+
```bash
37+
$ vitest foo:10
38+
$ vitest basic/foo.test.ts:10-25
39+
```
40+
:::
41+
2742
## Specifying a Timeout
2843

2944
You can optionally pass a timeout in milliseconds as a third argument to tests. The default is [5 seconds](/config/#testtimeout).

packages/runner/src/collect.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { VitestRunner } from './types/runner'
1+
import type { FileSpec, VitestRunner } from './types/runner'
22
import type { File, SuiteHooks } from './types/tasks'
33
import { toArray } from '@vitest/utils'
44
import { processError } from '@vitest/utils/error'
@@ -20,14 +20,17 @@ import {
2020
const now = globalThis.performance ? globalThis.performance.now.bind(globalThis.performance) : Date.now
2121

2222
export async function collectTests(
23-
paths: string[],
23+
specs: string[] | FileSpec[],
2424
runner: VitestRunner,
2525
): Promise<File[]> {
2626
const files: File[] = []
2727

2828
const config = runner.config
2929

30-
for (const filepath of paths) {
30+
for (const spec of specs) {
31+
const filepath = typeof spec === 'string' ? spec : spec.filepath
32+
const testLocations = typeof spec === 'string' ? undefined : spec.testLocations
33+
3134
const file = createFileTask(filepath, config.root, config.name, runner.pool)
3235

3336
runner.onCollectStart?.(file)
@@ -97,6 +100,7 @@ export async function collectTests(
97100
interpretTaskModes(
98101
file,
99102
config.testNamePattern,
103+
testLocations,
100104
hasOnlyTasks,
101105
false,
102106
config.allowOnly,

packages/runner/src/run.ts

+8-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Awaitable } from '@vitest/utils'
22
import type { DiffOptions } from '@vitest/utils/diff'
3-
import type { VitestRunner } from './types/runner'
3+
import type { FileSpec, VitestRunner } from './types/runner'
44
import type {
55
Custom,
66
File,
@@ -498,10 +498,11 @@ export async function runFiles(files: File[], runner: VitestRunner): Promise<voi
498498
}
499499
}
500500

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

504-
const files = await collectTests(paths, runner)
505+
const files = await collectTests(specs, runner)
505506

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

518-
async function publicCollect(paths: string[], runner: VitestRunner): Promise<File[]> {
519+
async function publicCollect(specs: string[] | FileSpec[], runner: VitestRunner): Promise<File[]> {
520+
const paths = specs.map(f => typeof f === 'string' ? f : f.filepath)
521+
519522
await runner.onBeforeCollect?.(paths)
520523

521-
const files = await collectTests(paths, runner)
524+
const files = await collectTests(specs, runner)
522525

523526
await runner.onCollected?.(files)
524527
return files

packages/runner/src/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export type {
22
CancelReason,
3+
FileSpec,
34
VitestRunner,
45
VitestRunnerConfig,
56
VitestRunnerConstructor,

packages/runner/src/types/runner.ts

+5
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ export interface VitestRunnerConfig {
4040
diffOptions?: DiffOptions
4141
}
4242

43+
export interface FileSpec {
44+
filepath: string
45+
testLocations: number[] | undefined
46+
}
47+
4348
export type VitestRunnerImportSource = 'collect' | 'setup'
4449

4550
export interface VitestRunnerConstructor {

packages/runner/src/utils/collect.ts

+71-30
Original file line numberDiff line numberDiff line change
@@ -6,53 +6,94 @@ import { relative } from 'pathe'
66
* If any tasks been marked as `only`, mark all other tasks as `skip`.
77
*/
88
export function interpretTaskModes(
9-
suite: Suite,
9+
file: Suite,
1010
namePattern?: string | RegExp,
11+
testLocations?: number[] | undefined,
1112
onlyMode?: boolean,
1213
parentIsOnly?: boolean,
1314
allowOnly?: boolean,
1415
): void {
15-
const suiteIsOnly = parentIsOnly || suite.mode === 'only'
16+
const matchedLocations: number[] = []
1617

17-
suite.tasks.forEach((t) => {
18-
// Check if either the parent suite or the task itself are marked as included
19-
const includeTask = suiteIsOnly || t.mode === 'only'
20-
if (onlyMode) {
21-
if (t.type === 'suite' && (includeTask || someTasksAreOnly(t))) {
22-
// Don't skip this suite
23-
if (t.mode === 'only') {
18+
const traverseSuite = (suite: Suite, parentIsOnly?: boolean) => {
19+
const suiteIsOnly = parentIsOnly || suite.mode === 'only'
20+
21+
suite.tasks.forEach((t) => {
22+
// Check if either the parent suite or the task itself are marked as included
23+
const includeTask = suiteIsOnly || t.mode === 'only'
24+
if (onlyMode) {
25+
if (t.type === 'suite' && (includeTask || someTasksAreOnly(t))) {
26+
// Don't skip this suite
27+
if (t.mode === 'only') {
28+
checkAllowOnly(t, allowOnly)
29+
t.mode = 'run'
30+
}
31+
}
32+
else if (t.mode === 'run' && !includeTask) {
33+
t.mode = 'skip'
34+
}
35+
else if (t.mode === 'only') {
2436
checkAllowOnly(t, allowOnly)
2537
t.mode = 'run'
2638
}
2739
}
28-
else if (t.mode === 'run' && !includeTask) {
29-
t.mode = 'skip'
40+
if (t.type === 'test') {
41+
if (namePattern && !getTaskFullName(t).match(namePattern)) {
42+
t.mode = 'skip'
43+
}
44+
45+
// Match test location against provided locations, only run if present
46+
// in `testLocations`. Note: if `includeTaskLocations` is not enabled,
47+
// all test will be skipped.
48+
if (testLocations !== undefined && testLocations.length !== 0) {
49+
if (t.location && testLocations?.includes(t.location.line)) {
50+
t.mode = 'run'
51+
matchedLocations.push(t.location.line)
52+
}
53+
else {
54+
t.mode = 'skip'
55+
}
56+
}
3057
}
31-
else if (t.mode === 'only') {
32-
checkAllowOnly(t, allowOnly)
33-
t.mode = 'run'
58+
else if (t.type === 'suite') {
59+
if (t.mode === 'skip') {
60+
skipAllTasks(t)
61+
}
62+
else {
63+
traverseSuite(t, includeTask)
64+
}
3465
}
35-
}
36-
if (t.type === 'test') {
37-
if (namePattern && !getTaskFullName(t).match(namePattern)) {
38-
t.mode = 'skip'
66+
})
67+
68+
// if all subtasks are skipped, mark as skip
69+
if (suite.mode === 'run') {
70+
if (suite.tasks.length && suite.tasks.every(i => i.mode !== 'run')) {
71+
suite.mode = 'skip'
3972
}
4073
}
41-
else if (t.type === 'suite') {
42-
if (t.mode === 'skip') {
43-
skipAllTasks(t)
44-
}
45-
else {
46-
interpretTaskModes(t, namePattern, onlyMode, includeTask, allowOnly)
74+
}
75+
76+
traverseSuite(file, parentIsOnly)
77+
78+
const nonMatching = testLocations?.filter(loc => !matchedLocations.includes(loc))
79+
if (nonMatching && nonMatching.length !== 0) {
80+
const message = nonMatching.length === 1
81+
? `line ${nonMatching[0]}`
82+
: `lines ${nonMatching.join(', ')}`
83+
84+
if (file.result === undefined) {
85+
file.result = {
86+
state: 'fail',
87+
errors: [],
4788
}
4889
}
49-
})
50-
51-
// if all subtasks are skipped, mark as skip
52-
if (suite.mode === 'run') {
53-
if (suite.tasks.length && suite.tasks.every(i => i.mode !== 'run')) {
54-
suite.mode = 'skip'
90+
if (file.result.errors === undefined) {
91+
file.result.errors = []
5592
}
93+
94+
file.result.errors.push(
95+
processError(new Error(`No test found in ${file.name} in ${message}`)),
96+
)
5697
}
5798
}
5899

packages/vitest/src/node/cli/cli-api.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getNames, getTests } from '@vitest/runner/utils'
99
import { dirname, relative, resolve } from 'pathe'
1010
import { CoverageProviderMap } from '../../integrations/coverage'
1111
import { createVitest } from '../create'
12-
import { FilesNotFoundError, GitNotFoundError } from '../errors'
12+
import { FilesNotFoundError, GitNotFoundError, IncludeTaskLocationDisabledError, LocationFilterFileNotFoundError, RangeLocationFilterProvidedError } from '../errors'
1313
import { registerConsoleShortcuts } from '../stdin'
1414

1515
export interface CliOptions extends UserConfig {
@@ -103,6 +103,15 @@ export async function startVitest(
103103
return ctx
104104
}
105105

106+
if (
107+
e instanceof IncludeTaskLocationDisabledError
108+
|| e instanceof RangeLocationFilterProvidedError
109+
|| e instanceof LocationFilterFileNotFoundError
110+
) {
111+
ctx.logger.printError(e, { verbose: false })
112+
return ctx
113+
}
114+
106115
process.exitCode = 1
107116
ctx.logger.printError(e, { fullStack: true, type: 'Unhandled Error' })
108117
ctx.logger.error('\n\n')
+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { groupBy } from '../../utils/base'
2+
import { RangeLocationFilterProvidedError } from '../errors'
3+
4+
export function parseFilter(filter: string): Filter {
5+
const colonIndex = filter.lastIndexOf(':')
6+
if (colonIndex === -1) {
7+
return { filename: filter }
8+
}
9+
10+
const [parsedFilename, lineNumber] = [
11+
filter.substring(0, colonIndex),
12+
filter.substring(colonIndex + 1),
13+
]
14+
15+
if (lineNumber.match(/^\d+$/)) {
16+
return {
17+
filename: parsedFilename,
18+
lineNumber: Number.parseInt(lineNumber),
19+
}
20+
}
21+
else if (lineNumber.match(/^\d+-\d+$/)) {
22+
throw new RangeLocationFilterProvidedError(filter)
23+
}
24+
else {
25+
return { filename: filter }
26+
}
27+
}
28+
29+
interface Filter {
30+
filename: string
31+
lineNumber?: undefined | number
32+
}
33+
34+
export function groupFilters(filters: Filter[]) {
35+
const groupedFilters_ = groupBy(filters, f => f.filename)
36+
const groupedFilters = Object.fromEntries(Object.entries(groupedFilters_)
37+
.map((entry) => {
38+
const [filename, filters] = entry
39+
const testLocations = filters.map(f => f.lineNumber)
40+
41+
return [
42+
filename,
43+
testLocations.filter(l => l !== undefined) as number[],
44+
]
45+
}),
46+
)
47+
48+
return groupedFilters
49+
}

packages/vitest/src/node/core.ts

+42-4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { ResolvedConfig, UserConfig, VitestRunMode } from './types/config'
1111
import type { CoverageProvider } from './types/coverage'
1212
import type { Reporter } from './types/reporter'
1313
import { existsSync, promises as fs, readFileSync } from 'node:fs'
14+
import { resolve } from 'node:path'
1415
import { getTasks, hasFailed } from '@vitest/runner/utils'
1516
import { SnapshotManager } from '@vitest/snapshot/manager'
1617
import { noop, slash, toArray } from '@vitest/utils'
@@ -25,8 +26,9 @@ import { getCoverageProvider } from '../integrations/coverage'
2526
import { distDir } from '../paths'
2627
import { wildcardPatternToRegExp } from '../utils/base'
2728
import { VitestCache } from './cache'
29+
import { groupFilters, parseFilter } from './cli/filter'
2830
import { resolveConfig } from './config/resolveConfig'
29-
import { FilesNotFoundError, GitNotFoundError } from './errors'
31+
import { FilesNotFoundError, GitNotFoundError, IncludeTaskLocationDisabledError, LocationFilterFileNotFoundError } from './errors'
3032
import { Logger } from './logger'
3133
import { VitestPackageInstaller } from './packageInstaller'
3234
import { createPool } from './pool'
@@ -1144,19 +1146,55 @@ export class Vitest {
11441146

11451147
public async globTestSpecs(filters: string[] = []) {
11461148
const files: TestSpecification[] = []
1149+
const dir = process.cwd()
1150+
const parsedFilters = filters.map(f => parseFilter(f))
1151+
1152+
// Require includeTaskLocation when a location filter is passed
1153+
if (
1154+
!this.config.includeTaskLocation
1155+
&& parsedFilters.some(f => f.lineNumber !== undefined)
1156+
) {
1157+
throw new IncludeTaskLocationDisabledError()
1158+
}
1159+
1160+
const testLocations = groupFilters(parsedFilters.map(
1161+
f => ({ ...f, filename: slash(resolve(dir, f.filename)) }),
1162+
))
1163+
1164+
// Key is file and val sepcifies whether we have matched this file with testLocation
1165+
const testLocHasMatch: { [f: string]: boolean } = {}
1166+
11471167
await Promise.all(this.projects.map(async (project) => {
1148-
const { testFiles, typecheckTestFiles } = await project.globTestFiles(filters)
1168+
const { testFiles, typecheckTestFiles } = await project.globTestFiles(
1169+
parsedFilters.map(f => f.filename),
1170+
)
1171+
11491172
testFiles.forEach((file) => {
1150-
const spec = project.createSpecification(file)
1173+
const loc = testLocations[file]
1174+
testLocHasMatch[file] = true
1175+
1176+
const spec = project.createSpecification(file, undefined, loc)
11511177
this.ensureSpecCached(spec)
11521178
files.push(spec)
11531179
})
11541180
typecheckTestFiles.forEach((file) => {
1155-
const spec = project.createSpecification(file, 'typescript')
1181+
const loc = testLocations[file]
1182+
testLocHasMatch[file] = true
1183+
1184+
const spec = project.createSpecification(file, 'typescript', loc)
11561185
this.ensureSpecCached(spec)
11571186
files.push(spec)
11581187
})
11591188
}))
1189+
1190+
Object.entries(testLocations).forEach(([filepath, loc]) => {
1191+
if (loc.length !== 0 && !testLocHasMatch[filepath]) {
1192+
throw new LocationFilterFileNotFoundError(
1193+
relative(dir, filepath),
1194+
)
1195+
}
1196+
})
1197+
11601198
return files as WorkspaceSpec[]
11611199
}
11621200

0 commit comments

Comments
 (0)