From 790839f0a26941b1950540322bee3897e51108c2 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 23 Apr 2025 17:11:05 +0200 Subject: [PATCH 01/17] feat: provide `ctx.signal` --- packages/browser/src/client/tester/runner.ts | 10 ++--- packages/browser/src/node/rpc.ts | 2 +- packages/browser/src/node/types.ts | 2 +- packages/runner/src/context.ts | 42 +++++++++++++++++-- packages/runner/src/hooks.ts | 23 ++++++++-- packages/runner/src/run.ts | 6 ++- packages/runner/src/suite.ts | 2 + packages/runner/src/types/runner.ts | 2 +- packages/runner/src/types/tasks.ts | 2 + .../src/node/workspace/resolveWorkspace.ts | 4 +- packages/vitest/src/runtime/runBaseTests.ts | 2 +- packages/vitest/src/runtime/runVmTests.ts | 2 +- packages/vitest/src/runtime/runners/index.ts | 2 +- packages/vitest/src/runtime/runners/test.ts | 2 +- 14 files changed, 82 insertions(+), 21 deletions(-) diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index bc42eeecf00b..a877aad7789f 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -62,8 +62,8 @@ export function createBrowserRunner( const currentFailures = 1 + previousFailures if (currentFailures >= this.config.bail) { - rpc().onCancel('test-failure') - this.onCancel('test-failure') + rpc().cancelCurrentRun('test-failure') + this.cancel('test-failure') } } } @@ -81,8 +81,8 @@ export function createBrowserRunner( } } - onCancel = (reason: CancelReason) => { - super.onCancel?.(reason) + cancel = (reason: CancelReason) => { + super.cancel?.(reason) globalChannel.postMessage({ type: 'cancel', reason }) } @@ -196,7 +196,7 @@ export async function initiateRunner( cachedRunner = runner onCancel.then((reason) => { - runner.onCancel?.(reason) + runner.cancel?.(reason) }) const [diffOptions] = await Promise.all([ diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index 01ae82d4e32c..bd829400bd74 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -195,7 +195,7 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke const mod = globalServer.vite.moduleGraph.getModuleById(id) return mod?.transformResult?.map }, - onCancel(reason) { + cancelCurrentRun(reason) { vitest.cancelCurrentRun(reason) }, async resolveId(id, importer) { diff --git a/packages/browser/src/node/types.ts b/packages/browser/src/node/types.ts index 96d373210e38..b4361ff2a53c 100644 --- a/packages/browser/src/node/types.ts +++ b/packages/browser/src/node/types.ts @@ -21,7 +21,7 @@ export interface WebSocketBrowserHandlers { onCollected: (method: TestExecutionMethod, files: RunnerTestFile[]) => Promise onTaskUpdate: (method: TestExecutionMethod, packs: TaskResultPack[], events: TaskEventPack[]) => void onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void - onCancel: (reason: CancelReason) => void + cancelCurrentRun: (reason: CancelReason) => void getCountOfFailedTests: () => number readSnapshotFile: (id: string) => Promise saveSnapshotFile: (id: string, content: string) => Promise diff --git a/packages/runner/src/context.ts b/packages/runner/src/context.ts index a15305a9f1e1..48712d1e3f9f 100644 --- a/packages/runner/src/context.ts +++ b/packages/runner/src/context.ts @@ -36,6 +36,7 @@ export function withTimeout any>( timeout: number, isHook = false, stackTraceError?: Error, + onTimeout?: (args: T extends (...args: infer A) => any ? A : never, error: Error) => void, ): T { if (timeout <= 0 || timeout === Number.POSITIVE_INFINITY) { return fn @@ -58,7 +59,9 @@ export function withTimeout any>( timer.unref?.() function rejectTimeoutError() { - reject_(makeTimeoutError(isHook, timeout, stackTraceError)) + const error = makeTimeoutError(isHook, timeout, stackTraceError) + onTimeout?.(args, error) + reject_(error) } function resolve(result: unknown) { @@ -102,6 +105,20 @@ export function withTimeout any>( }) as T } +const abortControllers = new WeakMap() + +export function getContextAbortController(context: TestContext): AbortController | undefined { + return abortControllers.get(context) +} + +export function abortIfTimeout([context]: [TestContext?], error: Error): void { + if (!context) { + return + } + const ac = getContextAbortController(context) + ac?.abort(error) +} + export function createTestContext( test: Test, runner: VitestRunner, @@ -110,6 +127,13 @@ export function createTestContext( throw new Error('done() callback is deprecated, use promise instead') } as unknown as TestContext + const ac = abortControllers.get(context) || (() => { + const ac = new AbortController() + abortControllers.set(context, ac) + return ac + })() + + context.signal = ac.signal context.task = test context.skip = (condition?: boolean | string, note?: string): never => { @@ -129,14 +153,26 @@ export function createTestContext( context.onTestFailed = (handler, timeout) => { test.onFailed ||= [] test.onFailed.push( - withTimeout(handler, timeout ?? runner.config.hookTimeout, true, new Error('STACK_TRACE_ERROR')), + withTimeout( + handler, + timeout ?? runner.config.hookTimeout, + true, + new Error('STACK_TRACE_ERROR'), + (_, error) => ac.abort(error), + ), ) } context.onTestFinished = (handler, timeout) => { test.onFinished ||= [] test.onFinished.push( - withTimeout(handler, timeout ?? runner.config.hookTimeout, true, new Error('STACK_TRACE_ERROR')), + withTimeout( + handler, + timeout ?? runner.config.hookTimeout, + true, + new Error('STACK_TRACE_ERROR'), + (_, error) => ac.abort(error), + ), ) } diff --git a/packages/runner/src/hooks.ts b/packages/runner/src/hooks.ts index 9740240906fc..c8a491d2b752 100644 --- a/packages/runner/src/hooks.ts +++ b/packages/runner/src/hooks.ts @@ -7,9 +7,10 @@ import type { OnTestFinishedHandler, TaskHook, TaskPopulated, + TestContext, } from './types/tasks' import { assertTypes } from '@vitest/utils' -import { withTimeout } from './context' +import { abortIfTimeout, getContextAbortController, withTimeout } from './context' import { withFixtures } from './fixture' import { getCurrentSuite, getRunner } from './suite' import { getCurrentTest } from './test-state' @@ -21,7 +22,8 @@ function getDefaultHookTimeout() { const CLEANUP_TIMEOUT_KEY = Symbol.for('VITEST_CLEANUP_TIMEOUT') const CLEANUP_STACK_TRACE_KEY = Symbol.for('VITEST_CLEANUP_STACK_TRACE') -export function getBeforeHookCleanupCallback(hook: Function, result: any): Function | undefined { +export function getBeforeHookCleanupCallback(hook: Function, result: any, context?: TestContext): Function | undefined { + // TODO: abortIfTimeout for afterEach if (typeof result === 'function') { const timeout = CLEANUP_TIMEOUT_KEY in hook && typeof hook[CLEANUP_TIMEOUT_KEY] === 'number' @@ -31,7 +33,18 @@ export function getBeforeHookCleanupCallback(hook: Function, result: any): Funct = CLEANUP_STACK_TRACE_KEY in hook && hook[CLEANUP_STACK_TRACE_KEY] instanceof Error ? hook[CLEANUP_STACK_TRACE_KEY] : undefined - return withTimeout(result, timeout, true, stackTraceError) + return withTimeout( + result, + timeout, + true, + stackTraceError, + (_, error) => { + if (context) { + const ac = getContextAbortController(context) + ac?.abort(error) + } + }, + ) } } @@ -136,6 +149,7 @@ export function beforeEach( timeout ?? getDefaultHookTimeout(), true, stackTraceError, + abortIfTimeout, ), { [CLEANUP_TIMEOUT_KEY]: timeout, @@ -174,6 +188,7 @@ export function afterEach( timeout ?? getDefaultHookTimeout(), true, new Error('STACK_TRACE_ERROR'), + abortIfTimeout, ), ) } @@ -206,6 +221,7 @@ export const onTestFailed: TaskHook = createTestHook( timeout ?? getDefaultHookTimeout(), true, new Error('STACK_TRACE_ERROR'), + abortIfTimeout, ), ) }, @@ -244,6 +260,7 @@ export const onTestFinished: TaskHook = createTestHook( timeout ?? getDefaultHookTimeout(), true, new Error('STACK_TRACE_ERROR'), + abortIfTimeout, ), ) }, diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index a7f50157e82a..8e17bf6e19ba 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -145,7 +145,11 @@ export async function callSuiteHook( } async function runHook(hook: Function) { - return getBeforeHookCleanupCallback(hook, await hook(...args)) + return getBeforeHookCleanupCallback( + hook, + await hook(...args), + name === 'beforeEach' ? args[0] : undefined, + ) } if (sequence === 'parallel') { diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index da8b8d913518..1c1463dd8d98 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -27,6 +27,7 @@ import { } from '@vitest/utils' import { parseSingleStack } from '@vitest/utils/source-map' import { + abortIfTimeout, collectorContext, collectTask, createTestContext, @@ -357,6 +358,7 @@ function createSuiteCollector( timeout, false, stackTraceError, + abortIfTimeout, ), ) } diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index 81bd8d73b094..13ca7de5e44e 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -77,7 +77,7 @@ export interface VitestRunner { * Runner should listen for this method and mark tests and suites as skipped in * "onBeforeRunSuite" and "onBeforeRunTask" when called. */ - onCancel?: (reason: CancelReason) => unknown + cancel?: (reason: CancelReason) => unknown /** * Called before running a single test. Doesn't have "result" yet. diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 47847d3943ca..26c0b9437a5c 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -649,6 +649,8 @@ export interface TestContext { */ task: Readonly> + signal: AbortSignal + /** * Extract hooks on test failed */ diff --git a/packages/vitest/src/node/workspace/resolveWorkspace.ts b/packages/vitest/src/node/workspace/resolveWorkspace.ts index 2d7ec50f22f9..87cb5bd8d2a3 100644 --- a/packages/vitest/src/node/workspace/resolveWorkspace.ts +++ b/packages/vitest/src/node/workspace/resolveWorkspace.ts @@ -150,7 +150,7 @@ export async function resolveWorkspace( fileProjects.map(p => ` - ${relative(vitest.config.root, p)}`).join('\n'), '\n\n', ].join('') - : [' '] + : ' ' throw new Error([ `Project name "${name}"`, project.vite.config.configFile ? ` from "${relative(vitest.config.root, project.vite.config.configFile)}"` : '', @@ -227,7 +227,7 @@ export async function resolveBrowserWorkspace( const ending = nth === 2 ? 'nd' : nth === 3 ? 'rd' : 'th' throw new Error(`The browser configuration must have a "browser" property. The ${nth}${ending} item in "browser.instances" doesn't have it. Make sure your${originalName ? ` "${originalName}"` : ''} configuration is correct.`) } - const name = config.name! + const name = config.name if (name == null) { throw new Error(`The browser configuration must have a "name" property. This is a bug in Vitest. Please, open a new issue with reproduction`) diff --git a/packages/vitest/src/runtime/runBaseTests.ts b/packages/vitest/src/runtime/runBaseTests.ts index d1027988d7ba..7f2a1acb63b3 100644 --- a/packages/vitest/src/runtime/runBaseTests.ts +++ b/packages/vitest/src/runtime/runBaseTests.ts @@ -40,7 +40,7 @@ export async function run( workerState.onCancel.then((reason) => { closeInspector(config) - runner.onCancel?.(reason) + runner.cancel?.(reason) }) workerState.durations.prepare = performance.now() - workerState.durations.prepare diff --git a/packages/vitest/src/runtime/runVmTests.ts b/packages/vitest/src/runtime/runVmTests.ts index 3fed9a6784e1..8ebaa2443363 100644 --- a/packages/vitest/src/runtime/runVmTests.ts +++ b/packages/vitest/src/runtime/runVmTests.ts @@ -79,7 +79,7 @@ export async function run( workerState.onCancel.then((reason) => { closeInspector(config) - runner.onCancel?.(reason) + runner.cancel?.(reason) }) workerState.durations.prepare diff --git a/packages/vitest/src/runtime/runners/index.ts b/packages/vitest/src/runtime/runners/index.ts index 8dcd019f963b..f7021e6a1886 100644 --- a/packages/vitest/src/runtime/runners/index.ts +++ b/packages/vitest/src/runtime/runners/index.ts @@ -113,7 +113,7 @@ export async function resolveTestRunner( if (currentFailures >= config.bail) { rpc().onCancel('test-failure') - testRunner.onCancel?.('test-failure') + testRunner.cancel?.('test-failure') } } await originalOnAfterRunTask?.call(testRunner, test) diff --git a/packages/vitest/src/runtime/runners/test.ts b/packages/vitest/src/runtime/runners/test.ts index d48adec04017..8a9877154f3b 100644 --- a/packages/vitest/src/runtime/runners/test.ts +++ b/packages/vitest/src/runtime/runners/test.ts @@ -76,7 +76,7 @@ export class VitestTestRunner implements VitestRunner { this.workerState.current = test.suite || test.file } - onCancel(_reason: CancelReason): void { + cancel(_reason: CancelReason): void { this.cancelRun = true } From 8c11cabdcb233247e61f08704fde75112fa7f68f Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 23 Apr 2025 17:19:12 +0200 Subject: [PATCH 02/17] chore: provide AbortController to the context --- test/core/vitest-environment-custom/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/core/vitest-environment-custom/index.ts b/test/core/vitest-environment-custom/index.ts index b061c7a8be2a..75bf988abb7a 100644 --- a/test/core/vitest-environment-custom/index.ts +++ b/test/core/vitest-environment-custom/index.ts @@ -14,6 +14,7 @@ export default { option: custom.option, setTimeout, clearTimeout, + AbortController, }) return { getVmContext() { From 77b82f65a97ac7c96bd22d2bfa7928cf9855fd1e Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 23 Apr 2025 17:53:37 +0200 Subject: [PATCH 03/17] chore: do terrible things --- packages/runner/src/errors.ts | 5 ++++ packages/runner/src/hooks.ts | 1 - packages/runner/src/run.ts | 40 ++++++++++++++++++++++--------- packages/runner/src/test-state.ts | 12 ++++++++++ 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/runner/src/errors.ts b/packages/runner/src/errors.ts index 2d3090a7fcc4..b42e46235d46 100644 --- a/packages/runner/src/errors.ts +++ b/packages/runner/src/errors.ts @@ -9,3 +9,8 @@ export class PendingError extends Error { this.taskId = task.id } } + +export class AbortError extends Error { + name = 'AbortError' + code = 20 +} diff --git a/packages/runner/src/hooks.ts b/packages/runner/src/hooks.ts index c8a491d2b752..581979d4b2b5 100644 --- a/packages/runner/src/hooks.ts +++ b/packages/runner/src/hooks.ts @@ -23,7 +23,6 @@ const CLEANUP_TIMEOUT_KEY = Symbol.for('VITEST_CLEANUP_TIMEOUT') const CLEANUP_STACK_TRACE_KEY = Symbol.for('VITEST_CLEANUP_STACK_TRACE') export function getBeforeHookCleanupCallback(hook: Function, result: any, context?: TestContext): Function | undefined { - // TODO: abortIfTimeout for afterEach if (typeof result === 'function') { const timeout = CLEANUP_TIMEOUT_KEY in hook && typeof hook[CLEANUP_TIMEOUT_KEY] === 'number' diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index 8e17bf6e19ba..f7975065c260 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -19,14 +19,15 @@ import type { import { shuffle } from '@vitest/utils' import { processError } from '@vitest/utils/error' import { collectTests } from './collect' -import { PendingError } from './errors' +import { AbortError, PendingError } from './errors' import { callFixtureCleanup } from './fixture' import { getBeforeHookCleanupCallback } from './hooks' import { getFn, getHooks } from './map' -import { setCurrentTest } from './test-state' +import { addRunningTest, getRunningTests, setCurrentTest } from './test-state' import { limitConcurrency } from './utils/limit-concurrency' import { partitionSuiteChildren } from './utils/suite' import { hasFailed, hasTests } from './utils/tasks' +import { getContextAbortController } from './context' const now = globalThis.performance ? globalThis.performance.now.bind(globalThis.performance) : Date.now const unixNow = Date.now @@ -278,6 +279,7 @@ export async function runTest(test: Test, runner: VitestRunner): Promise { } updateTask('test-prepare', test, runner) + const cleanupRunningTest = addRunningTest(test) setCurrentTest(test) const suite = test.suite || test.file @@ -378,6 +380,7 @@ export async function runTest(test: Test, runner: VitestRunner): Promise { } updateTask('test-finished', test, runner) setCurrentTest(undefined) + cleanupRunningTest() return } @@ -409,6 +412,7 @@ export async function runTest(test: Test, runner: VitestRunner): Promise { } } + cleanupRunningTest() setCurrentTest(undefined) test.result.duration = now() - start @@ -592,21 +596,35 @@ export async function runFiles(files: File[], runner: VitestRunner): Promise { - const paths = specs.map(f => typeof f === 'string' ? f : f.filepath) - await runner.onBeforeCollect?.(paths) + const cancel = runner.cancel + runner.cancel = (reason) => { + getRunningTests().forEach(test => { + const ac = getContextAbortController(test.context) + ac?.abort(new AbortError('The test run was aborted by the user.')) + }) + return cancel?.(reason) + } - const files = await collectTests(specs, runner) + try { + const paths = specs.map(f => typeof f === 'string' ? f : f.filepath) + await runner.onBeforeCollect?.(paths) - await runner.onCollected?.(files) - await runner.onBeforeRunFiles?.(files) + const files = await collectTests(specs, runner) - await runFiles(files, runner) + await runner.onCollected?.(files) + await runner.onBeforeRunFiles?.(files) - await runner.onAfterRunFiles?.(files) + await runFiles(files, runner) - await finishSendTasksUpdate(runner) + await runner.onAfterRunFiles?.(files) - return files + await finishSendTasksUpdate(runner) + + return files + } + finally { + runner.cancel = cancel + } } async function publicCollect(specs: string[] | FileSpecification[], runner: VitestRunner): Promise { diff --git a/packages/runner/src/test-state.ts b/packages/runner/src/test-state.ts index af1d5b04157f..010b4c54fb77 100644 --- a/packages/runner/src/test-state.ts +++ b/packages/runner/src/test-state.ts @@ -9,3 +9,15 @@ export function setCurrentTest(test: T | undefined): void { export function getCurrentTest(): T { return _test as T } + +const tests: Array = [] +export function addRunningTest(test: Test): () => void { + const index = tests.push(test) + return () => { + tests.splice(index) + } +} + +export function getRunningTests(): Array { + return tests +} From cb6a34a27ad58fabf1d2c0164b81698af1f63161 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 23 Apr 2025 17:58:47 +0200 Subject: [PATCH 04/17] chore: cleanup --- packages/runner/src/run.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index f7975065c260..bc744f83c24f 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -19,6 +19,7 @@ import type { import { shuffle } from '@vitest/utils' import { processError } from '@vitest/utils/error' import { collectTests } from './collect' +import { getContextAbortController } from './context' import { AbortError, PendingError } from './errors' import { callFixtureCleanup } from './fixture' import { getBeforeHookCleanupCallback } from './hooks' @@ -27,7 +28,6 @@ import { addRunningTest, getRunningTests, setCurrentTest } from './test-state' import { limitConcurrency } from './utils/limit-concurrency' import { partitionSuiteChildren } from './utils/suite' import { hasFailed, hasTests } from './utils/tasks' -import { getContextAbortController } from './context' const now = globalThis.performance ? globalThis.performance.now.bind(globalThis.performance) : Date.now const unixNow = Date.now @@ -596,9 +596,9 @@ export async function runFiles(files: File[], runner: VitestRunner): Promise { - const cancel = runner.cancel + const cancel = runner.cancel?.bind(runner) runner.cancel = (reason) => { - getRunningTests().forEach(test => { + getRunningTests().forEach((test) => { const ac = getContextAbortController(test.context) ac?.abort(new AbortError('The test run was aborted by the user.')) }) From 9b6e2e8c761ebb93552f27d8ff5ec0adf952dac5 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 23 Apr 2025 18:43:27 +0200 Subject: [PATCH 05/17] fix: correctly pass down test context --- packages/runner/src/suite.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index 1c1463dd8d98..6884b6bea8a7 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -358,7 +358,7 @@ function createSuiteCollector( timeout, false, stackTraceError, - abortIfTimeout, + (_, error) => abortIfTimeout([context], error), ), ) } From 2a82895ca52999b94dc248d09d3b7dfd45ca3bf4 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 23 Apr 2025 19:03:43 +0200 Subject: [PATCH 06/17] docs: add signal and make context readonly --- packages/runner/src/context.ts | 3 ++- packages/runner/src/run.ts | 11 +++++++---- packages/runner/src/types/tasks.ts | 18 +++++++++++++----- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/runner/src/context.ts b/packages/runner/src/context.ts index 48712d1e3f9f..10b79493f4cf 100644 --- a/packages/runner/src/context.ts +++ b/packages/runner/src/context.ts @@ -5,6 +5,7 @@ import type { SuiteCollector, Test, TestContext, + WriteableTestContext, } from './types/tasks' import { getSafeTimers } from '@vitest/utils' import { PendingError } from './errors' @@ -125,7 +126,7 @@ export function createTestContext( ): TestContext { const context = function () { throw new Error('done() callback is deprecated, use promise instead') - } as unknown as TestContext + } as unknown as WriteableTestContext const ac = abortControllers.get(context) || (() => { const ac = new AbortController() diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index bc744f83c24f..e3edb6fc471b 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -15,6 +15,7 @@ import type { TaskUpdateEvent, Test, TestContext, + WriteableTestContext, } from './types/tasks' import { shuffle } from '@vitest/utils' import { processError } from '@vitest/utils/error' @@ -88,12 +89,14 @@ async function callTestHooks( return } + const context = test.context as WriteableTestContext + const onTestFailed = test.context.onTestFailed const onTestFinished = test.context.onTestFinished - test.context.onTestFailed = () => { + context.onTestFailed = () => { throw new Error(`Cannot call "onTestFailed" inside a test hook.`) } - test.context.onTestFinished = () => { + context.onTestFinished = () => { throw new Error(`Cannot call "onTestFinished" inside a test hook.`) } @@ -116,8 +119,8 @@ async function callTestHooks( } } - test.context.onTestFailed = onTestFailed - test.context.onTestFinished = onTestFinished + context.onTestFailed = onTestFailed + context.onTestFinished = onTestFinished } export async function callSuiteHook( diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 26c0b9437a5c..50da3e0c78c9 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -647,25 +647,29 @@ export interface TestContext { /** * Metadata of the current test */ - task: Readonly> + readonly task: Readonly> - signal: AbortSignal + /** + * A signal object that will be aborted if the test times out or + * the user manually cancelled the test run with Ctrl+C. + */ + readonly signal: AbortSignal /** * Extract hooks on test failed */ - onTestFailed: (fn: OnTestFailedHandler, timeout?: number) => void + readonly onTestFailed: (fn: OnTestFailedHandler, timeout?: number) => void /** * Extract hooks on test failed */ - onTestFinished: (fn: OnTestFinishedHandler, timeout?: number) => void + readonly onTestFinished: (fn: OnTestFinishedHandler, timeout?: number) => void /** * Mark tests as skipped. All execution after this call will be skipped. * This function throws an error, so make sure you are not catching it accidentally. */ - skip: { + readonly skip: { (note?: string): never (condition: boolean, note?: string): void } @@ -689,3 +693,7 @@ export interface TaskHook { export type SequenceHooks = 'stack' | 'list' | 'parallel' export type SequenceSetupFiles = 'list' | 'parallel' + +export type WriteableTestContext = { + -readonly [P in keyof TestContext]: TestContext[P] +} From 87894a419550b2f7b76ccb463b605996fd8b0197 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 24 Apr 2025 14:52:02 +0200 Subject: [PATCH 07/17] chore: cleanup --- packages/runner/src/context.ts | 15 +++++++-------- packages/runner/src/hooks.ts | 5 ++--- packages/runner/src/run.ts | 10 +++++----- packages/runner/src/types/tasks.ts | 2 +- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/runner/src/context.ts b/packages/runner/src/context.ts index 10b79493f4cf..eda07edd6ae4 100644 --- a/packages/runner/src/context.ts +++ b/packages/runner/src/context.ts @@ -108,16 +108,15 @@ export function withTimeout any>( const abortControllers = new WeakMap() -export function getContextAbortController(context: TestContext): AbortController | undefined { - return abortControllers.get(context) -} - export function abortIfTimeout([context]: [TestContext?], error: Error): void { - if (!context) { - return + if (context) { + abortContextSignal(context, error) } - const ac = getContextAbortController(context) - ac?.abort(error) +} + +export function abortContextSignal(context: TestContext, error: Error): void { + const abortController = abortControllers.get(context) + abortController?.abort(error) } export function createTestContext( diff --git a/packages/runner/src/hooks.ts b/packages/runner/src/hooks.ts index 581979d4b2b5..55e3eb3fe2a3 100644 --- a/packages/runner/src/hooks.ts +++ b/packages/runner/src/hooks.ts @@ -10,7 +10,7 @@ import type { TestContext, } from './types/tasks' import { assertTypes } from '@vitest/utils' -import { abortIfTimeout, getContextAbortController, withTimeout } from './context' +import { abortContextSignal, abortIfTimeout, withTimeout } from './context' import { withFixtures } from './fixture' import { getCurrentSuite, getRunner } from './suite' import { getCurrentTest } from './test-state' @@ -39,8 +39,7 @@ export function getBeforeHookCleanupCallback(hook: Function, result: any, contex stackTraceError, (_, error) => { if (context) { - const ac = getContextAbortController(context) - ac?.abort(error) + abortContextSignal(context, error) } }, ) diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index e3edb6fc471b..6dbffd947eb0 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -20,7 +20,7 @@ import type { import { shuffle } from '@vitest/utils' import { processError } from '@vitest/utils/error' import { collectTests } from './collect' -import { getContextAbortController } from './context' +import { abortContextSignal } from './context' import { AbortError, PendingError } from './errors' import { callFixtureCleanup } from './fixture' import { getBeforeHookCleanupCallback } from './hooks' @@ -601,10 +601,10 @@ export async function runFiles(files: File[], runner: VitestRunner): Promise { const cancel = runner.cancel?.bind(runner) runner.cancel = (reason) => { - getRunningTests().forEach((test) => { - const ac = getContextAbortController(test.context) - ac?.abort(new AbortError('The test run was aborted by the user.')) - }) + const error = new AbortError('The test run was aborted by the user.') + getRunningTests().forEach(test => + abortContextSignal(test.context, error), + ) return cancel?.(reason) } diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 50da3e0c78c9..bf32032fc1d3 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -647,7 +647,7 @@ export interface TestContext { /** * Metadata of the current test */ - readonly task: Readonly> + readonly task: Readonly /** * A signal object that will be aborted if the test times out or From a17b5695fa5c06f3bc6d64889ada98a74d459c1b Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 24 Apr 2025 15:19:25 +0200 Subject: [PATCH 08/17] docs: add signal, update task --- docs/guide/test-context.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/guide/test-context.md b/docs/guide/test-context.md index de171f9bdab5..19162bc6e20d 100644 --- a/docs/guide/test-context.md +++ b/docs/guide/test-context.md @@ -14,9 +14,9 @@ The first argument for each test callback is a test context. ```ts import { it } from 'vitest' -it('should work', (ctx) => { +it('should work', ({ task }) => { // prints name of the test - console.log(ctx.task.name) + console.log(task.name) }) ``` @@ -65,6 +65,16 @@ it('math is hard', ({ skip }) => { }) ``` +#### `context.signal` 3.2.0 {#context-signal} + +A signal object that will be aborted if the test times out or the user manually cancelled the test run with Ctrl+C. + +```ts +it('stop request when test times out', async ({ signal }) => { + await fetch('/resource', { signal }) +}, 2000) +``` + ## Extend Test Context Vitest provides two different ways to help you extend the test context. From 551370955cf5b70d4d42e55c454cc1e2ac39a302 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 24 Apr 2025 15:19:55 +0200 Subject: [PATCH 09/17] chore: make expect a readonly --- packages/vitest/src/types/global.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/vitest/src/types/global.ts b/packages/vitest/src/types/global.ts index 1b039f61e0fa..47a38e0833cc 100644 --- a/packages/vitest/src/types/global.ts +++ b/packages/vitest/src/types/global.ts @@ -106,7 +106,12 @@ declare module '@vitest/expect' { declare module '@vitest/runner' { interface TestContext { - expect: ExpectStatic + /** + * `expect` instance bound to the current test. + * + * This API is useful for running snapshot tests concurrently because global expect cannot track them. + */ + readonly expect: ExpectStatic } interface TaskMeta { From c514e1a70ae2baaf7fa16a83d04aec7f6acf0866 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 24 Apr 2025 17:11:50 +0200 Subject: [PATCH 10/17] docs: expand signal info --- docs/guide/test-context.md | 7 ++++++- packages/runner/src/types/tasks.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/guide/test-context.md b/docs/guide/test-context.md index 19162bc6e20d..1dc79fa3f3dc 100644 --- a/docs/guide/test-context.md +++ b/docs/guide/test-context.md @@ -67,7 +67,12 @@ it('math is hard', ({ skip }) => { #### `context.signal` 3.2.0 {#context-signal} -A signal object that will be aborted if the test times out or the user manually cancelled the test run with Ctrl+C. +A signal object that can be aborted by Vitest. The signal is aborted in these situations: + +- Test times out +- User manually cancelled the test run with Ctrl+C +- [`vitest.cancelCurrentRun`](/advanced/api/vitest#cancelcurrentrun) was called programmatically +- Another test failed in parallel and the [`bail`](/config/#bail) flag is set ```ts it('stop request when test times out', async ({ signal }) => { diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index bf32032fc1d3..8bb0a27c6f1c 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -651,7 +651,7 @@ export interface TestContext { /** * A signal object that will be aborted if the test times out or - * the user manually cancelled the test run with Ctrl+C. + * the test run was cancelled. */ readonly signal: AbortSignal From d0b3fe6aa110aa05ea3e3ac3649a624ccd9f4f49 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 24 Apr 2025 17:11:55 +0200 Subject: [PATCH 11/17] test: add signal tests --- test/cli/test/signal.test.ts | 159 +++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 test/cli/test/signal.test.ts diff --git a/test/cli/test/signal.test.ts b/test/cli/test/signal.test.ts new file mode 100644 index 000000000000..e7634156801e --- /dev/null +++ b/test/cli/test/signal.test.ts @@ -0,0 +1,159 @@ +import type { UserConsoleLog } from 'vitest' +import type { Reporter, Vitest } from 'vitest/node' +import { expect, test } from 'vitest' +import { runInlineTests } from '../../test-utils' + +test('timeout aborts the signal without fixtures', async () => { + const { stderr, results } = await runInlineTests({ + 'basic.test.ts': /* ts */` + import { test } from 'vitest' + import { setTimeout } from 'node:timers/promises' + test('timeouts', async ({ signal, task, example }) => { + signal.addEventListener('abort', () => { + task.meta.aborted = true + }) + await setTimeout(100) + }, 10) + `, + }) + expect(stderr).toContain('Test timed out in 10ms.') + expect(results).toHaveLength(1) + expect(results[0].children.at(0)?.meta()).toEqual({ + aborted: true, + }) +}) + +test('timeout aborts the signal', async () => { + const { stderr, results } = await runInlineTests({ + 'basic.test.ts': /* ts */` + import { test } from 'vitest' + import { setTimeout } from 'node:timers/promises' + test.extend({ + // .extend to force fixture initialisation + example: true, + })('timeouts', async ({ signal, task, example }) => { + signal.addEventListener('abort', () => { + task.meta.aborted = true + }) + await setTimeout(100) + }, 10) + `, + }) + expect(stderr).toContain('Test timed out in 10ms.') + expect(results).toHaveLength(1) + expect(results[0].children.at(0)?.meta()).toEqual({ + aborted: true, + }) +}) + +test('timeout aborts all signals in concurrent tests', async () => { + const { stderr, results } = await runInlineTests({ + 'basic.test.ts': /* ts */` + import { test } from 'vitest' + import { setTimeout } from 'node:timers/promises' + test + // .extend to force fixture initialisation + .extend({ example: true }) + .concurrent + .for([1, 1, 1]) + ('timeouts', async (_, { signal, task, example }) => { + signal.addEventListener('abort', () => { + task.meta.aborted = true + }) + await setTimeout(100) + }, 10) + `, + }) + expect(stderr).toContain('Test timed out in 10ms.') + expect(results).toHaveLength(1) + expect(results[0].children.at(0)?.meta()).toEqual({ + aborted: true, + }) + expect(results[0].children.at(1)?.meta()).toEqual({ + aborted: true, + }) + expect(results[0].children.at(2)?.meta()).toEqual({ + aborted: true, + }) +}) + +class AbortReporter implements Reporter { + vitest!: Vitest + onInit(vitest: Vitest) { + this.vitest = vitest + } + + idx = 0 + + onUserConsoleLog(log: UserConsoleLog) { + this.idx++ + if (log.content.includes('ready')) { + this.vitest.cancelCurrentRun('keyboard-input') + } + } +} + +test('cancelling test run aborts the signal', async () => { + const { results, stderr } = await runInlineTests({ + 'basic.test.ts': /* ts */ ` + import { test } from 'vitest' + test('aborted', async ({ signal, task }) => { + return new Promise(resolve => { + console.log('ready') + signal.addEventListener('abort', () => { + task.meta.aborted = true + resolve() + }) + }) + }, Infinity) + `, + }, { + reporters: [ + 'default', + new AbortReporter(), + ], + }) + expect(stderr).toBe('') + expect(results).toHaveLength(1) + expect(results[0].children.at(0)?.meta()).toEqual({ + aborted: true, + }) +}) + +test('cancelling test run aborts the signal in all concurrent tests', async () => { + const { results, stderr } = await runInlineTests({ + 'basic.test.ts': /* ts */` + import { test } from 'vitest' + test.concurrent.for([1, 2, 3])( + 'aborted', + { timeout: Infinity }, + async (number, { signal, task }) => { + return new Promise(resolve => { + if (number === 3) { + console.log('ready') + } + signal.addEventListener('abort', () => { + task.meta.aborted = true + resolve() + }) + }) + }) + `, + }, { + reporters: [ + 'default', + new AbortReporter(), + ], + }) + expect(stderr).toBe('') + expect(results).toHaveLength(1) + expect(results[0].children.at(0)?.meta()).toEqual({ + aborted: true, + }) + expect(results[0].children.at(1)?.meta()).toEqual({ + aborted: true, + }) + expect(results[0].children.at(2)?.meta()).toEqual({ + aborted: true, + }) +}) From a13f3f9ffc4a24fe53d1c8296919bc728f40922d Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 24 Apr 2025 17:17:18 +0200 Subject: [PATCH 12/17] refactor: naming --- packages/runner/src/context.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/runner/src/context.ts b/packages/runner/src/context.ts index eda07edd6ae4..2a34455def17 100644 --- a/packages/runner/src/context.ts +++ b/packages/runner/src/context.ts @@ -127,13 +127,13 @@ export function createTestContext( throw new Error('done() callback is deprecated, use promise instead') } as unknown as WriteableTestContext - const ac = abortControllers.get(context) || (() => { - const ac = new AbortController() - abortControllers.set(context, ac) - return ac + const abortController = abortControllers.get(context) || (() => { + const abortController = new AbortController() + abortControllers.set(context, abortController) + return abortController })() - context.signal = ac.signal + context.signal = abortController.signal context.task = test context.skip = (condition?: boolean | string, note?: string): never => { @@ -158,7 +158,7 @@ export function createTestContext( timeout ?? runner.config.hookTimeout, true, new Error('STACK_TRACE_ERROR'), - (_, error) => ac.abort(error), + (_, error) => abortController.abort(error), ), ) } @@ -171,7 +171,7 @@ export function createTestContext( timeout ?? runner.config.hookTimeout, true, new Error('STACK_TRACE_ERROR'), - (_, error) => ac.abort(error), + (_, error) => abortController.abort(error), ), ) } From 3f7e1082bb2d2a323d89ce561e82b1f988e0c4bf Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 29 Apr 2025 16:42:03 +0200 Subject: [PATCH 13/17] chore: comments --- docs/guide/test-context.md | 2 +- packages/runner/src/context.ts | 9 +++++---- packages/runner/src/errors.ts | 2 ++ packages/runner/src/run.ts | 3 +++ 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/guide/test-context.md b/docs/guide/test-context.md index 1dc79fa3f3dc..0355b0ae0693 100644 --- a/docs/guide/test-context.md +++ b/docs/guide/test-context.md @@ -67,7 +67,7 @@ it('math is hard', ({ skip }) => { #### `context.signal` 3.2.0 {#context-signal} -A signal object that can be aborted by Vitest. The signal is aborted in these situations: +An [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that can be aborted by Vitest. The signal is aborted in these situations: - Test times out - User manually cancelled the test run with Ctrl+C diff --git a/packages/runner/src/context.ts b/packages/runner/src/context.ts index 2a34455def17..781050945ff1 100644 --- a/packages/runner/src/context.ts +++ b/packages/runner/src/context.ts @@ -127,11 +127,12 @@ export function createTestContext( throw new Error('done() callback is deprecated, use promise instead') } as unknown as WriteableTestContext - const abortController = abortControllers.get(context) || (() => { - const abortController = new AbortController() + let abortController = abortControllers.get(context) + + if (!abortController) { + abortController = new AbortController() abortControllers.set(context, abortController) - return abortController - })() + } context.signal = abortController.signal context.task = test diff --git a/packages/runner/src/errors.ts b/packages/runner/src/errors.ts index b42e46235d46..426296aeb630 100644 --- a/packages/runner/src/errors.ts +++ b/packages/runner/src/errors.ts @@ -12,5 +12,7 @@ export class PendingError extends Error { export class AbortError extends Error { name = 'AbortError' + // 20 is the legacy error code for AbortError + // https://developer.mozilla.org/en-US/docs/Web/API/DOMException#error_names code = 20 } diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index 6dbffd947eb0..892ea1955c98 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -600,7 +600,10 @@ export async function runFiles(files: File[], runner: VitestRunner): Promise { const cancel = runner.cancel?.bind(runner) + // Ideally, we need to have an event listener for this, but only have a runner here. + // Adding another onCancel felt wrong (maybe it needs to be refactored) runner.cancel = (reason) => { + // We intentionally create only one error since there is only one test run that can be cancelled const error = new AbortError('The test run was aborted by the user.') getRunningTests().forEach(test => abortContextSignal(test.context, error), From f04fafa537e8147516ff33fd567f8ac127e2b0b1 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 29 Apr 2025 16:42:15 +0200 Subject: [PATCH 14/17] fix: correctly remove the concurrent test --- packages/runner/src/test-state.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runner/src/test-state.ts b/packages/runner/src/test-state.ts index 010b4c54fb77..d4e76aeb8eaa 100644 --- a/packages/runner/src/test-state.ts +++ b/packages/runner/src/test-state.ts @@ -12,9 +12,9 @@ export function getCurrentTest(): T { const tests: Array = [] export function addRunningTest(test: Test): () => void { - const index = tests.push(test) + tests.push(test) return () => { - tests.splice(index) + tests.splice(tests.indexOf(test)) } } From 66aa36c0d86e46b5ea2b0d00e0b755758fbce3db Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 29 Apr 2025 16:45:05 +0200 Subject: [PATCH 15/17] chore: add reason, change AbortError to TestRunAbortError --- packages/runner/src/errors.ts | 13 ++++++++----- packages/runner/src/run.ts | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/runner/src/errors.ts b/packages/runner/src/errors.ts index 426296aeb630..53d2f551c2f5 100644 --- a/packages/runner/src/errors.ts +++ b/packages/runner/src/errors.ts @@ -1,3 +1,4 @@ +import type { CancelReason } from './types/runner' import type { TaskBase } from './types/tasks' export class PendingError extends Error { @@ -10,9 +11,11 @@ export class PendingError extends Error { } } -export class AbortError extends Error { - name = 'AbortError' - // 20 is the legacy error code for AbortError - // https://developer.mozilla.org/en-US/docs/Web/API/DOMException#error_names - code = 20 +export class TestRunAbortError extends Error { + public name = 'TestRunAbortError' + public reason: CancelReason + constructor(message: string, reason: CancelReason) { + super(message) + this.reason = reason + } } diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index 892ea1955c98..f8513b22bd3b 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -21,7 +21,7 @@ import { shuffle } from '@vitest/utils' import { processError } from '@vitest/utils/error' import { collectTests } from './collect' import { abortContextSignal } from './context' -import { AbortError, PendingError } from './errors' +import { PendingError, TestRunAbortError } from './errors' import { callFixtureCleanup } from './fixture' import { getBeforeHookCleanupCallback } from './hooks' import { getFn, getHooks } from './map' @@ -604,7 +604,7 @@ export async function startTests(specs: string[] | FileSpecification[], runner: // Adding another onCancel felt wrong (maybe it needs to be refactored) runner.cancel = (reason) => { // We intentionally create only one error since there is only one test run that can be cancelled - const error = new AbortError('The test run was aborted by the user.') + const error = new TestRunAbortError('The test run was aborted by the user.', reason) getRunningTests().forEach(test => abortContextSignal(test.context, error), ) From 01f4a2af96c23d804d073b4162bcc2165bb54d80 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 29 Apr 2025 16:46:27 +0200 Subject: [PATCH 16/17] docs: add abortsignal link in the context docs --- packages/runner/src/types/tasks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 8bb0a27c6f1c..99d4ae5ad4ce 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -650,7 +650,7 @@ export interface TestContext { readonly task: Readonly /** - * A signal object that will be aborted if the test times out or + * An [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that will be aborted if the test times out or * the test run was cancelled. */ readonly signal: AbortSignal From ace7edd5880d3160e4ba479dc8de76e687545e10 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 29 Apr 2025 16:48:20 +0200 Subject: [PATCH 17/17] chore: remove unneded idx --- test/cli/test/signal.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/cli/test/signal.test.ts b/test/cli/test/signal.test.ts index e7634156801e..58a2e3e63541 100644 --- a/test/cli/test/signal.test.ts +++ b/test/cli/test/signal.test.ts @@ -83,10 +83,7 @@ class AbortReporter implements Reporter { this.vitest = vitest } - idx = 0 - onUserConsoleLog(log: UserConsoleLog) { - this.idx++ if (log.content.includes('ready')) { this.vitest.cancelCurrentRun('keyboard-input') }