Skip to content
139 changes: 99 additions & 40 deletions lib/cli/cli.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,99 @@
/**
* Contains CLI entry point and public API for programmatic usage in Node.js.
* - Option parsing is handled by {@link https://npm.im/yargs yargs}.
* - Option parsing is handled by {@link module:lib/cli/parse-args}.
* - If executed via `node`, this module will run {@linkcode module:lib/cli.main main()}.
* @public
* @module lib/cli
*/

import debugModule from "debug";
import yargs from "yargs";
import { fileURLToPath } from "node:url";

import { loadOptions, YARGS_PARSER_CONFIG } from "./options.cjs";
import { loadOptions } from "./options.cjs";
import { run, init } from "./commands.js";
import pc from "picocolors";
import { logSymbols } from "../utils.cjs";

import packageJson from "../../package.json" with { type: "json" };
import { getRunOptionDefinitions } from "./run-option-metadata.cjs";

const __filename = fileURLToPath(import.meta.url);

const debug = debugModule("mocha:cli:cli");

const hasFlag = (args, longName, shortName) =>
args.includes(`--${longName}`) || args.includes(`-${shortName}`);

const formatOption = (option) => {
const aliases = option.aliases.map((alias) =>
alias.length === 1 ? `-${alias}` : `--${alias}`,
);
return [`--${option.name}`].concat(aliases).join(", ");
};

const printVersion = () => {
console.log(packageJson.version);
};

const printHelp = () => {
const options = getRunOptionDefinitions()
.filter((option) => option.description)
.map((option) => ` ${formatOption(option).padEnd(34)} ${option.description}`)
.join("\n");

console.log(`Usage: mocha [spec..]
mocha init <path>

Commands:
init <path> ${init.description}

Options:
${options}
--help, -h${"".padEnd(24)} Show usage information & exit
--version, -V${"".padEnd(21)} Show version number & exit

Mocha Resources
Chat: ${packageJson.discord}
GitHub: ${packageJson.repository.url}
Docs: ${packageJson.homepage}`);
};

const fail = (msg, err) => {
debug("caught error before command handler: %O", err);
printHelp();
console.error(`\n${logSymbols.error} ${pc.red("ERROR:")} ${msg}`);
if (!msg && err) {
// Log raw error and stack when an unexpected error is encountered, to
// make debugging easier (instead of an inactionable "ERROR: null").
console.error(err);
}
process.exit(1);
};

const failRunPreparation = (err) => {
console.error(`\n${logSymbols.error} ${pc.red("ERROR:")}`, err);
process.exit(1);
};

const runCommand = async (args) => {
args.spec = args.spec || (args._ && args._.length ? args._ : ["test"]);

try {
run.normalizeRunOptions(args);
run.validateRunOptions(args);
} catch (err) {
fail(err && err.message, err);
}

try {
await run.prepareRunOptions(args);
} catch (err) {
failRunPreparation(err);
}

return run.handler(args);
};

/**
* - Accepts an `Array` of arguments
* - Modifies {@link https://nodejs.org/api/modules.html#modules_module_paths Node.js' search path} for easy loading of consumer modules
Expand All @@ -39,43 +112,29 @@ export function main(argv = process.argv.slice(2), mochaArgs) {
debug("unable to set Error.stackTraceLimit = Infinity", err);
}

const args = mochaArgs ?? loadOptions(argv);

yargs()
.scriptName("mocha")
.command(run)
.command(init)
.updateStrings({
"Positionals:": "Positional Arguments",
"Options:": "Other Options",
"Commands:": "Commands",
})
.fail((msg, err, yargs) => {
debug("caught error sometime before command handler: %O", err);
yargs.showHelp();
console.error(`\n${logSymbols.error} ${pc.red("ERROR:")} ${msg}`);
if (!msg) {
// Log raw error and stack when an unexpected error is encountered, to
// make debugging easier (instead of an inactionable "ERROR: null").
console.error(err);
}
process.exit(1);
})
.help("help", "Show usage information & exit")
.alias("help", "h")
.version("version", "Show version number & exit", packageJson.version)
.alias("version", "V")
.wrap(process.stdout.columns ? Math.min(process.stdout.columns, 80) : 80)
.epilog(
`${pc.reset("Mocha Resources")}
Chat: ${pc.magenta(packageJson.discord)}
GitHub: ${pc.blue(packageJson.repository.url)}
Docs: ${pc.yellow(packageJson.homepage)}
`,
)
.parserConfiguration(YARGS_PARSER_CONFIG)
.config(args)
.parse(args._);
try {
const args = mochaArgs ?? loadOptions(argv);
if (hasFlag(argv, "help", "h") || args.help || args.h) {
printHelp();
process.exit(0);
}

if (hasFlag(argv, "version", "V") || args.version || args.V) {
printVersion();
process.exit(0);
}

const command = (args._ && args._[0]) || argv[0];
if (command === "init") {
const commandArgs =
args._ && args._[0] === "init" ? args._.slice(1) : argv.slice(1);
return init.handler(init.parse(commandArgs));
}

return runCommand(args);
} catch (err) {
fail(err && err.message, err);
}
}

// allow direct execution
Expand Down
3 changes: 1 addition & 2 deletions lib/cli/commands.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/**
* Exports Yargs commands
* @see https://github.com/yargs/yargs/blob/main/docs/advanced.md
* Exports Mocha CLI commands.
* @private
* @module
*/
Expand Down
12 changes: 7 additions & 5 deletions lib/cli/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ export const command = "init <path>";

export const description = "create a client-side Mocha setup at <path>";

export const builder = (yargs) =>
yargs.positional("path", {
type: "string",
normalize: true,
});
export const parse = (args) => {
const [pathArg] = args;
if (!pathArg) {
throw new Error("Not enough non-option arguments: got 0, need at least 1");
}
return { _: [], path: path.normalize(pathArg) };
};

export const handler = (argv) => {
const destdir = argv.path;
Expand Down
20 changes: 4 additions & 16 deletions lib/cli/options.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,7 @@ const debug = debugModule("mocha:cli:options");
*/

/**
* Base yargs parser configuration
* @private
*/
const YARGS_PARSER_CONFIG = {
"combine-arrays": true,
"short-option-groups": false,
"dot-notation": false,
"strip-aliased": true,
};

/**
* Wrapper around `parseArgs` which applies our settings
* Wrapper around `parseMochaArgs` which applies Mocha's CLI parsing behavior.
* @param {string|string[]} args - Arguments to parse
* @param {Object} defaultValues - Default values of mocharc.json
* @param {...Object} configObjects - Objects to merge into parsed arguments
Expand All @@ -68,7 +57,7 @@ const parse = (args = [], defaultValues = {}, ...configObjects) => {
* @param {string|boolean} [args.config] - Path to config file or `false` to skip
* @public
* @alias module:lib/cli.loadRc
* @returns {external:yargsParser.Arguments|void} Parsed config, or nothing if `args.config` is `false`
* @returns {external:parseArgs.Arguments|void} Parsed config, or nothing if `args.config` is `false`
*/
const loadRc = (args = {}) => {
if (args.config !== false) {
Expand All @@ -84,7 +73,7 @@ const loadRc = (args = {}) => {
* @param {string|boolean} [args.config] - Path to `package.json` or `false` to use default
* @public
* @alias module:lib/cli.loadPkgRc
* @returns {external:yargsParser.Arguments|void} Parsed config. Throws if unparsableF. Empty object if file not found.
* @returns {external:parseArgs.Arguments|void} Parsed config. Throws if unparsableF. Empty object if file not found.
*/
const loadPkgRc = (args = {}) => {
let result;
Expand Down Expand Up @@ -142,7 +131,7 @@ const loadPkgRc = (args = {}) => {
* @param {string|string[]} [argv] - Arguments to parse
* @public
* @alias module:lib/cli.loadOptions
* @returns {external:yargsParser.Arguments} Parsed args from everything
* @returns {external:parseArgs.Arguments} Parsed args from everything
*/
const loadOptions = (argv = []) => {
let args = parse(argv);
Expand Down Expand Up @@ -190,7 +179,6 @@ const loadOptions = (argv = []) => {
return args;
};

exports.YARGS_PARSER_CONFIG = YARGS_PARSER_CONFIG;
exports.loadRc = loadRc;
exports.loadPkgRc = loadPkgRc;
exports.loadOptions = loadOptions;
17 changes: 17 additions & 0 deletions lib/cli/parse-args.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ const toArray = (value) => {
// Array-type options should have unique values, with comma-delimited values
// split unless the option is a glob where commas can be part of the pattern.
const coerceArrayOption = (name, value) => {
if (
name === "reporter-option" &&
value &&
typeof value === "object" &&
!Array.isArray(value)
) {
return value;
}

if (
name === "reporter-option" &&
Array.isArray(value) &&
value.every((item) => item && typeof item === "object" && !Array.isArray(item))
) {
return Object.assign({}, ...value);
}

const values = toArray(value);
return Array.from(
new Set(globOptions.includes(name) ? values : list(values)),
Expand Down
62 changes: 61 additions & 1 deletion lib/cli/run-option-metadata.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

/**
* Dictionary of yargs option types to list of options having said type
* Dictionary of option types to list of options having said type
* @type {Record<string, string[]>}
* @private
*/
Expand Down Expand Up @@ -94,6 +94,65 @@ const aliases = {
watch: ["w"],
};

const descriptions = {
"allow-uncaught": "Allow uncaught errors to propagate",
"async-only": "Require all tests to use a callback (async) or return a Promise",
bail: 'Abort ("bail") after first test failure',
"check-leaks": "Check for global variable leaks",
color: "Force-enable color output",
config: "Path to config file",
delay: "Delay initial execution of root suite",
diff: "Show diff on failure",
"dry-run": "Report tests without executing them",
exit: "Force Mocha to quit after tests complete",
extension: "File extension(s) to load",
"fail-hook-affected-tests":
"Report tests as failed when affected by hook failures (before/beforeEach)",
"fail-zero": "Fail test run if no test(s) encountered",
"pass-on-failing-test-suite": "Not fail test run if tests were failed",
fgrep: "Only run tests containing this string",
file: "Specify file(s) to be loaded prior to root suite execution",
"forbid-only": "Fail if exclusive test(s) encountered",
"forbid-pending": "Fail if pending test(s) encountered",
"full-trace": "Display full stack traces",
global: "List of allowed global variables",
grep: "Only run tests matching this string or regexp",
ignore: "Ignore file(s) or glob pattern(s)",
"inline-diffs":
"Display actual/expected differences inline within each string",
invert: "Inverts --grep and --fgrep matches",
jobs: "Number of concurrent jobs for --parallel; use 1 to run in serial",
"list-interfaces": "List built-in user interfaces & exit",
"list-reporters": "List built-in reporters & exit",
"no-colors": "Force-disable color output",
"node-option": 'Node or V8 option (no leading "--")',
package: "Path to package.json for config",
parallel: "Run tests in parallel",
"posix-exit-codes": "Use POSIX and UNIX shell exit codes as Mocha's return value",
recursive: "Look for tests in subdirectories",
reporter: "Specify reporter to use",
"reporter-option": "Reporter-specific options (<k=v,[k1=v1,..]>)",
require: "Require module",
retries: "Retry failed tests this many times",
slow: 'Specify "slow" test threshold (in milliseconds)',
sort: "Sort test files",
timeout: "Specify test timeout threshold (in milliseconds)",
ui: "Specify user interface",
watch: "Watch files in the current working directory for changes",
"watch-files": "List of paths or globs to watch",
"watch-ignore": "List of paths or globs to exclude from watching",
};

const getRunOptionDefinitions = () =>
Object.entries(types).flatMap(([type, names]) =>
names.map((name) => ({
name,
type,
aliases: aliases[name] || [],
description: descriptions[name] || "",
})),
);

const ALL_MOCHA_FLAGS = Object.keys(types).reduce((acc, key) => {
// gets all flags from each of the fields in `types`, adds those,
// then adds aliases of each flag (if any)
Expand Down Expand Up @@ -139,5 +198,6 @@ const expectedTypeForFlag = (flag) => {

exports.types = types;
exports.aliases = aliases;
exports.getRunOptionDefinitions = getRunOptionDefinitions;
exports.isMochaFlag = isMochaFlag;
exports.expectedTypeForFlag = expectedTypeForFlag;
Loading
Loading