Skip to content

Commit

Permalink
fix: spawn a separate process when debugging (#376)
Browse files Browse the repository at this point in the history
* feat: spawn a separate process when debugging

* chore: dispose when session is terminated

* chore: cleanup
  • Loading branch information
sheremet-va committed May 7, 2024
1 parent b71f433 commit 6736630
Show file tree
Hide file tree
Showing 14 changed files with 428 additions and 376 deletions.
56 changes: 40 additions & 16 deletions src/api.ts
Expand Up @@ -12,6 +12,7 @@ import { createVitestRpc } from './api/rpc'
import type { WorkerRunnerOptions } from './worker/types'
import type { VitestPackage } from './api/pkg'
import { findNode, pluralize, showVitestError } from './utils'
import type { VitestProcess } from './process'

export class VitestReporter {
constructor(
Expand Down Expand Up @@ -78,18 +79,18 @@ export class VitestAPI {
this.meta.packages.forEach((pkg) => {
delete require.cache[pkg.vitestPackageJsonPath]
})
if (!this.meta.process.killed) {
if (!this.meta.process.closed) {
try {
await this.meta.rpc.close()
log.info('[API]', `Vitest process ${this.meta.process.pid} closed successfully`)
log.info('[API]', `Vitest process ${this.meta.process.id} closed successfully`)
}
catch (err) {
log.error('[API]', 'Failed to close Vitest process', err)
}
const promise = new Promise<void>((resolve) => {
this.meta.process.once('exit', () => resolve())
})
this.meta.process.kill()
this.meta.process.close()
await promise
}
}
Expand All @@ -115,7 +116,7 @@ export class VitestFolderAPI extends VitestReporter {
}

get processId() {
return this.meta.process.pid
return this.meta.process.id
}

get prefix() {
Expand All @@ -126,6 +127,10 @@ export class VitestFolderAPI extends VitestReporter {
return this.pkg.version
}

get package() {
return this.pkg
}

get workspaceFolder() {
return WEAKMAP_API_FOLDER.get(this)!
}
Expand Down Expand Up @@ -193,14 +198,6 @@ export class VitestFolderAPI extends VitestReporter {
async unwatchTests() {
await this.meta.rpc.unwatchTests(this.id)
}

stopInspect() {
return this.meta.rpc.stopInspect(this.id)
}

startInspect(port: number, address?: string) {
return this.meta.rpc.startInspect(this.id, port, address)
}
}

export async function resolveVitestAPI(showWarning: boolean, packages: VitestPackage[]) {
Expand All @@ -211,9 +208,9 @@ export async function resolveVitestAPI(showWarning: boolean, packages: VitestPac
return new VitestAPI(apis, vitest)
}

interface ResolvedMeta {
export interface ResolvedMeta {
rpc: VitestRPC
process: ChildProcess
process: VitestProcess
packages: VitestPackage[]
handlers: {
onConsoleLog: (listener: BirpcEvents['onConsoleLog']) => void
Expand Down Expand Up @@ -350,12 +347,39 @@ export async function createVitestProcess(showWarning: boolean, packages: Vitest

log.info('[API]', `Vitest process ${vitest.pid} created`)

const { handlers, api } = createVitestRpc(vitest)
const { handlers, api } = createVitestRpc({
on: listener => vitest.on('message', listener),
send: message => vitest.send(message),
})

return {
rpc: api,
process: vitest,
process: new VitestChildProvess(vitest),
handlers,
packages,
}
}

class VitestChildProvess implements VitestProcess {
constructor(private child: ChildProcess) {}

get id() {
return this.child.pid ?? 0
}

get closed() {
return this.child.killed
}

on(event: string, listener: (...args: any[]) => void) {
this.child.on(event, listener)
}

once(event: string, listener: (...args: any[]) => void) {
this.child.once(event, listener)
}

close() {
this.child.kill()
}
}
15 changes: 7 additions & 8 deletions src/api/rpc.ts
@@ -1,5 +1,4 @@
import v8 from 'node:v8'
import type { ChildProcess } from 'node:child_process'
import { type BirpcReturn, createBirpc } from 'birpc'
import type { File, TaskResultPack, UserConsoleLog } from 'vitest'

Expand All @@ -16,9 +15,6 @@ export interface VitestMethods {
enableCoverage: () => void
disableCoverage: () => void
waitForCoverageReport: () => Promise<string | null>

startInspect: (port: number, address?: string) => void
stopInspect: () => void
}

type VitestPoolMethods = {
Expand Down Expand Up @@ -59,7 +55,7 @@ function createHandler<T extends (...args: any) => any>() {
}
}

function createRpcOptions() {
export function createRpcOptions() {
const handlers = {
onConsoleLog: createHandler<BirpcEvents['onConsoleLog']>(),
onTaskUpdate: createHandler<BirpcEvents['onTaskUpdate']>(),
Expand Down Expand Up @@ -98,18 +94,21 @@ function createRpcOptions() {
}
}

export function createVitestRpc(vitest: ChildProcess) {
export function createVitestRpc(options: {
on: (listener: (message: any) => void) => void
send: (message: any) => void
}) {
const { events, handlers } = createRpcOptions()

const api = createBirpc<VitestPool, BirpcEvents>(
events,
{
timeout: -1,
on(listener) {
vitest.on('message', listener)
options.on(listener)
},
post(message) {
vitest.send(message)
options.send(message)
},
serialize: v8.serialize,
deserialize: v => v8.deserialize(Buffer.from(v)),
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Expand Up @@ -5,6 +5,7 @@ export const minimumDebugVersion = '1.5.0'

export const distDir = __dirname
export const workerPath = resolve(__dirname, 'worker.js')
export const debuggerPath = resolve(__dirname, 'debug.js')
export const setupFilePath = resolve(__dirname, 'setupFile.mjs')

export const configGlob = '**/*{vite,vitest}*.config*.{ts,js,mjs,cjs,cts,mts}'
Expand Down
218 changes: 218 additions & 0 deletions src/debug/api.ts
@@ -0,0 +1,218 @@
import { createServer } from 'node:http'
import * as vscode from 'vscode'
import WebSocket, { WebSocketServer } from 'ws'
import getPort from 'get-port'
import type { ResolvedMeta } from '../api'
import { VitestFolderAPI } from '../api'
import type { VitestPackage } from '../api/pkg'
import { createVitestRpc } from '../api/rpc'
import type { VitestProcess } from '../process'
import type { TestTree } from '../testTree'
import { log } from '../log'
import { getConfig } from '../config'
import type { WorkerRunnerOptions } from '../worker/types'
import { TestRunner } from '../runner/runner'
import { findNode } from '../utils'
import { debuggerPath } from '../constants'

export async function debugTests(
controller: vscode.TestController,
tree: TestTree,
pkg: VitestPackage,

request: vscode.TestRunRequest,
token: vscode.CancellationToken,
) {
const port = await getPort()
const server = createServer().listen(port)
const wss = new WebSocketServer({ server })
const wsAddress = `ws://localhost:${port}`

const config = getConfig(pkg.folder)
const promise = Promise.withResolvers<void>()

const execPath = getConfig().nodeExecutable || await findNode(
vscode.workspace.workspaceFile?.fsPath || pkg.folder.uri.fsPath,
)
const env = config.env || {}

const debugConfig = {
type: 'pwa-node',
request: 'launch',
name: 'Debug Tests',
autoAttachChildProcesses: true,
skipFiles: config.debugExclude,
smartStep: true,
runtimeExecutable: execPath,
program: debuggerPath,
__name: 'Vitest',
env: {
...process.env,
...env,
VITEST_VSCODE: 'true',
VITEST_WS_ADDRESS: wsAddress,
// same env var as `startVitest`
// https://github.com/vitest-dev/vitest/blob/5c7e9ca05491aeda225ce4616f06eefcd068c0b4/packages/vitest/src/node/cli/cli-api.ts
TEST: 'true',
VITEST: 'true',
NODE_ENV: env.NODE_ENV ?? process.env.NODE_ENV ?? 'test',
},
}

vscode.debug.startDebugging(
pkg.folder,
debugConfig,
{ suppressDebugView: true },
).then(
(fulfilled) => {
if (fulfilled) {
log.info('[DEBUG] Debugging started')
promise.resolve()
}
else {
promise.reject(new Error('Failed to start debugging. See output for more information.'))
log.error('[DEBUG] Debugging failed')
}
},
(err) => {
promise.reject(new Error('Failed to start debugging', { cause: err }))
log.error('[DEBUG] Start debugging failed')
log.error(err.toString())
},
)

const disposables: vscode.Disposable[] = []

const onDidStart = vscode.debug.onDidStartDebugSession(async (session) => {
if (session.configuration.__name !== 'Vitest')
return
if (token.isCancellationRequested) {
vscode.debug.stopDebugging(session)
return
}
const vitest = await startWebsocketServer(wss, pkg)
const api = new VitestFolderAPI(pkg, vitest)
const runner = new TestRunner(
controller,
tree,
api,
)
disposables.push(api, runner)

token.onCancellationRequested(async () => {
await vitest.rpc.close()
await vscode.debug.stopDebugging(session)
})

await runner.runTests(request, token)

if (!token.isCancellationRequested) {
await vitest.rpc.close()
await vscode.debug.stopDebugging(session)
}
})

const onDidTerminate = vscode.debug.onDidTerminateDebugSession((session) => {
if (session.configuration.__name !== 'Vitest')
return
disposables.forEach(d => d.dispose())
server.close()
})

disposables.push(onDidStart, onDidTerminate)
}

function startWebsocketServer(wss: WebSocketServer, pkg: VitestPackage) {
return new Promise<ResolvedMeta>((resolve, reject) => {
wss.once('connection', (ws) => {
function ready(_message: any) {
const message = JSON.parse(_message.toString())

if (message.type === 'debug')
log.worker('info', ...message.args)

if (message.type === 'ready') {
ws.off('message', ready)
const { api, handlers } = createVitestRpc({
on: listener => ws.on('message', listener),
send: message => ws.send(message),
})
resolve({
rpc: api,
handlers,
process: new VitestWebSocketProcess(Math.random(), wss, ws),
packages: [pkg],
})
}
if (message.type === 'error') {
ws.off('message', ready)
const error = new Error(`Vitest failed to start: \n${message.errors.map((r: any) => r[1]).join('\n')}`)
reject(error)
}
ws.off('error', error)
ws.off('message', ready)
ws.off('close', exit)
}

function error(err: Error) {
log.error('[API]', err)
reject(err)
ws.off('error', error)
ws.off('message', ready)
ws.off('close', exit)
}

function exit(code: number) {
reject(new Error(`Vitest process exited with code ${code}`))
}

ws.on('error', error)
ws.on('message', ready)
ws.on('close', exit)

const runnerOptions: WorkerRunnerOptions = {
type: 'init',
meta: [
{
vitestNodePath: pkg.vitestNodePath,
env: getConfig(pkg.folder).env || undefined,
configFile: pkg.configFile,
cwd: pkg.cwd,
arguments: pkg.arguments,
workspaceFile: pkg.workspaceFile,
id: pkg.id,
},
],
}

ws.send(JSON.stringify(runnerOptions))
})
wss.on('error', err => reject(err))
// TODO close if unexpected
// wss.once('close', () => reject(err))
})
}

class VitestWebSocketProcess implements VitestProcess {
constructor(
public id: number,
private wss: WebSocketServer,
private ws: WebSocket,
) {}

get closed() {
return this.ws.readyState === WebSocket.CLOSED
}

close() {
this.wss.close()
}

on(event: string, listener: (...args: any[]) => void) {
this.ws.on(event, listener)
}

once(event: string, listener: (...args: any[]) => void) {
this.ws.once(event, listener)
}
}

0 comments on commit 6736630

Please sign in to comment.