Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: better debug support #334

Merged
merged 23 commits into from
Apr 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
98716e1
feat: better debug support
sheremet-va Apr 6, 2024
c974ed1
refactor: cleanup
sheremet-va Apr 6, 2024
0486fc1
refactor: allow debugExclude as a folder option
sheremet-va Apr 6, 2024
eab82d0
refactor: organize worker
sheremet-va Apr 6, 2024
ebf35b4
chore: cleanup
sheremet-va Apr 6, 2024
a51c569
refactor: improve types
sheremet-va Apr 9, 2024
b59ffc9
fix: always report ocverage if exists
sheremet-va Apr 9, 2024
271345d
chore: print where coverage is reported to
sheremet-va Apr 9, 2024
d5c042f
chore: cleanup
sheremet-va Apr 9, 2024
823afdd
chore: privatize properties
sheremet-va Apr 9, 2024
03c910b
feat: add vitest.debuggerAddress option
sheremet-va Apr 9, 2024
ec6c2b2
fix: don't use empty string if debuggerAddress is not provided
sheremet-va Apr 9, 2024
b895d16
chore: comment for proxy
sheremet-va Apr 9, 2024
56eb39d
chore: remove unused isTestFile
sheremet-va Apr 9, 2024
8fc65e9
feat: support debug restart
sheremet-va Apr 9, 2024
354c83c
fix: stop debugger when request is cancelled
sheremet-va Apr 9, 2024
fab687b
fix: add minimum debug version
sheremet-va Apr 9, 2024
033bc9f
chore: add requirements for debugger to readme
sheremet-va Apr 9, 2024
2e4f497
chore: cleanup
sheremet-va Apr 10, 2024
4604f36
fix: stop tests so the extension doesn't hang on long-running tests
sheremet-va Apr 10, 2024
9d88f01
chore: improve test run reporting during the debug
sheremet-va Apr 10, 2024
ec6e04d
fix: use the same test run for tests in a folder
sheremet-va Apr 10, 2024
e8ac77f
docs: more info
sheremet-va Apr 10, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
sheremet-va marked this conversation as resolved.
Show resolved Hide resolved

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
}