Skip to content

Commit

Permalink
Improve error.message and add result.command (#31)
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky authored Aug 23, 2024
1 parent a9d7f6f commit 8bdc484
Show file tree
Hide file tree
Showing 3 changed files with 88 additions and 31 deletions.
40 changes: 29 additions & 11 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import {spawn} from 'node:child_process';
import {once} from 'node:events';
import {stripVTControlCharacters} from 'node:util';
import process from 'node:process';
import {finished} from 'node:stream/promises';
import {lineIterator, combineAsyncIterators} from './utilities.js';

export default function nanoSpawn(command, commandArguments = [], options = {}) {
export default function nanoSpawn(file, commandArguments = [], options = {}) {
[commandArguments, options] = Array.isArray(commandArguments)
? [commandArguments, options]
: [[], commandArguments];
const command = getCommand(file, commandArguments);

const subprocess = spawn(command, commandArguments, getOptions(options));
const subprocess = spawn(file, commandArguments, getOptions(options));

const promise = getResult(subprocess);
const promise = getResult(subprocess, command);

const stdoutLines = lineIterator(subprocess.stdout);
const stderrLines = lineIterator(subprocess.stderr);
Expand All @@ -36,20 +38,31 @@ const getOptions = ({
env: env === undefined ? env : {...process.env, ...env},
});

const getResult = async subprocess => {
const getCommand = (file, commandArguments) => [file, ...commandArguments]
.map(part => getCommandPart(part))
.join(' ');

const getCommandPart = part => {
part = stripVTControlCharacters(part);
return /[^\w./-]/.test(part)
? `'${part.replaceAll('\'', '\'\\\'\'')}'`
: part;
};

const getResult = async (subprocess, command) => {
const result = {};
const onExit = waitForExit(subprocess);
const onStdoutDone = bufferOutput(subprocess.stdout, result, 'stdout');
const onStderrDone = bufferOutput(subprocess.stderr, result, 'stderr');

try {
await Promise.all([onExit, onStdoutDone, onStderrDone]);
const output = getOutput(subprocess, result);
checkFailure(output);
const output = getOutput(subprocess, result, command);
checkFailure(command, output);
return output;
} catch (error) {
await Promise.allSettled([onExit, onStdoutDone, onStderrDone]);
throw Object.assign(error, getOutput(subprocess, result));
throw Object.assign(getResultError(error, command), getOutput(subprocess, result, command));
}
};

Expand Down Expand Up @@ -83,24 +96,29 @@ const bufferOutput = async (stream, result, streamName) => {
await finished(stream, {cleanup: true});
};

const getOutput = ({exitCode, signalCode}, {stdout, stderr}) => ({
const getOutput = ({exitCode, signalCode}, {stdout, stderr}, command) => ({
// `exitCode` can be a negative number (`errno`) when the `error` event is emitted on the subprocess
...(exitCode === null || exitCode < 0 ? {} : {exitCode}),
...(signalCode === null ? {} : {signalName: signalCode}),
stdout: stripNewline(stdout),
stderr: stripNewline(stderr),
command,
});

const stripNewline = input => input?.at(-1) === '\n'
? input.slice(0, input.at(-2) === '\r' ? -2 : -1)
: input;

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

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

const getResultError = (error, command) => error?.message.startsWith('Command ')
? error
: new Error(`Command failed: ${command}`, {cause: error});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"devDependencies": {
"ava": "^6.1.3",
"typescript": "^5.5.4",
"xo": "^0.59.3"
"xo": "^0.59.3",
"yoctocolors": "^2.1.1"
}
}
76 changes: 57 additions & 19 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'node:path';
import process from 'node:process';
import {setTimeout} from 'node:timers/promises';
import test from 'ava';
import {red} from 'yoctocolors';
import nanoSpawn from './index.js';

const isWindows = process.platform === 'win32';
Expand Down Expand Up @@ -93,41 +94,47 @@ test('result.exitCode|signalName on success', async t => {
t.is(signalName, undefined);
});

test('error.exitCode|signalName on non-0 exit code', async t => {
const {exitCode, signalName, message} = await t.throwsAsync(nanoSpawn('node', ['-e', 'process.exit(2)']));
test('error on non-0 exit code', async t => {
const {exitCode, signalName, message, cause} = await t.throwsAsync(nanoSpawn('node', ['-e', 'process.exit(2)']));
t.is(exitCode, 2);
t.is(signalName, undefined);
t.is(message, 'Command failed with exit code 2.');
t.is(message, 'Command failed with exit code 2: node -e \'process.exit(2)\'');
t.is(cause, undefined);
});

test('error.exitCode|signalName on signal termination', async t => {
const {exitCode, signalName, message} = await t.throwsAsync(nanoSpawn('node', {timeout: 1}));
test('error on signal termination', async t => {
const {exitCode, signalName, message, cause} = await t.throwsAsync(nanoSpawn('node', {timeout: 1}));
t.is(exitCode, undefined);
t.is(signalName, 'SIGTERM');
t.is(message, 'Command was terminated with SIGTERM.');
t.is(message, 'Command was terminated with SIGTERM: node');
t.is(cause, undefined);
});

test('error.exitCode|signalName on invalid child_process options', t => {
const {exitCode, signalName, message} = t.throws(() => nanoSpawn('node', ['--version'], {detached: 'true'}));
test('error on invalid child_process options', t => {
const {exitCode, signalName, message, cause} = t.throws(() => nanoSpawn('node', ['--version'], {detached: 'true'}));
t.is(exitCode, undefined);
t.is(signalName, undefined);
t.true(message.includes('options.detached'));
t.false(message.includes('Command'));
t.is(cause, undefined);
});

test('error.exitCode|signalName on "error" event before spawn', async t => {
const {exitCode, signalName, message} = await t.throwsAsync(nanoSpawn('non-existent-command'));
test('error on "error" event before spawn', async t => {
const {exitCode, signalName, message, cause} = await t.throwsAsync(nanoSpawn('non-existent-command'));
t.is(exitCode, undefined);
t.is(signalName, undefined);
t.true(message.includes('non-existent-command'));
t.is(message, 'Command failed: non-existent-command');
t.true(cause.message.includes('non-existent-command'));
});

test('error.exitCode|signalName on "error" event after spawn', async t => {
test('error on "error" event after spawn', async t => {
const error = new Error(testString);
const {exitCode, signalName, message, cause} = await t.throwsAsync(nanoSpawn('node', {signal: AbortSignal.abort(error)}));
t.is(exitCode, undefined);
t.is(signalName, 'SIGTERM');
t.is(message, 'The operation was aborted');
t.is(cause, error);
t.is(message, 'Command failed: node');
t.is(cause.message, 'The operation was aborted');
t.is(cause.cause, error);
});

test('result.stdout is set', async t => {
Expand Down Expand Up @@ -278,14 +285,45 @@ test('Handles stdout error', async t => {
const promise = nanoSpawn('node', ['--version']);
const error = new Error(testString);
promise.subprocess.stdout.emit('error', error);
t.is(await t.throwsAsync(promise), error);
const {cause} = await t.throwsAsync(promise);
t.is(cause, error);
});

test('Handles stderr error', async t => {
const promise = nanoSpawn('node', ['--version']);
const error = new Error(testString);
promise.subprocess.stderr.emit('error', error);
t.is(await t.throwsAsync(promise), error);
const {cause} = await t.throwsAsync(promise);
t.is(cause, error);
});

test('result.command is defined', async t => {
const {command} = await nanoSpawn('node', ['--version']);
t.is(command, 'node --version');
});

test('result.command quotes spaces', async t => {
const {command, stdout} = await nanoSpawn('node', ['-p', '". ."']);
t.is(command, 'node -p \'". ."\'');
t.is(stdout, '. .');
});

test('result.command quotes single quotes', async t => {
const {command, stdout} = await nanoSpawn('node', ['-p', '"\'"']);
t.is(command, 'node -p \'"\'\\\'\'"\'');
t.is(stdout, '\'');
});

test('result.command quotes unusual characters', async t => {
const {command, stdout} = await nanoSpawn('node', ['-p', '","']);
t.is(command, 'node -p \'","\'');
t.is(stdout, ',');
});

test('result.command strips ANSI sequences', async t => {
const {command, stdout} = await nanoSpawn('node', ['-p', `"${red('.')}"`]);
t.is(command, 'node -p \'"."\'');
t.is(stdout, red('.'));
});

if (isWindows) {
Expand Down Expand Up @@ -332,9 +370,9 @@ if (isWindows) {
}

test('Handles non-existing command without options.shell', async t => {
const {code, syscall} = await t.throwsAsync(nanoSpawn('non-existent-command', {shell: false}));
t.is(code, 'ENOENT');
t.is(syscall, 'spawn non-existent-command');
const {cause} = await t.throwsAsync(nanoSpawn('non-existent-command', {shell: false}));
t.is(cause.code, 'ENOENT');
t.is(cause.syscall, 'spawn non-existent-command');
});

test('Handles non-existing command with options.shell', async t => {
Expand Down

0 comments on commit 8bdc484

Please sign in to comment.