Skip to content

Commit

Permalink
feat: better debug support (#334)
Browse files Browse the repository at this point in the history
* feat: better debug support

* refactor: cleanup

* refactor: allow debugExclude as a folder option

* refactor: organize worker

(doesn't work, but looks nice)

* chore: cleanup

* refactor: improve types

* fix: always report ocverage if exists

* chore: print where coverage is reported to

* chore: cleanup

* chore: privatize properties

* feat: add vitest.debuggerAddress option

* fix: don't use empty string if debuggerAddress is not provided

* chore: comment for proxy

* chore: remove unused isTestFile

* feat: support debug restart

* fix: stop debugger when request is cancelled

* fix: add minimum debug version

* chore: add requirements for debugger to readme

* chore: cleanup

* fix: stop tests so the extension doesn't hang on long-running tests

* chore: improve test run reporting during the debug

* fix: use the same test run for tests in a folder

* docs: more info
  • Loading branch information
sheremet-va committed Apr 13, 2024
1 parent 1116f95 commit e0a1f6b
Show file tree
Hide file tree
Showing 20 changed files with 711 additions and 486 deletions.
27 changes: 15 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@

![](https://i.ibb.co/bJCbCf2/202203292020.gif)

## Features

- **Run**, **debug**, and **watch** Vitest tests in Visual Studio Code.
- **Coverage** support (requires VS Code >= 1.88)
- NX support (see the [NX sample](./samples/monorepo-nx/)).
- An `@open` tag can be used when filtering tests, to only show the tests open in the editor.

## Requirements

- Visual Studio Code version >= 1.77.0
- Vitest version >= v1.4.0
- Coverage requires Visual Studio Code >= 1.88.0
- Debugger requires Vitest >= 1.5.0

## Usage

You can manage tests both from the Testing view and directly within your test files.
Expand Down Expand Up @@ -46,18 +60,6 @@ When viewing a test file, you'll notice test icons in the gutter next to each te
- `Reveal in Test Explorer`: Locate and highlight the test in the centralized Testing view.
- `Breakpoint Settings`: Set breakpoints to pause execution during debugging. You can add a standard breakpoint, a conditional breakpoint, a logpoint, or a triggered breakpoint.

## Features

- **Run**, **debug**, and **watch** Vitest tests in Visual Studio Code.
- **Coverage** support (requires VS Code >= 1.88)
- NX support (see the [NX sample](./samples/monorepo-nx/)).
- An `@open` tag can be used when filtering tests, to only show the tests open in the editor.

## Requirements

- Visual Studio Code version >= 1.77.0.
- Vitest version >= v1.4.0

## Configuration

You can identify if your config is loaded by the extension with `process.env.VITEST_VSCODE` and change the configuration accordingly.
Expand All @@ -71,6 +73,7 @@ These options are resolved relative to the [workspace file](https://code.visuals
- `vitest.configSearchPatternExclude`: [Glob pattern](https://code.visualstudio.com/docs/editor/glob-patterns) that should be ignored when this extension looks for config files. Note that this is applied to _config_ files, not test files inside configs. Default: `**/{node_modules,.*}/**`If the extension cannot find Vitest, please open an issue.
- `vitest.nodeExecutable`: This extension spawns another process and will use this value as `execPath` argument.
- `vitest.debuggerPort`: Port that the debugger will be attached to. By default uses 9229 or tries to find a free port if it's not available.
- `vitest.debuggerAddress`: TCP/IP address of process to be debugged. Default: localhost

### Other Options

Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@
"default": "**/{node_modules,.*}/**",
"scope": "window"
},
"vitest.debuggerAddress": {
"description": "TCP/IP address of process to be debugged. Default: localhost",
"type": "string",
"scope": "window"
},
"vitest.debuggerPort": {
"description": "Port that the debugger will be attached to. By default uses 9229 or tries to find a free port if it's not available.",
"type": "string",
Expand Down
22 changes: 7 additions & 15 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,6 @@ export class VitestAPI {
return this.api
}

async isTestFile(file: string) {
return this.meta.rpc.isTestFile(file)
}

async dispose() {
this.disposing = true
try {
Expand Down Expand Up @@ -126,12 +122,12 @@ export class VitestFolderAPI extends VitestReporter {
return this.pkg.prefix
}

get workspaceFolder() {
return WEAKMAP_API_FOLDER.get(this)!
get version() {
return this.pkg.version
}

isTestFile(file: string) {
return this.meta.rpc.isTestFile(file)
get workspaceFolder() {
return WEAKMAP_API_FOLDER.get(this)!
}

async runFiles(files?: string[], testNamePatern?: string) {
Expand Down Expand Up @@ -173,10 +169,6 @@ export class VitestFolderAPI extends VitestReporter {
await this.meta.rpc.cancelRun(this.id)
}

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

waitForCoverageReport() {
return this.meta.rpc.waitForCoverageReport(this.id)
}
Expand All @@ -198,11 +190,11 @@ export class VitestFolderAPI extends VitestReporter {
}

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

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

Expand Down
37 changes: 21 additions & 16 deletions src/api/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
import v8 from 'node:v8'
import type { ChildProcess } from 'node:child_process'
import { type BirpcReturn, createBirpc } from 'birpc'
import type { File, ResolvedCoverageOptions, TaskResultPack, UserConsoleLog } from 'vitest'
import type { File, TaskResultPack, UserConsoleLog } from 'vitest'

export interface BirpcMethods {
getFiles: (id: string) => Promise<[project: string, file: string][]>
collectTests: (id: string, testFile: string[]) => Promise<void>
cancelRun: (id: string) => Promise<void>
runTests: (id: string, files?: string[], testNamePattern?: string) => Promise<void>
isTestFile: (file: string) => Promise<boolean>
export interface VitestMethods {
getFiles: () => Promise<[project: string, file: string][]>
collectTests: (testFile: string[]) => Promise<void>
cancelRun: () => Promise<void>
runTests: (files?: string[], testNamePattern?: string) => Promise<void>

watchTests: (id: string, files?: string[], testNamePattern?: string) => Promise<void>
unwatchTests: (id: string) => Promise<void>
watchTests: (files?: string[], testNamePattern?: string) => void
unwatchTests: () => void

enableCoverage: (id: string) => void
disableCoverage: (id: string) => void
getCoverageConfig: (id: string) => Promise<ResolvedCoverageOptions>
waitForCoverageReport: (id: string) => Promise<string | null>
enableCoverage: () => void
disableCoverage: () => void
waitForCoverageReport: () => Promise<string | null>

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

type VitestPoolMethods = {
[K in keyof VitestMethods]: (id: string, ...args: Parameters<VitestMethods[K]>) => ReturnType<VitestMethods[K]>
}

export interface VitestPool extends VitestPoolMethods {
close: () => void
}

Expand All @@ -36,7 +41,7 @@ export type BirpcEvents = {
[K in keyof VitestEvents]: (folder: string, ...args: Parameters<VitestEvents[K]>) => void
}

export type VitestRPC = BirpcReturn<BirpcMethods, BirpcEvents>
export type VitestRPC = BirpcReturn<VitestPool, BirpcEvents>

function createHandler<T extends (...args: any) => any>() {
const handlers: T[] = []
Expand Down Expand Up @@ -95,7 +100,7 @@ function createRpcOptions() {
export function createVitestRpc(vitest: ChildProcess) {
const { events, handlers } = createRpcOptions()

const api = createBirpc<BirpcMethods, BirpcEvents>(
const api = createBirpc<VitestPool, BirpcEvents>(
events,
{
timeout: -1,
Expand Down
3 changes: 2 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ export function getConfig(workspaceFolder?: WorkspaceFolder) {
configSearchPatternExclude,
nodeExecutable: resolvePath(nodeExecutable),
disableWorkspaceWarning: get<boolean>('disableWorkspaceWarning', false),
debuggerPort: get<number>('debuggerPort'),
debuggerPort: get<number>('debuggerPort') || undefined,
debuggerAddress: get<string>('debuggerAddress', undefined) || undefined,
}
}

Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { resolve } from 'pathe'

export const minimumVersion = '1.4.0'
export const minimumDebugVersion = '1.5.0'

export const distDir = resolve(__filename)
export const workerPath = resolve(__dirname, 'worker.js')
Expand Down
173 changes: 121 additions & 52 deletions src/debug/debugManager.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { randomUUID } from 'node:crypto'
import * as vscode from 'vscode'
import type { TestRunner } from '../runner/runner'
import getPort from 'get-port'
import { getConfig } from '../config'
import { log } from '../log'
import type { VitestFolderAPI } from '../api'
import { showVitestError } from '../utils'

interface VitestDebugConfig {
request: vscode.TestRunRequest
token: vscode.CancellationToken
runner: TestRunner
api: VitestFolderAPI
}

export class TestDebugManager extends vscode.Disposable {
private disposables: vscode.Disposable[] = []
private sessions = new Set<vscode.DebugSession>()
private configurations = new Map<string, VitestDebugConfig>()
private sessions = new Map<string, vscode.DebugSession>()
private port: number | undefined
private address: string | undefined

private static DEBUG_DEFAULT_PORT = 9229

private configurations = new Map<string, TestDebugConfiguration>()

constructor() {
super(() => {
Expand All @@ -22,59 +22,128 @@ export class TestDebugManager extends vscode.Disposable {

this.disposables.push(
vscode.debug.onDidStartDebugSession((session) => {
// the main attach session is called "Remote Process [0]"
// https://github.com/microsoft/vscode-js-debug/blob/dfceaf103ce0cb83b53f1c3d88c06b8b63cb17da/src/targets/node/nodeAttacher.ts#L89
// there are also other sessions that are spawned from the main session
// but they have different names (like workers are named [worker #id])
// I wonder how we could make it easier to make custom debug configurations here?
// All this logic exists only because when "retry" is clicked, the main debug session is
// not recreated, - instead it reattaches itself and this session is created again,
// so we need to track that to correctly restart the tests
if (!session.configuration.name.startsWith('Remote Process'))
return
const baseSession = this.getVitestSession(session)
if (!baseSession)
return
const sym = baseSession.configuration.__vitest as string
const config = this.configurations.get(sym)
if (!config)
return
this.sessions.add(baseSession)
const { request, runner, token } = config
runner.runTests(request, token).catch((err: any) => {
showVitestError('Failed to debug tests', err)
})
const id = session.configuration.__vitest
if (id)
this.sessions.set(id, session)
}),
vscode.debug.onDidTerminateDebugSession((session) => {
if (!session.configuration.__vitest)
const id = session.configuration.__vitest
if (id) {
this.sessions.delete(id)
this.configurations.get(id)?.resolve()
this.configurations.delete(id)
return
this.sessions.delete(session)
const sym = session.configuration.__vitest as string
const config = this.configurations.get(sym)
if (!config)
}
const vitestId = session.parentSession && session.parentSession.configuration.__vitest
if (!vitestId || !session.configuration.name.startsWith('Remote Process'))
return
const { api } = config
api.cancelRun()
api.stopInspect()

// I am going insane with this debugging API
// For long-running tests this line just stops the debugger,
// For fast tests this will rerun the suite correctly
const configuration = this.configurations.get(vitestId)
configuration?.stopTests().then(() => {
setTimeout(() => {
// if configuration is not empty, it means that the tests are still running
const configuration = this.configurations.get(vitestId)
if (configuration)
configuration.runTests()
}, 50)
})
}),
)
}

public configure(id: string, config: VitestDebugConfig) {
this.configurations.set(id, config)
public async enable(api: VitestFolderAPI) {
await this.stopDebugging()

const config = getConfig()
this.port ??= config.debuggerPort || await getPort({ port: TestDebugManager.DEBUG_DEFAULT_PORT })
this.address ??= config.debuggerAddress
await api.startInspect(this.port)
}

public async stop() {
await Promise.allSettled([...this.sessions].map(s => vscode.debug.stopDebugging(s)))
this.configurations.clear()
public async disable(api: VitestFolderAPI) {
await this.stopDebugging()
this.port = undefined
this.address = undefined
api.stopInspect()
}

private getVitestSession(session: vscode.DebugSession): vscode.DebugSession | undefined {
if (session.configuration.__vitest)
return session
if (session.parentSession)
return this.getVitestSession(session.parentSession)
public startDebugging(
runTests: () => Promise<void>,
stopTests: () => Promise<void>,
folder: vscode.WorkspaceFolder,
) {
const config = getConfig(folder)

const uniqueId = randomUUID()
let _resolve: () => void
let _reject: (error: Error) => void
const promise = new Promise<void>((resolve, reject) => {
_resolve = resolve
_reject = reject
})
this.configurations.set(uniqueId, {
runTests,
stopTests,
resolve: _resolve!,
reject: _reject!,
})

const debugConfig = {
type: 'pwa-node',
request: 'attach',
name: 'Debug Tests',
port: this.port,
address: this.address,
autoAttachChildProcesses: true,
skipFiles: config.debugExclude,
smartStep: true,
__vitest: uniqueId,
env: {
...process.env,
VITEST_VSCODE: 'true',
},
}

log.info(`[DEBUG] Starting debugging on ${debugConfig.address || 'localhost'}:${debugConfig.port}`)

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

return undefined
runTests()

return promise
}

public async stopDebugging() {
await Promise.allSettled([...this.sessions].map(([, s]) => vscode.debug.stopDebugging(s)))
}
}

interface TestDebugConfiguration {
runTests: () => Promise<void>
stopTests: () => Promise<void>

resolve: () => void
reject: (error: Error) => void
}

0 comments on commit e0a1f6b

Please sign in to comment.