From 3c965d4e8065c7d727cc53c31578dd03e616c5f4 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sun, 3 Apr 2022 11:09:36 +0300 Subject: [PATCH] feat: add commands to run tests depending on changed files (#1078) --- docs/config/index.md | 9 +++ docs/guide/index.md | 1 + packages/vitest/src/node/cli.ts | 1 + packages/vitest/src/node/config.ts | 3 + packages/vitest/src/node/core.ts | 13 ++++ packages/vitest/src/node/git.ts | 92 +++++++++++++++++++++++++++++ packages/vitest/src/types/config.ts | 7 +++ 7 files changed, 126 insertions(+) create mode 100644 packages/vitest/src/node/git.ts diff --git a/docs/config/index.md b/docs/config/index.md index 62a31e0eff9d..2f32284aceff 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -422,3 +422,12 @@ Format options for snapshot testing. - **Default:** `test` Overrides Vite mode. + +### changed + +- **Type**: `boolean | string` +- **Default**: false + +Run tests only against changed files. If no value is provided, it will run tests against uncomitted changes (includes staged and unstaged). + +To run tests against changes made in last commit, you can use `--changed HEAD~1`. You can also pass commit hash or branch name. diff --git a/docs/guide/index.md b/docs/guide/index.md index 4351c88ce16d..a33941eea8fc 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -118,6 +118,7 @@ You can specify additional CLI options like `--port` or `--https`. For a full li | `--environment ` | Runner environment (default: `node`) | | `--passWithNoTests` | Pass when no tests found | | `--allowOnly` | Allow tests and suites that are marked as `only` (default: false in CI, true otherwise) | +| `--changed [since]` | Run tests that are affected by the changed files (default: false) | `-h, --help` | Display available CLI options | ## Examples diff --git a/packages/vitest/src/node/cli.ts b/packages/vitest/src/node/cli.ts index 7418b09e47e5..271065e6e77e 100644 --- a/packages/vitest/src/node/cli.ts +++ b/packages/vitest/src/node/cli.ts @@ -33,6 +33,7 @@ cli .option('--environment ', 'runner environment (default: node)') .option('--passWithNoTests', 'pass when no tests found') .option('--allowOnly', 'Allow tests and suites that are marked as only (default: !process.env.CI)') + .option('--changed [since]', 'Run tests that are affected by the changed files (default: false)') .help() cli diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index 1c0d07b4f8d7..35b6e5f88c2e 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -134,5 +134,8 @@ export function resolveConfig( if (!resolved.reporters.length) resolved.reporters.push('default') + if (resolved.changed) + resolved.passWithNoTests ??= true + return resolved } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index bccf30e39c41..6c822eefa25c 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -15,6 +15,7 @@ import type { WorkerPool } from './pool' import { StateManager } from './state' import { resolveConfig } from './config' import { printError } from './error' +import { VitestGit } from './git' const WATCHER_DEBOUNCE = 100 const CLOSE_TIMEOUT = 1_000 @@ -163,6 +164,18 @@ export class Vitest { } async filterTestsBySource(tests: string[]) { + if (this.config.changed && !this.config.related) { + const vitestGit = new VitestGit(this.config.root) + const related = await vitestGit.findChangedFiles({ + changedSince: this.config.changed, + }) + if (!related) { + this.error(c.red('Could not find Git root. Have you initialized git with `git init`?\n')) + process.exit(1) + } + this.config.related = Array.from(new Set(related)) + } + const related = this.config.related if (!related) return tests diff --git a/packages/vitest/src/node/git.ts b/packages/vitest/src/node/git.ts new file mode 100644 index 000000000000..11bb260b9433 --- /dev/null +++ b/packages/vitest/src/node/git.ts @@ -0,0 +1,92 @@ +import { resolve } from 'pathe' +import { execa } from 'execa' +import type { ExecaReturnValue } from 'execa' + +export interface GitOptions { + changedSince?: string | boolean +} + +export class VitestGit { + private root!: string + + constructor(private cwd: string) {} + + private async resolveFilesWithGitCommand( + args: string[], + ): Promise { + let result: ExecaReturnValue + + try { + result = await execa('git', args, { cwd: this.root }) + } + catch (e: any) { + e.message = e.stderr + + throw e + } + + return result.stdout + .split('\n') + .filter(s => s !== '') + .map(changedPath => resolve(this.root, changedPath)) + } + + async findChangedFiles(options: GitOptions) { + const root = await this.getRoot(this.cwd) + if (!root) + return null + + this.root = root + + const changedSince = options.changedSince + if (typeof changedSince === 'string') { + const [committed, staged, unstaged] = await Promise.all([ + this.getFilesSince(changedSince), + this.getStagedFiles(), + this.getUnstagedFiles(), + ]) + return [...committed, ...staged, ...unstaged] + } + const [staged, unstaged] = await Promise.all([ + this.getStagedFiles(), + this.getUnstagedFiles(), + ]) + return [...staged, ...unstaged] + } + + private getFilesSince(hash: string) { + return this.resolveFilesWithGitCommand( + ['diff', '--name-only', `${hash}...HEAD`], + ) + } + + private getStagedFiles() { + return this.resolveFilesWithGitCommand( + ['diff', '--cached', '--name-only'], + ) + } + + private getUnstagedFiles() { + return this.resolveFilesWithGitCommand( + [ + 'ls-files', + '--other', + '--modified', + '--exclude-standard', + ], + ) + } + + async getRoot(cwd: string) { + const options = ['rev-parse', '--show-cdup'] + + try { + const result = await execa('git', options, { cwd }) + + return resolve(cwd, result.stdout) + } + catch { + return null + } + } +} diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index b7c884dbf0e4..37e8bd8bc0cc 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -329,6 +329,13 @@ export interface UserConfig extends InlineConfig { * @default 'test' */ mode?: string + + /** + * Runs tests that are affected by the changes in the repository, or between specified branch or commit hash + * Requires initialized git repository + * @default false + */ + changed?: boolean | string } export interface ResolvedConfig extends Omit, 'config' | 'filters' | 'coverage' | 'testNamePattern' | 'related' | 'api' | 'reporters'> {