Skip to content

Commit

Permalink
Add SubprocessError class (#83)
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky authored Oct 27, 2024
1 parent c0e5bbc commit fde676e
Show file tree
Hide file tree
Showing 7 changed files with 66 additions and 30 deletions.
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ Subprocesses fail either when their [exit code](#subprocesserrorexitcode) is not

Subprocess errors have the same shape as [successful results](#result), with the following additional properties.

This error class is exported, so you can use `if (error instanceof SubprocessError) { ... }`.

##### subprocessError.exitCode

_Type_: `number | undefined`
Expand Down
11 changes: 9 additions & 2 deletions source/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,14 @@ When the subprocess fails, its promise is rejected with this error.
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.
*/
export type SubprocessError = Error & Result & {
export class SubprocessError extends Error implements Result {
stdout: Result['stdout'];
stderr: Result['stderr'];
output: Result['output'];
command: Result['command'];
durationMs: Result['durationMs'];
pipedFrom?: Result['pipedFrom'];

/**
The numeric [exit code](https://en.wikipedia.org/wiki/Exit_status) of the subprocess that was run.
Expand All @@ -150,7 +157,7 @@ export type SubprocessError = Error & Result & {
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`.
*/
signalName?: string;
};
}

/**
Subprocess started by `spawn()`.
Expand Down
2 changes: 2 additions & 0 deletions source/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {getResult} from './result.js';
import {handlePipe} from './pipe.js';
import {lineIterator, combineAsyncIterators} from './iterable.js';

export {SubprocessError} from './result.js';

export default function spawn(file, second, third, previous) {
const [commandArguments = [], options = {}] = Array.isArray(second) ? [second, third] : [[], second];
const context = getContext([file, ...commandArguments]);
Expand Down
29 changes: 15 additions & 14 deletions source/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import {
expectError,
} from 'tsd';
import spawn, {
SubprocessError,
type Options,
type Result,
type SubprocessError,
type Subprocess,
} from './index.js';

Expand All @@ -28,19 +28,20 @@ try {
expectError(result.signalName);
expectError(result.other);
} catch (error) {
const subprocessError = error as SubprocessError;
expectType<string>(subprocessError.stdout);
expectType<string>(subprocessError.stderr);
expectType<string>(subprocessError.output);
expectType<string>(subprocessError.command);
expectType<number>(subprocessError.durationMs);
expectType<Result | SubprocessError | undefined>(subprocessError.pipedFrom);
expectType<Result | SubprocessError | undefined>(subprocessError.pipedFrom?.pipedFrom);
expectType<number | undefined>(subprocessError.pipedFrom?.durationMs);
expectAssignable<Error>(subprocessError);
expectType<number | undefined>(subprocessError.exitCode);
expectType<string | undefined>(subprocessError.signalName);
expectError(subprocessError.other);
if (error instanceof SubprocessError) {
expectType<string>(error.stdout);
expectType<string>(error.stderr);
expectType<string>(error.output);
expectType<string>(error.command);
expectType<number>(error.durationMs);
expectType<Result | SubprocessError | undefined>(error.pipedFrom);
expectType<Result | SubprocessError | undefined>(error.pipedFrom?.pipedFrom);
expectType<number | undefined>(error.pipedFrom?.durationMs);
expectAssignable<Error>(error);
expectType<number | undefined>(error.exitCode);
expectType<string | undefined>(error.signalName);
expectError(error.other);
}
}

expectAssignable<Options>({} as const);
Expand Down
12 changes: 8 additions & 4 deletions source/result.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ const onStreamError = async stream => {

const checkFailure = ({command}, {exitCode, signalName}) => {
if (signalName !== undefined) {
throw new Error(`Command was terminated with ${signalName}: ${command}`);
throw new SubprocessError(`Command was terminated with ${signalName}: ${command}`);
}

if (exitCode !== undefined) {
throw new Error(`Command failed with exit code ${exitCode}: ${command}`);
throw new SubprocessError(`Command failed with exit code ${exitCode}: ${command}`);
}
};

Expand All @@ -47,9 +47,13 @@ export const getResultError = (error, instance, context) => Object.assign(
getOutputs(context),
);

const getErrorInstance = (error, {command}) => error?.message.startsWith('Command ')
const getErrorInstance = (error, {command}) => error instanceof SubprocessError
? error
: new Error(`Command failed: ${command}`, {cause: error});
: new SubprocessError(`Command failed: ${command}`, {cause: error});

export class SubprocessError extends Error {
name = 'SubprocessError';
}

const getErrorOutput = ({exitCode, signalCode}) => ({
// `exitCode` can be a negative number (`errno`) when the `error` event is emitted on the `instance`
Expand Down
31 changes: 22 additions & 9 deletions test/helpers/assert.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import {nonExistentCommand, nodeHangingCommand, nodeEvalCommandStart} from './commands.js';

export const assertSubprocessErrorName = (t, name) => {
t.is(name, 'SubprocessError');
};

export const assertDurationMs = (t, durationMs) => {
t.true(Number.isFinite(durationMs));
t.true(durationMs >= 0);
};

export const assertNonExistent = (t, {exitCode, signalName, command, message, stderr, cause, durationMs}, commandStart = nonExistentCommand, expectedCommand = commandStart) => {
export const assertNonExistent = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, commandStart = nonExistentCommand, expectedCommand = commandStart) => {
assertSubprocessErrorName(t, name);
t.is(exitCode, undefined);
t.is(signalName, undefined);
t.is(command, expectedCommand);
Expand All @@ -18,7 +23,8 @@ export const assertNonExistent = (t, {exitCode, signalName, command, message, st
assertDurationMs(t, durationMs);
};

export const assertWindowsNonExistent = (t, {exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCommand = nonExistentCommand) => {
export const assertWindowsNonExistent = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCommand = nonExistentCommand) => {
assertSubprocessErrorName(t, name);
t.is(exitCode, 1);
t.is(signalName, undefined);
t.is(command, expectedCommand);
Expand All @@ -28,7 +34,8 @@ export const assertWindowsNonExistent = (t, {exitCode, signalName, command, mess
assertDurationMs(t, durationMs);
};

export const assertUnixNonExistentShell = (t, {exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCommand = nonExistentCommand) => {
export const assertUnixNonExistentShell = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCommand = nonExistentCommand) => {
assertSubprocessErrorName(t, name);
t.is(exitCode, 127);
t.is(signalName, undefined);
t.is(command, expectedCommand);
Expand All @@ -38,7 +45,8 @@ export const assertUnixNonExistentShell = (t, {exitCode, signalName, command, me
assertDurationMs(t, durationMs);
};

export const assertUnixNotFound = (t, {exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCommand = nonExistentCommand) => {
export const assertUnixNotFound = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCommand = nonExistentCommand) => {
assertSubprocessErrorName(t, name);
t.is(exitCode, 127);
t.is(signalName, undefined);
t.is(command, expectedCommand);
Expand All @@ -48,7 +56,8 @@ export const assertUnixNotFound = (t, {exitCode, signalName, command, message, s
assertDurationMs(t, durationMs);
};

export const assertFail = (t, {exitCode, signalName, command, message, cause, durationMs}, commandStart = nodeEvalCommandStart) => {
export const assertFail = (t, {name, exitCode, signalName, command, message, cause, durationMs}, commandStart = nodeEvalCommandStart) => {
assertSubprocessErrorName(t, name);
t.is(exitCode, 2);
t.is(signalName, undefined);
t.true(command.startsWith(commandStart));
Expand All @@ -57,7 +66,8 @@ export const assertFail = (t, {exitCode, signalName, command, message, cause, du
assertDurationMs(t, durationMs);
};

export const assertSigterm = (t, {exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCommand = nodeHangingCommand) => {
export const assertSigterm = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCommand = nodeHangingCommand) => {
assertSubprocessErrorName(t, name);
t.is(exitCode, undefined);
t.is(signalName, 'SIGTERM');
t.is(command, expectedCommand);
Expand All @@ -67,7 +77,8 @@ export const assertSigterm = (t, {exitCode, signalName, command, message, stderr
assertDurationMs(t, durationMs);
};

export const assertEarlyError = (t, {exitCode, signalName, command, message, stderr, cause, durationMs}, commandStart = nodeEvalCommandStart) => {
export const assertEarlyError = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, commandStart = nodeEvalCommandStart) => {
assertSubprocessErrorName(t, name);
t.is(exitCode, undefined);
t.is(signalName, undefined);
t.true(command.startsWith(commandStart));
Expand All @@ -78,7 +89,8 @@ export const assertEarlyError = (t, {exitCode, signalName, command, message, std
assertDurationMs(t, durationMs);
};

export const assertAbortError = (t, {exitCode, signalName, command, stderr, message, cause, durationMs}, expectedCause, expectedCommand = nodeHangingCommand) => {
export const assertAbortError = (t, {name, exitCode, signalName, command, stderr, message, cause, durationMs}, expectedCause, expectedCommand = nodeHangingCommand) => {
assertSubprocessErrorName(t, name);
t.is(exitCode, undefined);
t.is(signalName, undefined);
t.is(command, expectedCommand);
Expand All @@ -89,7 +101,8 @@ export const assertAbortError = (t, {exitCode, signalName, command, stderr, mess
assertDurationMs(t, durationMs);
};

export const assertErrorEvent = (t, {exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCause, commandStart = nodeEvalCommandStart) => {
export const assertErrorEvent = (t, {name, exitCode, signalName, command, message, stderr, cause, durationMs}, expectedCause, commandStart = nodeEvalCommandStart) => {
assertSubprocessErrorName(t, name);
t.is(exitCode, undefined);
t.is(signalName, undefined);
t.true(command.startsWith(commandStart));
Expand Down
9 changes: 8 additions & 1 deletion test/result.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import test from 'ava';
import spawn from '../source/index.js';
import spawn, {SubprocessError} from '../source/index.js';
import {
isWindows,
isLinux,
Expand All @@ -9,6 +9,7 @@ import {
} from './helpers/main.js';
import {testString, secondTestString} from './helpers/arguments.js';
import {
assertSubprocessErrorName,
assertFail,
assertSigterm,
assertEarlyError,
Expand All @@ -33,6 +34,12 @@ test('result.exitCode|signalName on success', async t => {
t.is(signalName, undefined);
});

test('Error is an instance of SubprocessError', async t => {
const error = await t.throwsAsync(spawn(...nodeEval('process.exit(2)')));
t.true(error instanceof SubprocessError);
assertSubprocessErrorName(t, error.name);
});

test('Error on non-0 exit code', async t => {
const error = await t.throwsAsync(spawn(...nodeEval('process.exit(2)')));
assertFail(t, error);
Expand Down

0 comments on commit fde676e

Please sign in to comment.