#!/usr/bin/env node import { spawn, spawnSync } from 'node:child_process' import console from 'node:console' import { readFileSync } from 'node:fs' import { mkdir, readdir, stat, writeFile } from 'node:fs/promises' import path from 'node:path' import { performance } from 'node:perf_hooks' import process from 'node:process' import { readTestDurations, selectChunk, taskWeight } from './ts-test-chunks.mjs' const rootDir = process.cwd() const pnpmCommand = resolveCommand('pn') const WINDOWS_SHELL_COMMAND_LENGTH_LIMIT = 7000 const DEFAULT_COMMAND_LENGTH_LIMIT = 100000 // Nothing in CI reads the coverage that the shared Jest config collects, and // collecting it made package test runs up to 20% slower. const JEST_CI_ARGS = ['--coverage=false'] const { chunk, chunks, dryRun, script, summary } = parseArgs(process.argv.slice(2)) if (!dryRun) { await runPnpm(['run', 'prepare-fixtures']) await runPnpm(['run', 'remove-temp-dir']) } const packages = await listSelectedPackages(script) const durations = readTestDurations() const tasks = await listTestTasks(packages) const selectedTasks = selectChunk(tasks, { chunk, chunks }) const selectedPackages = groupJestTasksByPackage(selectedTasks) const executionStatus = {} console.log(`Selected ${selectedTasks.length} of ${tasks.length} test tasks for chunk ${chunk}/${chunks}`) if (!dryRun) { for (const pkg of packages) { const selectedPackage = selectedPackages.get(pkg.path) if (selectedPackage != null) { await runJestPackage(pkg, selectedPackage) } else if (selectedTasks.some((task) => task.kind === 'script' && task.packagePath === pkg.path)) { await runScriptTask(pkg) } } await writeSummary(summary, executionStatus) } const failures = Object.values(executionStatus).filter((status) => status.status === 'failure') if (failures.length > 0) { process.exitCode = 1 } async function listSelectedPackages (scriptName) { const filterArgs = scriptName === 'ci:test-branch' ? ['--filter=...[origin/main]'] : [] const stdout = await capturePnpm([...filterArgs, '-r', 'list', '--depth=-1', '--json']) return JSON.parse(stdout) .map((pkg) => ({ path: pkg.path, manifest: readManifest(pkg.path), })) .filter((pkg) => pkg.manifest.scripts?.['.test'] != null) } async function listTestTasks (packages) { const tasks = [] for (const pkg of packages) { if (!usesJest(pkg.manifest.scripts)) { const task = { id: normalizePath(path.relative(rootDir, pkg.path)), kind: 'script', packagePath: pkg.path, } task.weight = taskWeight(task, durations) tasks.push(task) continue } const testFiles = await findJestTestFiles(pkg.path) tasks.push(...await Promise.all(testFiles.map(async (file) => { const fileStat = await stat(file) const task = { file, id: normalizePath(path.relative(rootDir, file)), kind: 'jest', packagePath: pkg.path, size: fileStat.size, } task.weight = taskWeight(task, durations) return task }))) } return tasks } function usesJest (scripts) { return Object.entries(scripts).some(([name, script]) => ( (name === '.test' || name.startsWith('.test:')) && script.includes('jest') )) } function readRegistryMockPort (scripts) { for (const script of Object.values(scripts ?? {})) { const match = /PNPM_REGISTRY_MOCK_PORT=(\d+)/.exec(script) if (match != null) return match[1] } return undefined } function groupJestTasksByPackage (tasks) { const selectedPackages = new Map() for (const task of tasks) { if (task.kind !== 'jest') continue const selectedPackage = selectedPackages.get(task.packagePath) ?? { files: [] } selectedPackage.files.push(task.file) selectedPackages.set(task.packagePath, selectedPackage) } for (const selectedPackage of selectedPackages.values()) { selectedPackage.files.sort() } return selectedPackages } async function runJestPackage (pkg, selectedPackage) { const startedAt = performance.now() const relDir = normalizePath(path.relative(rootDir, pkg.path)) const relFiles = selectedPackage.files.map((file) => normalizePath(path.relative(pkg.path, file))) const batches = getJestFileBatches(pkg, relFiles) console.log(`Running ${relFiles.length} Jest file(s) in ${relDir}`) const env = { ...process.env, NODE_OPTIONS: withJestNodeOptions(process.env.NODE_OPTIONS), PNPM_SCRIPT_SRC_DIR: pkg.path, } prependPath(env, [ path.join(pkg.path, 'node_modules', '.bin'), path.join(rootDir, 'node_modules', '.bin'), ]) // Some packages pin PNPM_REGISTRY_MOCK_PORT in their `.test` script (e.g. a // fixture whose dependency is a tarball URL baked to that port). Running jest // directly bypasses the script, so carry the pin over from the manifest. const registryMockPort = readRegistryMockPort(pkg.manifest.scripts) if (registryMockPort != null) { env.PNPM_REGISTRY_MOCK_PORT = registryMockPort } let status = 'passed' let exitCode = 0 try { if (pkg.manifest.scripts.pretest != null) { await runPnpm(['--dir', pkg.path, 'run', 'pretest'], { env }) } for (const [index, files] of batches.entries()) { if (batches.length > 1) { console.log(`Running batch ${index + 1}/${batches.length} (${files.length} Jest file(s)) in ${relDir}`) } await runPnpm(['--dir', pkg.path, 'exec', 'jest', ...JEST_CI_ARGS, '--runTestsByPath', ...files], { env }) } } catch (err) { status = 'failure' exitCode = err.exitCode ?? 1 } executionStatus[pkg.path] = { status, duration: performance.now() - startedAt, exitCode, } } async function runScriptTask (pkg) { const startedAt = performance.now() const relDir = normalizePath(path.relative(rootDir, pkg.path)) console.log(`Running non-Jest test script in ${relDir}`) let status = 'passed' let exitCode = 0 try { if (pkg.manifest.name === 'pd') { await runCommand('node', ['pd.js', '--version'], { cwd: pkg.path }) } else if (pkg.manifest.name === '@pnpm/bins.cmd-shim') { await runCommand('node', ['--test', 'test/test.js', 'test/e2e.test.js'], { cwd: pkg.path }) } else if (pkg.manifest.name === '@pnpm/eslint-config') { await runCommand('node', ['--test', 'test/perfectionist.test.js'], { cwd: pkg.path }) } else if (pkg.manifest.name === '@pnpm/esm-loader') { await runCommand('node', ['--test', 'test/loader.test.mjs', 'scripts/test-unrs-resolver.mjs'], { cwd: pkg.path }) } else { throw new Error(`Unsupported non-Jest .test script in ${relDir}: ${pkg.manifest.scripts['.test']}`) } } catch (err) { console.error(err) status = 'failure' exitCode = err.exitCode ?? 1 } executionStatus[pkg.path] = { status, duration: performance.now() - startedAt, exitCode, } } async function findJestTestFiles (packageDir) { const files = [] await collectTestFiles(path.join(packageDir, 'test'), files, isTestDirFile) await collectTestFiles(path.join(packageDir, 'src'), files, isSrcTestFile) return files.sort() } async function collectTestFiles (dir, files, matches) { let entries try { entries = await readdir(dir, { withFileTypes: true }) } catch (err) { if (err.code === 'ENOENT') return throw err } for (const entry of entries) { const entryPath = path.join(dir, entry.name) if (entry.isDirectory()) { if (entry.name === 'fixtures' || entry.name === '__fixtures__') continue if (isTestUtilsDir(entryPath)) continue await collectTestFiles(entryPath, files, matches) } else if (entry.isFile() && matches(entryPath)) { files.push(entryPath) } } } function isTestDirFile (file) { return /\.[jt]sx?$/.test(file) && !file.endsWith('.d.ts') } function isSrcTestFile (file) { return file.endsWith('.test.ts') } function isTestUtilsDir (dir) { const segments = normalizePath(path.relative(rootDir, dir)).split('/') const testIndex = segments.indexOf('test') return testIndex !== -1 && segments.slice(testIndex + 1).includes('utils') } async function writeSummary (summaryPath, status) { await mkdir(path.dirname(summaryPath), { recursive: true }) await writeFile(summaryPath, JSON.stringify({ executionStatus: status }, null, 2) + '\n') } function readManifest (packageDir) { return JSON.parse(readFileSyncUtf8(path.join(packageDir, 'package.json'))) } function readFileSyncUtf8 (file) { return readFileSync(file, 'utf8') } function withJestNodeOptions (current = '') { const options = current.split(/\s+/).filter(Boolean) addNodeOption(options, '--experimental-vm-modules') addNodeOption(options, '--disable-warning=ExperimentalWarning') addNodeOption(options, '--disable-warning=DEP0169') if (!options.some((option) => option.startsWith('--max-old-space-size='))) { options.push('--max-old-space-size=6144') } return options.join(' ') } function addNodeOption (options, option) { if (!options.includes(option)) { options.push(option) } } function getJestFileBatches (pkg, relFiles) { const isolatedFiles = Array.from(getExplicitJestFiles(pkg.manifest.scripts)) .filter((file) => relFiles.includes(file)) if (isolatedFiles.length === 0 && !hasSplitJestScripts(pkg.manifest.scripts)) return [relFiles] const isolatedFileSet = new Set(isolatedFiles) const remainingFiles = relFiles.filter((file) => !isolatedFileSet.has(file)) return [ ...isolatedFiles.map((file) => [file]), ...splitJestFilesByCommandLength(pkg, remainingFiles), ].filter((files) => files.length > 0) } function hasSplitJestScripts (scripts) { return Object.entries(scripts).some(([name, script]) => name.startsWith('.test:') && script.includes('jest')) } function getExplicitJestFiles (scripts) { const files = new Set() for (const [name, script] of Object.entries(scripts)) { if (!name.startsWith('.test:') || !script.includes('jest')) continue for (const match of script.matchAll(/(?:^|\s)(test\/[^\s"'`]+?\.[jt]sx?)(?=\s|$)/g)) { files.add(match[1]) } } return files } function splitJestFilesByCommandLength (pkg, files) { const limit = process.platform === 'win32' ? WINDOWS_SHELL_COMMAND_LENGTH_LIMIT : DEFAULT_COMMAND_LENGTH_LIMIT const baseArgs = [pnpmCommand, '--dir', pkg.path, 'exec', 'jest', ...JEST_CI_ARGS, '--runTestsByPath'] const chunks = [] let currentChunk = [] let currentLength = estimateCommandLength(baseArgs) for (const file of files) { const fileLength = estimateCommandLength([file]) if (currentChunk.length > 0 && currentLength + fileLength > limit) { chunks.push(currentChunk) currentChunk = [] currentLength = estimateCommandLength(baseArgs) } currentChunk.push(file) currentLength += fileLength } if (currentChunk.length > 0) { chunks.push(currentChunk) } return chunks } function estimateCommandLength (args) { return args.reduce((total, arg) => total + arg.length + 3, 0) } function prependPath (env, dirs) { const pathKey = getPathKey(env) env[pathKey] = [ ...dirs, env[pathKey], ].filter(Boolean).join(path.delimiter) } function getPathKey (env) { return Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH' } function runPnpm (args, opts = {}) { return runCommand(pnpmCommand, args, opts) } function capturePnpm (args) { return captureCommand(pnpmCommand, args) } function runCommand (command, args, opts = {}) { return new Promise((resolve, reject) => { const spawnTarget = resolveSpawnTarget(command, args) const child = spawn(spawnTarget.command, spawnTarget.args, { cwd: opts.cwd ?? rootDir, env: opts.env ?? process.env, shell: spawnTarget.shell, stdio: 'inherit', }) child.on('error', reject) child.on('close', (code, signal) => { if (signal) { reject(Object.assign(new Error(`${command} terminated by signal ${signal}`), { exitCode: 1 })) } else if (code === 0) { resolve() } else { reject(Object.assign(new Error(`${command} exited with code ${code}`), { exitCode: code ?? 1 })) } }) }) } function captureCommand (command, args) { return new Promise((resolve, reject) => { let stdout = '' const spawnTarget = resolveSpawnTarget(command, args) const child = spawn(spawnTarget.command, spawnTarget.args, { cwd: rootDir, shell: spawnTarget.shell, stdio: ['ignore', 'pipe', 'inherit'], }) child.stdout.setEncoding('utf8') child.stdout.on('data', (chunk) => { stdout += chunk }) child.on('error', reject) child.on('close', (code, signal) => { if (signal) { reject(Object.assign(new Error(`${command} terminated by signal ${signal}`), { exitCode: 1 })) } else if (code === 0) { resolve(stdout) } else { reject(Object.assign(new Error(`${command} exited with code ${code}`), { exitCode: code ?? 1 })) } }) }) } function resolveCommand (command) { const result = process.platform === 'win32' ? spawnSync('where', [command], { encoding: 'utf8' }) : spawnSync('sh', ['-c', `command -v ${command}`], { encoding: 'utf8' }) if (result.error != null) { throw result.error } if (result.status !== 0) { throw new Error(`Cannot resolve command: ${command}`) } return result.stdout.split(/\r?\n/).find(Boolean) ?? command } // On Windows the command (e.g. `pn`) resolves to a `.cmd` shim, which Node // refuses to spawn without a shell (CVE-2024-27980). But `spawn(cmd, args, // { shell: true })` joins the args into one string without quoting, so an arg // containing a space would be split into separate tokens. Build the quoted // command line ourselves and hand it to cmd.exe as a single string — the same // thing Node does internally for the non-shell Windows path. function resolveSpawnTarget (command, args) { if (process.platform !== 'win32') { return { command, args, shell: false } } validateWindowsShellArgs([command, ...args]) return { command: [command, ...args].map(quoteWindowsArg).join(' '), args: undefined, shell: true, } } function quoteWindowsArg (arg) { return arg === '' || /\s/.test(arg) ? `"${arg}"` : arg } function validateWindowsShellArgs (args) { for (const arg of args) { // `"` is rejected alongside the shell metacharacters so quoteWindowsArg can // wrap spaced args in double quotes without needing to escape inner quotes. if (/[&|<>^%"\r\n]/.test(arg)) { throw new Error(`Cannot run command with Windows shell metacharacters: ${arg}`) } } } function normalizePath (file) { return file.split(path.sep).join('/') } function parseArgs (args) { let chunk = Number(process.env.TEST_CHUNK) let chunks = Number(process.env.TEST_CHUNK_TOTAL) let script = process.env.TEST_SCRIPT let summary = 'pnpm-exec-summary.json' let dryRun = false for (let i = 0; i < args.length; i++) { const arg = args[i] if (arg === '--chunk') { chunk = Number(args[++i]) } else if (arg === '--chunks') { chunks = Number(args[++i]) } else if (arg === '--dry-run') { dryRun = true } else if (arg === '--script') { script = args[++i] } else if (arg === '--summary') { summary = args[++i] } else { usage(`unknown argument: ${arg}`) } } if (script !== 'ci:test-all' && script !== 'ci:test-branch') { usage('missing or unsupported --script') } if (!Number.isInteger(chunk) || chunk < 1) usage('missing or invalid --chunk') if (!Number.isInteger(chunks) || chunks < 1) usage('missing or invalid --chunks') if (chunk > chunks) usage('--chunk cannot be greater than --chunks') return { chunk, chunks, dryRun, script, summary } } function usage (message) { console.error(message) console.error('Usage: run-ts-tests-chunk.mjs --script --chunk --chunks [--summary ] [--dry-run]') console.error('--script, --chunk and --chunks default to $TEST_SCRIPT, $TEST_CHUNK and $TEST_CHUNK_TOTAL.') process.exit(1) }