From c9aeac4cf2ac92f51d6cc246aefcb10bf6beef0a Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sun, 30 Jul 2023 13:07:22 +0200 Subject: [PATCH] feat: running vitest with `--related --watch` reruns non-affected tests if they were changed during a run (#3844) --- packages/vite-node/src/hmr/emitter.ts | 18 ++++++- packages/vitest/src/node/core.ts | 43 +++++++++++------ packages/vitest/src/node/logger.ts | 13 +++-- packages/vitest/src/node/plugins/index.ts | 12 +++++ packages/vitest/src/node/workspace.ts | 8 ++++ test/reporters/tests/default.test.ts | 2 + test/test-utils/index.ts | 47 +++++++++++-------- .../src/{script.js => hmr-script.js} | 0 test/vite-node/test/hmr.test.ts | 2 +- test/watch/test/file-watching.test.ts | 9 +++- test/watch/test/related.test.ts | 22 +++++++++ test/watch/test/stdin.test.ts | 9 +++- test/watch/test/stdout.test.ts | 1 + test/watch/test/workspaces.test.ts | 6 ++- test/watch/vitest.config.ts | 2 +- 15 files changed, 147 insertions(+), 47 deletions(-) rename test/vite-node/src/{script.js => hmr-script.js} (100%) create mode 100644 test/watch/test/related.test.ts diff --git a/packages/vite-node/src/hmr/emitter.ts b/packages/vite-node/src/hmr/emitter.ts index 304cfa014ef8..3a814d29885e 100644 --- a/packages/vite-node/src/hmr/emitter.ts +++ b/packages/vite-node/src/hmr/emitter.ts @@ -30,6 +30,20 @@ export function viteNodeHmrPlugin(): Plugin { return { name: 'vite-node:hmr', + config() { + // chokidar fsevents is unstable on macos when emitting "ready" event + if (process.platform === 'darwin' && process.env.VITE_TEST_WATCHER_DEBUG) { + return { + server: { + watch: { + useFsEvents: false, + usePolling: false, + }, + }, + } + } + }, + configureServer(server) { const _send = server.ws.send server.emitter = emitter @@ -37,10 +51,10 @@ export function viteNodeHmrPlugin(): Plugin { _send(payload) emitter.emit('message', payload) } - if (process.env.VITE_NODE_WATCHER_DEBUG) { + if (process.env.VITE_TEST_WATCHER_DEBUG) { server.watcher.on('ready', () => { // eslint-disable-next-line no-console - console.log('[vie-node] watcher is ready') + console.log('[debug] watcher is ready') }) } }, diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index af6e1122628e..bfde636754ed 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -306,19 +306,28 @@ export class Vitest { await this.globTestFiles(filters), ) + // if run with --changed, don't exit if no tests are found if (!files.length) { - const exitCode = this.config.passWithNoTests ? 0 : 1 - await this.reportCoverage(true) + this.logger.printNoTestFound(filters) - process.exit(exitCode) + if (!this.config.watch || !(this.config.changed || this.config.related?.length)) { + const exitCode = this.config.passWithNoTests ? 0 : 1 + process.exit(exitCode) + } } - // populate once, update cache on watch - await this.cache.stats.populateStats(this.config.root, files) + // all subsequent runs will treat this as a fresh run + this.config.changed = false + this.config.related = undefined - await this.runFiles(files) + if (files.length) { + // populate once, update cache on watch + await this.cache.stats.populateStats(this.config.root, files) + + await this.runFiles(files) + } await this.reportCoverage(true) @@ -326,15 +335,16 @@ export class Vitest { await this.report('onWatcherStart') } - private async getTestDependencies(filepath: WorkspaceSpec) { - const deps = new Set() - + private async getTestDependencies(filepath: WorkspaceSpec, deps = new Set()) { const addImports = async ([project, filepath]: WorkspaceSpec) => { - const transformed = await project.vitenode.transformRequest(filepath) + if (deps.has(filepath)) + return + const mod = project.server.moduleGraph.getModuleById(filepath) + const transformed = mod?.ssrTransformResult || await project.vitenode.transformRequest(filepath) if (!transformed) return const dependencies = [...transformed.deps || [], ...transformed.dynamicDeps || []] - for (const dep of dependencies) { + await Promise.all(dependencies.map(async (dep) => { const path = await this.server.pluginContainer.resolveId(dep, filepath, { ssr: true }) const fsPath = path && !path.external && path.id.split('?')[0] if (fsPath && !fsPath.includes('node_modules') && !deps.has(fsPath) && existsSync(fsPath)) { @@ -342,7 +352,7 @@ export class Vitest { await addImports([project, fsPath]) } - } + })) } await addImports(filepath) @@ -373,7 +383,8 @@ export class Vitest { return specs // don't run anything if no related sources are found - if (!related.length) + // if we are in watch mode, we want to process all tests + if (!this.config.watch && !related.length) return [] const testGraphs = await Promise.all( @@ -653,7 +664,8 @@ export class Vitest { const files: string[] = [] - for (const { server, browser } of projects) { + for (const project of projects) { + const { server, browser } = project const mod = server.moduleGraph.getModuleById(id) || browser?.moduleGraph.getModuleById(id) if (!mod) { // files with `?v=` query from the browser @@ -675,7 +687,8 @@ export class Vitest { this.invalidates.add(id) - if (this.state.filesMap.has(id)) { + // one of test files that we already run, or one of test files that we can run + if (this.state.filesMap.has(id) || project.isTestFile(id)) { this.changedTests.add(id) files.push(id) continue diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index c509b516918b..76e15a216d88 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -98,10 +98,15 @@ export class Logger { if (config.watchExclude) this.console.error(c.dim('watch exclude: ') + c.yellow(config.watchExclude.join(comma))) - if (config.passWithNoTests) - this.log(`No ${config.mode} files found, exiting with code 0\n`) - else - this.error(c.red(`\nNo ${config.mode} files found, exiting with code 1`)) + if (config.watch && (config.changed || config.related?.length)) { + this.log(`No affected ${config.mode} files found\n`) + } + else { + if (config.passWithNoTests) + this.log(`No ${config.mode} files found, exiting with code 0\n`) + else + this.error(c.red(`\nNo ${config.mode} files found, exiting with code 1`)) + } } printBanner() { diff --git a/packages/vitest/src/node/plugins/index.ts b/packages/vitest/src/node/plugins/index.ts index ca080eab5b9b..3d282b759705 100644 --- a/packages/vitest/src/node/plugins/index.ts +++ b/packages/vitest/src/node/plugins/index.ts @@ -90,6 +90,12 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t }, } + // chokidar fsevents is unstable on macos when emitting "ready" event + if (process.platform === 'darwin' && process.env.VITE_TEST_WATCHER_DEBUG) { + config.server!.watch!.useFsEvents = false + config.server!.watch!.usePolling = false + } + const classNameStrategy = (typeof testConfig.css !== 'boolean' && testConfig.css?.modules?.classNameStrategy) || 'stable' if (classNameStrategy !== 'scoped') { @@ -154,6 +160,12 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t } }, async configureServer(server) { + if (options.watch && process.env.VITE_TEST_WATCHER_DEBUG) { + server.watcher.on('ready', () => { + // eslint-disable-next-line no-console + console.log('[debug] watcher is ready') + }) + } try { await ctx.setServer(options, server, userConfig) if (options.api && options.watch) diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts index 44b2cb913157..120c8f20ccee 100644 --- a/packages/vitest/src/node/workspace.ts +++ b/packages/vitest/src/node/workspace.ts @@ -73,6 +73,8 @@ export class WorkspaceProject { closingPromise: Promise | undefined browserProvider: BrowserProvider | undefined + testFilesList: string[] = [] + constructor( public path: string | number, public ctx: Vitest, @@ -132,9 +134,15 @@ export class WorkspaceProject { })) } + this.testFilesList = testFiles + return testFiles } + isTestFile(id: string) { + return this.testFilesList.includes(id) + } + async globFiles(include: string[], exclude: string[], cwd: string) { const globOptions: fg.Options = { absolute: true, diff --git a/test/reporters/tests/default.test.ts b/test/reporters/tests/default.test.ts index 7a77f45316a4..1957aeab94bd 100644 --- a/test/reporters/tests/default.test.ts +++ b/test/reporters/tests/default.test.ts @@ -32,6 +32,8 @@ describe('default reporter', async () => { test('rerun should undo', async () => { const vitest = await run([], true, '-t', 'passed') + vitest.resetOutput() + // one file vitest.write('p') await vitest.waitForStdout('Input filename pattern') diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index c3016704dfe8..7764e0b61112 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -100,7 +100,7 @@ export async function runCli(command: string, _options?: Options | string, ...ar let setDone: (value?: unknown) => void const isDone = new Promise(resolve => (setDone = resolve)) - const vitest = { + const cli = { stdout: '', stderr: '', stdoutListeners: [] as (() => void)[], @@ -165,13 +165,13 @@ export async function runCli(command: string, _options?: Options | string, ...ar } subprocess.stdout!.on('data', (data) => { - vitest.stdout += stripAnsi(data.toString()) - vitest.stdoutListeners.forEach(fn => fn()) + cli.stdout += stripAnsi(data.toString()) + cli.stdoutListeners.forEach(fn => fn()) }) subprocess.stderr!.on('data', (data) => { - vitest.stderr += stripAnsi(data.toString()) - vitest.stderrListeners.forEach(fn => fn()) + cli.stderr += stripAnsi(data.toString()) + cli.stderrListeners.forEach(fn => fn()) }) subprocess.on('exit', () => setDone()) @@ -181,44 +181,51 @@ export async function runCli(command: string, _options?: Options | string, ...ar if (subprocess.exitCode === null) subprocess.kill() - await vitest.isDone + await cli.isDone }) - if (command !== 'vitest') { - if (!args.includes('--watch')) - await vitest.isDone - else - await vitest.waitForStdout('[vie-node] watcher is ready') - return vitest - } - - if (args.includes('--watch')) { // Wait for initial test run to complete - await vitest.waitForStdout('Waiting for file changes') - vitest.resetOutput() + if (args.includes('--watch')) { + if (command === 'vitest') // Wait for initial test run to complete + await cli.waitForStdout('Waiting for file changes') + // make sure watcher is ready + await cli.waitForStdout('[debug] watcher is ready') + cli.stdout = cli.stdout.replace('[debug] watcher is ready\n', '') } else { - await vitest.isDone + await cli.isDone } - return vitest + return cli } export async function runVitestCli(_options?: Options | string, ...args: string[]) { + process.env.VITE_TEST_WATCHER_DEBUG = 'true' return runCli('vitest', _options, ...args) } export async function runViteNodeCli(_options?: Options | string, ...args: string[]) { - process.env.VITE_NODE_WATCHER_DEBUG = 'true' + process.env.VITE_TEST_WATCHER_DEBUG = 'true' return runCli('vite-node', _options, ...args) } const originalFiles = new Map() +const createdFiles = new Set() afterEach(() => { originalFiles.forEach((content, file) => { fs.writeFileSync(file, content, 'utf-8') }) + createdFiles.forEach((file) => { + fs.unlinkSync(file) + }) + originalFiles.clear() + createdFiles.clear() }) +export function createFile(file: string, content: string) { + createdFiles.add(file) + fs.writeFileSync(file, content, 'utf-8') +} + export function editFile(file: string, callback: (content: string) => string) { const content = fs.readFileSync(file, 'utf-8') if (!originalFiles.has(file)) diff --git a/test/vite-node/src/script.js b/test/vite-node/src/hmr-script.js similarity index 100% rename from test/vite-node/src/script.js rename to test/vite-node/src/hmr-script.js diff --git a/test/vite-node/test/hmr.test.ts b/test/vite-node/test/hmr.test.ts index af427f2bcaa3..0cfdca19b36a 100644 --- a/test/vite-node/test/hmr.test.ts +++ b/test/vite-node/test/hmr.test.ts @@ -3,7 +3,7 @@ import { resolve } from 'pathe' import { editFile, runViteNodeCli } from '../../test-utils' test('hmr.accept works correctly', async () => { - const scriptFile = resolve(__dirname, '../src/script.js') + const scriptFile = resolve(__dirname, '../src/hmr-script.js') const viteNode = await runViteNodeCli('--watch', scriptFile) diff --git a/test/watch/test/file-watching.test.ts b/test/watch/test/file-watching.test.ts index b5f3aebea7a0..c79754985307 100644 --- a/test/watch/test/file-watching.test.ts +++ b/test/watch/test/file-watching.test.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { afterEach, describe, expect, test } from 'vitest' -import { runVitestCli } from '../../test-utils' +import * as testUtils from '../../test-utils' const sourceFile = 'fixtures/math.ts' const sourceFileContent = readFileSync(sourceFile, 'utf-8') @@ -18,6 +18,13 @@ const forceTriggerFileContent = readFileSync(forceTriggerFile, 'utf-8') const cliArgs = ['--root', 'fixtures', '--watch'] const cleanups: (() => void)[] = [] +async function runVitestCli(...args: string[]) { + const vitest = await testUtils.runVitestCli(...args) + if (args.includes('--watch')) + vitest.resetOutput() + return vitest +} + function editFile(fileContent: string) { return `// Modified by file-watching.test.ts ${fileContent} diff --git a/test/watch/test/related.test.ts b/test/watch/test/related.test.ts new file mode 100644 index 000000000000..654e4c533c32 --- /dev/null +++ b/test/watch/test/related.test.ts @@ -0,0 +1,22 @@ +import { test } from 'vitest' +import { resolve } from 'pathe' +import { editFile, runVitestCli } from '../../test-utils' + +const cliArgs = ['--root', 'fixtures', '--watch', '--changed'] + +test('when nothing is changed, run nothing but keep watching', async () => { + const vitest = await runVitestCli(...cliArgs) + + await vitest.waitForStdout('No affected test files found') + await vitest.waitForStdout('Waiting for file changes...') + + editFile(resolve(__dirname, '../fixtures/math.ts'), content => `${content}\n\n`) + + await vitest.waitForStdout('RERUN ../math.ts') + await vitest.waitForStdout('1 passed') + + editFile(resolve(__dirname, '../fixtures/math.test.ts'), content => `${content}\n\n`) + + await vitest.waitForStdout('RERUN ../math.test.ts') + await vitest.waitForStdout('1 passed') +}) diff --git a/test/watch/test/stdin.test.ts b/test/watch/test/stdin.test.ts index af91599ec4f2..8de44d7b0fb6 100644 --- a/test/watch/test/stdin.test.ts +++ b/test/watch/test/stdin.test.ts @@ -1,7 +1,14 @@ import { rmSync, writeFileSync } from 'node:fs' import { afterEach, expect, test } from 'vitest' -import { runVitestCli } from '../../test-utils' +import * as testUtils from '../../test-utils' + +async function runVitestCli(...args: string[]) { + const vitest = await testUtils.runVitestCli(...args) + if (args.includes('--watch')) + vitest.resetOutput() + return vitest +} const cliArgs = ['--root', 'fixtures', '--watch'] const cleanups: (() => void)[] = [] diff --git a/test/watch/test/stdout.test.ts b/test/watch/test/stdout.test.ts index 6110bf26a850..5104a3632416 100644 --- a/test/watch/test/stdout.test.ts +++ b/test/watch/test/stdout.test.ts @@ -12,6 +12,7 @@ afterEach(() => { test('console.log is visible on test re-run', async () => { const vitest = await runVitestCli('--root', 'fixtures', '--watch') + vitest.resetOutput() const testCase = ` test('test with logging', () => { console.log('First') diff --git a/test/watch/test/workspaces.test.ts b/test/watch/test/workspaces.test.ts index 285734037ff8..18aeac8e5fa7 100644 --- a/test/watch/test/workspaces.test.ts +++ b/test/watch/test/workspaces.test.ts @@ -26,8 +26,8 @@ test("dynamic test case", () => { }) ` -function startVitest() { - return runVitestCli( +async function startVitest() { + const vitest = await runVitestCli( { cwd: root, env: { TEST_WATCH: 'true' } }, '--root', root, @@ -36,6 +36,8 @@ function startVitest() { '--watch', '--no-coverage', ) + vitest.resetOutput() + return vitest } afterEach(() => { diff --git a/test/watch/vitest.config.ts b/test/watch/vitest.config.ts index f5944ddb461d..069ca3858ee6 100644 --- a/test/watch/vitest.config.ts +++ b/test/watch/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ }, // For Windows CI mostly - testTimeout: process.env.CI ? 30_000 : 10_000, + testTimeout: process.env.CI ? 60_000 : 10_000, // Test cases may have side effects, e.g. files under fixtures/ are modified on the fly to trigger file watchers singleThread: true,