Skip to content

Commit

Permalink
feat(watch): test run cancelling
Browse files Browse the repository at this point in the history
  • Loading branch information
AriPerkkio committed Apr 14, 2023
1 parent baf902a commit dfa733e
Show file tree
Hide file tree
Showing 20 changed files with 199 additions and 21 deletions.
9 changes: 8 additions & 1 deletion docs/advanced/runner.md
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
:::
Expand Down
15 changes: 14 additions & 1 deletion packages/browser/src/client/main.ts
Expand Up @@ -30,7 +30,16 @@ function getQueryPaths() {
return url.searchParams.getAll('path')
}

export const client = createClient(ENTRY_URL)
let setCancel = () => {}
const onCancel = new Promise<void>((resolve) => {
setCancel = resolve
})

export const client = createClient(ENTRY_URL, {
handlers: {
onCancel: setCancel,
},
})

const ws = client.ws

Expand Down Expand Up @@ -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()

Expand Down
7 changes: 7 additions & 0 deletions packages/runner/src/types/runner.ts
Expand Up @@ -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.
*/
Expand Down
4 changes: 3 additions & 1 deletion packages/vitest/src/api/setup.ts
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/api/types.ts
Expand Up @@ -28,4 +28,5 @@ export interface WebSocketHandlers {
}

export interface WebSocketEvents extends Pick<Reporter, 'onCollected' | 'onFinished' | 'onTaskUpdate' | 'onUserConsoleLog' | 'onPathsCollected'> {
onCancel(): void
}
17 changes: 16 additions & 1 deletion packages/vitest/src/node/core.ts
Expand Up @@ -46,6 +46,7 @@ export class Vitest {
filenamePattern?: string
runningPromise?: Promise<void>
closingPromise?: Promise<void>
isCancelling = false

isFirstRun = true
restartsCount = 0
Expand All @@ -64,6 +65,7 @@ export class Vitest {

private _onRestartListeners: OnServerRestartHandler[] = []
private _onSetServer: OnServerRestartHandler[] = []
private _onCancelListeners: (() => Promise<void> | void)[] = []

async setServer(options: UserConfig, server: ViteDevServer, cliOptions: UserConfig) {
this.unregisterWatcher?.()
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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])
}
Expand Down Expand Up @@ -767,4 +778,8 @@ export class Vitest {
onAfterSetServer(fn: OnServerRestartHandler) {
this._onSetServer.push(fn)
}

onCancel(fn: () => void) {
this._onCancelListeners.push(fn)
}
}
12 changes: 12 additions & 0 deletions packages/vitest/src/node/pools/browser.ts
Expand Up @@ -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)

Expand All @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions packages/vitest/src/node/pools/child.ts
Expand Up @@ -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'
Expand All @@ -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<RunnerRPC, RuntimeRPC>(
createMethodsRPC(project),
{
eventNames: ['onCancel'],
serialize: v8.serialize,
deserialize: v => v8.deserialize(Buffer.from(v)),
post(v) {
Expand All @@ -29,6 +30,8 @@ function setupChildProcessChannel(project: WorkspaceProject, fork: ChildProcess)
},
},
)

project.ctx.onCancel(() => rpc.onCancel())
}

function stringifyRegex(input: RegExp | string): string {
Expand Down
16 changes: 14 additions & 2 deletions packages/vitest/src/node/pools/threads.ts
Expand Up @@ -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'
Expand All @@ -20,9 +20,10 @@ function createWorkerChannel(project: WorkspaceProject) {
const port = channel.port2
const workerPort = channel.port1

createBirpc<{}, RuntimeRPC>(
const rpc = createBirpc<RunnerRPC, RuntimeRPC>(
createMethodsRPC(project),
{
eventNames: ['onCancel'],
post(v) {
port.postMessage(v)
},
Expand All @@ -32,6 +33,8 @@ function createWorkerChannel(project: WorkspaceProject) {
},
)

project.ctx.onCancel(() => rpc.onCancel())

return { workerPort, port }
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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<WorkspaceProject, ResolvedConfig>()
const getConfig = (project: WorkspaceProject): ResolvedConfig => {
if (configs.has(project))
Expand Down
5 changes: 5 additions & 0 deletions packages/vitest/src/node/reporters/base.ts
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
11 changes: 11 additions & 0 deletions packages/vitest/src/node/state.ts
Expand Up @@ -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',
})))
}
}
15 changes: 9 additions & 6 deletions packages/vitest/src/node/stdin.ts
Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand Down
16 changes: 13 additions & 3 deletions packages/vitest/src/runtime/child.ts
Expand Up @@ -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'
Expand All @@ -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<void>((resolve) => {
setCancel = resolve
})

// @ts-expect-error untyped global
globalThis.__vitest_environment__ = config.environment
// @ts-expect-error I know what I am doing :P
Expand All @@ -22,12 +27,17 @@ function init(ctx: ChildContext) {
moduleCache,
config,
mockMap,
onCancel,
durations: {
environment: 0,
prepare: performance.now(),
},
rpc: createBirpc<RuntimeRPC>(
{},
rpc: createBirpc<RuntimeRPC, RunnerRPC>(
{
onCancel() {
setCancel()
},
},
{
eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onWorkerExit'],
serialize: v8.serialize,
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/runtime/entry.ts
Expand Up @@ -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

Expand Down

0 comments on commit dfa733e

Please sign in to comment.