Skip to content

Commit 4d94b95

Browse files
mzhubailsheremet-va
andauthoredDec 1, 2024··
feat(cli): Support specifying a line number when filtering tests (#6411)
Co-authored-by: Vladimir <sleuths.slews0s@icloud.com>
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

‎packages/vitest/src/node/errors.ts

+26
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,29 @@ export class GitNotFoundError extends Error {
1313
super('Could not find Git root. Have you initialized git with `git init`?')
1414
}
1515
}
16+
17+
export class LocationFilterFileNotFoundError extends Error {
18+
code = 'VITEST_LOCATION_FILTER_FILE_NOT_FOUND'
19+
20+
constructor(filename: string) {
21+
super(`Couldn\'t find file ${filename}. Note when specifying the test `
22+
+ 'location you have to specify the full test filename.')
23+
}
24+
}
25+
26+
export class IncludeTaskLocationDisabledError extends Error {
27+
code = 'VITEST_INCLUDE_TASK_LOCATION_DISABLED'
28+
29+
constructor() {
30+
super('Recieved line number filters while `includeTaskLocation` option is disabled')
31+
}
32+
}
33+
34+
export class RangeLocationFilterProvidedError extends Error {
35+
code = 'VITEST_RANGE_LOCATION_FILTER_PROVIDED'
36+
37+
constructor(filter: string) {
38+
super(`Found "-" in location filter ${filter}. Note that range location filters `
39+
+ `are not supported. Consider specifying the exact line numbers of your tests.`)
40+
}
41+
}

‎packages/vitest/src/node/pools/forks.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { FileSpec } from '@vitest/runner'
12
import type { TinypoolChannel, Options as TinypoolOptions } from 'tinypool'
23
import type { RunnerRPC, RuntimeRPC } from '../../types/rpc'
34
import type { ContextRPC, ContextTestEnvironment } from '../../types/worker'
@@ -101,11 +102,13 @@ export function createForksPool(
101102
async function runFiles(
102103
project: TestProject,
103104
config: SerializedConfig,
104-
files: string[],
105+
files: FileSpec[],
105106
environment: ContextTestEnvironment,
106107
invalidates: string[] = [],
107108
) {
108-
ctx.state.clearFiles(project, files)
109+
const paths = files.map(f => f.filepath)
110+
ctx.state.clearFiles(project, paths)
111+
109112
const { channel, cleanup } = createChildProcessChannel(project)
110113
const workerId = ++id
111114
const data: ContextRPC = {
@@ -129,7 +132,7 @@ export function createForksPool(
129132
&& /Failed to terminate worker/.test(error.message)
130133
) {
131134
ctx.state.addProcessTimeoutCause(
132-
`Failed to terminate worker while running ${files.join(', ')}.`,
135+
`Failed to terminate worker while running ${paths.join(', ')}.`,
133136
)
134137
}
135138
// Intentionally cancelled
@@ -138,7 +141,7 @@ export function createForksPool(
138141
&& error instanceof Error
139142
&& /The task has been cancelled/.test(error.message)
140143
) {
141-
ctx.state.cancelFiles(files, project)
144+
ctx.state.cancelFiles(paths, project)
142145
}
143146
else {
144147
throw error

‎packages/vitest/src/node/pools/threads.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { FileSpec } from '@vitest/runner/types/runner'
12
import type { Options as TinypoolOptions } from 'tinypool'
23
import type { RunnerRPC, RuntimeRPC } from '../../types/rpc'
34
import type { ContextTestEnvironment } from '../../types/worker'
@@ -95,11 +96,13 @@ export function createThreadsPool(
9596
async function runFiles(
9697
project: TestProject,
9798
config: SerializedConfig,
98-
files: string[],
99+
files: FileSpec[],
99100
environment: ContextTestEnvironment,
100101
invalidates: string[] = [],
101102
) {
102-
ctx.state.clearFiles(project, files)
103+
const paths = files.map(f => f.filepath)
104+
ctx.state.clearFiles(project, paths)
105+
103106
const { workerPort, port } = createWorkerChannel(project)
104107
const workerId = ++id
105108
const data: WorkerContext = {
@@ -124,7 +127,7 @@ export function createThreadsPool(
124127
&& /Failed to terminate worker/.test(error.message)
125128
) {
126129
ctx.state.addProcessTimeoutCause(
127-
`Failed to terminate worker while running ${files.join(
130+
`Failed to terminate worker while running ${paths.join(
128131
', ',
129132
)}. \nSee https://vitest.dev/guide/common-errors.html#failed-to-terminate-worker for troubleshooting.`,
130133
)
@@ -135,7 +138,7 @@ export function createThreadsPool(
135138
&& error instanceof Error
136139
&& /The task has been cancelled/.test(error.message)
137140
) {
138-
ctx.state.cancelFiles(files, project)
141+
ctx.state.cancelFiles(paths, project)
139142
}
140143
else {
141144
throw error

‎packages/vitest/src/node/pools/vmForks.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { FileSpec } from '@vitest/runner'
12
import type { TinypoolChannel, Options as TinypoolOptions } from 'tinypool'
23
import type { RunnerRPC, RuntimeRPC } from '../../types/rpc'
34
import type { ContextRPC, ContextTestEnvironment } from '../../types/worker'
@@ -109,11 +110,13 @@ export function createVmForksPool(
109110
async function runFiles(
110111
project: TestProject,
111112
config: SerializedConfig,
112-
files: string[],
113+
files: FileSpec[],
113114
environment: ContextTestEnvironment,
114115
invalidates: string[] = [],
115116
) {
116-
ctx.state.clearFiles(project, files)
117+
const paths = files.map(f => f.filepath)
118+
ctx.state.clearFiles(project, paths)
119+
117120
const { channel, cleanup } = createChildProcessChannel(project)
118121
const workerId = ++id
119122
const data: ContextRPC = {
@@ -137,7 +140,7 @@ export function createVmForksPool(
137140
&& /Failed to terminate worker/.test(error.message)
138141
) {
139142
ctx.state.addProcessTimeoutCause(
140-
`Failed to terminate worker while running ${files.join(', ')}.`,
143+
`Failed to terminate worker while running ${paths.join(', ')}.`,
141144
)
142145
}
143146
// Intentionally cancelled
@@ -146,7 +149,7 @@ export function createVmForksPool(
146149
&& error instanceof Error
147150
&& /The task has been cancelled/.test(error.message)
148151
) {
149-
ctx.state.cancelFiles(files, project)
152+
ctx.state.cancelFiles(paths, project)
150153
}
151154
else {
152155
throw error

‎packages/vitest/src/node/pools/vmThreads.ts

+8-5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { FileSpec } from '@vitest/runner'
12
import type { Options as TinypoolOptions } from 'tinypool'
23
import type { RunnerRPC, RuntimeRPC } from '../../types/rpc'
34
import type { ContextTestEnvironment } from '../../types/worker'
@@ -100,19 +101,21 @@ export function createVmThreadsPool(
100101
async function runFiles(
101102
project: TestProject,
102103
config: SerializedConfig,
103-
files: string[],
104+
files: FileSpec[],
104105
environment: ContextTestEnvironment,
105106
invalidates: string[] = [],
106107
) {
107-
ctx.state.clearFiles(project, files)
108+
const paths = files.map(f => f.filepath)
109+
ctx.state.clearFiles(project, paths)
110+
108111
const { workerPort, port } = createWorkerChannel(project)
109112
const workerId = ++id
110113
const data: WorkerContext = {
111114
pool: 'vmThreads',
112115
worker,
113116
port: workerPort,
114117
config,
115-
files,
118+
files: paths,
116119
invalidates,
117120
environment,
118121
workerId,
@@ -129,7 +132,7 @@ export function createVmThreadsPool(
129132
&& /Failed to terminate worker/.test(error.message)
130133
) {
131134
ctx.state.addProcessTimeoutCause(
132-
`Failed to terminate worker while running ${files.join(
135+
`Failed to terminate worker while running ${paths.join(
133136
', ',
134137
)}. \nSee https://vitest.dev/guide/common-errors.html#failed-to-terminate-worker for troubleshooting.`,
135138
)
@@ -140,7 +143,7 @@ export function createVmThreadsPool(
140143
&& error instanceof Error
141144
&& /The task has been cancelled/.test(error.message)
142145
) {
143-
ctx.state.cancelFiles(files, project)
146+
ctx.state.cancelFiles(paths, project)
144147
}
145148
else {
146149
throw error

‎packages/vitest/src/node/project.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,16 @@ export class TestProject {
131131
* Creates a new test specification. Specifications describe how to run tests.
132132
* @param moduleId The file path
133133
*/
134-
public createSpecification(moduleId: string, pool?: string): TestSpecification {
134+
public createSpecification(
135+
moduleId: string,
136+
pool?: string,
137+
testLocations?: number[] | undefined,
138+
): TestSpecification {
135139
return new TestSpecification(
136140
this,
137141
moduleId,
138142
pool || getFilePoolName(this, moduleId),
143+
testLocations,
139144
)
140145
}
141146

‎packages/vitest/src/node/spec.ts

+4
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@ export class TestSpecification {
1919
public readonly project: TestProject
2020
public readonly moduleId: string
2121
public readonly pool: Pool
22+
/** @private */
23+
public readonly testLocations: number[] | undefined
2224
// public readonly location: WorkspaceSpecLocation | undefined
2325

2426
constructor(
2527
project: TestProject,
2628
moduleId: string,
2729
pool: Pool,
30+
testLocations?: number[] | undefined,
2831
// location?: WorkspaceSpecLocation | undefined,
2932
) {
3033
this[0] = project
@@ -33,6 +36,7 @@ export class TestSpecification {
3336
this.project = project
3437
this.moduleId = moduleId
3538
this.pool = pool
39+
this.testLocations = testLocations
3640
// this.location = location
3741
}
3842

‎packages/vitest/src/runtime/inspector.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ export function setupInspect(ctx: ContextRPC) {
2828
)
2929

3030
if (config.inspectBrk) {
31-
const firstTestFile = ctx.files[0]
31+
const firstTestFile = typeof ctx.files[0] === 'string'
32+
? ctx.files[0]
33+
: ctx.files[0].filepath
3234

3335
// Stop at first test file
3436
if (firstTestFile) {

‎packages/vitest/src/runtime/runBaseTests.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { FileSpec } from '@vitest/runner'
12
import type { ResolvedTestEnvironment } from '../types/environment'
23
import type { SerializedConfig } from './config'
34
import type { VitestExecutor } from './execute'
@@ -17,7 +18,7 @@ import { getWorkerState, resetModules } from './utils'
1718
// browser shouldn't call this!
1819
export async function run(
1920
method: 'run' | 'collect',
20-
files: string[],
21+
files: FileSpec[],
2122
config: SerializedConfig,
2223
environment: ResolvedTestEnvironment,
2324
executor: VitestExecutor,
@@ -61,7 +62,7 @@ export async function run(
6162
resetModules(workerState.moduleCache, true)
6263
}
6364

64-
workerState.filepath = file
65+
workerState.filepath = file.filepath
6566

6667
if (method === 'run') {
6768
await startTests([file], runner)

‎packages/vitest/src/runtime/runVmTests.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { FileSpec } from '@vitest/runner'
12
import type { SerializedConfig } from './config'
23
import type { VitestExecutor } from './execute'
34
import { createRequire } from 'node:module'
@@ -21,7 +22,7 @@ import { getWorkerState } from './utils'
2122

2223
export async function run(
2324
method: 'run' | 'collect',
24-
files: string[],
25+
files: FileSpec[],
2526
config: SerializedConfig,
2627
executor: VitestExecutor,
2728
): Promise<void> {
@@ -85,7 +86,7 @@ export async function run(
8586
const { vi } = VitestIndex
8687

8788
for (const file of files) {
88-
workerState.filepath = file
89+
workerState.filepath = file.filepath
8990

9091
if (method === 'run') {
9192
await startTests([file], runner)

‎packages/vitest/src/runtime/workers/base.ts

+10-2
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,23 @@ export async function runBaseTests(method: 'run' | 'collect', state: WorkerGloba
3030
moduleCache.delete(`mock:${fsPath}`)
3131
})
3232
}
33-
ctx.files.forEach(i => state.moduleCache.delete(i))
33+
ctx.files.forEach(i => state.moduleCache.delete(
34+
typeof i === 'string' ? i : i.filepath,
35+
))
3436

3537
const [executor, { run }] = await Promise.all([
3638
startViteNode({ state, requestStubs: getDefaultRequestStubs() }),
3739
import('../runBaseTests'),
3840
])
41+
const fileSpecs = ctx.files.map(f =>
42+
typeof f === 'string'
43+
? { filepath: f, testLocations: undefined }
44+
: f,
45+
)
46+
3947
await run(
4048
method,
41-
ctx.files,
49+
fileSpecs,
4250
ctx.config,
4351
{ environment: state.environment, options: ctx.environment.options },
4452
executor,

‎packages/vitest/src/runtime/workers/vm.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,19 @@ export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalS
8787
const { run } = (await executor.importExternalModule(
8888
entryFile,
8989
)) as typeof import('../runVmTests')
90+
const fileSpecs = ctx.files.map(f =>
91+
typeof f === 'string'
92+
? { filepath: f, testLocations: undefined }
93+
: f,
94+
)
9095

9196
try {
92-
await run(method, ctx.files, ctx.config, executor)
97+
await run(
98+
method,
99+
fileSpecs,
100+
ctx.config,
101+
executor,
102+
)
93103
}
94104
finally {
95105
await vm.teardown?.()

‎packages/vitest/src/typecheck/collect.ts

+1
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ export async function collectTests(
212212
interpretTaskModes(
213213
file,
214214
ctx.config.testNamePattern,
215+
undefined,
215216
hasOnly,
216217
false,
217218
ctx.config.allowOnly,

‎packages/vitest/src/types/worker.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CancelReason, Task } from '@vitest/runner'
1+
import type { CancelReason, FileSpec, Task } from '@vitest/runner'
22
import type { BirpcReturn } from 'birpc'
33
import type { ModuleCacheMap, ViteNodeResolveId } from 'vite-node'
44
import type { SerializedConfig } from '../runtime/config'
@@ -26,7 +26,7 @@ export interface ContextRPC {
2626
workerId: number
2727
config: SerializedConfig
2828
projectName: string
29-
files: string[]
29+
files: string[] | FileSpec[]
3030
environment: ContextTestEnvironment
3131
providedContext: Record<string, any>
3232
invalidates?: string[]

‎packages/vitest/src/utils/test-helpers.ts

+9-5
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,18 @@ export async function groupFilesByEnv(
3131
) {
3232
const filesWithEnv = await Promise.all(
3333
files.map(async (spec) => {
34-
const file = spec.moduleId
34+
const filepath = spec.moduleId
35+
const { testLocations } = spec
3536
const project = spec.project
36-
const code = await fs.readFile(file, 'utf-8')
37+
const code = await fs.readFile(filepath, 'utf-8')
3738

3839
// 1. Check for control comments in the file
3940
let env = code.match(/@(?:vitest|jest)-environment\s+([\w-]+)\b/)?.[1]
4041
// 2. Check for globals
4142
if (!env) {
4243
for (const [glob, target] of project.config.environmentMatchGlobs
4344
|| []) {
44-
if (mm.isMatch(file, glob, { cwd: project.config.root })) {
45+
if (mm.isMatch(filepath, glob, { cwd: project.config.root })) {
4546
env = target
4647
break
4748
}
@@ -52,7 +53,7 @@ export async function groupFilesByEnv(
5253

5354
const transformMode = getTransformMode(
5455
project.config.testTransformMode,
55-
file,
56+
filepath,
5657
)
5758

5859
let envOptionsJson = code.match(/@(?:vitest|jest)-environment-options\s+(.+)/)?.[1]
@@ -71,7 +72,10 @@ export async function groupFilesByEnv(
7172
: null,
7273
}
7374
return {
74-
file,
75+
file: {
76+
filepath,
77+
testLocations,
78+
},
7579
project,
7680
environment,
7781
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
describe('basic suite', () => {
4+
describe('inner suite', () => {
5+
it('some test', () => {
6+
expect(1).toBe(1)
7+
})
8+
9+
it('another test', () => {
10+
expect(1).toBe(1)
11+
})
12+
})
13+
14+
it('basic test', () => {
15+
expect(1).toBe(1)
16+
})
17+
})
18+
19+
it('outside test', () => {
20+
expect(1).toBe(1)
21+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { expect, it } from 'vitest'
2+
3+
it('1 plus 1', () => {
4+
expect(1 + 1).toBe(2)
5+
})
6+
7+
it('2 plus 2', () => {
8+
expect(2 + 2).toBe(4)
9+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { expect, it } from 'vitest'
2+
3+
it('1 plus 1', () => {
4+
expect(1 + 1).toBe(2)
5+
})
6+
7+
it('2 plus 2', () => {
8+
expect(2 + 2).toBe(4)
9+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineConfig } from 'vitest/config'
2+
3+
export default defineConfig({
4+
test: {
5+
includeTaskLocation: false,
6+
},
7+
})
8+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from 'vitest/config'
2+
3+
export default defineConfig({
4+
test: {
5+
includeTaskLocation: true,
6+
},
7+
})
+215
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { runVitestCli } from '../../test-utils'
3+
4+
const fixturePath = './fixtures/location-filters'
5+
6+
describe('location filter with list command', () => {
7+
test('finds test at correct line number', async () => {
8+
const { stdout, stderr } = await runVitestCli(
9+
'list',
10+
`-r=${fixturePath}`,
11+
`${fixturePath}/basic.test.ts:5`,
12+
)
13+
14+
expect(stdout).toMatchInlineSnapshot(`
15+
"basic.test.ts > basic suite > inner suite > some test
16+
"
17+
`)
18+
expect(stderr).toEqual('')
19+
})
20+
21+
test('handles file with a dash in the name', async () => {
22+
const { stdout, stderr } = await runVitestCli(
23+
'list',
24+
`-r=${fixturePath}`,
25+
`${fixturePath}/math-with-dashes-in-name.test.ts:3`,
26+
)
27+
28+
expect(stdout).toMatchInlineSnapshot(`
29+
"math-with-dashes-in-name.test.ts > 1 plus 1
30+
"
31+
`)
32+
expect(stderr).toEqual('')
33+
})
34+
35+
test('reports not found test', async () => {
36+
const { stdout, stderr } = await runVitestCli(
37+
'list',
38+
`-r=${fixturePath}`,
39+
`${fixturePath}/basic.test.ts:99`,
40+
)
41+
42+
expect(stdout).toEqual('')
43+
expect(stderr).toMatchInlineSnapshot(`
44+
"Error: No test found in basic.test.ts in line 99
45+
"
46+
`)
47+
})
48+
49+
test('reports multiple not found tests', async () => {
50+
const { stdout, stderr } = await runVitestCli(
51+
'list',
52+
`-r=${fixturePath}`,
53+
`${fixturePath}/basic.test.ts:5`,
54+
`${fixturePath}/basic.test.ts:12`,
55+
`${fixturePath}/basic.test.ts:99`,
56+
)
57+
58+
expect(stdout).toEqual('')
59+
expect(stderr).toMatchInlineSnapshot(`
60+
"Error: No test found in basic.test.ts in lines 12, 99
61+
"
62+
`)
63+
})
64+
65+
test('errors if range location is provided', async () => {
66+
const { stdout, stderr } = await runVitestCli(
67+
'list',
68+
`-r=${fixturePath}`,
69+
`${fixturePath}/a/file/that/doesnt/exit:10-15`,
70+
)
71+
72+
expect(stdout).toEqual('')
73+
expect(stderr).toContain('Collect Error')
74+
expect(stderr).toContain('RangeLocationFilterProvidedError')
75+
})
76+
77+
test('parses file with a colon and dash in the name correctly', async () => {
78+
const { stdout, stderr } = await runVitestCli(
79+
'list',
80+
`-r=${fixturePath}`,
81+
`${fixturePath}/:a/file/that/doesn-t/exit:10`,
82+
)
83+
84+
expect(stdout).toEqual('')
85+
// shouldn't get a range location error
86+
expect(stderr).not.toContain('Error: Found "-"')
87+
})
88+
89+
test('erorrs if includeTaskLocation is not enabled', async () => {
90+
const { stdout, stderr } = await runVitestCli(
91+
'list',
92+
`-r=${fixturePath}`,
93+
'--config=no-task-location.config.ts',
94+
`${fixturePath}/a/file/that/doesnt/exist:5`,
95+
)
96+
97+
expect(stdout).toEqual('')
98+
expect(stderr).toContain('Collect Error')
99+
expect(stderr).toContain('IncludeTaskLocationDisabledError')
100+
})
101+
102+
test('fails on part of filename with location filter', async () => {
103+
const { stdout, stderr } = await runVitestCli(
104+
'list',
105+
`-r=${fixturePath}`,
106+
`math:999`,
107+
)
108+
109+
expect(stdout).toEqual('')
110+
expect(stderr).toContain('Collect Error')
111+
expect(stderr).toContain('LocationFilterFileNotFoundError')
112+
})
113+
})
114+
115+
describe('location filter with run command', () => {
116+
test('finds test at correct line number', async () => {
117+
const { stdout, stderr } = await runVitestCli(
118+
'run',
119+
`-r=${fixturePath}`,
120+
`${fixturePath}/math.test.ts:3`,
121+
)
122+
123+
// expect(`${stdout}\n--------------------\n${stderr}`).toEqual('')
124+
125+
expect(stdout).contain('1 passed')
126+
expect(stdout).contain('1 skipped')
127+
expect(stderr).toEqual('')
128+
})
129+
130+
test('handles file with a dash in the name', async () => {
131+
const { stdout, stderr } = await runVitestCli(
132+
'run',
133+
`-r=${fixturePath}`,
134+
`${fixturePath}/math-with-dashes-in-name.test.ts:3`,
135+
)
136+
137+
expect(stdout).contain('1 passed')
138+
expect(stdout).contain('1 skipped')
139+
expect(stderr).toEqual('')
140+
})
141+
142+
test('reports not found test', async () => {
143+
const { stdout, stderr } = await runVitestCli(
144+
'run',
145+
`-r=${fixturePath}`,
146+
`${fixturePath}/basic.test.ts:99`,
147+
)
148+
149+
expect(stdout).toContain('4 skipped')
150+
expect(stderr).toContain('Error: No test found in basic.test.ts in line 99')
151+
})
152+
153+
test('reports multiple not found tests', async () => {
154+
const { stdout, stderr } = await runVitestCli(
155+
'run',
156+
`-r=${fixturePath}`,
157+
`${fixturePath}/basic.test.ts:5`,
158+
`${fixturePath}/basic.test.ts:12`,
159+
`${fixturePath}/basic.test.ts:99`,
160+
)
161+
162+
expect(stdout).toContain('4 skipped')
163+
expect(stderr).toContain('Error: No test found in basic.test.ts in lines 12, 99')
164+
})
165+
166+
test('errors if range location is provided', async () => {
167+
const { stderr } = await runVitestCli(
168+
'run',
169+
`-r=${fixturePath}`,
170+
`${fixturePath}/a/file/that/doesnt/exit:10-15`,
171+
)
172+
173+
expect(stderr).toContain('Error: Found "-"')
174+
})
175+
176+
test('parses file with a colon and dash in the name correctly', async () => {
177+
const { stderr } = await runVitestCli(
178+
'run',
179+
`-r=${fixturePath}`,
180+
`${fixturePath}/:a/file/that/doesn-t/exit:10`,
181+
)
182+
183+
// shouldn't get a range location error
184+
expect(stderr).not.toContain('Error: Found "-"')
185+
})
186+
187+
test('errors if includeTaskLocation is not enabled', async () => {
188+
const { stderr } = await runVitestCli(
189+
'run',
190+
`-r=${fixturePath}`,
191+
`--config=no-task-location.config.ts`,
192+
`${fixturePath}/a/file/that/doesnt/exist:5`,
193+
)
194+
195+
expect(stderr).toMatchInlineSnapshot(`
196+
"Error: Recieved line number filters while \`includeTaskLocation\` option is disabled
197+
"
198+
`)
199+
})
200+
201+
test('fails on part of filename with location filter', async () => {
202+
const { stdout, stderr } = await runVitestCli(
203+
'run',
204+
`-r=${fixturePath}`,
205+
`math:999`,
206+
)
207+
208+
expect(stdout).not.contain('math.test.ts')
209+
expect(stdout).not.contain('math-with-dashes-in-name.test.ts')
210+
expect(stderr).toMatchInlineSnapshot(`
211+
"Error: Couldn't find file math. Note when specifying the test location you have to specify the full test filename.
212+
"
213+
`)
214+
})
215+
})

0 commit comments

Comments
 (0)
Please sign in to comment.