UNPKG

mocha

Version:

Classic, reliable, trusted test framework for Node.js and the browser

441 lines (383 loc) 11.3 kB
import { parseArgs } from "node:util"; import { createInvalidArgumentTypeError, createUnsupportedError, } from "../errors.js"; import { isNumeric } from "../utils.cjs"; import { list } from "./run-helpers.cjs"; import { aliases, expectedTypeForFlag, isMochaFlag, stripLeadingDashes, types, } from "./run-option-metadata.cjs"; import { isNodeFlag } from "./node-flags.cjs"; import debugModule from "debug"; const debug = debugModule("mocha:cli:parse-args"); const globOptions = ["spec", "ignore"]; const aliasToCanonical = Object.entries(aliases).reduce( (acc, [canonical, optionAliases]) => { optionAliases.forEach((alias) => { acc[alias] = canonical; }); return acc; }, {}, ); const canonicalOptionName = (name) => aliasToCanonical[name] || name; const optionTypeByName = Object.entries(types).reduce((acc, [type, names]) => { names.forEach((name) => { acc[name] = type; }); return acc; }, {}); const arrayOptions = new Set(types.array); const booleanOptions = new Set(types.boolean); const numberOptions = new Set(types.number); const scalarOptions = new Set(types.boolean.concat(types.string, types.number)); /** * Directly-passed Node/V8 flags are not a fixed list known to this parser. When * we see them, define them as boolean options for parseArgs(), then reapply any * values from equals-form flags after parsing. */ const createParseArgsOptions = (nodeArgs) => { const options = {}; Object.entries(optionTypeByName).forEach(([name, type]) => { const short = (aliases[name] || []).find((alias) => alias.length === 1); options[name] = { type: type === "boolean" ? "boolean" : "string", multiple: type !== "boolean", }; if (short) { options[name].short = short; } }); nodeArgs.forEach(([name]) => { options[name] = { type: "boolean" }; }); return options; }; const toArray = (value) => { if (value === undefined) { return []; } return Array.isArray(value) ? value : [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)), ); }; // Boolean/string/number options use the last value when given multiple times. const coerceScalarOption = (name, value) => { const scalar = Array.isArray(value) ? value[value.length - 1] : value; if (booleanOptions.has(name)) { if (scalar === "false") { return false; } if (scalar === "true") { return true; } } return scalar; }; const coerceNumberOption = (value) => { const scalar = Array.isArray(value) ? value[value.length - 1] : value; return scalar === undefined ? scalar : Number(scalar); }; const mergeValue = (existing, next) => { if (existing === undefined) { return next; } return toArray(existing).concat(toArray(next)); }; const mergeConfigObjects = (defaultValues, configObjects) => { const defaultArrayOptions = new Set(); return [defaultValues] .concat(configObjects.slice().reverse()) .reduce((merged, config) => { if (!config) { return merged; } Object.entries(config).forEach(([rawName, value]) => { const name = canonicalOptionName(rawName); if (name === "_") { merged._ = mergeValue(value, merged._); } else if (arrayOptions.has(name)) { if (config === defaultValues) { defaultArrayOptions.add(name); merged[name] = value; } else if (defaultArrayOptions.has(name)) { defaultArrayOptions.delete(name); merged[name] = value; } else { merged[name] = mergeValue(value, merged[name]); } } else { merged[name] = value; } }); return merged; }, {}); }; const preprocessArgs = (allArgs) => { return allArgs.map((arg) => { const noPrefix = stripLeadingDashes(arg); const [name, ...rest] = noPrefix.split("="); const canonical = canonicalOptionName(name.replace(/^no-/, "")); const negated = name.startsWith("no-"); const prefix = arg.startsWith("--") ? "--" : arg.startsWith("-") ? "-" : ""; if (!prefix || name === canonical || name.length === 1) { return arg; } const normalizedName = negated ? `no-${canonical}` : canonical; return `${prefix}${normalizedName}${rest.length ? `=${rest.join("=")}` : ""}`; }); }; const requiresValue = (name) => { const canonical = canonicalOptionName(name); return ( arrayOptions.has(canonical) || types.string.includes(canonical) || numberOptions.has(canonical) ); }; /** * Splits for downstream handling. * Does not apply Mocha-specific logic. * If arg is already string array, returns arg. * Respects escaped chars and quoted strings. * @param {string|string[]} args Input to be split, e.g. `"--grep -1"` * @returns {string[]} One entry per CLI arg-equivalent * @sample * ```js * const x = splitArgs("--timeout -1 --grep 'foo \' bar' --color"); * x === ["--timeout", "-1", "--grep", "foo ' bar", "--color"]; // true * ``` */ const splitArgs = (args) => { debug(`starting splitArgs(${args})`); if (!args) { debug("splitArgs took in falsy value, returning empty array"); return []; } if (Array.isArray(args)) { debug("splitArgs took in array, returning argument unchanged"); return args; } const result = []; let current = ""; let tokenStarted = false; let quote = null; let escaped = false; for (const char of args) { if (escaped) { current += char; tokenStarted = true; escaped = false; continue; } if (char === "\\") { tokenStarted = true; escaped = true; continue; } if (quote) { if (char === quote) { quote = null; } else { current += char; tokenStarted = true; } continue; } if (char === '"' || char === "'") { tokenStarted = true; quote = char; continue; } // At this point, we are not in quoted mode // Using newlines in CLI in this way is difficult. // We do not handle it for now to keep tests simple. if (/\t| /.test(char)) { if (tokenStarted) { result.push(current); current = ""; tokenStarted = false; } continue; } current += char; tokenStarted = true; } if (escaped) { current += "\\"; } // Unterminated is OK, usually passed via MOCHA_OPTIONS if (quote) { debug("unterminated quoted token, prepending quote"); current = quote + current; debug(`updated token: ${current}`); } if (tokenStarted) { result.push(current); } debug(`finishing splitArgs(${args})`); debug(`returning ${result}`); return result; }; /** * Disallows multiple arguments after a single option, such as `--grep abc def`. */ const validateArgsBeforeParse = (allArgs) => { allArgs.forEach((arg, index) => { if (!arg.startsWith("-") || arg === "--" || arg.includes("=")) { return; } const name = canonicalOptionName(stripLeadingDashes(arg)); const next = allArgs[index + 1]; if ( requiresValue(name) && (next === undefined || (next.startsWith("-") && !isNumeric(next))) ) { throw new Error(`Not enough arguments following: ${name}`); } }); }; const normalizeParsedValues = (values, positionals) => { const normalized = Object.assign({ _: positionals }, values); Object.keys(normalized).forEach((rawName) => { if (rawName === "_") { return; } const name = canonicalOptionName(rawName); if (name !== rawName) { normalized[name] = mergeValue(normalized[name], normalized[rawName]); delete normalized[rawName]; } }); arrayOptions.forEach((name) => { if (normalized[name] !== undefined) { normalized[name] = coerceArrayOption(name, normalized[name]); } }); scalarOptions.forEach((name) => { if (normalized[name] !== undefined) { normalized[name] = coerceScalarOption(name, normalized[name]); } }); numberOptions.forEach((name) => { if (normalized[name] !== undefined) { normalized[name] = coerceNumberOption(normalized[name]); } }); return normalized; }; const createErrorForNumericPositionalArg = ( numericArg, allArgs, parsedResult, ) => { // A flag for `numericArg` exists if: // 1. A Mocha flag immediately preceded the numericArg in `allArgs`, and // 2. parseArgs() could not assign the numericArg to that flag because the // option has an incompatible datatype. const flag = allArgs.find((arg, index) => { const normalizedArg = stripLeadingDashes(arg); return ( isMochaFlag(arg) && allArgs[index + 1] === String(numericArg) && parsedResult[normalizedArg] !== String(numericArg) ); }); if (flag) { throw createInvalidArgumentTypeError( `Mocha flag '${flag}' given invalid option: '${numericArg}'`, numericArg, expectedTypeForFlag(flag), ); } throw createUnsupportedError( `Option ${numericArg} is unsupported by the mocha cli`, ); }; /** * Parse the args into a config object. * Splits, * handles Node args, * validates general args, * parses into config object, * normalizes, * validates numerical args, * and returns resulting config object. * @param {string|string[]} args * @param {object} defaultValues * @param {...any} configObjects */ export const parseMochaArgs = ( args = [], defaultValues = {}, ...configObjects ) => { const allArgs = splitArgs(args); // Save Node-specific args for special handling. Equals-form args have values; // the rest are boolean flags. const nodeArgs = allArgs.reduce((acc, arg) => { const pair = arg.split("="); let flag = pair[0]; if (isNodeFlag(flag, false)) { flag = stripLeadingDashes(flag); return acc.concat([[flag, arg.includes("=") ? pair[1] : true]]); } return acc; }, []); validateArgsBeforeParse(allArgs); const parsed = parseArgs({ args: preprocessArgs(allArgs), options: createParseArgsOptions(nodeArgs), allowNegative: true, allowPositionals: true, strict: false, }); const result = normalizeParsedValues( Object.assign( mergeConfigObjects(defaultValues, configObjects), parsed.values, ), parsed.positionals, ); const numericPositionalArg = result._.find((arg) => isNumeric(arg)); if (numericPositionalArg) { createErrorForNumericPositionalArg( Number(numericPositionalArg), allArgs, result, ); } // Reapply equals-form Node/V8 flag values saved above. nodeArgs.forEach(([key, value]) => { result[key] = value; }); return result; };