diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 8808d03d8..88762dafc 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -22,7 +22,7 @@ import type { TestRunResult } from './types/tests' import type { VCSProvider } from './vcs/vcs' import os, { tmpdir } from 'node:os' import { SnapshotManager } from '@vitest/snapshot/manager' -import { deepClone, deepMerge, nanoid, toArray } from '@vitest/utils/helpers' +import { deepClone, deepMerge, nanoid, noop, toArray } from '@vitest/utils/helpers' import { serializeValue } from '@vitest/utils/serialize' import { join, normalize, relative } from 'pathe' import { version } from '../../package.json' with { type: 'json' } @@ -1440,6 +1440,10 @@ export class Vitest { } this._rerunTimer = setTimeout(async () => { + if (this.closingPromise) { + return + } + if (this.watcher.changedTests.size === 0) { this.watcher.invalidates.clear() return @@ -1545,6 +1549,12 @@ export class Vitest { public async close(): Promise { if (!this.closingPromise) { this.closingPromise = (async () => { + // let an in-flight (re)run settle instead of tearing down under it: + // its file stats and transforms would race the teardown and reject + // after the caller already cleaned up the test files + clearTimeout(this._rerunTimer) + await this.runningPromise?.catch(noop) + const teardownProjects = [...this.projects] if (this.coreWorkspaceProject && !teardownProjects.includes(this.coreWorkspaceProject)) { teardownProjects.push(this.coreWorkspaceProject) diff --git a/test/e2e/fixtures/watch/math.test.ts b/test/e2e/fixtures/related/math.test.ts similarity index 100% rename from test/e2e/fixtures/watch/math.test.ts rename to test/e2e/fixtures/related/math.test.ts diff --git a/test/e2e/fixtures/watch/math.ts b/test/e2e/fixtures/related/math.ts similarity index 100% rename from test/e2e/fixtures/watch/math.ts rename to test/e2e/fixtures/related/math.ts diff --git a/test/e2e/fixtures/related/vitest.config.ts b/test/e2e/fixtures/related/vitest.config.ts new file mode 100644 index 000000000..b1c6ea436 --- /dev/null +++ b/test/e2e/fixtures/related/vitest.config.ts @@ -0,0 +1 @@ +export default {} diff --git a/test/e2e/fixtures/watch/42.txt b/test/e2e/fixtures/watch/42.txt deleted file mode 100644 index 41bed4df1..000000000 --- a/test/e2e/fixtures/watch/42.txt +++ /dev/null @@ -1,4 +0,0 @@ -42 - - - diff --git a/test/e2e/fixtures/watch/example.test.ts b/test/e2e/fixtures/watch/example.test.ts deleted file mode 100644 index 8ca564f5f..000000000 --- a/test/e2e/fixtures/watch/example.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { expect, test } from 'vitest' - -import { getHelloWorld } from './example' - -// @ts-expect-error not typed txt -import answer from './42.txt?raw' - -test('answer is 42', () => { - expect(answer).toContain('42') -}) - -test('getHello', async () => { - expect(getHelloWorld()).toBe('Hello world') -}) diff --git a/test/e2e/fixtures/watch/example.ts b/test/e2e/fixtures/watch/example.ts deleted file mode 100644 index 1c18cd944..000000000 --- a/test/e2e/fixtures/watch/example.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function getHelloWorld() { - return 'Hello world' -} diff --git a/test/e2e/fixtures/watch/force-watch/trigger.js b/test/e2e/fixtures/watch/force-watch/trigger.js deleted file mode 100644 index 2e2b68e75..000000000 --- a/test/e2e/fixtures/watch/force-watch/trigger.js +++ /dev/null @@ -1 +0,0 @@ -export const trigger = false diff --git a/test/e2e/fixtures/watch/global-setup.ts b/test/e2e/fixtures/watch/global-setup.ts deleted file mode 100644 index 4d9c33792..000000000 --- a/test/e2e/fixtures/watch/global-setup.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TestProject } from 'vitest/node'; - -const calls: string[] = []; - -(globalThis as any).__CALLS = calls - -export default (project: TestProject) => { - calls.push('start') - project.onTestsRerun(() => { - calls.push('rerun') - }) - return () => { - calls.push('end') - } -} diff --git a/test/e2e/fixtures/watch/vitest.config.ts b/test/e2e/fixtures/watch/vitest.config.ts deleted file mode 100644 index 184e5719a..000000000 --- a/test/e2e/fixtures/watch/vitest.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defaultExclude, defineConfig } from 'vitest/config' - -// Patch stdin on the process so that we can fake it to seem like a real interactive terminal and pass the TTY checks -process.stdin.isTTY = true -process.stdin.setRawMode = () => process.stdin - -export default defineConfig({ - test: { - watch: true, - exclude: [ - ...defaultExclude, - '**/single-failed/**', - ], - - // This configuration is edited by tests - reporters: 'verbose', - - forceRerunTriggers: [ - '**/force-watch/**', - ], - }, -}) diff --git a/test/e2e/test/watch/file-watching.test.ts b/test/e2e/test/watch/file-watching.test.ts index 7293d5da4..06b4cf0f6 100644 --- a/test/e2e/test/watch/file-watching.test.ts +++ b/test/e2e/test/watch/file-watching.test.ts @@ -1,22 +1,49 @@ -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, rmSync } from 'node:fs' import { playwright } from '@vitest/browser-playwright' - -import { afterEach, describe, expect, onTestFinished, test } from 'vitest' +import { resolve } from 'pathe' +import { describe, expect, test } from 'vitest' import * as testUtils from '#test-utils' -const sourceFile = 'fixtures/watch/math.ts' -const sourceFileContent = readFileSync(sourceFile, 'utf-8') +const mathTs = /* ts */ ` +export function sum(a: number, b: number) { + return a + b +} +` + +const mathTestTs = /* ts */ ` +import { expect, test } from 'vitest' + +import { sum } from './math' + +test('sum', () => { + expect(sum(1, 2)).toBe(3) +}) +` + +const exampleTs = /* ts */ ` +export function getHelloWorld() { + return 'Hello world' +} +` -const testFile = 'fixtures/watch/math.test.ts' -const testFileContent = readFileSync(testFile, 'utf-8') +const exampleTestTs = /* ts */ ` +import { expect, test } from 'vitest' -const configFile = 'fixtures/watch/vitest.config.ts' -const configFileContent = readFileSync(configFile, 'utf-8') +import { getHelloWorld } from './example' -const forceTriggerFile = 'fixtures/watch/force-watch/trigger.js' -const forceTriggerFileContent = readFileSync(forceTriggerFile, 'utf-8') +test('getHello', async () => { + expect(getHelloWorld()).toBe('Hello world') +}) +` -const options = { root: 'fixtures/watch', watch: true } +// two test files, so the initial run reports "2 passed" and a "1 passed" wait +// can only be satisfied by a rerun of a single affected file +const baseFixture = { + 'math.ts': mathTs, + 'math.test.ts': mathTestTs, + 'example.ts': exampleTs, + 'example.test.ts': exampleTestTs, +} function modifyContent(fileContent: string) { return `// Modified by file-watching.test.ts @@ -25,70 +52,106 @@ console.log("New code running"); // This is used to check that edited changes ar ` } -afterEach(() => { - writeFileSync(sourceFile, sourceFileContent, 'utf8') - writeFileSync(testFile, testFileContent, 'utf8') - writeFileSync(configFile, configFileContent, 'utf8') - writeFileSync(forceTriggerFile, forceTriggerFileContent, 'utf8') -}) - test('editing source file triggers re-run', async () => { - const { vitest } = await testUtils.runVitest(options) + const { vitest, fs } = await testUtils.runInlineTests(baseFixture, { watch: true }) - writeFileSync(sourceFile, modifyContent(sourceFileContent), 'utf8') + fs.editFile('math.ts', modifyContent) await vitest.waitForStdout('New code running') - await vitest.waitForStdout('RERUN ../../math.ts') + await vitest.waitForStdout('RERUN ../math.ts') await vitest.waitForStdout('1 passed') }) test('editing file that was imported with a query reruns suite', async () => { - const { vitest } = await testUtils.runVitest(options) + const { vitest, fs } = await testUtils.runInlineTests({ + ...baseFixture, + '42.txt': '42\n', + 'answer.test.ts': /* ts */ ` +import { expect, test } from 'vitest' + +// @ts-expect-error not typed txt +import answer from './42.txt?raw' + +test('answer is 42', () => { + expect(answer).toContain('42') +}) +`, + }, { watch: true }) - testUtils.editFile( - testUtils.resolvePath(import.meta.url, '../../fixtures/watch/42.txt'), - file => `${file}\n`, - ) + fs.editFile('42.txt', file => `${file}\n`) - await vitest.waitForStdout('RERUN ../../42.txt') + await vitest.waitForStdout('RERUN ../42.txt') await vitest.waitForStdout('1 passed') }) test('editing force rerun trigger reruns all tests', async () => { - const { vitest } = await testUtils.runVitest(options) - - writeFileSync(forceTriggerFile, modifyContent(forceTriggerFileContent), 'utf8') + const { vitest, fs } = await testUtils.runInlineTests({ + ...baseFixture, + 'force-watch/trigger.js': 'export const trigger = false\n', + 'vitest.config.ts': /* ts */ ` +export default { + test: { + forceRerunTriggers: ['**/force-watch/**'], + }, +} +`, + }, { watch: true }) await vitest.waitForStdout('Waiting for file changes...') - await vitest.waitForStdout('RERUN ../../force-watch/trigger.js') + vitest.resetOutput() + + fs.editFile('force-watch/trigger.js', modifyContent) + + await vitest.waitForStdout('RERUN ../force-watch/trigger.js') await vitest.waitForStdout('example.test.ts') await vitest.waitForStdout('math.test.ts') await vitest.waitForStdout('2 passed') }) test('editing test file triggers re-run', async () => { - const { vitest } = await testUtils.runVitest(options) + const { vitest, fs } = await testUtils.runInlineTests(baseFixture, { watch: true }) - writeFileSync(testFile, modifyContent(testFileContent), 'utf8') + fs.editFile('math.test.ts', modifyContent) await vitest.waitForStdout('New code running') - await vitest.waitForStdout('RERUN ../../math.test.ts') + await vitest.waitForStdout('RERUN ../math.test.ts') await vitest.waitForStdout('1 passed') }) test('editing config file triggers re-run', async () => { - const { vitest } = await testUtils.runVitest(options) + const { vitest, fs } = await testUtils.runInlineTests({ + ...baseFixture, + 'vitest.config.ts': /* ts */ ` +export default { + test: { + reporters: 'verbose', + }, +} +`, + }, { watch: true, reporters: 'none' }) - writeFileSync(configFile, modifyContent(configFileContent), 'utf8') + await vitest.waitForStdout('Waiting for file changes...') + vitest.resetOutput() + + fs.editFile('vitest.config.ts', modifyContent) await vitest.waitForStdout('Restarting due to config changes') await vitest.waitForStdout('2 passed') }) test('editing config file reloads new changes', async () => { - const { vitest } = await testUtils.runVitest({ ...options, reporters: 'none' }) + const { vitest, fs } = await testUtils.runInlineTests({ + ...baseFixture, + 'vitest.config.ts': /* ts */ ` +export default { + test: { + reporters: 'verbose', + }, +} +`, + }, { watch: true, reporters: 'none' }) - writeFileSync(configFile, configFileContent.replace('reporters: \'verbose\'', 'reporters: \'tap\''), 'utf8') + fs.editFile('vitest.config.ts', content => content.replace('reporters: \'verbose\'', 'reporters: \'tap\'')) await vitest.waitForStdout('TAP version') await vitest.waitForStdout('ok 2') @@ -161,22 +224,14 @@ test('renaming an existing test file', { retry: 3 }, async () => { }) test('editing source file generates new test report to file system', async () => { - const report = 'fixtures/watch/test-results/junit.xml' - onTestFinished(() => { - if (existsSync(report)) { - rmSync(report) - } - }) - - // Test report should not be present before test run - expect(existsSync(report)).toBe(false) - - const { vitest } = await testUtils.runVitest({ - ...options, + const { vitest, fs, root } = await testUtils.runInlineTests(baseFixture, { + watch: true, reporters: ['verbose', 'junit'], outputFile: './test-results/junit.xml', }) + const report = resolve(root, 'test-results/junit.xml') + // Test report should be generated on initial test run expect(existsSync(report)).toBe(true) @@ -185,17 +240,15 @@ test('editing source file generates new test report to file system', async () => expect(existsSync(report)).toBe(false) vitest.resetOutput() - writeFileSync(sourceFile, modifyContent(sourceFileContent), 'utf8') + fs.editFile('math.ts', modifyContent) await vitest.waitForStdout('JUNIT report written') - await vitest.waitForStdout(report) expect(existsSync(report)).toBe(true) }) describe('browser', () => { test.runIf((process.platform !== 'win32'))('editing source file triggers re-run', { retry: 3 }, async () => { - const { vitest } = await testUtils.runVitest({ - root: 'fixtures/watch', + const { vitest, fs } = await testUtils.runInlineTests(baseFixture, { watch: true, browser: { instances: [{ browser: 'chromium' }], @@ -205,10 +258,10 @@ describe('browser', () => { }, }) - writeFileSync(sourceFile, modifyContent(sourceFileContent), 'utf8') + fs.editFile('math.ts', modifyContent) await vitest.waitForStdout('New code running') - await vitest.waitForStdout('RERUN ../../math.ts') + await vitest.waitForStdout('RERUN ../math.ts') await vitest.waitForStdout('1 passed') vitest.write('q') diff --git a/test/e2e/test/watch/global-setup-rerun.test.ts b/test/e2e/test/watch/global-setup-rerun.test.ts index eb25dc5ed..7a6279e8f 100644 --- a/test/e2e/test/watch/global-setup-rerun.test.ts +++ b/test/e2e/test/watch/global-setup-rerun.test.ts @@ -1,14 +1,45 @@ import { expect, test } from 'vitest' -import { editFile, runVitest } from '#test-utils' +import { runInlineTests } from '#test-utils' -const testFile = 'fixtures/watch/math.test.ts' +const fixture = { + 'math.ts': /* ts */ ` +export function sum(a: number, b: number) { + return a + b +} +`, + 'math.test.ts': /* ts */ ` +import { expect, test } from 'vitest' + +import { sum } from './math' + +test('sum', () => { + expect(sum(1, 2)).toBe(3) +}) +`, + 'globalSetup.ts': /* ts */ ` +import { TestProject } from 'vitest/node'; + +const calls: string[] = []; + +(globalThis as any).__CALLS = calls + +export default (project: TestProject) => { + calls.push('start') + project.onTestsRerun(() => { + calls.push('rerun') + }) + return () => { + calls.push('end') + } +} +`, +} test('global setup calls hooks correctly when file changes', async () => { - const { vitest, ctx } = await runVitest({ - root: 'fixtures/watch', + const { vitest, ctx, fs } = await runInlineTests(fixture, { watch: true, include: ['math.test.ts'], - globalSetup: ['./global-setup.ts'], + globalSetup: ['./globalSetup.ts'], }) await vitest.waitForStdout('Waiting for file changes') @@ -16,7 +47,7 @@ test('global setup calls hooks correctly when file changes', async () => { const calls = (globalThis as any).__CALLS as string[] expect(calls).toEqual(['start']) - editFile(testFile, testFileContent => `${testFileContent}\n\n`) + fs.editFile('math.test.ts', testFileContent => `${testFileContent}\n\n`) await vitest.waitForStdout('RERUN') expect(calls).toEqual(['start', 'rerun']) @@ -27,11 +58,10 @@ test('global setup calls hooks correctly when file changes', async () => { }) test('global setup calls hooks correctly with a manual rerun', async () => { - const { vitest, ctx } = await runVitest({ - root: 'fixtures/watch', + const { vitest, ctx } = await runInlineTests(fixture, { watch: true, include: ['math.test.ts'], - globalSetup: ['./global-setup.ts'], + globalSetup: ['./globalSetup.ts'], }) await vitest.waitForStdout('Waiting for file changes') diff --git a/test/e2e/test/watch/related.test.ts b/test/e2e/test/watch/related.test.ts index bfaad85d4..0144d8e49 100644 --- a/test/e2e/test/watch/related.test.ts +++ b/test/e2e/test/watch/related.test.ts @@ -2,9 +2,13 @@ import { resolve } from 'pathe' import { test } from 'vitest' import { editFile, runVitest } from '#test-utils' +// `changed: true` is git-driven, so unlike the other watch tests this one has +// to run against a committed fixture: an inline tmp directory is either +// untracked (git reports every file as changed) or gitignored (git never +// reports its files as changed, even after edits). test('when nothing is changed, run nothing but keep watching', async () => { const { vitest } = await runVitest({ - root: 'fixtures/watch', + root: 'fixtures/related', watch: true, changed: true, }) @@ -12,12 +16,12 @@ test('when nothing is changed, run nothing but keep watching', async () => { await vitest.waitForStdout('No affected test files found') await vitest.waitForStdout('Waiting for file changes...') - editFile(resolve(import.meta.dirname, '../../fixtures/watch/math.ts'), content => `${content}\n\n`) + editFile(resolve(import.meta.dirname, '../../fixtures/related/math.ts'), content => `${content}\n\n`) await vitest.waitForStdout('RERUN ../../math.ts') await vitest.waitForStdout('1 passed') - editFile(resolve(import.meta.dirname, '../../fixtures/watch/math.test.ts'), content => `${content}\n\n`) + editFile(resolve(import.meta.dirname, '../../fixtures/related/math.test.ts'), content => `${content}\n\n`) await vitest.waitForStdout('RERUN ../../math.test.ts') await vitest.waitForStdout('1 passed') diff --git a/test/e2e/test/watch/restart-coalescing.test.ts b/test/e2e/test/watch/restart-coalescing.test.ts index d6f7697ec..5d068eaac 100644 --- a/test/e2e/test/watch/restart-coalescing.test.ts +++ b/test/e2e/test/watch/restart-coalescing.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest' -import { runVitest } from '#test-utils' +import { runInlineTests } from '#test-utils' // chokidar regularly delivers several change events for one config edit, each // triggering a restart. A restart that begins while another is still @@ -7,10 +7,15 @@ import { runVitest } from '#test-utils' // were re-instantiated but not yet initialized, crashing the run with // "Cannot read properties of undefined (reading 'logger')". test('concurrent restarts are coalesced instead of overlapping', async () => { - const { ctx, vitest } = await runVitest({ - root: 'fixtures/watch', - watch: true, - }) + const { ctx, vitest } = await runInlineTests({ + 'basic.test.ts': /* ts */ ` +import { expect, test } from 'vitest' + +test('basic', () => { + expect(1).toBe(1) +}) +`, + }, { watch: true }) const restart = (ctx as any)._restart.bind(ctx) await Promise.all([restart('config'), restart('config'), restart('config')]) diff --git a/test/e2e/test/watch/stdout.test.ts b/test/e2e/test/watch/stdout.test.ts index 99b68e2ff..de45f4dc4 100644 --- a/test/e2e/test/watch/stdout.test.ts +++ b/test/e2e/test/watch/stdout.test.ts @@ -1,16 +1,23 @@ -import { readFileSync, writeFileSync } from 'node:fs' -import { afterEach, test } from 'vitest' -import { runVitest } from '#test-utils' +import { test } from 'vitest' +import { runInlineTests } from '#test-utils' -const testFile = 'fixtures/watch/math.test.ts' -const testFileContent = readFileSync(testFile, 'utf-8') +test('console.log is visible on test re-run', async () => { + const { vitest, fs } = await runInlineTests({ + 'math.ts': /* ts */ ` +export function sum(a: number, b: number) { + return a + b +} +`, + 'math.test.ts': /* ts */ ` +import { expect, test } from 'vitest' -afterEach(() => { - writeFileSync(testFile, testFileContent, 'utf8') -}) +import { sum } from './math' -test('console.log is visible on test re-run', async () => { - const { vitest } = await runVitest({ root: 'fixtures/watch', watch: true }) +test('sum', () => { + expect(sum(1, 2)).toBe(3) +}) +`, + }, { watch: true }) const testCase = ` test('test with logging', () => { @@ -21,7 +28,7 @@ test('test with logging', () => { }) ` - writeFileSync(testFile, `${testFileContent}${testCase}`, 'utf8') + fs.editFile('math.test.ts', content => `${content}${testCase}`) await vitest.waitForStdout('stdout | math.test.ts > test with logging') await vitest.waitForStdout('First') diff --git a/test/e2e/vitest.config.ts b/test/e2e/vitest.config.ts index bafe13afe..a3863d176 100644 --- a/test/e2e/vitest.config.ts +++ b/test/e2e/vitest.config.ts @@ -2,8 +2,11 @@ import path from 'node:path' import { defineConfig } from 'vite' import { defaultExclude } from 'vitest/config' -// Tests that drive git `--changed` against shared fixtures and cannot tolerate -// other tests mutating the working tree concurrently. Run in a serial project. +// Tests that drive git `--changed` against committed fixtures and cannot +// tolerate other tests mutating the working tree concurrently. Run in a serial +// project. Tests that only need a watched directory use `runInlineTests` +// instead: a private tmp root needs no serialization, but it cannot back the +// git-driven tests, which require tracked files. const serialTests = [ 'test/git-changed.test.ts', 'test/list-changed.test.ts', diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 6d5bfae86..68a19593e 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -44,6 +44,12 @@ export interface RunVitestConfig extends TestUserConfig { const process_ = process +// Polling interval of the file watcher in spawned watch-mode instances. The +// watcher-ready probe derives its timing from it: each probe state must +// outlive a full poll cycle, or the poller can keep sampling the state that +// matches its baseline and never observe a difference. +const WATCH_POLL_INTERVAL = 100 + export function createConsole({ tty, std }: { tty?: boolean; std?: 'inherit' } = {}) { const stdout = new Writable({ write(chunk, __, callback) { @@ -239,7 +245,7 @@ export async function runVitest( // misses change events, so enforce polling for consistency // https://github.com/vitejs/vite/blob/b723a753ced0667470e72b4853ecda27b17f546a/playground/vitestSetup.ts#L211 usePolling: true, - interval: 100, + interval: WATCH_POLL_INTERVAL, ...viteConfig.server?.watch, }, ...viteConfig?.server, @@ -290,7 +296,7 @@ export async function runVitest( // watcher's baseline and ignored, so newly added files wouldn't trigger a // rerun. Wait for the watcher to be ready before handing control back. if (watch && ctx) { - await waitForWatcherReady(ctx.vite.watcher, ctx.config.root) + await waitForWatcherReady(ctx) } return { @@ -329,23 +335,109 @@ export async function runVitest( // In watch mode `startVitest` can resolve before the file watcher has finished // establishing itself. chokidar's `ready`/`_readyEmitted` fires after the -// initial scan, but with polling the root directory isn't fully watched for new -// children until its parent shows up in `getWatched()`. A test file created -// before that point gets folded into the watcher's baseline and never emits an -// `add` event, so newly added files wouldn't trigger a rerun. Wait for both -// signals (with a timeout safety net) before handing control back. -async function waitForWatcherReady(watcher: FSWatcher, root: string): Promise { +// initial scan, but with polling a file only gets a per-file stat poller some +// time after it is discovered, and an edit made before the poller exists is +// folded into its baseline stat and never emits an event. No amount of chokidar +// bookkeeping (`_readyEmitted`, `getWatched()`) proves the pollers are live, so +// verify end-to-end: keep cycling a probe file in the root until the watcher +// reports it. The probe is recreated rather than rewritten because a creation +// folded into the initial scan of the file AND its directory leaves nothing to +// rescan; alternating present/absent guarantees the current state differs from +// whichever state the poller's baseline captured, and any probe event (`add`, +// `change` or `unlink`) proves delivery. Each state is held for multiple poll +// intervals: on a loaded runner, a cycle as fast as the poll interval aliases +// with it — every rescan can land when the probe is back in the state matching +// the poller's baseline, and no event ever fires. +// +// The scan can also surface writes made by the PREVIOUS test (an `afterEach` +// restoring a fixture right before this instance started) as fresh change +// events, triggering a rerun the test never asked for. While waiting, neuter +// every rerun attempt by clearing the watcher state (the rerun debounce +// returns silently when `changedTests` is empty) and only hand control back +// after the event backlog has been quiet for a couple of poll intervals. +// +// Only instances with an explicit small root (a fixture or an inline-test dir) +// get the probe: tests that assert on watch reruns always use one. Instances +// rooted at the whole test package (config-introspection tests passing +// `watch: true` without a root) never wait for file events, and polling their +// huge tree can take longer than any reasonable deadline, so for them only the +// cheap bookkeeping check runs, as before. +async function waitForWatcherReady(ctx: Vitest): Promise { + const watcher: FSWatcher = ctx.vite.watcher + const root = ctx.config.root const slash = (p: string) => p.replace(/\\/g, '/') const parent = slash(dirname(root)) - const deadline = Date.now() + 2000 - while (Date.now() < deadline) { + const bookkeepingDeadline = Date.now() + 2000 + + while (Date.now() < bookkeepingDeadline) { const isReady = (watcher as { _readyEmitted?: boolean })._readyEmitted const watchesParent = Object.keys(watcher.getWatched()).some(dir => slash(dir) === parent) if (isReady && watchesParent) { - return + break } await new Promise(resolve => setTimeout(resolve, 10)) } + + if (slash(root) === slash(process.cwd())) { + return + } + + const deadline = Date.now() + 10_000 + const probe = resolve(root, '.vitest-watcher-ready-probe') + const suppressRerun = () => { + ctx.watcher.changedTests.clear() + ctx.watcher.invalidates.clear() + } + let probeSeen = false + let lastEventAt = Date.now() + const onWatcherEvent = (_event: string, file: string) => { + suppressRerun() + if (slash(file) === probe) { + probeSeen = true + } + else { + lastEventAt = Date.now() + } + } + watcher.on('all', onWatcherEvent) + try { + const holdProbeState = async () => { + const stateEnd = Date.now() + WATCH_POLL_INTERVAL * 2.5 + while (Date.now() < stateEnd) { + if (probeSeen) { + return + } + await new Promise(resolve => setTimeout(resolve, 25)) + } + } + while (true) { + if (probeSeen) { + break + } + if (Date.now() > deadline) { + throw new Error( + `The watcher of ${root} did not report the probe file within 10s. Watched files:\n${ + JSON.stringify(watcher.getWatched(), null, 2)}`, + ) + } + fs.writeFileSync(probe, `${Date.now()}`, 'utf-8') + await holdProbeState() + if (probeSeen) { + break + } + fs.rmSync(probe, { force: true }) + await holdProbeState() + } + + while (Date.now() - lastEventAt < 200 && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 50)) + } + } + finally { + watcher.off('all', onWatcherEvent) + fs.rmSync(probe, { force: true }) + suppressRerun() + } } interface CliOptions extends Partial { @@ -440,6 +532,7 @@ export function getInternalState(): WorkerGlobalState { } const originalFiles = new Map() +const originalFileStats = new Map() export function createFile(file: string, content: string) { fs.mkdirSync(dirname(file), { recursive: true }) @@ -455,17 +548,31 @@ export function editFile(file: string, callback: (content: string) => string) { const content = fs.readFileSync(file, 'utf-8') if (!originalFiles.has(file)) { originalFiles.set(file, content) + originalFileStats.set(file, fs.statSync(file)) } fs.writeFileSync(file, callback(content), 'utf-8') onTestFinished(() => { const original = originalFiles.get(file) if (original !== undefined) { - fs.writeFileSync(file, original, 'utf-8') + restoreFile(file, original, originalFileStats.get(file)) originalFiles.delete(file) + originalFileStats.delete(file) } }) } +// Restore the original mtime along with the content: a restore with a fresh +// mtime is indistinguishable from a real edit to any watcher whose stat +// baseline predates it, and the phantom change event then reruns tests in the +// NEXT test's instance. With content, size and mtime all matching the +// pre-test state, no stat comparison can report the file as changed. +export function restoreFile(file: string, content: string, stat?: fs.Stats) { + fs.writeFileSync(file, content, 'utf-8') + if (stat) { + fs.utimesSync(file, stat.atime, stat.mtime) + } +} + export function resolvePath(baseUrl: string, path: string) { const filename = fileURLToPath(baseUrl) return resolve(dirname(filename), path)