From 8aeb5a328f6801920c576bf5b0c7cb619e22f6c2 Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Tue, 5 Nov 2024 09:45:27 -0700 Subject: [PATCH 1/2] feat(ng-dev): create workflow performance testing tooling Creates a piece of tooling within ng-dev that allows for a set of commands to be run that emulate an expected workflow within a repository. This is then measured for the time it takes to run these commands so they can be checked or tracked over time. --- .github/workflows/ci.yml | 16 ++++++++ .ng-dev/perf-tests/test-rerun.diff | 17 +++++++++ .ng-dev/workflows.yml | 17 +++++++++ ng-dev/BUILD.bazel | 1 + ng-dev/cli.ts | 2 + ng-dev/perf/BUILD.bazel | 11 ++++++ ng-dev/perf/cli.ts | 16 ++++++++ ng-dev/perf/workflow/BUILD.bazel | 13 +++++++ ng-dev/perf/workflow/cli.ts | 55 +++++++++++++++++++++++++++ ng-dev/perf/workflow/loader.ts | 14 +++++++ ng-dev/perf/workflow/workflow.ts | 60 ++++++++++++++++++++++++++++++ ng-dev/utils/spinner.ts | 23 ++++++++++-- 12 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 .ng-dev/perf-tests/test-rerun.diff create mode 100644 .ng-dev/workflows.yml create mode 100644 ng-dev/perf/BUILD.bazel create mode 100644 ng-dev/perf/cli.ts create mode 100644 ng-dev/perf/workflow/BUILD.bazel create mode 100644 ng-dev/perf/workflow/cli.ts create mode 100644 ng-dev/perf/workflow/loader.ts create mode 100644 ng-dev/perf/workflow/workflow.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38329908e..47ccfac95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,3 +71,19 @@ jobs: - uses: ./github-actions/bazel/setup - run: yarn install --immutable - run: yarn bazel test --sandbox_writable_path="$HOME/Library/Application Support" --test_tag_filters=macos --build_tests_only -- //... + + workflow-perf: + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + # Because the checkout and setup node action is contained in the dev-infra repo, we must + # checkout the repo to be able to run the action we have created. Other repos will skip + # this step. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: ./github-actions/npm/checkout-and-setup-node + - uses: ./github-actions/bazel/setup + - run: yarn install --immutable + - run: yarn ng-dev perf workflows --json + # Always run this step to ensure that the job always is successful + - if: ${{ always() }} + run: exit 0 diff --git a/.ng-dev/perf-tests/test-rerun.diff b/.ng-dev/perf-tests/test-rerun.diff new file mode 100644 index 000000000..a4fa8192a --- /dev/null +++ b/.ng-dev/perf-tests/test-rerun.diff @@ -0,0 +1,17 @@ +diff --git a/ng-dev/utils/test/g3.spec.ts b/ng-dev/utils/test/g3.spec.ts +index a82c1b7a..8e0b24f8 100644 +--- a/ng-dev/utils/test/g3.spec.ts ++++ b/ng-dev/utils/test/g3.spec.ts +@@ -29,9 +29,9 @@ describe('G3Stats', () => { + }); + + function setupFakeSyncConfig(config: GoogleSyncConfig): string { +- const configFileName = 'sync-test-conf.json'; +- fs.writeFileSync(path.join(git.baseDir, configFileName), JSON.stringify(config)); +- return configFileName; ++ const somethingelse = 'sync-test-conf.json'; ++ fs.writeFileSync(path.join(git.baseDir, somethingelse), JSON.stringify(config)); ++ return somethingelse; + } + + describe('gathering stats', () => { diff --git a/.ng-dev/workflows.yml b/.ng-dev/workflows.yml new file mode 100644 index 000000000..fb630ea41 --- /dev/null +++ b/.ng-dev/workflows.yml @@ -0,0 +1,17 @@ +workflows: + - name: Rerun a test + prepare: | + bazel clean; + bazel build //ng-dev/utils/test; + workflow: | + bazel test //ng-dev/utils/test; + git apply .ng-dev/perf-tests/test-rerun.diff; + bazel test //ng-dev/utils/test; + cleanup: | + git apply -R .ng-dev/perf-tests/test-rerun.diff; + + - name: Build Everything + prepare: | + bazel clean; + workflow: | + bazel build //...; diff --git a/ng-dev/BUILD.bazel b/ng-dev/BUILD.bazel index 0c91acd89..7e5c5448d 100644 --- a/ng-dev/BUILD.bazel +++ b/ng-dev/BUILD.bazel @@ -27,6 +27,7 @@ ts_library( "//ng-dev/format", "//ng-dev/misc", "//ng-dev/ngbot", + "//ng-dev/perf", "//ng-dev/pr", "//ng-dev/pr/common/labels", "//ng-dev/pr/config", diff --git a/ng-dev/cli.ts b/ng-dev/cli.ts index afd7162aa..10fdd29f4 100644 --- a/ng-dev/cli.ts +++ b/ng-dev/cli.ts @@ -20,6 +20,7 @@ import {buildReleaseParser} from './release/cli.js'; import {tsCircularDependenciesBuilder} from './ts-circular-dependencies/index.js'; import {captureLogOutputForCommand} from './utils/logging.js'; import {buildAuthParser} from './auth/cli.js'; +import {buildPerfParser} from './perf/cli.js'; import {Argv} from 'yargs'; runParserWithCompletedFunctions((yargs: Argv) => { @@ -38,6 +39,7 @@ runParserWithCompletedFunctions((yargs: Argv) => { .command('caretaker ', '', buildCaretakerParser) .command('misc ', '', buildMiscParser) .command('ngbot ', false, buildNgbotParser) + .command('perf ', '', buildPerfParser) .wrap(120) .strict(); }); diff --git a/ng-dev/perf/BUILD.bazel b/ng-dev/perf/BUILD.bazel new file mode 100644 index 000000000..aa6eb47a2 --- /dev/null +++ b/ng-dev/perf/BUILD.bazel @@ -0,0 +1,11 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "perf", + srcs = ["cli.ts"], + visibility = ["//ng-dev:__subpackages__"], + deps = [ + "//ng-dev/perf/workflow", + "@npm//@types/yargs", + ], +) diff --git a/ng-dev/perf/cli.ts b/ng-dev/perf/cli.ts new file mode 100644 index 000000000..b6639795b --- /dev/null +++ b/ng-dev/perf/cli.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Argv} from 'yargs'; + +import {WorkflowsModule} from './workflow/cli.js'; + +/** Build the parser for pull request commands. */ +export function buildPerfParser(localYargs: Argv) { + return localYargs.help().strict().demandCommand().command(WorkflowsModule); +} diff --git a/ng-dev/perf/workflow/BUILD.bazel b/ng-dev/perf/workflow/BUILD.bazel new file mode 100644 index 000000000..8359847f6 --- /dev/null +++ b/ng-dev/perf/workflow/BUILD.bazel @@ -0,0 +1,13 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "workflow", + srcs = glob(["*.ts"]), + visibility = ["//ng-dev:__subpackages__"], + deps = [ + "//ng-dev/utils", + "@npm//@types/node", + "@npm//@types/yargs", + "@npm//yaml", + ], +) diff --git a/ng-dev/perf/workflow/cli.ts b/ng-dev/perf/workflow/cli.ts new file mode 100644 index 000000000..dd58caf6c --- /dev/null +++ b/ng-dev/perf/workflow/cli.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Argv, CommandModule} from 'yargs'; +import {measureWorkflow} from './workflow.js'; +import {loadWorkflows} from './loader.js'; +import {join} from 'path'; +import {determineRepoBaseDirFromCwd} from '../../utils/repo-directory.js'; + +interface WorkflowsParams { + configFile: string; + json: boolean; +} + +/** Builds the checkout pull request command. */ +function builder(yargs: Argv) { + return yargs + .option('config-file' as 'configFile', { + default: '.ng-dev/workflows.yml', + type: 'string', + description: 'The path to the workflow definitions in a yml file', + }) + .option('json', { + default: false, + type: 'boolean', + description: 'Whether to ouput the results as a json object', + }); +} + +/** Handles the checkout pull request command. */ +async function handler({configFile, json}: WorkflowsParams) { + const workflows = await loadWorkflows(join(determineRepoBaseDirFromCwd(), configFile)); + const results: {[key: string]: number} = {}; + for (const workflow of workflows) { + const {name, duration} = await measureWorkflow(workflow); + results[name] = duration; + } + + if (json) { + process.stdout.write(JSON.stringify(results)); + } +} + +/** yargs command module for checking out a PR */ +export const WorkflowsModule: CommandModule<{}, WorkflowsParams> = { + handler, + builder, + command: 'workflows', + describe: 'Evaluate the performance of the provided workflows', +}; diff --git a/ng-dev/perf/workflow/loader.ts b/ng-dev/perf/workflow/loader.ts new file mode 100644 index 000000000..0a4b57ca3 --- /dev/null +++ b/ng-dev/perf/workflow/loader.ts @@ -0,0 +1,14 @@ +import {readFile} from 'fs/promises'; +import {parse} from 'yaml'; + +export interface Workflow { + name: string; + workflow: string; + prepare?: string; + cleanup?: string; +} + +export async function loadWorkflows(src: string) { + const rawWorkflows = await readFile(src, {encoding: 'utf-8'}); + return parse(rawWorkflows).workflows as Workflow[]; +} diff --git a/ng-dev/perf/workflow/workflow.ts b/ng-dev/perf/workflow/workflow.ts new file mode 100644 index 000000000..4938a6772 --- /dev/null +++ b/ng-dev/perf/workflow/workflow.ts @@ -0,0 +1,60 @@ +import {ChildProcess} from '../../utils/child-process.js'; +import {green} from '../../utils/logging.js'; +import {Spinner} from '../../utils/spinner.js'; +import {Workflow} from './loader.js'; + +export async function measureWorkflow({name, workflow, prepare, cleanup}: Workflow) { + const spinner = new Spinner(''); + try { + if (prepare) { + spinner.update('Preparing environment for workflow execution'); + // Run the `prepare` commands to establish the environment, caching, etc prior to running the + // workflow. + await runCommands(prepare); + spinner.update('Environment preperation completed'); + } + + spinner.update(`Executing workflow (${name})`); + // Mark the start time of the workflow, execute all of the commands provided in the workflow and + // then mark the ending time. + performance.mark('start'); + await runCommands(workflow); + performance.mark('end'); + + spinner.update('Workflow completed'); + + if (cleanup) { + spinner.update('Cleaning up environment after workflow'); + // Run the clean up commands to reset the environment and undo changes made during the workflow. + await runCommands(cleanup); + spinner.update('Environment cleanup complete'); + } + + const results = performance.measure(name, 'start', 'end'); + + spinner.complete(` ${green('✓')} ${name}: ${results.duration.toFixed(2)}ms`); + + return results.toJSON(); + } finally { + spinner.complete(); + } +} + +/** + * Run a set of commands provided as a multiline text block. Commands are assumed to always be + * provided on a single line. + */ +async function runCommands(cmds?: string) { + cmds = cmds?.trim(); + if (!cmds) { + return; + } + let commands = cmds + .split('\n') + .filter((_) => !!_) + .map((cmdStr: string) => cmdStr.trim().split(' ')); + + for (let [cmd, ...args] of commands) { + await ChildProcess.spawn(cmd, args, {mode: 'silent'}); + } +} diff --git a/ng-dev/utils/spinner.ts b/ng-dev/utils/spinner.ts index 9f9a0b045..af614f303 100644 --- a/ng-dev/utils/spinner.ts +++ b/ng-dev/utils/spinner.ts @@ -14,15 +14,20 @@ const hideCursor = '\x1b[?25l'; const showCursor = '\x1b[?25h'; export class Spinner { + /** Whether the spinner is currently running. */ + private isRunning = true; /** The id of the interval being used to trigger frame printing. */ private intervalId = setInterval(() => this.printFrame(), 125); /** The characters to iterate through to create the appearance of spinning in the spinner. */ private spinnerCharacters = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; /** The index of the spinner character used in the frame. */ private currentSpinnerCharacterIndex = 0; + /** The current text of the spinner. */ + private text: string = ''; - constructor(private text: string) { + constructor(text: string) { process.stdout.write(hideCursor); + this.update(text); } /** Get the next spinner character. */ @@ -44,12 +49,24 @@ export class Spinner { /** Updates the spinner text with the provided text. */ update(text: string) { this.text = text; + this.printFrame(this.spinnerCharacters[this.currentSpinnerCharacterIndex]); } /** Completes the spinner. */ - complete() { + complete(): void; + complete(text: string): void; + complete(text?: string) { + if (!this.isRunning) { + return; + } clearInterval(this.intervalId); - process.stdout.write('\n'); + clearLine(process.stdout, 1); + cursorTo(process.stdout, 0); + if (text) { + process.stdout.write(text); + process.stdout.write('\n'); + } process.stdout.write(showCursor); + this.isRunning = false; } } From 36d4fe5df0b0f2dce6d3ec9a7bd9b24a40eafebf Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Wed, 6 Nov 2024 08:47:07 -0700 Subject: [PATCH 2/2] fixup! feat(ng-dev): create workflow performance testing tooling --- .github/local-actions/branch-manager/main.js | 47 +++++++++++-- .github/local-actions/changelog/main.js | 47 +++++++++++-- .ng-dev/dx-perf-workflows.yml | 17 +++++ .ng-dev/workflows.yml | 17 ----- github-actions/create-pr-for-changes/main.js | 47 +++++++++++-- github-actions/slash-commands/main.js | 47 +++++++++++-- ng-dev/perf/workflow/cli.ts | 6 +- ng-dev/perf/workflow/loader.ts | 6 +- ng-dev/perf/workflow/workflow.ts | 14 ++-- ng-dev/utils/child-process.ts | 73 +++++++++++++++++++- 10 files changed, 269 insertions(+), 52 deletions(-) create mode 100644 .ng-dev/dx-perf-workflows.yml delete mode 100644 .ng-dev/workflows.yml diff --git a/.github/local-actions/branch-manager/main.js b/.github/local-actions/branch-manager/main.js index ece970a0d..1ce660fdd 100644 --- a/.github/local-actions/branch-manager/main.js +++ b/.github/local-actions/branch-manager/main.js @@ -56692,7 +56692,7 @@ var supportsColor2 = { var supports_color_default2 = supportsColor2; // -import { spawn as _spawn, spawnSync as _spawnSync } from "child_process"; +import { spawn as _spawn, spawnSync as _spawnSync, exec as _exec } from "child_process"; var ChildProcess = class { static spawnInteractive(command, args, options = {}) { return new Promise((resolve, reject) => { @@ -56706,7 +56706,7 @@ var ChildProcess = class { return new Promise((resolve, reject) => { const commandText = `${command} ${args.join(" ")}`; const outputMode = options.mode; - const env3 = getEnvironmentForNonInteractiveSpawn(options.env); + const env3 = getEnvironmentForNonInteractiveCommand(options.env); Log.debug(`Executing command: ${commandText}`); const childProcess = _spawn(command, args, { ...options, env: env3, shell: true, stdio: "pipe" }); let logOutput = ""; @@ -56747,7 +56747,7 @@ ${logOutput}`); } static spawnSync(command, args, options = {}) { const commandText = `${command} ${args.join(" ")}`; - const env3 = getEnvironmentForNonInteractiveSpawn(options.env); + const env3 = getEnvironmentForNonInteractiveCommand(options.env); Log.debug(`Executing command: ${commandText}`); const { status: exitCode, signal, stdout, stderr } = _spawnSync(command, args, { ...options, env: env3, encoding: "utf8", shell: true, stdio: "pipe" }); const status = statusFromExitCodeAndSignal(exitCode, signal); @@ -56756,11 +56756,50 @@ ${logOutput}`); } throw new Error(stderr); } + static exec(command, options = {}) { + return new Promise((resolve, reject) => { + var _a2, _b; + const outputMode = options.mode; + const env3 = getEnvironmentForNonInteractiveCommand(options.env); + Log.debug(`Executing command: ${command}`); + const childProcess = _exec(command, { ...options, env: env3 }); + let logOutput = ""; + let stdout = ""; + let stderr = ""; + (_a2 = childProcess.stderr) == null ? void 0 : _a2.on("data", (message) => { + stderr += message; + logOutput += message; + if (outputMode === void 0 || outputMode === "enabled") { + process.stderr.write(message); + } + }); + (_b = childProcess.stdout) == null ? void 0 : _b.on("data", (message) => { + stdout += message; + logOutput += message; + if (outputMode === void 0 || outputMode === "enabled") { + process.stderr.write(message); + } + }); + childProcess.on("close", (exitCode, signal) => { + const exitDescription = exitCode !== null ? `exit code "${exitCode}"` : `signal "${signal}"`; + const printFn = outputMode === "on-error" ? Log.error : Log.debug; + const status = statusFromExitCodeAndSignal(exitCode, signal); + printFn(`Command "${command}" completed with ${exitDescription}.`); + printFn(`Process output: +${logOutput}`); + if (status === 0 || options.suppressErrorOnFailingExitCode) { + resolve({ stdout, stderr, status }); + } else { + reject(outputMode === "silent" ? logOutput : void 0); + } + }); + }); + } }; function statusFromExitCodeAndSignal(exitCode, signal) { return exitCode ?? signal ?? -1; } -function getEnvironmentForNonInteractiveSpawn(userProvidedEnv) { +function getEnvironmentForNonInteractiveCommand(userProvidedEnv) { const forceColorValue = supports_color_default2.stdout !== false ? supports_color_default2.stdout.level.toString() : void 0; return { FORCE_COLOR: forceColorValue, ...userProvidedEnv ?? process.env }; } diff --git a/.github/local-actions/changelog/main.js b/.github/local-actions/changelog/main.js index c0446c062..9e03973f9 100644 --- a/.github/local-actions/changelog/main.js +++ b/.github/local-actions/changelog/main.js @@ -56565,7 +56565,7 @@ var supportsColor2 = { var supports_color_default2 = supportsColor2; // -import { spawn as _spawn, spawnSync as _spawnSync } from "child_process"; +import { spawn as _spawn, spawnSync as _spawnSync, exec as _exec } from "child_process"; var ChildProcess = class { static spawnInteractive(command, args, options = {}) { return new Promise((resolve, reject) => { @@ -56579,7 +56579,7 @@ var ChildProcess = class { return new Promise((resolve, reject) => { const commandText = `${command} ${args.join(" ")}`; const outputMode = options.mode; - const env3 = getEnvironmentForNonInteractiveSpawn(options.env); + const env3 = getEnvironmentForNonInteractiveCommand(options.env); Log.debug(`Executing command: ${commandText}`); const childProcess = _spawn(command, args, { ...options, env: env3, shell: true, stdio: "pipe" }); let logOutput = ""; @@ -56620,7 +56620,7 @@ ${logOutput}`); } static spawnSync(command, args, options = {}) { const commandText = `${command} ${args.join(" ")}`; - const env3 = getEnvironmentForNonInteractiveSpawn(options.env); + const env3 = getEnvironmentForNonInteractiveCommand(options.env); Log.debug(`Executing command: ${commandText}`); const { status: exitCode, signal, stdout, stderr } = _spawnSync(command, args, { ...options, env: env3, encoding: "utf8", shell: true, stdio: "pipe" }); const status = statusFromExitCodeAndSignal(exitCode, signal); @@ -56629,11 +56629,50 @@ ${logOutput}`); } throw new Error(stderr); } + static exec(command, options = {}) { + return new Promise((resolve, reject) => { + var _a2, _b; + const outputMode = options.mode; + const env3 = getEnvironmentForNonInteractiveCommand(options.env); + Log.debug(`Executing command: ${command}`); + const childProcess = _exec(command, { ...options, env: env3 }); + let logOutput = ""; + let stdout = ""; + let stderr = ""; + (_a2 = childProcess.stderr) == null ? void 0 : _a2.on("data", (message) => { + stderr += message; + logOutput += message; + if (outputMode === void 0 || outputMode === "enabled") { + process.stderr.write(message); + } + }); + (_b = childProcess.stdout) == null ? void 0 : _b.on("data", (message) => { + stdout += message; + logOutput += message; + if (outputMode === void 0 || outputMode === "enabled") { + process.stderr.write(message); + } + }); + childProcess.on("close", (exitCode, signal) => { + const exitDescription = exitCode !== null ? `exit code "${exitCode}"` : `signal "${signal}"`; + const printFn = outputMode === "on-error" ? Log.error : Log.debug; + const status = statusFromExitCodeAndSignal(exitCode, signal); + printFn(`Command "${command}" completed with ${exitDescription}.`); + printFn(`Process output: +${logOutput}`); + if (status === 0 || options.suppressErrorOnFailingExitCode) { + resolve({ stdout, stderr, status }); + } else { + reject(outputMode === "silent" ? logOutput : void 0); + } + }); + }); + } }; function statusFromExitCodeAndSignal(exitCode, signal) { return exitCode ?? signal ?? -1; } -function getEnvironmentForNonInteractiveSpawn(userProvidedEnv) { +function getEnvironmentForNonInteractiveCommand(userProvidedEnv) { const forceColorValue = supports_color_default2.stdout !== false ? supports_color_default2.stdout.level.toString() : void 0; return { FORCE_COLOR: forceColorValue, ...userProvidedEnv ?? process.env }; } diff --git a/.ng-dev/dx-perf-workflows.yml b/.ng-dev/dx-perf-workflows.yml new file mode 100644 index 000000000..a1ebf0af7 --- /dev/null +++ b/.ng-dev/dx-perf-workflows.yml @@ -0,0 +1,17 @@ +workflows: + - name: Rerun a test + prepare: + - bazel clean; + - bazel build //ng-dev/utils/test; + workflow: + - bazel test //ng-dev/utils/test; + - git apply .ng-dev/perf-tests/test-rerun.diff; + - bazel test //ng-dev/utils/test; + cleanup: + - git apply -R .ng-dev/perf-tests/test-rerun.diff; + + - name: Build Everything + prepare: + - bazel clean; + workflow: + - bazel build //...; diff --git a/.ng-dev/workflows.yml b/.ng-dev/workflows.yml deleted file mode 100644 index fb630ea41..000000000 --- a/.ng-dev/workflows.yml +++ /dev/null @@ -1,17 +0,0 @@ -workflows: - - name: Rerun a test - prepare: | - bazel clean; - bazel build //ng-dev/utils/test; - workflow: | - bazel test //ng-dev/utils/test; - git apply .ng-dev/perf-tests/test-rerun.diff; - bazel test //ng-dev/utils/test; - cleanup: | - git apply -R .ng-dev/perf-tests/test-rerun.diff; - - - name: Build Everything - prepare: | - bazel clean; - workflow: | - bazel build //...; diff --git a/github-actions/create-pr-for-changes/main.js b/github-actions/create-pr-for-changes/main.js index f2827a59d..76e99a1b3 100644 --- a/github-actions/create-pr-for-changes/main.js +++ b/github-actions/create-pr-for-changes/main.js @@ -41501,7 +41501,7 @@ var supportsColor2 = { var supports_color_default2 = supportsColor2; // -import { spawn as _spawn, spawnSync as _spawnSync } from "child_process"; +import { spawn as _spawn, spawnSync as _spawnSync, exec as _exec } from "child_process"; var ChildProcess = class { static spawnInteractive(command, args, options = {}) { return new Promise((resolve, reject) => { @@ -41515,7 +41515,7 @@ var ChildProcess = class { return new Promise((resolve, reject) => { const commandText = `${command} ${args.join(" ")}`; const outputMode = options.mode; - const env3 = getEnvironmentForNonInteractiveSpawn(options.env); + const env3 = getEnvironmentForNonInteractiveCommand(options.env); Log.debug(`Executing command: ${commandText}`); const childProcess = _spawn(command, args, { ...options, env: env3, shell: true, stdio: "pipe" }); let logOutput = ""; @@ -41556,7 +41556,7 @@ ${logOutput}`); } static spawnSync(command, args, options = {}) { const commandText = `${command} ${args.join(" ")}`; - const env3 = getEnvironmentForNonInteractiveSpawn(options.env); + const env3 = getEnvironmentForNonInteractiveCommand(options.env); Log.debug(`Executing command: ${commandText}`); const { status: exitCode, signal, stdout, stderr } = _spawnSync(command, args, { ...options, env: env3, encoding: "utf8", shell: true, stdio: "pipe" }); const status = statusFromExitCodeAndSignal(exitCode, signal); @@ -41565,11 +41565,50 @@ ${logOutput}`); } throw new Error(stderr); } + static exec(command, options = {}) { + return new Promise((resolve, reject) => { + var _a, _b; + const outputMode = options.mode; + const env3 = getEnvironmentForNonInteractiveCommand(options.env); + Log.debug(`Executing command: ${command}`); + const childProcess = _exec(command, { ...options, env: env3 }); + let logOutput = ""; + let stdout = ""; + let stderr = ""; + (_a = childProcess.stderr) == null ? void 0 : _a.on("data", (message) => { + stderr += message; + logOutput += message; + if (outputMode === void 0 || outputMode === "enabled") { + process.stderr.write(message); + } + }); + (_b = childProcess.stdout) == null ? void 0 : _b.on("data", (message) => { + stdout += message; + logOutput += message; + if (outputMode === void 0 || outputMode === "enabled") { + process.stderr.write(message); + } + }); + childProcess.on("close", (exitCode, signal) => { + const exitDescription = exitCode !== null ? `exit code "${exitCode}"` : `signal "${signal}"`; + const printFn = outputMode === "on-error" ? Log.error : Log.debug; + const status = statusFromExitCodeAndSignal(exitCode, signal); + printFn(`Command "${command}" completed with ${exitDescription}.`); + printFn(`Process output: +${logOutput}`); + if (status === 0 || options.suppressErrorOnFailingExitCode) { + resolve({ stdout, stderr, status }); + } else { + reject(outputMode === "silent" ? logOutput : void 0); + } + }); + }); + } }; function statusFromExitCodeAndSignal(exitCode, signal) { return exitCode ?? signal ?? -1; } -function getEnvironmentForNonInteractiveSpawn(userProvidedEnv) { +function getEnvironmentForNonInteractiveCommand(userProvidedEnv) { const forceColorValue = supports_color_default2.stdout !== false ? supports_color_default2.stdout.level.toString() : void 0; return { FORCE_COLOR: forceColorValue, ...userProvidedEnv ?? process.env }; } diff --git a/github-actions/slash-commands/main.js b/github-actions/slash-commands/main.js index 6bb42a099..3730f91cc 100644 --- a/github-actions/slash-commands/main.js +++ b/github-actions/slash-commands/main.js @@ -53425,7 +53425,7 @@ var supportsColor2 = { var supports_color_default2 = supportsColor2; // -import { spawn as _spawn, spawnSync as _spawnSync } from "child_process"; +import { spawn as _spawn, spawnSync as _spawnSync, exec as _exec } from "child_process"; var ChildProcess = class { static spawnInteractive(command, args, options = {}) { return new Promise((resolve, reject) => { @@ -53439,7 +53439,7 @@ var ChildProcess = class { return new Promise((resolve, reject) => { const commandText = `${command} ${args.join(" ")}`; const outputMode = options.mode; - const env3 = getEnvironmentForNonInteractiveSpawn(options.env); + const env3 = getEnvironmentForNonInteractiveCommand(options.env); Log.debug(`Executing command: ${commandText}`); const childProcess = _spawn(command, args, { ...options, env: env3, shell: true, stdio: "pipe" }); let logOutput = ""; @@ -53480,7 +53480,7 @@ ${logOutput}`); } static spawnSync(command, args, options = {}) { const commandText = `${command} ${args.join(" ")}`; - const env3 = getEnvironmentForNonInteractiveSpawn(options.env); + const env3 = getEnvironmentForNonInteractiveCommand(options.env); Log.debug(`Executing command: ${commandText}`); const { status: exitCode, signal, stdout, stderr } = _spawnSync(command, args, { ...options, env: env3, encoding: "utf8", shell: true, stdio: "pipe" }); const status = statusFromExitCodeAndSignal(exitCode, signal); @@ -53489,11 +53489,50 @@ ${logOutput}`); } throw new Error(stderr); } + static exec(command, options = {}) { + return new Promise((resolve, reject) => { + var _a2, _b; + const outputMode = options.mode; + const env3 = getEnvironmentForNonInteractiveCommand(options.env); + Log.debug(`Executing command: ${command}`); + const childProcess = _exec(command, { ...options, env: env3 }); + let logOutput = ""; + let stdout = ""; + let stderr = ""; + (_a2 = childProcess.stderr) == null ? void 0 : _a2.on("data", (message) => { + stderr += message; + logOutput += message; + if (outputMode === void 0 || outputMode === "enabled") { + process.stderr.write(message); + } + }); + (_b = childProcess.stdout) == null ? void 0 : _b.on("data", (message) => { + stdout += message; + logOutput += message; + if (outputMode === void 0 || outputMode === "enabled") { + process.stderr.write(message); + } + }); + childProcess.on("close", (exitCode, signal) => { + const exitDescription = exitCode !== null ? `exit code "${exitCode}"` : `signal "${signal}"`; + const printFn = outputMode === "on-error" ? Log.error : Log.debug; + const status = statusFromExitCodeAndSignal(exitCode, signal); + printFn(`Command "${command}" completed with ${exitDescription}.`); + printFn(`Process output: +${logOutput}`); + if (status === 0 || options.suppressErrorOnFailingExitCode) { + resolve({ stdout, stderr, status }); + } else { + reject(outputMode === "silent" ? logOutput : void 0); + } + }); + }); + } }; function statusFromExitCodeAndSignal(exitCode, signal) { return exitCode ?? signal ?? -1; } -function getEnvironmentForNonInteractiveSpawn(userProvidedEnv) { +function getEnvironmentForNonInteractiveCommand(userProvidedEnv) { const forceColorValue = supports_color_default2.stdout !== false ? supports_color_default2.stdout.level.toString() : void 0; return { FORCE_COLOR: forceColorValue, ...userProvidedEnv ?? process.env }; } diff --git a/ng-dev/perf/workflow/cli.ts b/ng-dev/perf/workflow/cli.ts index dd58caf6c..cbeeeab2c 100644 --- a/ng-dev/perf/workflow/cli.ts +++ b/ng-dev/perf/workflow/cli.ts @@ -21,14 +21,14 @@ interface WorkflowsParams { function builder(yargs: Argv) { return yargs .option('config-file' as 'configFile', { - default: '.ng-dev/workflows.yml', + default: '.ng-dev/dx-perf-workflows.yml', type: 'string', description: 'The path to the workflow definitions in a yml file', }) .option('json', { default: false, type: 'boolean', - description: 'Whether to ouput the results as a json object', + description: 'Whether to output the results as a json object', }); } @@ -46,7 +46,7 @@ async function handler({configFile, json}: WorkflowsParams) { } } -/** yargs command module for checking out a PR */ +/** yargs command module for checking out a PR. */ export const WorkflowsModule: CommandModule<{}, WorkflowsParams> = { handler, builder, diff --git a/ng-dev/perf/workflow/loader.ts b/ng-dev/perf/workflow/loader.ts index 0a4b57ca3..7829441ec 100644 --- a/ng-dev/perf/workflow/loader.ts +++ b/ng-dev/perf/workflow/loader.ts @@ -3,9 +3,9 @@ import {parse} from 'yaml'; export interface Workflow { name: string; - workflow: string; - prepare?: string; - cleanup?: string; + workflow: string[]; + prepare?: string[]; + cleanup?: string[]; } export async function loadWorkflows(src: string) { diff --git a/ng-dev/perf/workflow/workflow.ts b/ng-dev/perf/workflow/workflow.ts index 4938a6772..0fe50576a 100644 --- a/ng-dev/perf/workflow/workflow.ts +++ b/ng-dev/perf/workflow/workflow.ts @@ -44,17 +44,11 @@ export async function measureWorkflow({name, workflow, prepare, cleanup}: Workfl * Run a set of commands provided as a multiline text block. Commands are assumed to always be * provided on a single line. */ -async function runCommands(cmds?: string) { - cmds = cmds?.trim(); - if (!cmds) { +async function runCommands(commands?: string[]) { + if (!commands || commands.length === 0) { return; } - let commands = cmds - .split('\n') - .filter((_) => !!_) - .map((cmdStr: string) => cmdStr.trim().split(' ')); - - for (let [cmd, ...args] of commands) { - await ChildProcess.spawn(cmd, args, {mode: 'silent'}); + for (let cmd of commands) { + await ChildProcess.exec(cmd, {mode: 'silent'}); } } diff --git a/ng-dev/utils/child-process.ts b/ng-dev/utils/child-process.ts index 7f046faa7..778a35207 100644 --- a/ng-dev/utils/child-process.ts +++ b/ng-dev/utils/child-process.ts @@ -12,6 +12,8 @@ import { SpawnOptions as _SpawnOptions, spawnSync as _spawnSync, SpawnSyncOptions as _SpawnSyncOptions, + ExecOptions as _ExecOptions, + exec as _exec, } from 'child_process'; import {Log} from './logging.js'; @@ -32,6 +34,14 @@ export interface SpawnOptions extends Omit<_SpawnOptions, 'shell' | 'stdio'> { input?: string; } +/** Interface describing the options for exec-ing a process. */ +export interface ExecOptions extends Omit<_ExecOptions, 'shell' | 'stdio'> { + /** Console output mode. Defaults to "enabled". */ + mode?: 'enabled' | 'silent' | 'on-error'; + /** Whether to prevent exit codes being treated as failures. */ + suppressErrorOnFailingExitCode?: boolean; +} + /** Interface describing the options for spawning an interactive process. */ export type SpawnInteractiveCommandOptions = Omit<_SpawnOptions, 'shell' | 'stdio'>; @@ -81,7 +91,7 @@ export abstract class ChildProcess { return new Promise((resolve, reject) => { const commandText = `${command} ${args.join(' ')}`; const outputMode = options.mode; - const env = getEnvironmentForNonInteractiveSpawn(options.env); + const env = getEnvironmentForNonInteractiveCommand(options.env); Log.debug(`Executing command: ${commandText}`); @@ -148,7 +158,7 @@ export abstract class ChildProcess { */ static spawnSync(command: string, args: string[], options: SpawnSyncOptions = {}): SpawnResult { const commandText = `${command} ${args.join(' ')}`; - const env = getEnvironmentForNonInteractiveSpawn(options.env); + const env = getEnvironmentForNonInteractiveCommand(options.env); Log.debug(`Executing command: ${commandText}`); @@ -168,6 +178,63 @@ export abstract class ChildProcess { throw new Error(stderr); } + + static exec(command: string, options: ExecOptions = {}) { + return new Promise((resolve, reject) => { + const outputMode = options.mode; + const env = getEnvironmentForNonInteractiveCommand(options.env); + + Log.debug(`Executing command: ${command}`); + + const childProcess = _exec(command, {...options, env}); + let logOutput = ''; + let stdout = ''; + let stderr = ''; + + // Capture the stdout separately so that it can be passed as resolve value. + // This is useful if commands return parsable stdout. + childProcess.stderr?.on('data', (message) => { + stderr += message; + logOutput += message; + // If console output is enabled, print the message directly to the stderr. Note that + // we intentionally print all output to stderr as stdout should not be polluted. + if (outputMode === undefined || outputMode === 'enabled') { + process.stderr.write(message); + } + }); + + childProcess.stdout?.on('data', (message) => { + stdout += message; + logOutput += message; + // If console output is enabled, print the message directly to the stderr. Note that + // we intentionally print all output to stderr as stdout should not be polluted. + if (outputMode === undefined || outputMode === 'enabled') { + process.stderr.write(message); + } + }); + + // The `close` event is used because the process is guaranteed to have completed writing to + // stdout and stderr, using the `exit` event can cause inconsistent information in stdout and + // stderr due to a race condition around exiting. + childProcess.on('close', (exitCode, signal) => { + const exitDescription = + exitCode !== null ? `exit code "${exitCode}"` : `signal "${signal}"`; + const printFn = outputMode === 'on-error' ? Log.error : Log.debug; + const status = statusFromExitCodeAndSignal(exitCode, signal); + + printFn(`Command "${command}" completed with ${exitDescription}.`); + printFn(`Process output: \n${logOutput}`); + + // On success, resolve the promise. Otherwise reject with the captured stderr + // and stdout log output if the output mode was set to `silent`. + if (status === 0 || options.suppressErrorOnFailingExitCode) { + resolve({stdout, stderr, status}); + } else { + reject(outputMode === 'silent' ? logOutput : undefined); + } + }); + }); + } } /** * Convert the provided exitCode and signal to a single status code. @@ -188,7 +255,7 @@ function statusFromExitCodeAndSignal(exitCode: number | null, signal: NodeJS.Sig * Currently we enable `FORCE_COLOR` since non-interactive spawn's with * non-inherited `stdio` will not have colors enabled due to a missing TTY. */ -function getEnvironmentForNonInteractiveSpawn( +function getEnvironmentForNonInteractiveCommand( userProvidedEnv?: NodeJS.ProcessEnv, ): NodeJS.ProcessEnv { // Pass through the color level from the TTY/process performing the `spawn` call.