diff --git a/docs/guide/filtering.md b/docs/guide/filtering.md index d2931fcc2adb..5cdfef156d44 100644 --- a/docs/guide/filtering.md +++ b/docs/guide/filtering.md @@ -24,6 +24,21 @@ basic/foo.test.ts You can also use the `-t, --testNamePattern ` 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). diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index 1c9354cfe107..f0f4eaabc1b3 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -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' @@ -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 { 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) @@ -97,6 +100,7 @@ export async function collectTests( interpretTaskModes( file, config.testNamePattern, + testLocations, hasOnlyTasks, false, config.allowOnly, diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index b7b03e34ac1d..4ef1498d94c2 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -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, @@ -498,10 +498,11 @@ export async function runFiles(files: File[], runner: VitestRunner): Promise { +export async function startTests(specs: string[] | FileSpec[], runner: VitestRunner): Promise { + 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) @@ -515,10 +516,12 @@ export async function startTests(paths: string[], runner: VitestRunner): Promise return files } -async function publicCollect(paths: string[], runner: VitestRunner): Promise { +async function publicCollect(specs: string[] | FileSpec[], runner: VitestRunner): Promise { + 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 diff --git a/packages/runner/src/types.ts b/packages/runner/src/types.ts index 48a72003b7b6..6cbc309aad63 100644 --- a/packages/runner/src/types.ts +++ b/packages/runner/src/types.ts @@ -1,5 +1,6 @@ export type { CancelReason, + FileSpec, VitestRunner, VitestRunnerConfig, VitestRunnerConstructor, diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index ade94a143d1d..9fb34109303f 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -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 { diff --git a/packages/runner/src/utils/collect.ts b/packages/runner/src/utils/collect.ts index bd72b99e66b8..d9f595c0e4b8 100644 --- a/packages/runner/src/utils/collect.ts +++ b/packages/runner/src/utils/collect.ts @@ -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}`)), + ) } } diff --git a/packages/vitest/src/node/cli/cli-api.ts b/packages/vitest/src/node/cli/cli-api.ts index e36d07432768..a0fb736c518c 100644 --- a/packages/vitest/src/node/cli/cli-api.ts +++ b/packages/vitest/src/node/cli/cli-api.ts @@ -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 { @@ -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') diff --git a/packages/vitest/src/node/cli/filter.ts b/packages/vitest/src/node/cli/filter.ts new file mode 100644 index 000000000000..0fcc577e5a3f --- /dev/null +++ b/packages/vitest/src/node/cli/filter.ts @@ -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 +} diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 4d54e6b763df..0a90e0d9ddac 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -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' @@ -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' @@ -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[] } diff --git a/packages/vitest/src/node/errors.ts b/packages/vitest/src/node/errors.ts index 296458134d67..4e0a703713c3 100644 --- a/packages/vitest/src/node/errors.ts +++ b/packages/vitest/src/node/errors.ts @@ -13,3 +13,29 @@ export class GitNotFoundError extends Error { super('Could not find Git root. Have you initialized git with `git init`?') } } + +export class LocationFilterFileNotFoundError extends Error { + code = 'VITEST_LOCATION_FILTER_FILE_NOT_FOUND' + + constructor(filename: string) { + super(`Couldn\'t find file ${filename}. Note when specifying the test ` + + 'location you have to specify the full test filename.') + } +} + +export class IncludeTaskLocationDisabledError extends Error { + code = 'VITEST_INCLUDE_TASK_LOCATION_DISABLED' + + constructor() { + super('Recieved line number filters while `includeTaskLocation` option is disabled') + } +} + +export class RangeLocationFilterProvidedError extends Error { + code = 'VITEST_RANGE_LOCATION_FILTER_PROVIDED' + + constructor(filter: string) { + super(`Found "-" in location filter ${filter}. Note that range location filters ` + + `are not supported. Consider specifying the exact line numbers of your tests.`) + } +} diff --git a/packages/vitest/src/node/pools/forks.ts b/packages/vitest/src/node/pools/forks.ts index 65bd2b0e9f33..26172d6c2158 100644 --- a/packages/vitest/src/node/pools/forks.ts +++ b/packages/vitest/src/node/pools/forks.ts @@ -1,3 +1,4 @@ +import type { FileSpec } from '@vitest/runner' import type { TinypoolChannel, Options as TinypoolOptions } from 'tinypool' import type { RunnerRPC, RuntimeRPC } from '../../types/rpc' import type { ContextRPC, ContextTestEnvironment } from '../../types/worker' @@ -101,11 +102,13 @@ export function createForksPool( async function runFiles( project: TestProject, config: SerializedConfig, - files: string[], + files: FileSpec[], environment: ContextTestEnvironment, invalidates: string[] = [], ) { - ctx.state.clearFiles(project, files) + const paths = files.map(f => f.filepath) + ctx.state.clearFiles(project, paths) + const { channel, cleanup } = createChildProcessChannel(project) const workerId = ++id const data: ContextRPC = { @@ -129,7 +132,7 @@ export function createForksPool( && /Failed to terminate worker/.test(error.message) ) { ctx.state.addProcessTimeoutCause( - `Failed to terminate worker while running ${files.join(', ')}.`, + `Failed to terminate worker while running ${paths.join(', ')}.`, ) } // Intentionally cancelled @@ -138,7 +141,7 @@ export function createForksPool( && error instanceof Error && /The task has been cancelled/.test(error.message) ) { - ctx.state.cancelFiles(files, project) + ctx.state.cancelFiles(paths, project) } else { throw error diff --git a/packages/vitest/src/node/pools/threads.ts b/packages/vitest/src/node/pools/threads.ts index 26cc9d27d1b6..b1c8786163c9 100644 --- a/packages/vitest/src/node/pools/threads.ts +++ b/packages/vitest/src/node/pools/threads.ts @@ -1,3 +1,4 @@ +import type { FileSpec } from '@vitest/runner/types/runner' import type { Options as TinypoolOptions } from 'tinypool' import type { RunnerRPC, RuntimeRPC } from '../../types/rpc' import type { ContextTestEnvironment } from '../../types/worker' @@ -95,11 +96,13 @@ export function createThreadsPool( async function runFiles( project: TestProject, config: SerializedConfig, - files: string[], + files: FileSpec[], environment: ContextTestEnvironment, invalidates: string[] = [], ) { - ctx.state.clearFiles(project, files) + const paths = files.map(f => f.filepath) + ctx.state.clearFiles(project, paths) + const { workerPort, port } = createWorkerChannel(project) const workerId = ++id const data: WorkerContext = { @@ -124,7 +127,7 @@ export function createThreadsPool( && /Failed to terminate worker/.test(error.message) ) { ctx.state.addProcessTimeoutCause( - `Failed to terminate worker while running ${files.join( + `Failed to terminate worker while running ${paths.join( ', ', )}. \nSee https://vitest.dev/guide/common-errors.html#failed-to-terminate-worker for troubleshooting.`, ) @@ -135,7 +138,7 @@ export function createThreadsPool( && error instanceof Error && /The task has been cancelled/.test(error.message) ) { - ctx.state.cancelFiles(files, project) + ctx.state.cancelFiles(paths, project) } else { throw error diff --git a/packages/vitest/src/node/pools/vmForks.ts b/packages/vitest/src/node/pools/vmForks.ts index 70443a069853..dd4e2ec4a909 100644 --- a/packages/vitest/src/node/pools/vmForks.ts +++ b/packages/vitest/src/node/pools/vmForks.ts @@ -1,3 +1,4 @@ +import type { FileSpec } from '@vitest/runner' import type { TinypoolChannel, Options as TinypoolOptions } from 'tinypool' import type { RunnerRPC, RuntimeRPC } from '../../types/rpc' import type { ContextRPC, ContextTestEnvironment } from '../../types/worker' @@ -109,11 +110,13 @@ export function createVmForksPool( async function runFiles( project: TestProject, config: SerializedConfig, - files: string[], + files: FileSpec[], environment: ContextTestEnvironment, invalidates: string[] = [], ) { - ctx.state.clearFiles(project, files) + const paths = files.map(f => f.filepath) + ctx.state.clearFiles(project, paths) + const { channel, cleanup } = createChildProcessChannel(project) const workerId = ++id const data: ContextRPC = { @@ -137,7 +140,7 @@ export function createVmForksPool( && /Failed to terminate worker/.test(error.message) ) { ctx.state.addProcessTimeoutCause( - `Failed to terminate worker while running ${files.join(', ')}.`, + `Failed to terminate worker while running ${paths.join(', ')}.`, ) } // Intentionally cancelled @@ -146,7 +149,7 @@ export function createVmForksPool( && error instanceof Error && /The task has been cancelled/.test(error.message) ) { - ctx.state.cancelFiles(files, project) + ctx.state.cancelFiles(paths, project) } else { throw error diff --git a/packages/vitest/src/node/pools/vmThreads.ts b/packages/vitest/src/node/pools/vmThreads.ts index 3587dbc4fac0..bbdbbf7c00cb 100644 --- a/packages/vitest/src/node/pools/vmThreads.ts +++ b/packages/vitest/src/node/pools/vmThreads.ts @@ -1,3 +1,4 @@ +import type { FileSpec } from '@vitest/runner' import type { Options as TinypoolOptions } from 'tinypool' import type { RunnerRPC, RuntimeRPC } from '../../types/rpc' import type { ContextTestEnvironment } from '../../types/worker' @@ -100,11 +101,13 @@ export function createVmThreadsPool( async function runFiles( project: TestProject, config: SerializedConfig, - files: string[], + files: FileSpec[], environment: ContextTestEnvironment, invalidates: string[] = [], ) { - ctx.state.clearFiles(project, files) + const paths = files.map(f => f.filepath) + ctx.state.clearFiles(project, paths) + const { workerPort, port } = createWorkerChannel(project) const workerId = ++id const data: WorkerContext = { @@ -112,7 +115,7 @@ export function createVmThreadsPool( worker, port: workerPort, config, - files, + files: paths, invalidates, environment, workerId, @@ -129,7 +132,7 @@ export function createVmThreadsPool( && /Failed to terminate worker/.test(error.message) ) { ctx.state.addProcessTimeoutCause( - `Failed to terminate worker while running ${files.join( + `Failed to terminate worker while running ${paths.join( ', ', )}. \nSee https://vitest.dev/guide/common-errors.html#failed-to-terminate-worker for troubleshooting.`, ) @@ -140,7 +143,7 @@ export function createVmThreadsPool( && error instanceof Error && /The task has been cancelled/.test(error.message) ) { - ctx.state.cancelFiles(files, project) + ctx.state.cancelFiles(paths, project) } else { throw error diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index e25c03992735..fce4f51b954b 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -131,11 +131,16 @@ export class TestProject { * Creates a new test specification. Specifications describe how to run tests. * @param moduleId The file path */ - public createSpecification(moduleId: string, pool?: string): TestSpecification { + public createSpecification( + moduleId: string, + pool?: string, + testLocations?: number[] | undefined, + ): TestSpecification { return new TestSpecification( this, moduleId, pool || getFilePoolName(this, moduleId), + testLocations, ) } diff --git a/packages/vitest/src/node/spec.ts b/packages/vitest/src/node/spec.ts index 96e12a8580fd..9c74a8ca87d1 100644 --- a/packages/vitest/src/node/spec.ts +++ b/packages/vitest/src/node/spec.ts @@ -19,12 +19,15 @@ export class TestSpecification { public readonly project: TestProject public readonly moduleId: string public readonly pool: Pool + /** @private */ + public readonly testLocations: number[] | undefined // public readonly location: WorkspaceSpecLocation | undefined constructor( project: TestProject, moduleId: string, pool: Pool, + testLocations?: number[] | undefined, // location?: WorkspaceSpecLocation | undefined, ) { this[0] = project @@ -33,6 +36,7 @@ export class TestSpecification { this.project = project this.moduleId = moduleId this.pool = pool + this.testLocations = testLocations // this.location = location } diff --git a/packages/vitest/src/runtime/inspector.ts b/packages/vitest/src/runtime/inspector.ts index a6ecea20a387..84e605f279e0 100644 --- a/packages/vitest/src/runtime/inspector.ts +++ b/packages/vitest/src/runtime/inspector.ts @@ -28,7 +28,9 @@ export function setupInspect(ctx: ContextRPC) { ) if (config.inspectBrk) { - const firstTestFile = ctx.files[0] + const firstTestFile = typeof ctx.files[0] === 'string' + ? ctx.files[0] + : ctx.files[0].filepath // Stop at first test file if (firstTestFile) { diff --git a/packages/vitest/src/runtime/runBaseTests.ts b/packages/vitest/src/runtime/runBaseTests.ts index e8dcd4ee1009..b15e5767970a 100644 --- a/packages/vitest/src/runtime/runBaseTests.ts +++ b/packages/vitest/src/runtime/runBaseTests.ts @@ -1,3 +1,4 @@ +import type { FileSpec } from '@vitest/runner' import type { ResolvedTestEnvironment } from '../types/environment' import type { SerializedConfig } from './config' import type { VitestExecutor } from './execute' @@ -17,7 +18,7 @@ import { getWorkerState, resetModules } from './utils' // browser shouldn't call this! export async function run( method: 'run' | 'collect', - files: string[], + files: FileSpec[], config: SerializedConfig, environment: ResolvedTestEnvironment, executor: VitestExecutor, @@ -61,7 +62,7 @@ export async function run( resetModules(workerState.moduleCache, true) } - workerState.filepath = file + workerState.filepath = file.filepath if (method === 'run') { await startTests([file], runner) diff --git a/packages/vitest/src/runtime/runVmTests.ts b/packages/vitest/src/runtime/runVmTests.ts index f195ca90d716..bc22d124e72a 100644 --- a/packages/vitest/src/runtime/runVmTests.ts +++ b/packages/vitest/src/runtime/runVmTests.ts @@ -1,3 +1,4 @@ +import type { FileSpec } from '@vitest/runner' import type { SerializedConfig } from './config' import type { VitestExecutor } from './execute' import { createRequire } from 'node:module' @@ -21,7 +22,7 @@ import { getWorkerState } from './utils' export async function run( method: 'run' | 'collect', - files: string[], + files: FileSpec[], config: SerializedConfig, executor: VitestExecutor, ): Promise { @@ -85,7 +86,7 @@ export async function run( const { vi } = VitestIndex for (const file of files) { - workerState.filepath = file + workerState.filepath = file.filepath if (method === 'run') { await startTests([file], runner) diff --git a/packages/vitest/src/runtime/workers/base.ts b/packages/vitest/src/runtime/workers/base.ts index ecd7fa901236..a8824ef8cdd3 100644 --- a/packages/vitest/src/runtime/workers/base.ts +++ b/packages/vitest/src/runtime/workers/base.ts @@ -30,15 +30,23 @@ export async function runBaseTests(method: 'run' | 'collect', state: WorkerGloba moduleCache.delete(`mock:${fsPath}`) }) } - ctx.files.forEach(i => state.moduleCache.delete(i)) + ctx.files.forEach(i => state.moduleCache.delete( + typeof i === 'string' ? i : i.filepath, + )) const [executor, { run }] = await Promise.all([ startViteNode({ state, requestStubs: getDefaultRequestStubs() }), import('../runBaseTests'), ]) + const fileSpecs = ctx.files.map(f => + typeof f === 'string' + ? { filepath: f, testLocations: undefined } + : f, + ) + await run( method, - ctx.files, + fileSpecs, ctx.config, { environment: state.environment, options: ctx.environment.options }, executor, diff --git a/packages/vitest/src/runtime/workers/vm.ts b/packages/vitest/src/runtime/workers/vm.ts index 023701b019d6..dd3df091ef73 100644 --- a/packages/vitest/src/runtime/workers/vm.ts +++ b/packages/vitest/src/runtime/workers/vm.ts @@ -87,9 +87,19 @@ export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalS const { run } = (await executor.importExternalModule( entryFile, )) as typeof import('../runVmTests') + const fileSpecs = ctx.files.map(f => + typeof f === 'string' + ? { filepath: f, testLocations: undefined } + : f, + ) try { - await run(method, ctx.files, ctx.config, executor) + await run( + method, + fileSpecs, + ctx.config, + executor, + ) } finally { await vm.teardown?.() diff --git a/packages/vitest/src/typecheck/collect.ts b/packages/vitest/src/typecheck/collect.ts index c9139493a576..4b1ae34ddfa1 100644 --- a/packages/vitest/src/typecheck/collect.ts +++ b/packages/vitest/src/typecheck/collect.ts @@ -212,6 +212,7 @@ export async function collectTests( interpretTaskModes( file, ctx.config.testNamePattern, + undefined, hasOnly, false, ctx.config.allowOnly, diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index 2c8b9a0fc0c5..ee712b2b60c2 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -1,4 +1,4 @@ -import type { CancelReason, Task } from '@vitest/runner' +import type { CancelReason, FileSpec, Task } from '@vitest/runner' import type { BirpcReturn } from 'birpc' import type { ModuleCacheMap, ViteNodeResolveId } from 'vite-node' import type { SerializedConfig } from '../runtime/config' @@ -26,7 +26,7 @@ export interface ContextRPC { workerId: number config: SerializedConfig projectName: string - files: string[] + files: string[] | FileSpec[] environment: ContextTestEnvironment providedContext: Record invalidates?: string[] diff --git a/packages/vitest/src/utils/test-helpers.ts b/packages/vitest/src/utils/test-helpers.ts index 2d9c44d994b1..b343d154eff6 100644 --- a/packages/vitest/src/utils/test-helpers.ts +++ b/packages/vitest/src/utils/test-helpers.ts @@ -31,9 +31,10 @@ export async function groupFilesByEnv( ) { const filesWithEnv = await Promise.all( files.map(async (spec) => { - const file = spec.moduleId + const filepath = spec.moduleId + const { testLocations } = spec const project = spec.project - const code = await fs.readFile(file, 'utf-8') + const code = await fs.readFile(filepath, 'utf-8') // 1. Check for control comments in the file let env = code.match(/@(?:vitest|jest)-environment\s+([\w-]+)\b/)?.[1] @@ -41,7 +42,7 @@ export async function groupFilesByEnv( if (!env) { for (const [glob, target] of project.config.environmentMatchGlobs || []) { - if (mm.isMatch(file, glob, { cwd: project.config.root })) { + if (mm.isMatch(filepath, glob, { cwd: project.config.root })) { env = target break } @@ -52,7 +53,7 @@ export async function groupFilesByEnv( const transformMode = getTransformMode( project.config.testTransformMode, - file, + filepath, ) let envOptionsJson = code.match(/@(?:vitest|jest)-environment-options\s+(.+)/)?.[1] @@ -71,7 +72,10 @@ export async function groupFilesByEnv( : null, } return { - file, + file: { + filepath, + testLocations, + }, project, environment, } diff --git a/test/cli/fixtures/location-filters/basic.test.ts b/test/cli/fixtures/location-filters/basic.test.ts new file mode 100644 index 000000000000..37f68c1bd622 --- /dev/null +++ b/test/cli/fixtures/location-filters/basic.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +describe('basic suite', () => { + describe('inner suite', () => { + it('some test', () => { + expect(1).toBe(1) + }) + + it('another test', () => { + expect(1).toBe(1) + }) + }) + + it('basic test', () => { + expect(1).toBe(1) + }) +}) + +it('outside test', () => { + expect(1).toBe(1) +}) diff --git a/test/cli/fixtures/location-filters/math-with-dashes-in-name.test.ts b/test/cli/fixtures/location-filters/math-with-dashes-in-name.test.ts new file mode 100644 index 000000000000..4727c76eacc4 --- /dev/null +++ b/test/cli/fixtures/location-filters/math-with-dashes-in-name.test.ts @@ -0,0 +1,9 @@ +import { expect, it } from 'vitest' + +it('1 plus 1', () => { + expect(1 + 1).toBe(2) +}) + +it('2 plus 2', () => { + expect(2 + 2).toBe(4) +}) diff --git a/test/cli/fixtures/location-filters/math.test.ts b/test/cli/fixtures/location-filters/math.test.ts new file mode 100644 index 000000000000..4727c76eacc4 --- /dev/null +++ b/test/cli/fixtures/location-filters/math.test.ts @@ -0,0 +1,9 @@ +import { expect, it } from 'vitest' + +it('1 plus 1', () => { + expect(1 + 1).toBe(2) +}) + +it('2 plus 2', () => { + expect(2 + 2).toBe(4) +}) diff --git a/test/cli/fixtures/location-filters/no-task-location.config.ts b/test/cli/fixtures/location-filters/no-task-location.config.ts new file mode 100644 index 000000000000..f4d0e5f76d3b --- /dev/null +++ b/test/cli/fixtures/location-filters/no-task-location.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + includeTaskLocation: false, + }, +}) + diff --git a/test/cli/fixtures/location-filters/vitest.config.ts b/test/cli/fixtures/location-filters/vitest.config.ts new file mode 100644 index 000000000000..42676ddb8355 --- /dev/null +++ b/test/cli/fixtures/location-filters/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + includeTaskLocation: true, + }, +}) diff --git a/test/cli/test/location-filters.test.ts b/test/cli/test/location-filters.test.ts new file mode 100644 index 000000000000..0873b3963cac --- /dev/null +++ b/test/cli/test/location-filters.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, test } from 'vitest' +import { runVitestCli } from '../../test-utils' + +const fixturePath = './fixtures/location-filters' + +describe('location filter with list command', () => { + test('finds test at correct line number', async () => { + const { stdout, stderr } = await runVitestCli( + 'list', + `-r=${fixturePath}`, + `${fixturePath}/basic.test.ts:5`, + ) + + expect(stdout).toMatchInlineSnapshot(` + "basic.test.ts > basic suite > inner suite > some test + " + `) + expect(stderr).toEqual('') + }) + + test('handles file with a dash in the name', async () => { + const { stdout, stderr } = await runVitestCli( + 'list', + `-r=${fixturePath}`, + `${fixturePath}/math-with-dashes-in-name.test.ts:3`, + ) + + expect(stdout).toMatchInlineSnapshot(` + "math-with-dashes-in-name.test.ts > 1 plus 1 + " + `) + expect(stderr).toEqual('') + }) + + test('reports not found test', async () => { + const { stdout, stderr } = await runVitestCli( + 'list', + `-r=${fixturePath}`, + `${fixturePath}/basic.test.ts:99`, + ) + + expect(stdout).toEqual('') + expect(stderr).toMatchInlineSnapshot(` + "Error: No test found in basic.test.ts in line 99 + " + `) + }) + + test('reports multiple not found tests', async () => { + const { stdout, stderr } = await runVitestCli( + 'list', + `-r=${fixturePath}`, + `${fixturePath}/basic.test.ts:5`, + `${fixturePath}/basic.test.ts:12`, + `${fixturePath}/basic.test.ts:99`, + ) + + expect(stdout).toEqual('') + expect(stderr).toMatchInlineSnapshot(` + "Error: No test found in basic.test.ts in lines 12, 99 + " + `) + }) + + test('errors if range location is provided', async () => { + const { stdout, stderr } = await runVitestCli( + 'list', + `-r=${fixturePath}`, + `${fixturePath}/a/file/that/doesnt/exit:10-15`, + ) + + expect(stdout).toEqual('') + expect(stderr).toContain('Collect Error') + expect(stderr).toContain('RangeLocationFilterProvidedError') + }) + + test('parses file with a colon and dash in the name correctly', async () => { + const { stdout, stderr } = await runVitestCli( + 'list', + `-r=${fixturePath}`, + `${fixturePath}/:a/file/that/doesn-t/exit:10`, + ) + + expect(stdout).toEqual('') + // shouldn't get a range location error + expect(stderr).not.toContain('Error: Found "-"') + }) + + test('erorrs if includeTaskLocation is not enabled', async () => { + const { stdout, stderr } = await runVitestCli( + 'list', + `-r=${fixturePath}`, + '--config=no-task-location.config.ts', + `${fixturePath}/a/file/that/doesnt/exist:5`, + ) + + expect(stdout).toEqual('') + expect(stderr).toContain('Collect Error') + expect(stderr).toContain('IncludeTaskLocationDisabledError') + }) + + test('fails on part of filename with location filter', async () => { + const { stdout, stderr } = await runVitestCli( + 'list', + `-r=${fixturePath}`, + `math:999`, + ) + + expect(stdout).toEqual('') + expect(stderr).toContain('Collect Error') + expect(stderr).toContain('LocationFilterFileNotFoundError') + }) +}) + +describe('location filter with run command', () => { + test('finds test at correct line number', async () => { + const { stdout, stderr } = await runVitestCli( + 'run', + `-r=${fixturePath}`, + `${fixturePath}/math.test.ts:3`, + ) + + // expect(`${stdout}\n--------------------\n${stderr}`).toEqual('') + + expect(stdout).contain('1 passed') + expect(stdout).contain('1 skipped') + expect(stderr).toEqual('') + }) + + test('handles file with a dash in the name', async () => { + const { stdout, stderr } = await runVitestCli( + 'run', + `-r=${fixturePath}`, + `${fixturePath}/math-with-dashes-in-name.test.ts:3`, + ) + + expect(stdout).contain('1 passed') + expect(stdout).contain('1 skipped') + expect(stderr).toEqual('') + }) + + test('reports not found test', async () => { + const { stdout, stderr } = await runVitestCli( + 'run', + `-r=${fixturePath}`, + `${fixturePath}/basic.test.ts:99`, + ) + + expect(stdout).toContain('4 skipped') + expect(stderr).toContain('Error: No test found in basic.test.ts in line 99') + }) + + test('reports multiple not found tests', async () => { + const { stdout, stderr } = await runVitestCli( + 'run', + `-r=${fixturePath}`, + `${fixturePath}/basic.test.ts:5`, + `${fixturePath}/basic.test.ts:12`, + `${fixturePath}/basic.test.ts:99`, + ) + + expect(stdout).toContain('4 skipped') + expect(stderr).toContain('Error: No test found in basic.test.ts in lines 12, 99') + }) + + test('errors if range location is provided', async () => { + const { stderr } = await runVitestCli( + 'run', + `-r=${fixturePath}`, + `${fixturePath}/a/file/that/doesnt/exit:10-15`, + ) + + expect(stderr).toContain('Error: Found "-"') + }) + + test('parses file with a colon and dash in the name correctly', async () => { + const { stderr } = await runVitestCli( + 'run', + `-r=${fixturePath}`, + `${fixturePath}/:a/file/that/doesn-t/exit:10`, + ) + + // shouldn't get a range location error + expect(stderr).not.toContain('Error: Found "-"') + }) + + test('errors if includeTaskLocation is not enabled', async () => { + const { stderr } = await runVitestCli( + 'run', + `-r=${fixturePath}`, + `--config=no-task-location.config.ts`, + `${fixturePath}/a/file/that/doesnt/exist:5`, + ) + + expect(stderr).toMatchInlineSnapshot(` + "Error: Recieved line number filters while \`includeTaskLocation\` option is disabled + " + `) + }) + + test('fails on part of filename with location filter', async () => { + const { stdout, stderr } = await runVitestCli( + 'run', + `-r=${fixturePath}`, + `math:999`, + ) + + expect(stdout).not.contain('math.test.ts') + expect(stdout).not.contain('math-with-dashes-in-name.test.ts') + expect(stderr).toMatchInlineSnapshot(` + "Error: Couldn't find file math. Note when specifying the test location you have to specify the full test filename. + " + `) + }) +})