diff --git a/.changeset/3597-windows-argv-overflow.md b/.changeset/3597-windows-argv-overflow.md new file mode 100644 index 000000000..976b81859 --- /dev/null +++ b/.changeset/3597-windows-argv-overflow.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3649 +--- +**`scripts/run-tests.cjs` no longer fails on Windows when invoking many test files at once** — Windows `CreateProcess` caps `lpCommandLine` at 32,767 chars, so an unchunked spawn of `node --test <546 paths>` aborted instantly with exit 1 and no test output (Linux/macOS allow ~2 MB so the same path worked there). The harness now batches selected files into chunks whose total argv stays under 28,000 chars and runs each chunk sequentially, reporting `run-tests: chunk N/M — K files` to stderr. The ceiling is overridable via `RUN_TESTS_MAX_CMDLINE_CHARS` for tuning and tests. Adds a cross-platform regression test that forces chunking with a low ceiling. diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs index 55038b934..e38f47f50 100644 --- a/scripts/run-tests.cjs +++ b/scripts/run-tests.cjs @@ -130,14 +130,49 @@ function main() { ? `--test-concurrency=${process.env.TEST_CONCURRENCY}` : '--test-concurrency=4'; - try { - execFileSync(process.execPath, ['--test', concurrency, ...selected], { - stdio: 'inherit', - env: { ...process.env }, - }); - } catch (err) { - process.exit(err.status || 1); + // Windows `CreateProcess` caps the full command line at 32,767 chars + // (lpCommandLine). With 500+ test paths the spawn fails instantly with no + // test output. Linux/macOS allow ~2 MB (ARG_MAX) so unchunked spawns are + // fine there. Split into chunks sized for the tightest target so behavior + // is identical across platforms. (#3597) + // Operator override (also used by tests to force chunking with short paths). + const MAX_CMDLINE_CHARS = process.env.RUN_TESTS_MAX_CMDLINE_CHARS + ? Number(process.env.RUN_TESTS_MAX_CMDLINE_CHARS) + : 28000; // headroom below the 32,767 Windows ceiling + const FIXED_OVERHEAD = process.execPath.length + '--test'.length + concurrency.length + 8; + const chunks = []; + let current = []; + let currentLen = FIXED_OVERHEAD; + for (const file of selected) { + const add = file.length + 1; // +1 for the inter-arg separator + if (current.length > 0 && currentLen + add > MAX_CMDLINE_CHARS) { + chunks.push(current); + current = []; + currentLen = FIXED_OVERHEAD; + } + current.push(file); + currentLen += add; } + if (current.length > 0) chunks.push(current); + + let firstFailureExit = 0; + for (let i = 0; i < chunks.length; i++) { + if (chunks.length > 1) { + console.error(`run-tests: chunk ${i + 1}/${chunks.length} — ${chunks[i].length} files`); + } + try { + execFileSync(process.execPath, ['--test', concurrency, ...chunks[i]], { + stdio: 'inherit', + env: { ...process.env }, + }); + } catch (err) { + const code = err.status || 1; + // Run every chunk so the operator sees all failures in one pass; report + // the first non-zero exit at the end. + if (firstFailureExit === 0) firstFailureExit = code; + } + } + if (firstFailureExit !== 0) process.exit(firstFailureExit); } main(); diff --git a/tests/run-tests-harness.test.cjs b/tests/run-tests-harness.test.cjs index ad579c4e6..75cb12155 100644 --- a/tests/run-tests-harness.test.cjs +++ b/tests/run-tests-harness.test.cjs @@ -34,10 +34,10 @@ function seed(dir, names) { } } -function runHarness(testDir, args = []) { +function runHarness(testDir, args = [], extraEnv = {}) { // Clear node:test parent-context env so the harness's child `node --test` // doesn't refuse to run with "recursive run() skipping running files". - const env = { ...process.env, GSD_TEST_DIR: testDir }; + const env = { ...process.env, GSD_TEST_DIR: testDir, ...extraEnv }; delete env.NODE_TEST_CONTEXT; return spawnSync(process.execPath, [HARNESS, ...args], { cwd: path.join(__dirname, '..'), @@ -207,4 +207,34 @@ test('boom', () => { throw new Error('intentional'); }); ); }); }); + + describe('Windows argv-overflow chunking (issue #3597)', () => { + // Windows CreateProcess caps lpCommandLine at 32,767 chars. With ~550 + // tests the unchunked spawn fails instantly on Windows with no test + // output. Linux/macOS allow ~2 MB so the same path works there. The + // harness chunks selected files so each spawn stays under the ceiling, + // and chunking is observable via the `run-tests: chunk N/M …` stderr + // line. Long filenames force chunking even with a modest file count so + // the test stays fast on every platform. + test('chunks when total argv would exceed configured ceiling', () => { + // Use a deliberately low MAX_CMDLINE_CHARS so the test is independent + // of tmp-path length (varies by OS). With a 2000-char ceiling and 30 + // tests at ≥100 char paths, chunking must engage and at least one + // `chunk N/M …` marker must appear in stderr. + const longPrefix = 'a-deliberately-long-test-filename-to-force-chunking-behavior-cross-platform-'; + const names = Array.from({ length: 30 }, (_, i) => `${longPrefix}${String(i).padStart(4, '0')}.test.cjs`); + seed(tmpDir, names); + const r = runHarness(tmpDir, [], { RUN_TESTS_MAX_CMDLINE_CHARS: '2000' }); + assert.strictEqual( + r.status, + 0, + `expected zero exit; got status=${r.status} signal=${r.signal}\nSTDERR (tail):\n${r.stderr.split('\n').slice(-20).join('\n')}`, + ); + assert.match( + r.stderr, + /run-tests: chunk \d+\/\d+ — \d+ files/, + `expected chunking marker in stderr; STDERR (tail):\n${r.stderr.split('\n').slice(-20).join('\n')}`, + ); + }); + }); });