diff --git a/docs/guide/index.md b/docs/guide/index.md index 186d41a4b..133fb6280 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -76,6 +76,16 @@ Perform a single run without watch mode. Run vitest in development mode. +### `vitest related` + +Run only tests that cover a list of source files. Works with static lazy imports, but not the dynamic ones. All files should be relative to root folder. + +Useful to run with [`lint-staged`](https://github.com/okonet/lint-staged) or with your CI setup. + +```bash +vitest related /src/index.ts /src/hello-world.js +``` + ### CLI Options | Options | | @@ -95,7 +105,6 @@ Run vitest in development mode. | `--run` | Do not watch | | `--global` | Inject APIs globally | | `--dom` | Mock browser api with happy-dom | -| `--findRelatedTests ` | Run only tests that import specified file | | `--environment ` | Runner environment (default: node) | | `--passWithNoTests` | Pass when no tests found | | `-h, --help` | Display available CLI options | diff --git a/packages/vitest/src/node/cli.ts b/packages/vitest/src/node/cli.ts index e0db8a773..a48af7708 100644 --- a/packages/vitest/src/node/cli.ts +++ b/packages/vitest/src/node/cli.ts @@ -28,7 +28,6 @@ cli .option('--run', 'do not watch') .option('--global', 'inject apis globally') .option('--dom', 'mock browser api with happy-dom') - .option('--findRelatedTests ', 'run only tests that import specified file') .option('--environment ', 'runner environment', { default: 'node' }) .option('--passWithNoTests', 'pass when no tests found') .help() @@ -37,6 +36,10 @@ cli .command('run [...filters]') .action(run) +cli + .command('related [...filters]') + .action(runRelated) + cli .command('watch [...filters]') .action(dev) @@ -51,6 +54,11 @@ cli cli.parse() +async function runRelated(relatedFiles: string[] | string, argv: UserConfig) { + argv.related = relatedFiles + await dev([], argv) +} + async function dev(cliFilters: string[], argv: UserConfig) { if (argv.watch == null) argv.watch = !process.env.CI && !argv.run diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index a87366428..82d3c3398 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -69,8 +69,8 @@ export function resolveConfig( if (resolved.api === true) resolved.api = defaultPort - if (options.findRelatedTests) - resolved.findRelatedTests = toArray(options.findRelatedTests).map(file => resolve(resolved.root, file)) + if (options.related) + resolved.related = toArray(options.related).map(file => resolve(resolved.root, file)) return resolved } diff --git a/packages/vitest/src/node/index.ts b/packages/vitest/src/node/index.ts index 9a8f6b63e..9bd980aae 100644 --- a/packages/vitest/src/node/index.ts +++ b/packages/vitest/src/node/index.ts @@ -1,3 +1,4 @@ +import { existsSync, promises as fs } from 'fs' import { resolve } from 'pathe' import type { ViteDevServer, InlineConfig as ViteInlineConfig, Plugin as VitePlugin, UserConfig as ViteUserConfig } from 'vite' import { createServer, mergeConfig } from 'vite' @@ -39,6 +40,8 @@ class Vitest { runningPromise?: Promise closingPromise?: Promise + nestedCode = new Map() + isFirstRun = true restartsCount = 0 @@ -91,7 +94,9 @@ class Vitest { async start(filters?: string[]) { this.report('onInit', this) - const files = await this.globTestFiles(filters) + const files = await this.filterTestsBySource( + await this.globTestFiles(filters), + ) if (!files.length) { if (this.config.passWithNoTests) @@ -110,6 +115,69 @@ class Vitest { await reportCoverage(this) } + private async getFileContent(path: string) { + if (!this.nestedCode.get(path)) + this.nestedCode.set(path, await fs.readFile(path, 'utf-8')) + + return this.nestedCode.get(path)! + } + + private async getTestDependencies(filepath: string) { + const importRegexp = /import(?:["'\s]*([\w*${}\n\r\t, ]+)from\s*)?["'\s]["'\s](.*[@\w_-]+)["'\s]$/mg + const dynamicImportRegexp = /import\((?:["'\s]*([\w*{}\n\r\t, ]+)\s*)?["'\s](.*([@\w_-]+))["'\s]\)$/mg + + const deps = new Set() + + const addImports = async(code: string, filepath: string, pattern: RegExp) => { + const matches = code.matchAll(pattern) + for (const match of matches) { + const path = await this.server.pluginContainer.resolveId(match[2], filepath) + const fsPath = path && path.id.split('?')[0] + if (fsPath && !fsPath.includes('node_modules') && !deps.has(fsPath) && existsSync(fsPath)) { + deps.add(fsPath) + + const depCode = await this.getFileContent(fsPath) + await processImports(depCode, fsPath) + } + } + } + + function processImports(code: string, id: string) { + return Promise.all([ + addImports(code, id, importRegexp), + addImports(code, id, dynamicImportRegexp), + ]) + } + + await processImports(await this.getFileContent(filepath), filepath) + + return deps + } + + async filterTestsBySource(tests: string[]) { + const related = this.config.related + if (!related?.length) + return tests + + const testDeps = await Promise.all( + tests.map(async(filepath) => { + const deps = await this.getTestDependencies(filepath) + return [filepath, deps] as const + }), + ) + + const runningTests = [] + + for (const [filepath, deps] of testDeps) { + if (deps.size && related.some(path => deps.has(path))) + runningTests.push(filepath) + } + + this.nestedCode.clear() + + return runningTests + } + async runFiles(files: string[]) { await this.runningPromise diff --git a/packages/vitest/src/runtime/collect.ts b/packages/vitest/src/runtime/collect.ts index d36a1e0ae..0db9514a5 100644 --- a/packages/vitest/src/runtime/collect.ts +++ b/packages/vitest/src/runtime/collect.ts @@ -14,10 +14,6 @@ function hash(str: string, length = 10) { .slice(0, length) } -function inModuleGraph(files: string[]) { - return files.some(file => process.__vitest_worker__.moduleCache.has(file)) -} - export async function collectTests(paths: string[], config: ResolvedConfig) { const files: File[] = [] @@ -37,9 +33,6 @@ export async function collectTests(paths: string[], config: ResolvedConfig) { await runSetupFiles(config) await import(filepath) - if (config.findRelatedTests && !inModuleGraph(config.findRelatedTests)) - continue - const defaultTasks = await defaultSuite.collect(file) setHooks(file, getHooks(defaultTasks)) diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index dc556aa62..e6497fcbb 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -233,14 +233,14 @@ export interface UserConfig extends InlineConfig { /** * Run tests that cover a list of source files */ - findRelatedTests?: string[] | string + related?: string[] | string } -export interface ResolvedConfig extends Omit, 'config' | 'filters' | 'coverage' | 'testNamePattern' | 'findRelatedTests'> { +export interface ResolvedConfig extends Omit, 'config' | 'filters' | 'coverage' | 'testNamePattern' | 'related'> { config?: string filters?: string[] testNamePattern?: RegExp - findRelatedTests?: string[] + related?: string[] depsInline: (string | RegExp)[] depsExternal: (string | RegExp)[] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 627c1c323..02610abec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -457,6 +457,12 @@ importers: vite: 2.7.10 vitest: link:../../packages/vitest + test/related: + specifiers: + vitest: workspace:* + devDependencies: + vitest: link:../../packages/vitest + test/single-thread: specifiers: vitest: workspace:* diff --git a/test/related/package.json b/test/related/package.json new file mode 100644 index 000000000..895c6a197 --- /dev/null +++ b/test/related/package.json @@ -0,0 +1,10 @@ +{ + "name": "@vitest/test-related", + "private": true, + "scripts": { + "test": "vitest related src/sourceA.ts --global" + }, + "devDependencies": { + "vitest": "workspace:*" + } +} \ No newline at end of file diff --git a/test/related/src/sourceA.ts b/test/related/src/sourceA.ts new file mode 100644 index 000000000..7a40b0750 --- /dev/null +++ b/test/related/src/sourceA.ts @@ -0,0 +1 @@ +export const A = 'A' diff --git a/test/related/src/sourceB.ts b/test/related/src/sourceB.ts new file mode 100644 index 000000000..7c89a996a --- /dev/null +++ b/test/related/src/sourceB.ts @@ -0,0 +1 @@ +export const B = 'B' diff --git a/test/related/tests/not-related.test.ts b/test/related/tests/not-related.test.ts new file mode 100644 index 000000000..27066cbae --- /dev/null +++ b/test/related/tests/not-related.test.ts @@ -0,0 +1,6 @@ +import { B } from '../src/sourceB' + +test('shouldnt run', () => { + expect(B).toBe('B') + expect.fail() +}) diff --git a/test/related/tests/related.test.ts b/test/related/tests/related.test.ts new file mode 100644 index 000000000..c571be4fa --- /dev/null +++ b/test/related/tests/related.test.ts @@ -0,0 +1,10 @@ +import { access } from 'fs' +import { sep } from 'pathe' +import { A } from '../src/sourceA' + +test('A equeals A', () => { + expect(A).toBe('A') + expect(typeof sep).toBe('string') + // doesnt throw + expect(typeof access).toBe('function') +}) diff --git a/test/related/tsconfig.json b/test/related/tsconfig.json new file mode 100644 index 000000000..9f9b0a6a9 --- /dev/null +++ b/test/related/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "types": ["vitest/global"], + } +} \ No newline at end of file