diff --git a/src/backend/vitest.config.ts b/src/backend/vitest.config.ts index 236720f87..287284c97 100644 --- a/src/backend/vitest.config.ts +++ b/src/backend/vitest.config.ts @@ -112,6 +112,8 @@ export default defineConfig(({ mode }) => ({ // The MCP connector's signed-upload tools call the `/fs` HTTP API // directly, so their tests need a booted backend (`setupPuterTestEnv`). 'src/mcp-connector/**/*.test.{js,ts}', + // Root-level tools/ scripts are exercised through this suite. + 'tools/**/*.test.mjs', ], // Root is the repo root so that the file transformer (which // applies `lowerDecoratorsPlugin`) sees both src/backend and diff --git a/tools/extensionSetup.mjs b/tools/extensionSetup.mjs index 9ea1fcc03..a7b27f0c4 100755 --- a/tools/extensionSetup.mjs +++ b/tools/extensionSetup.mjs @@ -5,8 +5,8 @@ // the previous bash version. import { existsSync, readdirSync, statSync } from 'node:fs'; -import { spawn } from 'node:child_process'; import { join } from 'node:path'; +import { spawnNpm } from './npmSpawn.mjs'; const EXT_DIR = './extensions'; @@ -23,13 +23,11 @@ if (dirs.length === 0) { process.exit(0); } -const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; - function install(dir) { return new Promise((resolve, reject) => { const args = existsSync(join(dir, 'package-lock.json')) ? ['ci'] : ['install']; console.log(`[${dir}] starting npm ${args.join(' ')}`); - const child = spawn(npmCmd, args, { cwd: dir }); + const child = spawnNpm(args, { cwd: dir }); let out = ''; child.stdout.on('data', (d) => (out += d)); child.stderr.on('data', (d) => (out += d)); diff --git a/tools/npmSpawn.mjs b/tools/npmSpawn.mjs new file mode 100644 index 000000000..3e978bf20 --- /dev/null +++ b/tools/npmSpawn.mjs @@ -0,0 +1,21 @@ +// Shared helper for spawning npm from the repo's tools/ scripts. +// +// On Windows `npm` resolves to the `npm.cmd` batch shim, which Node refuses +// to spawn without a shell (`spawn EINVAL`). Node also warns when args are +// passed alongside `shell: true` (DEP0190), so the command line is joined +// here; args must be plain words that need no quoting. + +import { spawn } from 'node:child_process'; + +/** `[command, args, options]` for `spawn`. */ +export const npmSpawnArgs = ( + args, + options = {}, + platform = process.platform, +) => + platform === 'win32' + ? [['npm.cmd', ...args].join(' '), [], { ...options, shell: true }] + : ['npm', args, options]; + +export const spawnNpm = (args, options = {}) => + spawn(...npmSpawnArgs(args, options)); diff --git a/tools/npmSpawn.test.mjs b/tools/npmSpawn.test.mjs new file mode 100644 index 000000000..261b9d722 --- /dev/null +++ b/tools/npmSpawn.test.mjs @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { npmSpawnArgs, spawnNpm } from './npmSpawn.mjs'; + +describe('npmSpawnArgs', () => { + it('runs npm.cmd through a shell on Windows, with no separate args', () => { + expect( + npmSpawnArgs(['run', 'build:ts'], { cwd: '.' }, 'win32'), + ).toEqual(['npm.cmd run build:ts', [], { cwd: '.', shell: true }]); + }); + + it('spawns npm directly without a shell elsewhere', () => { + for (const platform of ['linux', 'darwin']) { + expect(npmSpawnArgs(['ci'], { cwd: '.' }, platform)).toEqual([ + 'npm', + ['ci'], + { cwd: '.' }, + ]); + } + }); +}); + +describe('spawnNpm', () => { + it('runs npm on the current platform', async () => { + const child = spawnNpm(['--version']); + let out = ''; + child.stdout.on('data', (d) => (out += d)); + const code = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + expect(code).toBe(0); + expect(out.trim()).toMatch(/^\d+\.\d+\.\d+/); + }, 20_000); +}); diff --git a/tools/start.mjs b/tools/start.mjs index e4e8f22aa..ff7ec36ac 100644 --- a/tools/start.mjs +++ b/tools/start.mjs @@ -23,6 +23,8 @@ import { spawn } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { npmSpawnArgs } from './npmSpawn.mjs'; + const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); // `npm start --flag=x` arrives as the npm_config_ env var; @@ -51,7 +53,6 @@ const run = (cmd, args, opts = {}) => new Promise((resolve, reject) => { }); }); -const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const server = getFlag('server'); const extensions = getFlag('extensions'); @@ -66,8 +67,8 @@ try { if ( extensions ) { console.warn('--extensions only applies to --server (GUI-only) mode; ignoring.'); } - await run(npm, ['run', 'setupExtensions']); - await run(npm, ['run', 'build:ts']); + await run(...npmSpawnArgs(['run', 'setupExtensions'])); + await run(...npmSpawnArgs(['run', 'build:ts'])); await run(process.execPath, [ '--enable-source-maps', '-r', './dist/src/backend/telemetry.js',