options.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. 'use strict';
  2. /**
  3. * Main entry point for handling filesystem-based configuration,
  4. * whether that's a config file or `package.json` or whatever.
  5. * @module lib/cli/options
  6. * @private
  7. */
  8. const fs = require('node:fs');
  9. const pc = require('picocolors');
  10. const yargsParser = require('yargs-parser');
  11. const {
  12. types,
  13. aliases,
  14. isMochaFlag,
  15. expectedTypeForFlag
  16. } = require('./run-option-metadata');
  17. const {ONE_AND_DONE_ARGS} = require('./one-and-dones');
  18. const mocharc = require('../mocharc.json');
  19. const {list} = require('./run-helpers');
  20. const {loadConfig, findConfig} = require('./config');
  21. const findUp = require('find-up');
  22. const debug = require('debug')('mocha:cli:options');
  23. const {isNodeFlag} = require('./node-flags');
  24. const {
  25. createUnparsableFileError,
  26. createInvalidArgumentTypeError,
  27. createUnsupportedError
  28. } = require('../errors');
  29. const {isNumeric} = require('../utils');
  30. /**
  31. * The `yargs-parser` namespace
  32. * @external yargsParser
  33. * @see {@link https://npm.im/yargs-parser}
  34. */
  35. /**
  36. * An object returned by a configured `yargs-parser` representing arguments
  37. * @memberof external:yargsParser
  38. * @interface Arguments
  39. */
  40. /**
  41. * Base yargs parser configuration
  42. * @private
  43. */
  44. const YARGS_PARSER_CONFIG = {
  45. 'combine-arrays': true,
  46. 'short-option-groups': false,
  47. 'dot-notation': false,
  48. 'strip-aliased': true
  49. };
  50. /**
  51. * This is the config pulled from the `yargs` property of Mocha's
  52. * `package.json`, but it also disables camel case expansion as to
  53. * avoid outputting non-canonical keynames, as we need to do some
  54. * lookups.
  55. * @private
  56. * @ignore
  57. */
  58. const configuration = Object.assign({}, YARGS_PARSER_CONFIG, {
  59. 'camel-case-expansion': false
  60. });
  61. /**
  62. * This is a really fancy way to:
  63. * - `array`-type options: ensure unique values and evtl. split comma-delimited lists
  64. * - `boolean`/`number`/`string`- options: use last element when given multiple times
  65. * This is passed as the `coerce` option to `yargs-parser`
  66. * @private
  67. * @ignore
  68. */
  69. const globOptions = ['spec', 'ignore'];
  70. const coerceOpts = Object.assign(
  71. types.array.reduce(
  72. (acc, arg) =>
  73. Object.assign(acc, {
  74. [arg]: v => Array.from(new Set(globOptions.includes(arg) ? v : list(v)))
  75. }),
  76. {}
  77. ),
  78. types.boolean
  79. .concat(types.string, types.number)
  80. .reduce(
  81. (acc, arg) =>
  82. Object.assign(acc, {[arg]: v => (Array.isArray(v) ? v.pop() : v)}),
  83. {}
  84. )
  85. );
  86. /**
  87. * We do not have a case when multiple arguments are ever allowed after a flag
  88. * (e.g., `--foo bar baz quux`), so we fix the number of arguments to 1 across
  89. * the board of non-boolean options.
  90. * This is passed as the `narg` option to `yargs-parser`
  91. * @private
  92. * @ignore
  93. */
  94. const nargOpts = types.array
  95. .concat(types.string, types.number)
  96. .reduce((acc, arg) => Object.assign(acc, {[arg]: 1}), {});
  97. /**
  98. * Throws either "UNSUPPORTED" error or "INVALID_ARG_TYPE" error for numeric positional arguments.
  99. * @param {string[]} allArgs - Stringified args passed to mocha cli
  100. * @param {number} numericArg - Numeric positional arg for which error must be thrown
  101. * @param {Object} parsedResult - Result from `yargs-parser`
  102. * @private
  103. * @ignore
  104. */
  105. const createErrorForNumericPositionalArg = (
  106. numericArg,
  107. allArgs,
  108. parsedResult
  109. ) => {
  110. // A flag for `numericArg` exists if:
  111. // 1. A mocha flag immediately preceeded the numericArg in `allArgs` array and
  112. // 2. `numericArg` value could not be assigned to this flag by `yargs-parser` because of incompatible datatype.
  113. const flag = allArgs.find((arg, index) => {
  114. const normalizedArg = arg.replace(/^--?/, '');
  115. return (
  116. isMochaFlag(arg) &&
  117. allArgs[index + 1] === String(numericArg) &&
  118. parsedResult[normalizedArg] !== String(numericArg)
  119. );
  120. });
  121. if (flag) {
  122. throw createInvalidArgumentTypeError(
  123. `Mocha flag '${flag}' given invalid option: '${numericArg}'`,
  124. numericArg,
  125. expectedTypeForFlag(flag)
  126. );
  127. } else {
  128. throw createUnsupportedError(
  129. `Option ${numericArg} is unsupported by the mocha cli`
  130. );
  131. }
  132. };
  133. /**
  134. * Wrapper around `yargs-parser` which applies our settings
  135. * @param {string|string[]} args - Arguments to parse
  136. * @param {Object} defaultValues - Default values of mocharc.json
  137. * @param {...Object} configObjects - `configObjects` for yargs-parser
  138. * @private
  139. * @ignore
  140. */
  141. const parse = (args = [], defaultValues = {}, ...configObjects) => {
  142. // save node-specific args for special handling.
  143. // 1. when these args have a "=" they should be considered to have values
  144. // 2. if they don't, they are just boolean flags
  145. // 3. to avoid explicitly defining the set of them, we tell yargs-parser they
  146. // are ALL boolean flags.
  147. // 4. we can then reapply the values after yargs-parser is done.
  148. const allArgs = Array.isArray(args) ? args : args.split(' ');
  149. const nodeArgs = allArgs.reduce((acc, arg) => {
  150. const pair = arg.split('=');
  151. let flag = pair[0];
  152. if (isNodeFlag(flag, false)) {
  153. flag = flag.replace(/^--?/, '');
  154. return acc.concat([[flag, arg.includes('=') ? pair[1] : true]]);
  155. }
  156. return acc;
  157. }, []);
  158. const result = yargsParser.detailed(args, {
  159. configuration,
  160. configObjects,
  161. default: defaultValues,
  162. coerce: coerceOpts,
  163. narg: nargOpts,
  164. alias: aliases,
  165. string: types.string,
  166. array: types.array,
  167. number: types.number,
  168. boolean: types.boolean.concat(nodeArgs.map(pair => pair[0]))
  169. });
  170. if (result.error) {
  171. console.error(pc.red(`Error: ${result.error.message}`));
  172. process.exit(1);
  173. }
  174. const numericPositionalArg = result.argv._.find(arg => isNumeric(arg));
  175. if (numericPositionalArg) {
  176. createErrorForNumericPositionalArg(
  177. numericPositionalArg,
  178. allArgs,
  179. result.argv
  180. );
  181. }
  182. // reapply "=" arg values from above
  183. nodeArgs.forEach(([key, value]) => {
  184. result.argv[key] = value;
  185. });
  186. return result.argv;
  187. };
  188. /**
  189. * Given path to config file in `args.config`, attempt to load & parse config file.
  190. * @param {Object} [args] - Arguments object
  191. * @param {string|boolean} [args.config] - Path to config file or `false` to skip
  192. * @public
  193. * @alias module:lib/cli.loadRc
  194. * @returns {external:yargsParser.Arguments|void} Parsed config, or nothing if `args.config` is `false`
  195. */
  196. const loadRc = (args = {}) => {
  197. if (args.config !== false) {
  198. const config = args.config || findConfig();
  199. return config ? loadConfig(config) : {};
  200. }
  201. };
  202. module.exports.loadRc = loadRc;
  203. /**
  204. * Given path to `package.json` in `args.package`, attempt to load config from `mocha` prop.
  205. * @param {Object} [args] - Arguments object
  206. * @param {string|boolean} [args.config] - Path to `package.json` or `false` to skip
  207. * @public
  208. * @alias module:lib/cli.loadPkgRc
  209. * @returns {external:yargsParser.Arguments|void} Parsed config, or nothing if `args.package` is `false`
  210. */
  211. const loadPkgRc = (args = {}) => {
  212. let result;
  213. if (args.package === false) {
  214. return result;
  215. }
  216. result = {};
  217. const filepath = args.package || findUp.sync(mocharc.package);
  218. if (filepath) {
  219. let configData;
  220. try {
  221. configData = fs.readFileSync(filepath, 'utf8');
  222. } catch (err) {
  223. // If `args.package` was explicitly specified, throw an error
  224. if (filepath == args.package) {
  225. throw createUnparsableFileError(
  226. `Unable to read ${filepath}: ${err}`,
  227. filepath
  228. );
  229. } else {
  230. debug('failed to read default package.json at %s; ignoring',
  231. filepath);
  232. return result;
  233. }
  234. }
  235. try {
  236. const pkg = JSON.parse(configData);
  237. if (pkg.mocha) {
  238. debug('`mocha` prop of package.json parsed: %O', pkg.mocha);
  239. result = pkg.mocha;
  240. } else {
  241. debug('no config found in %s', filepath);
  242. }
  243. } catch (err) {
  244. // If JSON failed to parse, throw an error.
  245. throw createUnparsableFileError(
  246. `Unable to parse ${filepath}: ${err}`,
  247. filepath
  248. );
  249. }
  250. }
  251. return result;
  252. };
  253. module.exports.loadPkgRc = loadPkgRc;
  254. /**
  255. * Priority list:
  256. *
  257. * 1. Command-line args
  258. * 2. `MOCHA_OPTIONS` environment variable.
  259. * 3. RC file (`.mocharc.c?js`, `.mocharc.ya?ml`, `mocharc.json`)
  260. * 4. `mocha` prop of `package.json`
  261. * 5. default configuration (`lib/mocharc.json`)
  262. *
  263. * If a {@link module:lib/cli/one-and-dones.ONE_AND_DONE_ARGS "one-and-done" option} is present in the `argv` array, no external config files will be read.
  264. * @summary Parses options read from `.mocharc.*` and `package.json`.
  265. * @param {string|string[]} [argv] - Arguments to parse
  266. * @public
  267. * @alias module:lib/cli.loadOptions
  268. * @returns {external:yargsParser.Arguments} Parsed args from everything
  269. */
  270. const loadOptions = (argv = []) => {
  271. let args = parse(argv);
  272. // short-circuit: look for a flag that would abort loading of options
  273. if (
  274. Array.from(ONE_AND_DONE_ARGS).reduce(
  275. (acc, arg) => acc || arg in args,
  276. false
  277. )
  278. ) {
  279. return args;
  280. }
  281. const envConfig = parse(process.env.MOCHA_OPTIONS || '');
  282. const rcConfig = loadRc(args);
  283. const pkgConfig = loadPkgRc(args);
  284. if (rcConfig) {
  285. args.config = false;
  286. args._ = args._.concat(rcConfig._ || []);
  287. }
  288. if (pkgConfig) {
  289. args.package = false;
  290. args._ = args._.concat(pkgConfig._ || []);
  291. }
  292. args = parse(
  293. args._,
  294. mocharc,
  295. args,
  296. envConfig,
  297. rcConfig || {},
  298. pkgConfig || {}
  299. );
  300. // recombine positional arguments and "spec"
  301. if (args.spec) {
  302. args._ = args._.concat(args.spec);
  303. delete args.spec;
  304. }
  305. // make unique
  306. args._ = Array.from(new Set(args._));
  307. return args;
  308. };
  309. module.exports.loadOptions = loadOptions;
  310. module.exports.YARGS_PARSER_CONFIG = YARGS_PARSER_CONFIG;