Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: running vitest with --related --watch reruns non-affected tests if they were changed during a run #3844

Merged
merged 7 commits into from Jul 30, 2023
18 changes: 16 additions & 2 deletions packages/vite-node/src/hmr/emitter.ts
Expand Up @@ -30,17 +30,31 @@ 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
server.ws.send = function (payload: HMRPayload) {
_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')
})
}
},
Expand Down
43 changes: 28 additions & 15 deletions packages/vitest/src/node/core.ts
Expand Up @@ -306,43 +306,53 @@ 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)

if (this.config.watch)
await this.report('onWatcherStart')
}

private async getTestDependencies(filepath: WorkspaceSpec) {
const deps = new Set<string>()

private async getTestDependencies(filepath: WorkspaceSpec, deps = new Set<string>()) {
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)) {
deps.add(fsPath)

await addImports([project, fsPath])
}
}
}))
}

await addImports(filepath)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 9 additions & 4 deletions packages/vitest/src/node/logger.ts
Expand Up @@ -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() {
Expand Down
12 changes: 12 additions & 0 deletions packages/vitest/src/node/plugins/index.ts
Expand Up @@ -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') {
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions packages/vitest/src/node/workspace.ts
Expand Up @@ -73,6 +73,8 @@ export class WorkspaceProject {
closingPromise: Promise<unknown> | undefined
browserProvider: BrowserProvider | undefined

testFilesList: string[] = []

constructor(
public path: string | number,
public ctx: Vitest,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions test/reporters/tests/default.test.ts
Expand Up @@ -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')
Expand Down
47 changes: 27 additions & 20 deletions test/test-utils/index.ts
Expand Up @@ -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)[],
Expand Down Expand Up @@ -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())
Expand All @@ -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<string, string>()
const createdFiles = new Set<string>()
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))
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion test/vite-node/test/hmr.test.ts
Expand Up @@ -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)

Expand Down
9 changes: 8 additions & 1 deletion 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')
Expand All @@ -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}
Expand Down
22 changes: 22 additions & 0 deletions 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')
})
9 changes: 8 additions & 1 deletion 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)[] = []
Expand Down
1 change: 1 addition & 0 deletions test/watch/test/stdout.test.ts
Expand Up @@ -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')
Expand Down