├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── license ├── media └── logo.jpg ├── package.json ├── readme.md ├── source ├── context.js ├── index.d.ts ├── index.js ├── index.test-d.ts ├── iterable.js ├── options.js ├── pipe.js ├── result.js ├── spawn.js └── windows.js ├── test ├── context.js ├── fixtures │ ├── ()[]%0!`.cmd │ ├── echo-file.js │ ├── echo.js │ ├── node-flags-path.js │ ├── node-flags.js │ ├── node-version.js │ ├── node_modules │ │ └── .bin │ │ │ └── git │ ├── shebang.js │ ├── spawnecho.cmd │ ├── subdir │ │ └── node_modules │ │ │ └── .bin │ │ │ └── git │ ├── test.js │ └── test.txt ├── helpers │ ├── arguments.js │ ├── assert.js │ ├── commands.js │ ├── main.js │ └── setup.js ├── index.js ├── iterable.js ├── options.js ├── pipe.js ├── result.js ├── spawn.js └── windows.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} on ${{ matrix.os }} 8 | runs-on: ${{ matrix.os }}-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 24 14 | - 20 15 | os: 16 | - ubuntu 17 | - macos 18 | - windows 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: npm install 25 | - run: npm test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | !/test/fixtures/**/node_modules 4 | coverage 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /media/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/nano-spawn/7f3fbe6590eec44f7e90f7735d173258dd80b420/media/logo.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nano-spawn", 3 | "version": "1.0.2", 4 | "description": "Tiny process execution for humans — a better child_process", 5 | "license": "MIT", 6 | "repository": "sindresorhus/nano-spawn", 7 | "funding": "https://github.com/sindresorhus/nano-spawn?sponsor=1", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": { 15 | "types": "./source/index.d.ts", 16 | "default": "./source/index.js" 17 | }, 18 | "sideEffects": false, 19 | "engines": { 20 | "node": ">=20.17" 21 | }, 22 | "scripts": { 23 | "test": "xo && c8 ava && npm run type", 24 | "type": "tsd -t ./source/index.d.ts -f ./source/index.test-d.ts" 25 | }, 26 | "files": [ 27 | "source/**/*.js", 28 | "source/**/*.d.ts" 29 | ], 30 | "keywords": [ 31 | "spawn", 32 | "exec", 33 | "child", 34 | "process", 35 | "subprocess", 36 | "execute", 37 | "fork", 38 | "execfile", 39 | "file", 40 | "shell", 41 | "bin", 42 | "binary", 43 | "binaries", 44 | "npm", 45 | "path", 46 | "local", 47 | "zx", 48 | "execa" 49 | ], 50 | "devDependencies": { 51 | "@types/node": "^22.5.4", 52 | "ava": "^6.1.3", 53 | "c8": "^10.1.2", 54 | "get-node": "^15.0.1", 55 | "log-process-errors": "^12.0.1", 56 | "path-key": "^4.0.0", 57 | "tempy": "^3.1.0", 58 | "tsd": "^0.32.0", 59 | "typescript": "^5.8.3", 60 | "xo": "^0.60.0", 61 | "yoctocolors": "^2.1.1" 62 | }, 63 | "ava": { 64 | "concurrency": 1, 65 | "timeout": "240s", 66 | "require": [ 67 | "./test/helpers/setup.js" 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 | nano-spawn logo 3 |

4 | 5 | ![Test coverage](https://img.shields.io/badge/coverage-100%25-green) 6 | 7 | 8 | 9 | > Tiny process execution for humans — a better [`child_process`](https://nodejs.org/api/child_process.html) 10 | 11 | ## Features 12 | 13 | No dependencies. Small package size: ![npm package minzipped size](https://img.shields.io/bundlejs/size/nano-spawn) [![Install size](https://packagephobia.com/badge?p=nano-spawn)](https://packagephobia.com/result?p=nano-spawn) 14 | 15 | Despite the small size, this is packed with some essential features: 16 | - [Promise-based](#spawnfile-arguments-options-default-export) interface. 17 | - [Iterate](#subprocesssymbolasynciterator) over the output lines. 18 | - [Pipe](#subprocesspipefile-arguments-options) multiple subprocesses and retrieve [intermediate results](#resultpipedfrom). 19 | - Execute [locally installed binaries](#optionspreferlocal) without `npx`. 20 | - Improved [Windows support](#windows-support). 21 | - Proper handling of [subprocess failures](#subprocesserror) and better error messages. 22 | - Get [interleaved output](#resultoutput) from stdout and stderr similar to what is printed on the terminal. 23 | - Strip [unnecessary newlines](#resultstdout). 24 | - Pass strings as [`stdin` input](#optionsstdin-optionsstdout-optionsstderr) to the subprocess. 25 | - Preserve the current [Node.js version and flags](#spawnfile-arguments-options-default-export). 26 | - Simpler syntax to set [environment variables](#optionsenv) or [`stdin`/`stdout`/`stderr`](#optionsstdin-optionsstdout-optionsstderr). 27 | - Compute the command [duration](#resultdurationms). 28 | 29 | For additional features, please check out [Execa](#execa). 30 | 31 | ## Install 32 | 33 | ```sh 34 | npm install nano-spawn 35 | ``` 36 | 37 | ## Usage 38 | 39 | ### Run commands 40 | 41 | ```js 42 | import spawn from 'nano-spawn'; 43 | 44 | const result = await spawn('echo', ['🦄']); 45 | 46 | console.log(result.output); 47 | //=> '🦄' 48 | ``` 49 | 50 | ### Iterate over output lines 51 | 52 | ```js 53 | for await (const line of spawn('ls', ['--oneline'])) { 54 | console.log(line); 55 | } 56 | //=> index.d.ts 57 | //=> index.js 58 | //=> … 59 | ``` 60 | 61 | ### Pipe commands 62 | 63 | ```js 64 | const result = await spawn('npm', ['run', 'build']) 65 | .pipe('sort') 66 | .pipe('head', ['-n', '2']); 67 | ``` 68 | 69 | ## API 70 | 71 | ### spawn(file, arguments?, options?) default export 72 | 73 | `file`: `string`\ 74 | `arguments`: `string[]`\ 75 | `options`: [`Options`](#options)\ 76 | _Returns_: [`Subprocess`](#subprocess) 77 | 78 | Executes a command using `file ...arguments`. 79 | 80 | This has the same syntax as [`child_process.spawn()`](https://nodejs.org/api/child_process.html#child_processspawncommand-args-options). 81 | 82 | If `file` is `'node'`, the current Node.js version and [flags](https://nodejs.org/api/cli.html#options) are inherited. 83 | 84 | #### Options 85 | 86 | ##### options.stdio, options.shell, options.timeout, options.signal, options.cwd, options.killSignal, options.serialization, options.detached, options.uid, options.gid, options.windowsVerbatimArguments, options.windowsHide, options.argv0 87 | 88 | All [`child_process.spawn()` options](https://nodejs.org/api/child_process.html#child_processspawncommand-args-options) can be passed to [`spawn()`](#spawnfile-arguments-options-default-export). 89 | 90 | ##### options.env 91 | 92 | _Type_: `object`\ 93 | _Default_: `{}` 94 | 95 | Override specific [environment variables](https://en.wikipedia.org/wiki/Environment_variable). Other environment variables are inherited from the current process ([`process.env`](https://nodejs.org/api/process.html#processenv)). 96 | 97 | ##### options.preferLocal 98 | 99 | _Type_: `boolean`\ 100 | _Default_: `false` 101 | 102 | Allows executing binaries installed locally with `npm` (or `yarn`, etc.). 103 | 104 | ##### options.stdin, options.stdout, options.stderr 105 | 106 | _Type_: `string | number | Stream | {string: string}` 107 | 108 | Subprocess's standard [input](https://en.wikipedia.org/wiki/Standard_streams#Standard_input_(stdin))/[output](https://en.wikipedia.org/wiki/Standard_streams#Standard_output_(stdout))/[error](https://en.wikipedia.org/wiki/Standard_streams#Standard_error_(stderr)). 109 | 110 | [All values supported](https://nodejs.org/api/child_process.html#optionsstdio) by `node:child_process` are available. The most common ones are: 111 | - `'pipe'` (default value): returns the output using [`result.stdout`](#resultstdout), [`result.stderr`](#resultstderr) and [`result.output`](#resultoutput). 112 | - `'inherit'`: uses the current process's [input](https://nodejs.org/api/process.html#processstdin)/[output](https://nodejs.org/api/process.html#processstdout). This is useful when running in a terminal. 113 | - `'ignore'`: discards the input/output. 114 | - [`Stream`](https://nodejs.org/api/stream.html#stream): redirects the input/output from/to a stream. For example, [`fs.createReadStream()`](https://nodejs.org/api/fs.html#fscreatereadstreampath-options)/[`fs.createWriteStream()`](https://nodejs.org/api/fs.html#fscreatewritestreampath-options) can be used, once the stream's [`open`](https://nodejs.org/api/fs.html#event-open) event has been emitted. 115 | - `{string: '...'}`: passes a string as input to `stdin`. 116 | 117 | #### Subprocess 118 | 119 | Subprocess started by [`spawn()`](#spawnfile-arguments-options-default-export). 120 | 121 | ##### await subprocess 122 | 123 | _Returns_: [`Result`](#result)\ 124 | _Throws_: [`SubprocessError`](#subprocesserror) 125 | 126 | A subprocess is a promise that is either resolved with a successful [`result` object](#result) or rejected with a [`subprocessError`](#error). 127 | 128 | ##### subprocess.stdout 129 | 130 | _Returns_: `AsyncIterable`\ 131 | _Throws_: [`SubprocessError`](#subprocesserror) 132 | 133 | Iterates over each [`stdout`](#resultstdout) line, as soon as it is available. 134 | 135 | The iteration waits for the subprocess to end (even when using [`break`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/break) or [`return`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/return)). It throws if the subprocess [fails](#subprocesserror). This means you do not need to call [`await subprocess`](#await-subprocess). 136 | 137 | ##### subprocess.stderr 138 | 139 | _Returns_: `AsyncIterable`\ 140 | _Throws_: [`SubprocessError`](#subprocesserror) 141 | 142 | Same as [`subprocess.stdout`](#subprocessstdout) but for [`stderr`](#resultstderr) instead. 143 | 144 | ##### subprocess[Symbol.asyncIterator]\() 145 | 146 | _Returns_: `AsyncIterable`\ 147 | _Throws_: [`SubprocessError`](#subprocesserror) 148 | 149 | Same as [`subprocess.stdout`](#subprocessstdout) but for both [`stdout` and `stderr`](#resultoutput). 150 | 151 | ##### subprocess.pipe(file, arguments?, options?) 152 | 153 | `file`: `string`\ 154 | `arguments`: `string[]`\ 155 | `options`: [`Options`](#options)\ 156 | _Returns_: [`Subprocess`](#subprocess) 157 | 158 | Similar to the `|` symbol in shells. [Pipe](https://nodejs.org/api/stream.html#readablepipedestination-options) the subprocess's[`stdout`](https://en.wikipedia.org/wiki/Standard_streams#Standard_output_(stdout)) to a second subprocess's [`stdin`](https://en.wikipedia.org/wiki/Standard_streams#Standard_input_(stdin)). 159 | 160 | This resolves with that second subprocess's [result](#result). If either subprocess is rejected, this is rejected with that subprocess's [error](#subprocesserror) instead. 161 | 162 | This follows the same syntax as [`spawn(file, arguments?, options?)`](#spawnfile-arguments-options-default-export). It can be done multiple times in a row. 163 | 164 | ##### await subprocess.nodeChildProcess 165 | 166 | _Type_: `ChildProcess` 167 | 168 | Underlying [Node.js child process](https://nodejs.org/api/child_process.html#class-childprocess). 169 | 170 | Among other things, this can be used to terminate the subprocess using [`.kill()`](https://nodejs.org/api/child_process.html#subprocesskillsignal) or exchange IPC message using [`.send()`](https://nodejs.org/api/child_process.html#subprocesssendmessage-sendhandle-options-callback). 171 | 172 | #### Result 173 | 174 | When the subprocess succeeds, its [promise](#await-subprocess) is resolved with an object with the following properties. 175 | 176 | ##### result.stdout 177 | 178 | _Type_: `string` 179 | 180 | The output of the subprocess on [standard output](https://en.wikipedia.org/wiki/Standard_streams#Standard_output_(stdout)). 181 | 182 | If the output ends with a [newline](https://en.wikipedia.org/wiki/Newline), that newline is automatically stripped. 183 | 184 | This is an empty string if either: 185 | - The [`stdout`](#optionsstdin-optionsstdout-optionsstderr) option is set to another value than `'pipe'` (its default value). 186 | - The output is being iterated using [`subprocess.stdout`](#subprocessstdout) or [`subprocess[Symbol.asyncIterator]`](#subprocesssymbolasynciterator). 187 | 188 | ##### result.stderr 189 | 190 | _Type_: `string` 191 | 192 | Like [`result.stdout`](#resultstdout) but for the [standard error](https://en.wikipedia.org/wiki/Standard_streams#Standard_error_(stderr)) instead. 193 | 194 | ##### result.output 195 | 196 | _Type_: `string` 197 | 198 | Like [`result.stdout`](#resultstdout) but for both the [standard output](https://en.wikipedia.org/wiki/Standard_streams#Standard_output_(stdout)) and [standard error](https://en.wikipedia.org/wiki/Standard_streams#Standard_error_(stderr)), interleaved. 199 | 200 | ##### result.command 201 | 202 | _Type_: `string` 203 | 204 | The file and arguments that were run. 205 | 206 | It is intended for logging or debugging. Since the escaping is fairly basic, it should not be executed directly. 207 | 208 | ##### result.durationMs 209 | 210 | _Type_: `number` 211 | 212 | Duration of the subprocess, in milliseconds. 213 | 214 | ##### result.pipedFrom 215 | 216 | _Type_: `Result | SubprocessError | undefined` 217 | 218 | If [`subprocess.pipe()`](#subprocesspipefile-arguments-options) was used, the [result](#result) or [error](#subprocesserror) of the other subprocess that was piped into this subprocess. 219 | 220 | #### SubprocessError 221 | 222 | _Type_: `Error` 223 | 224 | When the subprocess fails, its [promise](#await-subprocess) is rejected with this error. 225 | 226 | Subprocesses fail either when their [exit code](#subprocesserrorexitcode) is not `0` or when terminated by a [signal](#subprocesserrorsignalname). Other failure reasons include misspelling the command name or using the [`timeout`](https://nodejs.org/api/child_process.html#child_processspawncommand-args-options) option. 227 | 228 | Subprocess errors have the same shape as [successful results](#result), with the following additional properties. 229 | 230 | This error class is exported, so you can use `if (error instanceof SubprocessError) { ... }`. 231 | 232 | ##### subprocessError.exitCode 233 | 234 | _Type_: `number | undefined` 235 | 236 | The numeric [exit code](https://en.wikipedia.org/wiki/Exit_status) of the subprocess that was run. 237 | 238 | This is `undefined` when the subprocess could not be started, or when it was terminated by a [signal](#subprocesserrorsignalname). 239 | 240 | ##### subprocessError.signalName 241 | 242 | _Type_: `string | undefined` 243 | 244 | The name of the [signal](https://en.wikipedia.org/wiki/Signal_(IPC)) (like [`SIGTERM`](https://en.wikipedia.org/wiki/Signal_(IPC)#SIGTERM)) that terminated the subprocess, sent by either: 245 | - The current process. 246 | - Another process. This case is [not supported on Windows](https://nodejs.org/api/process.html#signal-events). 247 | 248 | If a signal terminated the subprocess, this property is defined and included in the [error message](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/message). Otherwise it is `undefined`. 249 | 250 | ## Windows support 251 | 252 | This package fixes several cross-platform issues with [`node:child_process`](https://nodejs.org/api/child_process.html). It brings full Windows support for: 253 | - Node modules binaries (without requiring the [`shell`](https://nodejs.org/api/child_process.html#default-windows-shell) option). This includes running `npm ...` or `yarn ...`. 254 | - `.cmd`, `.bat`, and other shell files. 255 | - The [`PATHEXT`](https://wiki.tcl-lang.org/page/PATHEXT) environment variable. 256 | - Windows-specific [newlines](https://en.wikipedia.org/wiki/Newline#Representation). 257 | 258 | ## Alternatives 259 | 260 | `nano-spawn`'s main goal is to be small, yet useful. Nonetheless, depending on your use case, there are other ways to run subprocesses in Node.js. 261 | 262 | ### Execa 263 | 264 | [Execa](https://github.com/sindresorhus/execa) is a similar package: it provides the same features, but more. It is also built on top of `node:child_process`, and is maintained by the [same people](#maintainers). 265 | 266 | On one hand, it has a bigger size: [![Install size](https://packagephobia.com/badge?p=execa)](https://packagephobia.com/result?p=execa) 267 | 268 | On the other hand, it provides a bunch of additional features: [scripts](https://github.com/sindresorhus/execa/blob/main/docs/scripts.md), [template string syntax](https://github.com/sindresorhus/execa/blob/main/docs/execution.md#template-string-syntax), [synchronous execution](https://github.com/sindresorhus/execa/blob/main/docs/execution.md#synchronous-execution), [file input/output](https://github.com/sindresorhus/execa/blob/main/docs/output.md#file-output), [binary input/output](https://github.com/sindresorhus/execa/blob/main/docs/binary.md), [advanced piping](https://github.com/sindresorhus/execa/blob/main/docs/pipe.md), [verbose mode](https://github.com/sindresorhus/execa/blob/main/docs/debugging.md#verbose-mode), [graceful](https://github.com/sindresorhus/execa/blob/main/docs/termination.md#graceful-termination) or [forceful termination](https://github.com/sindresorhus/execa/blob/main/docs/termination.md#forceful-termination), [IPC](https://github.com/sindresorhus/execa/blob/main/docs/ipc.md), [shebangs on Windows](https://github.com/sindresorhus/execa/blob/main/docs/windows.md), [and much more](https://github.com/sindresorhus/nano-spawn/issues/14). Also, it is [very widely used](https://github.com/sindresorhus/execa/network/dependents) and [battle-tested](https://github.com/sindresorhus/execa/graphs/contributors). 269 | 270 | We recommend using Execa in most cases, unless your environment requires using small packages (for example, in a library or in a serverless function). It is definitely the best option inside scripts, servers, or apps. 271 | 272 | ### `node:child_process` 273 | 274 | `nano-spawn` is built on top of the [`node:child_process`](https://nodejs.org/api/child_process.html) core module. 275 | 276 | If you'd prefer avoiding adding any dependency, you may use `node:child_process` directly. However, you might miss the [features](#features) `nano-spawn` provides: [proper error handling](#subprocesserror), [full Windows support](#windows-support), [local binaries](#optionspreferlocal), [piping](#subprocesspipefile-arguments-options), [lines iteration](#subprocesssymbolasynciterator), [interleaved output](#resultoutput), [and more](#features). 277 | 278 | ```js 279 | import {execFile} from 'node:child_process'; 280 | import {promisify} from 'node:util'; 281 | 282 | const pExecFile = promisify(execFile); 283 | 284 | const result = await pExecFile('npm', ['run', 'build']); 285 | ``` 286 | 287 | ## Maintainers 288 | 289 | - [Sindre Sorhus](https://github.com/sindresorhus) 290 | - [@ehmicky](https://github.com/ehmicky) 291 | -------------------------------------------------------------------------------- /source/context.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import {stripVTControlCharacters} from 'node:util'; 3 | 4 | export const getContext = raw => ({ 5 | start: process.hrtime.bigint(), 6 | command: raw.map(part => getCommandPart(stripVTControlCharacters(part))).join(' '), 7 | state: {stdout: '', stderr: '', output: ''}, 8 | }); 9 | 10 | const getCommandPart = part => /[^\w./-]/.test(part) 11 | ? `'${part.replaceAll('\'', '\'\\\'\'')}'` 12 | : part; 13 | -------------------------------------------------------------------------------- /source/index.d.ts: -------------------------------------------------------------------------------- 1 | import type {ChildProcess, SpawnOptions} from 'node:child_process'; 2 | 3 | type StdioOption = Readonly[number]>; 4 | type StdinOption = StdioOption | {readonly string?: string}; 5 | 6 | /** 7 | Options passed to `nano-spawn`. 8 | 9 | All [`child_process.spawn()` options](https://nodejs.org/api/child_process.html#child_processspawncommand-args-options) can be passed. 10 | */ 11 | export type Options = Omit & Readonly>>; 81 | }>>; 82 | 83 | /** 84 | When the subprocess succeeds, its promise is resolved with this object. 85 | */ 86 | export type Result = { 87 | /** 88 | The output of the subprocess on [standard output](https://en.wikipedia.org/wiki/Standard_streams#Standard_output_(stdout)). 89 | 90 | If the output ends with a [newline](https://en.wikipedia.org/wiki/Newline), that newline is automatically stripped. 91 | 92 | This is an empty string if either: 93 | - The `stdout` option is set to another value than `'pipe'` (its default value). 94 | - The output is being iterated using `subprocess.stdout` or `subprocess[Symbol.asyncIterator]`. 95 | */ 96 | stdout: string; 97 | 98 | /** 99 | The output of the subprocess on [standard error](https://en.wikipedia.org/wiki/Standard_streams#Standard_error_(stderr)). 100 | 101 | If the output ends with a [newline](https://en.wikipedia.org/wiki/Newline), that newline is automatically stripped. 102 | 103 | This is an empty string if either: 104 | - The `stderr` option is set to another value than `'pipe'` (its default value). 105 | - The output is being iterated using `subprocess.stderr` or `subprocess[Symbol.asyncIterator]`. 106 | */ 107 | stderr: string; 108 | 109 | /** 110 | Like `result.stdout` but for both the [standard output](https://en.wikipedia.org/wiki/Standard_streams#Standard_output_(stdout)) and [standard error](https://en.wikipedia.org/wiki/Standard_streams#Standard_error_(stderr)), interleaved. 111 | */ 112 | output: string; 113 | 114 | /** 115 | The file and arguments that were run. 116 | 117 | It is intended for logging or debugging. Since the escaping is fairly basic, it should not be executed directly. 118 | */ 119 | command: string; 120 | 121 | /** 122 | Duration of the subprocess, in milliseconds. 123 | */ 124 | durationMs: number; 125 | 126 | /** 127 | If `subprocess.pipe()` was used, the result or error of the other subprocess that was piped into this subprocess. 128 | */ 129 | pipedFrom?: Result | SubprocessError; 130 | }; 131 | 132 | /** 133 | When the subprocess fails, its promise is rejected with this error. 134 | 135 | Subprocesses fail either when their exit code is not `0` or when terminated by a signal. Other failure reasons include misspelling the command name or using the [`timeout`](https://nodejs.org/api/child_process.html#child_processspawncommand-args-options) option. 136 | */ 137 | export class SubprocessError extends Error implements Result { 138 | stdout: Result['stdout']; 139 | stderr: Result['stderr']; 140 | output: Result['output']; 141 | command: Result['command']; 142 | durationMs: Result['durationMs']; 143 | pipedFrom?: Result['pipedFrom']; 144 | 145 | /** 146 | The numeric [exit code](https://en.wikipedia.org/wiki/Exit_status) of the subprocess that was run. 147 | 148 | This is `undefined` when the subprocess could not be started, or when it was terminated by a signal. 149 | */ 150 | exitCode?: number; 151 | 152 | /** 153 | The name of the [signal](https://en.wikipedia.org/wiki/Signal_(IPC)) (like [`SIGTERM`](https://en.wikipedia.org/wiki/Signal_(IPC)#SIGTERM)) that terminated the subprocess, sent by either: 154 | - The current process. 155 | - Another process. This case is [not supported on Windows](https://nodejs.org/api/process.html#signal-events). 156 | 157 | If a signal terminated the subprocess, this property is defined and included in the [error message](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/message). Otherwise it is `undefined`. 158 | */ 159 | signalName?: string; 160 | } 161 | 162 | /** 163 | Subprocess started by `spawn()`. 164 | 165 | A subprocess is a promise that is either resolved with a successful `result` object or rejected with a `subprocessError`. 166 | 167 | It is also an iterable, iterating over each `stdout`/`stderr` line, as soon as it is available. The iteration waits for the subprocess to end (even when using [`break`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/break) or [`return`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/return)). It throws if the subprocess fails. This means you do not need to call `await subprocess`. 168 | */ 169 | export type Subprocess = Promise & AsyncIterable & { 170 | /** 171 | Underlying [Node.js child process](https://nodejs.org/api/child_process.html#class-childprocess). 172 | 173 | Among other things, this can be used to terminate the subprocess using [`.kill()`](https://nodejs.org/api/child_process.html#subprocesskillsignal) or exchange IPC message using [`.send()`](https://nodejs.org/api/child_process.html#subprocesssendmessage-sendhandle-options-callback). 174 | */ 175 | nodeChildProcess: Promise; 176 | 177 | /** 178 | Iterates over each `stdout` line, as soon as it is available. 179 | 180 | The iteration waits for the subprocess to end (even when using [`break`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/break) or [`return`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/return)). It throws if the subprocess fails. This means you do not need to call `await subprocess`. 181 | */ 182 | stdout: AsyncIterable; 183 | 184 | /** 185 | Iterates over each `stderr` line, as soon as it is available. 186 | 187 | The iteration waits for the subprocess to end (even when using [`break`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/break) or [`return`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/return)). It throws if the subprocess fails. This means you do not need to call `await subprocess`. 188 | */ 189 | stderr: AsyncIterable; 190 | 191 | /** 192 | Similar to the `|` symbol in shells. [Pipe](https://nodejs.org/api/stream.html#readablepipedestination-options) the subprocess's[`stdout`](https://en.wikipedia.org/wiki/Standard_streams#Standard_output_(stdout)) to a second subprocess's [`stdin`](https://en.wikipedia.org/wiki/Standard_streams#Standard_input_(stdin)). 193 | 194 | This resolves with that second subprocess's result. If either subprocess is rejected, this is rejected with that subprocess's error instead. 195 | 196 | This follows the same syntax as `spawn(file, arguments?, options?)`. It can be done multiple times in a row. 197 | 198 | @param file - The program/script to execute 199 | @param arguments - Arguments to pass to `file` on execution. 200 | @param options 201 | @returns `Subprocess` 202 | 203 | @example 204 | 205 | ``` 206 | const result = await spawn('npm', ['run', 'build']) 207 | .pipe('sort') 208 | .pipe('head', ['-n', '2']); 209 | ``` 210 | */ 211 | pipe(file: string, arguments?: readonly string[], options?: Options): Subprocess; 212 | pipe(file: string, options?: Options): Subprocess; 213 | }; 214 | 215 | /** 216 | Executes a command using `file ...arguments`. 217 | 218 | This has the same syntax as [`child_process.spawn()`](https://nodejs.org/api/child_process.html#child_processspawncommand-args-options). 219 | 220 | If `file` is `'node'`, the current Node.js version and [flags](https://nodejs.org/api/cli.html#options) are inherited. 221 | 222 | @param file - The program/script to execute 223 | @param arguments - Arguments to pass to `file` on execution. 224 | @param options 225 | @returns `Subprocess` 226 | 227 | @example Run commands 228 | 229 | ``` 230 | import spawn from 'nano-spawn'; 231 | 232 | const result = await spawn('echo', ['🦄']); 233 | 234 | console.log(result.output); 235 | //=> '🦄' 236 | ``` 237 | 238 | @example Iterate over output lines 239 | 240 | ``` 241 | for await (const line of spawn('ls', ['--oneline'])) { 242 | console.log(line); 243 | } 244 | //=> index.d.ts 245 | //=> index.js 246 | //=> … 247 | ``` 248 | */ 249 | export default function spawn(file: string, arguments?: readonly string[], options?: Options): Subprocess; 250 | export default function spawn(file: string, options?: Options): Subprocess; 251 | -------------------------------------------------------------------------------- /source/index.js: -------------------------------------------------------------------------------- 1 | import {getContext} from './context.js'; 2 | import {getOptions} from './options.js'; 3 | import {spawnSubprocess} from './spawn.js'; 4 | import {getResult} from './result.js'; 5 | import {handlePipe} from './pipe.js'; 6 | import {lineIterator, combineAsyncIterators} from './iterable.js'; 7 | 8 | export {SubprocessError} from './result.js'; 9 | 10 | export default function spawn(file, second, third, previous) { 11 | const [commandArguments = [], options = {}] = Array.isArray(second) ? [second, third] : [[], second]; 12 | const context = getContext([file, ...commandArguments]); 13 | const spawnOptions = getOptions(options); 14 | const nodeChildProcess = spawnSubprocess(file, commandArguments, spawnOptions, context); 15 | let subprocess = getResult(nodeChildProcess, spawnOptions, context); 16 | Object.assign(subprocess, {nodeChildProcess}); 17 | subprocess = previous ? handlePipe([previous, subprocess]) : subprocess; 18 | 19 | const stdout = lineIterator(subprocess, context, 'stdout'); 20 | const stderr = lineIterator(subprocess, context, 'stderr'); 21 | return Object.assign(subprocess, { 22 | nodeChildProcess, 23 | stdout, 24 | stderr, 25 | [Symbol.asyncIterator]: () => combineAsyncIterators(stdout, stderr), 26 | pipe: (file, second, third) => spawn(file, second, third, subprocess), 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /source/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import type {ChildProcess} from 'node:child_process'; 2 | import { 3 | expectType, 4 | expectAssignable, 5 | expectNotAssignable, 6 | expectError, 7 | } from 'tsd'; 8 | import spawn, { 9 | SubprocessError, 10 | type Options, 11 | type Result, 12 | type Subprocess, 13 | } from './index.js'; 14 | 15 | try { 16 | const result = await spawn('test'); 17 | expectType(result); 18 | expectType(result.stdout); 19 | expectType(result.stderr); 20 | expectType(result.output); 21 | expectType(result.command); 22 | expectType(result.durationMs); 23 | expectType(result.pipedFrom); 24 | expectType(result.pipedFrom?.pipedFrom); 25 | expectType(result.pipedFrom?.durationMs); 26 | expectNotAssignable(result); 27 | expectError(result.exitCode); 28 | expectError(result.signalName); 29 | expectError(result.other); 30 | } catch (error) { 31 | if (error instanceof SubprocessError) { 32 | expectType(error.stdout); 33 | expectType(error.stderr); 34 | expectType(error.output); 35 | expectType(error.command); 36 | expectType(error.durationMs); 37 | expectType(error.pipedFrom); 38 | expectType(error.pipedFrom?.pipedFrom); 39 | expectType(error.pipedFrom?.durationMs); 40 | expectAssignable(error); 41 | expectType(error.exitCode); 42 | expectType(error.signalName); 43 | expectError(error.other); 44 | } 45 | } 46 | 47 | expectAssignable({} as const); 48 | expectAssignable({argv0: 'test'} as const); 49 | expectNotAssignable({other: 'test'} as const); 50 | expectNotAssignable('test'); 51 | 52 | await spawn('test', {argv0: 'test'} as const); 53 | expectError(await spawn('test', {argv0: true} as const)); 54 | await spawn('test', {preferLocal: true} as const); 55 | expectError(await spawn('test', {preferLocal: 'true'} as const)); 56 | await spawn('test', {env: {}} as const); 57 | // eslint-disable-next-line @typescript-eslint/naming-convention 58 | await spawn('test', {env: {TEST: 'test'}} as const); 59 | expectError(await spawn('test', {env: true} as const)); 60 | // eslint-disable-next-line @typescript-eslint/naming-convention 61 | expectError(await spawn('test', {env: {TEST: true}} as const)); 62 | await spawn('test', {stdin: 'pipe'} as const); 63 | await spawn('test', {stdin: {string: 'test'} as const} as const); 64 | expectError(await spawn('test', {stdin: {string: true} as const} as const)); 65 | expectError(await spawn('test', {stdin: {other: 'test'} as const} as const)); 66 | expectError(await spawn('test', {stdin: true} as const)); 67 | await spawn('test', {stdout: 'pipe'} as const); 68 | expectError(await spawn('test', {stdout: {string: 'test'} as const} as const)); 69 | expectError(await spawn('test', {stdout: true} as const)); 70 | await spawn('test', {stderr: 'pipe'} as const); 71 | expectError(await spawn('test', {stderr: {string: 'test'} as const} as const)); 72 | expectError(await spawn('test', {stderr: true} as const)); 73 | await spawn('test', {stdio: ['pipe', 'pipe', 'pipe'] as const} as const); 74 | await spawn('test', {stdio: [{string: 'test'} as const, 'pipe', 'pipe'] as const} as const); 75 | expectError(await spawn('test', {stdio: ['pipe', {string: 'test'} as const, 'pipe'] as const} as const)); 76 | expectError(await spawn('test', {stdio: ['pipe', 'pipe', {string: 'test'} as const] as const} as const)); 77 | expectError(await spawn('test', {stdio: [{string: true} as const, 'pipe', 'pipe'] as const} as const)); 78 | expectError(await spawn('test', {stdio: [{other: 'test'} as const, 'pipe', 'pipe'] as const} as const)); 79 | expectError(await spawn('test', {stdio: [true, true, true] as const} as const)); 80 | await spawn('test', {stdio: 'pipe'} as const); 81 | expectError(await spawn('test', {stdio: true} as const)); 82 | expectError(await spawn('test', {other: 'test'} as const)); 83 | 84 | expectError(await spawn()); 85 | expectError(await spawn(true)); 86 | await spawn('test', [] as const); 87 | await spawn('test', ['one'] as const); 88 | expectError(await spawn('test', [true] as const)); 89 | await spawn('test', {} as const); 90 | expectError(await spawn('test', true)); 91 | await spawn('test', ['one'] as const, {} as const); 92 | expectError(await spawn('test', ['one'] as const, true)); 93 | expectError(await spawn('test', ['one'] as const, {} as const, true)); 94 | 95 | expectError(await spawn('test').pipe()); 96 | expectError(await spawn('test').pipe(true)); 97 | await spawn('test').pipe('test', [] as const); 98 | await spawn('test').pipe('test', ['one'] as const); 99 | expectError(await spawn('test').pipe('test', [true] as const)); 100 | await spawn('test').pipe('test', {} as const); 101 | expectError(await spawn('test').pipe('test', true)); 102 | await spawn('test').pipe('test', ['one'] as const, {} as const); 103 | expectError(await spawn('test').pipe('test', ['one'] as const, true)); 104 | expectError(await spawn('test').pipe('test', ['one'] as const, {} as const, true)); 105 | 106 | expectError(await spawn('test').pipe('test').pipe()); 107 | expectError(await spawn('test').pipe('test').pipe(true)); 108 | await spawn('test').pipe('test').pipe('test', [] as const); 109 | await spawn('test').pipe('test').pipe('test', ['one'] as const); 110 | expectError(await spawn('test').pipe('test').pipe('test', [true] as const)); 111 | await spawn('test').pipe('test').pipe('test', {} as const); 112 | expectError(await spawn('test').pipe('test').pipe('test', true)); 113 | await spawn('test').pipe('test').pipe('test', ['one'] as const, {} as const); 114 | expectError(await spawn('test').pipe('test').pipe('test', ['one'] as const, true)); 115 | expectError(await spawn('test').pipe('test').pipe('test', ['one'] as const, {} as const, true)); 116 | 117 | expectType(spawn('test').pipe('test')); 118 | expectType(spawn('test').pipe('test').pipe('test')); 119 | expectType(await spawn('test').pipe('test')); 120 | expectType(await spawn('test').pipe('test').pipe('test')); 121 | 122 | for await (const line of spawn('test')) { 123 | expectType(line); 124 | } 125 | 126 | for await (const line of spawn('test').pipe('test')) { 127 | expectType(line); 128 | } 129 | 130 | for await (const line of spawn('test').stdout) { 131 | expectType(line); 132 | } 133 | 134 | for await (const line of spawn('test').pipe('test').stdout) { 135 | expectType(line); 136 | } 137 | 138 | for await (const line of spawn('test').stderr) { 139 | expectType(line); 140 | } 141 | 142 | for await (const line of spawn('test').pipe('test').stderr) { 143 | expectType(line); 144 | } 145 | 146 | const subprocess = spawn('test'); 147 | expectType(subprocess); 148 | 149 | const nodeChildProcess = await subprocess.nodeChildProcess; 150 | expectType(nodeChildProcess); 151 | expectType(nodeChildProcess.pid); 152 | -------------------------------------------------------------------------------- /source/iterable.js: -------------------------------------------------------------------------------- 1 | import * as readline from 'node:readline/promises'; 2 | 3 | export const lineIterator = async function * (subprocess, {state}, streamName) { 4 | // Prevent buffering when iterating. 5 | // This would defeat one of the main goals of iterating: low memory consumption. 6 | if (state.isIterating === false) { 7 | throw new Error(`The subprocess must be iterated right away, for example: 8 | for await (const line of spawn(...)) { ... }`); 9 | } 10 | 11 | state.isIterating = true; 12 | 13 | try { 14 | const {[streamName]: stream} = await subprocess.nodeChildProcess; 15 | if (!stream) { 16 | return; 17 | } 18 | 19 | yield * readline.createInterface({input: stream}); 20 | } finally { 21 | await subprocess; 22 | } 23 | }; 24 | 25 | // Merge two async iterators into one 26 | export const combineAsyncIterators = async function * (...iterators) { 27 | try { 28 | let promises = []; 29 | while (iterators.length > 0) { 30 | promises = iterators.map((iterator, index) => promises[index] ?? getNext(iterator)); 31 | // eslint-disable-next-line no-await-in-loop 32 | const [{value, done}, index] = await Promise.race(promises 33 | .map((promise, index) => Promise.all([promise, index]))); 34 | 35 | const [iterator] = iterators.splice(index, 1); 36 | promises.splice(index, 1); 37 | 38 | if (!done) { 39 | iterators.push(iterator); 40 | yield value; 41 | } 42 | } 43 | } finally { 44 | await Promise.all(iterators.map(iterator => iterator.return())); 45 | } 46 | }; 47 | 48 | const getNext = async iterator => { 49 | try { 50 | return await iterator.next(); 51 | } catch (error) { 52 | await iterator.throw(error); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /source/options.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import {fileURLToPath} from 'node:url'; 3 | import process from 'node:process'; 4 | 5 | export const getOptions = ({ 6 | stdin, 7 | stdout, 8 | stderr, 9 | stdio = [stdin, stdout, stderr], 10 | env: envOption, 11 | preferLocal, 12 | cwd: cwdOption = '.', 13 | ...options 14 | }) => { 15 | const cwd = cwdOption instanceof URL ? fileURLToPath(cwdOption) : path.resolve(cwdOption); 16 | const env = envOption ? {...process.env, ...envOption} : undefined; 17 | const input = stdio[0]?.string; 18 | return { 19 | ...options, 20 | input, 21 | stdio: input === undefined ? stdio : ['pipe', ...stdio.slice(1)], 22 | env: preferLocal ? addLocalPath(env ?? process.env, cwd) : env, 23 | cwd, 24 | }; 25 | }; 26 | 27 | const addLocalPath = ({Path = '', PATH = Path, ...env}, cwd) => { 28 | const pathParts = PATH.split(path.delimiter); 29 | const localPaths = getLocalPaths([], path.resolve(cwd)) 30 | .map(localPath => path.join(localPath, 'node_modules/.bin')) 31 | .filter(localPath => !pathParts.includes(localPath)); 32 | return {...env, PATH: [...localPaths, PATH].filter(Boolean).join(path.delimiter)}; 33 | }; 34 | 35 | const getLocalPaths = (localPaths, localPath) => localPaths.at(-1) === localPath 36 | ? localPaths 37 | : getLocalPaths([...localPaths, localPath], path.resolve(localPath, '..')); 38 | -------------------------------------------------------------------------------- /source/pipe.js: -------------------------------------------------------------------------------- 1 | import {pipeline} from 'node:stream/promises'; 2 | 3 | export const handlePipe = async subprocesses => { 4 | // Ensure both subprocesses have exited before resolving, and that we handle errors from both 5 | const [[from, to]] = await Promise.all([Promise.allSettled(subprocesses), pipeStreams(subprocesses)]); 6 | 7 | // If both subprocesses fail, throw destination error to use a predictable order and avoid race conditions 8 | if (to.reason) { 9 | to.reason.pipedFrom = from.reason ?? from.value; 10 | throw to.reason; 11 | } 12 | 13 | if (from.reason) { 14 | throw from.reason; 15 | } 16 | 17 | return {...to.value, pipedFrom: from.value}; 18 | }; 19 | 20 | const pipeStreams = async subprocesses => { 21 | try { 22 | const [{stdout}, {stdin}] = await Promise.all(subprocesses.map(({nodeChildProcess}) => nodeChildProcess)); 23 | if (stdin === null) { 24 | throw new Error('The "stdin" option must be set on the first "spawn()" call in the pipeline.'); 25 | } 26 | 27 | if (stdout === null) { 28 | throw new Error('The "stdout" option must be set on the last "spawn()" call in the pipeline.'); 29 | } 30 | 31 | // Do not `await` nor handle stream errors since this is already done by each subprocess 32 | // eslint-disable-next-line promise/prefer-await-to-then 33 | pipeline(stdout, stdin).catch(() => {}); 34 | } catch (error) { 35 | await Promise.allSettled(subprocesses.map(({nodeChildProcess}) => closeStdin(nodeChildProcess))); 36 | throw error; 37 | } 38 | }; 39 | 40 | const closeStdin = async nodeChildProcess => { 41 | const {stdin} = await nodeChildProcess; 42 | stdin.end(); 43 | }; 44 | -------------------------------------------------------------------------------- /source/result.js: -------------------------------------------------------------------------------- 1 | import {once, on} from 'node:events'; 2 | import process from 'node:process'; 3 | 4 | export const getResult = async (nodeChildProcess, {input}, context) => { 5 | const instance = await nodeChildProcess; 6 | if (input !== undefined) { 7 | instance.stdin.end(input); 8 | } 9 | 10 | const onClose = once(instance, 'close'); 11 | 12 | try { 13 | await Promise.race([ 14 | onClose, 15 | ...instance.stdio.filter(Boolean).map(stream => onStreamError(stream)), 16 | ]); 17 | checkFailure(context, getErrorOutput(instance)); 18 | return getOutputs(context); 19 | } catch (error) { 20 | await Promise.allSettled([onClose]); 21 | throw getResultError(error, instance, context); 22 | } 23 | }; 24 | 25 | const onStreamError = async stream => { 26 | for await (const [error] of on(stream, 'error')) { 27 | // Ignore errors that are due to closing errors when the subprocesses exit normally, or due to piping 28 | if (!['ERR_STREAM_PREMATURE_CLOSE', 'EPIPE'].includes(error?.code)) { 29 | throw error; 30 | } 31 | } 32 | }; 33 | 34 | const checkFailure = ({command}, {exitCode, signalName}) => { 35 | if (signalName !== undefined) { 36 | throw new SubprocessError(`Command was terminated with ${signalName}: ${command}`); 37 | } 38 | 39 | if (exitCode !== undefined) { 40 | throw new SubprocessError(`Command failed with exit code ${exitCode}: ${command}`); 41 | } 42 | }; 43 | 44 | export const getResultError = (error, instance, context) => Object.assign( 45 | getErrorInstance(error, context), 46 | getErrorOutput(instance), 47 | getOutputs(context), 48 | ); 49 | 50 | const getErrorInstance = (error, {command}) => error instanceof SubprocessError 51 | ? error 52 | : new SubprocessError(`Command failed: ${command}`, {cause: error}); 53 | 54 | export class SubprocessError extends Error { 55 | name = 'SubprocessError'; 56 | } 57 | 58 | const getErrorOutput = ({exitCode, signalCode}) => ({ 59 | // `exitCode` can be a negative number (`errno`) when the `error` event is emitted on the `instance` 60 | ...(exitCode < 1 ? {} : {exitCode}), 61 | ...(signalCode === null ? {} : {signalName: signalCode}), 62 | }); 63 | 64 | const getOutputs = ({state: {stdout, stderr, output}, command, start}) => ({ 65 | stdout: getOutput(stdout), 66 | stderr: getOutput(stderr), 67 | output: getOutput(output), 68 | command, 69 | durationMs: Number(process.hrtime.bigint() - start) / 1e6, 70 | }); 71 | 72 | const getOutput = output => output.at(-1) === '\n' 73 | ? output.slice(0, output.at(-2) === '\r' ? -2 : -1) 74 | : output; 75 | -------------------------------------------------------------------------------- /source/spawn.js: -------------------------------------------------------------------------------- 1 | import {spawn} from 'node:child_process'; 2 | import {once} from 'node:events'; 3 | import process from 'node:process'; 4 | import {applyForceShell} from './windows.js'; 5 | import {getResultError} from './result.js'; 6 | 7 | export const spawnSubprocess = async (file, commandArguments, options, context) => { 8 | try { 9 | // When running `node`, keep the current Node version and CLI flags. 10 | // Not applied with file paths to `.../node` since those indicate a clear intent to use a specific Node version. 11 | // This also provides a way to opting out, e.g. using `process.execPath` instead of `node` to discard current CLI flags. 12 | // Does not work with shebangs, but those don't work cross-platform anyway. 13 | if (['node', 'node.exe'].includes(file.toLowerCase())) { 14 | file = process.execPath; 15 | commandArguments = [...process.execArgv.filter(flag => !flag.startsWith('--inspect')), ...commandArguments]; 16 | } 17 | 18 | [file, commandArguments, options] = await applyForceShell(file, commandArguments, options); 19 | [file, commandArguments, options] = concatenateShell(file, commandArguments, options); 20 | const instance = spawn(file, commandArguments, options); 21 | bufferOutput(instance.stdout, context, 'stdout'); 22 | bufferOutput(instance.stderr, context, 'stderr'); 23 | 24 | // The `error` event is caught by `once(instance, 'spawn')` and `once(instance, 'close')`. 25 | // But it creates an uncaught exception if it happens exactly one tick after 'spawn'. 26 | // This prevents that. 27 | instance.once('error', () => {}); 28 | 29 | await once(instance, 'spawn'); 30 | return instance; 31 | } catch (error) { 32 | throw getResultError(error, {}, context); 33 | } 34 | }; 35 | 36 | // When the `shell` option is set, any command argument is concatenated as a single string by Node.js: 37 | // https://github.com/nodejs/node/blob/e38ce27f3ca0a65f68a31cedd984cddb927d4002/lib/child_process.js#L614-L624 38 | // However, since Node 24, it also prints a deprecation warning. 39 | // To avoid this warning, we perform that same operation before calling `node:child_process`. 40 | // Shells only understand strings, which is why Node.js performs that concatenation. 41 | // However, we rely on users splitting command arguments as an array. 42 | // For example, this allows us to easily detect whether the binary file is `node` or `node.exe`. 43 | // So we do want users to pass array of arguments even with `shell: true`, but we also want to avoid any warning. 44 | const concatenateShell = (file, commandArguments, options) => options.shell && commandArguments.length > 0 45 | ? [[file, ...commandArguments].join(' '), [], options] 46 | : [file, commandArguments, options]; 47 | 48 | const bufferOutput = (stream, {state}, streamName) => { 49 | if (stream) { 50 | stream.setEncoding('utf8'); 51 | if (!state.isIterating) { 52 | state.isIterating = false; 53 | stream.on('data', chunk => { 54 | state[streamName] += chunk; 55 | state.output += chunk; 56 | }); 57 | } 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /source/windows.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | import process from 'node:process'; 4 | 5 | // When setting `shell: true` under-the-hood, we must manually escape the file and arguments. 6 | // This ensures arguments are properly split, and prevents command injection. 7 | export const applyForceShell = async (file, commandArguments, options) => await shouldForceShell(file, options) 8 | ? [escapeFile(file), commandArguments.map(argument => escapeArgument(argument)), {...options, shell: true}] 9 | : [file, commandArguments, options]; 10 | 11 | // On Windows, running most executable files (except *.exe and *.com) requires using a shell. 12 | // This includes *.cmd and *.bat, which itself includes Node modules binaries. 13 | // We detect this situation and automatically: 14 | // - Set the `shell: true` option 15 | // - Escape shell-specific characters 16 | const shouldForceShell = async (file, {shell, cwd, env = process.env}) => process.platform === 'win32' 17 | && !shell 18 | && !(await isExe(file, cwd, env)); 19 | 20 | // Detect whether the executable file is a *.exe or *.com file. 21 | // Windows allows omitting file extensions (present in the `PATHEXT` environment variable). 22 | // Therefore we must use the `PATH` environment variable and make `access` calls to check this. 23 | // Environment variables are case-insensitive on Windows, so we check both `PATH` and `Path`. 24 | const isExe = (file, cwd, {Path = '', PATH = Path}) => 25 | // If the *.exe or *.com file extension was not omitted. 26 | // Windows common file systems are case-insensitive. 27 | exeExtensions.some(extension => file.toLowerCase().endsWith(extension)) 28 | || mIsExe(file, cwd, PATH); 29 | 30 | // Memoize the `mIsExe` and `fs.access`, for performance 31 | const EXE_MEMO = {}; 32 | // eslint-disable-next-line no-return-assign 33 | const memoize = function_ => (...arguments_) => 34 | // Use returned assignment to keep code small 35 | EXE_MEMO[arguments_.join('\0')] ??= function_(...arguments_); 36 | 37 | const access = memoize(fs.access); 38 | const mIsExe = memoize(async (file, cwd, PATH) => { 39 | const parts = PATH 40 | // `PATH` is ;-separated on Windows 41 | .split(path.delimiter) 42 | // `PATH` allows leading/trailing ; on Windows 43 | .filter(Boolean) 44 | // `PATH` parts can be double quoted on Windows 45 | .map(part => part.replace(/^"(.*)"$/, '$1')); 46 | 47 | // For performance, parallelize and stop iteration as soon as an *.exe or *.com file is found 48 | try { 49 | await Promise.any( 50 | [cwd, ...parts].flatMap(part => exeExtensions 51 | .map(extension => access(`${path.resolve(part, file)}${extension}`)), 52 | ), 53 | ); 54 | } catch { 55 | return false; 56 | } 57 | 58 | return true; 59 | }); 60 | 61 | // Other file extensions require using a shell 62 | const exeExtensions = ['.exe', '.com']; 63 | 64 | // `cmd.exe` escaping for arguments. 65 | // Taken from https://github.com/moxystudio/node-cross-spawn 66 | const escapeArgument = argument => escapeFile(escapeFile(`"${argument 67 | .replaceAll(/(\\*)"/g, '$1$1\\"') 68 | .replace(/(\\*)$/, '$1$1')}"`)); 69 | 70 | // `cmd.exe` escaping for file and arguments. 71 | const escapeFile = file => file.replaceAll(/([()\][%!^"`<>&|;, *?])/g, '^$1'); 72 | -------------------------------------------------------------------------------- /test/context.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {red} from 'yoctocolors'; 3 | import spawn from '../source/index.js'; 4 | import {testString} from './helpers/arguments.js'; 5 | import {assertDurationMs} from './helpers/assert.js'; 6 | import {nodePrint, nodePrintFail, nodePrintStdout} from './helpers/commands.js'; 7 | 8 | test('result.command does not quote normal arguments', async t => { 9 | const {command} = await spawn('node', ['--version']); 10 | t.is(command, 'node --version'); 11 | }); 12 | 13 | const testCommandEscaping = async (t, input, expectedCommand) => { 14 | const {command, stdout} = await spawn(...nodePrint(`"${input}"`)); 15 | t.is(command, `node -p '"${expectedCommand}"'`); 16 | t.is(stdout, input); 17 | }; 18 | 19 | test('result.command quotes spaces', testCommandEscaping, '. .', '. .'); 20 | test('result.command quotes single quotes', testCommandEscaping, '\'', '\'\\\'\''); 21 | test('result.command quotes unusual characters', testCommandEscaping, ',', ','); 22 | test('result.command strips ANSI sequences', testCommandEscaping, red(testString), testString); 23 | 24 | test('result.durationMs is set', async t => { 25 | const {durationMs} = await spawn(...nodePrintStdout); 26 | assertDurationMs(t, durationMs); 27 | }); 28 | 29 | test('error.durationMs is set', async t => { 30 | const {durationMs} = await t.throwsAsync(spawn(...nodePrintFail)); 31 | assertDurationMs(t, durationMs); 32 | }); 33 | -------------------------------------------------------------------------------- /test/fixtures/()[]%0!`.cmd: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | echo %0 3 | node echo-file.js %0 4 | -------------------------------------------------------------------------------- /test/fixtures/echo-file.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import process from 'node:process'; 3 | 4 | console.log(process.argv[2]); 5 | -------------------------------------------------------------------------------- /test/fixtures/echo.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import process from 'node:process'; 3 | 4 | console.log(process.argv[2]); 5 | -------------------------------------------------------------------------------- /test/fixtures/node-flags-path.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import process from 'node:process'; 3 | import spawn from '../../source/index.js'; 4 | 5 | await spawn(process.execPath, ['-p', 'process.execArgv'], {stdout: 'inherit'}); 6 | -------------------------------------------------------------------------------- /test/fixtures/node-flags.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import spawn from '../../source/index.js'; 3 | 4 | await spawn('node', ['-p', 'process.execArgv'], {stdout: 'inherit'}); 5 | -------------------------------------------------------------------------------- /test/fixtures/node-version.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import spawn from '../../source/index.js'; 3 | 4 | await spawn('node', ['--version'], {stdout: 'inherit'}); 5 | -------------------------------------------------------------------------------- /test/fixtures/node_modules/.bin/git: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | console.log('test'); 3 | -------------------------------------------------------------------------------- /test/fixtures/shebang.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | console.log('test'); 3 | -------------------------------------------------------------------------------- /test/fixtures/spawnecho.cmd: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | node echo.js %* 3 | -------------------------------------------------------------------------------- /test/fixtures/subdir/node_modules/.bin/git: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | console.log('secondTest'); 3 | -------------------------------------------------------------------------------- /test/fixtures/test.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line ava/no-ignored-test-files 2 | import process from 'node:process'; 3 | import test from 'ava'; 4 | 5 | test('Dummy test', t => { 6 | console.error(process.argv[2]); 7 | t.pass(); 8 | }); 9 | -------------------------------------------------------------------------------- /test/fixtures/test.txt: -------------------------------------------------------------------------------- 1 | test 2 | -------------------------------------------------------------------------------- /test/helpers/arguments.js: -------------------------------------------------------------------------------- 1 | export const testString = 'test'; 2 | export const secondTestString = 'secondTest'; 3 | export const thirdTestString = 'thirdTest'; 4 | export const fourthTestString = 'fourthTest'; 5 | export const testUpperCase = testString.toUpperCase(); 6 | export const testDouble = `${testString}${testString}`; 7 | export const testDoubleUpperCase = `${testUpperCase}${testUpperCase}`; 8 | 9 | export const multibyteString = '.\u{1F984}.'; 10 | const multibyteUint8Array = new TextEncoder().encode(multibyteString); 11 | export const multibyteFirstHalf = multibyteUint8Array.slice(0, 3); 12 | export const multibyteSecondHalf = multibyteUint8Array.slice(3); 13 | -------------------------------------------------------------------------------- /test/helpers/assert.js: -------------------------------------------------------------------------------- 1 | import {nonExistentCommand, nodeHangingCommand, nodeEvalCommandStart} from './commands.js'; 2 | 3 | export const assertSubprocessErrorName = (t, name) => { 4 | t.is(name, 'SubprocessError'); 5 | }; 6 | 7 | export const assertDurationMs = (t, durationMs) => { 8 | t.true(Number.isFinite(durationMs)); 9 | t.true(durationMs >= 0); 10 | }; 11 | 12 | export const assertNonExistent = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, commandStart = nonExistentCommand, expectedCommand = commandStart) => { 13 | assertSubprocessErrorName(t, name); 14 | t.is(exitCode, undefined); 15 | t.is(signalName, undefined); 16 | t.is(command, expectedCommand); 17 | t.is(message, `Command failed: ${expectedCommand}`); 18 | t.is(stderr, ''); 19 | t.true(cause.message.includes(commandStart)); 20 | t.is(cause.code, 'ENOENT'); 21 | t.is(cause.syscall, `spawn ${commandStart}`); 22 | t.is(cause.path, commandStart); 23 | assertDurationMs(t, durationMs); 24 | }; 25 | 26 | export const assertWindowsNonExistent = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCommand = nonExistentCommand) => { 27 | assertSubprocessErrorName(t, name); 28 | t.is(exitCode, 1); 29 | t.is(signalName, undefined); 30 | t.is(command, expectedCommand); 31 | t.is(message, `Command failed with exit code 1: ${expectedCommand}`); 32 | t.true(stderr.includes('not recognized as an internal or external command')); 33 | t.is(cause, undefined); 34 | assertDurationMs(t, durationMs); 35 | }; 36 | 37 | export const assertUnixNonExistentShell = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCommand = nonExistentCommand) => { 38 | assertSubprocessErrorName(t, name); 39 | t.is(exitCode, 127); 40 | t.is(signalName, undefined); 41 | t.is(command, expectedCommand); 42 | t.is(message, `Command failed with exit code 127: ${expectedCommand}`); 43 | t.true(stderr.includes('not found')); 44 | t.is(cause, undefined); 45 | assertDurationMs(t, durationMs); 46 | }; 47 | 48 | export const assertUnixNotFound = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCommand = nonExistentCommand) => { 49 | assertSubprocessErrorName(t, name); 50 | t.is(exitCode, 127); 51 | t.is(signalName, undefined); 52 | t.is(command, expectedCommand); 53 | t.is(message, `Command failed with exit code 127: ${expectedCommand}`); 54 | t.true(stderr.includes('No such file or directory')); 55 | t.is(cause, undefined); 56 | assertDurationMs(t, durationMs); 57 | }; 58 | 59 | export const assertFail = (t, {name, exitCode, signalName, command, message, cause, durationMs}, commandStart = nodeEvalCommandStart) => { 60 | assertSubprocessErrorName(t, name); 61 | t.is(exitCode, 2); 62 | t.is(signalName, undefined); 63 | t.true(command.startsWith(commandStart)); 64 | t.true(message.startsWith(`Command failed with exit code 2: ${commandStart}`)); 65 | t.is(cause, undefined); 66 | assertDurationMs(t, durationMs); 67 | }; 68 | 69 | export const assertSigterm = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCommand = nodeHangingCommand) => { 70 | assertSubprocessErrorName(t, name); 71 | t.is(exitCode, undefined); 72 | t.is(signalName, 'SIGTERM'); 73 | t.is(command, expectedCommand); 74 | t.is(message, `Command was terminated with SIGTERM: ${expectedCommand}`); 75 | t.is(stderr, ''); 76 | t.is(cause, undefined); 77 | assertDurationMs(t, durationMs); 78 | }; 79 | 80 | export const assertEarlyError = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, commandStart = nodeEvalCommandStart) => { 81 | assertSubprocessErrorName(t, name); 82 | t.is(exitCode, undefined); 83 | t.is(signalName, undefined); 84 | t.true(command.startsWith(commandStart)); 85 | t.true(message.startsWith(`Command failed: ${commandStart}`)); 86 | t.is(stderr, ''); 87 | t.true(cause.message.includes('options.detached')); 88 | t.false(cause.message.includes('Command')); 89 | assertDurationMs(t, durationMs); 90 | }; 91 | 92 | export const assertAbortError = (t, {name, exitCode, signalName, command, stderr, message, cause, durationMs}, expectedCause, expectedCommand = nodeHangingCommand) => { 93 | assertSubprocessErrorName(t, name); 94 | t.is(exitCode, undefined); 95 | t.is(signalName, undefined); 96 | t.is(command, expectedCommand); 97 | t.is(message, `Command failed: ${expectedCommand}`); 98 | t.is(stderr, ''); 99 | t.is(cause.message, 'The operation was aborted'); 100 | t.is(cause.cause, expectedCause); 101 | assertDurationMs(t, durationMs); 102 | }; 103 | 104 | export const assertErrorEvent = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCause, commandStart = nodeEvalCommandStart) => { 105 | assertSubprocessErrorName(t, name); 106 | t.is(exitCode, undefined); 107 | t.is(signalName, undefined); 108 | t.true(command.startsWith(commandStart)); 109 | t.true(message.startsWith(`Command failed: ${commandStart}`)); 110 | t.is(stderr, ''); 111 | t.is(cause, expectedCause); 112 | assertDurationMs(t, durationMs); 113 | }; 114 | -------------------------------------------------------------------------------- /test/helpers/commands.js: -------------------------------------------------------------------------------- 1 | import logProcessErrors from 'log-process-errors'; 2 | import {testString, secondTestString} from './arguments.js'; 3 | 4 | // Make tests fail if any warning (such as a deprecation warning) is emitted 5 | logProcessErrors({ 6 | exit: true, 7 | onError(error) { 8 | throw error; 9 | }, 10 | }); 11 | 12 | export const nodeHanging = ['node']; 13 | export const [nodeHangingCommand] = nodeHanging; 14 | export const nodePrint = bodyString => ['node', ['-p', bodyString]]; 15 | export const nodeEval = bodyString => ['node', ['-e', bodyString]]; 16 | export const nodeEvalCommandStart = 'node -e'; 17 | export const nodePrintStdout = nodeEval(`console.log("${testString}")`); 18 | export const nodePrintStderr = nodeEval(`console.error("${testString}")`); 19 | export const nodePrintBoth = nodeEval(`console.log("${testString}"); 20 | setTimeout(() => { 21 | console.error("${secondTestString}"); 22 | }, 0);`); 23 | export const nodePrintBothFail = nodeEval(`console.log("${testString}"); 24 | setTimeout(() => { 25 | console.error("${secondTestString}"); 26 | process.exit(2); 27 | }, 0);`); 28 | export const nodePrintFail = nodeEval(`console.log("${testString}"); 29 | process.exit(2);`); 30 | export const nodePrintSleep = nodeEval(`setTimeout(() => { 31 | console.log("${testString}"); 32 | }, 1e2);`); 33 | export const nodePrintSleepFail = nodeEval(`setTimeout(() => { 34 | console.log("${testString}"); 35 | process.exit(2); 36 | }, 1e2);`); 37 | export const nodePrintArgv0 = nodePrint('process.argv0'); 38 | export const nodePrintNoNewline = output => nodeEval(`process.stdout.write("${output.replaceAll('\n', '\\n').replaceAll('\r', '\\r')}")`); 39 | export const nodePassThrough = nodeEval('process.stdin.pipe(process.stdout)'); 40 | export const nodePassThroughPrint = nodeEval(`process.stdin.pipe(process.stdout); 41 | console.log("${testString}");`); 42 | export const nodePassThroughPrintFail = nodeEval(`process.stdin.once("data", (chunk) => { 43 | console.log(chunk.toString()); 44 | process.exit(2); 45 | }); 46 | console.log("${testString}");`); 47 | export const nodeToUpperCase = nodeEval(`process.stdin.on("data", chunk => { 48 | console.log(chunk.toString().trim().toUpperCase()); 49 | });`); 50 | export const nodeToUpperCaseStderr = nodeEval(`process.stdin.on("data", chunk => { 51 | console.error(chunk.toString().trim().toUpperCase()); 52 | });`); 53 | export const nodeToUpperCaseFail = nodeEval(`process.stdin.on("data", chunk => { 54 | console.log(chunk.toString().trim().toUpperCase()); 55 | process.exit(2); 56 | });`); 57 | export const nodeDouble = nodeEval(`process.stdin.on("data", chunk => { 58 | console.log(chunk.toString().trim() + chunk.toString().trim()); 59 | });`); 60 | export const nodeDoubleFail = nodeEval(`process.stdin.on("data", chunk => { 61 | console.log(chunk.toString().trim() + chunk.toString().trim()); 62 | process.exit(2); 63 | });`); 64 | export const localBinary = ['ava', ['--version']]; 65 | export const localBinaryCommand = localBinary.flat().join(' '); 66 | export const [localBinaryCommandStart] = localBinary; 67 | export const nonExistentCommand = 'non-existent-command'; 68 | -------------------------------------------------------------------------------- /test/helpers/main.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import process from 'node:process'; 3 | import {setTimeout} from 'node:timers/promises'; 4 | import {fileURLToPath} from 'node:url'; 5 | import {multibyteFirstHalf, multibyteSecondHalf} from './arguments.js'; 6 | 7 | export const isLinux = process.platform === 'linux'; 8 | export const isWindows = process.platform === 'win32'; 9 | 10 | export const FIXTURES_URL = new URL('../fixtures/', import.meta.url); 11 | export const fixturesPath = fileURLToPath(FIXTURES_URL); 12 | 13 | export const nodeDirectory = path.dirname(process.execPath); 14 | 15 | export const NODE_VERSION = Number(process.version.slice(1).split('.')[0]); 16 | 17 | export const earlyErrorOptions = {detached: 'true'}; 18 | 19 | // TODO: replace with Array.fromAsync() after dropping support for Node <22.0.0 20 | export const arrayFromAsync = async asyncIterable => { 21 | const chunks = []; 22 | for await (const chunk of asyncIterable) { 23 | chunks.push(chunk); 24 | } 25 | 26 | return chunks; 27 | }; 28 | 29 | export const destroySubprocessStream = async (subprocess, error, streamName) => { 30 | const nodeChildProcess = await subprocess.nodeChildProcess; 31 | nodeChildProcess[streamName].destroy(error); 32 | }; 33 | 34 | export const writeMultibyte = async subprocess => { 35 | const {stdin} = await subprocess.nodeChildProcess; 36 | stdin.write(multibyteFirstHalf); 37 | await setTimeout(1e2); 38 | stdin.end(multibyteSecondHalf); 39 | }; 40 | -------------------------------------------------------------------------------- /test/helpers/setup.js: -------------------------------------------------------------------------------- 1 | import childProcess from 'node:child_process'; 2 | import process from 'node:process'; 3 | import assert from 'node:assert/strict'; 4 | 5 | const getCodePage = () => childProcess.execSync('chcp', {encoding: 'utf8'}) 6 | .trim() 7 | .split(' ') 8 | .pop(); 9 | 10 | const updateCodePage = codePage => { 11 | childProcess.execSync(`chcp ${codePage}`); 12 | assert(getCodePage() === codePage); 13 | }; 14 | 15 | // On Windows in simplified chinese, the default code page is 936 16 | // Run `chcp 65001` to change it to UTF8 17 | // https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/chcp 18 | export default function setup() { 19 | if (process.platform !== 'win32') { 20 | return; 21 | } 22 | 23 | const originalCodePage = getCodePage(); 24 | if (originalCodePage === '936') { 25 | updateCodePage('65001'); 26 | 27 | process.on('exit', () => { 28 | updateCodePage(originalCodePage); 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import spawn from '../source/index.js'; 3 | import {assertSigterm} from './helpers/assert.js'; 4 | import {nodePrintStdout, nodeHanging, nodePrint} from './helpers/commands.js'; 5 | 6 | test('Can pass no arguments', async t => { 7 | const error = await t.throwsAsync(spawn(...nodeHanging, {timeout: 1})); 8 | assertSigterm(t, error); 9 | }); 10 | 11 | test('Can pass no arguments nor options', async t => { 12 | const subprocess = spawn(...nodeHanging); 13 | const nodeChildProcess = await subprocess.nodeChildProcess; 14 | nodeChildProcess.kill(); 15 | const error = await t.throwsAsync(subprocess); 16 | assertSigterm(t, error); 17 | }); 18 | 19 | test('Returns a promise', async t => { 20 | const subprocess = spawn(...nodePrintStdout); 21 | t.false(Object.prototype.propertyIsEnumerable.call(subprocess, 'then')); 22 | t.false(Object.hasOwn(subprocess, 'then')); 23 | t.true(subprocess instanceof Promise); 24 | await subprocess; 25 | }); 26 | 27 | test('subprocess.nodeChildProcess is set', async t => { 28 | const subprocess = spawn(...nodePrintStdout); 29 | const nodeChildProcess = await subprocess.nodeChildProcess; 30 | t.true(Number.isInteger(nodeChildProcess.pid)); 31 | await subprocess; 32 | }); 33 | 34 | const PARALLEL_COUNT = 100; 35 | 36 | test.serial('Can run many times at once', async t => { 37 | const inputs = Array.from({length: PARALLEL_COUNT}, (_, index) => `${index}`); 38 | const results = await Promise.all(inputs.map(input => spawn(...nodePrint(input)))); 39 | t.deepEqual(results.map(({output}) => output), inputs); 40 | }); 41 | -------------------------------------------------------------------------------- /test/iterable.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import spawn from '../source/index.js'; 3 | import {arrayFromAsync, destroySubprocessStream, writeMultibyte} from './helpers/main.js'; 4 | import { 5 | testString, 6 | secondTestString, 7 | thirdTestString, 8 | fourthTestString, 9 | multibyteString, 10 | } from './helpers/arguments.js'; 11 | import {assertFail, assertErrorEvent} from './helpers/assert.js'; 12 | import { 13 | nodeEval, 14 | nodePrintStdout, 15 | nodePrintStderr, 16 | nodePrintBoth, 17 | nodePrintBothFail, 18 | nodePrintNoNewline, 19 | nodePassThrough, 20 | nodePassThroughPrint, 21 | nodePassThroughPrintFail, 22 | } from './helpers/commands.js'; 23 | 24 | const getIterable = (subprocess, iterableType) => iterableType === '' 25 | ? subprocess 26 | : subprocess[iterableType]; 27 | 28 | test('subprocess.stdout can be iterated', async t => { 29 | const subprocess = spawn(...nodePrintStdout); 30 | const lines = await arrayFromAsync(subprocess.stdout); 31 | t.deepEqual(lines, [testString]); 32 | const {stdout, output} = await subprocess; 33 | t.is(stdout, ''); 34 | t.is(output, ''); 35 | }); 36 | 37 | test('subprocess.stderr can be iterated', async t => { 38 | const subprocess = spawn(...nodePrintStderr); 39 | const lines = await arrayFromAsync(subprocess.stderr); 40 | t.deepEqual(lines, [testString]); 41 | const {stderr, output} = await subprocess; 42 | t.is(stderr, ''); 43 | t.is(output, ''); 44 | }); 45 | 46 | test('subprocess[Symbol.asyncIterator] can be iterated', async t => { 47 | const subprocess = spawn(...nodeEval(`console.log("${testString}"); 48 | console.log("${secondTestString}"); 49 | console.error("${thirdTestString}"); 50 | console.error("${fourthTestString}");`)); 51 | 52 | const lines = await arrayFromAsync(subprocess); 53 | t.deepEqual(lines, [testString, secondTestString, thirdTestString, fourthTestString]); 54 | 55 | const {stdout, stderr, output} = await subprocess; 56 | t.is(stdout, ''); 57 | t.is(stderr, ''); 58 | t.is(output, ''); 59 | }); 60 | 61 | test.serial('subprocess iteration can be interleaved', async t => { 62 | const length = 10; 63 | const subprocess = spawn('node', ['--input-type=module', '-e', ` 64 | import {setTimeout} from 'node:timers/promises'; 65 | 66 | for (let index = 0; index < ${length}; index += 1) { 67 | console.log("${testString}"); 68 | await setTimeout(50); 69 | console.error("${secondTestString}"); 70 | await setTimeout(50); 71 | }`]); 72 | 73 | const lines = await arrayFromAsync(subprocess); 74 | t.deepEqual(lines, Array.from({length}, () => [testString, secondTestString]).flat()); 75 | 76 | const {stdout, stderr, output} = await subprocess; 77 | t.is(stdout, ''); 78 | t.is(stderr, ''); 79 | t.is(output, ''); 80 | }); 81 | 82 | test('subprocess.stdout has no iterations if options.stdout "ignore"', async t => { 83 | const subprocess = spawn(...nodePrintBoth, {stdout: 'ignore'}); 84 | const [stdoutLines, stderrLines] = await Promise.all([arrayFromAsync(subprocess.stdout), arrayFromAsync(subprocess.stderr)]); 85 | t.deepEqual(stdoutLines, []); 86 | t.deepEqual(stderrLines, [secondTestString]); 87 | const {stdout, stderr, output} = await subprocess; 88 | t.is(stdout, ''); 89 | t.is(stderr, ''); 90 | t.is(output, ''); 91 | }); 92 | 93 | test('subprocess.stderr has no iterations if options.stderr "ignore"', async t => { 94 | const subprocess = spawn(...nodePrintBoth, {stderr: 'ignore'}); 95 | const [stdoutLines, stderrLines] = await Promise.all([arrayFromAsync(subprocess.stdout), arrayFromAsync(subprocess.stderr)]); 96 | t.deepEqual(stdoutLines, [testString]); 97 | t.deepEqual(stderrLines, []); 98 | const {stdout, stderr, output} = await subprocess; 99 | t.is(stdout, ''); 100 | t.is(stderr, ''); 101 | t.is(output, ''); 102 | }); 103 | 104 | test('subprocess[Symbol.asyncIterator] has iterations if only options.stdout "ignore"', async t => { 105 | const subprocess = spawn(...nodePrintBoth, {stdout: 'ignore'}); 106 | const lines = await arrayFromAsync(subprocess); 107 | t.deepEqual(lines, [secondTestString]); 108 | const {stdout, stderr, output} = await subprocess; 109 | t.is(stdout, ''); 110 | t.is(stderr, ''); 111 | t.is(output, ''); 112 | }); 113 | 114 | test('subprocess[Symbol.asyncIterator] has iterations if only options.stderr "ignore"', async t => { 115 | const subprocess = spawn(...nodePrintBoth, {stderr: 'ignore'}); 116 | const lines = await arrayFromAsync(subprocess); 117 | t.deepEqual(lines, [testString]); 118 | const {stdout, stderr, output} = await subprocess; 119 | t.is(stdout, ''); 120 | t.is(stderr, ''); 121 | t.is(output, ''); 122 | }); 123 | 124 | test('subprocess[Symbol.asyncIterator] has no iterations if only options.stdout + options.stderr "ignore"', async t => { 125 | const subprocess = spawn(...nodePrintBoth, {stdout: 'ignore', stderr: 'ignore'}); 126 | const lines = await arrayFromAsync(subprocess); 127 | t.deepEqual(lines, []); 128 | const {stdout, stderr, output} = await subprocess; 129 | t.is(stdout, ''); 130 | t.is(stderr, ''); 131 | t.is(output, ''); 132 | }); 133 | 134 | test('subprocess.stdout has no iterations but waits for the subprocess if options.stdout "ignore"', async t => { 135 | const subprocess = spawn(...nodePrintBothFail, {stdout: 'ignore'}); 136 | const error = await t.throwsAsync(arrayFromAsync(subprocess.stdout)); 137 | assertFail(t, error); 138 | const promiseError = await t.throwsAsync(subprocess); 139 | t.is(promiseError, error); 140 | t.is(promiseError.stdout, ''); 141 | t.is(promiseError.stderr, ''); 142 | t.is(promiseError.output, ''); 143 | }); 144 | 145 | const testIterationLate = async (t, iterableType) => { 146 | const subprocess = spawn(...nodePrintStdout); 147 | await subprocess.nodeChildProcess; 148 | await t.throwsAsync(arrayFromAsync(getIterable(subprocess, iterableType)), {message: /must be iterated right away/}); 149 | }; 150 | 151 | test('subprocess.stdout must be called right away', testIterationLate, 'stdout'); 152 | test('subprocess.stderr must be called right away', testIterationLate, 'stderr'); 153 | test('subprocess[Symbol.asyncIterator] must be called right away', testIterationLate, ''); 154 | 155 | test('subprocess[Symbol.asyncIterator] is line-wise', async t => { 156 | const subprocess = spawn('node', ['--input-type=module', '-e', ` 157 | import {setTimeout} from 'node:timers/promises'; 158 | 159 | process.stdout.write("a\\nb\\n"); 160 | await setTimeout(0); 161 | process.stderr.write("c\\nd\\n");`]); 162 | const lines = await arrayFromAsync(subprocess); 163 | t.deepEqual(lines, ['a', 'b', 'c', 'd']); 164 | }); 165 | 166 | const testNewlineIteration = async (t, input, expectedLines) => { 167 | const subprocess = spawn(...nodePrintNoNewline(input)); 168 | const lines = await arrayFromAsync(subprocess.stdout); 169 | t.deepEqual(lines, expectedLines); 170 | }; 171 | 172 | test('subprocess.stdout handles newline at the beginning', testNewlineIteration, '\na\nb', ['', 'a', 'b']); 173 | test('subprocess.stdout handles newline in the middle', testNewlineIteration, 'a\nb', ['a', 'b']); 174 | test('subprocess.stdout handles newline at the end', testNewlineIteration, 'a\nb\n', ['a', 'b']); 175 | test('subprocess.stdout handles Windows newline at the beginning', testNewlineIteration, '\r\na\r\nb', ['', 'a', 'b']); 176 | test('subprocess.stdout handles Windows newline in the middle', testNewlineIteration, 'a\r\nb', ['a', 'b']); 177 | test('subprocess.stdout handles Windows newline at the end', testNewlineIteration, 'a\r\nb\r\n', ['a', 'b']); 178 | test('subprocess.stdout handles 2 newlines at the beginning', testNewlineIteration, '\n\na\nb', ['', '', 'a', 'b']); 179 | test('subprocess.stdout handles 2 newlines in the middle', testNewlineIteration, 'a\n\nb', ['a', '', 'b']); 180 | test('subprocess.stdout handles 2 newlines at the end', testNewlineIteration, 'a\nb\n\n', ['a', 'b', '']); 181 | test('subprocess.stdout handles 2 Windows newlines at the beginning', testNewlineIteration, '\r\n\r\na\r\nb', ['', '', 'a', 'b']); 182 | test('subprocess.stdout handles 2 Windows newlines in the middle', testNewlineIteration, 'a\r\n\r\nb', ['a', '', 'b']); 183 | test('subprocess.stdout handles 2 Windows newlines at the end', testNewlineIteration, 'a\r\nb\r\n\r\n', ['a', 'b', '']); 184 | 185 | test.serial('subprocess.stdout works with multibyte sequences', async t => { 186 | const subprocess = spawn(...nodePassThrough); 187 | writeMultibyte(subprocess); 188 | const lines = await arrayFromAsync(subprocess.stdout); 189 | t.deepEqual(lines, [multibyteString]); 190 | const {stdout, output} = await subprocess; 191 | t.is(stdout, ''); 192 | t.is(output, ''); 193 | }); 194 | 195 | const testStreamIterateError = async (t, streamName) => { 196 | const subprocess = spawn(...nodePrintStdout); 197 | const cause = new Error(testString); 198 | destroySubprocessStream(subprocess, cause, streamName); 199 | const error = await t.throwsAsync(arrayFromAsync(subprocess[streamName])); 200 | assertErrorEvent(t, error, cause); 201 | const promiseError = await t.throwsAsync(subprocess); 202 | assertErrorEvent(t, promiseError, cause); 203 | t.is(promiseError[streamName], ''); 204 | t.is(promiseError.output, ''); 205 | }; 206 | 207 | test('Handles subprocess.stdout error', testStreamIterateError, 'stdout'); 208 | test('Handles subprocess.stderr error', testStreamIterateError, 'stderr'); 209 | 210 | const testStreamIterateAllError = async (t, streamName) => { 211 | const subprocess = spawn(...nodePrintStdout); 212 | const cause = new Error(testString); 213 | destroySubprocessStream(subprocess, cause, streamName); 214 | const error = await t.throwsAsync(arrayFromAsync(subprocess)); 215 | assertErrorEvent(t, error, cause); 216 | const promiseError = await t.throwsAsync(subprocess); 217 | assertErrorEvent(t, promiseError, cause); 218 | t.is(promiseError[streamName], ''); 219 | t.is(promiseError.output, ''); 220 | }; 221 | 222 | test('Handles subprocess.stdout error in subprocess[Symbol.asyncIterator]', testStreamIterateAllError, 'stdout'); 223 | test('Handles subprocess.stderr error in subprocess[Symbol.asyncIterator]', testStreamIterateAllError, 'stderr'); 224 | 225 | // eslint-disable-next-line max-params 226 | const iterateOnOutput = async (t, subprocess, state, cause, shouldThrow, iterableType) => { 227 | // eslint-disable-next-line no-unreachable-loop 228 | for await (const line of getIterable(subprocess, iterableType)) { 229 | t.is(line, testString); 230 | 231 | globalThis.setTimeout(async () => { 232 | const {stdin, stdout} = await subprocess.nodeChildProcess; 233 | t.true(stdout.readable); 234 | t.true(stdin.writable); 235 | stdin.end(secondTestString); 236 | state.done = true; 237 | }, 1e2); 238 | 239 | if (shouldThrow) { 240 | throw cause; 241 | } else { 242 | break; 243 | } 244 | } 245 | }; 246 | 247 | const testIteration = async (t, shouldThrow, iterableType) => { 248 | const subprocess = spawn(...nodePassThroughPrint); 249 | const state = {done: false}; 250 | const cause = new Error(testString); 251 | 252 | try { 253 | await iterateOnOutput(t, subprocess, state, cause, shouldThrow, iterableType); 254 | } catch (error) { 255 | t.is(error, cause); 256 | } 257 | 258 | t.true(state.done); 259 | 260 | const {stdout, output} = await subprocess; 261 | t.is(stdout, ''); 262 | t.is(output, ''); 263 | }; 264 | 265 | test.serial('subprocess.stdout iteration break waits for the subprocess success', testIteration, false, 'stdout'); 266 | test.serial('subprocess[Symbol.asyncIterator] iteration break waits for the subprocess success', testIteration, false, ''); 267 | test.serial('subprocess.stdout iteration exception waits for the subprocess success', testIteration, true, 'stdout'); 268 | test.serial('subprocess[Symbol.asyncIterator] iteration exception waits for the subprocess success', testIteration, true, ''); 269 | 270 | const testIterationFail = async (t, shouldThrow, iterableType) => { 271 | const subprocess = spawn(...nodePassThroughPrintFail); 272 | const state = {done: false}; 273 | const cause = new Error(testString); 274 | let caughtError; 275 | 276 | try { 277 | await iterateOnOutput(t, subprocess, state, cause, shouldThrow, iterableType); 278 | } catch (error) { 279 | t.is(error === cause, shouldThrow); 280 | caughtError = error; 281 | } 282 | 283 | t.true(state.done); 284 | 285 | const promiseError = await t.throwsAsync(subprocess); 286 | assertFail(t, promiseError); 287 | t.is(promiseError === caughtError, !shouldThrow); 288 | t.is(promiseError.stdout, ''); 289 | t.is(promiseError.output, ''); 290 | }; 291 | 292 | test.serial('subprocess.stdout iteration break waits for the subprocess failure', testIterationFail, false, 'stdout'); 293 | test.serial('subprocess[Symbol.asyncIterator] iteration break waits for the subprocess failure', testIterationFail, false, ''); 294 | test.serial('subprocess.stdout iteration exception waits for the subprocess failure', testIterationFail, true, 'stdout'); 295 | test.serial('subprocess[Symbol.asyncIterator] iteration exception waits for the subprocess failure', testIterationFail, true, ''); 296 | -------------------------------------------------------------------------------- /test/options.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import process from 'node:process'; 3 | import {fileURLToPath} from 'node:url'; 4 | import test from 'ava'; 5 | import pathKey from 'path-key'; 6 | import spawn from '../source/index.js'; 7 | import { 8 | isWindows, 9 | FIXTURES_URL, 10 | fixturesPath, 11 | nodeDirectory, 12 | } from './helpers/main.js'; 13 | import {testString, secondTestString} from './helpers/arguments.js'; 14 | import { 15 | assertNonExistent, 16 | assertWindowsNonExistent, 17 | assertUnixNotFound, 18 | } from './helpers/assert.js'; 19 | import { 20 | nodePrint, 21 | nodePrintStdout, 22 | nodePrintArgv0, 23 | nodePassThrough, 24 | localBinary, 25 | localBinaryCommand, 26 | localBinaryCommandStart, 27 | } from './helpers/commands.js'; 28 | 29 | const VERSION_REGEXP = /^\d+\.\d+\.\d+$/; 30 | 31 | test.serial('options.env augments process.env', async t => { 32 | process.env.ONE = 'one'; 33 | process.env.TWO = 'two'; 34 | const {stdout} = await spawn(...nodePrint('process.env.ONE + process.env.TWO'), {env: {TWO: testString}}); 35 | t.is(stdout, `${process.env.ONE}${testString}`); 36 | delete process.env.ONE; 37 | delete process.env.TWO; 38 | }); 39 | 40 | const testArgv0 = async (t, shell) => { 41 | const {stdout} = await spawn(...nodePrintArgv0, {argv0: testString, shell}); 42 | t.is(stdout, shell ? process.execPath : testString); 43 | }; 44 | 45 | test('Can pass options.argv0', testArgv0, false); 46 | test('Can pass options.argv0, shell', testArgv0, true); 47 | 48 | const testCwd = async (t, cwd) => { 49 | const {stdout} = await spawn(...nodePrint('process.cwd()'), {cwd}); 50 | t.is(stdout, fixturesPath.replace(/[\\/]$/, '')); 51 | }; 52 | 53 | test('Can pass options.cwd string', testCwd, fixturesPath); 54 | test('Can pass options.cwd URL', testCwd, FIXTURES_URL); 55 | 56 | const testStdOption = async (t, optionName) => { 57 | const subprocess = spawn(...nodePrintStdout, {[optionName]: 'ignore'}); 58 | const nodeChildProcess = await subprocess.nodeChildProcess; 59 | t.is(nodeChildProcess[optionName], null); 60 | await subprocess; 61 | }; 62 | 63 | test('Can pass options.stdin', testStdOption, 'stdin'); 64 | test('Can pass options.stdout', testStdOption, 'stdout'); 65 | test('Can pass options.stderr', testStdOption, 'stderr'); 66 | 67 | const testStdOptionDefault = async (t, optionName) => { 68 | const subprocess = spawn(...nodePrintStdout); 69 | const nodeChildProcess = await subprocess.nodeChildProcess; 70 | t.not(nodeChildProcess[optionName], null); 71 | await subprocess; 72 | }; 73 | 74 | test('options.stdin defaults to "pipe"', testStdOptionDefault, 'stdin'); 75 | test('options.stdout defaults to "pipe"', testStdOptionDefault, 'stdout'); 76 | test('options.stderr defaults to "pipe"', testStdOptionDefault, 'stderr'); 77 | 78 | test('Can pass options.stdio array', async t => { 79 | const subprocess = spawn(...nodePrintStdout, {stdio: ['ignore', 'pipe', 'pipe', 'pipe']}); 80 | const {stdin, stdout, stderr, stdio} = await subprocess.nodeChildProcess; 81 | t.is(stdin, null); 82 | t.not(stdout, null); 83 | t.not(stderr, null); 84 | t.is(stdio.length, 4); 85 | await subprocess; 86 | }); 87 | 88 | test('Can pass options.stdio string', async t => { 89 | const subprocess = spawn(...nodePrintStdout, {stdio: 'ignore'}); 90 | const {stdin, stdout, stderr, stdio} = await subprocess.nodeChildProcess; 91 | t.is(stdin, null); 92 | t.is(stdout, null); 93 | t.is(stderr, null); 94 | t.is(stdio.length, 3); 95 | await subprocess; 96 | }); 97 | 98 | const testStdioPriority = async (t, stdio) => { 99 | const subprocess = spawn(...nodePrintStdout, {stdio, stdout: 'ignore'}); 100 | const {stdout} = await subprocess.nodeChildProcess; 101 | t.not(stdout, null); 102 | await subprocess; 103 | }; 104 | 105 | test('options.stdio array has priority over options.stdout', testStdioPriority, ['pipe', 'pipe', 'pipe']); 106 | test('options.stdio string has priority over options.stdout', testStdioPriority, 'pipe'); 107 | 108 | const testInput = async (t, options, expectedStdout) => { 109 | const {stdout} = await spawn(...nodePassThrough, options); 110 | t.is(stdout, expectedStdout); 111 | }; 112 | 113 | test('options.stdin can be {string: string}', testInput, {stdin: {string: testString}}, testString); 114 | test('options.stdio[0] can be {string: string}', testInput, {stdio: [{string: testString}, 'pipe', 'pipe']}, testString); 115 | test('options.stdin can be {string: ""}', testInput, {stdin: {string: ''}}, ''); 116 | test('options.stdio[0] can be {string: ""}', testInput, {stdio: [{string: ''}, 'pipe', 'pipe']}, ''); 117 | 118 | const testLocalBinaryExec = async (t, cwd) => { 119 | const {stdout} = await spawn(...localBinary, {preferLocal: true, cwd}); 120 | t.regex(stdout, VERSION_REGEXP); 121 | }; 122 | 123 | test('options.preferLocal true runs local npm binaries', testLocalBinaryExec, undefined); 124 | test('options.preferLocal true runs local npm binaries with options.cwd string', testLocalBinaryExec, fixturesPath); 125 | test('options.preferLocal true runs local npm binaries with options.cwd URL', testLocalBinaryExec, FIXTURES_URL); 126 | 127 | const testPathVariable = async (t, pathName) => { 128 | const {stdout} = await spawn(...localBinary, {preferLocal: true, env: {PATH: undefined, Path: undefined, [pathName]: isWindows ? process.env[pathKey()] : nodeDirectory}}); 129 | t.regex(stdout, VERSION_REGEXP); 130 | }; 131 | 132 | test('options.preferLocal true uses options.env.PATH when set', testPathVariable, 'PATH'); 133 | test('options.preferLocal true uses options.env.Path when set', testPathVariable, 'Path'); 134 | 135 | const testNoLocal = async (t, preferLocal) => { 136 | const PATH = process.env[pathKey()] 137 | .split(path.delimiter) 138 | .filter(pathPart => !pathPart.includes(path.join('node_modules', '.bin'))) 139 | .join(path.delimiter); 140 | const error = await t.throwsAsync(spawn(...localBinary, {preferLocal, env: {Path: undefined, PATH}})); 141 | if (isWindows) { 142 | assertWindowsNonExistent(t, error, localBinaryCommand); 143 | } else { 144 | assertNonExistent(t, error, localBinaryCommandStart, localBinaryCommand); 145 | } 146 | }; 147 | 148 | test('options.preferLocal undefined does not run local npm binaries', testNoLocal, undefined); 149 | test('options.preferLocal false does not run local npm binaries', testNoLocal, false); 150 | 151 | test('options.preferLocal true uses options.env when empty', async t => { 152 | const error = await t.throwsAsync(spawn(...localBinary, {preferLocal: true, env: {PATH: undefined, Path: undefined}})); 153 | if (isWindows) { 154 | assertNonExistent(t, error, 'cmd.exe', localBinaryCommand); 155 | } else { 156 | assertUnixNotFound(t, error, localBinaryCommand); 157 | } 158 | }); 159 | 160 | test('options.preferLocal true can use an empty PATH', async t => { 161 | const {stdout} = await spawn(process.execPath, ['--version'], {preferLocal: true, env: {PATH: undefined, Path: undefined}}); 162 | t.is(stdout, process.version); 163 | }); 164 | 165 | test('options.preferLocal true does not add node_modules/.bin if already present', async t => { 166 | const localDirectory = fileURLToPath(new URL('node_modules/.bin', import.meta.url)); 167 | const currentPath = process.env[pathKey()]; 168 | const pathValue = `${localDirectory}${path.delimiter}${currentPath}`; 169 | const {stdout} = await spawn(...nodePrint(`process.env.${pathKey()}`), {preferLocal: true, env: {[pathKey()]: pathValue}}); 170 | t.is( 171 | stdout.split(path.delimiter).filter(pathPart => pathPart === localDirectory).length 172 | - currentPath.split(path.delimiter).filter(pathPart => pathPart === localDirectory).length, 173 | 1, 174 | ); 175 | }); 176 | 177 | const testLocalBinary = async (t, input) => { 178 | const {stderr} = await spawn('ava', ['test.js', '--', input], {preferLocal: true, cwd: FIXTURES_URL}); 179 | t.is(stderr, input); 180 | }; 181 | 182 | test('options.preferLocal true can pass arguments to local npm binaries, "', testLocalBinary, '"'); 183 | test('options.preferLocal true can pass arguments to local npm binaries, \\', testLocalBinary, '\\'); 184 | test('options.preferLocal true can pass arguments to local npm binaries, \\.', testLocalBinary, '\\.'); 185 | test('options.preferLocal true can pass arguments to local npm binaries, \\"', testLocalBinary, '\\"'); 186 | test('options.preferLocal true can pass arguments to local npm binaries, \\\\"', testLocalBinary, '\\\\"'); 187 | test('options.preferLocal true can pass arguments to local npm binaries, a b', testLocalBinary, 'a b'); 188 | test('options.preferLocal true can pass arguments to local npm binaries, \'.\'', testLocalBinary, '\'.\''); 189 | test('options.preferLocal true can pass arguments to local npm binaries, "."', testLocalBinary, '"."'); 190 | test('options.preferLocal true can pass arguments to local npm binaries, (', testLocalBinary, '('); 191 | test('options.preferLocal true can pass arguments to local npm binaries, )', testLocalBinary, ')'); 192 | test('options.preferLocal true can pass arguments to local npm binaries, ]', testLocalBinary, ']'); 193 | test('options.preferLocal true can pass arguments to local npm binaries, [', testLocalBinary, '['); 194 | test('options.preferLocal true can pass arguments to local npm binaries, %', testLocalBinary, '%'); 195 | test('options.preferLocal true can pass arguments to local npm binaries, %1', testLocalBinary, '%1'); 196 | test('options.preferLocal true can pass arguments to local npm binaries, !', testLocalBinary, '!'); 197 | test('options.preferLocal true can pass arguments to local npm binaries, ^', testLocalBinary, '^'); 198 | test('options.preferLocal true can pass arguments to local npm binaries, `', testLocalBinary, '`'); 199 | test('options.preferLocal true can pass arguments to local npm binaries, <', testLocalBinary, '<'); 200 | test('options.preferLocal true can pass arguments to local npm binaries, >', testLocalBinary, '>'); 201 | test('options.preferLocal true can pass arguments to local npm binaries, &', testLocalBinary, '&'); 202 | test('options.preferLocal true can pass arguments to local npm binaries, |', testLocalBinary, '|'); 203 | test('options.preferLocal true can pass arguments to local npm binaries, ;', testLocalBinary, ';'); 204 | test('options.preferLocal true can pass arguments to local npm binaries, ,', testLocalBinary, ','); 205 | test('options.preferLocal true can pass arguments to local npm binaries, space', testLocalBinary, ' '); 206 | test('options.preferLocal true can pass arguments to local npm binaries, *', testLocalBinary, '*'); 207 | test('options.preferLocal true can pass arguments to local npm binaries, ?', testLocalBinary, '?'); 208 | 209 | if (!isWindows) { 210 | test('options.preferLocal true prefer local binaries over global ones', async t => { 211 | const {stdout} = await spawn('git', {preferLocal: true, cwd: FIXTURES_URL}); 212 | t.is(stdout, testString); 213 | }); 214 | 215 | test('options.preferLocal true prefer subdirectories over parent directories', async t => { 216 | const {stdout} = await spawn('git', {preferLocal: true, cwd: new URL('subdir', FIXTURES_URL)}); 217 | t.is(stdout, secondTestString); 218 | }); 219 | } 220 | 221 | test('Can run global npm binaries', async t => { 222 | const {stdout} = await spawn('npm', ['--version']); 223 | t.regex(stdout, VERSION_REGEXP); 224 | }); 225 | 226 | test('Can run OS binaries', async t => { 227 | const {stdout} = await spawn('git', ['--version']); 228 | t.regex(stdout, /^git version \d+\.\d+\.\d+/); 229 | }); 230 | -------------------------------------------------------------------------------- /test/pipe.js: -------------------------------------------------------------------------------- 1 | import {createReadStream, createWriteStream} from 'node:fs'; 2 | import {readFile} from 'node:fs/promises'; 3 | import {once} from 'node:events'; 4 | import {temporaryWriteTask} from 'tempy'; 5 | import test from 'ava'; 6 | import spawn from '../source/index.js'; 7 | import { 8 | isWindows, 9 | FIXTURES_URL, 10 | earlyErrorOptions, 11 | arrayFromAsync, 12 | NODE_VERSION, 13 | } from './helpers/main.js'; 14 | import { 15 | testString, 16 | testUpperCase, 17 | testDoubleUpperCase, 18 | testDouble, 19 | } from './helpers/arguments.js'; 20 | import { 21 | assertDurationMs, 22 | assertFail, 23 | assertEarlyError, 24 | assertErrorEvent, 25 | assertSigterm, 26 | } from './helpers/assert.js'; 27 | import { 28 | nodeEval, 29 | nodePrintStdout, 30 | nodePassThrough, 31 | nodeToUpperCase, 32 | nodeToUpperCaseFail, 33 | nodeToUpperCaseStderr, 34 | nodePrintFail, 35 | nodeDouble, 36 | nodeDoubleFail, 37 | nodePrintSleep, 38 | nodePrintSleepFail, 39 | nodeHanging, 40 | } from './helpers/commands.js'; 41 | 42 | const testFixtureUrl = new URL('test.txt', FIXTURES_URL); 43 | 44 | test('.pipe() success', async t => { 45 | const first = spawn(...nodePrintStdout); 46 | const {stdout, output, durationMs, pipedFrom} = await first.pipe(...nodeToUpperCase); 47 | const firstResult = await first; 48 | t.is(firstResult.pipedFrom, undefined); 49 | t.is(pipedFrom, firstResult); 50 | t.is(stdout, testUpperCase); 51 | t.is(output, stdout); 52 | assertDurationMs(t, durationMs); 53 | }); 54 | 55 | test('.pipe() source fails', async t => { 56 | const first = spawn(...nodePrintFail); 57 | const secondError = await t.throwsAsync(first.pipe(...nodeToUpperCase)); 58 | const firstError = await t.throwsAsync(first); 59 | t.is(firstError, secondError); 60 | t.is(secondError.pipedFrom, undefined); 61 | assertFail(t, secondError); 62 | t.is(secondError.stdout, testString); 63 | t.is(secondError.output, secondError.stdout); 64 | }); 65 | 66 | test('.pipe() source fails due to child_process invalid option', async t => { 67 | const first = spawn(...nodePrintStdout, earlyErrorOptions); 68 | const secondError = await t.throwsAsync(first.pipe(...nodeToUpperCase)); 69 | const firstError = await t.throwsAsync(first); 70 | assertEarlyError(t, secondError); 71 | t.is(firstError, secondError); 72 | t.is(secondError.pipedFrom, undefined); 73 | }); 74 | 75 | test('.pipe() source fails due to stream error', async t => { 76 | const first = spawn(...nodePrintStdout); 77 | const second = first.pipe(...nodeToUpperCase); 78 | const cause = new Error(testString); 79 | const nodeChildProcess = await first.nodeChildProcess; 80 | nodeChildProcess.stdout.destroy(cause); 81 | const secondError = await t.throwsAsync(second); 82 | const firstError = await t.throwsAsync(first); 83 | assertErrorEvent(t, secondError, cause); 84 | assertErrorEvent(t, firstError, cause); 85 | t.is(firstError.pipedFrom, undefined); 86 | t.is(secondError.pipedFrom, firstError); 87 | }); 88 | 89 | test('.pipe() destination fails', async t => { 90 | const first = spawn(...nodePrintStdout); 91 | const secondError = await t.throwsAsync(first.pipe(...nodeToUpperCaseFail)); 92 | const firstResult = await first; 93 | assertFail(t, secondError); 94 | t.is(firstResult.pipedFrom, undefined); 95 | t.is(secondError.pipedFrom, firstResult); 96 | t.is(firstResult.stdout, testString); 97 | t.is(secondError.stdout, testUpperCase); 98 | }); 99 | 100 | test('.pipe() destination fails due to child_process invalid option', async t => { 101 | const first = spawn(...nodePrintStdout); 102 | const secondError = await t.throwsAsync(first.pipe(...nodeToUpperCase, earlyErrorOptions)); 103 | const firstResult = await first; 104 | assertEarlyError(t, secondError); 105 | t.is(firstResult.pipedFrom, undefined); 106 | t.is(secondError.pipedFrom, undefined); 107 | t.is(firstResult.stdout, testString); 108 | }); 109 | 110 | test('.pipe() destination fails due to stream error', async t => { 111 | const first = spawn(...nodePrintStdout); 112 | const second = first.pipe(...nodeToUpperCase); 113 | const cause = new Error(testString); 114 | const nodeChildProcess = await second.nodeChildProcess; 115 | nodeChildProcess.stdin.destroy(cause); 116 | const secondError = await t.throwsAsync(second); 117 | assertErrorEvent(t, secondError, cause); 118 | 119 | // Node 23 changed the behavior of `stream.pipeline()` 120 | if (NODE_VERSION >= 23) { 121 | const firstResult = await first; 122 | t.is(firstResult.stdout, testString); 123 | t.is(firstResult.pipedFrom, undefined); 124 | t.is(secondError.pipedFrom, firstResult); 125 | } else { 126 | const firstError = await t.throwsAsync(first); 127 | assertErrorEvent(t, firstError, cause); 128 | t.is(firstError.pipedFrom, undefined); 129 | t.is(secondError.pipedFrom, firstError); 130 | } 131 | }); 132 | 133 | test('.pipe() source and destination fail', async t => { 134 | const first = spawn(...nodePrintFail); 135 | const secondError = await t.throwsAsync(first.pipe(...nodeToUpperCaseFail)); 136 | const firstError = await t.throwsAsync(first); 137 | assertFail(t, firstError); 138 | assertFail(t, secondError); 139 | t.is(firstError.pipedFrom, undefined); 140 | t.is(secondError.pipedFrom, firstError); 141 | t.is(firstError.stdout, testString); 142 | t.is(firstError.output, firstError.stdout); 143 | t.is(secondError.stdout, testUpperCase); 144 | t.is(secondError.output, secondError.stdout); 145 | }); 146 | 147 | test('.pipe().pipe() success', async t => { 148 | const first = spawn(...nodePrintStdout).pipe(...nodeToUpperCase); 149 | const secondResult = await first.pipe(...nodeDouble); 150 | const firstResult = await first; 151 | t.is(firstResult.stdout, testUpperCase); 152 | t.is(firstResult.output, firstResult.stdout); 153 | t.is(secondResult.stdout, testDoubleUpperCase); 154 | t.is(secondResult.output, secondResult.stdout); 155 | assertDurationMs(t, firstResult.durationMs); 156 | }); 157 | 158 | test('.pipe().pipe() first source fail', async t => { 159 | const first = spawn(...nodePrintFail).pipe(...nodeToUpperCase); 160 | const secondError = await t.throwsAsync(first.pipe(...nodeDouble)); 161 | const firstError = await t.throwsAsync(first); 162 | assertFail(t, firstError); 163 | t.is(firstError, secondError); 164 | t.is(firstError.stdout, testString); 165 | t.is(firstError.output, firstError.stdout); 166 | }); 167 | 168 | test('.pipe().pipe() second source fail', async t => { 169 | const first = spawn(...nodePrintStdout).pipe(...nodeToUpperCaseFail); 170 | const secondError = await t.throwsAsync(first.pipe(...nodeDouble)); 171 | const firstError = await t.throwsAsync(first); 172 | assertFail(t, firstError); 173 | t.is(firstError, secondError); 174 | t.is(firstError.stdout, testUpperCase); 175 | t.is(firstError.output, firstError.stdout); 176 | }); 177 | 178 | test('.pipe().pipe() destination fail', async t => { 179 | const first = spawn(...nodePrintStdout).pipe(...nodeToUpperCase); 180 | const secondError = await t.throwsAsync(first.pipe(...nodeDoubleFail)); 181 | const firstResult = await first; 182 | assertFail(t, secondError); 183 | t.is(firstResult.stdout, testUpperCase); 184 | t.is(firstResult.output, firstResult.stdout); 185 | t.is(secondError.stdout, testDoubleUpperCase); 186 | t.is(secondError.output, secondError.stdout); 187 | assertDurationMs(t, firstResult.durationMs); 188 | }); 189 | 190 | test('.pipe().pipe() all fail', async t => { 191 | const first = spawn(...nodePrintFail).pipe(...nodeToUpperCaseFail); 192 | const secondError = await t.throwsAsync(first.pipe(...nodeDoubleFail)); 193 | const firstError = await t.throwsAsync(first); 194 | assertFail(t, firstError); 195 | assertFail(t, secondError); 196 | t.is(secondError.pipedFrom, firstError); 197 | t.is(firstError.stdout, testUpperCase); 198 | t.is(firstError.output, firstError.stdout); 199 | t.is(secondError.stdout, testDoubleUpperCase); 200 | t.is(secondError.output, secondError.stdout); 201 | }); 202 | 203 | // Cannot guarantee that `cat` exists on Windows 204 | if (!isWindows) { 205 | test('.pipe() without arguments', async t => { 206 | const {stdout} = await spawn(...nodePrintStdout).pipe('cat'); 207 | t.is(stdout, testString); 208 | }); 209 | } 210 | 211 | test('.pipe() with options', async t => { 212 | const argv0 = 'Foo'; 213 | const {stdout} = await spawn(...nodePrintStdout).pipe(...nodeEval(`process.stdin.on("data", chunk => { 214 | console.log(chunk.toString().trim() + process.argv0); 215 | });`), {argv0}); 216 | t.is(stdout, `${testString}${argv0}`); 217 | }); 218 | 219 | test.serial('.pipe() which does not read stdin, source ends first', async t => { 220 | const {stdout, output} = await spawn(...nodePrintStdout).pipe(...nodePrintSleep); 221 | t.is(stdout, testString); 222 | t.is(output, stdout); 223 | }); 224 | 225 | test.serial('.pipe() which does not read stdin, source fails first', async t => { 226 | const error = await t.throwsAsync(spawn(...nodePrintFail).pipe(...nodePrintSleep)); 227 | assertFail(t, error); 228 | t.is(error.stdout, testString); 229 | t.is(error.output, error.stdout); 230 | }); 231 | 232 | test.serial('.pipe() which does not read stdin, source ends last', async t => { 233 | const {stdout, output} = await spawn(...nodePrintSleep).pipe(...nodePrintStdout); 234 | t.is(stdout, testString); 235 | t.is(output, stdout); 236 | }); 237 | 238 | test.serial('.pipe() which does not read stdin, source fails last', async t => { 239 | const error = await t.throwsAsync(spawn(...nodePrintStdout).pipe(...nodePrintSleepFail)); 240 | assertFail(t, error); 241 | t.is(error.stdout, testString); 242 | t.is(error.output, error.stdout); 243 | }); 244 | 245 | test('.pipe() which has hanging stdin', async t => { 246 | const error = await t.throwsAsync(spawn(...nodeHanging, {timeout: 1e3}).pipe(...nodePassThrough)); 247 | assertSigterm(t, error); 248 | t.is(error.stdout, ''); 249 | t.is(error.output, ''); 250 | }); 251 | 252 | test('.pipe() with stdin stream in source', async t => { 253 | const stream = createReadStream(testFixtureUrl); 254 | await once(stream, 'open'); 255 | const {stdout} = await spawn(...nodePassThrough, {stdin: stream}).pipe(...nodeToUpperCase); 256 | t.is(stdout, testUpperCase); 257 | }); 258 | 259 | test('.pipe() with stdin stream in destination', async t => { 260 | const stream = createReadStream(testFixtureUrl); 261 | await once(stream, 'open'); 262 | await t.throwsAsync( 263 | spawn(...nodePassThrough).pipe(...nodeToUpperCase, {stdin: stream}), 264 | {message: 'The "stdin" option must be set on the first "spawn()" call in the pipeline.'}); 265 | }); 266 | 267 | test('.pipe() with stdout stream in destination', async t => { 268 | await temporaryWriteTask('', async temporaryPath => { 269 | const stream = createWriteStream(temporaryPath); 270 | await once(stream, 'open'); 271 | const {stdout} = await spawn(...nodePrintStdout).pipe(...nodePassThrough, {stdout: stream}); 272 | t.is(stdout, ''); 273 | t.is(await readFile(temporaryPath, 'utf8'), `${testString}\n`); 274 | }); 275 | }); 276 | 277 | test('.pipe() with stdout stream in source', async t => { 278 | await temporaryWriteTask('', async temporaryPath => { 279 | const stream = createWriteStream(temporaryPath); 280 | await once(stream, 'open'); 281 | await t.throwsAsync( 282 | spawn(...nodePrintStdout, {stdout: stream}).pipe(...nodePassThrough), 283 | {message: 'The "stdout" option must be set on the last "spawn()" call in the pipeline.'}, 284 | ); 285 | }); 286 | }); 287 | 288 | test('.pipe() + stdout/stderr iteration', async t => { 289 | const subprocess = spawn(...nodePrintStdout).pipe(...nodeToUpperCase); 290 | const lines = await arrayFromAsync(subprocess); 291 | t.deepEqual(lines, [testUpperCase]); 292 | const {stdout, stderr, output} = await subprocess; 293 | t.is(stdout, ''); 294 | t.is(stderr, ''); 295 | t.is(output, ''); 296 | }); 297 | 298 | test('.pipe() + stdout iteration', async t => { 299 | const subprocess = spawn(...nodePrintStdout).pipe(...nodeToUpperCase); 300 | const lines = await arrayFromAsync(subprocess.stdout); 301 | t.deepEqual(lines, [testUpperCase]); 302 | const {stdout, output} = await subprocess; 303 | t.is(stdout, ''); 304 | t.is(output, ''); 305 | }); 306 | 307 | test('.pipe() + stderr iteration', async t => { 308 | const subprocess = spawn(...nodePrintStdout).pipe(...nodeToUpperCaseStderr); 309 | const lines = await arrayFromAsync(subprocess.stderr); 310 | t.deepEqual(lines, [testUpperCase]); 311 | const {stderr, output} = await subprocess; 312 | t.is(stderr, ''); 313 | t.is(output, ''); 314 | }); 315 | 316 | test('.pipe() + stdout iteration, source fail', async t => { 317 | const subprocess = spawn(...nodePrintFail).pipe(...nodeToUpperCase); 318 | const error = await t.throwsAsync(arrayFromAsync(subprocess.stdout)); 319 | assertFail(t, error); 320 | t.is(error.stdout, testString); 321 | const secondError = await t.throwsAsync(subprocess); 322 | t.is(secondError.stdout, testString); 323 | t.is(secondError.output, secondError.stdout); 324 | }); 325 | 326 | test('.pipe() + stdout iteration, destination fail', async t => { 327 | const subprocess = spawn(...nodePrintStdout).pipe(...nodeToUpperCaseFail); 328 | const error = await t.throwsAsync(arrayFromAsync(subprocess.stdout)); 329 | assertFail(t, error); 330 | t.is(error.stdout, ''); 331 | const secondError = await t.throwsAsync(subprocess); 332 | t.is(secondError.stdout, ''); 333 | t.is(secondError.output, ''); 334 | }); 335 | 336 | test('.pipe() with EPIPE', async t => { 337 | const subprocess = spawn(...nodeEval(`setInterval(() => { 338 | console.log("${testString}"); 339 | }, 0); 340 | process.stdout.on("error", () => { 341 | process.exit(); 342 | });`)).pipe('head', ['-n', '2']); 343 | const lines = await arrayFromAsync(subprocess); 344 | t.deepEqual(lines, [testString, testString]); 345 | const {stdout, output} = await subprocess; 346 | t.is(stdout, ''); 347 | t.is(output, ''); 348 | }); 349 | 350 | test('.pipe() one source to multiple destinations', async t => { 351 | const first = spawn(...nodePrintStdout); 352 | const [firstResult, secondResult, thirdResult] = await Promise.all([ 353 | first, 354 | first.pipe(...nodeToUpperCase), 355 | first.pipe(...nodeDouble), 356 | ]); 357 | t.is(secondResult.pipedFrom, firstResult); 358 | t.is(thirdResult.pipedFrom, firstResult); 359 | t.is(firstResult.stdout, testString); 360 | t.is(firstResult.output, firstResult.stdout); 361 | t.is(secondResult.stdout, testUpperCase); 362 | t.is(secondResult.output, secondResult.stdout); 363 | t.is(thirdResult.stdout, testDouble); 364 | t.is(thirdResult.output, thirdResult.stdout); 365 | }); 366 | -------------------------------------------------------------------------------- /test/result.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import spawn, {SubprocessError} from '../source/index.js'; 3 | import { 4 | isWindows, 5 | isLinux, 6 | destroySubprocessStream, 7 | arrayFromAsync, 8 | earlyErrorOptions, 9 | } from './helpers/main.js'; 10 | import {testString, secondTestString} from './helpers/arguments.js'; 11 | import { 12 | assertSubprocessErrorName, 13 | assertFail, 14 | assertSigterm, 15 | assertEarlyError, 16 | assertWindowsNonExistent, 17 | assertNonExistent, 18 | assertAbortError, 19 | assertErrorEvent, 20 | } from './helpers/assert.js'; 21 | import { 22 | nodePrintStdout, 23 | nodePrintStderr, 24 | nodePrintBoth, 25 | nodeHanging, 26 | nodeEval, 27 | nodePrintNoNewline, 28 | nonExistentCommand, 29 | } from './helpers/commands.js'; 30 | 31 | test('result.exitCode|signalName on success', async t => { 32 | const {exitCode, signalName} = await spawn(...nodePrintStdout); 33 | t.is(exitCode, undefined); 34 | t.is(signalName, undefined); 35 | }); 36 | 37 | test('Error is an instance of SubprocessError', async t => { 38 | const error = await t.throwsAsync(spawn(...nodeEval('process.exit(2)'))); 39 | t.true(error instanceof SubprocessError); 40 | assertSubprocessErrorName(t, error.name); 41 | }); 42 | 43 | test('Error on non-0 exit code', async t => { 44 | const error = await t.throwsAsync(spawn(...nodeEval('process.exit(2)'))); 45 | assertFail(t, error); 46 | }); 47 | 48 | test('Error on signal termination', async t => { 49 | const error = await t.throwsAsync(spawn(...nodeHanging, {timeout: 1})); 50 | assertSigterm(t, error); 51 | }); 52 | 53 | test('Error on invalid child_process options', async t => { 54 | const error = await t.throwsAsync(spawn(...nodePrintStdout, earlyErrorOptions)); 55 | assertEarlyError(t, error); 56 | }); 57 | 58 | test('Error on "error" event before spawn', async t => { 59 | const error = await t.throwsAsync(spawn(nonExistentCommand)); 60 | 61 | if (isWindows) { 62 | assertWindowsNonExistent(t, error); 63 | } else { 64 | assertNonExistent(t, error); 65 | } 66 | }); 67 | 68 | test('Error on "error" event during spawn', async t => { 69 | const error = await t.throwsAsync(spawn(...nodeHanging, {signal: AbortSignal.abort()})); 70 | assertSigterm(t, error); 71 | }); 72 | 73 | test('Error on "error" event during spawn, with iteration', async t => { 74 | const subprocess = spawn(...nodeHanging, {signal: AbortSignal.abort()}); 75 | const error = await t.throwsAsync(arrayFromAsync(subprocess.stdout)); 76 | assertSigterm(t, error); 77 | }); 78 | 79 | // The `signal` option sends `SIGTERM`. 80 | // Whether the subprocess is terminated before or after an `error` event is emitted depends on the speed of the OS syscall. 81 | if (isLinux) { 82 | test('Error on "error" event after spawn', async t => { 83 | const cause = new Error(testString); 84 | const controller = new AbortController(); 85 | const subprocess = spawn(...nodeHanging, {signal: controller.signal}); 86 | await subprocess.nodeChildProcess; 87 | controller.abort(cause); 88 | const error = await t.throwsAsync(subprocess); 89 | assertAbortError(t, error, cause); 90 | }); 91 | } 92 | 93 | test('result.stdout is set', async t => { 94 | const {stdout, stderr, output} = await spawn(...nodePrintStdout); 95 | t.is(stdout, testString); 96 | t.is(stderr, ''); 97 | t.is(output, stdout); 98 | }); 99 | 100 | test('result.stderr is set', async t => { 101 | const {stdout, stderr, output} = await spawn(...nodePrintStderr); 102 | t.is(stdout, ''); 103 | t.is(stderr, testString); 104 | t.is(output, stderr); 105 | }); 106 | 107 | test('result.output is set', async t => { 108 | const {stdout, stderr, output} = await spawn(...nodePrintBoth); 109 | t.is(stdout, testString); 110 | t.is(stderr, secondTestString); 111 | t.is(output, `${stdout}\n${stderr}`); 112 | }); 113 | 114 | test('error.stdout is set', async t => { 115 | const error = await t.throwsAsync(spawn(...nodeEval(`console.log("${testString}"); 116 | process.exit(2);`))); 117 | assertFail(t, error); 118 | t.is(error.stdout, testString); 119 | t.is(error.stderr, ''); 120 | t.is(error.output, error.stdout); 121 | }); 122 | 123 | test('error.stderr is set', async t => { 124 | const error = await t.throwsAsync(spawn(...nodeEval(`console.error("${testString}"); 125 | process.exit(2);`))); 126 | assertFail(t, error); 127 | t.is(error.stdout, ''); 128 | t.is(error.stderr, testString); 129 | t.is(error.output, error.stderr); 130 | }); 131 | 132 | test('error.output is set', async t => { 133 | const error = await t.throwsAsync(spawn(...nodeEval(`console.log("${testString}"); 134 | setTimeout(() => { 135 | console.error("${secondTestString}"); 136 | process.exit(2); 137 | }, 0);`))); 138 | assertFail(t, error); 139 | t.is(error.stdout, testString); 140 | t.is(error.stderr, secondTestString); 141 | t.is(error.output, `${error.stdout}\n${error.stderr}`); 142 | }); 143 | 144 | const testStreamError = async (t, streamName) => { 145 | const subprocess = spawn(...nodePrintStdout); 146 | const cause = new Error(testString); 147 | destroySubprocessStream(subprocess, cause, streamName); 148 | const error = await t.throwsAsync(subprocess); 149 | assertErrorEvent(t, error, cause); 150 | }; 151 | 152 | test('Handles subprocess.stdin error', testStreamError, 'stdin'); 153 | test('Handles subprocess.stdout error', testStreamError, 'stdout'); 154 | test('Handles subprocess.stderr error', testStreamError, 'stderr'); 155 | 156 | const testNewline = async (t, input, expectedOutput) => { 157 | const {stdout, output} = await spawn(...nodePrintNoNewline(input)); 158 | t.is(stdout, expectedOutput); 159 | t.is(output, stdout); 160 | }; 161 | 162 | test('result.stdout handles newline at the beginning', testNewline, '\na\nb', '\na\nb'); 163 | test('result.stdout handles newline in the middle', testNewline, 'a\nb', 'a\nb'); 164 | test('result.stdout handles newline at the end', testNewline, 'a\nb\n', 'a\nb'); 165 | test('result.stdout handles Windows newline at the beginning', testNewline, '\r\na\r\nb', '\r\na\r\nb'); 166 | test('result.stdout handles Windows newline in the middle', testNewline, 'a\r\nb', 'a\r\nb'); 167 | test('result.stdout handles Windows newline at the end', testNewline, 'a\r\nb\r\n', 'a\r\nb'); 168 | test('result.stdout handles 2 newlines at the beginning', testNewline, '\n\na\nb', '\n\na\nb'); 169 | test('result.stdout handles 2 newlines in the middle', testNewline, 'a\n\nb', 'a\n\nb'); 170 | test('result.stdout handles 2 newlines at the end', testNewline, 'a\nb\n\n', 'a\nb\n'); 171 | test('result.stdout handles 2 Windows newlines at the beginning', testNewline, '\r\n\r\na\r\nb', '\r\n\r\na\r\nb'); 172 | test('result.stdout handles 2 Windows newlines in the middle', testNewline, 'a\r\n\r\nb', 'a\r\n\r\nb'); 173 | test('result.stdout handles 2 Windows newlines at the end', testNewline, 'a\r\nb\r\n\r\n', 'a\r\nb\r\n'); 174 | -------------------------------------------------------------------------------- /test/spawn.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import test from 'ava'; 3 | import getNode from 'get-node'; 4 | import spawn from '../source/index.js'; 5 | import {isWindows, FIXTURES_URL, writeMultibyte} from './helpers/main.js'; 6 | import { 7 | assertWindowsNonExistent, 8 | assertNonExistent, 9 | assertUnixNonExistentShell, 10 | } from './helpers/assert.js'; 11 | import {testString, secondTestString, multibyteString} from './helpers/arguments.js'; 12 | import {nonExistentCommand, nodePrintBoth, nodePassThrough} from './helpers/commands.js'; 13 | 14 | const nodeCliFlag = '--jitless'; 15 | const inspectCliFlag = '--inspect-port=8091'; 16 | 17 | const testNodeFlags = async (t, binaryName, fixtureName, hasFlag) => { 18 | const {stdout} = await spawn(binaryName, [nodeCliFlag, fixtureName], {cwd: FIXTURES_URL}); 19 | t.is(stdout.includes(nodeCliFlag), hasFlag); 20 | }; 21 | 22 | test('Keeps Node flags', testNodeFlags, 'node', 'node-flags.js', true); 23 | test('Does not keep Node flags, full path', testNodeFlags, 'node', 'node-flags-path.js', false); 24 | 25 | if (isWindows) { 26 | test('Keeps Node flags, node.exe', testNodeFlags, 'node.exe', 'node-flags.js', true); 27 | test('Keeps Node flags, case-insensitive', testNodeFlags, 'NODE', 'node-flags.js', true); 28 | } 29 | 30 | test('Does not keep --inspect* Node flags', async t => { 31 | const {stdout} = await spawn('node', [nodeCliFlag, inspectCliFlag, 'node-flags.js'], {cwd: FIXTURES_URL}); 32 | t.true(stdout.includes(nodeCliFlag)); 33 | t.false(stdout.includes(inspectCliFlag)); 34 | }); 35 | 36 | const TEST_NODE_VERSION = '20.17.0'; 37 | 38 | test.serial('Keeps Node version', async t => { 39 | const {path: nodePath} = await getNode(TEST_NODE_VERSION); 40 | t.not(nodePath, process.execPath); 41 | const {stdout} = await spawn(nodePath, ['node-version.js'], {cwd: FIXTURES_URL}); 42 | t.is(stdout, `v${TEST_NODE_VERSION}`); 43 | }); 44 | 45 | test('Handles non-existing command', async t => { 46 | const error = await t.throwsAsync(spawn(nonExistentCommand)); 47 | 48 | if (isWindows) { 49 | assertWindowsNonExistent(t, error); 50 | } else { 51 | assertNonExistent(t, error); 52 | } 53 | }); 54 | 55 | test('Handles non-existing command, shell', async t => { 56 | const error = await t.throwsAsync(spawn(nonExistentCommand, {shell: true})); 57 | 58 | if (isWindows) { 59 | assertWindowsNonExistent(t, error); 60 | } else { 61 | assertUnixNonExistentShell(t, error); 62 | } 63 | }); 64 | 65 | test('result.stdout is an empty string if options.stdout "ignore"', async t => { 66 | const {stdout, stderr, output} = await spawn(...nodePrintBoth, {stdout: 'ignore'}); 67 | t.is(stdout, ''); 68 | t.is(stderr, secondTestString); 69 | t.is(output, stderr); 70 | }); 71 | 72 | test('result.stderr is an empty string if options.stderr "ignore"', async t => { 73 | const {stdout, stderr, output} = await spawn(...nodePrintBoth, {stderr: 'ignore'}); 74 | t.is(stdout, testString); 75 | t.is(stderr, ''); 76 | t.is(output, stdout); 77 | }); 78 | 79 | test('result.output is an empty string if options.stdout and options.stderr "ignore"', async t => { 80 | const {stdout, stderr, output} = await spawn(...nodePrintBoth, {stdout: 'ignore', stderr: 'ignore'}); 81 | t.is(stdout, ''); 82 | t.is(stderr, ''); 83 | t.is(output, ''); 84 | }); 85 | 86 | test.serial('result.stdout works with multibyte sequences', async t => { 87 | const subprocess = spawn(...nodePassThrough); 88 | writeMultibyte(subprocess); 89 | const {stdout, output} = await subprocess; 90 | t.is(stdout, multibyteString); 91 | t.is(output, stdout); 92 | }); 93 | -------------------------------------------------------------------------------- /test/windows.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import {fileURLToPath} from 'node:url'; 3 | import test from 'ava'; 4 | import pathKey from 'path-key'; 5 | import spawn from '../source/index.js'; 6 | import { 7 | isWindows, 8 | FIXTURES_URL, 9 | fixturesPath, 10 | nodeDirectory, 11 | } from './helpers/main.js'; 12 | import {testString} from './helpers/arguments.js'; 13 | import {assertWindowsNonExistent} from './helpers/assert.js'; 14 | import {nodePrintArgv0} from './helpers/commands.js'; 15 | 16 | if (isWindows) { 17 | test('Current OS uses node.exe', t => { 18 | t.true(process.execPath.endsWith('\\node.exe')); 19 | }); 20 | 21 | const testExe = async (t, shell) => { 22 | const {stdout} = await spawn(process.execPath, ['--version'], {shell}); 23 | t.is(stdout, process.version); 24 | }; 25 | 26 | test('Can run .exe file', testExe, undefined); 27 | test('Can run .exe file, no shell', testExe, false); 28 | test('Can run .exe file, shell', testExe, true); 29 | 30 | test('.exe does not use shell by default', async t => { 31 | const {stdout} = await spawn(...nodePrintArgv0, {argv0: testString}); 32 | t.is(stdout, testString); 33 | }); 34 | 35 | test('.exe can use shell', async t => { 36 | const {stdout} = await spawn(...nodePrintArgv0, {argv0: testString, shell: true}); 37 | t.is(stdout, process.execPath); 38 | }); 39 | 40 | const testExeDetection = async (t, execPath) => { 41 | const {stdout} = await spawn(execPath, ['-p', 'process.argv0'], {argv0: testString}); 42 | t.is(stdout, testString); 43 | }; 44 | 45 | test('.exe detection with explicit file extension', testExeDetection, process.execPath); 46 | test('.exe detection with explicit file extension, case insensitive', testExeDetection, process.execPath.toUpperCase()); 47 | test('.exe detection with file paths without file extension', testExeDetection, process.execPath.replace('.exe', '')); 48 | test('.exe detection with Unix slashes', testExeDetection, process.execPath.replace('\\node.exe', '/node.exe')); 49 | 50 | const testPathValue = async (t, pathValue) => { 51 | const {stdout} = await spawn(...nodePrintArgv0, {argv0: testString, env: {[pathKey()]: pathValue}}); 52 | t.is(stdout, testString); 53 | }; 54 | 55 | test('.exe detection with custom Path', testPathValue, nodeDirectory); 56 | test('.exe detection with custom Path and leading ;', testPathValue, `;${nodeDirectory}`); 57 | test('.exe detection with custom Path and double quoting', testPathValue, `"${nodeDirectory}"`); 58 | 59 | const testCom = async (t, shell) => { 60 | const {stdout} = await spawn('tree.com', [fileURLToPath(FIXTURES_URL), '/f'], {shell}); 61 | t.true(stdout.includes('spawnecho.cmd')); 62 | }; 63 | 64 | test('Can run .com file', testCom, undefined); 65 | test('Can run .com file, no shell', testCom, false); 66 | test('Can run .com file, shell', testCom, true); 67 | 68 | const testCmd = async (t, shell) => { 69 | const {stdout} = await spawn('spawnecho.cmd', [testString], {cwd: FIXTURES_URL, shell}); 70 | t.is(stdout, testString); 71 | }; 72 | 73 | test('Can run .cmd file', testCmd, undefined); 74 | test('Can run .cmd file, no shell', testCmd, false); 75 | test('Can run .cmd file, shell', testCmd, true); 76 | 77 | test('Memoize .cmd file logic', async t => { 78 | await spawn('spawnecho.cmd', [testString], {cwd: FIXTURES_URL}); 79 | const {stdout} = await spawn('spawnecho.cmd', [testString], {cwd: FIXTURES_URL}); 80 | t.is(stdout, testString); 81 | }); 82 | 83 | test('Uses PATHEXT by default', async t => { 84 | const {stdout} = await spawn('spawnecho', [testString], {cwd: FIXTURES_URL}); 85 | t.is(stdout, testString); 86 | }); 87 | 88 | test('Uses cwd as string', async t => { 89 | const {stdout} = await spawn('spawnecho', [testString], {cwd: fixturesPath}); 90 | t.is(stdout, testString); 91 | }); 92 | 93 | const testPathExtension = async (t, shell) => { 94 | const error = await t.throwsAsync(spawn('spawnecho', [testString], { 95 | env: {PATHEXT: '.COM'}, 96 | cwd: FIXTURES_URL, 97 | shell, 98 | })); 99 | assertWindowsNonExistent(t, error, `spawnecho ${testString}`); 100 | }; 101 | 102 | test('Can set PATHEXT', testPathExtension, undefined); 103 | test('Can set PATHEXT, no shell', testPathExtension, false); 104 | test('Can set PATHEXT, shell', testPathExtension, true); 105 | 106 | test('Escapes file when setting shell option', async t => { 107 | const file = '()[]%0!`'; 108 | const {stdout} = await spawn(file, {cwd: FIXTURES_URL}); 109 | t.is(stdout, `${file}\r\n${file}`); 110 | }); 111 | 112 | const testEscape = async (t, input) => { 113 | const {stdout} = await spawn('spawnecho', [input], {cwd: FIXTURES_URL}); 114 | t.is(stdout, input); 115 | }; 116 | 117 | test('Escapes argument when setting shell option, "', testEscape, '"'); 118 | test('Escapes argument when setting shell option, \\', testEscape, '\\'); 119 | test('Escapes argument when setting shell option, \\.', testEscape, '\\.'); 120 | test('Escapes argument when setting shell option, \\"', testEscape, '\\"'); 121 | test('Escapes argument when setting shell option, \\\\"', testEscape, '\\\\"'); 122 | test('Escapes argument when setting shell option, a b', testEscape, 'a b'); 123 | test('Escapes argument when setting shell option, \'.\'', testEscape, '\'.\''); 124 | test('Escapes argument when setting shell option, "."', testEscape, '"."'); 125 | test('Escapes argument when setting shell option, (', testEscape, '('); 126 | test('Escapes argument when setting shell option, )', testEscape, ')'); 127 | test('Escapes argument when setting shell option, ]', testEscape, ']'); 128 | test('Escapes argument when setting shell option, [', testEscape, '['); 129 | test('Escapes argument when setting shell option, %', testEscape, '%'); 130 | test('Escapes argument when setting shell option, !', testEscape, '!'); 131 | test('Escapes argument when setting shell option, ^', testEscape, '^'); 132 | test('Escapes argument when setting shell option, `', testEscape, '`'); 133 | test('Escapes argument when setting shell option, <', testEscape, '<'); 134 | test('Escapes argument when setting shell option, >', testEscape, '>'); 135 | test('Escapes argument when setting shell option, &', testEscape, '&'); 136 | test('Escapes argument when setting shell option, |', testEscape, '|'); 137 | test('Escapes argument when setting shell option, ;', testEscape, ';'); 138 | test('Escapes argument when setting shell option, ,', testEscape, ','); 139 | test('Escapes argument when setting shell option, space', testEscape, ' '); 140 | test('Escapes argument when setting shell option, *', testEscape, '*'); 141 | test('Escapes argument when setting shell option, ?', testEscape, '?'); 142 | test('Escapes argument when setting shell option, é', testEscape, 'é'); 143 | test('Escapes argument when setting shell option, empty', testEscape, ''); 144 | test('Escapes argument when setting shell option, ()', testEscape, '()'); 145 | test('Escapes argument when setting shell option, []', testEscape, '[]'); 146 | test('Escapes argument when setting shell option, %1', testEscape, '%1'); 147 | test('Escapes argument when setting shell option, %*', testEscape, '%*'); 148 | test('Escapes argument when setting shell option, %!', testEscape, '%!'); 149 | test('Escapes argument when setting shell option, %CD%', testEscape, '%CD%'); 150 | test('Escapes argument when setting shell option, ^<', testEscape, '^<'); 151 | test('Escapes argument when setting shell option, >&', testEscape, '>&'); 152 | test('Escapes argument when setting shell option, |;', testEscape, '|;'); 153 | test('Escapes argument when setting shell option, , space', testEscape, ', '); 154 | test('Escapes argument when setting shell option, !=', testEscape, '!='); 155 | test('Escapes argument when setting shell option, \\*', testEscape, '\\*'); 156 | test('Escapes argument when setting shell option, ?.', testEscape, '?.'); 157 | test('Escapes argument when setting shell option, =`', testEscape, '=`'); 158 | test('Escapes argument when setting shell option, --help 0', testEscape, '--help 0'); 159 | test('Escapes argument when setting shell option, "a b"', testEscape, '"a b"'); 160 | test('Escapes argument when setting shell option, "foo|bar>baz"', testEscape, '"foo|bar>baz"'); 161 | test('Escapes argument when setting shell option, "(foo|bar>baz|foz)"', testEscape, '"(foo|bar>baz|foz)"'); 162 | 163 | test('Cannot run shebangs', async t => { 164 | const error = await t.throwsAsync(spawn('./shebang.js', {cwd: FIXTURES_URL})); 165 | assertWindowsNonExistent(t, error, './shebang.js'); 166 | }); 167 | } else { 168 | test('Can run shebangs', async t => { 169 | const {stdout} = await spawn('./shebang.js', {cwd: FIXTURES_URL}); 170 | t.is(stdout, testString); 171 | }); 172 | } 173 | 174 | test('Can run Bash', async t => { 175 | const {stdout} = await spawn(`echo ${testString}`, {cwd: FIXTURES_URL, shell: 'bash'}); 176 | t.is(stdout, testString); 177 | }); 178 | 179 | test('Does not double escape shell strings', async t => { 180 | const {stdout} = await spawn('node -p "0"', {shell: true}); 181 | t.is(stdout, '0'); 182 | }); 183 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "nodenext", 4 | "moduleResolution": "nodenext", 5 | "target": "ES2022", 6 | "strict": true 7 | }, 8 | "files": [ 9 | "source/index.d.ts" 10 | ] 11 | } 12 | --------------------------------------------------------------------------------