Skip to content

Commit

Permalink
Separate error creation logic into its own file (#311)
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky authored and sindresorhus committed Jun 23, 2019
1 parent 8af8c96 commit 4dd258d
Show file tree
Hide file tree
Showing 4 changed files with 267 additions and 238 deletions.
95 changes: 15 additions & 80 deletions index.js
@@ -1,7 +1,6 @@
'use strict';
const path = require('path');
const os = require('os');
const util = require('util');
const childProcess = require('child_process');
const crossSpawn = require('cross-spawn');
const stripFinalNewline = require('strip-final-newline');
Expand All @@ -11,6 +10,7 @@ const getStream = require('get-stream');
const mergeStream = require('merge-stream');
const pFinally = require('p-finally');
const onExit = require('signal-exit');
const makeError = require('./lib/error');
const normalizeStdio = require('./lib/stdio');

const DEFAULT_MAX_BUFFER = 1000 * 1000 * 100;
Expand Down Expand Up @@ -158,80 +158,6 @@ const getPromiseResult = async ({stdout, stderr, all}, {encoding, buffer, maxBuf
}
};

const makeError = (result, options) => {
const {stdout, stderr, signal} = result;
let {error} = result;
const {code, command, timedOut, isCanceled, killed, parsed: {options: {timeout}}} = options;

const [exitCodeName, exitCode] = getCode(result, code);

const prefix = getErrorPrefix({timedOut, timeout, signal, exitCodeName, exitCode, isCanceled});
const message = `Command ${prefix}: ${command}`;

if (error instanceof Error) {
error.message = `${message}\n${error.message}`;
} else {
error = new Error(message);
}

error.command = command;
delete error.code;
error.exitCode = exitCode;
error.exitCodeName = exitCodeName;
error.stdout = stdout;
error.stderr = stderr;

if ('all' in result) {
error.all = result.all;
}

if ('bufferedData' in error) {
delete error.bufferedData;
}

error.failed = true;
error.timedOut = timedOut;
error.isCanceled = isCanceled;
error.killed = killed && !timedOut;
// `signal` emitted on `spawned.on('exit')` event can be `null`. We normalize
// it to `undefined`
error.signal = signal || undefined;

return error;
};

const getCode = ({error = {}}, code) => {
if (error.code) {
return [error.code, os.constants.errno[error.code]];
}

if (Number.isInteger(code)) {
return [util.getSystemErrorName(-code), code];
}

return [];
};

const getErrorPrefix = ({timedOut, timeout, signal, exitCodeName, exitCode, isCanceled}) => {
if (timedOut) {
return `timed out after ${timeout} milliseconds`;
}

if (isCanceled) {
return 'was canceled';
}

if (signal) {
return `was killed with ${signal}`;
}

if (exitCode !== undefined) {
return `failed with exit code ${exitCode} (${exitCodeName})`;
}

return 'failed';
};

const joinCommand = (file, args = []) => {
if (!Array.isArray(args)) {
return file;
Expand Down Expand Up @@ -372,7 +298,11 @@ const execa = (file, args, options) => {
spawned = childProcess.spawn(parsed.file, parsed.args, parsed.options);
} catch (error) {
return mergePromise(new childProcess.ChildProcess(), () =>
Promise.reject(makeError({error, stdout: '', stderr: '', all: ''}, {
Promise.reject(makeError({
error,
stdout: '',
stderr: '',
all: '',
command,
parsed,
timedOut: false,
Expand Down Expand Up @@ -402,8 +332,8 @@ const execa = (file, args, options) => {
result.all = handleOutput(parsed.options, all);

if (result.error || result.code !== 0 || result.signal !== null) {
const error = makeError(result, {
code: result.code,
const error = makeError({
...result,
command,
parsed,
timedOut: context.timedOut,
Expand Down Expand Up @@ -455,7 +385,11 @@ module.exports.sync = (file, args, options) => {
try {
result = childProcess.spawnSync(parsed.file, parsed.args, parsed.options);
} catch (error) {
throw makeError({error, stdout: '', stderr: '', all: ''}, {
throw makeError({
error,
stdout: '',
stderr: '',
all: '',
command,
parsed,
timedOut: false,
Expand All @@ -468,7 +402,8 @@ module.exports.sync = (file, args, options) => {
result.stderr = handleOutput(parsed.options, result.stderr, result.error);

if (result.error || result.status !== 0 || result.signal !== null) {
const error = makeError(result, {
const error = makeError({
...result,
code: result.status,
command,
parsed,
Expand Down
87 changes: 87 additions & 0 deletions lib/error.js
@@ -0,0 +1,87 @@
'use strict';
const os = require('os');
const util = require('util');

const getCode = (error, code) => {
if (error && error.code) {
return [error.code, os.constants.errno[error.code]];
}

if (Number.isInteger(code)) {
return [util.getSystemErrorName(-code), code];
}

return [];
};

const getErrorPrefix = ({timedOut, timeout, signal, exitCodeName, exitCode, isCanceled}) => {
if (timedOut) {
return `timed out after ${timeout} milliseconds`;
}

if (isCanceled) {
return 'was canceled';
}

if (signal) {
return `was killed with ${signal}`;
}

if (exitCode !== undefined) {
return `failed with exit code ${exitCode} (${exitCodeName})`;
}

return 'failed';
};

const makeError = ({
stdout,
stderr,
all,
error,
signal,
code,
command,
timedOut,
isCanceled,
killed,
parsed: {options: {timeout}}
}) => {
const [exitCodeName, exitCode] = getCode(error, code);

const prefix = getErrorPrefix({timedOut, timeout, signal, exitCodeName, exitCode, isCanceled});
const message = `Command ${prefix}: ${command}`;

if (error instanceof Error) {
error.message = `${message}\n${error.message}`;
} else {
error = new Error(message);
}

error.command = command;
delete error.code;
error.exitCode = exitCode;
error.exitCodeName = exitCodeName;
error.stdout = stdout;
error.stderr = stderr;

if (all !== undefined) {
error.all = all;
}

if ('bufferedData' in error) {
delete error.bufferedData;
}

error.failed = true;
error.timedOut = timedOut;
error.isCanceled = isCanceled;
error.killed = killed && !timedOut;
// `signal` emitted on `spawned.on('exit')` event can be `null`. We normalize
// it to `undefined`
error.signal = signal || undefined;

return error;
};

module.exports = makeError;
165 changes: 165 additions & 0 deletions test/error.js
@@ -0,0 +1,165 @@
import path from 'path';
import childProcess from 'child_process';
import test from 'ava';
import execa from '..';

process.env.PATH = path.join(__dirname, 'fixtures') + path.delimiter + process.env.PATH;

const TIMEOUT_REGEXP = /timed out after/;

const getExitRegExp = exitMessage => new RegExp(`failed with exit code ${exitMessage}`);

test('stdout/stderr/all available on errors', async t => {
const {stdout, stderr, all} = await t.throwsAsync(execa('exit', ['2']), {message: getExitRegExp('2')});
t.is(typeof stdout, 'string');
t.is(typeof stderr, 'string');
t.is(typeof all, 'string');
});

const WRONG_COMMAND = process.platform === 'win32' ?
'\'wrong\' is not recognized as an internal or external command,\r\noperable program or batch file.' :
'';

test('stdout/stderr/all on process errors', async t => {
const {stdout, stderr, all} = await t.throwsAsync(execa('wrong command'));
t.is(stdout, '');
t.is(stderr, WRONG_COMMAND);
t.is(all, WRONG_COMMAND);
});

test('stdout/stderr/all on process errors, in sync mode', t => {
const {stdout, stderr, all} = t.throws(() => {
execa.sync('wrong command');
});
t.is(stdout, '');
t.is(stderr, WRONG_COMMAND);
t.is(all, undefined);
});

test('allow unknown exit code', async t => {
const {exitCode, exitCodeName} = await t.throwsAsync(execa('exit', ['255']), {message: /exit code 255 \(Unknown system error -255\)/});
t.is(exitCode, 255);
t.is(exitCodeName, 'Unknown system error -255');
});

test('execa() does not return code and failed properties on success', async t => {
const {exitCode, exitCodeName, failed} = await execa('noop', ['foo']);
t.is(exitCode, 0);
t.is(exitCodeName, 'SUCCESS');
t.false(failed);
});

test('execa() returns code and failed properties', async t => {
const {exitCode, exitCodeName, failed} = await t.throwsAsync(execa('exit', ['2']), {message: getExitRegExp('2')});
t.is(exitCode, 2);
const expectedName = process.platform === 'win32' ? 'Unknown system error -2' : 'ENOENT';
t.is(exitCodeName, expectedName);
t.true(failed);
});

test('error.killed is true if process was killed directly', async t => {
const cp = execa('forever');

cp.kill();

const {killed} = await t.throwsAsync(cp, {message: /was killed with SIGTERM/});
t.true(killed);
});

test('error.killed is false if process was killed indirectly', async t => {
const cp = execa('forever');

process.kill(cp.pid, 'SIGINT');

// `process.kill()` is emulated by Node.js on Windows
const message = process.platform === 'win32' ? /failed with exit code 1/ : /was killed with SIGINT/;
const {killed} = await t.throwsAsync(cp, {message});
t.false(killed);
});

test('result.killed is false if not killed', async t => {
const {killed} = await execa('noop');
t.false(killed);
});

test('result.killed is false if not killed, in sync mode', t => {
const {killed} = execa.sync('noop');
t.false(killed);
});

test('result.killed is false on process error', async t => {
const {killed} = await t.throwsAsync(execa('wrong command'));
t.false(killed);
});

test('result.killed is false on process error, in sync mode', t => {
const {killed} = t.throws(() => {
execa.sync('wrong command');
});
t.false(killed);
});

if (process.platform === 'darwin') {
test.cb('sanity check: child_process.exec also has killed.false if killed indirectly', t => {
const {pid} = childProcess.exec('forever', error => {
t.truthy(error);
t.false(error.killed);
t.end();
});

process.kill(pid, 'SIGINT');
});
}

if (process.platform !== 'win32') {
test('error.signal is SIGINT', async t => {
const cp = execa('forever');

process.kill(cp.pid, 'SIGINT');

const {signal} = await t.throwsAsync(cp, {message: /was killed with SIGINT/});
t.is(signal, 'SIGINT');
});

test('error.signal is SIGTERM', async t => {
const cp = execa('forever');

process.kill(cp.pid, 'SIGTERM');

const {signal} = await t.throwsAsync(cp, {message: /was killed with SIGTERM/});
t.is(signal, 'SIGTERM');
});

test('custom error.signal', async t => {
const {signal} = await t.throwsAsync(execa('forever', {killSignal: 'SIGHUP', timeout: 1, message: TIMEOUT_REGEXP}));
t.is(signal, 'SIGHUP');
});
}

test('result.signal is undefined for successful execution', async t => {
const {signal} = await execa('noop');
t.is(signal, undefined);
});

test('result.signal is undefined if process failed, but was not killed', async t => {
const {signal} = await t.throwsAsync(execa('exit', [2]), {message: getExitRegExp('2')});
t.is(signal, undefined);
});

const testExitCode = async (t, num) => {
const {exitCode} = await t.throwsAsync(execa('exit', [`${num}`]), {message: getExitRegExp(num)});
t.is(exitCode, num);
};

test('error.exitCode is 2', testExitCode, 2);
test('error.exitCode is 3', testExitCode, 3);
test('error.exitCode is 4', testExitCode, 4);

const errorMessage = async (t, expected, ...args) => {
await t.throwsAsync(execa('exit', args), {message: expected});
};

errorMessage.title = (message, expected) => `error.message matches: ${expected}`;

test(errorMessage, /Command failed with exit code 2.*: exit 2 foo bar/, 2, 'foo', 'bar');
test(errorMessage, /Command failed with exit code 3.*: exit 3 baz quz/, 3, 'baz', 'quz');

0 comments on commit 4dd258d

Please sign in to comment.