diff --git a/docs/config/experimental.md b/docs/config/experimental.md index be80135dbac1..4d64cfb22d95 100644 --- a/docs/config/experimental.md +++ b/docs/config/experimental.md @@ -410,6 +410,67 @@ export default defineConfig({ If you are running tests in Deno, TypeScript files are processed by the runtime without any additional configurations. ::: +## experimental.vcsProvider 4.1.1 {#experimental-vcsprovider} + +- **Type:** `VCSProvider | string` + +```ts +interface VCSProvider { + findChangedFiles(options: VCSProviderOptions): Promise +} + +interface VCSProviderOptions { + root: string + changedSince?: string | boolean +} +``` + +- **Default:** `'git'` + +Custom provider for detecting changed files. Used with the [`--changed`](/guide/cli#changed) flag to determine which files have been modified. + +By default, Vitest uses Git to detect changed files. You can provide a custom implementation of the `VCSProvider` interface to use a different version control system: + +```ts [vitest.config.ts] +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + experimental: { + vcsProvider: { + async findChangedFiles({ root, changedSince }) { + // return paths of changed files + return [] + }, + }, + }, + }, +}) +``` + +You can also pass a string path to a module with a default export that implements the `VCSProvider` interface: + +```js [vitest.config.js] +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + experimental: { + vcsProvider: './my-vcs-provider.js', + }, + }, +}) +``` + +```js [my-vcs-provider.js] +export default { + async findChangedFiles({ root, changedSince }) { + // return paths of changed files + return [] + }, +} +``` + ## experimental.nodeLoader 4.1.0 {#experimental-nodeloader} - **Type:** `boolean` diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md index 7b880b2a2c42..ddf59427ce05 100644 --- a/docs/guide/cli-generated.md +++ b/docs/guide/cli-generated.md @@ -949,3 +949,10 @@ Control whether Vitest uses Vite's module runner to run the code or fallback to - **Config:** [experimental.nodeLoader](/config/experimental#experimental-nodeloader) Controls whether Vitest will use Node.js Loader API to process in-source or mocked files. This has no effect if `viteModuleRunner` is enabled. Disabling this can increase performance. (default: `true`) + +### experimental.vcsProvider + +- **CLI:** `--experimental.vcsProvider ` +- **Config:** [experimental.vcsProvider](/config/experimental#experimental-vcsprovider) + +Custom provider for detecting changed files. (default: `git`) diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 43afbe38082b..f1cb5cc3ee06 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -901,6 +901,11 @@ export const cliOptionsConfig: VitestCLIOptions = { nodeLoader: { description: 'Controls whether Vitest will use Node.js Loader API to process in-source or mocked files. This has no effect if `viteModuleRunner` is enabled. Disabling this can increase performance. (default: `true`)', }, + vcsProvider: { + argument: '', + description: 'Custom provider for detecting changed files. (default: `git`)', + subcommands: null, + }, }, }, // disable CLI options diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 26fe5673b6df..78964c1829c7 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -950,6 +950,10 @@ export function resolveConfig( resolved.experimental.importDurations.thresholds.warn ??= 100 resolved.experimental.importDurations.thresholds.danger ??= 500 + if (typeof resolved.experimental.vcsProvider === 'string' && resolved.experimental.vcsProvider !== 'git') { + resolved.experimental.vcsProvider = resolvePath(resolved.experimental.vcsProvider, resolved.root) + } + return resolved } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 269deee75c56..60ce250c24bb 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -15,6 +15,7 @@ import type { ResolvedConfig, TestProjectConfiguration, UserConfig, VitestRunMod import type { CoverageProvider, ResolvedCoverageOptions } from './types/coverage' import type { Reporter } from './types/reporter' import type { TestRunResult } from './types/tests' +import type { VCSProvider } from './vcs/vcs' import os, { tmpdir } from 'node:os' import { getTasks, hasFailed, limitConcurrency } from '@vitest/runner/utils' import { SnapshotManager } from '@vitest/snapshot/manager' @@ -51,6 +52,7 @@ import { VitestSpecifications } from './specifications' import { StateManager } from './state' import { populateProjectsTags } from './tags' import { TestRun } from './test-run' +import { loadVCSProvider } from './vcs/vcs' import { VitestWatcher } from './watcher' const WATCHER_DEBOUNCE = 100 @@ -101,6 +103,13 @@ export class Vitest { * Vitest behaviour. */ public readonly watcher: VitestWatcher + /** + * The version control system provider used to detect changed files. + * This is used with the `--changed` flag to determine which test files to run. + * By default, Vitest uses Git. You can provide a custom implementation via + * `experimental.vcsProvider` in your config. + */ + public vcs!: VCSProvider /** @internal */ configOverride: Partial = {} /** @internal */ filenamePattern?: string[] @@ -263,6 +272,7 @@ export class Vitest { configurable: true, }) } + this.vcs = await loadVCSProvider(this.runner, resolved.experimental.vcsProvider) if (this.config.watch) { // hijack server restart diff --git a/packages/vitest/src/node/coverage.ts b/packages/vitest/src/node/coverage.ts index eebbf7d51314..60b507fea1f3 100644 --- a/packages/vitest/src/node/coverage.ts +++ b/packages/vitest/src/node/coverage.ts @@ -331,11 +331,17 @@ export class BaseCoverageProvider { async onTestRunStart(): Promise { if (this.options.changed) { - const { VitestGit } = await import('./git') - const vitestGit = new VitestGit(this.ctx.config.root) - const changedFiles = await vitestGit.findChangedFiles({ changedSince: this.options.changed }) + try { + const changedFiles = await this.ctx.vcs.findChangedFiles({ + root: this.ctx.config.root, + changedSince: this.options.changed, + }) - this.changedFiles = changedFiles ?? undefined + this.changedFiles = changedFiles + } + catch { + this.changedFiles = undefined + } } else if (this.ctx.config.changed) { this.changedFiles = this.ctx.config.related diff --git a/packages/vitest/src/node/specifications.ts b/packages/vitest/src/node/specifications.ts index 54fe67e7544b..222d271eab25 100644 --- a/packages/vitest/src/node/specifications.ts +++ b/packages/vitest/src/node/specifications.ts @@ -6,7 +6,7 @@ import { join, relative, resolve } from 'pathe' import pm from 'picomatch' import { isWindows } from '../utils/env' import { groupFilters, parseFilter } from './cli/filter' -import { GitNotFoundError, IncludeTaskLocationDisabledError, LocationFilterFileNotFoundError } from './errors' +import { IncludeTaskLocationDisabledError, LocationFilterFileNotFoundError } from './errors' export class VitestSpecifications { private readonly _cachedSpecs = new Map() @@ -121,15 +121,10 @@ export class VitestSpecifications { private async filterTestsBySource(specs: TestSpecification[]): Promise { if (this.vitest.config.changed && !this.vitest.config.related) { - const { VitestGit } = await import('./git') - const vitestGit = new VitestGit(this.vitest.config.root) - const related = await vitestGit.findChangedFiles({ + const related = await this.vitest.vcs.findChangedFiles({ + root: this.vitest.config.root, changedSince: this.vitest.config.changed, }) - if (!related) { - process.exitCode = 1 - throw new GitNotFoundError() - } this.vitest.config.related = Array.from(new Set(related)) } diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index e530e30e3ad3..3f6ec9ece2db 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -17,6 +17,7 @@ import type { } from '../reporters' import type { TestCase, TestModule, TestSuite } from '../reporters/reported-tasks' import type { TestSequencerConstructor } from '../sequencers/types' +import type { VCSProvider } from '../vcs/vcs' import type { WatcherTriggerPattern } from '../watcher' import type { BenchmarkUserOptions } from './benchmark' import type { BrowserConfigOptions, ResolvedBrowserOptions } from './browser' @@ -937,6 +938,15 @@ export interface InlineConfig { * This option only affects `loader.load` method, Vitest always defines a `loader.resolve` to populate the module graph. */ nodeLoader?: boolean + + /** + * Custom provider for detecting changed files. Used with the `--changed` flag + * to determine which files have been modified. + * + * By default, Vitest uses Git to detect changed files. You can provide a custom + * implementation of the `VCSProvider` interface to use a different version control system. + */ + vcsProvider?: VCSProvider | string } /** diff --git a/packages/vitest/src/node/git.ts b/packages/vitest/src/node/vcs/git.ts similarity index 84% rename from packages/vitest/src/node/git.ts rename to packages/vitest/src/node/vcs/git.ts index 3402fbafa7be..627b7d13c8fd 100644 --- a/packages/vitest/src/node/git.ts +++ b/packages/vitest/src/node/vcs/git.ts @@ -1,16 +1,12 @@ import type { Output } from 'tinyexec' +import type { VCSProvider, VCSProviderOptions } from './vcs' import { resolve } from 'pathe' import { x } from 'tinyexec' +import { GitNotFoundError } from '../errors' -export interface GitOptions { - changedSince?: string | boolean -} - -export class VitestGit { +export class GitVCSProvider implements VCSProvider { private root!: string - constructor(private cwd: string) {} - private async resolveFilesWithGitCommand(args: string[]): Promise { let result: Output @@ -29,10 +25,10 @@ export class VitestGit { .map(changedPath => resolve(this.root, changedPath)) } - async findChangedFiles(options: GitOptions): Promise { - const root = await this.getRoot(this.cwd) + async findChangedFiles(options: VCSProviderOptions): Promise { + const root = this.root || await this.getRoot(options.root) if (!root) { - return null + throw new GitNotFoundError() } this.root = root diff --git a/packages/vitest/src/node/vcs/vcs.ts b/packages/vitest/src/node/vcs/vcs.ts new file mode 100644 index 000000000000..a16f7683564b --- /dev/null +++ b/packages/vitest/src/node/vcs/vcs.ts @@ -0,0 +1,36 @@ +import type { ModuleRunner } from 'vite/module-runner' +import { resolve } from 'pathe' +import { GitVCSProvider } from './git' + +export interface VCSProviderOptions { + root: string + changedSince?: string | boolean +} + +export interface VCSProvider { + // eslint-disable-next-line ts/method-signature-style + findChangedFiles(options: VCSProviderOptions): Promise +} + +export async function loadVCSProvider(runner: ModuleRunner, vcsProvider: string | VCSProvider | undefined): Promise { + if (typeof vcsProvider === 'object' && vcsProvider != null) { + return wrapVCSProvider(vcsProvider) + } + if (!vcsProvider || vcsProvider === 'git') { + return new GitVCSProvider() + } + const module = await runner.import(vcsProvider) as { default: VCSProvider } + if (!module.default || typeof module.default !== 'object' || typeof module.default.findChangedFiles !== 'function') { + throw new Error(`The vcsProvider module '${vcsProvider}' doesn't have a default export with \`findChangedFiles\` method.`) + } + return wrapVCSProvider(module.default) +} + +function wrapVCSProvider(provider: VCSProvider): VCSProvider { + return { + async findChangedFiles(options) { + const changedFiles = await provider.findChangedFiles(options) + return changedFiles.map(file => resolve(options.root, file)) + }, + } +} diff --git a/test/cli/test/vcs-provider.test.ts b/test/cli/test/vcs-provider.test.ts new file mode 100644 index 000000000000..1185978d2e2e --- /dev/null +++ b/test/cli/test/vcs-provider.test.ts @@ -0,0 +1,195 @@ +import crypto from 'node:crypto' +import { runInlineTests, useFS } from '#test-utils' +import { resolve } from 'pathe' +import { expect, onTestFinished, test, vi } from 'vitest' +import { createVitest } from 'vitest/node' + +test('custom vcsProvider that returns specific files runs only matching tests', async () => { + const { testTree, stderr } = await runInlineTests({ + 'vitest.config.js': ` + import path from 'node:path' + export default { + test: { + experimental: { + vcsProvider: { + async findChangedFiles({ root }) { + return [path.resolve(root, 'src', 'changed.ts')] + }, + }, + }, + }, + } + `, + 'src/changed.ts': 'export const a = 1', + 'src/not-changed.ts': 'export const b = 2', + 'related.test.ts': ` + import { a } from './src/changed.ts' + import { test, expect } from 'vitest' + test('related test', () => { + expect(a).toBe(1) + }) + `, + 'not-related.test.ts': ` + import { b } from './src/not-changed.ts' + import { test, expect } from 'vitest' + test('not related test', () => { + expect(b).toBe(2) + }) + `, + }, { + changed: true, + }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "related.test.ts": { + "related test": "passed", + }, + } + `) +}) + +test('custom vcsProvider that returns no files runs no tests', async () => { + const { testTree, stdout } = await runInlineTests({ + 'vitest.config.js': ` + export default { + test: { + passWithNoTests: true, + experimental: { + vcsProvider: { + async findChangedFiles() { + return [] + }, + }, + }, + }, + } + `, + 'basic.test.ts': ` + import { test, expect } from 'vitest' + test('should not run', () => { + expect(1).toBe(1) + }) + `, + }, { + changed: true, + }) + + expect(stdout).toContain(`No test files found, exiting with code 0`) + expect(testTree()).toMatchInlineSnapshot('{}') +}) + +test('custom vcsProvider that returns all files runs all tests', async () => { + const { testTree, stderr } = await runInlineTests({ + 'vitest.config.js': ` + import path from 'node:path' + export default { + test: { + experimental: { + vcsProvider: { + async findChangedFiles({ root }) { + return [ + path.resolve(root, 'src', 'a.ts'), + path.resolve(root, 'src', 'b.ts'), + ] + }, + }, + }, + }, + } + `, + 'src/a.ts': 'export const a = 1', + 'src/b.ts': 'export const b = 2', + 'first.test.ts': ` + import { a } from './src/a.ts' + import { test, expect } from 'vitest' + test('first test', () => { + expect(a).toBe(1) + }) + `, + 'second.test.ts': ` + import { b } from './src/b.ts' + import { test, expect } from 'vitest' + test('second test', () => { + expect(b).toBe(2) + }) + `, + }, { + changed: true, + }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "first.test.ts": { + "first test": "passed", + }, + "second.test.ts": { + "second test": "passed", + }, + } + `) +}) + +function createRoot(structure: Record) { + const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`) + useFS(root, structure) + return root +} + +async function vitest(config: Parameters[1]) { + const v = await createVitest('test', { ...config, watch: false, config: false }, {}) + onTestFinished(() => v.close()) + return v +} + +test('vcsProvider defaults to GitVCSProvider when not specified', async () => { + const v = await vitest({}) + expect(v.config.experimental.vcsProvider).toBeUndefined() + expect(v.vcs).toBeDefined() + expect(v.vcs.constructor.name).toBe('GitVCSProvider') +}) + +test('vcsProvider "git" resolves to GitVCSProvider', async () => { + const v = await vitest({ + experimental: { + vcsProvider: 'git', + }, + }) + expect(v.config.experimental.vcsProvider).toBe('git') + expect(v.vcs.constructor.name).toBe('GitVCSProvider') +}) + +test('vcsProvider object is used directly', async () => { + const customProvider = { + findChangedFiles: vi.fn(async () => { + return [] + }), + } + const v = await vitest({ + experimental: { + vcsProvider: customProvider, + }, + }) + const files = await v.vcs.findChangedFiles({ root: v.config.root }) + expect(files).toEqual([]) + expect(customProvider.findChangedFiles).toHaveBeenCalledOnce() +}) + +test('vcsProvider string path is resolved to absolute path', async () => { + const root = createRoot({ + 'my-vcs-provider.ts': ` + export default { + async findChangedFiles() { + return [] + }, + } + `, + }) + const v = await createVitest('test', { watch: false, config: false, root, experimental: { vcsProvider: './my-vcs-provider.ts' } }, {}) + onTestFinished(() => v.close()) + expect(v.config.experimental.vcsProvider).toBe(resolve(root, 'my-vcs-provider.ts')) + expect(v.vcs).toBeDefined() + expect(typeof v.vcs.findChangedFiles).toBe('function') +})