/
githubmirror
/
angular-cli
Обзор
Документация
Войти
/
githubmirror
/
angular-cli
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
tests/e2e/utils/process.ts
499 строк
14 KB
Alan Agius
refactor: remove `tree-kill` dependency and refactor `killAllProcesses` to use native `childProc.kill`.
03 мар 2026, 19:00
03 мар 2026, 19:00
73186ba
Код
Авторство
О чём код?
import { spawn, SpawnOptions } from 'node:child_process'; import * as child_process from 'node:child_process'; import { getGlobalVariable, getGlobalVariablesEnv } from './env'; import { delimiter, join, resolve } from 'node:path'; import { stripVTControlCharacters, styleText } from 'node:util'; import { assertIsError } from './utils'; interface ExecOptions { silent?: boolean; waitForMatch?: RegExp; env?: NodeJS.ProcessEnv; stdin?: string; cwd?: string; } /** * While `NPM_CONFIG_` and `YARN_` are case insensitive we filter based on case. * This is because when invoking a command using `yarn` it will add a bunch of these variables in lower case. * This causes problems when we try to update the variables during the test setup. */ const NPM_CONFIG_RE = /^(NPM_CONFIG_|YARN_|NO_UPDATE_NOTIFIER)/; let _processes: child_process.ChildProcess[] = []; export type ProcessOutput = { stdout: string; stderr: string; }; function _exec(options: ExecOptions, cmd: string, args: string[]): Promise<ProcessOutput> { const cwd = options.cwd ?? process.cwd(); const env = options.env ?? process.env; console.log( `==========================================================================================`, ); // Ensure the custom npm and yarn global bin is on the PATH // https://docs.npmjs.com/cli/v8/configuring-npm/folders#executables const paths = [ join(getGlobalVariable('yarn-global'), 'bin'), join(getGlobalVariable('npm-global'), process.platform.startsWith('win') ? '' : 'bin'), env.PATH || process.env['PATH'], ].join(delimiter); args = args.filter((x) => x !== undefined); const flags = [ options.silent && 'silent', options.waitForMatch && `matching(${options.waitForMatch})`, ] .filter((x) => !!x) // Remove false and undefined. .join(', ') .replace(/^(.+)$/, ' [$1]'); // Proper formatting. console.log( styleText(['blue'], `Running \`${cmd} ${args.map((x) => `"${x}"`).join(' ')}\`${flags}...`), ); console.log(styleText(['blue'], `CWD: ${cwd}`)); const spawnOptions: SpawnOptions = { cwd, env: { ...env, PATH: paths }, }; if (process.platform.startsWith('win')) { args.unshift('/c', cmd); cmd = 'cmd.exe'; spawnOptions['stdio'] = 'pipe'; } const childProcess = child_process.spawn(cmd, args, spawnOptions); _processes.push(childProcess); // Create the error here so the stack shows who called this function. const error = new Error(); const processPromise = new Promise<ProcessOutput>((resolve, reject) => { let stdout = ''; let stderr = ''; let matched = false; // Return log info about the current process status function envDump() { return `STDOUT:\n${stdout}\n\nSTDERR:\n${stderr}`; } childProcess.stdout!.on('data', (data: Buffer) => { stdout += data.toString('utf-8'); if (options.waitForMatch && stdout.match(options.waitForMatch)) { resolve({ stdout, stderr }); matched = true; } if (options.silent) { return; } data .toString('utf-8') .split(/[\n\r]+/) .filter((line) => line !== '') .forEach((line) => console.log(' ' + line)); }); childProcess.stderr!.on('data', (data: Buffer) => { stderr += data.toString('utf-8'); if (options.waitForMatch && stderr.match(options.waitForMatch)) { resolve({ stdout, stderr }); matched = true; } if (options.silent) { return; } data .toString('utf-8') .split(/[\n\r]+/) .filter((line) => line !== '') .forEach((line) => console.error(styleText(['yellow'], ' ' + line))); }); childProcess.on('close', (code) => { _processes = _processes.filter((p) => p !== childProcess); if (options.waitForMatch && !matched) { reject( `Process output didn't match - "${cmd} ${args.join(' ')}": '${ options.waitForMatch }': ${code}...\n\n${envDump()}\n`, ); return; } if (!code) { resolve({ stdout, stderr }); return; } reject(`Process exit error - "${cmd} ${args.join(' ')}": ${code}...\n\n${envDump()}\n`); }); childProcess.on('error', (err) => { reject(`Process error - "${cmd} ${args.join(' ')}": ${err}...\n\n${envDump()}\n`); }); // Provide input to stdin if given. if (options.stdin) { childProcess.stdin!.write(options.stdin); childProcess.stdin!.end(); } }).catch((err) => { error.message = err.toString(); return Promise.reject(error); }); if (!options.waitForMatch) { return processPromise; } let timeout: NodeJS.Timeout | undefined; const timeoutPromise: Promise<never> = new Promise((_resolve, reject) => { // Wait for 60 seconds and timeout. const duration = 60_000; timeout = setTimeout(() => { reject( new Error(`Waiting for ${options.waitForMatch} timed out (timeout: ${duration}msec)...`), ); }, duration); }); return Promise.race([timeoutPromise, processPromise]).finally( () => timeout && clearTimeout(timeout), ); } export function extractNpmEnv() { return Object.keys(process.env) .filter((v) => NPM_CONFIG_RE.test(v)) .reduce<NodeJS.ProcessEnv>((vars, n) => { vars[n] = process.env[n]; return vars; }, {}); } export function extractCIAndInfraEnv(): NodeJS.ProcessEnv { return Object.keys(process.env) .filter( (v) => v.startsWith('SAUCE_') || v === 'CI' || v === 'CHROME_BIN' || v === 'CHROME_PATH' || v === 'CHROMEDRIVER_BIN' || v.startsWith('JS_BINARY__'), ) .reduce<NodeJS.ProcessEnv>((vars, n) => { vars[n] = process.env[n]; return vars; }, {}); } function extractNgEnv() { return Object.keys(process.env) .filter((v) => v.startsWith('NG_')) .reduce<NodeJS.ProcessEnv>((vars, n) => { vars[n] = process.env[n]; return vars; }, {}); } export async function waitForAnyProcessOutputToMatch( match: RegExp, timeout = 30000, ): Promise<ProcessOutput> { let timeoutId: ReturnType<typeof setTimeout> | null = null; // Race between _all_ processes, and the timeout. First one to resolve/reject wins. const timeoutPromise: Promise<ProcessOutput> = new Promise((_resolve, reject) => { // Wait for 30 seconds and timeout. timeoutId = setTimeout(() => { reject(new Error(`Waiting for ${match} timed out (timeout: ${timeout}msec)...`)); }, timeout); }); const matchPromises: Promise<ProcessOutput>[] = _processes.map( (childProcess) => new Promise((resolve) => { let stdout = ''; let stderr = ''; childProcess.stdout!.on('data', (data: Buffer) => { stdout += data.toString(); if (stripVTControlCharacters(stdout).match(match)) { resolve({ stdout, stderr }); } }); childProcess.stderr!.on('data', (data: Buffer) => { stderr += data.toString(); if (stripVTControlCharacters(stderr).match(match)) { resolve({ stdout, stderr }); } }); }), ); const matchingProcess = await Promise.race(matchPromises.concat([timeoutPromise])); if (timeoutId !== null) { clearTimeout(timeoutId); } return matchingProcess; } /** * Kills a process by PID * @param pid The PID of the process to kill * @param signal The signal to send to the process */ async function killProcess(pid: number, signal: NodeJS.Signals): Promise<void> { if (process.platform === 'win32') { // /T kills child processes, /F forces it await new Promise<void>((resolve) => { child_process.exec(`taskkill /pid ${pid} /T /F`, () => resolve()); }); } else { // Use -pid to signal the entire process group try { process.kill(-pid, signal); } catch (error) { assertIsError(error); if (error.code !== 'ESRCH') { throw error; } } } } /** * Kills all tracked processes */ export async function killAllProcesses(signal: NodeJS.Signals = 'SIGTERM'): Promise<void> { const processesToKill: Promise<void>[] = []; while (_processes.length) { const childProc = _processes.pop(); if (!childProc || childProc.pid === undefined || childProc.killed) { continue; } processesToKill.push(killProcess(childProc.pid, signal)); } await Promise.all(processesToKill); } export function exec(cmd: string, ...args: string[]) { return _exec({}, cmd, args); } export function silentExec(cmd: string, ...args: string[]) { return _exec({ silent: true }, cmd, args); } export function execWithEnv(cmd: string, args: string[], env: NodeJS.ProcessEnv, stdin?: string) { return _exec({ env, stdin }, cmd, args); } export async function execAndCaptureError( cmd: string, args: string[], env?: NodeJS.ProcessEnv, stdin?: string, ): Promise<Error> { try { await _exec({ env, stdin }, cmd, args); throw new Error('Tried to capture subprocess exception, but it completed successfully.'); } catch (err) { if (err instanceof Error) { return err; } throw new Error('Subprocess exception was not an Error instance'); } } export async function execAndWaitForOutputToMatch( cmd: string, args: string[], match: RegExp, env?: NodeJS.ProcessEnv, ) { if (cmd === 'ng' && args[0] === 'serve') { // Accept matches up to 20 times after the initial match. // Useful because the Webpack watcher can rebuild a few times due to files changes that // happened just before the build (e.g. `git clean`). // This seems to be due to host file system differences, see // https://nodejs.org/docs/latest/api/fs.html#fs_caveats const maxRetries = 20; let lastResult = await _exec({ waitForMatch: match, env }, cmd, args); for (let i = 0; i < maxRetries; i++) { try { lastResult = await waitForAnyProcessOutputToMatch(match, 2500); } catch { // If we timeout (no new match found), we assume the process is stable. break; } } return lastResult; } else { return _exec({ waitForMatch: match, env }, cmd, args); } } export function ng(...args: string[]) { const argv = getGlobalVariable('argv'); const maybeSilentNg = argv['nosilent'] ? noSilentNg : silentNg; if (['build', 'serve', 'test', 'e2e', 'extract-i18n'].indexOf(args[0]) != -1) { if (args[0] == 'e2e') { // Wait 1 second before running any end-to-end test. return new Promise((resolve) => setTimeout(resolve, 1000)).then(() => maybeSilentNg(...args)); } return maybeSilentNg(...args); } else { return noSilentNg(...args); } } export function noSilentNg(...args: string[]) { return _exec({}, 'ng', args); } export function silentNg(...args: string[]) { return _exec({ silent: true }, 'ng', args); } export function silentNpm(...args: string[]): Promise<ProcessOutput>; export function silentNpm(args: string[], options?: { cwd?: string }): Promise<ProcessOutput>; export function silentNpm( ...args: string[] | [args: string[], options?: { cwd?: string }] ): Promise<ProcessOutput> { if (Array.isArray(args[0])) { const [params, options] = args; return _exec( { silent: true, cwd: (options as { cwd?: string } | undefined)?.cwd, }, 'npm', params, ); } else { return _exec({ silent: true }, 'npm', args as string[]); } } export function silentYarn(...args: string[]) { return _exec({ silent: true }, 'yarn', args); } export function silentPnpm(...args: string[]) { return _exec({ silent: true }, 'pnpm', args); } export function silentBun(...args: string[]) { return _exec({ silent: true }, 'bun', args); } export function globalNpm(args: string[], env?: NodeJS.ProcessEnv) { if (!process.env.LEGACY_CLI_RUNNER) { throw new Error( 'The global npm cli should only be executed from the primary e2e runner process', ); } return _exec({ silent: true, env }, 'npm', args); } export function node(...args: string[]) { return _exec({}, process.execPath, args); } export function git(...args: string[]) { return _exec({}, process.env.GIT_BIN || 'git', args); } export function silentGit(...args: string[]) { return _exec({ silent: true }, process.env.GIT_BIN || 'git', args); } /** * Launch the given entry in an child process isolated to the test environment. * * The test environment includes the local NPM registry, isolated NPM globals, * the PATH variable only referencing the local node_modules and local NPM * registry (not the test runner or standard global node_modules). */ export async function launchTestProcess(entry: string, ...args: any[]): Promise<void> { // NOTE: do NOT use the bazel TEST_TMPDIR. When sandboxing is not enabled the // TEST_TMPDIR is not sandboxed and has symlinks into the src dir in a // parent directory. Symlinks into the src dir will include package.json, // .git and other files/folders that may effect e2e tests. const tempRoot: string = getGlobalVariable('tmp-root'); const TEMP = process.env.TEMP ?? process.env.TMPDIR ?? tempRoot; // Extract explicit environment variables for the test process. const env: NodeJS.ProcessEnv = { TEMP, TMPDIR: TEMP, HOME: TEMP, // Use BAZEL_TARGET as a metadata variable to show it is a // process managed by bazel BAZEL_TARGET: process.env.BAZEL_TARGET, ...extractNpmEnv(), ...extractCIAndInfraEnv(), ...extractNgEnv(), ...getGlobalVariablesEnv(), }; // Only include paths within the sandboxed test environment or external // non angular-cli paths such as /usr/bin for generic commands. env.PATH = process.env .PATH!.split(delimiter) .filter((p) => p.startsWith(tempRoot) || p.startsWith(TEMP) || !p.includes('angular-cli')) .join(delimiter); const testProcessArgs = [ // Note: `__dirname` is the bundle directory here. resolve(__dirname, 'e2e/utils/test_process.js'), entry, ...args, ]; return new Promise<void>((resolve, reject) => { spawn(process.execPath, testProcessArgs, { stdio: 'inherit', env, }) .on('close', (code) => { if (!code) { resolve(); return; } reject(`Process error - "${testProcessArgs}`); }) .on('error', (err) => { reject(`Process exit error - "${testProcessArgs}]\n\n${err}`); }); }); }