Repository: sindresorhus/execa (js)

Issue:
Allow `stdin`/`stdout`/`stderr` options to be a file URL
## New feature

Allow the `stdin`, `stdout` and `stderr` options to be `new URL('file://path')`. It would be transformed to a stream using [`fs.createReadStream()`](https://nodejs.org/api/fs.html#fscreatereadstreampath-options)/[`fs.createWriteStream()`](https://nodejs.org/api/fs.html#fscreatewritestreampath-options).

## Removed features

The above would replace the `inputFile` option. 
Combined with #591, this would also allow removing the `.pipeStdout()`, `.pipeStderr()` and `.pipeAll()` methods.

## Rational

Why remove the current `.pipe*()` methods and replace them with #591 and this? Those methods have multiple problems.

### Unhandled errors

When piping two processes, any `error` thrown on the first process is unhandled, making the process crash.

```js
try {
  await execa('crash!').pipeStdout(execa('cat'))
} catch (error) {
  // This is not handled
}
```

This is because `.pipeStdout()` returns the second child process. Any `error` thrown on the child process is left unhandled.

### Ignored `stderr`

For the same reason, any `stderr` from the first child process will be ignored, which might or might not be intended. In many cases, this could hide some bugs. 

From an efficiency standpoint, this means the first child process's `stderr` uses the default `pipe` option, even though it behaves as if `ignore` had been used, except that a stream is unnecessarily created.

### Race condition

When piping to a stream (including the one created when passing a file path), the Execa promise resolves once all data has been sent to the stream, but not when that stream has finished processing that data.

For example, when `await execa(...).pipeStdout('filePath')` resolves, the file might not exist yet. This is currently caught by our tests for `.pipeStdout(filePath)` which randomly fail.

### Complexity

Users currently have too many ways to pipe. While each of them serves different purposes, the API is not orthogonal and can be confusing. We offer: `childProcess.pipeStdout()`, `childProcess.pipeStderr()`, `childProcess.pipeAll()`, `childProcess.stdout|stderr|all.pipe()`, the `all` option, the `input` option, the `inputFile` option and the `stdin`/`stdout`/`stderr` options.

This proposal would simplify it to:
  - For most use cases: the `stdin`/`stdout`/`stderr` options are enough
  - For advanced use cases: use `childProcess.stdout|stderr|all.pipe()`
  - To pipe multiple processes: use `Stream.pipeline(childProcess...)`

### Piping stdout and stderr separately

Piping `stdout` and `stderr` separately might be confusing. For example, the following works:

```js
execa(...).pipeStdout('stdout.txt').pipeStderr('stderr.txt')
```

But the following (potentially unexpectedly) redirects the second (not the first) process's `stderr` to the third process's:

```js
execa(...).pipeStdout(execa(...)).pipeStderr(execa(...))
```

### Piping to stream's confusion

When piping to another child process, that other child process is returned. When piping to a stream, that stream is not returned. Making both behave the same would be less confusing, and give users more the impression that child process behave like streams when using Execa.

Pull request description:
Add `stdout|stderr: filePath | fileURL` option
Fixes #592.
Follow-up after #610 and #614.

This allows the `stdout`/`stderr` options to be a file path or a file URL.

Patch:
diff --git a/index.d.ts b/index.d.ts
index 4e267f5..92af32c 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -22,6 +22,8 @@ export type StdinOption =
 
 export type StdoutStderrOption =
 	| StdioOption
+	| URL
+	| string
 	| WritableStream;
 
 type EncodingOption =
@@ -107,7 +109,7 @@ export type CommonOptions<EncodingType extends EncodingOption = DefaultEncodingO
 	/**
 	Same options as [`stdio`](https://nodejs.org/dist/latest-v6.x/docs/api/child_process.html#child_process_options_stdio).
 
-	It can also be a web stream ([`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream)), unless [`execaSync()`](#execasyncfile-arguments-options) is used.
+	It can also be a file path, a file URL, a web stream ([`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream)), unless [`execaSync()`](#execasyncfile-arguments-options) is used. If the file path is relative, it must start with `.`.
 
 	@default 'pipe'
 	*/
@@ -116,7 +118,7 @@ export type CommonOptions<EncodingType extends EncodingOption = DefaultEncodingO
 	/**
 	Same options as [`stdio`](https://nodejs.org/dist/latest-v6.x/docs/api/child_process.html#child_process_options_stdio).
 
-	It can also be a web stream ([`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream)), unless [`execaSync()`](#execasyncfile-arguments-options) is used.
+	It can also be a file path, a file URL, a web stream ([`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream)), unless [`execaSync()`](#execasyncfile-arguments-options) is used. If the file path is relative, it must start with `.`.
 
 	@default 'pipe'
 	*/
diff --git a/index.js b/index.js
index b34b342..ad62e94 100644
--- a/index.js
+++ b/index.js
@@ -7,7 +7,7 @@ import stripFinalNewline from 'strip-final-newline';
 import {npmRunPathEnv} from 'npm-run-path';
 import onetime from 'onetime';
 import {makeError} from './lib/error.js';
-import {handleStdioOption, handleInputOption, pipeStdioOption, normalizeStdioNode} from './lib/stdio.js';
+import {handleStdioOption, handleInputSync, handleOutputSync, pipeStdioOptions, normalizeStdioNode} from './lib/stdio.js';
 import {spawnedKill, spawnedCancel, setupTimeout, validateTimeout, setExitHandler} from './lib/kill.js';
 import {addPipeMethods} from './lib/pipe.js';
 import {getSpawnedResult, makeAllStream} from './lib/stream.js';
@@ -158,7 +158,7 @@ export function execa(file, args, options) {
 
 	const handlePromiseOnce = onetime(handlePromise);
 
-	pipeStdioOption(spawned, stdioStreams);
+	pipeStdioOptions(spawned, stdioStreams);
 
 	spawned.all = makeAllStream(spawned, parsed.options);
 
@@ -169,7 +169,7 @@ export function execa(file, args, options) {
 
 export function execaSync(file, args, options) {
 	const parsed = handleArguments(file, args, options);
-	handleInputOption(parsed.options);
+	const stdioArray = handleInputSync(parsed.options);
 
 	const command = joinCommand(file, args);
 	const escapedCommand = getEscapedCommand(file, args);
@@ -193,6 +193,7 @@ export function execaSync(file, args, options) {
 		});
 	}
 
+	handleOutputSync(stdioArray, result);
 	const stdout = handleOutput(parsed.options, result.stdout, result.error);
 	const stderr = handleOutput(parsed.options, result.stderr, result.error);
 
diff --git a/lib/stdio.js b/lib/stdio.js
index cd69619..c25b2c9 100644
--- a/lib/stdio.js
+++ b/lib/stdio.js
@@ -1,10 +1,8 @@
-import {createReadStream, readFileSync} from 'node:fs';
+import {createReadStream, createWriteStream, readFileSync, writeFileSync} from 'node:fs';
 import {isAbsolute} from 'node:path';
 import {Readable, Writable} from 'node:stream';
 import {isStream as isNodeStream} from 'is-stream';
 
-const aliases = ['stdin', 'stdout', 'stderr'];
-
 const arrifyStdio = (stdio = []) => Array.isArray(stdio) ? stdio : [stdio, stdio, stdio];
 
 const isIterableStdin = stdinOption => typeof stdinOption === 'object'
@@ -42,14 +40,14 @@ const cannotPipeStdin = stdinOption => NO_PIPE_STDIO.has(stdinOption)
 const NO_PIPE_STDIO = new Set(['ipc', 'ignore', 'inherit']);
 const KNOWN_STDIO = new Set([...NO_PIPE_STDIO, 'overlapped', 'pipe']);
 
-const validateFileSdio = stdioOption => {
+const validateFileSdio = (stdioOption, optionName) => {
 	if (isRegularUrl(stdioOption)) {
-		throw new TypeError(`The \`stdin: URL\` option must use the \`file:\` scheme.
+		throw new TypeError(`The \`${optionName}: URL\` option must use the \`file:\` scheme.
 For example, you can use the \`pathToFileURL()\` method of the \`url\` core module.`);
 	}
 
 	if (isUnknownStdioString(stdioOption)) {
-		throw new TypeError('The `stdin: filePath` option must either be an absolute file path or start with `.`.');
+		throw new TypeError(`The \`${optionName}: filePath\` option must either be an absolute file path or start with \`.\`.`);
 	}
 };
 
@@ -67,32 +65,34 @@ const validateInputOptions = (stdioArray, input, inputFile) => {
 		throw new TypeError('The `inputFile` and `stdin` options cannot be both set.');
 	}
 
-	validateFileSdio(stdioArray[0]);
+	validateFileSdio(stdioArray[0], 'stdin');
+	validateFileSdio(stdioArray[1], 'stdout');
+	validateFileSdio(stdioArray[2], 'stderr');
 };
 
-const getStdioStreams = (stdioArray, {input, inputFile}) => ({
-	...getStdinStream(stdioArray[0], input, inputFile),
-	...getStdoutStream(stdioArray[1]),
-	...getStderrStream(stdioArray[2]),
-});
+const getStdioStreams = (stdioArray, {input, inputFile}) => [
+	{...getStdinStream(stdioArray[0], input, inputFile), isInput: true},
+	getOutputStream(stdioArray[1]),
+	getOutputStream(stdioArray[2]),
+];
 
 const getStdinStream = (stdinOption, input, inputFile) => {
 	const iterableStdin = getIterableStdin(stdinOption);
 
 	if (iterableStdin !== undefined) {
-		return {stdinStream: Readable.from(iterableStdin)};
+		return {value: Readable.from(iterableStdin)};
 	}
 
 	if (isReadableStream(stdinOption)) {
-		return {stdinStream: Readable.fromWeb(stdinOption)};
+		return {value: Readable.fromWeb(stdinOption)};
 	}
 
 	if (isFileUrl(stdinOption) || isFilePath(stdinOption)) {
-		return {stdinStream: createReadStream(stdinOption)};
+		return {value: createReadStream(stdinOption), finite: true};
 	}
 
 	if (inputFile !== undefined) {
-		return {stdinStream: createReadStream(inputFile)};
+		return {value: createReadStream(inputFile), finite: true};
 	}
 
 	if (input === undefined) {
@@ -100,54 +100,34 @@ const getStdinStream = (stdinOption, input, inputFile) => {
 	}
 
 	if (isNodeStream(input)) {
-		return {stdinStream: input};
+		return {value: input};
 	}
 
-	return {stdinInput: input};
-};
-
-const getStdoutStream = stdoutOption => {
-	const stdoutStream = getOutputStream(stdoutOption);
-	return stdoutStream === undefined ? {} : {stdoutStream};
-};
-
-const getStderrStream = stderrOption => {
-	const stderrStream = getOutputStream(stderrOption);
-	return stderrStream === undefined ? {} : {stderrStream};
+	return {value: input, single: true};
 };
 
 const getOutputStream = stdioOption => {
 	if (isWritableStream(stdioOption)) {
-		return Writable.fromWeb(stdioOption);
+		return {value: Writable.fromWeb(stdioOption)};
 	}
-};
 
-// When the `stdin: Iterable | ReadableStream | 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 willPipeStreams = (index, {stdinStream, stdinInput, stdoutStream, stderrStream}) => {
-	if (index === 0) {
-		return stdinStream !== undefined || stdinInput !== undefined;
-	}
-
-	if (index === 1) {
-		return stdoutStream !== undefined;
+	if (isFileUrl(stdioOption) || isFilePath(stdioOption)) {
+		return {value: createWriteStream(stdioOption), finite: true};
 	}
 
-	if (index === 2) {
-		return stderrStream !== undefined;
-	}
-
-	return false;
+	return {};
 };
 
+// When the `stdin: Iterable | ReadableStream | 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 transformStdioItem = (stdioItem, index, stdioStreams) =>
-	willPipeStreams(index, stdioStreams) && stdioItem !== 'overlapped' ? 'pipe' : stdioItem;
+	stdioStreams[index]?.value !== undefined && stdioItem !== 'overlapped' ? 'pipe' : stdioItem;
 
 const transformStdio = (stdio, stdioStreams) => Array.isArray(stdio)
 	? stdio.map((stdioItem, index) => transformStdioItem(stdioItem, index, stdioStreams))
 	: stdio;
 
-// Handle `input`, `inputFile` and `stdin` options, before spawning, in async mode
+// Handle `input`, `inputFile`, `stdin`, `stdout` and `stderr` options, before spawning, in async mode
 export const handleStdioOption = options => {
 	const stdio = normalizeStdio(options);
 	const stdioArray = arrifyStdio(stdio);
@@ -157,23 +137,29 @@ export const handleStdioOption = options => {
 	return stdioStreams;
 };
 
-// Handle `input`, `inputFile` and `stdin` options, after spawning, in async mode
-export const pipeStdioOption = (spawned, {stdinStream, stdinInput, stdoutStream, stderrStream}) => {
-	if (stdinStream !== undefined) {
-		stdinStream.pipe(spawned.stdin);
+// Handle `input`, `inputFile`, `stdin`, `stdout` and `stderr` options, after spawning, in async mode
+export const pipeStdioOptions = (spawned, stdioStreams) => {
+	for (const [index, stdioStream] of stdioStreams.entries()) {
+		pipeStdioOption(spawned.stdio[index], stdioStream);
 	}
+};
 
-	if (stdinInput !== undefined) {
-		spawned.stdin.end(stdinInput);
+const pipeStdioOption = (childStream, {single, value, isInput}) => {
+	if (value === undefined) {
+		return;
 	}
 
-	if (stdoutStream !== undefined) {
-		spawned.stdout.pipe(stdoutStream);
+	if (!isInput) {
+		childStream.pipe(value);
+		return;
 	}
 
-	if (stderrStream !== undefined) {
-		spawned.stderr.pipe(stderrStream);
+	if (single) {
+		childStream.end(value);
+		return;
 	}
+
+	value.pipe(childStream);
 };
 
 const transformStdioItemSync = stdioItem => isFileUrl(stdioItem) || isFilePath(stdioItem) ? 'pipe' : stdioItem;
@@ -222,7 +208,7 @@ const getInputOption = (stdinOption, {input, inputFile}) => {
 };
 
 // Handle `input`, `inputFile` and `stdin` options, before spawning, in sync mode
-export const handleInputOption = options => {
+export const handleInputSync = options => {
 	const stdio = normalizeStdio(options);
 	const stdioArray = arrifyStdio(stdio);
 	validateOptionsSync(stdioArray, options);
@@ -233,8 +219,27 @@ export const handleInputOption = options => {
 	}
 
 	options.stdio = transformStdioSync(stdio);
+	return stdioArray;
 };
 
+// Handle `stdout` and `stderr` options, before spawning, in sync mode
+const handleOutputOption = (stdioOption, result) => {
+	if (result === null) {
+		return;
+	}
+
+	if (isFileUrl(stdioOption) || isFilePath(stdioOption)) {
+		writeFileSync(stdioOption, result);
+	}
+};
+
+export const handleOutputSync = (stdioArray, {stdout, stderr}) => {
+	handleOutputOption(stdioArray[1], stdout);
+	handleOutputOption(stdioArray[2], stderr);
+};
+
+const aliases = ['stdin', 'stdout', 'stderr'];
+
 const hasAlias = options => aliases.some(alias => options[alias] !== undefined);
 
 export const normalizeStdio = options => {
diff --git a/lib/stream.js b/lib/stream.js
index 1885aa2..222f6ae 100644
--- a/lib/stream.js
+++ b/lib/stream.js
@@ -1,5 +1,6 @@
 import {once} from 'node:events';
 import {setTimeout} from 'node:timers/promises';
+import {finished} from 'node:stream/promises';
 import getStream, {getStreamAsBuffer} from 'get-stream';
 import mergeStreams from '@sindresorhus/merge-streams';
 
@@ -53,9 +54,19 @@ const applyEncoding = async (stream, maxBuffer, encoding) => {
 	return buffer.toString(encoding);
 };
 
-// Handle any errors thrown by the iterable passed to the `stdin`/`stdout`/`stderr` option, if any.
-// We do not consume nor wait on those streams though, since it could potentially be infinite (like `process.stdin` in an interactive TTY).
-const throwOnStreamsError = streams => streams.filter(Boolean).map(stream => throwOnStreamError(stream));
+// We need to handle any `error` coming from the `stdin|stdout|stderr` options.
+// However, those might be infinite streams, e.g. a TTY passed as input or output.
+// We wait for completion or not depending on whether `finite` is `true`.
+// In either case, we handle `error` events while the process is running.
+const waitForStreamEnd = ({value, finite, single}, processDone) => {
+	if (value === undefined || single) {
+		return;
+	}
+
+	return finite
+		? finished(value)
+		: Promise.race([processDone, throwOnStreamError(value)]);
+};
 
 const throwOnStreamError = async stream => {
 	const [error] = await once(stream, 'error');
@@ -63,14 +74,24 @@ const throwOnStreamError = async stream => {
 };
 
 // Retrieve result of child process: exit code, signal, error, streams (stdout/stderr/all)
-export const getSpawnedResult = async (spawned, {encoding, buffer, maxBuffer}, {stdinStream, stdoutStream, stderrStream}, processDone) => {
+export const getSpawnedResult = async (
+	spawned,
+	{encoding, buffer, maxBuffer},
+	stdioStreams,
+	processDone,
+) => {
 	const stdoutPromise = getStreamPromise(spawned.stdout, {encoding, buffer, maxBuffer});
 	const stderrPromise = getStreamPromise(spawned.stderr, {encoding, buffer, maxBuffer});
 	const allPromise = getStreamPromise(spawned.all, {encoding, buffer, maxBuffer: maxBuffer * 2});
-	const processDoneOrStreamsError = Promise.race([processDone, ...throwOnStreamsError([stdinStream, stdoutStream, stderrStream])]);
 
 	try {
-		return await Promise.all([processDoneOrStreamsError, stdoutPromise, stderrPromise, allPromise]);
+		return await Promise.all([
+			processDone,
+			stdoutPromise,
+			stderrPromise,
+			allPromise,
+			...stdioStreams.map(stdioStream => waitForStreamEnd(stdioStream, processDone)),
+		]);
 	} catch (error) {
 		spawned.kill();
 		return Promise.all([
diff --git a/readme.md b/readme.md
index 9da497a..197a712 100644
--- a/readme.md
+++ b/readme.md
@@ -568,20 +568,22 @@ It can also be a file path, a file URL, a web stream ([`ReadableStream`](https:/
 
 #### stdout
 
-Type: `string | number | stream.Writable | WritableStream | undefined`\
+Type: `string | number | stream.Writable | WritableStream | undefined | URL`\
 Default: `pipe`
 
 Same options as [`stdio`](https://nodejs.org/dist/latest-v6.x/docs/api/child_process.html#child_process_options_stdio).
 
-It can also be a web stream ([`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream)), unless [`execaSync()`](#execasyncfile-arguments-options) is used.
+It can also be a file path, a file URL, a web stream ([`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream)), unless [`execaSync()`](#execasyncfile-arguments-options) is used. If the file path is relative, it must start with `.`.
 
 #### stderr
 
-Type: `string | number | stream.Writable | WritableStream | undefined`\
+Type: `string | number | stream.Writable | WritableStream | undefined | URL`\
 Default: `pipe`
 
 Same options as [`stdio`](https://nodejs.org/dist/latest-v6.x/docs/api/child_process.html#child_process_options_stdio).
 
+It can also be a file path, a file URL, a web stream ([`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream)), unless [`execaSync()`](#execasyncfile-arguments-options) is used. If the file path is relative, it must start with `.`.
+
 #### all
 
 Type: `boolean`\


Test patch:
diff --git a/index.test-d.ts b/index.test-d.ts
index a062c37..c3a301a 100644
--- a/index.test-d.ts
+++ b/index.test-d.ts
@@ -176,6 +176,8 @@ execa('unicorns', {stdout: 'ignore'});
 execa('unicorns', {stdout: 'inherit'});
 execa('unicorns', {stdout: process.stdout});
 execa('unicorns', {stdout: new WritableStream()});
+execa('unicorns', {stdout: new URL('file:///test')});
+execa('unicorns', {stdout: './test'});
 execa('unicorns', {stdout: 1});
 execa('unicorns', {stdout: undefined});
 execa('unicorns', {stderr: 'pipe'});
@@ -185,6 +187,8 @@ execa('unicorns', {stderr: 'ignore'});
 execa('unicorns', {stderr: 'inherit'});
 execa('unicorns', {stderr: process.stderr});
 execa('unicorns', {stderr: new WritableStream()});
+execa('unicorns', {stderr: new URL('file:///test')});
+execa('unicorns', {stderr: './test'});
 execa('unicorns', {stderr: 1});
 execa('unicorns', {stderr: undefined});
 execa('unicorns', {all: true});
diff --git a/test/stream.js b/test/stream.js
index 8c0b066..837a680 100644
--- a/test/stream.js
+++ b/test/stream.js
@@ -2,6 +2,7 @@ import {Buffer} from 'node:buffer';
 import {exec} from 'node:child_process';
 import process from 'node:process';
 import fs from 'node:fs';
+import {readFile} from 'node:fs/promises';
 import {relative} from 'node:path';
 import Stream from 'node:stream';
 import {setTimeout} from 'node:timers/promises';
@@ -18,6 +19,8 @@ const pExec = promisify(exec);
 
 setFixtureDir();
 
+const nonFileUrl = new URL('https://example.com');
+
 test('buffer', async t => {
 	const {stdout} = await execa('noop.js', ['foo'], {encoding: null});
 	t.true(Buffer.isBuffer(stdout));
@@ -281,16 +284,31 @@ test('stdin cannot be a ReadableStream when inputFile is used', t => {
 test('stdin can be a file URL', async t => {
 	const inputFile = tempfile();
 	fs.writeFileSync(inputFile, 'howdy');
-	const stdin = pathToFileURL(inputFile);
-	const {stdout} = await execa('stdin.js', {stdin});
+	const {stdout} = await execa('stdin.js', {stdin: pathToFileURL(inputFile)});
 	t.is(stdout, 'howdy');
 });
 
-test('stdin cannot be a non-file URL', async t => {
-	await t.throws(() => {
-		execa('stdin.js', {stdin: new URL('https://example.com')});
+const testOutputFileUrl = async (t, streamName, fixtureName) => {
+	const outputFile = tempfile();
+	await execa(fixtureName, ['foobar'], {[streamName]: pathToFileURL(outputFile)});
+	t.is(await readFile(outputFile, 'utf8'), 'foobar\n');
+};
+
+test('stdout can be a file URL', testOutputFileUrl, 'stdout', 'noop.js');
+test('stderr can be a file URL', testOutputFileUrl, 'stderr', 'noop-err.js');
+
+const testStdioNonFileUrl = (t, streamName, method) => {
+	t.throws(() => {
+		method('noop.js', {[streamName]: nonFileUrl});
 	}, {message: /pathToFileURL/});
-});
+};
+
+test('stdin cannot be a non-file URL', testStdioNonFileUrl, 'stdin', execa);
+test('stdout cannot be a non-file URL', testStdioNonFileUrl, 'stdout', execa);
+test('stderr cannot be a non-file URL', testStdioNonFileUrl, 'stderr', execa);
+test('stdin cannot be a non-file URL - sync', testStdioNonFileUrl, 'stdin', execaSync);
+test('stdout cannot be a non-file URL - sync', testStdioNonFileUrl, 'stdout', execaSync);
+test('stderr cannot be a non-file URL - sync', testStdioNonFileUrl, 'stderr', execaSync);
 
 test('stdin can be an absolute file path', async t => {
 	const inputFile = tempfile();
@@ -299,19 +317,43 @@ test('stdin can be an absolute file path', async t => {
 	t.is(stdout, 'howdy');
 });
 
+const testOutputAbsoluteFile = async (t, streamName, fixtureName) => {
+	const outputFile = tempfile();
+	await execa(fixtureName, ['foobar'], {[streamName]: outputFile});
+	t.is(await readFile(outputFile, 'utf8'), 'foobar\n');
+};
+
+test('stdout can be an absolute file path', testOutputAbsoluteFile, 'stdout', 'noop.js');
+test('stderr can be an absolute file path', testOutputAbsoluteFile, 'stderr', 'noop-err.js');
+
 test('stdin can be a relative file path', async t => {
 	const inputFile = tempfile();
 	fs.writeFileSync(inputFile, 'howdy');
-	const stdin = relative('.', inputFile);
-	const {stdout} = await execa('stdin.js', {stdin});
+	const {stdout} = await execa('stdin.js', {stdin: relative('.', inputFile)});
 	t.is(stdout, 'howdy');
 });
 
-test('stdin option must start with . when being a relative file path', t => {
+const testOutputRelativeFile = async (t, streamName, fixtureName) => {
+	const outputFile = tempfile();
+	await execa(fixtureName, ['foobar'], {[streamName]: relative('.', outputFile)});
+	t.is(await readFile(outputFile, 'utf8'), 'foobar\n');
+};
+
+test('stdout can be a relative file path', testOutputRelativeFile, 'stdout', 'noop.js');
+test('stderr can be a relative file path', testOutputRelativeFile, 'stderr', 'noop-err.js');
+
+const testStdioValidUrl = (t, streamName, method) => {
 	t.throws(() => {
-		execa('stdin.js', {stdin: 'foobar'});
+		method('noop.js', {[streamName]: 'foobar'});
 	}, {message: /absolute file path/});
-});
+};
+
+test('stdin must start with . when being a relative file path', testStdioValidUrl, 'stdin', execa);
+test('stdout must start with . when being a relative file path', testStdioValidUrl, 'stdout', execa);
+test('stderr must start with . when being a relative file path', testStdioValidUrl, 'stderr', execa);
+test('stdin must start with . when being a relative file path - sync', testStdioValidUrl, 'stdin', execaSync);
+test('stdout must start with . when being a relative file path - sync', testStdioValidUrl, 'stdout', execaSync);
+test('stderr must start with . when being a relative file path - sync', testStdioValidUrl, 'stderr', execaSync);
 
 test('inputFile can be set', async t => {
 	const inputFile = tempfile();
@@ -339,13 +381,47 @@ test('inputFile option cannot be set when stdin is set', t => {
 	}, {message: /`inputFile` and `stdin` options/});
 });
 
-test('stdin file URL errors should be handled', async t => {
-	await t.throwsAsync(execa('stdin.js', {stdin: pathToFileURL('unknown')}), {code: 'ENOENT'});
-});
+const testFileUrlError = async (t, streamName) => {
+	await t.throwsAsync(
+		execa('noop.js', {[streamName]: pathToFileURL('./unknown/file')}),
+		{code: 'ENOENT'},
+	);
+};
 
-test('stdin file path errors should be handled', async t => {
-	await t.throwsAsync(execa('stdin.js', {stdin: './unknown'}), {code: 'ENOENT'});
-});
+test('stdin file URL errors should be handled', testFileUrlError, 'stdin');
+test('stdout file URL errors should be handled', testFileUrlError, 'stdout');
+test('stderr file URL errors should be handled', testFileUrlError, 'stderr');
+
+const testFileUrlErrorSync = (t, streamName) => {
+	t.throws(() => {
+		execaSync('noop.js', {[streamName]: pathToFileURL('./unknown/file')});
+	}, {code: 'ENOENT'});
+};
+
+test('stdin file URL errors should be handled - sync', testFileUrlErrorSync, 'stdin');
+test('stdout file URL errors should be handled - sync', testFileUrlErrorSync, 'stdout');
+test('stderr file URL errors should be handled - sync', testFileUrlErrorSync, 'stderr');
+
+const testFilePathError = async (t, streamName) => {
+	await t.throwsAsync(
+		execa('noop.js', {[streamName]: './unknown/file'}),
+		{code: 'ENOENT'},
+	);
+};
+
+test('stdin file path errors should be handled', testFilePathError, 'stdin');
+test('stdout file path errors should be handled', testFilePathError, 'stdout');
+test('stderr file path errors should be handled', testFilePathError, 'stderr');
+
+const testFilePathErrorSync = (t, streamName) => {
+	t.throws(() => {
+		execaSync('noop.js', {[streamName]: './unknown/file'});
+	}, {code: 'ENOENT'});
+};
+
+test('stdin file path errors should be handled - sync', testFilePathErrorSync, 'stdin');
+test('stdout file path errors should be handled - sync', testFilePathErrorSync, 'stdout');
+test('stderr file path errors should be handled - sync', testFilePathErrorSync, 'stderr');
 
 test('inputFile errors should be handled', async t => {
 	await t.throwsAsync(execa('stdin.js', {inputFile: 'unknown'}), {code: 'ENOENT'});
@@ -411,6 +487,15 @@ test('stdin can be a file URL - sync', t => {
 	t.is(stdout, 'howdy');
 });
 
+const testOutputFileUrlSync = (t, streamName, fixtureName) => {
+	const outputFile = tempfile();
+	execaSync(fixtureName, ['foobar'], {[streamName]: pathToFileURL(outputFile)});
+	t.is(fs.readFileSync(outputFile, 'utf8'), 'foobar\n');
+};
+
+test('stdout can be a file URL - sync', testOutputFileUrlSync, 'stdout', 'noop.js');
+test('stderr can be a file URL - sync', testOutputFileUrlSync, 'stderr', 'noop-err.js');
+
 test('stdin can be an absolute file path - sync', t => {
 	const inputFile = tempfile();
 	fs.writeFileSync(inputFile, 'howdy');
@@ -418,6 +503,15 @@ test('stdin can be an absolute file path - sync', t => {
 	t.is(stdout, 'howdy');
 });
 
+const testOutputAbsoluteFileSync = (t, streamName, fixtureName) => {
+	const outputFile = tempfile();
+	execaSync(fixtureName, ['foobar'], {[streamName]: outputFile});
+	t.is(fs.readFileSync(outputFile, 'utf8'), 'foobar\n');
+};
+
+test('stdout can be an absolute file path - sync', testOutputAbsoluteFileSync, 'stdout', 'noop.js');
+test('stderr can be an absolute file path - sync', testOutputAbsoluteFileSync, 'stderr', 'noop-err.js');
+
 test('stdin can be a relative file path - sync', t => {
 	const inputFile = tempfile();
 	fs.writeFileSync(inputFile, 'howdy');
@@ -426,11 +520,14 @@ test('stdin can be a relative file path - sync', t => {
 	t.is(stdout, 'howdy');
 });
 
-test('stdin cannot be a non-file URL - sync', async t => {
-	await t.throws(() => {
-		execaSync('stdin.js', {stdin: new URL('https://example.com')});
-	}, {message: /pathToFileURL/});
-});
+const testOutputRelativeFileSync = (t, streamName, fixtureName) => {
+	const outputFile = tempfile();
+	execaSync(fixtureName, ['foobar'], {[streamName]: relative('.', outputFile)});
+	t.is(fs.readFileSync(outputFile, 'utf8'), 'foobar\n');
+};
+
+test('stdout can be a relative file path - sync', testOutputRelativeFileSync, 'stdout', 'noop.js');
+test('stderr can be a relative file path - sync', testOutputRelativeFileSync, 'stderr', 'noop-err.js');
 
 test('inputFile can be set - sync', t => {
 	const inputFile = tempfile();