diff --git a/README.md b/README.md index 2d1dfbc..6988407 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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 diff --git a/package.json b/package.json index 0e534d7..24a7dd0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api.ts b/src/api.ts index e6d5d0a..0b75dfb 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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 { @@ -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) { @@ -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) } @@ -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) } } diff --git a/src/api/rpc.ts b/src/api/rpc.ts index e8c5797..c826d2b 100644 --- a/src/api/rpc.ts +++ b/src/api/rpc.ts @@ -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 - cancelRun: (id: string) => Promise - runTests: (id: string, files?: string[], testNamePattern?: string) => Promise - isTestFile: (file: string) => Promise +export interface VitestMethods { + getFiles: () => Promise<[project: string, file: string][]> + collectTests: (testFile: string[]) => Promise + cancelRun: () => Promise + runTests: (files?: string[], testNamePattern?: string) => Promise - watchTests: (id: string, files?: string[], testNamePattern?: string) => Promise - unwatchTests: (id: string) => Promise + watchTests: (files?: string[], testNamePattern?: string) => void + unwatchTests: () => void - enableCoverage: (id: string) => void - disableCoverage: (id: string) => void - getCoverageConfig: (id: string) => Promise - waitForCoverageReport: (id: string) => Promise + enableCoverage: () => void + disableCoverage: () => void + waitForCoverageReport: () => Promise - startInspect: (port: number) => void + startInspect: (port: number, address?: string) => void stopInspect: () => void +} + +type VitestPoolMethods = { + [K in keyof VitestMethods]: (id: string, ...args: Parameters) => ReturnType +} + +export interface VitestPool extends VitestPoolMethods { close: () => void } @@ -36,7 +41,7 @@ export type BirpcEvents = { [K in keyof VitestEvents]: (folder: string, ...args: Parameters) => void } -export type VitestRPC = BirpcReturn +export type VitestRPC = BirpcReturn function createHandler any>() { const handlers: T[] = [] @@ -95,7 +100,7 @@ function createRpcOptions() { export function createVitestRpc(vitest: ChildProcess) { const { events, handlers } = createRpcOptions() - const api = createBirpc( + const api = createBirpc( events, { timeout: -1, diff --git a/src/config.ts b/src/config.ts index 655f30d..ff8479e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -52,7 +52,8 @@ export function getConfig(workspaceFolder?: WorkspaceFolder) { configSearchPatternExclude, nodeExecutable: resolvePath(nodeExecutable), disableWorkspaceWarning: get('disableWorkspaceWarning', false), - debuggerPort: get('debuggerPort'), + debuggerPort: get('debuggerPort') || undefined, + debuggerAddress: get('debuggerAddress', undefined) || undefined, } } diff --git a/src/constants.ts b/src/constants.ts index 628df79..e02b661 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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') diff --git a/src/debug/debugManager.ts b/src/debug/debugManager.ts index dd014d7..1897d1a 100644 --- a/src/debug/debugManager.ts +++ b/src/debug/debugManager.ts @@ -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() - private configurations = new Map() + private sessions = new Map() + private port: number | undefined + private address: string | undefined + + private static DEBUG_DEFAULT_PORT = 9229 + + private configurations = new Map() constructor() { super(() => { @@ -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, + stopTests: () => Promise, + folder: vscode.WorkspaceFolder, + ) { + const config = getConfig(folder) + + const uniqueId = randomUUID() + let _resolve: () => void + let _reject: (error: Error) => void + const promise = new Promise((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 + stopTests: () => Promise + + resolve: () => void + reject: (error: Error) => void +} diff --git a/src/debug/startSession.ts b/src/debug/startSession.ts deleted file mode 100644 index 077847d..0000000 --- a/src/debug/startSession.ts +++ /dev/null @@ -1,60 +0,0 @@ -import * as vscode from 'vscode' -import getPort from 'get-port' -import { log } from '../log' -import { getConfig } from '../config' -import type { TestRunner } from '../runner/runner' -import type { VitestFolderAPI } from '../api' -import { nanoid } from '../utils' -import type { TestDebugManager } from './debugManager' - -export interface DebugSessionAPI { - stop: () => Promise -} - -const DEBUG_DEFAULT_PORT = 9229 - -export async function startDebugSession( - debug: TestDebugManager, - api: VitestFolderAPI, - runner: TestRunner, - request: vscode.TestRunRequest, - token: vscode.CancellationToken, -) { - const config = getConfig() - const port = config.debuggerPort || await getPort({ port: DEBUG_DEFAULT_PORT }) - - const inspectPromise = api.startInspect(port) - - const id = nanoid() - - const debugConfig = { - type: 'pwa-node', - request: 'attach', - name: 'Debug Tests', - port, - autoAttachChildProcesses: true, - skipFiles: config.debugExclude, - smartStep: true, - __vitest: id, - env: { - ...process.env, - VITEST_VSCODE: 'true', - }, - } - - debug.configure(id, { request, runner, api, token }) - - vscode.debug.startDebugging(undefined, debugConfig, { - suppressDebugView: true, - }).then((fulfilled) => { - if (fulfilled) - log.info('[DEBUG] Debugging started') - else - log.error('[DEBUG] Debugging failed') - }, (err) => { - log.error('[DEBUG] Start debugging failed') - log.error(err.toString()) - }) - - await inspectPromise -} diff --git a/src/extension.ts b/src/extension.ts index 9659475..c49b0aa 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,11 +1,12 @@ import * as vscode from 'vscode' +import { gte } from 'semver' import { version } from '../package.json' import { getConfig, testControllerId } from './config' import type { VitestAPI } from './api' import { resolveVitestAPI } from './api' import { TestRunner } from './runner/runner' import { TestTree } from './testTree' -import { configGlob, workspaceGlob } from './constants' +import { configGlob, minimumDebugVersion, workspaceGlob } from './constants' import { log } from './log' import { createVitestWorkspaceFile, debounce, noop, showVitestError } from './utils' import { resolveVitestPackages } from './api/pkg' @@ -66,6 +67,8 @@ class VitestExtension { const configFiles = vitest.filter(x => x.configFile && !x.workspaceFile) + // TODO: hard limit on the number of config files + if (configFiles.length > 3 && configFiles.every(c => getConfig(c.folder).disableWorkspaceWarning !== true)) { vscode.window.showWarningMessage( `Vitest found ${configFiles.length} config files. For better performance, consider using a workspace configuration.`, @@ -146,11 +149,20 @@ class VitestExtension { () => {}, false, undefined, - true, + false, // disable continues debugging ) } debugProfile.tag = api.tag - debugProfile.runHandler = (request, token) => runner.debugTests(request, token) + debugProfile.runHandler = (request, token) => { + if (!gte(api.version, minimumDebugVersion)) { + vscode.window.showWarningMessage( + `Debugging is not supported for Vitest v${api.version}. Please update Vitest to v${minimumDebugVersion} or higher.`, + ) + } + else { + runner.debugTests(request, token) + } + } this.runProfiles.set(`${api.id}:debug`, debugProfile) // coverage is supported since VS Code 1.88 diff --git a/src/runner/runner.ts b/src/runner/runner.ts index dd55cb3..26836c7 100644 --- a/src/runner/runner.ts +++ b/src/runner/runner.ts @@ -4,12 +4,11 @@ import stripAnsi from 'strip-ansi' import * as vscode from 'vscode' import { getTasks } from '@vitest/ws-client' import type { ErrorWithDiff, File, ParsedStack, Task, TaskResult } from 'vitest' -import { basename, normalize } from 'pathe' +import { basename, normalize, relative } from 'pathe' import { type TestData, TestFile, TestFolder, getTestData } from '../testTreeData' import type { TestTree } from '../testTree' import type { VitestFolderAPI } from '../api' import { log } from '../log' -import { startDebugSession } from '../debug/startSession' import type { TestDebugManager } from '../debug/debugManager' import { showVitestError } from '../utils' import { coverageContext, readCoverageReport } from '../coverage' @@ -85,7 +84,7 @@ export class TestRunner extends vscode.Disposable { showVitestError(`Failed to report coverage. ${err.message}`, err) } - const runs = new Set() + const finishedRuns = new Set() files.forEach((file) => { const data = this.tree.getTestDataByTask(file) as TestFile | undefined @@ -93,8 +92,8 @@ export class TestRunner extends vscode.Disposable { if (testRun && data) { this.markResult(testRun, data.item, file.result, file) this.testRunsByFile.delete(file.filepath) - if (!runs.has(testRun)) { - runs.add(testRun) + if (!finishedRuns.has(testRun)) { + finishedRuns.add(testRun) testRun.end() } } @@ -118,15 +117,44 @@ export class TestRunner extends vscode.Disposable { } public async debugTests(request: vscode.TestRunRequest, token: vscode.CancellationToken) { - await Promise.all([this.api.stopInspect(), this.debug.stop()]) - - await startDebugSession( - this.debug, - this.api, - this, - request, - token, - ) + await this.debug.enable(this.api) + + const testItems = request.include?.length + ? partitionTestFileItems(request.include) + : this.tree.getAllFileItems().map(item => [getTestData(item), []] as [TestFile, never[]]) + + this.simpleTestRunRequest = request + token.onCancellationRequested(() => { + this.debug.disable(this.api) + this.endRequestRuns(request) + this.simpleTestRunRequest = null + this.api.cancelRun() + }) + + // we need to run tests one file at a time, so we partition them + // it's important to keep the same test items that were in the original request + // because they dictate how format testNamePattern + for (const [testFileData, testFileChildren] of testItems) { + if (token.isCancellationRequested) + break + + const includedTests = testFileChildren.length + ? testFileChildren + : [testFileData.item] + + await this.debug.startDebugging( + () => this.runTestItems(includedTests).catch((err) => { + log.error(err) + }), + () => this.api.cancelRun(), + this.api.workspaceFolder, + ) + } + + await this.debug.disable(this.api) + + this.simpleTestRunRequest = null + this._onRequestsExhausted.fire() } private endRequestRuns(request: vscode.TestRunRequest) { @@ -170,8 +198,10 @@ export class TestRunner extends vscode.Disposable { } const { dispose } = this._onRequestsExhausted.event(() => { - this.api.disableCoverage() - dispose() + if (!this.continuousRequests.size && !this.simpleTestRunRequest) { + this.api.disableCoverage() + dispose() + } }) token.onCancellationRequested(() => { @@ -195,8 +225,13 @@ export class TestRunner extends vscode.Disposable { this.api.cancelRun() }) - const tests = request.include || [] + await this.runTestItems(request.include || []) + + this.simpleTestRunRequest = null + this._onRequestsExhausted.fire() + } + private async runTestItems(tests: readonly vscode.TestItem[]) { if (!tests.length) { log.info(`Running all tests in ${basename(this.api.workspaceFolder.uri.fsPath)}`) await this.api.runFiles() @@ -210,9 +245,6 @@ export class TestRunner extends vscode.Disposable { log.info(`Running ${files.length} file(s):`, files) await this.api.runFiles(files, testNamePatern) } - - this.simpleTestRunRequest = null - this._onRequestsExhausted.fire() } private getTestRunByData(data: TestData): vscode.TestRun | null { @@ -247,20 +279,11 @@ export class TestRunner extends vscode.Disposable { } private getTestFilesInFolder(path: string) { - function getFiles(folder: vscode.TestItem): string[] { - const files: string[] = [] - for (const [_, item] of folder.children) { - const data = getTestData(item) - if (data instanceof TestFile) - files.push(data.filepath) - else if (data instanceof TestFolder) - files.push(...getFiles(item)) - } - return files - } - const folder = this.tree.getOrCreateFolderTestItem(this.api, path) - return getFiles(folder) + const items = getFolderFiles(folder) + return Array.from( + new Set(items.map(item => (getTestData(item) as TestFile).filepath)), + ) } private createContinuousRequest() { @@ -283,20 +306,24 @@ export class TestRunner extends vscode.Disposable { ) } - private startTestRun(files: string[], primaryRequest?: vscode.TestRunRequest) { + private async startTestRun(files: string[], primaryRun?: vscode.TestRun, primaryRequest?: vscode.TestRunRequest) { const request = primaryRequest || this.simpleTestRunRequest || this.createContinuousRequest() if (!request) return - const run = this.controller.createTestRun(request) + const name = files.length > 1 + ? undefined + : relative(this.api.workspaceFolder.uri.fsPath, files[0]) + + const run = primaryRun || this.controller.createTestRun(request, name) const testRunsByRequest = this.testRunsByRequest.get(request) || [] this.testRunsByRequest.set(request, [...testRunsByRequest, run]) for (const file of files) { if (file[file.length - 1] === '/') { const files = this.getTestFilesInFolder(file) - this.startTestRun(files, request) + this.startTestRun(files, run, request) continue } @@ -451,6 +478,46 @@ function parseLocationFromStacks(testItem: vscode.TestItem, stacks: ParsedStack[ } } +function getFolderFiles(folder: vscode.TestItem): vscode.TestItem[] { + const files: vscode.TestItem[] = [] + for (const [_, item] of folder.children) { + const data = getTestData(item) + if (data instanceof TestFile) + files.push(data.item) + else if (data instanceof TestFolder) + files.push(...getFolderFiles(item)) + } + return files +} + +function partitionTestFileItems(tests: readonly vscode.TestItem[]) { + const fileItems = new Map() + + for (const testItem of tests) { + const data = getTestData(testItem) + if (data instanceof TestFile) { + const items = fileItems.get(data) || [] + fileItems.set(data, items) + continue + } + if (data instanceof TestFolder) { + const items = getFolderFiles(testItem) + items.forEach((item) => { + const data = getTestData(item) as TestFile + const fileItemsForFile = fileItems.get(data) || [] + fileItems.set(data, fileItemsForFile) + }) + continue + } + const fileData = data.file + const items = fileItems.get(fileData) || [] + fileItems.set(fileData, items) + items.push(testItem) + } + + return Array.from(fileItems.entries()) as [TestFile, vscode.TestItem[]][] +} + function getTestFiles(tests: readonly vscode.TestItem[]) { return Array.from( new Set(tests.map((test) => { diff --git a/src/testTree.ts b/src/testTree.ts index 3f1ed73..9bdc38a 100644 --- a/src/testTree.ts +++ b/src/testTree.ts @@ -40,6 +40,10 @@ export class TestTree extends vscode.Disposable { return this.testItemsByFile.get(normalize(fsPath)) || [] } + public getAllFileItems() { + return Array.from(this.fileItems.values()) + } + public reset(workspaceFolders: vscode.WorkspaceFolder[]) { this.folderItems.clear() this.testItemsByFile.clear() diff --git a/src/worker/actions.ts b/src/worker/actions.ts deleted file mode 100644 index cc012b6..0000000 --- a/src/worker/actions.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { tmpdir } from 'node:os' -import { existsSync } from 'node:fs' -import { dirname, join } from 'pathe' -import type { CoverageProvider, Vitest } from 'vitest' -import type { BirpcMethods } from '../api/rpc' - -const _require = require - -const inspector = () => _require('inspector') as typeof import('inspector') - -interface WatchState { - files: string[] - testNamePattern: string | undefined - watchEveryFile: boolean - rerunTriggered: boolean -} - -export function createWorkerMethods(vitestById: Record): BirpcMethods { - let debuggerEnabled = false - - const watchStateById: Record = {} - const providers = new WeakMap() - const coverages = new WeakMap() - const coverageStatuses = new WeakMap() - - const vitestEntries = Object.entries(vitestById) - vitestEntries.forEach(([id, vitest]) => { - vitest.getCoreWorkspaceProject().provide('__vscode', { - get continuousFiles() { - const state = watchStateById[id] - return state?.files || [] - }, - get watchEveryFile() { - const state = watchStateById[id] - return state?.watchEveryFile ?? true - }, - get rerunTriggered() { - const state = watchStateById[id] - return state?.rerunTriggered ?? false - }, - }) - - // @ts-expect-error modifying a private property - const originalScheduleRerun = vitest.scheduleRerun.bind(vitest) - // @ts-expect-error modifying a private property - vitest.scheduleRerun = async function (files: string[]) { - // if trigger is not a test file, remove all non-continious files from this.changedTests - const triggerFile = files[0] - const isTestFileTrigger = this.changedTests.has(triggerFile) - - const state = watchStateById[id] - - // no continuous files for this Vitest instance, just collect tests - if (!state) { - // do not run any tests if continuous run was not enabled - if (!isTestFileTrigger) { - this.changedTests.clear() - this.invalidates.clear() - } - - vitest.configOverride.testNamePattern = /$a/ - return await originalScheduleRerun.call(this, files) - } - - state.rerunTriggered = true - - vitest.configOverride.testNamePattern = state.testNamePattern ? new RegExp(state.testNamePattern) : undefined - if (state.watchEveryFile) - return await originalScheduleRerun.call(this, files) - - if (!isTestFileTrigger) { - // if souce code is changed and related tests are not continious, remove them from changedTests - const updatedTests = new Set() - for (const file of this.changedTests) { - if (state.files.includes(file)) - updatedTests.add(file) - } - this.changedTests = updatedTests - } - - return await originalScheduleRerun.call(this, files) - } - }) - - async function rerunTests(vitest: Vitest, files: string[]) { - await vitest.report('onWatcherRerun', files) - await vitest.runFiles(files.flatMap(file => vitest.getProjectsByTestFile(file)), false) - - await vitest.report('onWatcherStart', vitest.state.getFiles(files)) - } - - async function runTests(id: string, files: string[], testNamePattern?: string) { - const cwd = process.cwd() - const vitest = vitestById[id] - const state = watchStateById[id] - if (state) - state.rerunTriggered = false - - process.chdir(dirname(id)) - try { - vitest.configOverride.testNamePattern = testNamePattern ? new RegExp(testNamePattern) : undefined - - if (!debuggerEnabled) { - await rerunTests(vitest, files) - } - else { - for (const file of files) - await rerunTests(vitest, [file]) - } - } - finally { - process.chdir(cwd) - } - } - - async function globTestFiles(id: string, vitest: Vitest, filters?: string[]) { - const cwd = process.cwd() - process.chdir(dirname(id)) - const files = await vitest.globTestFiles(filters) - process.chdir(cwd) - return files - } - - return { - async watchTests(id: string, files, testNamePattern) { - const vitest = vitestById[id] - if (!vitest) - throw new Error(`Vitest instance not found with id: ${id}`) - watchStateById[id] = { - files: files || [], - watchEveryFile: !files, - testNamePattern, - rerunTriggered: false, - } - }, - async unwatchTests(id) { - const vitest = vitestById[id] - if (!vitest) - throw new Error(`Vitest instance not found with id: ${id}`) - watchStateById[id] = null - }, - async collectTests(id: string, testFiles: string[]) { - const vitest = vitestById[id] - vitest.config.coverage.enabled = false - vitest.coverageProvider = undefined - try { - await runTests(id, testFiles, '$a') - } - finally { - vitest.configOverride.testNamePattern = undefined - vitest.coverageProvider = coverageStatuses.get(vitest) ? providers.get(vitest) : undefined - vitest.config.coverage.enabled = coverageStatuses.get(vitest) || false - } - }, - async cancelRun(id: string) { - const vitest = vitestById[id] - if (!vitest) - throw new Error(`Vitest instance with id "${id}" not found.`) - await vitest.cancelCurrentRun('keyboard-input') - }, - async runTests(id, files, testNamePattern) { - const vitest = vitestById[id] - if (!vitest) - throw new Error(`Vitest instance not found for id: ${id}`) - - // @ts-expect-error private method - await vitest.initBrowserProviders() - - if (testNamePattern) { - await runTests(id, files || vitest.state.getFilepaths(), testNamePattern) - } - else { - const specs = await globTestFiles(id, vitest, files) - await runTests(id, specs.map(([_, spec]) => spec)) - } - }, - async getFiles(id: string) { - const vitest = vitestById[id] - const files = await globTestFiles(id, vitest) - // reset cached test files list - vitest.projects.forEach((project) => { - project.testFilesList = null - }) - return files.map(([project, spec]) => [project.config.name || '', spec]) - }, - async isTestFile(file: string) { - for (const [_, vitest] of vitestEntries) { - for (const project of vitest.projects) { - if (project.isTestFile(file)) - return true - } - } - return false - }, - async enableCoverage(id: string) { - const vitest = vitestById[id] - coverages.delete(vitest) - vitest.config.coverage.enabled = true - coverageStatuses.set(vitest, true) - - const jsonReporter = vitest.config.coverage.reporter.find(([name]) => name === 'json') - vitest.config.coverage.reporter = jsonReporter ? [jsonReporter] : [['json', {}]] - vitest.config.coverage.reportOnFailure = true - vitest.config.coverage.reportsDirectory = join(tmpdir(), `vitest-coverage-${randomUUID()}`) - - try { - if (!providers.has(vitest)) { - // @ts-expect-error private method - await vitest.initCoverageProvider() - await vitest.coverageProvider?.clean(vitest.config.coverage.clean) - providers.set(vitest, vitest.coverageProvider!) - } - else { - vitest.coverageProvider = providers.get(vitest) - await vitest.coverageProvider?.clean(vitest.config.coverage.clean) - } - } - catch (err) { - coverageStatuses.set(vitest, false) - vitest.config.coverage.enabled = false - throw err - } - }, - disableCoverage(id: string) { - const vitest = vitestById[id] - coverages.delete(vitest) - coverageStatuses.set(vitest, false) - vitest.config.coverage.enabled = false - vitest.coverageProvider = undefined - }, - async getCoverageConfig(id: string) { - const vitest = vitestById[id] - return vitest.config.coverage - }, - async waitForCoverageReport(id: string) { - const vitest = vitestById[id] - const coverage = vitest.config.coverage - if (!coverage.enabled || !vitest.coverageProvider) - return null - await vitest.runningPromise - if (existsSync(coverage.reportsDirectory)) - return coverage.reportsDirectory - return null - }, - startInspect(port) { - inspector().open(port) - debuggerEnabled = true - }, - stopInspect() { - debuggerEnabled = false - inspector().close() - }, - async close() { - for (const vitest of vitestEntries) { - try { - await vitest[1].close() - } - catch { - // ignore - } - } - }, - } -} diff --git a/src/worker/coverage.ts b/src/worker/coverage.ts new file mode 100644 index 0000000..080b29c --- /dev/null +++ b/src/worker/coverage.ts @@ -0,0 +1,87 @@ +import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import type { CoverageProvider, ResolvedCoverageOptions, Vitest as VitestCore } from 'vitest' +import type { Vitest } from './vitest' + +export class VitestCoverage { + private _enabled = false + private _provider: CoverageProvider | null | undefined = undefined + + private _config: ResolvedCoverageOptions + + constructor( + private ctx: VitestCore, + private vitest: Vitest, + ) { + this._config = ctx.config.coverage + Object.defineProperty(ctx.config, 'coverage', { + get: () => { + return this.config + }, + set: (coverage: ResolvedCoverageOptions) => { + this._config = coverage + }, + }) + Object.defineProperty(ctx, 'coverageProvider', { + get: () => { + if (this.enabled) + return this._provider + + return null + }, + set: (provider: CoverageProvider | null) => { + this._provider = provider + }, + }) + } + + private get config(): ResolvedCoverageOptions { + return { + ...this._config, + enabled: this.enabled, + } + } + + private get enabled() { + return this._enabled && !this.vitest.collecting + } + + public async enable() { + const vitest = this.ctx + this._enabled = true + + const jsonReporter = this._config.reporter.find(([name]) => name === 'json') + this._config.reporter = [jsonReporter || ['json', {}]] + this._config.reportOnFailure = true + this._config.reportsDirectory = join(tmpdir(), `vitest-coverage-${randomUUID()}`) + + this.ctx.logger.log('Coverage reports directory is generated:', this._config.reportsDirectory) + + if (!this._provider) { + // @ts-expect-error private method + await vitest.initCoverageProvider() + await vitest.coverageProvider?.clean(this._config.clean) + } + else { + await this._provider.clean(this._config.clean) + } + } + + public disable() { + this._enabled = false + } + + async waitForReport() { + if (!this.enabled) + return null + const coverage = this.ctx.config.coverage + if (!coverage.enabled || !this.ctx.coverageProvider) + return null + await this.ctx.runningPromise + if (existsSync(coverage.reportsDirectory)) + return coverage.reportsDirectory + return null + } +} diff --git a/src/worker/debugger.ts b/src/worker/debugger.ts new file mode 100644 index 0000000..d1f8291 --- /dev/null +++ b/src/worker/debugger.ts @@ -0,0 +1,41 @@ +import type { Vitest as VitestCore } from 'vitest' +import type { Vitest } from './vitest' + +export class VitestDebugger { + private _enabled = false + private _port: number | undefined + private _address: string | undefined + + constructor( + ctx: VitestCore, + private vitest: Vitest, + ) { + // @ts-expect-error not released yet + const inspector = ctx.config.inspector + Object.defineProperty(ctx.config, 'inspector', { + get: () => { + return { + ...inspector, + enabled: this.enabled, + port: this._port, + host: this._address, + waitForDebugger: true, + } + }, + }) + } + + private get enabled() { + return this._enabled && !this.vitest.collecting + } + + public start(port: number, address?: string) { + this._enabled = true + this._port = port + this._address = address + } + + public stop() { + this._enabled = false + } +} diff --git a/src/worker/pool.ts b/src/worker/pool.ts new file mode 100644 index 0000000..d730c90 --- /dev/null +++ b/src/worker/pool.ts @@ -0,0 +1,34 @@ +import type { VitestPool } from '../api/rpc' +import type { Vitest } from './vitest' + +export function createVitestPool(vitestById: Record): VitestPool { + return new Proxy({ + async close() { + for (const vitest in vitestById) { + try { + await vitestById[vitest].dispose() + } + catch { + // ignore + } + } + }, + }, { + // because we don't want to copy-past the same VitestPool for every vitestById + // we use a Proxy to dynamically call these methods on the correct Vitest instance + get(target, prop) { + if (prop === 'close') + return Reflect.get(target, prop) + + return function (id: string, ...args: any[]) { + const vitest = vitestById[id] + if (!vitest) + throw new Error(`Vitest instance not found with id: ${id} (calling method: ${String(prop)})`) + if (prop in vitest) + return vitest[prop as 'collectTests'](...args as [any]) + else + throw new Error(`Method not found: ${String(prop)}`) + } + }, + }) +} diff --git a/src/worker/reporter.ts b/src/worker/reporter.ts index bdbd6c7..c08fc57 100644 --- a/src/worker/reporter.ts +++ b/src/worker/reporter.ts @@ -1,22 +1,24 @@ import { nextTick } from 'node:process' import { parseErrorStacktrace } from '@vitest/utils/source-map' import type { BirpcReturn } from 'birpc' -import type { File, Reporter, TaskResultPack, UserConsoleLog, Vitest } from 'vitest' -import type { BirpcEvents, BirpcMethods } from '../api/rpc' +import type { File, Reporter, TaskResultPack, UserConsoleLog, Vitest as VitestCore } from 'vitest' +import type { BirpcEvents, VitestPool } from '../api/rpc' import { setupFilePath } from '../constants' +import type { WorkerMeta } from './types' +import { Vitest } from './vitest' export class VSCodeReporter implements Reporter { - private rpc!: BirpcReturn - private ctx!: Vitest - private id!: string + private rpc!: BirpcReturn + private ctx!: VitestCore - get isCollecting(): boolean { - return this.ctx.configOverride.testNamePattern?.toString() === '/$a/' + constructor(private meta: WorkerMeta) {} + + private get collecting(): boolean { + return this.ctx.configOverride.testNamePattern?.toString() === `/${Vitest.COLLECT_NAME_PATTERN}/` } - initVitest(ctx: Vitest, id: string) { + init(ctx: VitestCore) { this.ctx = ctx - this.id = id ctx.config.setupFiles = [ ...ctx.config.setupFiles || [], setupFilePath, @@ -29,12 +31,12 @@ export class VSCodeReporter implements Reporter { }) } - initRpc(rpc: BirpcReturn) { + initRpc(rpc: BirpcReturn) { this.rpc = rpc } onUserConsoleLog(log: UserConsoleLog) { - this.rpc.onConsoleLog(this.id, log) + this.rpc.onConsoleLog(this.meta.id, log) } onTaskUpdate(packs: TaskResultPack[]) { @@ -50,25 +52,25 @@ export class VSCodeReporter implements Reporter { }) }) - this.rpc.onTaskUpdate(this.id, packs) + this.rpc.onTaskUpdate(this.meta.id, packs) } onFinished(files?: File[], errors?: unknown[]) { - const collecting = this.isCollecting + const collecting = this.collecting nextTick(() => { - this.rpc.onFinished(this.id, files, errors, collecting) + this.rpc.onFinished(this.meta.id, files, errors, collecting) }) } onCollected(files?: File[]) { - this.rpc.onCollected(this.id, files, this.isCollecting) + this.rpc.onCollected(this.meta.id, files, this.collecting) } onWatcherStart(files?: File[], errors?: unknown[]) { - this.rpc.onWatcherStart(this.id, files, errors, this.isCollecting) + this.rpc.onWatcherStart(this.meta.id, files, errors, this.collecting) } onWatcherRerun(files: string[], trigger?: string) { - this.rpc.onWatcherRerun(this.id, files, trigger, this.isCollecting) + this.rpc.onWatcherRerun(this.meta.id, files, trigger, this.collecting) } } diff --git a/src/worker/rpc.ts b/src/worker/rpc.ts index 161fc06..ae9e2e9 100644 --- a/src/worker/rpc.ts +++ b/src/worker/rpc.ts @@ -1,11 +1,11 @@ import type { ChannelOptions } from 'birpc' import { createBirpc } from 'birpc' -import type { Vitest } from 'vitest' -import type { BirpcEvents, BirpcMethods } from '../api/rpc' -import { createWorkerMethods } from './actions' +import type { BirpcEvents, VitestPool } from '../api/rpc' +import { createVitestPool } from './pool' +import type { Vitest } from './vitest' export function createWorkerRPC(vitestById: Record, channel: ChannelOptions) { - const rpc = createBirpc(createWorkerMethods(vitestById), { + const rpc = createBirpc(createVitestPool(vitestById), { timeout: -1, eventNames: [ 'onConsoleLog', diff --git a/src/worker/vitest.ts b/src/worker/vitest.ts new file mode 100644 index 0000000..a775edd --- /dev/null +++ b/src/worker/vitest.ts @@ -0,0 +1,134 @@ +import { dirname } from 'node:path' +import type { Vitest as VitestCore } from 'vitest' +import type { VitestMethods } from '../api/rpc' +import { VitestWatcher } from './watcher' +import { VitestCoverage } from './coverage' +import { VitestDebugger } from './debugger' + +const cwd = process.cwd() + +export class Vitest implements VitestMethods { + private readonly watcher: VitestWatcher + private readonly coverage: VitestCoverage + private readonly debugger: VitestDebugger + + public static COLLECT_NAME_PATTERN = '$a' + + constructor( + private readonly id: string, + private readonly ctx: VitestCore, + ) { + this.watcher = new VitestWatcher(ctx) + this.coverage = new VitestCoverage(ctx, this) + this.debugger = new VitestDebugger(ctx, this) + } + + public get collecting() { + return this.ctx.configOverride.testNamePattern?.toString() === `/${Vitest.COLLECT_NAME_PATTERN}/` + } + + public async collectTests(files: string[]) { + try { + await this.runTestFiles(files, Vitest.COLLECT_NAME_PATTERN) + } + finally { + this.setTestNamePattern(undefined) + } + } + + public async runTests(files: string[] | undefined, testNamePattern?: string) { + // @ts-expect-error private method + await this.ctx.initBrowserProviders() + + if (testNamePattern) { + await this.runTestFiles(files || this.ctx.state.getFilepaths(), testNamePattern) + } + else { + const specs = await this.globTestFiles(files) + await this.runTestFiles(specs.map(([_, spec]) => spec)) + } + } + + public cancelRun() { + return this.ctx.cancelCurrentRun('keyboard-input') + } + + public async getFiles(): Promise<[project: string, file: string][]> { + const files = await this.globTestFiles() + // reset cached test files list + this.ctx.projects.forEach((project) => { + project.testFilesList = null + }) + return files.map(([project, spec]) => [project.config.name || '', spec]) + } + + private async globTestFiles(filters?: string[]) { + process.chdir(dirname(this.id)) + const files = await this.ctx.globTestFiles(filters) + process.chdir(cwd) + return files + } + + private async runTestFiles(files: string[], testNamePattern?: string | undefined) { + await this.ctx.runningPromise + this.watcher.markRerun(false) + process.chdir(dirname(this.id)) + + try { + this.setTestNamePattern(testNamePattern) + + await this.rerunTests(files) + } + finally { + process.chdir(cwd) + } + } + + private setTestNamePattern(pattern: string | undefined) { + this.ctx.configOverride.testNamePattern = pattern ? new RegExp(pattern) : undefined + } + + private async rerunTests(files: string[]) { + await this.ctx.report('onWatcherRerun', files) + await this.ctx.runFiles(files.flatMap(file => this.ctx.getProjectsByTestFile(file)), false) + + await this.ctx.report('onWatcherStart', this.ctx.state.getFiles(files)) + } + + unwatchTests() { + return this.watcher.stopTracking() + } + + watchTests(files?: string[], testNamePatern?: string) { + if (files) + this.watcher.trackTests(files, testNamePatern) + else + this.watcher.trackEveryFile() + } + + disableCoverage() { + return this.coverage.disable() + } + + enableCoverage() { + return this.coverage.enable() + } + + startInspect(port: number, address?: string) { + this.debugger.start(port, address) + } + + stopInspect() { + this.debugger.stop() + } + + waitForCoverageReport() { + return this.coverage.waitForReport() + } + + dispose() { + this.coverage.disable() + this.watcher?.stopTracking() + return this.ctx.close() + } +} diff --git a/src/worker/watcher.ts b/src/worker/watcher.ts new file mode 100644 index 0000000..cd05305 --- /dev/null +++ b/src/worker/watcher.ts @@ -0,0 +1,90 @@ +import type { ProvidedContext, Vitest as VitestCore } from 'vitest' +import { Vitest } from './vitest' + +export class VitestWatcher { + private files: string[] = [] + private testNamePattern: string | undefined + private watchEveryFile = false + private rerunTriggered = false + + private enabled = false + + constructor(ctx: VitestCore) { + // eslint-disable-next-line ts/no-this-alias + const state = this + ctx.getCoreWorkspaceProject().provide('__vscode', { + get continuousFiles() { + return state.files || [] + }, + get watchEveryFile() { + return state.watchEveryFile ?? true + }, + get rerunTriggered() { + return state.rerunTriggered ?? false + }, + } satisfies ProvidedContext['__vscode']) + + // @ts-expect-error modifying a private property + const originalScheduleRerun = ctx.scheduleRerun.bind(ctx) + // @ts-expect-error modifying a private property + ctx.scheduleRerun = async function (files: string[]) { + // if trigger is not a test file, remove all non-continious files from this.changedTests + const triggerFile = files[0] + const isTestFileTrigger = this.changedTests.has(triggerFile) + + // no continuous files for this Vitest instance, just collect tests + if (!state.enabled) { + // do not run any tests if continuous run was not enabled + if (!isTestFileTrigger) { + this.changedTests.clear() + this.invalidates.clear() + } + + ctx.configOverride.testNamePattern = new RegExp(Vitest.COLLECT_NAME_PATTERN) + return await originalScheduleRerun.call(this, files) + } + + state.rerunTriggered = true + + ctx.configOverride.testNamePattern = state.testNamePattern ? new RegExp(state.testNamePattern) : undefined + if (state.watchEveryFile) + return await originalScheduleRerun.call(this, files) + + if (!isTestFileTrigger) { + // if souce code is changed and related tests are not continious, remove them from changedTests + const updatedTests = new Set() + for (const file of this.changedTests) { + if (state.files.includes(file)) + updatedTests.add(file) + } + this.changedTests = updatedTests + } + + return await originalScheduleRerun.call(this, files) + } + } + + markRerun(rerun: boolean) { + this.rerunTriggered = rerun + } + + trackTests(files: string[], testNamePatern: string | undefined) { + this.enabled = true + this.files = files + this.watchEveryFile = false + this.testNamePattern = testNamePatern + this.rerunTriggered = false + } + + trackEveryFile() { + this.enabled = true + this.watchEveryFile = true + this.files = [] + this.testNamePattern = undefined + this.rerunTriggered = false + } + + stopTracking() { + this.enabled = false + } +} diff --git a/src/worker/worker.ts b/src/worker/worker.ts index b66a27d..a8165d4 100644 --- a/src/worker/worker.ts +++ b/src/worker/worker.ts @@ -5,11 +5,12 @@ import { setupFilePath } from '../constants' import { createWorkerRPC } from './rpc' import type { WorkerMeta, WorkerRunnerOptions } from './types' import { VSCodeReporter } from './reporter' +import { Vitest } from './vitest' async function initVitest(meta: WorkerMeta) { - const vitestMode = await import(meta.vitestNodePath) as typeof import('vitest/node') - const reporter = new VSCodeReporter() - const vitest = await vitestMode.createVitest( + const vitestModule = await import(meta.vitestNodePath) as typeof import('vitest/node') + const reporter = new VSCodeReporter(meta) + const vitest = await vitestModule.createVitest( 'test', { config: meta.configFile, @@ -44,7 +45,7 @@ async function initVitest(meta: WorkerMeta) { ], }, ) - reporter.initVitest(vitest, meta.id) + reporter.init(vitest) return { vitest, reporter, @@ -81,7 +82,9 @@ process.on('message', async function init(message: any) { return } - const vitestById = Object.fromEntries(vitest.map(v => [v.meta.id, v.vitest])) + const vitestById = Object.fromEntries(vitest.map(v => + [v.meta.id, new Vitest(v.meta.id, v.vitest)], + )) const rpc = createWorkerRPC(vitestById, { on(listener) { process.on('message', listener)