diff --git a/bin/publish.ts b/bin/publish.ts index 7e451a2..556493d 100644 --- a/bin/publish.ts +++ b/bin/publish.ts @@ -7,30 +7,34 @@ import { EventType, IPublishProgress, IPublishProgressListener, + globalOutputHandler, } from '../lib'; export async function publish(args: { path: string; assets?: string[]; profile?: string }) { let manifest = AssetManifest.fromPath(args.path); - log('verbose', `Loaded manifest from ${args.path}: ${manifest.entries.length} assets found`); - - if (args.assets && args.assets.length > 0) { - const selection = args.assets.map((a) => DestinationPattern.parse(a)); - manifest = manifest.select(selection); - log('verbose', `Applied selection: ${manifest.entries.length} assets selected.`); - } + // AssetPublishing will assign the global output handler const pub = new AssetPublishing(manifest, { aws: new DefaultAwsClient(args.profile), progressListener: new ConsoleProgress(), throwOnError: false, }); + globalOutputHandler.verbose( + `Loaded manifest from ${args.path}: ${manifest.entries.length} assets found` + ); + + if (args.assets && args.assets.length > 0) { + const selection = args.assets.map((a) => DestinationPattern.parse(a)); + manifest = manifest.select(selection); + globalOutputHandler.verbose(`Applied selection: ${manifest.entries.length} assets selected.`); + } + await pub.publish(); if (pub.hasFailures) { for (const failure of pub.failures) { - // eslint-disable-next-line no-console - console.error('Failure:', failure.error.stack); + globalOutputHandler.error(`Failure: ${failure.error.stack}`); } process.exitCode = 1; diff --git a/lib/private/docker.ts b/lib/private/docker.ts index ab519db..bfc3f40 100644 --- a/lib/private/docker.ts +++ b/lib/private/docker.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { cdkCredentialsConfig, obtainEcrCredentials } from './docker-credentials'; -import { Logger, shell, ShellOptions, ProcessFailedError } from './shell'; +import { shell, ShellOptions, ProcessFailedError } from './shell'; import { createCriticalSection } from './util'; import { IECRClient } from '../aws'; @@ -55,8 +55,6 @@ export interface DockerCacheOption { export class Docker { private configDir: string | undefined = undefined; - constructor(private readonly logger?: Logger) {} - /** * Whether an image with the given tag exists */ @@ -200,10 +198,8 @@ export class Docker { const pathToCdkAssets = path.resolve(__dirname, '..', '..', 'bin'); try { await shell([getDockerCmd(), ...configArgs, ...args], { - logger: this.logger, ...options, env: { - ...process.env, ...options.env, PATH: `${pathToCdkAssets}${path.delimiter}${options.env?.PATH ?? process.env.PATH}`, }, @@ -234,7 +230,6 @@ export class Docker { export interface DockerFactoryOptions { readonly repoUri: string; readonly ecr: IECRClient; - readonly logger: (m: string) => void; } /** @@ -249,7 +244,7 @@ export class DockerFactory { * Gets a Docker instance for building images. */ public async forBuild(options: DockerFactoryOptions): Promise { - const docker = new Docker(options.logger); + const docker = new Docker(); // Default behavior is to login before build so that the Dockerfile can reference images in the ECR repo // However, if we're in a pipelines environment (for example), @@ -268,7 +263,7 @@ export class DockerFactory { * Gets a Docker instance for pushing images to ECR. */ public async forEcrPush(options: DockerFactoryOptions) { - const docker = new Docker(options.logger); + const docker = new Docker(); await this.loginOncePerDestination(docker, options); return docker; } diff --git a/lib/private/handlers/container-images.ts b/lib/private/handlers/container-images.ts index 7cbf89a..ff4912f 100644 --- a/lib/private/handlers/container-images.ts +++ b/lib/private/handlers/container-images.ts @@ -38,7 +38,6 @@ export class ContainerImageAssetHandler implements IAssetHandler { const dockerForBuilding = await this.host.dockerFactory.forBuild({ repoUri: initOnce.repoUri, - logger: (m: string) => this.host.emitMessage(EventType.DEBUG, m), ecr: initOnce.ecr, }); @@ -85,7 +84,6 @@ export class ContainerImageAssetHandler implements IAssetHandler { const dockerForPushing = await this.host.dockerFactory.forEcrPush({ repoUri: initOnce.repoUri, - logger: (m: string) => this.host.emitMessage(EventType.DEBUG, m), ecr: initOnce.ecr, }); diff --git a/lib/private/shell.ts b/lib/private/shell.ts index 567d695..844d4a1 100644 --- a/lib/private/shell.ts +++ b/lib/private/shell.ts @@ -1,10 +1,10 @@ import * as child_process from 'child_process'; +import { EventType, globalOutputHandler } from '../progress'; export type Logger = (x: string) => void; export interface ShellOptions extends child_process.SpawnOptions { readonly quiet?: boolean; - readonly logger?: Logger; readonly input?: string; } @@ -15,9 +15,9 @@ export interface ShellOptions extends child_process.SpawnOptions { * string. */ export async function shell(command: string[], options: ShellOptions = {}): Promise { - if (options.logger) { - options.logger(renderCommandLine(command)); - } + globalOutputHandler.publishEvent(EventType.START, command.join(' ')); + globalOutputHandler.info(renderCommandLine(command)); + const child = child_process.spawn(command[0], command.slice(1), { ...options, stdio: [options.input ? 'pipe' : 'ignore', 'pipe', 'pipe'], @@ -32,36 +32,39 @@ export async function shell(command: string[], options: ShellOptions = {}): Prom const stdout = new Array(); const stderr = new Array(); - // Both write to stdout and collect child.stdout!.on('data', (chunk) => { if (!options.quiet) { - process.stdout.write(chunk); + globalOutputHandler.publishEvent(chunk, EventType.DEBUG); } stdout.push(chunk); }); child.stderr!.on('data', (chunk) => { if (!options.quiet) { - process.stderr.write(chunk); + globalOutputHandler.publishEvent(chunk, EventType.DEBUG); } - stderr.push(chunk); }); - child.once('error', reject); + child.once('error', (error) => { + globalOutputHandler.publishEvent(EventType.FAIL, error.message); + reject(error); + }); child.once('close', (code, signal) => { if (code === 0) { - resolve(Buffer.concat(stdout).toString('utf-8')); + const output = Buffer.concat(stdout).toString('utf-8'); + globalOutputHandler.publishEvent(EventType.SUCCESS, output); + resolve(output); } else { - const out = Buffer.concat(stderr).toString('utf-8').trim(); - reject( - new ProcessFailed( - code, - signal, - `${renderCommandLine(command)} exited with ${code != null ? 'error code' : 'signal'} ${code ?? signal}: ${out}` - ) + const errorOutput = Buffer.concat(stderr).toString('utf-8').trim(); + const error = new ProcessFailed( + code, + signal, + `${renderCommandLine(command)} exited with ${code != null ? 'error code' : 'signal'} ${code ?? signal}: ${errorOutput}` ); + globalOutputHandler.publishEvent(EventType.FAIL, error.message); + reject(error); } }); }); diff --git a/lib/progress.ts b/lib/progress.ts index b2c8e77..c61563b 100644 --- a/lib/progress.ts +++ b/lib/progress.ts @@ -84,3 +84,50 @@ export interface IPublishProgress { */ abort(): void; } + +class GlobalOutputHandler { + private progressListener: IPublishProgressListener | undefined; + private completionProgress: number; + + constructor(completionProgress: number = 0, progressListener?: IPublishProgressListener) { + this.progressListener = progressListener; + this.completionProgress = completionProgress; + } + + public setListener(listener: IPublishProgressListener) { + this.progressListener = listener; + } + + public setCompletionProgress(progress: number) { + this.completionProgress = progress; + } + + public publishEvent(eventType: EventType = EventType.DEBUG, text: string) { + const progressEvent: IPublishProgress = { + message: text, + abort: () => {}, + percentComplete: this.completionProgress, + }; + if (this.progressListener) { + this.progressListener.onPublishEvent(eventType, progressEvent); + } + } + + public verbose(text: string) { + this.publishEvent(EventType.DEBUG, text); + } + + public error(text: string) { + this.publishEvent(EventType.FAIL, text); + } + + public info(text: string) { + this.publishEvent(EventType.SUCCESS, text); + } + + public hasListener() { + return this.progressListener !== undefined; + } +} + +export let globalOutputHandler = new GlobalOutputHandler(); diff --git a/lib/publishing.ts b/lib/publishing.ts index cf0293c..1b4020c 100644 --- a/lib/publishing.ts +++ b/lib/publishing.ts @@ -4,7 +4,12 @@ import { IAssetHandler, IHandlerHost, type PublishOptions } from './private/asse import { DockerFactory } from './private/docker'; import { makeAssetHandler } from './private/handlers'; import { pLimit } from './private/p-limit'; -import { EventType, IPublishProgress, IPublishProgressListener } from './progress'; +import { + EventType, + IPublishProgress, + IPublishProgressListener, + globalOutputHandler, +} from './progress'; export interface AssetPublishingOptions { /** @@ -113,6 +118,10 @@ export class AssetPublishing implements IPublishProgress { }, dockerFactory: new DockerFactory(), }; + if (options.progressListener) { + globalOutputHandler.setListener(options.progressListener); + } + globalOutputHandler.setCompletionProgress(this.percentComplete); } /** @@ -249,10 +258,12 @@ export class AssetPublishing implements IPublishProgress { } public get percentComplete() { - if (this.totalOperations === 0) { - return 100; - } - return Math.floor((this.completedOperations / this.totalOperations) * 100); + const completionProgress = + this.totalOperations === 0 + ? 100 + : Math.floor((this.completedOperations / this.totalOperations) * 100); + globalOutputHandler.setCompletionProgress(completionProgress); + return completionProgress; } public abort(): void { diff --git a/test/asset-publishing-logs.test.ts b/test/asset-publishing-logs.test.ts new file mode 100644 index 0000000..3c1c667 --- /dev/null +++ b/test/asset-publishing-logs.test.ts @@ -0,0 +1,450 @@ +jest.mock('child_process'); + +import { Manifest } from '@aws-cdk/cloud-assembly-schema'; +import { + DescribeImagesCommand, + DescribeRepositoriesCommand, + GetAuthorizationTokenCommand, +} from '@aws-sdk/client-ecr'; +import { ListObjectsV2Command } from '@aws-sdk/client-s3'; +import { MockAws, mockS3, mockEcr } from './mock-aws'; +import { mockSpawn } from './mock-child_process'; +import mockfs from './mock-fs'; +import { AssetManifest, AssetPublishing, EventType } from '../lib'; + +describe('Console Logging', () => { + // Store original console methods + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + const originalConsoleWarn = console.warn; + const originalConsoleInfo = console.info; + const originalConsoleDebug = console.debug; + + // Create spies for console methods + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + let consoleInfoSpy: jest.SpyInstance; + let consoleDebugSpy: jest.SpyInstance; + + beforeEach(() => { + // Mock filesystem with test assets + mockfs({ + '/test/cdk.out/assets.json': JSON.stringify({ + version: Manifest.version(), + files: { + asset1: { + source: { + path: 'some_file', + }, + destinations: { + dest1: { + bucketName: 'test-bucket', + objectKey: 'test-key', + region: 'us-east-1', + }, + }, + }, + }, + }), + '/test/cdk.out/some_file': 'test content', + }); + + // Set up spies for all console methods + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(() => {}); + consoleDebugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {}); + + // Mock S3 client to prevent actual AWS calls + mockS3.on(ListObjectsV2Command).resolves({ + Contents: [], + }); + }); + + afterEach(() => { + mockfs.restore(); + + // Restore all console methods + console.log = originalConsoleLog; + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + console.info = originalConsoleInfo; + console.debug = originalConsoleDebug; + + // Clear all mocks + jest.clearAllMocks(); + }); + + test('no console output during successful asset publishing while still publishing assets', async () => { + const aws = new MockAws(); + const publishedAssets: string[] = []; + const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/test/cdk.out')), { + aws, + progressListener: { + onPublishEvent: (type, event) => { + if (type === 'success') { + publishedAssets.push(event.message); + } + }, + }, + }); + + await pub.publish(); + + // Verify no console output occurred + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleInfoSpy).not.toHaveBeenCalled(); + expect(consoleDebugSpy).not.toHaveBeenCalled(); + + // Verify asset was actually published + expect(publishedAssets.length).toBeGreaterThan(0); + expect(publishedAssets[0]).toContain('Published asset1:dest1'); + }); + + test('no console output when checking if asset is published while still checking status', async () => { + const aws = new MockAws(); + const checkEvents: string[] = []; + const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/test/cdk.out')), { + aws, + progressListener: { + onPublishEvent: (type, event) => { + if (type === 'check') { + checkEvents.push(event.message); + } + }, + }, + }); + + const manifest = AssetManifest.fromPath(mockfs.path('/test/cdk.out')); + await pub.isEntryPublished(manifest.entries[0]); + + // Verify no console output occurred + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleInfoSpy).not.toHaveBeenCalled(); + expect(consoleDebugSpy).not.toHaveBeenCalled(); + + // Verify check was actually performed + expect(checkEvents.length).toBeGreaterThan(0); + expect(checkEvents[0]).toContain('Check'); + }); + + test('no console output when building asset', async () => { + const aws = new MockAws(); + const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/test/cdk.out')), { aws }); + + const manifest = AssetManifest.fromPath(mockfs.path('/test/cdk.out')); + await pub.buildEntry(manifest.entries[0]); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleInfoSpy).not.toHaveBeenCalled(); + expect(consoleDebugSpy).not.toHaveBeenCalled(); + }); + + test('no console output during parallel publishing while still publishing assets', async () => { + const aws = new MockAws(); + const publishEvents: string[] = []; + const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/test/cdk.out')), { + aws, + publishInParallel: true, + progressListener: { + onPublishEvent: (type, event) => { + if (type === 'start' || type === 'success') { + publishEvents.push(event.message); + } + }, + }, + }); + + await pub.publish(); + + // Verify no console output occurred + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleInfoSpy).not.toHaveBeenCalled(); + expect(consoleDebugSpy).not.toHaveBeenCalled(); + + // Verify publishing actually occurred + expect(publishEvents.length).toBeGreaterThan(0); + expect(publishEvents).toContainEqual(expect.stringContaining('Publishing asset1:dest1')); + expect(publishEvents).toContainEqual(expect.stringContaining('Published asset1:dest1')); + }); + + test('no console output when publishing fails while still handling errors properly', async () => { + const aws = new MockAws(); + const failureEvents: string[] = []; + const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/test/cdk.out')), { + aws, + throwOnError: false, // Prevent the test from failing due to the error + progressListener: { + onPublishEvent: (type, event) => { + if (type === 'fail') { + failureEvents.push(event.message); + } + }, + }, + }); + + // Force a failure by making S3 throw an error + const errorMessage = 'Simulated S3 error'; + mockS3.on(ListObjectsV2Command).rejects(new Error(errorMessage)); + + await pub.publish(); + + // Verify no console output occurred + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleInfoSpy).not.toHaveBeenCalled(); + expect(consoleDebugSpy).not.toHaveBeenCalled(); + + // Verify error was properly handled + expect(failureEvents.length).toBeGreaterThan(0); + expect(failureEvents[0]).toContain(errorMessage); + expect(pub.hasFailures).toBe(true); + expect(pub.failures.length).toBe(1); + expect(pub.failures[0].error.message).toContain(errorMessage); + }); + + test('progress listener receives messages without console output', async () => { + const aws = new MockAws(); + const messages: string[] = []; + const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/test/cdk.out')), { + aws, + progressListener: { + onPublishEvent: (type, event) => { + messages.push(event.message); + }, + }, + }); + + await pub.publish(); + + // Verify that the progress listener received messages + expect(messages.length).toBeGreaterThan(0); + + // But verify no console output occurred + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleInfoSpy).not.toHaveBeenCalled(); + expect(consoleDebugSpy).not.toHaveBeenCalled(); + }); +}); + +describe('Shell Command Logging', () => { + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + // Mock filesystem with docker test assets + mockfs({ + '/test/cdk.out/assets.json': JSON.stringify({ + version: Manifest.version(), + dockerImages: { + theDockerAsset: { + source: { + directory: 'dockerdir', + }, + destinations: { + theDestination: { + region: 'us-east-1', + repositoryName: 'repo', + imageTag: 'tag', + }, + }, + }, + }, + }), + '/test/cdk.out/dockerdir/Dockerfile': 'FROM node:14', + }); + + // Setup ECR mocks + mockEcr.reset(); + mockEcr.on(GetAuthorizationTokenCommand).resolves({ + authorizationData: [ + { + authorizationToken: Buffer.from('user:pass').toString('base64'), + proxyEndpoint: 'https://12345.dkr.ecr.region.amazonaws.com', + }, + ], + }); + mockEcr.on(DescribeRepositoriesCommand).resolves({ + repositories: [{ repositoryUri: '12345.dkr.ecr.region.amazonaws.com/repo' }], + }); + mockEcr.on(DescribeImagesCommand).rejects({ name: 'ImageNotFoundException' }); + }); + + afterEach(() => { + mockfs.restore(); + jest.clearAllMocks(); + }); + + test('captures both stdout and stderr from shell commands', async () => { + const messages: string[] = []; + const aws = new MockAws(); + + const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/test/cdk.out')), { + aws, + progressListener: { + onPublishEvent: (type: EventType, event) => { + messages.push(event.message); + }, + }, + }); + + // Mock Docker commands with output + const expectAllSpawns = mockSpawn( + { + commandLine: [ + 'docker', + 'login', + '--username', + 'user', + '--password-stdin', + 'https://12345.dkr.ecr.region.amazonaws.com', + ], + stdout: 'Login Succeeded', + }, + { + commandLine: ['docker', 'inspect', 'cdkasset-thedockerasset'], + exitCode: 1, + stderr: 'Warning: using default credentials\n', + }, + { + commandLine: ['docker', 'build', '--tag', 'cdkasset-thedockerasset', '.'], + cwd: '/test/cdk.out/dockerdir', + }, + { + commandLine: [ + 'docker', + 'tag', + 'cdkasset-thedockerasset', + '12345.dkr.ecr.region.amazonaws.com/repo:tag', + ], + }, + { + commandLine: ['docker', 'push', '12345.dkr.ecr.region.amazonaws.com/repo:tag'], + } + ); + + await pub.publish(); + expectAllSpawns(); + + // Check that both stdout and stderr were captured + expect(messages).toEqual( + expect.arrayContaining([ + expect.stringContaining('Login Succeeded'), + expect.stringContaining('Warning: using default credentials'), + ]) + ); + }); + + test('captures shell command output in quiet mode', async () => { + const messages: string[] = []; + const aws = new MockAws(); + + const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/test/cdk.out')), { + aws, + quiet: true, + progressListener: { + onPublishEvent: (type: EventType, event) => { + messages.push(event.message); + }, + }, + }); + + const expectAllSpawns = mockSpawn( + { + commandLine: [ + 'docker', + 'login', + '--username', + 'user', + '--password-stdin', + 'https://12345.dkr.ecr.region.amazonaws.com', + ], + stdout: 'Login Succeeded\n', + stderr: 'Warning message\n', + }, + { + commandLine: ['docker', 'inspect', 'cdkasset-thedockerasset'], + exitCode: 1, + }, + { + commandLine: ['docker', 'build', '--tag', 'cdkasset-thedockerasset', '.'], + cwd: '/test/cdk.out/dockerdir', + }, + { + commandLine: [ + 'docker', + 'tag', + 'cdkasset-thedockerasset', + '12345.dkr.ecr.region.amazonaws.com/repo:tag', + ], + }, + { + commandLine: ['docker', 'push', '12345.dkr.ecr.region.amazonaws.com/repo:tag'], + } + ); + + await pub.publish(); + expectAllSpawns(); + + // In quiet mode, shell outputs should not be captured + expect(messages).not.toContain('Login Succeeded'); + expect(messages).not.toContain('Warning message'); + + // Verify no direct console output + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + test('shell output is captured when command fails', async () => { + const messages: string[] = []; + const aws = new MockAws(); + + const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/test/cdk.out')), { + aws, + throwOnError: false, + progressListener: { + onPublishEvent: (type: EventType, event) => { + messages.push(event.message); + }, + }, + }); + + const expectAllSpawns = mockSpawn({ + commandLine: [ + 'docker', + 'login', + '--username', + 'user', + '--password-stdin', + 'https://12345.dkr.ecr.region.amazonaws.com', + ], + stderr: 'Authentication failed', + exitCode: 1, + }); + + await pub.publish(); + expectAllSpawns(); + + // Check error capture + expect(messages).toEqual( + expect.arrayContaining([expect.stringContaining('Authentication failed')]) + ); + expect(pub.hasFailures).toBe(true); + }); +}); diff --git a/test/fake-listener.ts b/test/fake-listener.ts index 1b02685..6bd73cc 100644 --- a/test/fake-listener.ts +++ b/test/fake-listener.ts @@ -6,7 +6,8 @@ export class FakeListener implements IPublishProgressListener { constructor(private readonly doAbort = false) {} - public onPublishEvent(_type: EventType, event: IPublishProgress): void { + public onPublishEvent(type: EventType, event: IPublishProgress): void { + this.types.push(type); this.messages.push(event.message); if (this.doAbort) { diff --git a/test/mock-child_process.ts b/test/mock-child_process.ts index 4588650..a5818f2 100644 --- a/test/mock-child_process.ts +++ b/test/mock-child_process.ts @@ -10,7 +10,7 @@ export interface Invocation { cwd?: string; exitCode?: number; stdout?: string; - + stderr?: string; /** * Only match a prefix of the command (don't care about the details of the arguments) */ @@ -48,6 +48,9 @@ export function mockSpawn(...invocations: Invocation[]): () => void { if (invocation.stdout) { mockEmit(child.stdout, 'data', Buffer.from(invocation.stdout)); } + if (invocation.stderr) { + mockEmit(child.stderr, 'data', Buffer.from(invocation.stderr)); + } mockEmit(child, 'close', invocation.exitCode ?? 0); return child;