From 11281897a2e0f88610f9dc865c39a7bf75af478f Mon Sep 17 00:00:00 2001 From: Mark Wiemer Date: Thu, 27 Aug 2026 20:19:41 -0700 Subject: [PATCH 01/10] Fixup parsing split arguments --- lib/cli/parse-args.js | 81 +++++++++++++++++++++++- test/node-unit/cli/options.spec.js | 41 ++++++++++++ test/node-unit/cli/parse-args.spec.js | 91 +++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 2 deletions(-) diff --git a/lib/cli/parse-args.js b/lib/cli/parse-args.js index 496eadae5d..e965dc5b3a 100644 --- a/lib/cli/parse-args.js +++ b/lib/cli/parse-args.js @@ -186,6 +186,79 @@ const requiresValue = (name) => { ); }; +const splitArgs = (args) => { + if (!args) { + return []; + } + + if (Array.isArray(args)) { + 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; + } + + if (/\s/.test(char)) { + if (tokenStarted) { + result.push(current); + current = ""; + tokenStarted = false; + } + continue; + } + + current += char; + tokenStarted = true; + } + + if (escaped) { + current += "\\"; + } + + if (quote) { + throw new Error(`Unterminated quote in arguments: ${args}`); + } + + if (tokenStarted) { + result.push(current); + } + + return result; +}; + /** * Disallows multiple arguments after a single option, such as `--grep abc def`. */ @@ -198,7 +271,11 @@ const validateArgsBeforeParse = (allArgs) => { const name = canonicalOptionName(stripLeadingDashes(arg)); const next = allArgs[index + 1]; - if (requiresValue(name) && (next === undefined || next.startsWith("-"))) { + if ( + requiresValue(name) && + (next === undefined || + (next.startsWith("-") && !isNumeric(next))) + ) { throw new Error(`Not enough arguments following: ${name}`); } }); @@ -276,7 +353,7 @@ export const parseMochaArgs = ( defaultValues = {}, ...configObjects ) => { - const allArgs = Array.isArray(args) ? args : args ? args.split(" ") : []; + const allArgs = splitArgs(args); // Save Node-specific args for special handling. Equals-form args have values; // the rest are boolean flags. diff --git a/test/node-unit/cli/options.spec.js b/test/node-unit/cli/options.spec.js index 69ab6b49e7..b24cbd1496 100644 --- a/test/node-unit/cli/options.spec.js +++ b/test/node-unit/cli/options.spec.js @@ -466,6 +466,47 @@ describe("options", function () { color: true, }); }); + + it("should parse quoted flags from MOCHA_OPTIONS", function () { + readFileSync = sinon.stub().onFirstCall().returns("{}"); + findConfig = sinon.stub().returns("/some/.mocharc.json"); + loadConfig = sinon.stub().returns({}); + findupSync = sinon.stub().returns("/some/package.json"); + sinon + .stub(process, "env") + .value({ MOCHA_OPTIONS: "--grep 'foo bar' --color" }); + + loadOptions = proxyLoadOptions({ + readFileSync, + findConfig, + loadConfig, + findupSync, + }); + + expect(loadOptions(), "to satisfy", { + grep: "foo bar", + color: true, + }); + }); + + it("should allow negative numeric values in MOCHA_OPTIONS", function () { + readFileSync = sinon.stub().onFirstCall().returns("{}"); + findConfig = sinon.stub().returns("/some/.mocharc.json"); + loadConfig = sinon.stub().returns({}); + findupSync = sinon.stub().returns("/some/package.json"); + sinon + .stub(process, "env") + .value({ MOCHA_OPTIONS: "--timeout -1" }); + + loadOptions = proxyLoadOptions({ + readFileSync, + findConfig, + loadConfig, + findupSync, + }); + + expect(loadOptions(), "to have property", "timeout", "-1"); + }); }); describe("config priority", function () { diff --git a/test/node-unit/cli/parse-args.spec.js b/test/node-unit/cli/parse-args.spec.js index c99b02d704..79a02e325d 100644 --- a/test/node-unit/cli/parse-args.spec.js +++ b/test/node-unit/cli/parse-args.spec.js @@ -239,6 +239,97 @@ describe("parse-args", function () { }); }); + it("accepts negative numeric option values in separated form", function () { + expect(parseMochaArgs(["--timeout", "-1"], defaults), "to satisfy", { + timeout: "-1", + }); + }); + + it("accepts negative numeric option values from strings", function () { + expect(parseMochaArgs("--timeout -1", defaults), "to satisfy", { + timeout: "-1", + }); + }); + + it("accepts decimal and exponential negative numeric option values", function () { + ["-1.5", "-.5", "-1e3"].forEach((timeout) => { + expect(parseMochaArgs(["--timeout", timeout], defaults), "to satisfy", { + timeout, + }); + }); + }); + + it("parses double-quoted arguments from strings", function () { + expect( + parseMochaArgs('--grep "foo bar" --retries 3 --color', defaults), + "to satisfy", + { + grep: "foo bar", + retries: 3, + color: true, + }, + ); + }); + + it("parses single-quoted arguments from strings", function () { + expect( + parseMochaArgs("--grep 'foo bar' --color", defaults), + "to satisfy", + { + grep: "foo bar", + color: true, + }, + ); + }); + + it("parses quoted values attached to options from strings", function () { + expect(parseMochaArgs('--grep="foo bar"', defaults), "to satisfy", { + grep: "foo bar", + }); + }); + + it("preserves empty quoted option values from strings", function () { + expect(parseMochaArgs('--grep ""', defaults), "to satisfy", { + grep: "", + }); + }); + + it("parses escaped spaces and quotes from strings", function () { + expect( + parseMochaArgs('--grep foo\\ bar --fgrep "a \\"quote\\""', defaults), + "to satisfy", + { + grep: "foo bar", + fgrep: 'a "quote"', + }, + ); + }); + + it("preserves trailing backslashes in unquoted arguments", function () { + expect(parseMochaArgs("--grep foo\\", defaults), "to satisfy", { + grep: "foo\\", + }); + }); + + it("splits arguments on all whitespace", function () { + expect( + parseMochaArgs("\t--grep\nfoo\r\n--color ", defaults), + "to satisfy", + { + grep: "foo", + color: true, + }, + ); + }); + + it("rejects unterminated quoted arguments", function () { + expect( + () => parseMochaArgs('--grep "foo', defaults), + "to throw", + /Unterminated quote in arguments/, + ); + }); + it("rejects dash-prefixed option values in separated form", function () { expect( () => parseMochaArgs(["--grep", "-foo"], defaults), From 236d942d978746c974c63377cefcb1a868fa7077 Mon Sep 17 00:00:00 2001 From: Mark Wiemer Date: Sat, 29 Aug 2026 07:43:50 -0700 Subject: [PATCH 02/10] Format --- lib/cli/parse-args.js | 3 +-- test/node-unit/cli/options.spec.js | 4 +--- test/node-unit/cli/parse-args.spec.js | 12 ++++-------- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/lib/cli/parse-args.js b/lib/cli/parse-args.js index e965dc5b3a..b2a419fd81 100644 --- a/lib/cli/parse-args.js +++ b/lib/cli/parse-args.js @@ -273,8 +273,7 @@ const validateArgsBeforeParse = (allArgs) => { if ( requiresValue(name) && - (next === undefined || - (next.startsWith("-") && !isNumeric(next))) + (next === undefined || (next.startsWith("-") && !isNumeric(next))) ) { throw new Error(`Not enough arguments following: ${name}`); } diff --git a/test/node-unit/cli/options.spec.js b/test/node-unit/cli/options.spec.js index b24cbd1496..48ea0430a6 100644 --- a/test/node-unit/cli/options.spec.js +++ b/test/node-unit/cli/options.spec.js @@ -494,9 +494,7 @@ describe("options", function () { findConfig = sinon.stub().returns("/some/.mocharc.json"); loadConfig = sinon.stub().returns({}); findupSync = sinon.stub().returns("/some/package.json"); - sinon - .stub(process, "env") - .value({ MOCHA_OPTIONS: "--timeout -1" }); + sinon.stub(process, "env").value({ MOCHA_OPTIONS: "--timeout -1" }); loadOptions = proxyLoadOptions({ readFileSync, diff --git a/test/node-unit/cli/parse-args.spec.js b/test/node-unit/cli/parse-args.spec.js index 79a02e325d..63fe21e29d 100644 --- a/test/node-unit/cli/parse-args.spec.js +++ b/test/node-unit/cli/parse-args.spec.js @@ -272,14 +272,10 @@ describe("parse-args", function () { }); it("parses single-quoted arguments from strings", function () { - expect( - parseMochaArgs("--grep 'foo bar' --color", defaults), - "to satisfy", - { - grep: "foo bar", - color: true, - }, - ); + expect(parseMochaArgs("--grep 'foo bar' --color", defaults), "to satisfy", { + grep: "foo bar", + color: true, + }); }); it("parses quoted values attached to options from strings", function () { From 53d88b2d1bcad4c227c7f0e44dccf28e5b156be8 Mon Sep 17 00:00:00 2001 From: Mark Wiemer Date: Sat, 29 Aug 2026 12:49:24 -0700 Subject: [PATCH 03/10] Cleanup parse-args, drop newline support --- lib/cli/parse-args.js | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/cli/parse-args.js b/lib/cli/parse-args.js index b2a419fd81..fd96507ddc 100644 --- a/lib/cli/parse-args.js +++ b/lib/cli/parse-args.js @@ -186,6 +186,19 @@ const requiresValue = (name) => { ); }; +/** + * 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) => { if (!args) { return []; @@ -230,8 +243,11 @@ const splitArgs = (args) => { quote = char; continue; } + // At this point, we are not in quoted mode - if (/\s/.test(char)) { + // 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 = ""; @@ -347,6 +363,19 @@ const createErrorForNumericPositionalArg = ( ); }; +/** + * 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 = {}, From 8045af7033c745a1fc3cab852c654fd3aa6b9c25 Mon Sep 17 00:00:00 2001 From: Mark Wiemer Date: Sat, 29 Aug 2026 13:05:11 -0700 Subject: [PATCH 04/10] Simplify whitespace test --- test/node-unit/cli/parse-args.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/node-unit/cli/parse-args.spec.js b/test/node-unit/cli/parse-args.spec.js index 63fe21e29d..4c54ce0b8f 100644 --- a/test/node-unit/cli/parse-args.spec.js +++ b/test/node-unit/cli/parse-args.spec.js @@ -307,9 +307,9 @@ describe("parse-args", function () { }); }); - it("splits arguments on all whitespace", function () { + it("splits arguments on all spaces and tabs", function () { expect( - parseMochaArgs("\t--grep\nfoo\r\n--color ", defaults), + parseMochaArgs("\t--grep \t foo --color", defaults), "to satisfy", { grep: "foo", From 3e707cadfde252e801a1b297c231ac4d5f13ecd6 Mon Sep 17 00:00:00 2001 From: Mark Wiemer Date: Sat, 29 Aug 2026 13:28:11 -0700 Subject: [PATCH 05/10] Accept unterminated quoteds, add debugs --- lib/cli/parse-args.js | 21 ++++++++++++++++----- test/node-unit/cli/parse-args.spec.js | 10 ++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/lib/cli/parse-args.js b/lib/cli/parse-args.js index fd96507ddc..ebb24d03c5 100644 --- a/lib/cli/parse-args.js +++ b/lib/cli/parse-args.js @@ -14,6 +14,9 @@ import { } 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]) => { @@ -200,11 +203,14 @@ const requiresValue = (name) => { * ``` */ 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; } @@ -247,7 +253,7 @@ const splitArgs = (args) => { // 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 (/\t| /.test(char)) { if (tokenStarted) { result.push(current); current = ""; @@ -264,14 +270,19 @@ const splitArgs = (args) => { current += "\\"; } + // Unterminated is OK, usually passed via MOCHA_OPTIONS if (quote) { - throw new Error(`Unterminated quote in arguments: ${args}`); + 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; }; @@ -372,9 +383,9 @@ const createErrorForNumericPositionalArg = ( * normalizes, * validates numerical args, * and returns resulting config object. - * @param {string|string[]} args - * @param {object} defaultValues - * @param {...any} configObjects + * @param {string|string[]} args + * @param {object} defaultValues + * @param {...any} configObjects */ export const parseMochaArgs = ( args = [], diff --git a/test/node-unit/cli/parse-args.spec.js b/test/node-unit/cli/parse-args.spec.js index 4c54ce0b8f..6d47156a12 100644 --- a/test/node-unit/cli/parse-args.spec.js +++ b/test/node-unit/cli/parse-args.spec.js @@ -318,11 +318,13 @@ describe("parse-args", function () { ); }); - it("rejects unterminated quoted arguments", function () { + it("accepts unterminated quoted arguments", function () { expect( - () => parseMochaArgs('--grep "foo', defaults), - "to throw", - /Unterminated quote in arguments/, + parseMochaArgs('--grep "foo', defaults), + "to satisfy", + { + grep: '"foo', + }, ); }); From 59d25cc94100203f9f860ec0b8cb584bb4ec6264 Mon Sep 17 00:00:00 2001 From: Mark Wiemer Date: Sat, 29 Aug 2026 13:29:07 -0700 Subject: [PATCH 06/10] Format --- lib/cli/parse-args.js | 3 +-- test/node-unit/cli/parse-args.spec.js | 10 +++------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/lib/cli/parse-args.js b/lib/cli/parse-args.js index ebb24d03c5..bb6aaf89d1 100644 --- a/lib/cli/parse-args.js +++ b/lib/cli/parse-args.js @@ -13,10 +13,9 @@ import { types, } from "./run-option-metadata.cjs"; import { isNodeFlag } from "./node-flags.cjs"; - import debugModule from "debug"; -const debug = debugModule("mocha:cli:parse-args"); +const debug = debugModule("mocha:cli:parse-args"); const globOptions = ["spec", "ignore"]; const aliasToCanonical = Object.entries(aliases).reduce( (acc, [canonical, optionAliases]) => { diff --git a/test/node-unit/cli/parse-args.spec.js b/test/node-unit/cli/parse-args.spec.js index 6d47156a12..cf9a936e14 100644 --- a/test/node-unit/cli/parse-args.spec.js +++ b/test/node-unit/cli/parse-args.spec.js @@ -319,13 +319,9 @@ describe("parse-args", function () { }); it("accepts unterminated quoted arguments", function () { - expect( - parseMochaArgs('--grep "foo', defaults), - "to satisfy", - { - grep: '"foo', - }, - ); + expect(parseMochaArgs('--grep "foo', defaults), "to satisfy", { + grep: '"foo', + }); }); it("rejects dash-prefixed option values in separated form", function () { From 8b0f6d8b7f72e27fa4bacbd2398ad77bc0d7c327 Mon Sep 17 00:00:00 2001 From: Mark Wiemer Date: Sat, 29 Aug 2026 13:33:17 -0700 Subject: [PATCH 07/10] Add `--grep -1` test --- test/node-unit/cli/options.spec.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/node-unit/cli/options.spec.js b/test/node-unit/cli/options.spec.js index 48ea0430a6..ef534fa72b 100644 --- a/test/node-unit/cli/options.spec.js +++ b/test/node-unit/cli/options.spec.js @@ -505,6 +505,24 @@ describe("options", function () { expect(loadOptions(), "to have property", "timeout", "-1"); }); + + it("should allow strings starting with hyphen in MOCHA_OPTIONS", function () { + readFileSync = sinon.stub().onFirstCall().returns("{}"); + findConfig = sinon.stub().returns("/some/.mocharc.json"); + loadConfig = sinon.stub().returns({}); + findupSync = sinon.stub().returns("/some/package.json"); + sinon.stub(process, "env").value({ MOCHA_OPTIONS: "--grep -1" }); + + loadOptions = proxyLoadOptions({ + readFileSync, + findConfig, + loadConfig, + findupSync, + }); + + expect(loadOptions(), "to have property", "grep", "-1"); + + }) }); describe("config priority", function () { From 6941599cc5ef5f6f5f8e670ccd453ed39cc19152 Mon Sep 17 00:00:00 2001 From: Mark Wiemer Date: Sat, 29 Aug 2026 13:35:08 -0700 Subject: [PATCH 08/10] Remove trailing backslash test --- test/node-unit/cli/parse-args.spec.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/node-unit/cli/parse-args.spec.js b/test/node-unit/cli/parse-args.spec.js index cf9a936e14..1c7d80e23f 100644 --- a/test/node-unit/cli/parse-args.spec.js +++ b/test/node-unit/cli/parse-args.spec.js @@ -301,12 +301,6 @@ describe("parse-args", function () { ); }); - it("preserves trailing backslashes in unquoted arguments", function () { - expect(parseMochaArgs("--grep foo\\", defaults), "to satisfy", { - grep: "foo\\", - }); - }); - it("splits arguments on all spaces and tabs", function () { expect( parseMochaArgs("\t--grep \t foo --color", defaults), From 6ffa5ca90d9685a0a3a7eb8ee76ff38f9904fef9 Mon Sep 17 00:00:00 2001 From: Mark Wiemer Date: Sat, 29 Aug 2026 13:39:07 -0700 Subject: [PATCH 09/10] Rename to "dash-prefixed" and format --- test/node-unit/cli/options.spec.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/node-unit/cli/options.spec.js b/test/node-unit/cli/options.spec.js index ef534fa72b..997d6de71c 100644 --- a/test/node-unit/cli/options.spec.js +++ b/test/node-unit/cli/options.spec.js @@ -506,7 +506,7 @@ describe("options", function () { expect(loadOptions(), "to have property", "timeout", "-1"); }); - it("should allow strings starting with hyphen in MOCHA_OPTIONS", function () { + it("should allow dash-prefixed option values in MOCHA_OPTIONS", function () { readFileSync = sinon.stub().onFirstCall().returns("{}"); findConfig = sinon.stub().returns("/some/.mocharc.json"); loadConfig = sinon.stub().returns({}); @@ -521,8 +521,7 @@ describe("options", function () { }); expect(loadOptions(), "to have property", "grep", "-1"); - - }) + }); }); describe("config priority", function () { From bf03330d873d6239193d5e8dcd1a315bed938902 Mon Sep 17 00:00:00 2001 From: Mark Wiemer Date: Sat, 29 Aug 2026 13:39:25 -0700 Subject: [PATCH 10/10] Move dash-prefixed tests together --- test/node-unit/cli/parse-args.spec.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/node-unit/cli/parse-args.spec.js b/test/node-unit/cli/parse-args.spec.js index 1c7d80e23f..f3c4e9c47a 100644 --- a/test/node-unit/cli/parse-args.spec.js +++ b/test/node-unit/cli/parse-args.spec.js @@ -239,6 +239,14 @@ describe("parse-args", function () { }); }); + it("rejects dash-prefixed option values in separated form", function () { + expect( + () => parseMochaArgs(["--grep", "-foo"], defaults), + "to throw", + /Not enough arguments following: grep/, + ); + }); + it("accepts negative numeric option values in separated form", function () { expect(parseMochaArgs(["--timeout", "-1"], defaults), "to satisfy", { timeout: "-1", @@ -318,14 +326,6 @@ describe("parse-args", function () { }); }); - it("rejects dash-prefixed option values in separated form", function () { - expect( - () => parseMochaArgs(["--grep", "-foo"], defaults), - "to throw", - /Not enough arguments following: grep/, - ); - }); - it("preserves directly-passed Node and V8 flags", function () { expect( parseMochaArgs(