Skip to content

Commit

Permalink
fix: spawn node directly to run tests avoiding validation (#568)
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va authored Jan 9, 2025
1 parent 45d13e9 commit 6d169b6
Show file tree
Hide file tree
Showing 3 changed files with 37 additions and 261 deletions.
175 changes: 36 additions & 139 deletions src/api/child_process.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,22 @@
import { type ChildProcess, fork } from 'node:child_process'
import { spawn } from 'node:child_process'
import { pathToFileURL } from 'node:url'
import * as vscode from 'vscode'
import { gte } from 'semver'
import { findNode, formatPkg, getNodeJsVersion, showVitestError } from '../utils'
import { createServer } from 'node:http'
import getPort from 'get-port'
import { WebSocketServer } from 'ws'
import { formatPkg, showVitestError } from '../utils'
import { log } from '../log'
import type { ResolvedMeta } from '../api'
import type { WorkerEvent, WorkerRunnerOptions } from '../worker/types'
import { getConfig } from '../config'
import { minimumNodeVersion, workerPath } from '../constants'
import { workerPath } from '../constants'
import type { ResolvedMeta } from '../api'
import type { VitestPackage } from './pkg'
import { createVitestRpc } from './rpc'
import type { VitestProcess } from './types'
import { waitForWsResolvedMeta } from './ws'

async function createChildVitestProcess(pkg: VitestPackage) {
export async function createVitestProcess(pkg: VitestPackage) {
const pnpLoader = pkg.loader
const pnp = pkg.pnp
if (pnpLoader && !pnp)
throw new Error('pnp file is required if loader option is used')
const env = getConfig().env || {}
const execPath = await findNode(vscode.workspace.workspaceFile?.fsPath || pkg.cwd)
const execVersion = await getNodeJsVersion(execPath)
if (execVersion && !gte(execVersion, minimumNodeVersion)) {
const errorMsg = `Node.js version ${execVersion} is not supported. Minimum required version is ${minimumNodeVersion}`
log.error('[API]', errorMsg)
throw new Error(errorMsg)
}
const runtimeArgs = getConfig(pkg.folder).nodeExecArgs || []
const execArgv = pnpLoader && pnp // && !gte(execVersion, '18.19.0')
? [
Expand All @@ -35,28 +27,28 @@ async function createChildVitestProcess(pkg: VitestPackage) {
...runtimeArgs,
]
: runtimeArgs
log.info('[API]', `Running ${formatPkg(pkg)} with Node.js@${execVersion}: ${execPath} ${execArgv ? execArgv.join(' ') : ''}`)
const script = `node ${workerPath} ${execArgv.join(' ')}`.trim()
log.info('[API]', `Running ${formatPkg(pkg)} with "${script}"`)
const logLevel = getConfig(pkg.folder).logLevel
const vitest = fork(
workerPath,
{
execPath,
execArgv,
env: {
...process.env,
...env,
VITEST_VSCODE_LOG: env.VITEST_VSCODE_LOG ?? process.env.VITEST_VSCODE_LOG ?? logLevel,
VITEST_VSCODE: 'true',
// same env var as `startVitest`
// https://github.com/vitest-dev/vitest/blob/5c7e9ca05491aeda225ce4616f06eefcd068c0b4/packages/vitest/src/node/cli/cli-api.ts
TEST: 'true',
VITEST: 'true',
NODE_ENV: env.NODE_ENV ?? process.env.NODE_ENV ?? 'test',
},
stdio: 'overlapped',
cwd: pkg.cwd,
const port = await getPort()
const server = createServer().listen(port)
const wss = new WebSocketServer({ server })
const wsAddress = `ws://localhost:${port}`
const vitest = spawn('node', [workerPath, ...execArgv], {
env: {
...process.env,
...env,
VITEST_VSCODE_LOG: env.VITEST_VSCODE_LOG ?? process.env.VITEST_VSCODE_LOG ?? logLevel,
VITEST_VSCODE: 'true',
// same env var as `startVitest`
// https://github.com/vitest-dev/vitest/blob/5c7e9ca05491aeda225ce4616f06eefcd068c0b4/packages/vitest/src/node/cli/cli-api.ts
TEST: 'true',
VITEST_WS_ADDRESS: wsAddress,
VITEST: 'true',
NODE_ENV: env.NODE_ENV ?? process.env.NODE_ENV ?? 'test',
},
)
cwd: pkg.cwd,
})

vitest.stdout?.on('data', d => log.worker('info', d.toString()))
vitest.stderr?.on('data', (chunk) => {
Expand All @@ -68,112 +60,17 @@ async function createChildVitestProcess(pkg: VitestPackage) {
}
})

return new Promise<{ process: ChildProcess; configs: string[] }>((resolve, reject) => {
function onMessage(message: WorkerEvent) {
if (message.type === 'debug')
log.worker('info', ...message.args)

if (message.type === 'ready') {
resolve({ process: vitest, configs: message.configs })
}
if (message.type === 'error') {
const error = new Error(`Vitest failed to start: \n${message.error}`)
reject(error)
}
vitest.off('error', onError)
vitest.off('message', onMessage)
vitest.off('exit', onExit)
}

function onError(err: Error) {
log.error('[API]', err)
reject(err)
vitest.off('error', onError)
vitest.off('message', onMessage)
vitest.off('exit', onExit)
}

function onExit(code: number) {
return new Promise<ResolvedMeta>((resolve, reject) => {
function onExit(code: number | null) {
reject(new Error(`Vitest process exited with code ${code}`))
}

vitest.on('error', onError)
vitest.on('message', onMessage)
vitest.on('exit', onExit)
vitest.once('spawn', () => {
const runnerOptions: WorkerRunnerOptions = {
type: 'init',
meta: {
shellType: 'child_process',
vitestNodePath: pkg.vitestNodePath,
env: getConfig(pkg.folder).env || undefined,
configFile: pkg.configFile,
cwd: pkg.cwd,
arguments: pkg.arguments,
workspaceFile: pkg.workspaceFile,
id: pkg.id,
pnpApi: pnp,
pnpLoader: pnpLoader // && gte(execVersion, '18.19.0')
? pathToFileURL(pnpLoader).toString()
: undefined,
},
debug: false,
astCollect: getConfig(pkg.folder).experimentalStaticAstCollect,
}

vitest.send(runnerOptions)
})
})
}

export async function createVitestProcess(pkg: VitestPackage): Promise<ResolvedMeta> {
const { process: vitest, configs } = await createChildVitestProcess(pkg)

log.info('[API]', `${formatPkg(pkg)} child process ${vitest.pid} created`)

const { handlers, api } = createVitestRpc({
on: listener => vitest.on('message', listener),
send: message => vitest.send(message),
})

vitest.once('exit', () => {
log.verbose?.('[API]', 'Vitest child_process connection closed, cannot call RPC anymore.')
api.$close()
waitForWsResolvedMeta(wss, pkg, true, 'child_process')
.then(resolve, reject)
.finally(() => {
vitest.off('exit', onExit)
})
})

return {
rpc: api,
configs,
process: new VitestChildProcess(vitest),
handlers,
pkg,
}
}

class VitestChildProcess implements VitestProcess {
constructor(private child: ChildProcess) {}

get id() {
return this.child.pid ?? 0
}

get closed() {
return this.child.killed
}

on(event: string, listener: (...args: any[]) => void) {
this.child.on(event, listener)
}

once(event: string, listener: (...args: any[]) => void) {
this.child.once(event, listener)
}

off(event: string, listener: (...args: any[]) => void) {
this.child.off(event, listener)
}

close() {
this.child.kill()
}
}
6 changes: 1 addition & 5 deletions src/debug/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import type { TestTree } from '../testTree'
import { log } from '../log'
import { getConfig } from '../config'
import { TestRunner } from '../runner/runner'
import { findNode } from '../utils'
import { workerPath } from '../constants'
import { waitForWsResolvedMeta } from '../api/ws'

Expand Down Expand Up @@ -144,9 +143,6 @@ async function getRuntimeOptions(pkg: VitestPackage) {
const config = getConfig(pkg.folder)

// if (config.shellType === 'child_process') {
const node = await findNode(
vscode.workspace.workspaceFile?.fsPath || pkg.folder.uri.fsPath,
)
const runtimeArgs = config.nodeExecArgs || []
const pnpLoader = pkg.loader
const pnp = pkg.pnp
Expand All @@ -160,7 +156,7 @@ async function getRuntimeOptions(pkg: VitestPackage) {
]
: runtimeArgs
return {
runtimeExecutable: node,
runtimeExecutable: 'node',
runtimeArgs: execArgv,
}
// }
Expand Down
117 changes: 0 additions & 117 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import fs from 'node:fs'
import { spawn } from 'node:child_process'
import * as vscode from 'vscode'
import which from 'which'
import { dirname, relative } from 'pathe'
import type { VitestPackage } from './api/pkg'
import { log } from './log'
import { getConfig } from './config'

export function noop() {}

Expand Down Expand Up @@ -94,117 +91,3 @@ export function waitUntilExists(file: string, timeoutMs = 5000) {
}, 50)
})
}

let pathToNodeJS: string | undefined

// based on https://github.com/microsoft/playwright-vscode/blob/main/src/utils.ts#L144
export async function findNode(cwd: string): Promise<string> {
if (getConfig().nodeExecutable)
// if empty string, keep as undefined
pathToNodeJS = getConfig().nodeExecutable || undefined

if (pathToNodeJS)
return pathToNodeJS

// Stage 1: Try to find Node.js via process.env.PATH
let node = await which('node').catch(() => undefined)
// Stage 2: When extension host boots, it does not have the right env set, so we might need to wait.
for (let i = 0; i < 5 && !node; ++i) {
await new Promise(f => setTimeout(f, 200))
node = await which('node').catch(() => undefined)
}
// Stage 3: If we still haven't found Node.js, try to find it via a subprocess.
// This evaluates shell rc/profile files and makes nvm work.
node ??= await findNodeViaShell(cwd)

// If volta isn't installed in a Volta folder, this test will fail.
// If anyone got a better idea for checking volta's presence, please let me know.
const voltaRegex = /Volta\\node\.exe/i

// Stage 4: We have found Node.js, but it might be managed by Volta.
// This attempt to ask volta for the path to the node executable.
if (node && voltaRegex.test(node))
node = await findNodeUsingVoltaOnWindows(cwd) ?? node

if (!node) {
const msg = `Unable to find 'node' executable.\nMake sure to have Node.js installed and available in your PATH.\nCurrent PATH: '${process.env.PATH}'.`
log.error(msg)
throw new Error(msg)
}
pathToNodeJS = node
return node
}

function findNodeUsingVoltaOnWindows(cwd: string): Promise<string | undefined> {
if (process.platform !== 'win32')
return Promise.resolve(undefined)
return new Promise<string | undefined>((resolve) => {
const childProcess = spawn('volta which node', {
stdio: 'pipe',
shell: true,
cwd,
})
let output = ''
childProcess.stdout.on('data', data => output += data.toString())
childProcess.on('error', () => resolve(undefined))
childProcess.on('exit', (exitCode) => {
if (exitCode !== 0)
return resolve(undefined)
return resolve(output.trim())
})
})
}

async function findNodeViaShell(cwd: string): Promise<string | undefined> {
if (process.platform === 'win32')
return undefined
return new Promise<string | undefined>((resolve) => {
const startToken = '___START_SHELL__'
const endToken = '___END_SHELL__'
try {
const childProcess = spawn(`${vscode.env.shell} -i -c 'if type node 2>/dev/null | grep -q "function"; then node --version; fi; echo ${startToken} && which node && echo ${endToken}'`, {
stdio: 'pipe',
shell: true,
cwd,
})
let output = ''
childProcess.stdout.on('data', data => output += data.toString())
childProcess.on('error', () => resolve(undefined))
childProcess.on('exit', (exitCode) => {
if (exitCode !== 0)
return resolve(undefined)
const start = output.indexOf(startToken)
const end = output.indexOf(endToken)
if (start === -1 || end === -1)
return resolve(undefined)
return resolve(output.substring(start + startToken.length, end).trim())
})
}
catch (e) {
log.error('[SPAWN]', vscode.env.shell, e)
resolve(undefined)
}
})
}

export function getNodeJsVersion(nodeJsPath: string) {
return new Promise<string>((resolve) => {
const childProcess = spawn(nodeJsPath, ['--version'], {
stdio: 'pipe',
})
let output = ''
childProcess.stdout.on('data', data => output += data.toString())
childProcess.on('error', (error) => {
log.error(`Failed to run ${nodeJsPath} --version`)
log.error(error)
return resolve('')
})
childProcess.on('exit', (exitCode) => {
if (exitCode !== 0) {
log.error(`${nodeJsPath} --version exited with code ${exitCode}`)
return resolve('')
}
return resolve(output.trim())
})
})
}

0 comments on commit 6d169b6

Please sign in to comment.