diff --git a/docs/advanced/runner.md b/docs/advanced/runner.md index a03b6d74ea28..dc4ae5ea257e 100644 --- a/docs/advanced/runner.md +++ b/docs/advanced/runner.md @@ -17,6 +17,13 @@ export interface VitestRunner { */ onCollected?(files: File[]): unknown + /** + * Called when test runner should cancel next test runs. + * Runner should listen for this method and mark tests and suites as skipped in + * "onBeforeRunSuite" and "onBeforeRunTest" when called. + */ + onCancel?(): unknown + /** * Called before running a single test. Doesn't have "result" yet. */ @@ -86,7 +93,7 @@ export interface VitestRunner { When initiating this class, Vitest passes down Vitest config, - you should expose it as a `config` property. ::: warning -Vitest also injects an instance of `ViteNodeRunner` as `__vitest_executor` property. You can use it to process files in `importFile` method (this is default behavior of `TestRunner`` and `BenchmarkRunner`). +Vitest also injects an instance of `ViteNodeRunner` as `__vitest_executor` property. You can use it to process files in `importFile` method (this is default behavior of `TestRunner` and `BenchmarkRunner`). `ViteNodeRunner` exposes `executeId` method, which is used to import test files in a Vite-friendly environment. Meaning, it will resolve imports and transform file content at runtime so that Node can understand it. ::: diff --git a/packages/browser/src/client/main.ts b/packages/browser/src/client/main.ts index 50a636cb7ca5..78693dca9b76 100644 --- a/packages/browser/src/client/main.ts +++ b/packages/browser/src/client/main.ts @@ -30,7 +30,16 @@ function getQueryPaths() { return url.searchParams.getAll('path') } -export const client = createClient(ENTRY_URL) +let setCancel = () => {} +const onCancel = new Promise((resolve) => { + setCancel = resolve +}) + +export const client = createClient(ENTRY_URL, { + handlers: { + onCancel: setCancel, + }, +}) const ws = client.ws @@ -103,6 +112,10 @@ async function runTests(paths: string[], config: ResolvedConfig) { runner = new BrowserRunner({ config, browserHashMap }) } + onCancel.then(() => { + runner?.onCancel?.() + }) + if (!config.snapshotOptions.snapshotEnvironment) config.snapshotOptions.snapshotEnvironment = new BrowserSnapshotEnvironment() diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index 382b7eee04c1..fbf796869892 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -37,6 +37,13 @@ export interface VitestRunner { */ onCollected?(files: File[]): unknown + /** + * Called when test runner should cancel next test runs. + * Runner should listen for this method and mark tests and suites as skipped in + * "onBeforeRunSuite" and "onBeforeRunTest" when called. + */ + onCancel?(): unknown + /** * Called before running a single test. Doesn't have "result" yet. */ diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index b3f76efc9c1d..c6c3df772345 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -115,12 +115,14 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, server?: Vit { post: msg => ws.send(msg), on: fn => ws.on('message', fn), - eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected'], + eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onCancel'], serialize: stringify, deserialize: parse, }, ) + ctx.onCancel(() => rpc.onCancel()) + clients.set(ws, rpc) ws.on('close', () => { diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index 1178b66152c9..8a9708eb265a 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -28,4 +28,5 @@ export interface WebSocketHandlers { } export interface WebSocketEvents extends Pick { + onCancel(): void } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 9383ee21eea3..46be43a7478f 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -46,6 +46,7 @@ export class Vitest { filenamePattern?: string runningPromise?: Promise closingPromise?: Promise + isCancelling = false isFirstRun = true restartsCount = 0 @@ -64,6 +65,7 @@ export class Vitest { private _onRestartListeners: OnServerRestartHandler[] = [] private _onSetServer: OnServerRestartHandler[] = [] + private _onCancelListeners: (() => Promise | void)[] = [] async setServer(options: UserConfig, server: ViteDevServer, cliOptions: UserConfig) { this.unregisterWatcher?.() @@ -396,13 +398,14 @@ export class Vitest { async runFiles(paths: WorkspaceSpec[]) { const filepaths = paths.map(([, file]) => file) - this.state.collectPaths(filepaths) await this.report('onPathsCollected', filepaths) // previous run await this.runningPromise + this._onCancelListeners = [] + this.isCancelling = false // schedule the new run this.runningPromise = (async () => { @@ -438,6 +441,11 @@ export class Vitest { return await this.runningPromise } + async cancelCurrentRun() { + this.isCancelling = true + await Promise.all(this._onCancelListeners.splice(0).map(listener => listener())) + } + async rerunFiles(files: string[] = this.state.getFilepaths(), trigger?: string) { if (this.filenamePattern) { const filteredFiles = await this.globTestFiles([this.filenamePattern]) @@ -592,6 +600,9 @@ export class Vitest { id = slash(id) updateLastChanged(id) if (await this.isTargetFile(id)) { + // TODO: Remove, fixed in #3189. Required for test cases now. + await this.globTestFiles([id]) + this.changedTests.add(id) this.scheduleRerun([id]) } @@ -767,4 +778,8 @@ export class Vitest { onAfterSetServer(fn: OnServerRestartHandler) { this._onSetServer.push(fn) } + + onCancel(fn: () => void) { + this._onCancelListeners.push(fn) + } } diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index fcdef6b87d8f..b3ce9ece7ab9 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -15,6 +15,13 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { } const runTests = async (project: WorkspaceProject, files: string[]) => { + ctx.state.clearFiles(project, files) + + let isCancelled = false + project.ctx.onCancel(() => { + isCancelled = true + }) + const provider = project.browserProvider! providers.add(provider) @@ -24,6 +31,11 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { const isolate = project.config.isolate if (isolate) { for (const path of paths) { + if (isCancelled) { + ctx.state.cancelFiles(files) + break + } + const url = new URL('/', origin) url.searchParams.append('path', path) url.searchParams.set('id', path) diff --git a/packages/vitest/src/node/pools/child.ts b/packages/vitest/src/node/pools/child.ts index 33fac5f414c3..8c48333597e3 100644 --- a/packages/vitest/src/node/pools/child.ts +++ b/packages/vitest/src/node/pools/child.ts @@ -4,7 +4,7 @@ import { fork } from 'node:child_process' import { fileURLToPath, pathToFileURL } from 'node:url' import { createBirpc } from 'birpc' import { resolve } from 'pathe' -import type { ContextTestEnvironment, ResolvedConfig, RuntimeRPC, Vitest } from '../../types' +import type { ContextTestEnvironment, ResolvedConfig, RunnerRPC, RuntimeRPC, Vitest } from '../../types' import type { ChildContext } from '../../types/child' import type { PoolProcessOptions, ProcessPool, WorkspaceSpec } from '../pool' import { distDir } from '../../paths' @@ -16,9 +16,10 @@ import { createMethodsRPC } from './rpc' const childPath = fileURLToPath(pathToFileURL(resolve(distDir, './child.js')).href) function setupChildProcessChannel(project: WorkspaceProject, fork: ChildProcess): void { - createBirpc<{}, RuntimeRPC>( + const rpc = createBirpc( createMethodsRPC(project), { + eventNames: ['onCancel'], serialize: v8.serialize, deserialize: v => v8.deserialize(Buffer.from(v)), post(v) { @@ -29,6 +30,8 @@ function setupChildProcessChannel(project: WorkspaceProject, fork: ChildProcess) }, }, ) + + project.ctx.onCancel(() => rpc.onCancel()) } function stringifyRegex(input: RegExp | string): string { diff --git a/packages/vitest/src/node/pools/threads.ts b/packages/vitest/src/node/pools/threads.ts index 3335ed477788..9570a4a8b0cb 100644 --- a/packages/vitest/src/node/pools/threads.ts +++ b/packages/vitest/src/node/pools/threads.ts @@ -6,7 +6,7 @@ import { resolve } from 'pathe' import type { Options as TinypoolOptions } from 'tinypool' import Tinypool from 'tinypool' import { distDir } from '../../paths' -import type { ContextTestEnvironment, ResolvedConfig, RuntimeRPC, Vitest, WorkerContext } from '../../types' +import type { ContextTestEnvironment, ResolvedConfig, RunnerRPC, RuntimeRPC, Vitest, WorkerContext } from '../../types' import type { PoolProcessOptions, ProcessPool, RunWithFiles } from '../pool' import { envsOrder, groupFilesByEnv } from '../../utils/test-helpers' import { AggregateError, groupBy } from '../../utils/base' @@ -20,9 +20,10 @@ function createWorkerChannel(project: WorkspaceProject) { const port = channel.port2 const workerPort = channel.port1 - createBirpc<{}, RuntimeRPC>( + const rpc = createBirpc( createMethodsRPC(project), { + eventNames: ['onCancel'], post(v) { port.postMessage(v) }, @@ -32,6 +33,8 @@ function createWorkerChannel(project: WorkspaceProject) { }, ) + project.ctx.onCancel(() => rpc.onCancel()) + return { workerPort, port } } @@ -93,6 +96,11 @@ export function createThreadsPool(ctx: Vitest, { execArgv, env }: PoolProcessOpt // Worker got stuck and won't terminate - this may cause process to hang if (error instanceof Error && /Failed to terminate worker/.test(error.message)) ctx.state.addProcessTimeoutCause(`Failed to terminate worker while running ${files.join(', ')}.`) + + // Intentionally cancelled + else if (ctx.isCancelling && error instanceof Error && /The task has been cancelled/.test(error.message)) + ctx.state.cancelFiles(files) + else throw error } @@ -106,6 +114,10 @@ export function createThreadsPool(ctx: Vitest, { execArgv, env }: PoolProcessOpt const sequencer = new Sequencer(ctx) return async (specs, invalidates) => { + // TODO: Requires tinylibs/tinypool#52 + // Cancel pending tasks from pool when possible + // ctx.onCancel(() => pool.cancelPendingTasks()) + const configs = new Map() const getConfig = (project: WorkspaceProject): ResolvedConfig => { if (configs.has(project)) diff --git a/packages/vitest/src/node/reporters/base.ts b/packages/vitest/src/node/reporters/base.ts index 15087eec1229..484840208bec 100644 --- a/packages/vitest/src/node/reporters/base.ts +++ b/packages/vitest/src/node/reporters/base.ts @@ -13,6 +13,7 @@ const HELP_QUITE = `${c.dim('press ')}${c.bold('q')}${c.dim(' to quit')}` const WAIT_FOR_CHANGE_PASS = `\n${c.bold(c.inverse(c.green(' PASS ')))}${c.green(' Waiting for file changes...')}` const WAIT_FOR_CHANGE_FAIL = `\n${c.bold(c.inverse(c.red(' FAIL ')))}${c.red(' Tests failed. Watching for file changes...')}` +const WAIT_FOR_CHANGE_CANCELLED = `\n${c.bold(c.inverse(c.red(' CANCELLED ')))}${c.red(' Test run cancelled. Watching for file changes...')}` const LAST_RUN_LOG_TIMEOUT = 1_500 @@ -102,8 +103,12 @@ export abstract class BaseReporter implements Reporter { const failed = errors.length > 0 || hasFailed(files) const failedSnap = hasFailedSnapshot(files) + const cancelled = this.ctx.isCancelling + if (failed) this.ctx.logger.log(WAIT_FOR_CHANGE_FAIL) + else if (cancelled) + this.ctx.logger.log(WAIT_FOR_CHANGE_CANCELLED) else this.ctx.logger.log(WAIT_FOR_CHANGE_PASS) diff --git a/packages/vitest/src/node/state.ts b/packages/vitest/src/node/state.ts index b956317262dd..fd9529de3fa1 100644 --- a/packages/vitest/src/node/state.ts +++ b/packages/vitest/src/node/state.ts @@ -125,4 +125,15 @@ export class StateManager { task.logs.push(log) } } + + cancelFiles(files: string[]) { + this.collectFiles(files.map(filepath => ({ + filepath, + id: filepath, + mode: 'skip', + name: filepath, + tasks: [], + type: 'suite', + }))) + } } diff --git a/packages/vitest/src/node/stdin.ts b/packages/vitest/src/node/stdin.ts index dfcc5d4824e4..4fa854883737 100644 --- a/packages/vitest/src/node/stdin.ts +++ b/packages/vitest/src/node/stdin.ts @@ -12,6 +12,7 @@ const keys = [ ['t', 'filter by a test name regex pattern'], ['q', 'quit'], ] +const cancelKeys = ['space', 'c', ...keys.map(key => key[0])] export function printShortcutsHelp() { stdout().write( @@ -37,12 +38,14 @@ export function registerConsoleShortcuts(ctx: Vitest) { return } - // is running, ignore keypress - if (ctx.runningPromise) - return - const name = key?.name + if (ctx.runningPromise) { + if (cancelKeys.includes(name)) + await ctx.cancelCurrentRun() + return + } + // quit if (name === 'q') return ctx.exit(true) @@ -83,8 +86,8 @@ export function registerConsoleShortcuts(ctx: Vitest) { message: 'Input test name pattern (RegExp)', initial: ctx.configOverride.testNamePattern?.source || '', }]) - await ctx.changeNamePattern(filter.trim(), undefined, 'change pattern') on() + await ctx.changeNamePattern(filter.trim(), undefined, 'change pattern') } async function inputFilePattern() { @@ -96,8 +99,8 @@ export function registerConsoleShortcuts(ctx: Vitest) { initial: latestFilename, }]) latestFilename = filter.trim() - await ctx.changeFilenamePattern(filter.trim()) on() + await ctx.changeFilenamePattern(filter.trim()) } let rl: readline.Interface | undefined diff --git a/packages/vitest/src/runtime/child.ts b/packages/vitest/src/runtime/child.ts index 29a6d690333b..228776e5311b 100644 --- a/packages/vitest/src/runtime/child.ts +++ b/packages/vitest/src/runtime/child.ts @@ -2,7 +2,7 @@ import v8 from 'node:v8' import { createBirpc } from 'birpc' import { parseRegexp } from '@vitest/utils' import type { ResolvedConfig } from '../types' -import type { RuntimeRPC } from '../types/rpc' +import type { RunnerRPC, RuntimeRPC } from '../types/rpc' import type { ChildContext } from '../types/child' import { mockMap, moduleCache, startViteNode } from './execute' import { rpcDone } from './rpc' @@ -14,6 +14,11 @@ function init(ctx: ChildContext) { process.env.VITEST_WORKER_ID = '1' process.env.VITEST_POOL_ID = '1' + let setCancel = () => {} + const onCancel = new Promise((resolve) => { + setCancel = resolve + }) + // @ts-expect-error untyped global globalThis.__vitest_environment__ = config.environment // @ts-expect-error I know what I am doing :P @@ -22,12 +27,17 @@ function init(ctx: ChildContext) { moduleCache, config, mockMap, + onCancel, durations: { environment: 0, prepare: performance.now(), }, - rpc: createBirpc( - {}, + rpc: createBirpc( + { + onCancel() { + setCancel() + }, + }, { eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onWorkerExit'], serialize: v8.serialize, diff --git a/packages/vitest/src/runtime/entry.ts b/packages/vitest/src/runtime/entry.ts index 471f02fd6497..efabd3db4a37 100644 --- a/packages/vitest/src/runtime/entry.ts +++ b/packages/vitest/src/runtime/entry.ts @@ -85,6 +85,7 @@ export async function run(files: string[], config: ResolvedConfig, environment: setupChaiConfig(config.chaiConfig) const runner = await getTestRunner(config, executor) + workerState.onCancel.then(() => runner.onCancel?.()) workerState.durations.prepare = performance.now() - workerState.durations.prepare diff --git a/packages/vitest/src/runtime/runners/test.ts b/packages/vitest/src/runtime/runners/test.ts index 1abcfce709ce..8b51519192bc 100644 --- a/packages/vitest/src/runtime/runners/test.ts +++ b/packages/vitest/src/runtime/runners/test.ts @@ -12,6 +12,7 @@ export class VitestTestRunner implements VitestRunner { private snapshotClient = getSnapshotClient() private workerState = getWorkerState() private __vitest_executor!: VitestExecutor + private cancelRun = false constructor(public config: ResolvedConfig) {} @@ -45,9 +46,16 @@ export class VitestTestRunner implements VitestRunner { this.workerState.current = undefined } + onCancel() { + this.cancelRun = true + } + async onBeforeRunTest(test: Test) { const name = getNames(test).slice(1).join(' > ') + if (this.cancelRun) + test.mode = 'skip' + if (test.mode !== 'run') { this.snapshotClient.skipTestSnapshots(name) return @@ -59,6 +67,11 @@ export class VitestTestRunner implements VitestRunner { this.workerState.current = test } + onBeforeRunSuite(suite: Suite) { + if (this.cancelRun) + suite.mode = 'skip' + } + onBeforeTryTest(test: Test) { setState({ assertionCalls: 0, diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts index 105d470f08f3..6eaee43a97f0 100644 --- a/packages/vitest/src/runtime/worker.ts +++ b/packages/vitest/src/runtime/worker.ts @@ -1,7 +1,7 @@ import { performance } from 'node:perf_hooks' import { createBirpc } from 'birpc' import { workerId as poolId } from 'tinypool' -import type { RuntimeRPC, WorkerContext } from '../types' +import type { RunnerRPC, RuntimeRPC, WorkerContext } from '../types' import { getWorkerState } from '../utils/global' import { mockMap, moduleCache, startViteNode } from './execute' import { setupInspect } from './inspector' @@ -17,6 +17,11 @@ function init(ctx: WorkerContext) { process.env.VITEST_WORKER_ID = String(workerId) process.env.VITEST_POOL_ID = String(poolId) + let setCancel = () => {} + const onCancel = new Promise((resolve) => { + setCancel = resolve + }) + // @ts-expect-error untyped global globalThis.__vitest_environment__ = config.environment // @ts-expect-error I know what I am doing :P @@ -25,12 +30,17 @@ function init(ctx: WorkerContext) { moduleCache, config, mockMap, + onCancel, durations: { environment: 0, prepare: performance.now(), }, - rpc: createBirpc( - {}, + rpc: createBirpc( + { + onCancel() { + setCancel() + }, + }, { eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onWorkerExit'], post(v) { port.postMessage(v) }, diff --git a/packages/vitest/src/types/rpc.ts b/packages/vitest/src/types/rpc.ts index eb10ba97ed35..2281131b9462 100644 --- a/packages/vitest/src/types/rpc.ts +++ b/packages/vitest/src/types/rpc.ts @@ -24,6 +24,11 @@ export interface RuntimeRPC { resolveSnapshotPath: (testPath: string) => string } +export interface RunnerRPC { + // TODO: This could be "(reason: 'INPUT' | 'BAIL') => void" instead + onCancel: () => void +} + export interface ContextTestEnvironment { name: VitestEnvironment options: EnvironmentOptions | null diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index cc0face52dbe..be5e07267571 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -24,6 +24,7 @@ export interface WorkerGlobalState { current?: Test filepath?: string environmentTeardownRun?: boolean + onCancel: Promise moduleCache: ModuleCacheMap mockMap: MockMap durations: { diff --git a/packages/ws-client/src/index.ts b/packages/ws-client/src/index.ts index d016e12c8cee..62cb8c5cb47d 100644 --- a/packages/ws-client/src/index.ts +++ b/packages/ws-client/src/index.ts @@ -66,6 +66,9 @@ export function createClient(url: string, options: VitestClientOptions = {}) { onFinished(files) { handlers.onFinished?.(files) }, + onCancel() { + handlers.onCancel?.() + }, } const birpcHandlers: BirpcOptions = { diff --git a/test/watch/test/stdin.test.ts b/test/watch/test/stdin.test.ts index f39e8c41d234..fd4d13a2af00 100644 --- a/test/watch/test/stdin.test.ts +++ b/test/watch/test/stdin.test.ts @@ -1,7 +1,14 @@ -import { test } from 'vitest' +import { rmSync, writeFileSync } from 'node:fs' +import { afterEach, expect, test } from 'vitest' import { startWatchMode } from './utils' +const cleanups: (() => void)[] = [] + +afterEach(() => { + cleanups.splice(0).forEach(fn => fn()) +}) + test('quit watch mode', async () => { const vitest = await startWatchMode() @@ -35,3 +42,40 @@ test('filter by test name', async () => { await vitest.waitForOutput('Test name pattern: /sum/') await vitest.waitForOutput('1 passed') }) + +test('cancel test run', async () => { + const vitest = await startWatchMode() + + const testPath = 'fixtures/cancel.test.ts' + const testCase = `// Dynamic test case +import { afterAll, afterEach, test } from 'vitest' + +// These should be called even when test is cancelled +afterAll(() => console.log('[cancel-test]: afterAll')) +afterEach(() => console.log('[cancel-test]: afterEach')) + +test('1 - test that finishes', async () => { + console.log('[cancel-test]: test') + + await new Promise(resolve => setTimeout(resolve, 1000)) +}) + +test('2 - test that is cancelled', async () => { + console.log('[cancel-test]: should not run') +}) +` + + cleanups.push(() => rmSync(testPath)) + writeFileSync(testPath, testCase, 'utf8') + + // Test case is running, cancel it + await vitest.waitForOutput('[cancel-test]: test') + vitest.write('c') + + // Test hooks should still be called + await vitest.waitForOutput('CANCELLED') + await vitest.waitForOutput('[cancel-test]: afterAll') + await vitest.waitForOutput('[cancel-test]: afterEach') + + expect(vitest.output).not.include('[cancel-test]: should not run') +})