Skip to content

Commit 4793fec

Browse files
authored
feat: allow custom shell via vitest.shellType option (#490)
* feat: allow custom shell via `vitest.shellType` option * feat: add "Show Shell Terminal" command * feat: add vitest.nodeExecArgs to debugger
1 parent 1933801 commit 4793fec

File tree

17 files changed

+623
-394
lines changed

17 files changed

+623
-394
lines changed

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,19 @@ These options are resolved relative to the [workspace file](https://code.visuals
7272

7373
- `vitest.rootConfig`: The path to your root config file. If you have several Vitest configs, consider using a [Vitest workspace](https://vitest.dev/guide/workspace).
7474
- `vitest.workspaceConfig`: The path to the [Vitest workspace](https://vitest.dev/guide/workspace) config file. You can only have a single workspace config per VSCode workspace.
75-
- `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/**,**/.*/**,*.d.ts}`If the extension cannot find Vitest, please open an issue.
76-
- `vitest.nodeExecutable`: This extension spawns another process and will use this value as `execPath` argument.
75+
- `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/**,**/.*/**,*.d.ts}`. If the extension cannot find Vitest, please open an issue.
76+
- `vitest.shellType`: The method the extension uses to spawn a long-running Vitest process. This is particularly useful if you are using a custom shell script to set up the environment. When using the `terminal` shell type, the websocket connection will be established. Can either be `terminal` or `child_process`. Default: `child process`.
77+
- `vitest.nodeExecutable`: The path to the Node.js executable. If not assigned, tries to find Node.js path via a PATH variable or a `which` command. This is applied only when `vitest.shellType` is `child_process` (the default).
78+
- `vitest.nodeExecArgs`: The arguments to pass to the Node.js executable. This is applied only when `vitest.shellType` is `child_process` (the default).
79+
- `vitest.terminalShellPath`: The path to the shell executable. This is applied only when `vitest.shellType` is `terminal`.
80+
- `vitest.terminalShellArgs`: The arguments to pass to the shell executable. This is applied only when `vitest.shellType` is `terminal`.
7781
- `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.
7882
- `vitest.debuggerAddress`: TCP/IP address of process to be debugged. Default: localhost
7983

84+
> [!NOTE]
85+
> The `vitest.nodeExecutable` and `vitest.nodeExecArgs` settings are used as `execPath` and `execArgv` when spawning a new `child_process`, and as `runtimeExecutable` and `runtimeArgs` when [debugging a test](https://github.com/microsoft/vscode-js-debug/blob/main/OPTIONS.md).
86+
> The `vitest.terminalShellPath` and `vitest.terminalShellArgs` settings are used as `shellPath` and `shellArgs` when creating a new [terminal](https://code.visualstudio.com/api/references/vscode-api#Terminal)
87+
8088
### Other Options
8189

8290
- `vitest.filesWatcherInclude`: Glob pattern for the watcher that triggers a test rerun or collects changes. Default: `**/*`
@@ -104,3 +112,7 @@ You can change the behaviour of testing view by modifying `testing.openTesting`
104112
- `openExplorerOnTestStart` will open the test tree view when tests starts
105113

106114
This is a vscode's built-in option and will control every plugin.
115+
116+
### I am using `vitest.shellType: terminal`, but I don't see it
117+
118+
The extension uses a modified Vitest script that removes the reporter output. For this reason, the terminal is hidden by default. However, it might be useful to debug issues with the extension or Vitest itself - to open the terminal in the "Terminals" view you can use the "Vitest: Show Shell Terminal" command.

package.json

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@
7070
"title": "Show Output Channel",
7171
"command": "vitest.openOutput",
7272
"category": "Vitest"
73+
},
74+
{
75+
"title": "Show Shell Terminal",
76+
"command": "vitest.showShellTerminal",
77+
"category": "Vitest"
7378
}
7479
],
7580
"menus": {
@@ -107,10 +112,15 @@
107112
"scope": "resource"
108113
},
109114
"vitest.nodeExecutable": {
110-
"description": "The path to the Node.js executable. If not assigned, uses VSCode's Node.js instance.",
115+
"markdownDescription": "The path to the Node.js executable. If not assigned, tries to find Node.js path via a PATH variable or a `which` command. This is applied only when `vitest.shellType` is `child_process` (the default).",
111116
"type": "string",
112117
"scope": "window"
113118
},
119+
"vitest.nodeExecArgs": {
120+
"description": "The arguments to pass to the Node.js executable. This is applied only when `vitest.shellType` is `child_process` (the default).",
121+
"type": "array",
122+
"scope": "resource"
123+
},
114124
"vitest.workspaceConfig": {
115125
"markdownDescription": "The path to the Vitest [workspace configuration file](https://vitest.dev/guide/workspace). The extension supports only a single file per VSCode workspace.",
116126
"type": "string",
@@ -177,6 +187,26 @@
177187
],
178188
"default": "info",
179189
"scope": "resource"
190+
},
191+
"vitest.shellType": {
192+
"markdownDescription": "The method the extension uses to spawn a long-running Vitest process. This is particularly useful if you are using a custom shell script to set up the environment. When using the `terminal` shell type, the websocket connection will be established.",
193+
"type": "string",
194+
"enum": [
195+
"terminal",
196+
"child_process"
197+
],
198+
"default": "child_process",
199+
"scope": "resource"
200+
},
201+
"vitest.terminalShellPath": {
202+
"markdownDescription": "The path to the shell executable. This is applied only when `vitest.shellType` is `terminal`.",
203+
"type": "string",
204+
"scope": "resource"
205+
},
206+
"vitest.terminalShellArgs": {
207+
"markdownDescription": "The arguments to pass to the shell executable. This is applied only when `vitest.shellType` is `terminal`.",
208+
"type": "array",
209+
"scope": "resource"
180210
}
181211
}
182212
}

src/api.ts

Lines changed: 45 additions & 207 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,13 @@
1-
import type { ChildProcess } from 'node:child_process'
2-
import { fork } from 'node:child_process'
3-
import { pathToFileURL } from 'node:url'
4-
import { gte } from 'semver'
5-
import { dirname, normalize, relative } from 'pathe'
1+
import { normalize, relative } from 'pathe'
62
import * as vscode from 'vscode'
73
import { log } from './log'
8-
import { minimumNodeVersion, workerPath } from './constants'
9-
import { getConfig } from './config'
104
import type { VitestEvents, VitestRPC } from './api/rpc'
11-
import { createVitestRpc } from './api/rpc'
12-
import type { WorkerEvent, WorkerRunnerOptions } from './worker/types'
135
import type { VitestPackage } from './api/pkg'
14-
import { findNode, getNodeJsVersion, showVitestError } from './utils'
15-
import type { VitestProcess } from './process'
16-
17-
export class VitestReporter {
18-
constructor(
19-
public readonly id: string,
20-
protected handlers: ResolvedMeta['handlers'],
21-
) {}
22-
23-
onConsoleLog = this.createHandler('onConsoleLog')
24-
onTaskUpdate = this.createHandler('onTaskUpdate')
25-
onFinished = this.createHandler('onFinished')
26-
onCollected = this.createHandler('onCollected')
27-
onWatcherStart = this.createHandler('onWatcherStart')
28-
onWatcherRerun = this.createHandler('onWatcherRerun')
29-
30-
clearListeners(name?: Exclude<keyof ResolvedMeta['handlers'], 'clearListeners' | 'removeListener'>) {
31-
if (name)
32-
this.handlers.removeListener(name, this.handlers[name])
33-
34-
this.handlers.clearListeners()
35-
}
36-
37-
private createHandler<K extends Exclude<keyof ResolvedMeta['handlers'], 'clearListeners' | 'removeListener'>>(name: K) {
38-
return (callback: VitestEvents[K]) => {
39-
this.handlers[name](callback as any)
40-
}
41-
}
42-
}
6+
import { showVitestError } from './utils'
7+
import type { VitestProcess } from './api/types'
8+
import { createVitestTerminalProcess } from './api/terminal'
9+
import { getConfig } from './config'
10+
import { createVitestProcess } from './api/child_process'
4311

4412
export class VitestAPI {
4513
private disposing = false
@@ -97,17 +65,21 @@ export class VitestAPI {
9765
}
9866
}
9967

100-
export class VitestFolderAPI extends VitestReporter {
68+
export class VitestFolderAPI {
69+
readonly id: string
10170
readonly tag: vscode.TestTag
10271
readonly workspaceFolder: vscode.WorkspaceFolder
10372

73+
private handlers: ResolvedMeta['handlers']
74+
10475
constructor(
10576
private pkg: VitestPackage,
10677
private meta: ResolvedMeta,
10778
) {
10879
const normalizedId = normalize(pkg.id)
109-
super(normalizedId, meta.handlers)
80+
this.id = normalizedId
11081
this.workspaceFolder = pkg.folder
82+
this.handlers = meta.handlers
11183
this.tag = new vscode.TestTag(pkg.prefix)
11284
}
11385

@@ -177,14 +149,17 @@ export class VitestFolderAPI extends VitestReporter {
177149
catch (err) {
178150
log.error('[API]', 'Failed to close Vitest RPC', err)
179151
}
180-
const promise = new Promise<void>((resolve) => {
181-
this.meta.process.once('exit', () => resolve())
152+
const promise = new Promise<void>((resolve, reject) => {
153+
const timer = setTimeout(() => {
154+
reject(new Error('Vitest process did not exit in time'))
155+
}, 5_000)
156+
this.meta.process.once('exit', () => {
157+
resolve()
158+
clearTimeout(timer)
159+
})
182160
})
183161
this.meta.process.close()
184-
await Promise.all([
185-
promise,
186-
AbortSignal.timeout(5000),
187-
]).catch((err) => {
162+
await promise.catch((err) => {
188163
log.error('[API]', 'Failed to close Vitest process', err)
189164
})
190165
}
@@ -213,6 +188,26 @@ export class VitestFolderAPI extends VitestReporter {
213188
async unwatchTests() {
214189
await this.meta.rpc.unwatchTests()
215190
}
191+
192+
onConsoleLog = this.createHandler('onConsoleLog')
193+
onTaskUpdate = this.createHandler('onTaskUpdate')
194+
onFinished = this.createHandler('onFinished')
195+
onCollected = this.createHandler('onCollected')
196+
onWatcherStart = this.createHandler('onWatcherStart')
197+
onWatcherRerun = this.createHandler('onWatcherRerun')
198+
199+
clearListeners(name?: Exclude<keyof ResolvedMeta['handlers'], 'clearListeners' | 'removeListener'>) {
200+
if (name)
201+
this.handlers.removeListener(name, this.handlers[name])
202+
203+
this.handlers.clearListeners()
204+
}
205+
206+
private createHandler<K extends Exclude<keyof ResolvedMeta['handlers'], 'clearListeners' | 'removeListener'>>(name: K) {
207+
return (callback: VitestEvents[K]) => {
208+
this.handlers[name](callback as any)
209+
}
210+
}
216211
}
217212

218213
function createQueuedHandler<T>(resolver: (value: T[]) => Promise<void>) {
@@ -239,7 +234,10 @@ function createQueuedHandler<T>(resolver: (value: T[]) => Promise<void>) {
239234

240235
export async function resolveVitestAPI(packages: VitestPackage[]) {
241236
const promises = packages.map(async (pkg) => {
242-
const vitest = await createVitestProcess(pkg)
237+
const config = getConfig(pkg.folder)
238+
const vitest = config.shellType === 'terminal'
239+
? await createVitestTerminalProcess(pkg)
240+
: await createVitestProcess(pkg)
243241
return new VitestFolderAPI(pkg, vitest)
244242
})
245243
const apis = await Promise.all(promises)
@@ -261,163 +259,3 @@ export interface ResolvedMeta {
261259
removeListener: (name: string, listener: any) => void
262260
}
263261
}
264-
265-
function formapPkg(pkg: VitestPackage) {
266-
return `Vitest v${pkg.version} (${relative(dirname(pkg.cwd), pkg.id)})`
267-
}
268-
269-
async function createChildVitestProcess(pkg: VitestPackage) {
270-
const pnpLoader = pkg.loader
271-
const pnp = pkg.pnp
272-
if (pnpLoader && !pnp)
273-
throw new Error('pnp file is required if loader option is used')
274-
const env = getConfig().env || {}
275-
const execPath = await findNode(vscode.workspace.workspaceFile?.fsPath || pkg.cwd)
276-
const execVersion = await getNodeJsVersion(execPath)
277-
if (execVersion && !gte(execVersion, minimumNodeVersion)) {
278-
const errorMsg = `Node.js version ${execVersion} is not supported. Minimum required version is ${minimumNodeVersion}`
279-
log.error('[API]', errorMsg)
280-
throw new Error(errorMsg)
281-
}
282-
const execArgv = pnpLoader && pnp // && !gte(execVersion, '18.19.0')
283-
? [
284-
'--require',
285-
pnp,
286-
'--experimental-loader',
287-
pathToFileURL(pnpLoader).toString(),
288-
]
289-
: undefined
290-
log.info('[API]', `Running ${formapPkg(pkg)} with Node.js@${execVersion}: ${execPath} ${execArgv ? execArgv.join(' ') : ''}`)
291-
const logLevel = getConfig(pkg.folder).logLevel
292-
const vitest = fork(
293-
// to support pnp, we need to spawn `yarn node` instead of `node`
294-
workerPath,
295-
{
296-
execPath,
297-
execArgv,
298-
env: {
299-
...process.env,
300-
...env,
301-
VITEST_VSCODE_LOG: env.VITEST_VSCODE_LOG ?? process.env.VITEST_VSCODE_LOG ?? logLevel,
302-
VITEST_VSCODE: 'true',
303-
// same env var as `startVitest`
304-
// https://github.com/vitest-dev/vitest/blob/5c7e9ca05491aeda225ce4616f06eefcd068c0b4/packages/vitest/src/node/cli/cli-api.ts
305-
TEST: 'true',
306-
VITEST: 'true',
307-
NODE_ENV: env.NODE_ENV ?? process.env.NODE_ENV ?? 'test',
308-
},
309-
stdio: 'overlapped',
310-
cwd: pnp ? dirname(pnp) : pkg.cwd,
311-
},
312-
)
313-
314-
vitest.stdout?.on('data', d => log.worker('info', d.toString()))
315-
vitest.stderr?.on('data', (chunk) => {
316-
const string = chunk.toString()
317-
log.worker('error', string)
318-
if (string.startsWith(' MISSING DEPENDENCY')) {
319-
const error = string.split(/\r?\n/, 1)[0].slice(' MISSING DEPENDENCY'.length)
320-
showVitestError(error)
321-
}
322-
})
323-
324-
return new Promise<ChildProcess>((resolve, reject) => {
325-
function onMessage(message: WorkerEvent) {
326-
if (message.type === 'debug')
327-
log.worker('info', ...message.args)
328-
329-
if (message.type === 'ready') {
330-
resolve(vitest)
331-
}
332-
if (message.type === 'error') {
333-
const error = new Error(`Vitest failed to start: \n${message.error}`)
334-
reject(error)
335-
}
336-
vitest.off('error', onError)
337-
vitest.off('message', onMessage)
338-
vitest.off('exit', onExit)
339-
}
340-
341-
function onError(err: Error) {
342-
log.error('[API]', err)
343-
reject(err)
344-
vitest.off('error', onError)
345-
vitest.off('message', onMessage)
346-
vitest.off('exit', onExit)
347-
}
348-
349-
function onExit(code: number) {
350-
reject(new Error(`Vitest process exited with code ${code}`))
351-
}
352-
353-
vitest.on('error', onError)
354-
vitest.on('message', onMessage)
355-
vitest.on('exit', onExit)
356-
vitest.once('spawn', () => {
357-
const runnerOptions: WorkerRunnerOptions = {
358-
type: 'init',
359-
meta: {
360-
vitestNodePath: pkg.vitestNodePath,
361-
env: getConfig(pkg.folder).env || undefined,
362-
configFile: pkg.configFile,
363-
cwd: pkg.cwd,
364-
arguments: pkg.arguments,
365-
workspaceFile: pkg.workspaceFile,
366-
id: pkg.id,
367-
pnpApi: pnp,
368-
pnpLoader: pnpLoader // && gte(execVersion, '18.19.0')
369-
? pathToFileURL(pnpLoader).toString()
370-
: undefined,
371-
},
372-
}
373-
374-
vitest.send(runnerOptions)
375-
})
376-
})
377-
}
378-
379-
export async function createVitestProcess(pkg: VitestPackage): Promise<ResolvedMeta> {
380-
const vitest = await createChildVitestProcess(pkg)
381-
382-
log.info('[API]', `${formapPkg(pkg)} process ${vitest.pid} created`)
383-
384-
const { handlers, api } = createVitestRpc({
385-
on: listener => vitest.on('message', listener),
386-
send: message => vitest.send(message),
387-
})
388-
389-
return {
390-
rpc: api,
391-
process: new VitestChildProcess(vitest),
392-
handlers,
393-
pkg,
394-
}
395-
}
396-
397-
class VitestChildProcess implements VitestProcess {
398-
constructor(private child: ChildProcess) {}
399-
400-
get id() {
401-
return this.child.pid ?? 0
402-
}
403-
404-
get closed() {
405-
return this.child.killed
406-
}
407-
408-
on(event: string, listener: (...args: any[]) => void) {
409-
this.child.on(event, listener)
410-
}
411-
412-
once(event: string, listener: (...args: any[]) => void) {
413-
this.child.once(event, listener)
414-
}
415-
416-
off(event: string, listener: (...args: any[]) => void) {
417-
this.child.off(event, listener)
418-
}
419-
420-
close() {
421-
this.child.kill()
422-
}
423-
}

0 commit comments

Comments
 (0)