Skip to content

Commit 8d4606e

Browse files
authoredApr 26, 2023
feat(watch): test run cancelling, feat: --bail option for cancelling test run (#3163)
1 parent 97b1b4a commit 8d4606e

35 files changed

+433
-33
lines changed
 

‎docs/advanced/runner.md

+8-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ export interface VitestRunner {
1717
*/
1818
onCollected?(files: File[]): unknown
1919

20+
/**
21+
* Called when test runner should cancel next test runs.
22+
* Runner should listen for this method and mark tests and suites as skipped in
23+
* "onBeforeRunSuite" and "onBeforeRunTest" when called.
24+
*/
25+
onCancel?(reason: CancelReason): unknown
26+
2027
/**
2128
* Called before running a single test. Doesn't have "result" yet.
2229
*/
@@ -86,7 +93,7 @@ export interface VitestRunner {
8693
When initiating this class, Vitest passes down Vitest config, - you should expose it as a `config` property.
8794

8895
::: warning
89-
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`).
96+
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`).
9097

9198
`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.
9299
:::

‎docs/config/index.md

+10
Original file line numberDiff line numberDiff line change
@@ -1375,3 +1375,13 @@ Influences whether or not the `showDiff` flag should be included in the thrown A
13751375
Sets length threshold for actual and expected values in assertion errors. If this threshold is exceeded, for example for large data structures, the value is replaced with something like `[ Array(3) ]` or `{ Object (prop1, prop2) }`. Set it to `0` if you want to disable truncating altogether.
13761376

13771377
This config option affects truncating values in `test.each` titles and inside the assertion error message.
1378+
1379+
### bail
1380+
1381+
- **Type:** `number`
1382+
- **Default:** `0`
1383+
- **CLI**: `--bail=<value>`
1384+
1385+
Stop test execution when given number of tests have failed.
1386+
1387+
By default Vitest will run all of your test cases even if some of them fail. This may not be desired for CI builds where you are only interested in 100% successful builds and would like to stop test execution as early as possible when test failures occur. The `bail` option can be used to speed up CI runs by preventing it from running more tests when failures have occured.

‎docs/guide/cli.md

+1
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Run only [benchmark](https://vitest.dev/guide/features.html#benchmarking-experim
9292
| `--no-color` | Removes colors from the console output |
9393
| `--inspect` | Enables Node.js inspector |
9494
| `--inspect-brk` | Enables Node.js inspector with break |
95+
| `--bail <number>` | Stop test execution when given number of tests have failed |
9596
| `-h, --help` | Display available CLI options |
9697

9798
::: tip

‎package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"test:run": "vitest run -r test/core",
2424
"test:all": "CI=true pnpm -r --stream run test --allowOnly",
2525
"test:ci": "CI=true pnpm -r --stream --filter !test-fails --filter !test-browser --filter !test-esm --filter !test-browser run test --allowOnly",
26-
"test:ci:single-thread": "CI=true pnpm -r --stream --filter !test-fails --filter !test-coverage --filter !test-watch --filter !test-esm --filter !test-browser run test --allowOnly --no-threads",
26+
"test:ci:single-thread": "CI=true pnpm -r --stream --filter !test-fails --filter !test-coverage --filter !test-watch --filter !test-bail --filter !test-esm --filter !test-browser run test --allowOnly --no-threads",
2727
"typecheck": "tsc --noEmit",
2828
"typecheck:why": "tsc --noEmit --explainFiles > explainTypes.txt",
2929
"ui:build": "vite build packages/ui",

‎packages/browser/src/client/main.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createClient } from '@vitest/ws-client'
22
// eslint-disable-next-line no-restricted-imports
33
import type { ResolvedConfig } from 'vitest'
4-
import type { VitestRunner } from '@vitest/runner'
4+
import type { CancelReason, VitestRunner } from '@vitest/runner'
55
import { createBrowserRunner } from './runner'
66
import { importId } from './utils'
77
import { setupConsoleLogSpy } from './logger'
@@ -30,7 +30,16 @@ function getQueryPaths() {
3030
return url.searchParams.getAll('path')
3131
}
3232

33-
export const client = createClient(ENTRY_URL)
33+
let setCancel = (_: CancelReason) => {}
34+
const onCancel = new Promise<CancelReason>((resolve) => {
35+
setCancel = resolve
36+
})
37+
38+
export const client = createClient(ENTRY_URL, {
39+
handlers: {
40+
onCancel: setCancel,
41+
},
42+
})
3443

3544
const ws = client.ws
3645

@@ -103,6 +112,10 @@ async function runTests(paths: string[], config: ResolvedConfig) {
103112
runner = new BrowserRunner({ config, browserHashMap })
104113
}
105114

115+
onCancel.then((reason) => {
116+
runner?.onCancel?.(reason)
117+
})
118+
106119
if (!config.snapshotOptions.snapshotEnvironment)
107120
config.snapshotOptions.snapshotEnvironment = new BrowserSnapshotEnvironment()
108121

‎packages/browser/src/client/runner.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,20 @@ export function createBrowserRunner(original: any, coverageModule: CoverageHandl
2323
}
2424

2525
async onAfterRunTest(task: Test) {
26-
await super.onAfterRunTest?.()
26+
await super.onAfterRunTest?.(task)
2727
task.result?.errors?.forEach((error) => {
2828
console.error(error.message)
2929
})
30+
31+
if (this.config.bail && task.result?.state === 'fail') {
32+
const previousFailures = await rpc().getCountOfFailedTests()
33+
const currentFailures = 1 + previousFailures
34+
35+
if (currentFailures >= this.config.bail) {
36+
rpc().onCancel('test-failure')
37+
this.onCancel?.('test-failure')
38+
}
39+
}
3040
}
3141

3242
async onAfterRunSuite() {

‎packages/runner/src/types/runner.ts

+9
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export interface VitestRunnerConstructor {
2727
new(config: VitestRunnerConfig): VitestRunner
2828
}
2929

30+
export type CancelReason = 'keyboard-input' | 'test-failure' | string & {}
31+
3032
export interface VitestRunner {
3133
/**
3234
* First thing that's getting called before actually collecting and running tests.
@@ -37,6 +39,13 @@ export interface VitestRunner {
3739
*/
3840
onCollected?(files: File[]): unknown
3941

42+
/**
43+
* Called when test runner should cancel next test runs.
44+
* Runner should listen for this method and mark tests and suites as skipped in
45+
* "onBeforeRunSuite" and "onBeforeRunTest" when called.
46+
*/
47+
onCancel?(reason: CancelReason): unknown
48+
4049
/**
4150
* Called before running a single test. Doesn't have "result" yet.
4251
*/

‎packages/vitest/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@
155155
"std-env": "^3.3.2",
156156
"strip-literal": "^1.0.1",
157157
"tinybench": "^2.4.0",
158-
"tinypool": "^0.4.0",
158+
"tinypool": "^0.5.0",
159159
"vite": "^3.0.0 || ^4.0.0",
160160
"vite-node": "workspace:*",
161161
"why-is-node-running": "^2.2.2"

‎packages/vitest/src/api/setup.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,24 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, server?: Vit
111111
return ctx.updateSnapshot()
112112
return ctx.updateSnapshot([file.filepath])
113113
},
114+
onCancel(reason) {
115+
ctx.cancelCurrentRun(reason)
116+
},
117+
getCountOfFailedTests() {
118+
return ctx.state.getCountOfFailedTests()
119+
},
114120
},
115121
{
116122
post: msg => ws.send(msg),
117123
on: fn => ws.on('message', fn),
118-
eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected'],
124+
eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onCancel'],
119125
serialize: stringify,
120126
deserialize: parse,
121127
},
122128
)
123129

130+
ctx.onCancel(reason => rpc.onCancel(reason))
131+
124132
clients.set(ws, rpc)
125133

126134
ws.on('close', () => {

‎packages/vitest/src/api/types.ts

+4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { TransformResult } from 'vite'
2+
import type { CancelReason } from '@vitest/runner'
23
import type { AfterSuiteRunMeta, File, ModuleGraphData, Reporter, ResolvedConfig, SnapshotResult, TaskResultPack, UserConsoleLog } from '../types'
34

45
export interface TransformResultWithSource extends TransformResult {
@@ -10,6 +11,8 @@ export interface WebSocketHandlers {
1011
onTaskUpdate(packs: TaskResultPack[]): void
1112
onAfterSuiteRun(meta: AfterSuiteRunMeta): void
1213
onDone(name: string): void
14+
onCancel(reason: CancelReason): void
15+
getCountOfFailedTests(): number
1316
sendLog(log: UserConsoleLog): void
1417
getFiles(): File[]
1518
getPaths(): string[]
@@ -28,4 +31,5 @@ export interface WebSocketHandlers {
2831
}
2932

3033
export interface WebSocketEvents extends Pick<Reporter, 'onCollected' | 'onFinished' | 'onTaskUpdate' | 'onUserConsoleLog' | 'onPathsCollected'> {
34+
onCancel(reason: CancelReason): void
3135
}

‎packages/vitest/src/node/cli.ts

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ cli
4545
.option('--inspect', 'Enable Node.js inspector')
4646
.option('--inspect-brk', 'Enable Node.js inspector with break')
4747
.option('--test-timeout <time>', 'Default timeout of a test in milliseconds (default: 5000)')
48+
.option('--bail <number>', 'Stop test execution when given number of tests have failed', { default: 0 })
4849
.help()
4950

5051
cli

‎packages/vitest/src/node/core.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import c from 'picocolors'
88
import { normalizeRequestId } from 'vite-node/utils'
99
import { ViteNodeRunner } from 'vite-node/client'
1010
import { SnapshotManager } from '@vitest/snapshot/manager'
11+
import type { CancelReason } from '@vitest/runner'
1112
import type { ArgumentsType, CoverageProvider, OnServerRestartHandler, Reporter, ResolvedConfig, UserConfig, UserWorkspaceConfig, VitestRunMode } from '../types'
1213
import { hasFailed, noop, slash, toArray } from '../utils'
1314
import { getCoverageProvider } from '../integrations/coverage'
@@ -46,6 +47,7 @@ export class Vitest {
4647
filenamePattern?: string
4748
runningPromise?: Promise<void>
4849
closingPromise?: Promise<void>
50+
isCancelling = false
4951

5052
isFirstRun = true
5153
restartsCount = 0
@@ -64,6 +66,7 @@ export class Vitest {
6466

6567
private _onRestartListeners: OnServerRestartHandler[] = []
6668
private _onSetServer: OnServerRestartHandler[] = []
69+
private _onCancelListeners: ((reason: CancelReason) => Promise<void> | void)[] = []
6770

6871
async setServer(options: UserConfig, server: ViteDevServer, cliOptions: UserConfig) {
6972
this.unregisterWatcher?.()
@@ -395,13 +398,14 @@ export class Vitest {
395398

396399
async runFiles(paths: WorkspaceSpec[]) {
397400
const filepaths = paths.map(([, file]) => file)
398-
399401
this.state.collectPaths(filepaths)
400402

401403
await this.report('onPathsCollected', filepaths)
402404

403405
// previous run
404406
await this.runningPromise
407+
this._onCancelListeners = []
408+
this.isCancelling = false
405409

406410
// schedule the new run
407411
this.runningPromise = (async () => {
@@ -437,6 +441,11 @@ export class Vitest {
437441
return await this.runningPromise
438442
}
439443

444+
async cancelCurrentRun(reason: CancelReason) {
445+
this.isCancelling = true
446+
await Promise.all(this._onCancelListeners.splice(0).map(listener => listener(reason)))
447+
}
448+
440449
async rerunFiles(files: string[] = this.state.getFilepaths(), trigger?: string) {
441450
if (this.filenamePattern) {
442451
const filteredFiles = await this.globTestFiles([this.filenamePattern])
@@ -760,4 +769,8 @@ export class Vitest {
760769
onAfterSetServer(fn: OnServerRestartHandler) {
761770
this._onSetServer.push(fn)
762771
}
772+
773+
onCancel(fn: (reason: CancelReason) => void) {
774+
this._onCancelListeners.push(fn)
775+
}
763776
}

‎packages/vitest/src/node/pools/browser.ts

+12
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ export function createBrowserPool(ctx: Vitest): ProcessPool {
1515
}
1616

1717
const runTests = async (project: WorkspaceProject, files: string[]) => {
18+
ctx.state.clearFiles(project, files)
19+
20+
let isCancelled = false
21+
project.ctx.onCancel(() => {
22+
isCancelled = true
23+
})
24+
1825
const provider = project.browserProvider!
1926
providers.add(provider)
2027

@@ -24,6 +31,11 @@ export function createBrowserPool(ctx: Vitest): ProcessPool {
2431
const isolate = project.config.isolate
2532
if (isolate) {
2633
for (const path of paths) {
34+
if (isCancelled) {
35+
ctx.state.cancelFiles(files.slice(paths.indexOf(path)), ctx.config.root)
36+
break
37+
}
38+
2739
const url = new URL('/', origin)
2840
url.searchParams.append('path', path)
2941
url.searchParams.set('id', path)

‎packages/vitest/src/node/pools/child.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { fork } from 'node:child_process'
44
import { fileURLToPath, pathToFileURL } from 'node:url'
55
import { createBirpc } from 'birpc'
66
import { resolve } from 'pathe'
7-
import type { ContextTestEnvironment, ResolvedConfig, RuntimeRPC, Vitest } from '../../types'
7+
import type { ContextTestEnvironment, ResolvedConfig, RunnerRPC, RuntimeRPC, Vitest } from '../../types'
88
import type { ChildContext } from '../../types/child'
99
import type { PoolProcessOptions, ProcessPool, WorkspaceSpec } from '../pool'
1010
import { distDir } from '../../paths'
@@ -16,9 +16,10 @@ import { createMethodsRPC } from './rpc'
1616
const childPath = fileURLToPath(pathToFileURL(resolve(distDir, './child.js')).href)
1717

1818
function setupChildProcessChannel(project: WorkspaceProject, fork: ChildProcess): void {
19-
createBirpc<{}, RuntimeRPC>(
19+
const rpc = createBirpc<RunnerRPC, RuntimeRPC>(
2020
createMethodsRPC(project),
2121
{
22+
eventNames: ['onCancel'],
2223
serialize: v8.serialize,
2324
deserialize: v => v8.deserialize(Buffer.from(v)),
2425
post(v) {
@@ -29,6 +30,8 @@ function setupChildProcessChannel(project: WorkspaceProject, fork: ChildProcess)
2930
},
3031
},
3132
)
33+
34+
project.ctx.onCancel(reason => rpc.onCancel(reason))
3235
}
3336

3437
function stringifyRegex(input: RegExp | string): string {

‎packages/vitest/src/node/pools/rpc.ts

+6
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,11 @@ export function createMethodsRPC(project: WorkspaceProject): RuntimeRPC {
5858
onFinished(files) {
5959
project.report('onFinished', files, ctx.state.getUnhandledErrors())
6060
},
61+
onCancel(reason) {
62+
ctx.cancelCurrentRun(reason)
63+
},
64+
getCountOfFailedTests() {
65+
return ctx.state.getCountOfFailedTests()
66+
},
6167
}
6268
}

‎packages/vitest/src/node/pools/threads.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { resolve } from 'pathe'
66
import type { Options as TinypoolOptions } from 'tinypool'
77
import Tinypool from 'tinypool'
88
import { distDir } from '../../paths'
9-
import type { ContextTestEnvironment, ResolvedConfig, RuntimeRPC, Vitest, WorkerContext } from '../../types'
9+
import type { ContextTestEnvironment, ResolvedConfig, RunnerRPC, RuntimeRPC, Vitest, WorkerContext } from '../../types'
1010
import type { PoolProcessOptions, ProcessPool, RunWithFiles } from '../pool'
1111
import { envsOrder, groupFilesByEnv } from '../../utils/test-helpers'
1212
import { AggregateError, groupBy } from '../../utils/base'
@@ -20,9 +20,10 @@ function createWorkerChannel(project: WorkspaceProject) {
2020
const port = channel.port2
2121
const workerPort = channel.port1
2222

23-
createBirpc<{}, RuntimeRPC>(
23+
const rpc = createBirpc<RunnerRPC, RuntimeRPC>(
2424
createMethodsRPC(project),
2525
{
26+
eventNames: ['onCancel'],
2627
post(v) {
2728
port.postMessage(v)
2829
},
@@ -32,6 +33,8 @@ function createWorkerChannel(project: WorkspaceProject) {
3233
},
3334
)
3435

36+
project.ctx.onCancel(reason => rpc.onCancel(reason))
37+
3538
return { workerPort, port }
3639
}
3740

@@ -93,6 +96,11 @@ export function createThreadsPool(ctx: Vitest, { execArgv, env }: PoolProcessOpt
9396
// Worker got stuck and won't terminate - this may cause process to hang
9497
if (error instanceof Error && /Failed to terminate worker/.test(error.message))
9598
ctx.state.addProcessTimeoutCause(`Failed to terminate worker while running ${files.join(', ')}.`)
99+
100+
// Intentionally cancelled
101+
else if (ctx.isCancelling && error instanceof Error && /The task has been cancelled/.test(error.message))
102+
ctx.state.cancelFiles(files, ctx.config.root)
103+
96104
else
97105
throw error
98106
}
@@ -106,6 +114,9 @@ export function createThreadsPool(ctx: Vitest, { execArgv, env }: PoolProcessOpt
106114
const sequencer = new Sequencer(ctx)
107115

108116
return async (specs, invalidates) => {
117+
// Cancel pending tasks from pool when possible
118+
ctx.onCancel(() => pool.cancelPendingTasks())
119+
109120
const configs = new Map<WorkspaceProject, ResolvedConfig>()
110121
const getConfig = (project: WorkspaceProject): ResolvedConfig => {
111122
if (configs.has(project))

‎packages/vitest/src/node/reporters/base.ts

+6
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const HELP_QUITE = `${c.dim('press ')}${c.bold('q')}${c.dim(' to quit')}`
1313

1414
const WAIT_FOR_CHANGE_PASS = `\n${c.bold(c.inverse(c.green(' PASS ')))}${c.green(' Waiting for file changes...')}`
1515
const WAIT_FOR_CHANGE_FAIL = `\n${c.bold(c.inverse(c.red(' FAIL ')))}${c.red(' Tests failed. Watching for file changes...')}`
16+
const WAIT_FOR_CHANGE_CANCELLED = `\n${c.bold(c.inverse(c.red(' CANCELLED ')))}${c.red(' Test run cancelled. Watching for file changes...')}`
1617

1718
const LAST_RUN_LOG_TIMEOUT = 1_500
1819

@@ -49,6 +50,7 @@ export abstract class BaseReporter implements Reporter {
4950

5051
async onFinished(files = this.ctx.state.getFiles(), errors = this.ctx.state.getUnhandledErrors()) {
5152
this.end = performance.now()
53+
5254
await this.reportSummary(files)
5355
if (errors.length) {
5456
if (!this.ctx.config.dangerouslyIgnoreUnhandledErrors)
@@ -102,8 +104,12 @@ export abstract class BaseReporter implements Reporter {
102104

103105
const failed = errors.length > 0 || hasFailed(files)
104106
const failedSnap = hasFailedSnapshot(files)
107+
const cancelled = this.ctx.isCancelling
108+
105109
if (failed)
106110
this.ctx.logger.log(WAIT_FOR_CHANGE_FAIL)
111+
else if (cancelled)
112+
this.ctx.logger.log(WAIT_FOR_CHANGE_CANCELLED)
107113
else
108114
this.ctx.logger.log(WAIT_FOR_CHANGE_PASS)
109115

‎packages/vitest/src/node/state.ts

+20
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { relative } from 'pathe'
12
import type { ErrorWithDiff, File, Task, TaskResultPack, UserConsoleLog } from '../types'
23
// can't import actual functions from utils, because it's incompatible with @vitest/browsers
34
import type { AggregateError as AggregateErrorPonyfill } from '../utils'
@@ -125,4 +126,23 @@ export class StateManager {
125126
task.logs.push(log)
126127
}
127128
}
129+
130+
getCountOfFailedTests() {
131+
return Array.from(this.idMap.values()).filter(t => t.result?.state === 'fail').length
132+
}
133+
134+
cancelFiles(files: string[], root: string) {
135+
this.collectFiles(files.map(filepath => ({
136+
filepath,
137+
name: relative(root, filepath),
138+
id: filepath,
139+
mode: 'skip',
140+
type: 'suite',
141+
result: {
142+
state: 'skip',
143+
},
144+
// Cancelled files have not yet collected tests
145+
tasks: [],
146+
})))
147+
}
128148
}

‎packages/vitest/src/node/stdin.ts

+9-6
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const keys = [
1212
['t', 'filter by a test name regex pattern'],
1313
['q', 'quit'],
1414
]
15+
const cancelKeys = ['space', 'c', ...keys.map(key => key[0])]
1516

1617
export function printShortcutsHelp() {
1718
stdout().write(
@@ -37,12 +38,14 @@ export function registerConsoleShortcuts(ctx: Vitest) {
3738
return
3839
}
3940

40-
// is running, ignore keypress
41-
if (ctx.runningPromise)
42-
return
43-
4441
const name = key?.name
4542

43+
if (ctx.runningPromise) {
44+
if (cancelKeys.includes(name))
45+
await ctx.cancelCurrentRun('keyboard-input')
46+
return
47+
}
48+
4649
// quit
4750
if (name === 'q')
4851
return ctx.exit(true)
@@ -83,8 +86,8 @@ export function registerConsoleShortcuts(ctx: Vitest) {
8386
message: 'Input test name pattern (RegExp)',
8487
initial: ctx.configOverride.testNamePattern?.source || '',
8588
}])
86-
await ctx.changeNamePattern(filter.trim(), undefined, 'change pattern')
8789
on()
90+
await ctx.changeNamePattern(filter.trim(), undefined, 'change pattern')
8891
}
8992

9093
async function inputFilePattern() {
@@ -96,8 +99,8 @@ export function registerConsoleShortcuts(ctx: Vitest) {
9699
initial: latestFilename,
97100
}])
98101
latestFilename = filter.trim()
99-
await ctx.changeFilenamePattern(filter.trim())
100102
on()
103+
await ctx.changeFilenamePattern(filter.trim())
101104
}
102105

103106
let rl: readline.Interface | undefined

‎packages/vitest/src/runtime/child.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import v8 from 'node:v8'
22
import { createBirpc } from 'birpc'
33
import { parseRegexp } from '@vitest/utils'
4+
import type { CancelReason } from '@vitest/runner'
45
import type { ResolvedConfig } from '../types'
5-
import type { RuntimeRPC } from '../types/rpc'
6+
import type { RunnerRPC, RuntimeRPC } from '../types/rpc'
67
import type { ChildContext } from '../types/child'
78
import { mockMap, moduleCache, startViteNode } from './execute'
89
import { rpcDone } from './rpc'
@@ -14,6 +15,11 @@ function init(ctx: ChildContext) {
1415
process.env.VITEST_WORKER_ID = '1'
1516
process.env.VITEST_POOL_ID = '1'
1617

18+
let setCancel = (_reason: CancelReason) => {}
19+
const onCancel = new Promise<CancelReason>((resolve) => {
20+
setCancel = resolve
21+
})
22+
1723
// @ts-expect-error untyped global
1824
globalThis.__vitest_environment__ = config.environment
1925
// @ts-expect-error I know what I am doing :P
@@ -22,14 +28,17 @@ function init(ctx: ChildContext) {
2228
moduleCache,
2329
config,
2430
mockMap,
31+
onCancel,
2532
durations: {
2633
environment: 0,
2734
prepare: performance.now(),
2835
},
29-
rpc: createBirpc<RuntimeRPC>(
30-
{},
36+
rpc: createBirpc<RuntimeRPC, RunnerRPC>(
37+
{
38+
onCancel: setCancel,
39+
},
3140
{
32-
eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onWorkerExit'],
41+
eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onWorkerExit', 'onCancel'],
3342
serialize: v8.serialize,
3443
deserialize: v => v8.deserialize(Buffer.from(v)),
3544
post(v) {

‎packages/vitest/src/runtime/entry.ts

+15
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ async function getTestRunner(config: ResolvedConfig, executor: VitestExecutor):
7171
await originalOnAfterRun?.call(testRunner, files)
7272
}
7373

74+
const originalOnAfterRunTest = testRunner.onAfterRunTest
75+
testRunner.onAfterRunTest = async (test) => {
76+
if (config.bail && test.result?.state === 'fail') {
77+
const previousFailures = await rpc().getCountOfFailedTests()
78+
const currentFailures = 1 + previousFailures
79+
80+
if (currentFailures >= config.bail) {
81+
rpc().onCancel('test-failure')
82+
testRunner.onCancel?.('test-failure')
83+
}
84+
}
85+
await originalOnAfterRunTest?.call(testRunner, test)
86+
}
87+
7488
return testRunner
7589
}
7690

@@ -85,6 +99,7 @@ export async function run(files: string[], config: ResolvedConfig, environment:
8599
setupChaiConfig(config.chaiConfig)
86100

87101
const runner = await getTestRunner(config, executor)
102+
workerState.onCancel.then(reason => runner.onCancel?.(reason))
88103

89104
workerState.durations.prepare = performance.now() - workerState.durations.prepare
90105

‎packages/vitest/src/runtime/runners/test.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Suite, Test, TestContext, VitestRunner, VitestRunnerImportSource } from '@vitest/runner'
1+
import type { CancelReason, Suite, Test, TestContext, VitestRunner, VitestRunnerImportSource } from '@vitest/runner'
22
import { GLOBAL_EXPECT, getState, setState } from '@vitest/expect'
33
import { getSnapshotClient } from '../../integrations/snapshot/chai'
44
import { vi } from '../../integrations/vi'
@@ -12,6 +12,7 @@ export class VitestTestRunner implements VitestRunner {
1212
private snapshotClient = getSnapshotClient()
1313
private workerState = getWorkerState()
1414
private __vitest_executor!: VitestExecutor
15+
private cancelRun = false
1516

1617
constructor(public config: ResolvedConfig) {}
1718

@@ -45,9 +46,16 @@ export class VitestTestRunner implements VitestRunner {
4546
this.workerState.current = undefined
4647
}
4748

49+
onCancel(_reason: CancelReason) {
50+
this.cancelRun = true
51+
}
52+
4853
async onBeforeRunTest(test: Test) {
4954
const name = getNames(test).slice(1).join(' > ')
5055

56+
if (this.cancelRun)
57+
test.mode = 'skip'
58+
5159
if (test.mode !== 'run') {
5260
this.snapshotClient.skipTestSnapshots(name)
5361
return
@@ -59,6 +67,11 @@ export class VitestTestRunner implements VitestRunner {
5967
this.workerState.current = test
6068
}
6169

70+
onBeforeRunSuite(suite: Suite) {
71+
if (this.cancelRun)
72+
suite.mode = 'skip'
73+
}
74+
6275
onBeforeTryTest(test: Test) {
6376
setState({
6477
assertionCalls: 0,

‎packages/vitest/src/runtime/worker.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { performance } from 'node:perf_hooks'
22
import { createBirpc } from 'birpc'
33
import { workerId as poolId } from 'tinypool'
4-
import type { RuntimeRPC, WorkerContext } from '../types'
4+
import type { CancelReason } from '@vitest/runner'
5+
import type { RunnerRPC, RuntimeRPC, WorkerContext } from '../types'
56
import { getWorkerState } from '../utils/global'
67
import { mockMap, moduleCache, startViteNode } from './execute'
78
import { setupInspect } from './inspector'
@@ -17,6 +18,11 @@ function init(ctx: WorkerContext) {
1718
process.env.VITEST_WORKER_ID = String(workerId)
1819
process.env.VITEST_POOL_ID = String(poolId)
1920

21+
let setCancel = (_reason: CancelReason) => {}
22+
const onCancel = new Promise<CancelReason>((resolve) => {
23+
setCancel = resolve
24+
})
25+
2026
// @ts-expect-error untyped global
2127
globalThis.__vitest_environment__ = config.environment
2228
// @ts-expect-error I know what I am doing :P
@@ -25,14 +31,17 @@ function init(ctx: WorkerContext) {
2531
moduleCache,
2632
config,
2733
mockMap,
34+
onCancel,
2835
durations: {
2936
environment: 0,
3037
prepare: performance.now(),
3138
},
32-
rpc: createBirpc<RuntimeRPC>(
33-
{},
39+
rpc: createBirpc<RuntimeRPC, RunnerRPC>(
40+
{
41+
onCancel: setCancel,
42+
},
3443
{
35-
eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onWorkerExit'],
44+
eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onWorkerExit', 'onCancel'],
3645
post(v) { port.postMessage(v) },
3746
on(fn) { port.addListener('message', fn) },
3847
},

‎packages/vitest/src/types/config.ts

+5
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,11 @@ export interface InlineConfig {
567567
* https://github.com/chaijs/chai/blob/4.x.x/lib/chai/config.js
568568
*/
569569
chaiConfig?: ChaiConfig
570+
571+
/**
572+
* Stop test execution when given number of tests have failed.
573+
*/
574+
bail?: number
570575
}
571576

572577
export interface TypecheckConfig {

‎packages/vitest/src/types/rpc.ts

+7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { FetchResult, RawSourceMap, ViteNodeResolveId } from 'vite-node'
2+
import type { CancelReason } from '@vitest/runner'
23
import type { EnvironmentOptions, ResolvedConfig, VitestEnvironment } from './config'
34
import type { UserConsoleLog } from './general'
45
import type { SnapshotResult } from './snapshot'
@@ -18,11 +19,17 @@ export interface RuntimeRPC {
1819
onCollected: (files: File[]) => void
1920
onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void
2021
onTaskUpdate: (pack: TaskResultPack[]) => void
22+
onCancel(reason: CancelReason): void
23+
getCountOfFailedTests(): number
2124

2225
snapshotSaved: (snapshot: SnapshotResult) => void
2326
resolveSnapshotPath: (testPath: string) => string
2427
}
2528

29+
export interface RunnerRPC {
30+
onCancel: (reason: CancelReason) => void
31+
}
32+
2633
export interface ContextTestEnvironment {
2734
name: VitestEnvironment
2835
options: EnvironmentOptions | null

‎packages/vitest/src/types/worker.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { MessagePort } from 'node:worker_threads'
2-
import type { Test } from '@vitest/runner'
2+
import type { CancelReason, Test } from '@vitest/runner'
33
import type { ModuleCacheMap, ViteNodeResolveId } from 'vite-node'
44
import type { BirpcReturn } from 'birpc'
55
import type { MockMap } from './mocker'
@@ -24,6 +24,7 @@ export interface WorkerGlobalState {
2424
current?: Test
2525
filepath?: string
2626
environmentTeardownRun?: boolean
27+
onCancel: Promise<CancelReason>
2728
moduleCache: ModuleCacheMap
2829
mockMap: MockMap
2930
durations: {

‎packages/ws-client/src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createBirpc } from 'birpc'
33
import { parse, stringify } from 'flatted'
44
// eslint-disable-next-line no-restricted-imports
55
import type { WebSocketEvents, WebSocketHandlers } from 'vitest'
6+
import type { CancelReason } from '@vitest/runner'
67
import { StateManager } from '../../vitest/src/node/state'
78

89
export * from '../../vitest/src/utils/tasks'
@@ -66,6 +67,9 @@ export function createClient(url: string, options: VitestClientOptions = {}) {
6667
onFinished(files) {
6768
handlers.onFinished?.(files)
6869
},
70+
onCancel(reason: CancelReason) {
71+
handlers.onCancel?.(reason)
72+
},
6973
}
7074

7175
const birpcHandlers: BirpcOptions<WebSocketHandlers> = {

‎pnpm-lock.yaml

+22-4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/bail/fixtures/test/first.test.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { expect, test } from 'vitest'
2+
3+
test('1 - first.test.ts - this should pass', async () => {
4+
await new Promise(resolve => setTimeout(resolve, 250))
5+
expect(true).toBeTruthy()
6+
})
7+
8+
test('2 - first.test.ts - this should fail', () => {
9+
expect(false).toBeTruthy()
10+
})
11+
12+
test('3 - first.test.ts - this should be skipped', () => {
13+
expect(true).toBeTruthy()
14+
})
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { expect, test } from 'vitest'
2+
3+
// When using multi threads the first test will start before failing.test.ts fails
4+
const isThreads = process.env.THREADS === 'true'
5+
6+
test(`1 - second.test.ts - this should ${isThreads ? 'pass' : 'be skipped'}`, async () => {
7+
await new Promise(resolve => setTimeout(resolve, 1500))
8+
expect(true).toBeTruthy()
9+
})
10+
11+
test('2 - second.test.ts - this should be skipped', () => {
12+
expect(true).toBeTruthy()
13+
})
14+
15+
test('3 - second.test.ts - this should be skipped', () => {
16+
expect(true).toBeTruthy()
17+
})

‎test/bail/fixtures/vitest.config.ts

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { defineConfig } from 'vitest/config'
2+
import type { WorkspaceSpec } from 'vitest/node'
3+
4+
class TestNameSequencer {
5+
async sort(files: WorkspaceSpec[]): Promise<WorkspaceSpec[]> {
6+
return [...files].sort(([, filenameA], [, filenameB]) => {
7+
if (filenameA > filenameB)
8+
return 1
9+
10+
if (filenameA < filenameB)
11+
return -1
12+
13+
return 0
14+
})
15+
}
16+
17+
public async shard(files: WorkspaceSpec[]): Promise<WorkspaceSpec[]> {
18+
return files
19+
}
20+
}
21+
22+
export default defineConfig({
23+
test: {
24+
reporters: 'verbose',
25+
cache: false,
26+
watch: false,
27+
sequence: {
28+
sequencer: TestNameSequencer,
29+
},
30+
browser: {
31+
headless: true,
32+
name: 'chrome',
33+
},
34+
},
35+
})

‎test/bail/package.json

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "@vitest/test-bail",
3+
"private": true,
4+
"scripts": {
5+
"test": "vitest"
6+
},
7+
"devDependencies": {
8+
"@vitest/browser": "workspace:*",
9+
"execa": "^7.0.0",
10+
"vite": "latest",
11+
"vitest": "workspace:*",
12+
"webdriverio": "latest"
13+
}
14+
}

‎test/bail/test/bail.test.ts

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { expect, test } from 'vitest'
2+
import { execa } from 'execa'
3+
4+
const configs: string[][] = []
5+
const pools = [['--threads', 'true'], ['--threads', 'false'], ['--single-thread']]
6+
7+
if (process.platform !== 'win32')
8+
pools.push(['--browser'])
9+
10+
for (const isolate of ['true', 'false']) {
11+
for (const pool of pools) {
12+
configs.push([
13+
'--bail',
14+
'1',
15+
'--isolate',
16+
isolate,
17+
...pool,
18+
])
19+
}
20+
}
21+
22+
for (const config of configs) {
23+
test(`should bail with "${config.join(' ')}"`, async () => {
24+
const { exitCode, stdout } = await execa('vitest', [
25+
'--no-color',
26+
'--root',
27+
'fixtures',
28+
...config,
29+
], {
30+
reject: false,
31+
env: { THREADS: config.join(' ').includes('--threads true') ? 'true' : 'false' },
32+
})
33+
34+
expect(exitCode).toBe(1)
35+
expect(stdout).toMatch('✓ test/first.test.ts > 1 - first.test.ts - this should pass')
36+
expect(stdout).toMatch('× test/first.test.ts > 2 - first.test.ts - this should fail')
37+
38+
// Cancelled tests should not be run
39+
expect(stdout).not.toMatch('test/first.test.ts > 3 - first.test.ts - this should be skipped')
40+
expect(stdout).not.toMatch('test/second.test.ts > 1 - second.test.ts - this should be skipped')
41+
expect(stdout).not.toMatch('test/second.test.ts > 2 - second.test.ts - this should be skipped')
42+
expect(stdout).not.toMatch('test/second.test.ts > 3 - second.test.ts - this should be skipped')
43+
})
44+
}

‎test/bail/vitest.config.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { defineConfig } from 'vitest/config'
2+
3+
export default defineConfig({
4+
test: {
5+
reporters: 'verbose',
6+
include: ['test/**/*.test.*'],
7+
8+
// For Windows CI mostly
9+
testTimeout: process.env.CI ? 30_000 : 10_000,
10+
chaiConfig: {
11+
truncateThreshold: 999,
12+
},
13+
},
14+
})

‎test/watch/test/stdin.test.ts

+45-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
import { test } from 'vitest'
1+
import { rmSync, writeFileSync } from 'node:fs'
2+
import { afterEach, expect, test } from 'vitest'
23

34
import { startWatchMode } from './utils'
45

6+
const cleanups: (() => void)[] = []
7+
8+
afterEach(() => {
9+
cleanups.splice(0).forEach(fn => fn())
10+
})
11+
512
test('quit watch mode', async () => {
613
const vitest = await startWatchMode()
714

@@ -35,3 +42,40 @@ test('filter by test name', async () => {
3542
await vitest.waitForOutput('Test name pattern: /sum/')
3643
await vitest.waitForOutput('1 passed')
3744
})
45+
46+
test('cancel test run', async () => {
47+
const vitest = await startWatchMode()
48+
49+
const testPath = 'fixtures/cancel.test.ts'
50+
const testCase = `// Dynamic test case
51+
import { afterAll, afterEach, test } from 'vitest'
52+
53+
// These should be called even when test is cancelled
54+
afterAll(() => console.log('[cancel-test]: afterAll'))
55+
afterEach(() => console.log('[cancel-test]: afterEach'))
56+
57+
test('1 - test that finishes', async () => {
58+
console.log('[cancel-test]: test')
59+
60+
await new Promise(resolve => setTimeout(resolve, 1000))
61+
})
62+
63+
test('2 - test that is cancelled', async () => {
64+
console.log('[cancel-test]: should not run')
65+
})
66+
`
67+
68+
cleanups.push(() => rmSync(testPath))
69+
writeFileSync(testPath, testCase, 'utf8')
70+
71+
// Test case is running, cancel it
72+
await vitest.waitForOutput('[cancel-test]: test')
73+
vitest.write('c')
74+
75+
// Test hooks should still be called
76+
await vitest.waitForOutput('CANCELLED')
77+
await vitest.waitForOutput('[cancel-test]: afterAll')
78+
await vitest.waitForOutput('[cancel-test]: afterEach')
79+
80+
expect(vitest.output).not.include('[cancel-test]: should not run')
81+
})

0 commit comments

Comments
 (0)
Please sign in to comment.