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

fix: error handling for unexpected numeric arguments passed to cli #5263

Merged
Merged
Show file tree
Hide file tree
Changes from 5 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
61 changes: 44 additions & 17 deletions lib/cli/options.js
Dinika marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,25 @@
const fs = require('fs');
const ansi = require('ansi-colors');
const yargsParser = require('yargs-parser');
const {types, aliases} = require('./run-option-metadata');
const {
types,
aliases,
isMochaFlag,
expectedTypeForFlag
} = require('./run-option-metadata');
const {ONE_AND_DONE_ARGS} = require('./one-and-dones');
const mocharc = require('../mocharc.json');
const {list} = require('./run-helpers');
const {loadConfig, findConfig} = require('./config');
const findUp = require('find-up');
const debug = require('debug')('mocha:cli:options');
const {isNodeFlag} = require('./node-flags');
const {createUnparsableFileError} = require('../errors');
const {
createUnparsableFileError,
createInvalidArgumentTypeError,
createUnsupportedError
} = require('../errors');
const {isNumeric} = require('../utils');

/**
* The `yargs-parser` namespace
Expand Down Expand Up @@ -104,24 +114,41 @@ const nargOpts = types.array
const parse = (args = [], defaultValues = {}, ...configObjects) => {
// save node-specific args for special handling.
// 1. when these args have a "=" they should be considered to have values
// 2. if they don't, they just boolean flags
// 2. if they don't, they are just boolean flags
// 3. to avoid explicitly defining the set of them, we tell yargs-parser they
// are ALL boolean flags.
// 4. we can then reapply the values after yargs-parser is done.
const nodeArgs = (Array.isArray(args) ? args : args.split(' ')).reduce(
(acc, arg) => {
const pair = arg.split('=');
let flag = pair[0];
if (isNodeFlag(flag, false)) {
flag = flag.replace(/^--?/, '');
return arg.includes('=')
? acc.concat([[flag, pair[1]]])
: acc.concat([[flag, true]]);
}
return acc;
},
[]
);
const allArgs = Array.isArray(args) ? args : args.split(' ');
const nodeArgs = allArgs.reduce((acc, arg, index, allArgs) => {
const maybeFlag = allArgs[index - 1];

if (isNumeric(arg) && (!maybeFlag || !isMochaFlag(maybeFlag))) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Question] Why isNumeric(arg) &&? Wouldn't we want to check this for other string flags too?

Unless I'm missing something, could this be trimmed down to?:

Suggested change
if (isNumeric(arg) && (!maybeFlag || !isMochaFlag(maybeFlag))) {
if (!maybeFlag || !isMochaFlag(maybeFlag)) {

Copy link
Contributor Author

@Dinika Dinika Dec 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think one reason why I am only handling numeric args is because all other types of arguments don't really make mocha crash (summary in the comment below). They are converted to positional string arguments, mocha tries to find files matching those strings and if it doesn't find those files, it prints a warning.

Example: mocha --global var1,var2 var3 true false my-test.spec.js prints this warning and carries on testing my-test.spec.js:

Warning: Cannot find any files matching pattern "var3"
Warning: Cannot find any files matching pattern "true"
Warning: Cannot find any files matching pattern "false"

Do you think I should instead throw an error in these cases?

throw createUnsupportedError(
`Option ${arg} is unsupported by the mocha cli`
);
}
if (
isNumeric(arg) &&
isMochaFlag(maybeFlag) &&
expectedTypeForFlag(maybeFlag) !== 'number'
) {
throw createInvalidArgumentTypeError(
`Mocha flag '--${maybeFlag}' given invalid option: '${arg}'`,
Number(arg),
expectedTypeForFlag(maybeFlag)
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Feature] This check seems legitimate and good. I like it. But, it feels odd to me that it's only implemented for expected numeric types. There are booleans and strings too. I think it'd be weird as a user to only get this report for an incorrect number, but not incorrect booleans or strings.

What do you think about generalizing it to all argument types?

I.e. instead of:

if numeric and expected_type !== 'number':
  throw

Doing roughly:

if actual_type !== expected_type:
  throw

Copy link
Contributor Author

@Dinika Dinika Dec 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on this and the previous comment, I've summarized the current behaviour of mocha for different flags and values data-type.

Columns show flags and rows show values passed to flags.

Red dot 🔴 is placed on cells where mocha currently crashes without a good error message. So far, this PR has addressed only these cases.
Orange dot 🟠 is placed where the behaviour could be improved perhaps. Mocha does not crash in these situations. Lemme know if I should improve the behaviour in this PR.

Value / Flag No flags number ⚑ boolean ⚑ array ⚑ string ⚑
number value 🔴 Eg: mocha 123 - Mocha crashes with error "arg.split()" Eg: mocha --retries 123 - 123 is assigned to retries 🔴 Eg: mocha --delay 123: delay is assigned value "true". 123 is treated as positional arg. Mocha crashes with error "arg.split()". Mocha does not accept filenames with only numeric chars Eg: mocha --file 123: assigns value "123" to the flag. Appropriate error/warning is thrown if flag can't handle value (eg. file not found). Eg: mocha --grep 123 assigns "123" to flag. Appropriate action is taken.
boolean value Eg: mocha true: "true" is treated like a positional value for filename. Eg: mocha --retries true: "NaN" is assigned to flag. This value is then ignored. Eg: mocha --delay true: works same as mocha --delay. true is assigned to flag. Eg mocha --spec true: stringified "true" is added to array of flag values. Appropriate action is taken. Eg mocha --ui true. Stringified "true" is assigned to flag.
array value Eg: mocha v1,v2: "v1,v2" is treated like a positional value for a filename 🟠 Eg: mocha --retries v1,v2 "NaN" is assigned to flag. This value is then ignored. Eg: mocha --delay v1,v2: value true is assigned to flag. "v1,v2" is treated like a positional filename. Eg: mocha --spec v1,v2 Flag has value ["v1", "v2"] Eg: mocha --grep v1,v2 String "v1,v2" is assigned to flag
string value Eg: mocha abc: "abc" is treated like a positional value for a filename. 🟠 Eg: mocha --retries abc: "NaN" is assigned to flag. This value is then ignored. Eg: mocha --delay abc: value true is assigned to flag. "abc" is treated like positional value for a filename. Eg: mocha --spec abc. "abc" is added to array of flag values. Eg: mocha --grep abc: Works as expected.
No value - Eg: mocha --retries. Error Not enough arguments following: retries is thrown Eg: mocha --delay: Flag has value true Eg: mocha --spec Error Not enough arguments following: spec is thrown Eg: mocha grep: Error Not enough arguments following: grep is thrown

Based on the table above, only numeric values (or flags) seem to be causing the problem right now. That being said, I don't mind creating a more generic error handling approach. Lemme know if you prefer that - I'll make the changes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What a great table 😄. Very helpful. I think the 🟠 ones can be ignored for now, agreed.


const pair = arg.split('=');
let flag = pair[0];
if (isNodeFlag(flag, false)) {
flag = flag.replace(/^--?/, '');
return arg.includes('=')
? acc.concat([[flag, pair[1]]])
: acc.concat([[flag, true]]);
Dinika marked this conversation as resolved.
Show resolved Hide resolved
}
return acc;
}, []);

const result = yargsParser.detailed(args, {
configuration,
Expand Down
33 changes: 22 additions & 11 deletions lib/cli/run-option-metadata.js
Dinika marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,8 @@ const TYPES = (exports.types = {
'sort',
'watch'
],
number: ['retries', 'jobs'],
string: [
'config',
'fgrep',
'grep',
'package',
'reporter',
'ui',
'slow',
'timeout'
]
number: ['retries', 'jobs', 'slow', 'timeout'],
string: ['config', 'fgrep', 'grep', 'package', 'reporter', 'ui']
});

/**
Expand Down Expand Up @@ -114,3 +105,23 @@ const ALL_MOCHA_FLAGS = Object.keys(TYPES).reduce((acc, key) => {
exports.isMochaFlag = flag => {
return ALL_MOCHA_FLAGS.has(flag.replace(/^--?/, ''));
};

/**
* Returns expected yarg option type for a given mocha flag.
* @param {string} flag - Flag to check (can be with out without leading "--"")
* @returns {string | undefined} - If flag is a valid mocha flag, the expected type of argument for this flag is returned, otherwise undefined is returned.
*/
Dinika marked this conversation as resolved.
Show resolved Hide resolved
exports.expectedTypeForFlag = flag => {
const normalizedName = flag?.replace(/^--?/, '');
Dinika marked this conversation as resolved.
Show resolved Hide resolved

// If flag is an alias, get it's full name.
const aliases = exports.aliases;
const fullFlagName =
Object.keys(aliases).find(flagName =>
aliases[flagName].includes(normalizedName)
) || normalizedName;

return Object.keys(TYPES).find(flagType =>
TYPES[flagType].includes(fullFlagName)
);
};
7 changes: 7 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -689,3 +689,10 @@ exports.breakCircularDeps = inputObj => {

return _breakCircularDeps(inputObj);
};

/**
* Checks if provided input can be parsed as a JavaScript Number.
*/
exports.isNumeric = input => {
return !isNaN(parseFloat(input));
};
27 changes: 27 additions & 0 deletions test/node-unit/cli/mocha-flags.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use strict';

const {
types,
expectedTypeForFlag
} = require('../../../lib/cli/run-option-metadata');

describe('mocha-flags', function () {
describe('expectedTypeForFlag()', function () {
it('returns expected type for all mocha flags', function () {
Object.entries(types).forEach(([dataType, flags]) => {
flags.forEach(flag => {
Dinika marked this conversation as resolved.
Show resolved Hide resolved
expect(expectedTypeForFlag(flag), 'to equal', dataType);
});
});
});

it('returns undefined for node flags', function () {
expect(expectedTypeForFlag('--throw-deprecation'), 'to equal', undefined);
expect(expectedTypeForFlag('throw-deprecation'), 'to equal', undefined);
});

it('returns undefined for unsupported flags', function () {
expect(expectedTypeForFlag('--foo'), 'to equal', undefined);
});
});
});
99 changes: 97 additions & 2 deletions test/node-unit/cli/options.spec.js
Dinika marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const sinon = require('sinon');
const rewiremock = require('rewiremock/node');
const {ONE_AND_DONE_ARGS} = require('../../../lib/cli/one-and-dones');
const {constants} = require('../../../lib/errors');

const modulePath = require.resolve('../../../lib/cli/options');
const mocharcPath = require.resolve('../../../lib/mocharc.json');
Expand Down Expand Up @@ -294,7 +295,6 @@ describe('options', function () {

result = loadOptions('--no-diff --no-config');
});

it('should return parsed args, default config and package.json', function () {
Dinika marked this conversation as resolved.
Show resolved Hide resolved
expect(
result,
Expand Down Expand Up @@ -526,7 +526,7 @@ describe('options', function () {
loadOptions('--timeout 500'),
'to have property',
'timeout',
'500'
500
Dinika marked this conversation as resolved.
Show resolved Hide resolved
);
});

Expand Down Expand Up @@ -676,5 +676,100 @@ describe('options', function () {
]);
});
});

describe('"numeric arguments"', function () {
const numericArg = 123;

const unsupportedError = arg => {
return {
message: `Option ${arg} is unsupported by the mocha cli`,
code: constants.UNSUPPORTED
};
};

const invalidArgError = (flag, arg, expectedType = 'string') => {
return {
message: `Mocha flag '--${flag}' given invalid option: '${arg}'`,
code: constants.INVALID_ARG_TYPE,
argument: arg,
actual: 'number',
expected: expectedType
};
};

beforeEach(function () {
readFileSync = sinon.stub();
findConfig = sinon.stub();
loadConfig = sinon.stub();
findupSync = sinon.stub();
loadOptions = proxyLoadOptions({
readFileSync,
findConfig,
loadConfig,
findupSync
});
});

it('throws UNSUPPORTED error when numeric option is passed to cli', function () {
expect(
() => loadOptions(`${numericArg}`),
'to throw',
unsupportedError(numericArg)
);
});

it('throws INVALID_ARG_TYPE error when numeric argument is passed to mocha flag that does not accept numeric value', function () {
const flag = '--delay';
expect(
() => loadOptions(`${flag} ${numericArg}`),
'to throw',
invalidArgError(flag, numericArg, 'boolean')
);
});

it('throws INVALID_ARG_TYPE error when incompatible flag does not have preceding "--"', function () {
const flag = 'delay';
expect(
() => loadOptions(`${flag} ${numericArg}`),
'to throw',
invalidArgError(flag, numericArg, 'boolean')
);
});

it('shows correct flag in error when multiple mocha flags have numeric values', function () {
const flag = '--delay';
expect(
() =>
loadOptions(
`--timeout ${numericArg} ${flag} ${numericArg} --retries ${numericArg}`
),
'to throw',
invalidArgError(flag, numericArg, 'boolean')
);
});

it('throws UNSUPPORTED error when numeric arg is passed to unsupported flag', function () {
const invalidFlag = 'foo';
expect(
() => loadOptions(`${invalidFlag} ${numericArg}`),
'to throw',
unsupportedError(numericArg)
);
});

it('does not throw error if numeric value is passed to a compatible mocha flag', function () {
expect(() => loadOptions(`--retries ${numericArg}`), 'not to throw');
});

it('does not throw error if numeric value is passed to a node options', function () {
expect(
() =>
loadOptions(
`--secure-heap-min=${numericArg} --conditions=${numericArg}`
),
'not to throw'
);
});
});
Dinika marked this conversation as resolved.
Show resolved Hide resolved
});
});
11 changes: 11 additions & 0 deletions test/node-unit/utils.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,16 @@ describe('utils', function () {
);
});
});
describe('isNumeric()', function () {
Dinika marked this conversation as resolved.
Show resolved Hide resolved
it('returns true for a number type', function () {
expect(utils.isNumeric(42), 'to equal', true);
});
it('returns true for a string that can be parsed as a number', function () {
expect(utils.isNumeric('42'), 'to equal', true);
});
it('returns false for a string that cannot be parsed as a number', function () {
expect(utils.isNumeric('foo'), 'to equal', false);
});
});
});
});
Loading