diff --git a/index.js b/index.js index 088e787e02..ab1240d0b7 100644 --- a/index.js +++ b/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'); @@ -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; @@ -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; @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/lib/error.js b/lib/error.js new file mode 100644 index 0000000000..5a7701b9ec --- /dev/null +++ b/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; diff --git a/test/error.js b/test/error.js new file mode 100644 index 0000000000..4dfec23258 --- /dev/null +++ b/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'); diff --git a/test/test.js b/test/test.js index bf274a81bc..6fa7e84633 100644 --- a/test/test.js +++ b/test/test.js @@ -1,7 +1,6 @@ import path from 'path'; import fs from 'fs'; import stream from 'stream'; -import childProcess from 'child_process'; import test from 'ava'; import getStream from 'get-stream'; import isRunning from 'is-running'; @@ -14,8 +13,6 @@ process.env.FOO = 'foo'; const TIMEOUT_REGEXP = /timed out after/; -const getExitRegExp = exitMessage => new RegExp(`failed with exit code ${exitMessage}`); - test('execa()', async t => { const {stdout} = await execa('noop', ['foo']); t.is(stdout, 'foo'); @@ -44,13 +41,6 @@ test.serial('result.all shows both `stdout` and `stderr` intermixed', async t => t.is(all, '132'); }); -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'); -}); - test('stdout/stderr/all are undefined if ignored', async t => { const {stdout, stderr, all} = await execa('noop', {stdio: 'ignore'}); t.is(stdout, undefined); @@ -65,26 +55,6 @@ test('stdout/stderr/all are undefined if ignored in sync mode', t => { t.is(all, undefined); }); -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('pass `stdout` to a file descriptor', async t => { const file = tempfile('.txt'); await execa('test/fixtures/noop', ['foo bar'], {stdout: fs.openSync(file, 'w')}); @@ -360,27 +330,6 @@ test('do not buffer stderr when `buffer` set to `false`', async t => { t.is(stderr, '.........\n'); }); -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('use relative path with \'..\' chars', async t => { const pathViaParentDir = path.join('..', path.basename(path.dirname(__dirname)), 'test', 'fixtures', 'noop'); const {stdout} = await execa(pathViaParentDir, ['foo']); @@ -398,104 +347,6 @@ if (process.platform !== 'win32') { }); } -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); - test('timeout kills the process if it times out', async t => { const {killed, timedOut} = await t.throwsAsync(execa('forever', {timeout: 1, message: TIMEOUT_REGEXP})); t.false(killed); @@ -525,15 +376,6 @@ test('timedOut will be false if no timeout was set and zero exit code in sync mo t.false(timedOut); }); -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'); - const command = async (t, expected, ...args) => { const {command: failCommand} = await t.throwsAsync(execa('fail', args)); t.is(failCommand, `fail${expected}`);