diff --git a/.vscode/launch.json b/.vscode/launch.json index 7f365d0b..b607f255 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,7 +15,8 @@ "${workspaceFolder}/packages/vscode/sample" ], "outFiles": ["${workspaceFolder}/packages/vscode/dist/**/*.js"], - "preLaunchTask": "npm: build:local" + "preLaunchTask": "npm: build:local", + "autoAttachChildProcesses": true } ] } diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 1764449b..02acf60d 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -70,15 +70,13 @@ "@types/mocha": "^10.0.10", "@types/node": "^22.16.5", "@types/vscode": "1.97.0", - "@types/ws": "^8.18.1", "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "3.6.2", - "get-port": "^7.1.0", + "birpc": "2.6.1", "glob": "^7.2.3", "mocha": "^11.7.4", "ovsx": "^0.10.6", - "typescript": "^5.9.3", - "ws": "^8.18.3" + "typescript": "^5.9.3" } } diff --git a/packages/vscode/src/logger.ts b/packages/vscode/src/logger.ts index 10051996..7734599e 100644 --- a/packages/vscode/src/logger.ts +++ b/packages/vscode/src/logger.ts @@ -1,35 +1,16 @@ -import { formatWithOptions } from 'node:util'; import vscode from 'vscode'; +import { BaseLogger, type LogLevel } from './shared/logger'; -function formatValues(values: unknown[]): string { - return formatWithOptions({ depth: 4 }, ...values); -} - -export class Logger implements vscode.Disposable { +export class MasterLogger extends BaseLogger implements vscode.Disposable { readonly #channel: vscode.LogOutputChannel; constructor(private readonly name = 'Rstest') { + super(); this.#channel = vscode.window.createOutputChannel(this.name, { log: true }); } - public trace(...values: unknown[]) { - this.#channel.trace(formatValues(values)); - } - - public debug(...values: unknown[]) { - this.#channel.debug(formatValues(values)); - } - - public info(...values: unknown[]) { - this.#channel.info(formatValues(values)); - } - - public warn(...values: unknown[]) { - this.#channel.warn(formatValues(values)); - } - - public error(...values: unknown[]) { - this.#channel.error(formatValues(values)); + override log(level: LogLevel, message: string) { + this.#channel[level](message); } public dispose() { @@ -37,4 +18,4 @@ export class Logger implements vscode.Disposable { } } -export const logger = new Logger(); +export const logger = new MasterLogger(); diff --git a/packages/vscode/src/master.ts b/packages/vscode/src/master.ts index c1372204..31bda8e9 100644 --- a/packages/vscode/src/master.ts +++ b/packages/vscode/src/master.ts @@ -1,24 +1,16 @@ import { spawn } from 'node:child_process'; -import { createServer } from 'node:http'; import path, { dirname } from 'node:path'; -import getPort from 'get-port'; +import { createBirpc } from 'birpc'; import vscode from 'vscode'; -import type { WebSocket } from 'ws'; -import { WebSocketServer } from 'ws'; import { getConfigValue } from './config'; import { logger } from './logger'; -import type { - WorkerEvent, - WorkerEventFinish, - WorkerRunTestData, -} from './types'; +import type { LogLevel } from './shared/logger'; +import type { WorkerRunTestData } from './types'; +import { promiseWithTimeout } from './utils'; +import type { Worker } from './worker'; export class RstestApi { - public ws: WebSocket | null = null; - private testPromises: Map< - string, - { resolve: (value: any) => void; reject: (reason?: any) => void } - > = new Map(); + public worker: Pick | null = null; private versionMismatchWarned = false; public resolveRstestPath(): { cwd: string; rstestPath: string }[] { @@ -116,140 +108,87 @@ export class RstestApi { } public async runTest(item: vscode.TestItem) { - if (this.ws) { + if (this.worker) { const data: WorkerRunTestData = { - type: 'runTest', id: item.id, fileFilters: [item.uri!.fsPath], testNamePattern: item.label, }; - // Create a promise that will be resolved when we get a response with the matching ID - const promise = new Promise((resolve, reject) => { - this.testPromises.set(item.id, { resolve, reject }); - - // Set a timeout to prevent hanging indefinitely - setTimeout(() => { - const promiseObj = this.testPromises.get(item.id); - if (promiseObj) { - this.testPromises.delete(item.id); - reject(new Error(`Test execution timed out for ${item.label}`)); - } - }, 10000); // 10 seconds timeout - }); - - this.ws.send(JSON.stringify(data)); - return promise; + return promiseWithTimeout( + this.worker.runTest(data), + 10_000, + new Error(`Test execution timed out for ${item.label}`), + ); // 10 seconds timeout } } public async runFileTests(fileItem: vscode.TestItem) { - if (this.ws) { + if (this.worker) { const fileId = `file_${fileItem.id}`; const data: WorkerRunTestData = { - type: 'runTest', id: fileId, fileFilters: [fileItem.uri!.fsPath], testNamePattern: '', // Empty pattern to run all tests in the file }; - // Create a promise that will be resolved when we get a response with the matching ID - const promise = new Promise((resolve, reject) => { - this.testPromises.set(fileId, { resolve, reject }); - - // Set a timeout to prevent hanging indefinitely - setTimeout(() => { - const promiseObj = this.testPromises.get(fileId); - if (promiseObj) { - this.testPromises.delete(fileId); - reject( - new Error( - `File test execution timed out for ${fileItem.uri!.fsPath}`, - ), - ); - } - }, 30000); // 30 seconds timeout for file-level tests - }); - - this.ws.send(JSON.stringify(data)); - return promise; + return promiseWithTimeout( + this.worker.runTest(data), + 30_000, + new Error(`File test execution timed out for ${fileItem.uri!.fsPath}`), + ); // 30 seconds timeout for file-level tests } } public async createChildProcess() { + const { cwd, rstestPath } = this.resolveRstestPath()[0]; + if (!cwd || !rstestPath) { + logger.error('Failed to resolve rstest path or cwd'); + return; + } + const execArgv: string[] = []; const workerPath = path.resolve(__dirname, 'worker.js'); - const port = await getPort(); - const wsAddress = `ws://localhost:${port}`; logger.debug('Spawning worker process', { workerPath, - wsAddress, }); const rstestProcess = spawn('node', [...execArgv, workerPath], { - stdio: 'pipe', + cwd, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + serialization: 'advanced', env: { ...process.env, TEST: 'true', - RSTEST_WS_ADDRESS: wsAddress, }, }); rstestProcess.stdout?.on('data', (d) => { const content = d.toString(); - logger.debug('worker stdout', content.trimEnd()); + logger.debug('[worker stdout]', content.trimEnd()); }); rstestProcess.stderr?.on('data', (d) => { const content = d.toString(); - logger.error('worker stderr', content.trimEnd()); + logger.error('[worker stderr]', content.trimEnd()); }); - const server = createServer().listen(port).unref(); - const wss = new WebSocketServer({ server }); - - wss.once('connection', (ws) => { - this.ws = ws; - logger.debug('Worker connected', { wsAddress }); - const { cwd, rstestPath } = this.resolveRstestPath()[0]; - if (!cwd || !rstestPath) { - logger.error('Failed to resolve rstest path or cwd'); - return; - } - - ws.send( - JSON.stringify({ - type: 'init', - rstestPath, - cwd, - }), - ); - logger.debug('Sent init payload to worker', { cwd, rstestPath }); - - ws.on('message', (_data) => { - const _message = JSON.parse(_data.toString()) as WorkerEvent; - if (_message.type === 'finish') { - const message: WorkerEventFinish = _message; - logger.debug('Received worker completion event', { - id: message.id, - testResult: message.testResults, - testFileResult: message.testFileResults, - }); - // Check if we have a pending promise for this test ID - const promiseObj = this.testPromises.get(message.id); - if (promiseObj) { - // Resolve the promise with the message data - promiseObj.resolve(message); - // Remove the promise from the map - this.testPromises.delete(message.id); - } - } - }); + this.worker = createBirpc(this, { + post: (data) => rstestProcess.send(data), + on: (fn) => rstestProcess.on('message', fn), + bind: 'functions', }); + await this.worker.initRstest({ cwd, rstestPath }); + logger.debug('Sent init payload to worker', { cwd, rstestPath }); + rstestProcess.on('exit', (code, signal) => { logger.debug('Worker process exited', { code, signal }); }); } public async createRstestWorker() {} + + async log(level: LogLevel, message: string) { + logger[level](message); + } } diff --git a/packages/vscode/src/shared/logger.ts b/packages/vscode/src/shared/logger.ts new file mode 100644 index 00000000..71ae8936 --- /dev/null +++ b/packages/vscode/src/shared/logger.ts @@ -0,0 +1,39 @@ +import { formatWithOptions } from 'node:util'; + +export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; + +export type Logger = { + [K in LogLevel]: (...params: unknown[]) => void; +}; + +export abstract class BaseLogger implements Logger { + constructor(private prefix?: string) {} + protected log(level: LogLevel, message: string): void { + console[level](message); + } + private logWithFormat(level: LogLevel, params: unknown[]) { + this.log( + level, + formatWithOptions( + { depth: 4 }, + ...(this.prefix ? [`[${this.prefix}]`] : []), + ...params, + ), + ); + } + trace(...params: unknown[]) { + this.logWithFormat('trace', params); + } + debug(...params: unknown[]) { + this.logWithFormat('debug', params); + } + info(...params: unknown[]) { + this.logWithFormat('info', params); + } + warn(...params: unknown[]) { + this.logWithFormat('warn', params); + } + error(...params: unknown[]) { + this.logWithFormat('error', params); + } +} diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index ce5870a2..932a6193 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -2,25 +2,19 @@ import type { TestFileResult, TestResult } from '@rstest/core'; //#region master -> worker export type WorkerInitData = { - type: 'init'; rstestPath: string; cwd: string; }; export type WorkerRunTestData = { id: string; - type: 'runTest'; fileFilters: string[]; testNamePattern: string; }; // #endregion //#region worker -> master -export type WorkerEvent = WorkerEventFinish; - export type WorkerEventFinish = { - type: 'finish'; - id: string; testResults: TestResult[]; testFileResults?: TestFileResult[]; }; diff --git a/packages/vscode/src/utils.ts b/packages/vscode/src/utils.ts index dbfee28f..babc3f71 100644 --- a/packages/vscode/src/utils.ts +++ b/packages/vscode/src/utils.ts @@ -33,3 +33,14 @@ export function getWorkspaceTestPatterns(): WorkspaceTestPattern[] { })); }); } + +export function promiseWithTimeout( + promise: Promise, + timeout: number, + error: Error, +) { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(reject, timeout, error)), + ]); +} diff --git a/packages/vscode/src/worker/index.ts b/packages/vscode/src/worker/index.ts index ef4b92e7..239727ab 100644 --- a/packages/vscode/src/worker/index.ts +++ b/packages/vscode/src/worker/index.ts @@ -1,6 +1,11 @@ import { pathToFileURL } from 'node:url'; -import { WebSocket } from 'ws'; -import type { WorkerInitData, WorkerRunTestData } from '../types'; +import { createBirpc } from 'birpc'; +import type { RstestApi } from '../master'; +import type { + WorkerEventFinish, + WorkerInitData, + WorkerRunTestData, +} from '../types'; import { logger } from './logger'; import { VscodeReporter } from './reporter'; @@ -12,29 +17,18 @@ const normalizeImportPath = (path: string) => { return pathToFileURL(path).toString(); }; -class Worker { - private ws: WebSocket; +export class Worker { public rstestPath!: string; public cwd!: string; - constructor() { - this.ws = new WebSocket(process.env.RSTEST_WS_ADDRESS!); - this.ws.on('message', (bufferData) => { - const _data = JSON.parse(bufferData.toString()); - if (_data.type === 'init') { - const data: WorkerInitData = _data; - this.initRstest(data); - } else if (_data.type === 'runTest') { - const data: WorkerRunTestData = _data; - this.runTest(data); - } - }); - } - public async runTest(data: WorkerRunTestData) { logger.debug('Received runTest request', JSON.stringify(data, null, 2)); + let resolve!: (value: WorkerEventFinish) => void; + const promise = new Promise((res) => { + resolve = res; + }); try { - const rstest = await this.createRstest(data); + const rstest = await this.createRstest(resolve); rstest.context.fileFilters = data.fileFilters; rstest.context.normalizedConfig.testNamePattern = data.testNamePattern; const res = await rstest.runTests(); @@ -44,7 +38,9 @@ class Worker { ); } catch (error) { logger.error('Test run failed', error); + throw error; } + return promise; } public async initRstest(data: WorkerInitData) { @@ -56,7 +52,9 @@ class Worker { }); } - public async createRstest(data: WorkerRunTestData) { + public async createRstest( + onTestRunEndCallback: (data: WorkerEventFinish) => void, + ) { const rstestModule = (await import( normalizeImportPath(this.rstestPath) )) as typeof import('@rstest/core'); @@ -79,20 +77,7 @@ class Worker { { config: { ...config, - reporters: [ - new VscodeReporter({ - onTestRunEndCallback: ({ testFileResults, testResults }) => { - this.ws.send( - JSON.stringify({ - type: 'finish', - id: data.id, - testResults, - testFileResults, - }), - ); - }, - }), - ], + reporters: [new VscodeReporter({ onTestRunEndCallback })], }, configFilePath, projects, @@ -105,6 +90,11 @@ class Worker { } } -(async () => { - const _worker = new Worker(); -})(); +export const masterApi = createBirpc, Worker>( + new Worker(), + { + post: (data) => process.send?.(data), + on: (fn) => process.on('message', fn), + bind: 'functions', + }, +); diff --git a/packages/vscode/src/worker/logger.ts b/packages/vscode/src/worker/logger.ts index d2483df3..fb471065 100644 --- a/packages/vscode/src/worker/logger.ts +++ b/packages/vscode/src/worker/logger.ts @@ -1,30 +1,12 @@ -import { inspect } from 'node:util'; +import { BaseLogger, type LogLevel } from '../shared/logger'; +import { masterApi } from '.'; -function format(values: unknown[]): string { - return values - .map((value) => - typeof value === 'string' - ? value - : inspect(value, { depth: 4, colors: false }), - ) - .join(' '); -} - -class WorkerLogger { - public debug(...values: unknown[]) { - console.log(format(values)); - } - - public info(...values: unknown[]) { - console.log(format(values)); +class WorkerLogger extends BaseLogger { + constructor() { + super('worker'); } - - public warn(...values: unknown[]) { - console.warn(format(values)); - } - - public error(...values: unknown[]) { - console.error(format(values)); + protected log(level: LogLevel, message: string) { + masterApi.log(level, message); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7203507a..26a1d764 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -596,9 +596,6 @@ importers: '@types/vscode': specifier: 1.97.0 version: 1.97.0 - '@types/ws': - specifier: ^8.18.1 - version: 8.18.1 '@vscode/test-cli': specifier: ^0.0.12 version: 0.0.12 @@ -608,9 +605,9 @@ importers: '@vscode/vsce': specifier: 3.6.2 version: 3.6.2 - get-port: - specifier: ^7.1.0 - version: 7.1.0 + birpc: + specifier: 2.6.1 + version: 2.6.1 glob: specifier: ^7.2.3 version: 7.2.3 @@ -623,9 +620,6 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 - ws: - specifier: ^8.18.3 - version: 8.18.3 scripts/tsconfig: {} @@ -2497,9 +2491,6 @@ packages: '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -3658,10 +3649,6 @@ packages: resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} engines: {node: '>=8'} - get-port@7.1.0: - resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} - engines: {node: '>=16'} - get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -8286,10 +8273,6 @@ snapshots: '@types/whatwg-mimetype@3.0.2': {} - '@types/ws@8.18.1': - dependencies: - '@types/node': 22.18.6 - '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -9544,8 +9527,6 @@ snapshots: get-port@5.1.1: {} - get-port@7.1.0: {} - get-proto@1.0.1: dependencies: dunder-proto: 1.0.1