Repository: sindresorhus/execa
Pull request #634 (2023-12-21 17:51:17)

Fix `stdio` option
This fixes the `stdio` option. Its types are currently incorrect.

Also, when passing more than 3 values to the `stdio` option (i.e. creating custom file descriptors), the additional values should work like the `stdin`/`stdout`/`stderr` options, e.g. allow web streams, file paths, file URLs, etc.

This PR also adds a better error message when users pass a Node.js stream to `execaSync()`. `child_process.spawnSync()` already forbids it, but the error message is less clear.


Patch:
diff --git a/index.d.ts b/index.d.ts
index f0f6aa8..2034ab6 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1,30 +1,38 @@
 import {type ChildProcess} from 'node:child_process';
-import {type Stream, type Readable, type Writable} from 'node:stream';
+import {type Readable, type Writable} from 'node:stream';
 
-export type StdioOption =
+type BaseStdioOption =
 	| 'pipe'
 	| 'overlapped'
-	| 'ipc'
 	| 'ignore'
-	| 'inherit'
-	| Stream
+	| 'inherit';
+
+type CommonStdioOption =
+	| BaseStdioOption
+	| 'ipc'
 	| number
-	| undefined;
+	| undefined
+	| URL
+	| string;
 
-export type StdinOption =
-	| StdioOption
+type InputStdioOption =
 	| Iterable<string | Uint8Array>
 	| AsyncIterable<string | Uint8Array>
-	| URL
-	| string
+	| Readable
 	| ReadableStream;
 
-export type StdoutStderrOption =
-	| StdioOption
-	| URL
-	| string
+type OutputStdioOption =
+	| Writable
 	| WritableStream;
 
+export type StdinOption = CommonStdioOption | InputStdioOption;
+export type StdoutStderrOption = CommonStdioOption | OutputStdioOption;
+export type StdioOption = CommonStdioOption | InputStdioOption | OutputStdioOption;
+
+type StdioOptions =
+	| BaseStdioOption
+	| readonly [StdinOption, StdoutStderrOption, StdoutStderrOption, ...StdioOption[]];
+
 type EncodingOption =
   | 'utf8'
   // eslint-disable-next-line unicorn/text-encoding-identifier-case
@@ -208,7 +216,7 @@ export type CommonOptions<EncodingType extends EncodingOption = DefaultEncodingO
 
 	@default 'pipe'
 	*/
-	readonly stdio?: 'pipe' | 'overlapped' | 'ignore' | 'inherit' | readonly StdioOption[];
+	readonly stdio?: StdioOptions;
 
 	/**
 	Specify the kind of serialization used for sending messages between processes when using the `stdio: 'ipc'` option or `execaNode()`:
diff --git a/lib/stdio/handle.js b/lib/stdio/handle.js
index a4f2e8c..a1b601e 100644
--- a/lib/stdio/handle.js
+++ b/lib/stdio/handle.js
@@ -1,4 +1,4 @@
-import {getStdioOptionType, isRegularUrl, isUnknownStdioString} from './type.js';
+import {getStdioOptionType, isRegularUrl, isUnknownStdioString, isInputDirection} from './type.js';
 import {normalizeStdio} from './normalize.js';
 import {handleInputOption, handleInputFileOption} from './input.js';
 
@@ -14,12 +14,7 @@ export const handleInput = (addProperties, options) => {
 const arrifyStdio = (stdio = []) => Array.isArray(stdio) ? stdio : [stdio, stdio, stdio];
 
 const getStdioStream = (stdioOption, index, addProperties, {input, inputFile}) => {
-	let stdioStream = {
-		type: getStdioOptionType(stdioOption),
-		value: stdioOption,
-		optionName: OPTION_NAMES[index],
-		direction: index === 0 ? 'input' : 'output',
-	};
+	let stdioStream = getInitialStdioStream(stdioOption, index);
 
 	stdioStream = handleInputOption(stdioStream, index, input);
 	stdioStream = handleInputFileOption(stdioStream, index, inputFile, input);
@@ -32,7 +27,20 @@ const getStdioStream = (stdioOption, index, addProperties, {input, inputFile}) =
 	};
 };
 
-const OPTION_NAMES = ['stdin', 'stdout', 'stderr'];
+const getInitialStdioStream = (stdioOption, index) => {
+	const type = getStdioOptionType(stdioOption);
+	const {
+		optionName = `stdio[${index}]`,
+		direction = isInputDirection[type](stdioOption) ? 'input' : 'output',
+	} = KNOWN_STREAMS[index] ?? {};
+	return {type, value: stdioOption, optionName, direction};
+};
+
+const KNOWN_STREAMS = [
+	{optionName: 'stdin', direction: 'input'},
+	{optionName: 'stdout', direction: 'output'},
+	{optionName: 'stderr', direction: 'output'},
+];
 
 const validateFileStdio = ({type, value, optionName}) => {
 	if (isRegularUrl(value)) {
@@ -48,8 +56,7 @@ For example, you can use the \`pathToFileURL()\` method of the \`url\` core modu
 // When the `std*: Iterable | WebStream | URL | filePath`, `input` or `inputFile` option is used, we pipe to `spawned.std*`.
 // Therefore the `std*` options must be either `pipe` or `overlapped`. Other values do not set `spawned.std*`.
 const transformStdio = (stdio, stdioStreams) => Array.isArray(stdio)
-	? stdio.map((stdioItem, index) => transformStdioItem(stdioItem, index, stdioStreams))
+	? stdio.map((stdioItem, index) => transformStdioItem(stdioItem, stdioStreams[index]))
 	: stdio;
 
-const transformStdioItem = (stdioItem, index, stdioStreams) =>
-	stdioStreams[index].type !== 'native' && stdioItem !== 'overlapped' ? 'pipe' : stdioItem;
+const transformStdioItem = (stdioItem, {type}) => type !== 'native' && stdioItem !== 'overlapped' ? 'pipe' : stdioItem;
diff --git a/lib/stdio/sync.js b/lib/stdio/sync.js
index 042f8cc..3ac29ff 100644
--- a/lib/stdio/sync.js
+++ b/lib/stdio/sync.js
@@ -1,4 +1,5 @@
 import {readFileSync, writeFileSync} from 'node:fs';
+import {isStream as isNodeStream} from 'is-stream';
 import {handleInput} from './handle.js';
 import {TYPE_TO_MESSAGE} from './type.js';
 
@@ -9,6 +10,12 @@ export const handleInputSync = options => {
 	return stdioStreams;
 };
 
+const forbiddenIfStreamSync = ({value, optionName}) => {
+	if (isNodeStream(value)) {
+		forbiddenIfSync({type: 'nodeStream', optionName});
+	}
+};
+
 const forbiddenIfSync = ({type, optionName}) => {
 	throw new TypeError(`The \`${optionName}\` option cannot be ${TYPE_TO_MESSAGE[type]} in sync mode.`);
 };
@@ -19,11 +26,13 @@ const addPropertiesSync = {
 		webStream: forbiddenIfSync,
 		nodeStream: forbiddenIfSync,
 		iterable: forbiddenIfSync,
+		native: forbiddenIfStreamSync,
 	},
 	output: {
 		webStream: forbiddenIfSync,
 		nodeStream: forbiddenIfSync,
 		iterable: forbiddenIfSync,
+		native: forbiddenIfStreamSync,
 	},
 };
 
diff --git a/lib/stdio/type.js b/lib/stdio/type.js
index fe5a45b..ce9d601 100644
--- a/lib/stdio/type.js
+++ b/lib/stdio/type.js
@@ -1,5 +1,9 @@
 import {isAbsolute} from 'node:path';
-import {isStream as isNodeStream} from 'is-stream';
+import {
+	isStream as isNodeStream,
+	isReadableStream as isNodeReadableStream,
+	isWritableStream as isNodeWritableStream,
+} from 'is-stream';
 
 // The `stdin`/`stdout`/`stderr` option can be of many types. This detects it.
 export const getStdioOptionType = stdioOption => {
@@ -41,6 +45,17 @@ const isIterableObject = stdinOption => typeof stdinOption === 'object'
 	&& stdinOption !== null
 	&& (typeof stdinOption[Symbol.asyncIterator] === 'function' || typeof stdinOption[Symbol.iterator] === 'function');
 
+// For `stdio[index]` beyond stdin/stdout/stderr, we need to guess whether the value passed is intended for inputs or outputs.
+// When ambiguous, we default to `output` since it is the most common use case for additional file descriptors.
+// For the same reason, Duplex streams and TransformStreams are considered as outputs.
+// `nodeStream` and `stringOrBuffer` types always apply to `stdin`, i.e. missing here.
+export const isInputDirection = {
+	filePath: () => false,
+	webStream: stdioOption => !isWritableStream(stdioOption),
+	native: stdioOption => (isNodeReadableStream(stdioOption) && !isNodeWritableStream(stdioOption)) || stdioOption === 0,
+	iterable: () => true,
+};
+
 // Convert types to human-friendly strings for error messages
 export const TYPE_TO_MESSAGE = {
 	filePath: 'a file path',


Test patch:
diff --git a/index.test-d.ts b/index.test-d.ts
index e2b695e..c6ad45c 100644
--- a/index.test-d.ts
+++ b/index.test-d.ts
@@ -2,7 +2,7 @@
 // `process.stdin`, `process.stderr`, and `process.stdout`
 // to get treated as `any` by `@typescript-eslint/no-unsafe-assignment`.
 import * as process from 'node:process';
-import {type Readable} from 'node:stream';
+import {Readable, Writable} from 'node:stream';
 import {createWriteStream} from 'node:fs';
 import {expectType, expectError, expectAssignable} from 'tsd';
 import {
@@ -139,34 +139,44 @@ const numberGenerator = function * () {
 	yield 0;
 };
 
+const asyncStringGenerator = async function * () {
+	yield '';
+};
+
+const fileUrl = new URL('file:///test');
+
 /* eslint-disable @typescript-eslint/no-floating-promises */
 execa('unicorns', {cleanup: false});
 execa('unicorns', {preferLocal: false});
 execa('unicorns', {localDir: '.'});
-execa('unicorns', {localDir: new URL('file:///test')});
+execa('unicorns', {localDir: fileUrl});
 expectError(execa('unicorns', {encoding: 'unknownEncoding'}));
 execa('unicorns', {execPath: '/path'});
-execa('unicorns', {execPath: new URL('file:///test')});
+execa('unicorns', {execPath: fileUrl});
 execa('unicorns', {buffer: false});
 execa('unicorns', {input: ''});
 execa('unicorns', {input: new Uint8Array()});
 execa('unicorns', {input: process.stdin});
 execa('unicorns', {inputFile: ''});
-execa('unicorns', {inputFile: new URL('file:///test')});
+execa('unicorns', {inputFile: fileUrl});
 execa('unicorns', {stdin: 'pipe'});
 execa('unicorns', {stdin: 'overlapped'});
 execa('unicorns', {stdin: 'ipc'});
 execa('unicorns', {stdin: 'ignore'});
 execa('unicorns', {stdin: 'inherit'});
 execa('unicorns', {stdin: process.stdin});
+execa('unicorns', {stdin: new Readable()});
+expectError(execa('unicorns', {stdin: new Writable()}));
 execa('unicorns', {stdin: new ReadableStream()});
+expectError(execa('unicorns', {stdin: new WritableStream()}));
 execa('unicorns', {stdin: ['']});
 execa('unicorns', {stdin: [new Uint8Array(0)]});
 execa('unicorns', {stdin: stringGenerator()});
 execa('unicorns', {stdin: binaryGenerator()});
+execa('unicorns', {stdin: asyncStringGenerator()});
 expectError(execa('unicorns', {stdin: [0]}));
 expectError(execa('unicorns', {stdin: numberGenerator()}));
-execa('unicorns', {stdin: new URL('file:///test')});
+execa('unicorns', {stdin: fileUrl});
 execa('unicorns', {stdin: './test'});
 execa('unicorns', {stdin: 1});
 execa('unicorns', {stdin: undefined});
@@ -176,8 +186,11 @@ execa('unicorns', {stdout: 'ipc'});
 execa('unicorns', {stdout: 'ignore'});
 execa('unicorns', {stdout: 'inherit'});
 execa('unicorns', {stdout: process.stdout});
+execa('unicorns', {stdout: new Writable()});
+expectError(execa('unicorns', {stdout: new Readable()}));
 execa('unicorns', {stdout: new WritableStream()});
-execa('unicorns', {stdout: new URL('file:///test')});
+expectError(execa('unicorns', {stdout: new ReadableStream()}));
+execa('unicorns', {stdout: fileUrl});
 execa('unicorns', {stdout: './test'});
 execa('unicorns', {stdout: 1});
 execa('unicorns', {stdout: undefined});
@@ -187,8 +200,11 @@ execa('unicorns', {stderr: 'ipc'});
 execa('unicorns', {stderr: 'ignore'});
 execa('unicorns', {stderr: 'inherit'});
 execa('unicorns', {stderr: process.stderr});
+execa('unicorns', {stderr: new Writable()});
+expectError(execa('unicorns', {stderr: new Readable()}));
 execa('unicorns', {stderr: new WritableStream()});
-execa('unicorns', {stderr: new URL('file:///test')});
+expectError(execa('unicorns', {stderr: new ReadableStream()}));
+execa('unicorns', {stderr: fileUrl});
 execa('unicorns', {stderr: './test'});
 execa('unicorns', {stderr: 1});
 execa('unicorns', {stderr: undefined});
@@ -197,7 +213,7 @@ execa('unicorns', {reject: false});
 execa('unicorns', {stripFinalNewline: false});
 execa('unicorns', {extendEnv: false});
 execa('unicorns', {cwd: '.'});
-execa('unicorns', {cwd: new URL('file:///test')});
+execa('unicorns', {cwd: fileUrl});
 // eslint-disable-next-line @typescript-eslint/naming-convention
 execa('unicorns', {env: {PATH: ''}});
 execa('unicorns', {argv0: ''});
@@ -205,8 +221,42 @@ execa('unicorns', {stdio: 'pipe'});
 execa('unicorns', {stdio: 'overlapped'});
 execa('unicorns', {stdio: 'ignore'});
 execa('unicorns', {stdio: 'inherit'});
+expectError(execa('unicorns', {stdio: 'ipc'}));
+expectError(execa('unicorns', {stdio: 1}));
+expectError(execa('unicorns', {stdio: fileUrl}));
+expectError(execa('unicorns', {stdio: './test'}));
+expectError(execa('unicorns', {stdio: new Writable()}));
+expectError(execa('unicorns', {stdio: new Readable()}));
+expectError(execa('unicorns', {stdio: new WritableStream()}));
+expectError(execa('unicorns', {stdio: new ReadableStream()}));
+expectError(execa('unicorns', {stdio: stringGenerator()}));
+expectError(execa('unicorns', {stdio: asyncStringGenerator()}));
+expectError(execa('unicorns', {stdio: ['pipe', 'pipe']}));
+execa('unicorns', {stdio: [new Readable(), 'pipe', 'pipe']});
+execa('unicorns', {stdio: ['pipe', new Writable(), 'pipe']});
+execa('unicorns', {stdio: ['pipe', 'pipe', new Writable()]});
+expectError(execa('unicorns', {stdio: [new Writable(), 'pipe', 'pipe']}));
+expectError(execa('unicorns', {stdio: ['pipe', new Readable(), 'pipe']}));
+expectError(execa('unicorns', {stdio: ['pipe', 'pipe', new Readable()]}));
 execa('unicorns', {
-	stdio: ['pipe', 'overlapped', 'ipc', 'ignore', 'inherit', process.stdin, 1, undefined],
+	stdio: [
+		'pipe',
+		'overlapped',
+		'ipc',
+		'ignore',
+		'inherit',
+		process.stdin,
+		1,
+		undefined,
+		fileUrl,
+		'./test',
+		new Writable(),
+		new Readable(),
+		new WritableStream(),
+		new ReadableStream(),
+		stringGenerator(),
+		asyncStringGenerator(),
+	],
 });
 execa('unicorns', {serialization: 'advanced'});
 execa('unicorns', {detached: true});
@@ -233,7 +283,7 @@ execa('unicorns').kill('SIGKILL', {forceKillAfterTimeout: undefined});
 
 expectError(execa(['unicorns', 'arg']));
 expectType<ExecaChildProcess>(execa('unicorns'));
-expectType<ExecaChildProcess>(execa(new URL('file:///test')));
+expectType<ExecaChildProcess>(execa(fileUrl));
 expectType<ExecaReturnValue>(await execa('unicorns'));
 expectType<ExecaReturnValue>(
 	await execa('unicorns', {encoding: 'utf8'}),
@@ -248,7 +298,7 @@ expectType<ExecaReturnValue<Uint8Array>>(
 
 expectError(execaSync(['unicorns', 'arg']));
 expectType<ExecaSyncReturnValue>(execaSync('unicorns'));
-expectType<ExecaSyncReturnValue>(execaSync(new URL('file:///test')));
+expectType<ExecaSyncReturnValue>(execaSync(fileUrl));
 expectType<ExecaSyncReturnValue>(
 	execaSync('unicorns', {encoding: 'utf8'}),
 );
@@ -278,7 +328,7 @@ expectType<ExecaSyncReturnValue<Uint8Array>>(execaCommandSync('unicorns foo', {e
 expectError(execaNode(['unicorns', 'arg']));
 expectType<ExecaChildProcess>(execaNode('unicorns'));
 expectType<ExecaReturnValue>(await execaNode('unicorns'));
-expectType<ExecaReturnValue>(await execaNode(new URL('file:///test')));
+expectType<ExecaReturnValue>(await execaNode(fileUrl));
 expectType<ExecaReturnValue>(
 	await execaNode('unicorns', {encoding: 'utf8'}),
 );
@@ -291,7 +341,7 @@ expectType<ExecaReturnValue<Uint8Array>>(
 );
 
 expectType<ExecaChildProcess>(execaNode('unicorns', {nodePath: './node'}));
-expectType<ExecaChildProcess>(execaNode('unicorns', {nodePath: new URL('file:///test')}));
+expectType<ExecaChildProcess>(execaNode('unicorns', {nodePath: fileUrl}));
 
 expectType<ExecaChildProcess>(execaNode('unicorns', {nodeOptions: ['--async-stack-traces']}));
 expectType<ExecaChildProcess>(execaNode('unicorns', ['foo'], {nodeOptions: ['--async-stack-traces']}));
diff --git a/test/fixtures/noop-fd3.js b/test/fixtures/noop-fd3.js
new file mode 100755
index 0000000..bb32353
--- /dev/null
+++ b/test/fixtures/noop-fd3.js
@@ -0,0 +1,6 @@
+#!/usr/bin/env node
+import process from 'node:process';
+import {writeSync} from 'node:fs';
+
+const fileDescriptorIndex = Number(process.argv[3] || 3);
+writeSync(fileDescriptorIndex, `${process.argv[2]}\n`);
diff --git a/test/fixtures/stdin-fd3.js b/test/fixtures/stdin-fd3.js
new file mode 100755
index 0000000..65d7045
--- /dev/null
+++ b/test/fixtures/stdin-fd3.js
@@ -0,0 +1,6 @@
+#!/usr/bin/env node
+import process from 'node:process';
+import {readFileSync} from 'node:fs';
+
+const fileDescriptorIndex = Number(process.argv[3] || 3);
+console.log(readFileSync(fileDescriptorIndex, {encoding: 'utf8'}));
diff --git a/test/helpers/stdio.js b/test/helpers/stdio.js
index 7045f1a..3d75fb4 100644
--- a/test/helpers/stdio.js
+++ b/test/helpers/stdio.js
@@ -1,6 +1,7 @@
 export const getStdinOption = stdioOption => ({stdin: stdioOption});
 export const getStdoutOption = stdioOption => ({stdout: stdioOption});
 export const getStderrOption = stdioOption => ({stderr: stdioOption});
+export const getStdioOption = stdioOption => ({stdio: ['pipe', 'pipe', 'pipe', stdioOption]});
 export const getPlainStdioOption = stdioOption => ({stdio: stdioOption});
 export const getInputOption = input => ({input});
 export const getInputFileOption = inputFile => ({inputFile});
diff --git a/test/stdio/file-descriptor.js b/test/stdio/file-descriptor.js
index 8116f38..b940e22 100644
--- a/test/stdio/file-descriptor.js
+++ b/test/stdio/file-descriptor.js
@@ -3,11 +3,12 @@ import test from 'ava';
 import tempfile from 'tempfile';
 import {execa, execaSync} from '../../index.js';
 import {setFixtureDir} from '../helpers/fixtures-dir.js';
-import {getStdinOption, getStdoutOption, getStderrOption} from '../helpers/stdio.js';
+import {getStdinOption, getStdoutOption, getStderrOption, getStdioOption} from '../helpers/stdio.js';
 
 setFixtureDir();
 
 const getStdinProp = ({stdin}) => stdin;
+const getStdioProp = ({stdio}) => stdio[3];
 
 const testFileDescriptorOption = async (t, fixtureName, getOptions, execaMethod) => {
 	const filePath = tempfile();
@@ -19,8 +20,10 @@ const testFileDescriptorOption = async (t, fixtureName, getOptions, execaMethod)
 
 test('pass `stdout` to a file descriptor', testFileDescriptorOption, 'noop.js', getStdoutOption, execa);
 test('pass `stderr` to a file descriptor', testFileDescriptorOption, 'noop-err.js', getStderrOption, execa);
+test('pass `stdio[*]` to a file descriptor', testFileDescriptorOption, 'noop-fd3.js', getStdioOption, execa);
 test('pass `stdout` to a file descriptor - sync', testFileDescriptorOption, 'noop.js', getStdoutOption, execaSync);
 test('pass `stderr` to a file descriptor - sync', testFileDescriptorOption, 'noop-err.js', getStderrOption, execaSync);
+test('pass `stdio[*]` to a file descriptor - sync', testFileDescriptorOption, 'noop-fd3.js', getStdioOption, execaSync);
 
 const testStdinWrite = async (t, getStreamProp, fixtureName, getOptions) => {
 	const subprocess = execa(fixtureName, getOptions('pipe'));
@@ -30,3 +33,4 @@ const testStdinWrite = async (t, getStreamProp, fixtureName, getOptions) => {
 };
 
 test('you can write to child.stdin', testStdinWrite, getStdinProp, 'stdin.js', getStdinOption);
+test('you can write to child.stdio[*]', testStdinWrite, getStdioProp, 'stdin-fd3.js', getStdioOption);
diff --git a/test/stdio/file-path.js b/test/stdio/file-path.js
index c8835b2..d5a358d 100644
--- a/test/stdio/file-path.js
+++ b/test/stdio/file-path.js
@@ -6,7 +6,7 @@ import test from 'ava';
 import tempfile from 'tempfile';
 import {execa, execaSync, $} from '../../index.js';
 import {setFixtureDir} from '../helpers/fixtures-dir.js';
-import {getStdinOption, getStdoutOption, getStderrOption, getInputFileOption, getScriptSync, identity} from '../helpers/stdio.js';
+import {getStdinOption, getStdoutOption, getStderrOption, getStdioOption, getInputFileOption, getScriptSync, identity} from '../helpers/stdio.js';
 
 setFixtureDir();
 
@@ -45,16 +45,22 @@ const testOutputFile = async (t, mapFile, fixtureName, getOptions, execaMethod)
 
 test('stdout can be a file URL', testOutputFile, pathToFileURL, 'noop.js', getStdoutOption, execa);
 test('stderr can be a file URL', testOutputFile, pathToFileURL, 'noop-err.js', getStderrOption, execa);
+test('stdio[*] can be a file URL', testOutputFile, pathToFileURL, 'noop-fd3.js', getStdioOption, execa);
 test('stdout can be an absolute file path', testOutputFile, identity, 'noop.js', getStdoutOption, execa);
 test('stderr can be an absolute file path', testOutputFile, identity, 'noop-err.js', getStderrOption, execa);
+test('stdio[*] can be an absolute file path', testOutputFile, identity, 'noop-fd3.js', getStdioOption, execa);
 test('stdout can be a relative file path', testOutputFile, getRelativePath, 'noop.js', getStdoutOption, execa);
 test('stderr can be a relative file path', testOutputFile, getRelativePath, 'noop-err.js', getStderrOption, execa);
+test('stdio[*] can be a relative file path', testOutputFile, getRelativePath, 'noop-fd3.js', getStdioOption, execa);
 test('stdout can be a file URL - sync', testOutputFile, pathToFileURL, 'noop.js', getStdoutOption, execaSync);
 test('stderr can be a file URL - sync', testOutputFile, pathToFileURL, 'noop-err.js', getStderrOption, execaSync);
+test('stdio[*] can be a file URL - sync', testOutputFile, pathToFileURL, 'noop-fd3.js', getStdioOption, execaSync);
 test('stdout can be an absolute file path - sync', testOutputFile, identity, 'noop.js', getStdoutOption, execaSync);
 test('stderr can be an absolute file path - sync', testOutputFile, identity, 'noop-err.js', getStderrOption, execaSync);
+test('stdio[*] can be an absolute file path - sync', testOutputFile, identity, 'noop-fd3.js', getStdioOption, execaSync);
 test('stdout can be a relative file path - sync', testOutputFile, getRelativePath, 'noop.js', getStdoutOption, execaSync);
 test('stderr can be a relative file path - sync', testOutputFile, getRelativePath, 'noop-err.js', getStderrOption, execaSync);
+test('stdio[*] can be a relative file path - sync', testOutputFile, getRelativePath, 'noop-fd3.js', getStdioOption, execaSync);
 
 const testStdioNonFileUrl = (t, getOptions, execaMethod) => {
 	t.throws(() => {
@@ -66,10 +72,12 @@ test('inputFile cannot be a non-file URL', testStdioNonFileUrl, getInputFileOpti
 test('stdin cannot be a non-file URL', testStdioNonFileUrl, getStdinOption, execa);
 test('stdout cannot be a non-file URL', testStdioNonFileUrl, getStdoutOption, execa);
 test('stderr cannot be a non-file URL', testStdioNonFileUrl, getStderrOption, execa);
+test('stdio[*] cannot be a non-file URL', testStdioNonFileUrl, getStdioOption, execa);
 test('inputFile cannot be a non-file URL - sync', testStdioNonFileUrl, getInputFileOption, execaSync);
 test('stdin cannot be a non-file URL - sync', testStdioNonFileUrl, getStdinOption, execaSync);
 test('stdout cannot be a non-file URL - sync', testStdioNonFileUrl, getStdoutOption, execaSync);
 test('stderr cannot be a non-file URL - sync', testStdioNonFileUrl, getStderrOption, execaSync);
+test('stdio[*] cannot be a non-file URL - sync', testStdioNonFileUrl, getStdioOption, execaSync);
 
 const testInputFileValidUrl = async (t, execaMethod) => {
 	const filePath = tempfile();
@@ -98,9 +106,11 @@ const testStdioValidUrl = (t, getOptions, execaMethod) => {
 test('stdin must start with . when being a relative file path', testStdioValidUrl, getStdinOption, execa);
 test('stdout must start with . when being a relative file path', testStdioValidUrl, getStdoutOption, execa);
 test('stderr must start with . when being a relative file path', testStdioValidUrl, getStderrOption, execa);
+test('stdio[*] must start with . when being a relative file path', testStdioValidUrl, getStdioOption, execa);
 test('stdin must start with . when being a relative file path - sync', testStdioValidUrl, getStdinOption, execaSync);
 test('stdout must start with . when being a relative file path - sync', testStdioValidUrl, getStdoutOption, execaSync);
 test('stderr must start with . when being a relative file path - sync', testStdioValidUrl, getStderrOption, execaSync);
+test('stdio[*] must start with . when being a relative file path - sync', testStdioValidUrl, getStdioOption, execaSync);
 
 const testFileError = async (t, mapFile, getOptions) => {
 	await t.throwsAsync(
@@ -113,10 +123,12 @@ test('inputFile file URL errors should be handled', testFileError, pathToFileURL
 test('stdin file URL errors should be handled', testFileError, pathToFileURL, getStdinOption);
 test('stdout file URL errors should be handled', testFileError, pathToFileURL, getStdoutOption);
 test('stderr file URL errors should be handled', testFileError, pathToFileURL, getStderrOption);
+test('stdio[*] file URL errors should be handled', testFileError, pathToFileURL, getStdioOption);
 test('inputFile file path errors should be handled', testFileError, identity, getInputFileOption);
 test('stdin file path errors should be handled', testFileError, identity, getStdinOption);
 test('stdout file path errors should be handled', testFileError, identity, getStdoutOption);
 test('stderr file path errors should be handled', testFileError, identity, getStderrOption);
+test('stdio[*] file path errors should be handled', testFileError, identity, getStdioOption);
 
 const testFileErrorSync = (t, mapFile, getOptions) => {
 	t.throws(() => {
@@ -128,10 +140,12 @@ test('inputFile file URL errors should be handled - sync', testFileErrorSync, pa
 test('stdin file URL errors should be handled - sync', testFileErrorSync, pathToFileURL, getStdinOption);
 test('stdout file URL errors should be handled - sync', testFileErrorSync, pathToFileURL, getStdoutOption);
 test('stderr file URL errors should be handled - sync', testFileErrorSync, pathToFileURL, getStderrOption);
+test('stdio[*] file URL errors should be handled - sync', testFileErrorSync, pathToFileURL, getStdioOption);
 test('inputFile file path errors should be handled - sync', testFileErrorSync, identity, getInputFileOption);
 test('stdin file path errors should be handled - sync', testFileErrorSync, identity, getStdinOption);
 test('stdout file path errors should be handled - sync', testFileErrorSync, identity, getStdoutOption);
 test('stderr file path errors should be handled - sync', testFileErrorSync, identity, getStderrOption);
+test('stdio[*] file path errors should be handled - sync', testFileErrorSync, identity, getStdioOption);
 
 const testInputFile = async (t, execaMethod) => {
 	const inputFile = tempfile();
diff --git a/test/stdio/iterable.js b/test/stdio/iterable.js
index d2f9432..233d6ab 100644
--- a/test/stdio/iterable.js
+++ b/test/stdio/iterable.js
@@ -1,7 +1,7 @@
 import test from 'ava';
 import {execa, execaSync} from '../../index.js';
 import {setFixtureDir} from '../helpers/fixtures-dir.js';
-import {getStdinOption, getStdoutOption, getStderrOption} from '../helpers/stdio.js';
+import {getStdinOption, getStdoutOption, getStderrOption, getStdioOption} from '../helpers/stdio.js';
 
 setFixtureDir();
 
@@ -28,9 +28,13 @@ const testIterable = async (t, stdioOption, fixtureName, getOptions) => {
 };
 
 test('stdin option can be a sync iterable of strings', testIterable, ['foo', 'bar'], 'stdin.js', getStdinOption);
+test('stdio[*] option can be a sync iterable of strings', testIterable, ['foo', 'bar'], 'stdin-fd3.js', getStdioOption);
 test('stdin option can be a sync iterable of Uint8Arrays', testIterable, [binaryFoo, binaryBar], 'stdin.js', getStdinOption);
+test('stdio[*] option can be a sync iterable of Uint8Arrays', testIterable, [binaryFoo, binaryBar], 'stdin-fd3.js', getStdioOption);
 test('stdin option can be an sync iterable of strings', testIterable, stringGenerator(), 'stdin.js', getStdinOption);
+test('stdio[*] option can be an sync iterable of strings', testIterable, stringGenerator(), 'stdin-fd3.js', getStdioOption);
 test('stdin option can be an sync iterable of Uint8Arrays', testIterable, binaryGenerator(), 'stdin.js', getStdinOption);
+test('stdio[*] option can be an sync iterable of Uint8Arrays', testIterable, binaryGenerator(), 'stdin-fd3.js', getStdioOption);
 
 const testIterableSync = (t, stdioOption, fixtureName, getOptions) => {
 	t.throws(() => {
@@ -39,7 +43,9 @@ const testIterableSync = (t, stdioOption, fixtureName, getOptions) => {
 };
 
 test('stdin option cannot be a sync iterable - sync', testIterableSync, ['foo', 'bar'], 'stdin.js', getStdinOption);
+test('stdio[*] option cannot be a sync iterable - sync', testIterableSync, ['foo', 'bar'], 'stdin-fd3.js', getStdioOption);
 test('stdin option cannot be an async iterable - sync', testIterableSync, stringGenerator(), 'stdin.js', getStdinOption);
+test('stdio[*] option cannot be an async iterable - sync', testIterableSync, stringGenerator(), 'stdin-fd3.js', getStdioOption);
 
 const testIterableError = async (t, fixtureName, getOptions) => {
 	const {originalMessage} = await t.throwsAsync(execa(fixtureName, getOptions(throwingGenerator())));
@@ -47,6 +53,7 @@ const testIterableError = async (t, fixtureName, getOptions) => {
 };
 
 test('stdin option handles errors in iterables', testIterableError, 'stdin.js', getStdinOption);
+test('stdio[*] option handles errors in iterables', testIterableError, 'stdin-fd3.js', getStdioOption);
 
 const testNoIterableOutput = (t, getOptions, execaMethod) => {
 	t.throws(() => {
diff --git a/test/stdio/node-stream.js b/test/stdio/node-stream.js
index de8eb01..619c738 100644
--- a/test/stdio/node-stream.js
+++ b/test/stdio/node-stream.js
@@ -6,7 +6,7 @@ import test from 'ava';
 import tempfile from 'tempfile';
 import {execa, execaSync} from '../../index.js';
 import {setFixtureDir} from '../helpers/fixtures-dir.js';
-import {getStdinOption, getStdoutOption, getStderrOption, getInputOption} from '../helpers/stdio.js';
+import {getStdinOption, getStdoutOption, getStderrOption, getStdioOption, getInputOption} from '../helpers/stdio.js';
 
 setFixtureDir();
 
@@ -24,6 +24,11 @@ const testNodeStreamSync = (t, StreamClass, getOptions, optionName) => {
 };
 
 test('input cannot be a Node.js Readable - sync', testNodeStreamSync, Readable, getInputOption, 'input');
+test('stdin cannot be a Node.js Readable - sync', testNodeStreamSync, Readable, getStdinOption, 'stdin');
+test('stdio[*] cannot be a Node.js Readable - sync', testNodeStreamSync, Readable, getStdioOption, 'stdio[3]');
+test('stdout cannot be a Node.js Writable - sync', testNodeStreamSync, Writable, getStdoutOption, 'stdout');
+test('stderr cannot be a Node.js Writable - sync', testNodeStreamSync, Writable, getStderrOption, 'stderr');
+test('stdio[*] cannot be a Node.js Writable - sync', testNodeStreamSync, Writable, getStdioOption, 'stdio[3]');
 
 test('input can be a Node.js Readable without a file descriptor', async t => {
 	const {stdout} = await execa('stdin.js', {input: createNoFileReadable('foobar')});
@@ -37,6 +42,8 @@ const testNoFileStream = async (t, getOptions, StreamClass) => {
 test('stdin cannot be a Node.js Readable without a file descriptor', testNoFileStream, getStdinOption, Readable);
 test('stdout cannot be a Node.js Writable without a file descriptor', testNoFileStream, getStdoutOption, Writable);
 test('stderr cannot be a Node.js Writable without a file descriptor', testNoFileStream, getStderrOption, Writable);
+test('stdio[*] cannot be a Node.js Readable without a file descriptor', testNoFileStream, getStdioOption, Readable);
+test('stdio[*] cannot be a Node.js Writable without a file descriptor', testNoFileStream, getStdioOption, Writable);
 
 const testFileReadable = async (t, fixtureName, getOptions) => {
 	const filePath = tempfile();
@@ -52,6 +59,7 @@ const testFileReadable = async (t, fixtureName, getOptions) => {
 
 test('input can be a Node.js Readable with a file descriptor', testFileReadable, 'stdin.js', getInputOption);
 test('stdin can be a Node.js Readable with a file descriptor', testFileReadable, 'stdin.js', getStdinOption);
+test('stdio[*] can be a Node.js Readable with a file descriptor', testFileReadable, 'stdin-fd3.js', getStdioOption);
 
 const testFileWritable = async (t, getOptions, fixtureName) => {
 	const filePath = tempfile();
@@ -66,3 +74,4 @@ const testFileWritable = async (t, getOptions, fixtureName) => {
 
 test('stdout can be a Node.js Writable with a file descriptor', testFileWritable, getStdoutOption, 'noop.js');
 test('stderr can be a Node.js Writable with a file descriptor', testFileWritable, getStderrOption, 'noop-err.js');
+test('stdio[*] can be a Node.js Writable with a file descriptor', testFileWritable, getStdioOption, 'noop-fd3.js');
diff --git a/test/stdio/web-stream.js b/test/stdio/web-stream.js
index bc33492..00d2016 100644
--- a/test/stdio/web-stream.js
+++ b/test/stdio/web-stream.js
@@ -2,7 +2,7 @@ import {Readable} from 'node:stream';
 import test from 'ava';
 import {execa, execaSync} from '../../index.js';
 import {setFixtureDir} from '../helpers/fixtures-dir.js';
-import {getStdinOption, getStdoutOption, getStderrOption} from '../helpers/stdio.js';
+import {getStdinOption, getStdoutOption, getStderrOption, getStdioOption} from '../helpers/stdio.js';
 
 setFixtureDir();
 
@@ -13,6 +13,7 @@ const testReadableStream = async (t, fixtureName, getOptions) => {
 };
 
 test('stdin can be a ReadableStream', testReadableStream, 'stdin.js', getStdinOption);
+test('stdio[*] can be a ReadableStream', testReadableStream, 'stdin-fd3.js', getStdioOption);
 
 const testWritableStream = async (t, fixtureName, getOptions) => {
 	const result = [];
@@ -27,6 +28,7 @@ const testWritableStream = async (t, fixtureName, getOptions) => {
 
 test('stdout can be a WritableStream', testWritableStream, 'noop.js', getStdoutOption);
 test('stderr can be a WritableStream', testWritableStream, 'noop-err.js', getStderrOption);
+test('stdio[*] can be a WritableStream', testWritableStream, 'noop-fd3.js', getStdioOption);
 
 const testWebStreamSync = (t, StreamClass, getOptions, optionName) => {
 	t.throws(() => {
@@ -35,8 +37,10 @@ const testWebStreamSync = (t, StreamClass, getOptions, optionName) => {
 };
 
 test('stdin cannot be a ReadableStream - sync', testWebStreamSync, ReadableStream, getStdinOption, 'stdin');
+test('stdio[*] cannot be a ReadableStream - sync', testWebStreamSync, ReadableStream, getStdioOption, 'stdio[3]');
 test('stdout cannot be a WritableStream - sync', testWebStreamSync, WritableStream, getStdoutOption, 'stdout');
 test('stderr cannot be a WritableStream - sync', testWebStreamSync, WritableStream, getStderrOption, 'stderr');
+test('stdio[*] cannot be a WritableStream - sync', testWebStreamSync, WritableStream, getStdioOption, 'stdio[3]');
 
 const testWritableStreamError = async (t, getOptions) => {
 	const writableStream = new WritableStream({
@@ -50,3 +54,4 @@ const testWritableStreamError = async (t, getOptions) => {
 
 test('stdout option handles errors in WritableStream', testWritableStreamError, getStdoutOption);
 test('stderr option handles errors in WritableStream', testWritableStreamError, getStderrOption);
+test('stdio[*] option handles errors in WritableStream', testWritableStreamError, getStdioOption);