|
1 | 1 | import { spawn } from 'node:child_process'; |
2 | | -import { createServer } from 'node:http'; |
3 | 2 | import path, { dirname } from 'node:path'; |
4 | | -import getPort from 'get-port'; |
| 3 | +import { createBirpc } from 'birpc'; |
5 | 4 | import vscode from 'vscode'; |
6 | | -import type { WebSocket } from 'ws'; |
7 | | -import { WebSocketServer } from 'ws'; |
8 | 5 | import { getConfigValue } from './config'; |
9 | 6 | import { logger } from './logger'; |
10 | | -import type { |
11 | | - WorkerEvent, |
12 | | - WorkerEventFinish, |
13 | | - WorkerRunTestData, |
14 | | -} from './types'; |
| 7 | +import type { LogLevel } from './shared/logger'; |
| 8 | +import type { WorkerRunTestData } from './types'; |
| 9 | +import { promiseWithTimeout } from './utils'; |
| 10 | +import type { Worker } from './worker'; |
15 | 11 |
|
16 | 12 | export class RstestApi { |
17 | | - public ws: WebSocket | null = null; |
18 | | - private testPromises: Map< |
19 | | - string, |
20 | | - { resolve: (value: any) => void; reject: (reason?: any) => void } |
21 | | - > = new Map(); |
| 13 | + public worker: Pick<Worker, 'initRstest' | 'runTest'> | null = null; |
22 | 14 | private versionMismatchWarned = false; |
23 | 15 |
|
24 | 16 | public resolveRstestPath(): { cwd: string; rstestPath: string }[] { |
@@ -116,140 +108,87 @@ export class RstestApi { |
116 | 108 | } |
117 | 109 |
|
118 | 110 | public async runTest(item: vscode.TestItem) { |
119 | | - if (this.ws) { |
| 111 | + if (this.worker) { |
120 | 112 | const data: WorkerRunTestData = { |
121 | | - type: 'runTest', |
122 | 113 | id: item.id, |
123 | 114 | fileFilters: [item.uri!.fsPath], |
124 | 115 | testNamePattern: item.label, |
125 | 116 | }; |
126 | 117 |
|
127 | | - // Create a promise that will be resolved when we get a response with the matching ID |
128 | | - const promise = new Promise<any>((resolve, reject) => { |
129 | | - this.testPromises.set(item.id, { resolve, reject }); |
130 | | - |
131 | | - // Set a timeout to prevent hanging indefinitely |
132 | | - setTimeout(() => { |
133 | | - const promiseObj = this.testPromises.get(item.id); |
134 | | - if (promiseObj) { |
135 | | - this.testPromises.delete(item.id); |
136 | | - reject(new Error(`Test execution timed out for ${item.label}`)); |
137 | | - } |
138 | | - }, 10000); // 10 seconds timeout |
139 | | - }); |
140 | | - |
141 | | - this.ws.send(JSON.stringify(data)); |
142 | | - return promise; |
| 118 | + return promiseWithTimeout( |
| 119 | + this.worker.runTest(data), |
| 120 | + 10_000, |
| 121 | + new Error(`Test execution timed out for ${item.label}`), |
| 122 | + ); // 10 seconds timeout |
143 | 123 | } |
144 | 124 | } |
145 | 125 |
|
146 | 126 | public async runFileTests(fileItem: vscode.TestItem) { |
147 | | - if (this.ws) { |
| 127 | + if (this.worker) { |
148 | 128 | const fileId = `file_${fileItem.id}`; |
149 | 129 | const data: WorkerRunTestData = { |
150 | | - type: 'runTest', |
151 | 130 | id: fileId, |
152 | 131 | fileFilters: [fileItem.uri!.fsPath], |
153 | 132 | testNamePattern: '', // Empty pattern to run all tests in the file |
154 | 133 | }; |
155 | 134 |
|
156 | | - // Create a promise that will be resolved when we get a response with the matching ID |
157 | | - const promise = new Promise<WorkerEventFinish>((resolve, reject) => { |
158 | | - this.testPromises.set(fileId, { resolve, reject }); |
159 | | - |
160 | | - // Set a timeout to prevent hanging indefinitely |
161 | | - setTimeout(() => { |
162 | | - const promiseObj = this.testPromises.get(fileId); |
163 | | - if (promiseObj) { |
164 | | - this.testPromises.delete(fileId); |
165 | | - reject( |
166 | | - new Error( |
167 | | - `File test execution timed out for ${fileItem.uri!.fsPath}`, |
168 | | - ), |
169 | | - ); |
170 | | - } |
171 | | - }, 30000); // 30 seconds timeout for file-level tests |
172 | | - }); |
173 | | - |
174 | | - this.ws.send(JSON.stringify(data)); |
175 | | - return promise; |
| 135 | + return promiseWithTimeout( |
| 136 | + this.worker.runTest(data), |
| 137 | + 30_000, |
| 138 | + new Error(`File test execution timed out for ${fileItem.uri!.fsPath}`), |
| 139 | + ); // 30 seconds timeout for file-level tests |
176 | 140 | } |
177 | 141 | } |
178 | 142 |
|
179 | 143 | public async createChildProcess() { |
| 144 | + const { cwd, rstestPath } = this.resolveRstestPath()[0]; |
| 145 | + if (!cwd || !rstestPath) { |
| 146 | + logger.error('Failed to resolve rstest path or cwd'); |
| 147 | + return; |
| 148 | + } |
| 149 | + |
180 | 150 | const execArgv: string[] = []; |
181 | 151 | const workerPath = path.resolve(__dirname, 'worker.js'); |
182 | | - const port = await getPort(); |
183 | | - const wsAddress = `ws://localhost:${port}`; |
184 | 152 | logger.debug('Spawning worker process', { |
185 | 153 | workerPath, |
186 | | - wsAddress, |
187 | 154 | }); |
188 | 155 | const rstestProcess = spawn('node', [...execArgv, workerPath], { |
189 | | - stdio: 'pipe', |
| 156 | + cwd, |
| 157 | + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], |
| 158 | + serialization: 'advanced', |
190 | 159 | env: { |
191 | 160 | ...process.env, |
192 | 161 | TEST: 'true', |
193 | | - RSTEST_WS_ADDRESS: wsAddress, |
194 | 162 | }, |
195 | 163 | }); |
196 | 164 |
|
197 | 165 | rstestProcess.stdout?.on('data', (d) => { |
198 | 166 | const content = d.toString(); |
199 | | - logger.debug('worker stdout', content.trimEnd()); |
| 167 | + logger.debug('[worker stdout]', content.trimEnd()); |
200 | 168 | }); |
201 | 169 |
|
202 | 170 | rstestProcess.stderr?.on('data', (d) => { |
203 | 171 | const content = d.toString(); |
204 | | - logger.error('worker stderr', content.trimEnd()); |
| 172 | + logger.error('[worker stderr]', content.trimEnd()); |
205 | 173 | }); |
206 | 174 |
|
207 | | - const server = createServer().listen(port).unref(); |
208 | | - const wss = new WebSocketServer({ server }); |
209 | | - |
210 | | - wss.once('connection', (ws) => { |
211 | | - this.ws = ws; |
212 | | - logger.debug('Worker connected', { wsAddress }); |
213 | | - const { cwd, rstestPath } = this.resolveRstestPath()[0]; |
214 | | - if (!cwd || !rstestPath) { |
215 | | - logger.error('Failed to resolve rstest path or cwd'); |
216 | | - return; |
217 | | - } |
218 | | - |
219 | | - ws.send( |
220 | | - JSON.stringify({ |
221 | | - type: 'init', |
222 | | - rstestPath, |
223 | | - cwd, |
224 | | - }), |
225 | | - ); |
226 | | - logger.debug('Sent init payload to worker', { cwd, rstestPath }); |
227 | | - |
228 | | - ws.on('message', (_data) => { |
229 | | - const _message = JSON.parse(_data.toString()) as WorkerEvent; |
230 | | - if (_message.type === 'finish') { |
231 | | - const message: WorkerEventFinish = _message; |
232 | | - logger.debug('Received worker completion event', { |
233 | | - id: message.id, |
234 | | - testResult: message.testResults, |
235 | | - testFileResult: message.testFileResults, |
236 | | - }); |
237 | | - // Check if we have a pending promise for this test ID |
238 | | - const promiseObj = this.testPromises.get(message.id); |
239 | | - if (promiseObj) { |
240 | | - // Resolve the promise with the message data |
241 | | - promiseObj.resolve(message); |
242 | | - // Remove the promise from the map |
243 | | - this.testPromises.delete(message.id); |
244 | | - } |
245 | | - } |
246 | | - }); |
| 175 | + this.worker = createBirpc<Worker, RstestApi>(this, { |
| 176 | + post: (data) => rstestProcess.send(data), |
| 177 | + on: (fn) => rstestProcess.on('message', fn), |
| 178 | + bind: 'functions', |
247 | 179 | }); |
248 | 180 |
|
| 181 | + await this.worker.initRstest({ cwd, rstestPath }); |
| 182 | + logger.debug('Sent init payload to worker', { cwd, rstestPath }); |
| 183 | + |
249 | 184 | rstestProcess.on('exit', (code, signal) => { |
250 | 185 | logger.debug('Worker process exited', { code, signal }); |
251 | 186 | }); |
252 | 187 | } |
253 | 188 |
|
254 | 189 | public async createRstestWorker() {} |
| 190 | + |
| 191 | + async log(level: LogLevel, message: string) { |
| 192 | + logger[level](message); |
| 193 | + } |
255 | 194 | } |
0 commit comments