packages/@aws-cdk-testing/cli-integ/lib/shell.ts (164 lines of code) (raw):

import type * as child_process from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { TestContext } from './integ-test'; import { Process } from './process'; import type { TemporaryDirectoryContext } from './with-temporary-directory'; /** * A shell command that does what you want * * Is platform-aware, handles errors nicely. */ export async function shell(command: string[], options: ShellOptions = {}): Promise<string> { if (options.modEnv && options.env) { throw new Error('Use either env or modEnv but not both'); } const outputs = new Set(options.outputs); const writeToOutputs = (x: string) => { for (const outputStream of outputs) { outputStream.write(x); } }; // Always output the command writeToOutputs(`💻 ${command.join(' ')}\n`); const show = options.show ?? 'always'; const env = options.env ?? (options.modEnv ? { ...process.env, ...options.modEnv } : process.env); const tty = options.interact && options.interact.length > 0; // Coerce to `any` because `ShellOptions` contains custom properties // that don't exist in the underlying interfaces. We could either rebuild each options map, // or just pass through and let the underlying implemenation ignore what it doesn't know about. // We choose the lazy one. const spawnOptions = { ...options, env } as any; const child = tty ? Process.spawnTTY(command[0], command.slice(1), spawnOptions) : Process.spawn(command[0], command.slice(1), spawnOptions); // copy because we will be shifting it const remainingInteractions = [...(options.interact ?? [])]; return new Promise<string>((resolve, reject) => { const stdout = new Array<Buffer>(); const stderr = new Array<Buffer>(); const lastLine = new LastLine(); child.onStdout(chunk => { if (show === 'always') { writeToOutputs(chunk.toString('utf-8')); } stdout.push(chunk); lastLine.append(chunk.toString('utf-8')); const interaction = remainingInteractions[0]; if (interaction) { if (interaction.prompt.test(lastLine.get())) { // subprocess expects a user input now. // first, shift the interactions to ensure the same interaction is not reused remainingInteractions.shift(); // then, reset the last line to prevent repeated matches caused by tty echoing lastLine.reset(); // now write the input with a slight delay to ensure // the child process has already started reading. setTimeout(() => { child.writeStdin(interaction.input + (interaction.end ?? os.EOL)); }, 500); } } }); if (tty && options.captureStderr === false) { // in a tty stderr goes to the same fd as stdout throw new Error('Cannot disable \'captureStderr\' in tty'); } if (!tty) { // in a tty stderr goes to the same fd as stdout, so onStdout // is sufficient. child.onStderr(chunk => { if (show === 'always') { writeToOutputs(chunk.toString('utf-8')); } if (options.captureStderr ?? true) { stderr.push(chunk); } }); } child.onError(reject); child.onExit(code => { const stderrOutput = Buffer.concat(stderr).toString('utf-8'); const stdoutOutput = Buffer.concat(stdout).toString('utf-8'); const out = (options.onlyStderr ? stderrOutput : stdoutOutput + stderrOutput).trim(); const logAndreject = (error: Error) => { if (show === 'error') { writeToOutputs(`${out}\n`); } reject(error); }; if (remainingInteractions.length !== 0) { // regardless of the exit code, if we didn't consume all expected interactions we probably // did somethiing wrong. logAndreject(new Error(`Expected more user interactions but subprocess exited with ${code}`)); return; } if (code === 0 || options.allowErrExit) { resolve(out); } else { logAndreject(new Error(`'${command.join(' ')}' exited with error code ${code}.`)); } }); }); } /** * Models a single user interaction with the shell. */ export interface UserInteraction { /** * The prompt to expect. Regex matched against the last line in * the output before the prompt is displayed. * * Most commonly this would be a simple string to match for inclusion. * * Examples: * * - Process Output: "Hey there! Are you sure?" * Prompt: /Are you sure?/ * Match (Yes/No): Yes * Reason: "Hey there! Are you sure?" ~ /Are you sure?/ * * - Process Output: "Hey there!\nAre you sure?" * Prompt: /Are you sure?/ * Match (Yes/No): Yes * Reason: "Are you sure?" ~ /Are you sure?/ * * - Process Output: "Are you sure?\n(remember this is destructive)" * Prompt: /Are you sure?/ * Match (Yes/No): No * Reason: "(remember this is destructive)" ≄ /Are you sure?/ * * - Process Output: "Are you sure?\n(remember this is destructive)" * Prompt: /remember this is destructive/ * Match (Yes/No): Yes * Reason: "(remember this is destructive)" ~ /remember this is destructive/ * */ readonly prompt: RegExp; /** * The input to provide. */ readonly input: string; /** * The string to signal the end of input. * * @default os.EOL */ readonly end?: string; } export interface ShellOptions extends child_process.SpawnOptions { /** * Properties to add to 'env' */ readonly modEnv?: Record<string, string | undefined>; /** * Don't fail when exiting with an error * * @default false */ readonly allowErrExit?: boolean; /** * Whether to capture stderr * * @default true */ readonly captureStderr?: boolean; /** * Pass output here */ readonly outputs?: NodeJS.WritableStream[]; /** * Only return stderr. For example, this is used to validate * that when CI=true, all logs are sent to stdout. * * @default false */ readonly onlyStderr?: boolean; /** * Don't log to stdout * * @default always */ readonly show?: 'always' | 'never' | 'error'; /** * Provide user interaction to respond to shell prompts. * * Order and count should correspond to the expected prompts issued by the subprocess. */ readonly interact?: UserInteraction[]; } export class ShellHelper { public static fromContext(context: TestContext & TemporaryDirectoryContext) { return new ShellHelper(context.integTestDir, context.output); } constructor( private readonly _cwd: string, private readonly _output: NodeJS.WritableStream) { } public async shell(command: string[], options: Omit<ShellOptions, 'cwd' | 'outputs'> = {}): Promise<string> { return shell(command, { outputs: [this._output], cwd: this._cwd, ...options, modEnv: { // give every shell its own docker config directory // so that parallel runs don't interfere with each other. DOCKER_CONFIG: path.join(this._cwd, '.docker'), ...options.modEnv, }, }); } } /** * rm -rf reimplementation, don't want to depend on an NPM package for this * * Returns `true` if everything got deleted, or `false` if some files could * not be deleted due to permissions issues. */ export function rimraf(fsPath: string): boolean { try { let success = true; const isDir = fs.lstatSync(fsPath).isDirectory(); if (isDir) { for (const file of fs.readdirSync(fsPath)) { success &&= rimraf(path.join(fsPath, file)); } fs.rmdirSync(fsPath); } else { fs.unlinkSync(fsPath); } return success; } catch (e: any) { // Can happen if some files got generated inside a Docker container and are now inadvertently owned by `root`. // We can't ever clean those up anymore, but since it only happens inside GitHub Actions containers we also don't care too much. if (e.code === 'EACCES' || e.code === 'ENOTEMPTY') { return false; } // Already gone if (e.code === 'ENOENT') { return true; } throw e; } } export function addToShellPath(x: string) { const parts = process.env.PATH?.split(':') ?? []; if (!parts.includes(x)) { parts.unshift(x); } process.env.PATH = parts.join(':'); } /** * Accumulate text since the last line break (or beginning of string) it has seen in the chunks. * * Examples: * * - Chunks: ['one\n', 'two\n', three'] * - Last Line: 'three' * * - Chunks: ['one', 'two', '\nthree'] * - Last Line: 'three' * * - Chunks: ['one', 'two'] * - Last Line: 'onetwo' * * - Chunks: ['one', 'two', '\nthree', 'four'] * - Last Line: 'threefour' */ class LastLine { private lastLine: string = ''; public append(chunk: string): void { const lines = chunk.split(os.EOL); if (lines.length === 1) { // chunk doesn't contain a new line so just append this.lastLine += lines[0]; } else { // chunk contains multiple lines so just override with the last one this.lastLine = lines[lines.length - 1]; } } public get(): string { return this.lastLine; } public reset() { this.lastLine = ''; } }