particle-cli
Version:
Simple Node commandline application for working with your Particle devices and using the Particle Cloud
505 lines (418 loc) • 19 kB
JavaScript
/*
******************************************************************************
Copyright (c) 2016 Particle Industries, Inc. All rights reserved.
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation, either
version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this program; if not, see <http://www.gnu.org/licenses/>.
******************************************************************************
*/
const { expect, sinon } = require('../../test/setup');
const commandProcessor = require('./command-processor');
describe('command-line parsing', () => {
const sandbox = sinon.createSandbox();
describe('errors', () => {
it('unknown command', () => {
const args = ['a', 'b'];
const item = {};
const result = commandProcessor.errors.unknownCommandError(args, item);
expect(result.data).to.be.equal(args);
expect(result.item).to.be.equal(item);
expect(result.message).to.be.equal('No such command \'a b\'');
expect(result.isUsageError).to.be.true;
expect(result.type).to.be.equal(commandProcessor.errors.unknownCommandError);
});
it('unknown argument', () => {
const arg = ['a'];
const result = commandProcessor.errors.unknownArgumentError(arg);
expect(result.data).to.be.equal(arg);
expect(result.message).to.be.equal('Unknown argument \'a\'');
expect(result.isUsageError).to.be.true;
expect(result.type).to.be.equal(commandProcessor.errors.unknownArgumentError);
});
it('unknown arguments', () => {
const arg = ['a', 'b'];
const result = commandProcessor.errors.unknownArgumentError(arg);
expect(result.data).to.be.equal(arg);
expect(result.message).to.be.equal('Unknown arguments \'a, b\'');
expect(result.isUsageError).to.be.true;
expect(result.type).to.be.equal(commandProcessor.errors.unknownArgumentError);
});
it('variadic parameter position', () => {
const param = 'param';
const result = commandProcessor.errors.variadicParameterPositionError(param);
expect(result.data).to.be.equal(param);
expect(result.message).to.be.equal('Variadic parameter \'param\' must the final parameter.');
expect(result.isApplicationError).to.be.true;
expect(result.type).to.be.equal(commandProcessor.errors.variadicParameterPositionError);
});
it('optional parameter position', () => {
const param = 'param';
const result = commandProcessor.errors.requiredParameterPositionError(param);
expect(result.data).to.be.equal(param);
expect(result.message).to.be.equal('Required parameter \'param\' must be placed before all optional parameters.');
expect(result.isApplicationError).to.be.true;
expect(result.type).to.be.equal(commandProcessor.errors.requiredParameterPositionError);
});
it('parameter required', () => {
const param = 'param';
const result = commandProcessor.errors.requiredParameterError(param);
expect(result.data).to.be.equal(param);
expect(result.message).to.be.equal('Parameter \'param\' is required.');
expect(result.isUsageError).to.be.true;
expect(result.type).to.be.equal(commandProcessor.errors.requiredParameterError);
});
it('variadic parameter required', () => {
const param = 'param';
const result = commandProcessor.errors.variadicParameterRequiredError(param);
expect(result.data).to.be.equal(param);
expect(result.message).to.be.equal('Parameter \'param\' must have at least one item.');
expect(result.isUsageError).to.be.true;
expect(result.type).to.be.equal(commandProcessor.errors.variadicParameterRequiredError);
});
it('unknown parameters', () => {
const param = ['1', '2'];
const result = commandProcessor.errors.unknownParametersError(param);
expect(result.data).to.be.equal(param);
expect(result.message).to.be.equal('Command parameters \'1 2\' are not expected here.');
expect(result.isUsageError).to.be.true;
expect(result.type).to.be.equal(commandProcessor.errors.unknownParametersError);
});
});
it('returns the root cateogry for an empty command line', () => {
const app = commandProcessor.createAppCategory();
const argv = commandProcessor.parse(app, []);
expect(argv.clierror).to.be.undefined;
expect(argv.clicommand).to.be.equal(app);
});
it('returns an error for an unknown command', () => {
const app = commandProcessor.createAppCategory();
const unknownCommand = ['funkwomble', 'farcenugget'];
const argv = commandProcessor.parse(app, unknownCommand);
const error = commandProcessor.errors.unknownCommandError(unknownCommand, app);
expect(argv.clicommand).to.equal(undefined);
expectCLIError(argv.clierror, error);
});
it('returns the command when the command line matches', () => {
const app = commandProcessor.createAppCategory();
const one = commandProcessor.createCategory(app, 'one', 'you get');
const cmd = commandProcessor.createCommand(one, 'chance', 'at life');
const argv = commandProcessor.parse(app, ['one', 'chance']);
expect(argv.clierror).to.be.undefined;
expect(argv.clicommand).to.be.equal(cmd);
});
it('returns the category when the command line matches', () => {
const app = commandProcessor.createAppCategory();
const one = commandProcessor.createCategory(app, 'one', 'you get');
const argv = commandProcessor.parse(app, ['one']);
expect(argv.clierror).to.be.undefined;
expect(argv.clicommand).to.be.equal(one);
});
it('returns the category when the alias matches', () => {
const app = commandProcessor.createAppCategory();
const one = commandProcessor.createCategory(app, 'one', { alias: 'uno' });
const argv = commandProcessor.parse(app, ['uno']);
expect(argv.clierror).to.be.undefined;
expect(argv.clicommand).to.be.equal(one);
});
it('returns an error when the command line is not recognized', () => {
const app = commandProcessor.createAppCategory();
const one = commandProcessor.createCategory(app, 'one', 'you get');
const args = ['one', 'frumpet'];
const argv = commandProcessor.parse(app, args);
const error = commandProcessor.errors.unknownCommandError(args, one);
expect(argv.clicommand).to.be.equal(undefined);
expectCLIError(argv.clierror, error);
expect(one.path).to.eql(['one']);
});
it('returns an error when an unknown option is present', () => {
const app = commandProcessor.createAppCategory();
const argv = commandProcessor.parse(app, ['--ftlspeed']);
const error = commandProcessor.errors.unknownArgumentError(['ftlspeed']);
expect(argv.clicommand).to.equal(undefined);
expectCLIError(argv.clierror, error);
});
it('returns an unknown command error before an unknown argument error', () => {
const app = commandProcessor.createAppCategory();
commandProcessor.createCategory(app, 'one', 'you get');
const args = ['blah', '--frumpet'];
const argv = commandProcessor.parse(app, args);
const error = commandProcessor.errors.unknownCommandError(['blah'], app);
expect(argv.clicommand).to.be.equal(undefined);
expectCLIError(argv.clierror, error);
});
it('can accept options', () => {
// see '.choices()` https://www.npmjs.com/package/yargs
const app = commandProcessor.createAppCategory({ options: {
cm: { alias: 'chundermonkey', boolean: true }
} });
expect(commandProcessor.parse(app, ['--cm'])).to.have.property('cm').equal(true);
expect(commandProcessor.parse(app, ['--chundermonkey'])).to.have.property('cm').equal(true);
});
it('refuses options meant for other commands', () => {
const app = commandProcessor.createAppCategory();
const one = commandProcessor.createCommand(app, 'one', 'first', {
options: {
one: { alias: '1', description: '', boolean: true }
}
});
commandProcessor.createCommand(app, 'two', 'second', {
options: {
two: { alias: '2', description: '', boolean: true }
}
});
let argv, error;
// sanity test
argv = commandProcessor.parse(app, ['one', '--one']);
expect(argv).to.not.have.property('clierror');
expect(argv).to.have.property('clicommand').equal(one);
expect(argv).to.have.property('one').equal(true);
// the real test
argv = commandProcessor.parse(app, ['two', '--one']);
error = commandProcessor.errors.unknownArgumentError(['one']);
expect(argv.clicommand).to.equal(undefined);
expectCLIError(argv.clierror, error);
});
describe('parameters', () => {
function paramsCommand(params, args) {
const app = commandProcessor.createAppCategory();
const cmd = commandProcessor.createCommand(app, 'cmd', 'do stuff', {
params: params,
options: {
test: {
alias: 'flag',
boolean: true
},
string: {
},
multipleStrings: {
nargs: 2
},
number: {
number: true,
},
count: {
count: true
}
}
});
const result = commandProcessor.parse(app, ['cmd'].concat(args));
// only one of them set
expect(result.clicommand || result.clierror).to.be.ok;
expect(result.clicommand && result.clierror).to.be.not.ok;
if (result.clicommand) {
expect(result.clicommand).to.be.equal(cmd);
}
return result;
}
it('accepts parameters before flags', () => {
const result = paramsCommand('[a]', ['1', '--flag']);
expect(result.params).to.deep.equal({ a:'1' });
expect(result).to.have.property('flag').that.is.true;
});
it('accepts parameters after flags', () => {
const result = paramsCommand('[a]', ['--flag', '1']);
expect(result.params).to.deep.equal({ a:'1' });
expect(result).to.have.property('flag').that.is.true;
});
it('accepts parameters before and after flags', () => {
const result = paramsCommand('[a...]', ['1', '--flag', '2']);
expect(result.params).to.deep.equal({ a:['1','2'] });
expect(result).to.have.property('flag').that.is.true;
});
it('rejects varadic parameters not in final position', () => {
const argv = paramsCommand('[a] [b...] [c]', ['1','2', '3']);
const error = commandProcessor.errors.variadicParameterPositionError('b');
expect(argv.clicommand).to.equal(undefined);
expectCLIError(argv.clierror, error);
});
it('rejects omitted required varadic parameters', () => {
const argv = paramsCommand('[a] <b...>', ['1']);
const error = commandProcessor.errors.variadicParameterRequiredError('b');
expect(argv.clicommand).to.equal(undefined);
expectCLIError(argv.clierror, error);
});
it('rejects omitted required parameters', () => {
const argv = paramsCommand('<a> <b>', ['1']);
const error = commandProcessor.errors.requiredParameterError('b');
expect(argv.clicommand).to.equal(undefined);
expectCLIError(argv.clierror, error);
});
it('rejects required parameters after optional parameters', () => {
const argv = paramsCommand('[a] <b>', ['1']);
const error = commandProcessor.errors.requiredParameterPositionError('b');
expect(argv.clicommand).to.equal(undefined);
expectCLIError(argv.clierror, error);
});
it('allows commands with unfilled optional parameters', () => {
expect(paramsCommand('[a]', []).params).to.have.property('a').equal(undefined);
});
it('allows commands with filled optional parameters', () => {
expect(paramsCommand('[a]', ['hey']).params).to.have.property('a').equal('hey');
});
it('allows commands with filled required parameters', () => {
expect(paramsCommand('<a>', ['hey']).params).to.have.property('a').equal('hey');
});
it('allows commands with multiple parameter names', () => {
const args = paramsCommand('<a|b>', ['hey']).params;
expect(args).to.have.property('a').equal('hey');
expect(args).to.have.property('b').equal('hey');
});
it('rejects commands with unfilled required parameters', () => {
const argv = paramsCommand('<a> <b>', ['1']);
const error = commandProcessor.errors.requiredParameterError('b');
expect(argv.clicommand).to.equal(undefined);
expectCLIError(argv.clierror, error);
});
it('allows commands with mixed optional and required parameters', () => {
const result = paramsCommand('<a> [b] [c]', ['1', '2']).params;
expect(result).to.have.property('a').equal('1');
expect(result).to.have.property('b').equal('2');
expect(result).to.have.property('c').equal(undefined);
});
it('allows commands with mixed optional and required parameters and optional unfilled variadic', () => {
const result = paramsCommand('<a> [b] [c...]', ['1', '2']).params;
expect(result).to.have.property('a').equal('1');
expect(result).to.have.property('b').equal('2');
expect(result).to.have.property('c').deep.equal([]);
});
it('allows commands with mixed optional and required parameters and optional filled variadic', () => {
const result = paramsCommand('<a> [b] [c...]', ['1', '2', '3', '4']).params;
expect(result).to.have.property('a').equal('1');
expect(result).to.have.property('b').equal('2');
expect(result).to.have.property('c').deep.equal(['3','4']);
});
it('rejects parameterized command with surplus arguments', () => {
const argv = paramsCommand('[a]', ['hey', 'there', 'you']);
const error = commandProcessor.errors.unknownParametersError(['there', 'you']);
expect(argv.clicommand).to.equal(undefined);
expectCLIError(argv.clierror, error);
});
it('rejects parameters to non-parameterized command', () => {
const app = commandProcessor.createAppCategory();
commandProcessor.createCommand(app, 'cmd', 'do summat');
const argv = commandProcessor.parse(app, ['cmd', 'stragglers', 'here']);
const error = commandProcessor.errors.unknownParametersError(['stragglers', 'here']);
expect(argv.clicommand).to.equal(undefined);
expectCLIError(argv.clierror, error);
});
it('flags default to strings', () => {
const result = paramsCommand('', ['--string', '42']);
expect(result).to.have.property('string').equal('42');
});
it('flags require one argument by default', () => {
expect(() => paramsCommand('', ['--string'])).to.throw(/Not enough arguments following: string/);
});
it('flags can require multiple arguments', () => {
const result = paramsCommand('', ['--multipleStrings', '1', '2']);
expect(result).to.have.property('multipleStrings').deep.equal(['1', '2']);
});
it('number flag get converted', () => {
const result = paramsCommand('', ['--number', '42']);
expect(result).to.have.property('number').equal(42);
});
it('count flag get counted', () => {
const result = paramsCommand('', ['--count', '--count']);
expect(result).to.have.property('count').equal(2);
});
it('keeps device ids as strings', () => {
const result = paramsCommand('<deviceid>', ['500000000000000000000000']).params;
expect(result).to.have.property('deviceid').equal('500000000000000000000000');
});
});
describe('consoleErrorLogger()', () => {
const { consoleErrorLogger } = commandProcessor.test;
let fakeConsole, fakeYargs;
beforeEach(() => {
fakeConsole = { log: sandbox.stub() };
fakeYargs = { showHelp: sandbox.stub() };
});
afterEach(() => {
sandbox.restore();
});
it('calls yargs.showHelp if the error is falsey', () => {
const error = '';
consoleErrorLogger(fakeConsole, fakeYargs, false, error);
expect(fakeYargs.showHelp).to.have.property('callCount', 1);
});
it('calls yargs.showHelp if the error is a usage error', () => {
const error = { isUsageError: true };
consoleErrorLogger(fakeConsole, fakeYargs, false, error);
expect(fakeYargs.showHelp).to.have.property('callCount', 1);
});
it('logs the error message to the console', () => {
const message = 'we come in peace';
const error = { message };
consoleErrorLogger(fakeConsole, fakeYargs, false, error);
expect(fakeConsole.log).to.have.been.calledWithMatch(message);
expect(fakeYargs.showHelp).to.have.property('callCount', 0);
});
it('logs the error to the console when no message is given', () => {
const error = { bass: 'ice ice baby' };
consoleErrorLogger(fakeConsole, fakeYargs, false, error);
expect(fakeConsole.log).to.have.been.calledWithMatch('{ bass: \'ice ice baby\' }');
expect(fakeYargs.showHelp).to.have.property('callCount', 0);
});
it('logs the error as JSON when `asJSON` field is true', () => {
const error = new Error('nope!');
error.asJSON = true;
consoleErrorLogger(fakeConsole, fakeYargs, false, error);
expect(fakeYargs.showHelp).to.have.property('callCount', 0);
expect(fakeConsole.log).to.have.property('callCount', 1);
const json = JSON.parse(fakeConsole.log.firstCall.args[0]);
expect(json).to.have.all.keys('meta', 'error');
expect(json.meta).to.have.all.keys('version');
expect(json.meta.version).to.equal('1.0.0');
expect(json.error).to.have.all.keys('message', 'stack');
expect(json.error.message).to.equal('nope!');
expect(json.error).to.have.property('stack').that.is.a('string');
});
it('logs the stack trace to the console when verbose mode is enabled', () => {
const error = new Error('hey');
try {
global.verboseLevel = 2;
consoleErrorLogger(fakeConsole, undefined /*yargs*/, false, error);
} finally {
delete global.verboseLevel;
}
expect(fakeConsole.log).to.have.been.calledWithMatch('hey');
expect(fakeConsole.log).to.have.been.calledWithMatch('Error: hey\n' +
' at Context.');
});
it('does not log the stack for usage errors.', () => {
const error = { stack: '1\n2\n3', isUsageError:true, message: 'hey' };
consoleErrorLogger(fakeConsole, fakeYargs, false, error);
expect(fakeConsole.log).to.have.been.calledWithMatch('hey').and.calledOnce;
expect(fakeYargs.showHelp).to.have.property('callCount', 1);
});
});
describe('CLICommand', () => {
function assertCanSetDescription(desc) {
const root = commandProcessor.createAppCategory();
const sut = commandProcessor.createCommand(root, 'test', desc);
expect(sut).to.have.property('description').equal(desc);
}
it('can have a false description to indicate a hidden command', () => {
assertCanSetDescription(false);
});
it('can have a string description', () => {
assertCanSetDescription('123');
});
});
function expectCLIError(actual, expected){
expect(actual).to.not.be.undefined;
expect(actual).to.have.keys(Object.keys(expected));
expect(actual.stack).to.be.a('string').with.lengthOf.above(100);
expect(actual.isUsageError).to.equal(expected.isUsageError);
expect(actual.message).to.equal(expected.message);
expect(actual.type).to.eql(expected.type);
expect(actual.data).to.eql(expected.data);
expect(actual.item).to.eql(expected.item);
}
});