Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve error.message and add result.command #31

Merged
merged 2 commits into from
Aug 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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