From ddd04b6997d9d8ccdb1f4434d9b1205066d5e091 Mon Sep 17 00:00:00 2001 From: Luke Tomlinson Date: Fri, 21 May 2021 12:12:16 -0400 Subject: [PATCH] Add getExecOutput function (#814) * Add getExecOutput function * Add tests for exec output * Modify tests to not rely on buffer size, but only test larger output * Handle split multi-byte characters + PR feedback * Fix tests * Lint * Update how split byte are sent for tests --- packages/exec/__tests__/exec.test.ts | 159 ++++++++++++++++++ .../__tests__/scripts/stdoutoutputlarge.js | 3 + .../__tests__/scripts/stdoutputspecial.js | 5 + packages/exec/src/exec.ts | 65 ++++++- packages/exec/src/interfaces.ts | 46 +++-- 5 files changed, 265 insertions(+), 13 deletions(-) create mode 100644 packages/exec/__tests__/scripts/stdoutoutputlarge.js create mode 100644 packages/exec/__tests__/scripts/stdoutputspecial.js diff --git a/packages/exec/__tests__/exec.test.ts b/packages/exec/__tests__/exec.test.ts index ae9844d6..054d2690 100644 --- a/packages/exec/__tests__/exec.test.ts +++ b/packages/exec/__tests__/exec.test.ts @@ -636,6 +636,165 @@ describe('@actions/exec', () => { expect(output.trim()).toBe(`args[0]: "hello"${os.EOL}args[1]: "world"`) }) + it('correctly outputs for getExecOutput', async () => { + const stdErrPath: string = path.join( + __dirname, + 'scripts', + 'stderroutput.js' + ) + const stdOutPath: string = path.join( + __dirname, + 'scripts', + 'stdoutoutput.js' + ) + const nodePath: string = await io.which('node', true) + + const {exitCode: exitCodeOut, stdout} = await exec.getExecOutput( + `"${nodePath}"`, + [stdOutPath], + getExecOptions() + ) + + expect(exitCodeOut).toBe(0) + expect(stdout).toBe('this is output to stdout') + + const {exitCode: exitCodeErr, stderr} = await exec.getExecOutput( + `"${nodePath}"`, + [stdErrPath], + getExecOptions() + ) + expect(exitCodeErr).toBe(0) + expect(stderr).toBe('this is output to stderr') + }) + + it('correctly outputs for getExecOutput with additional listeners', async () => { + const stdErrPath: string = path.join( + __dirname, + 'scripts', + 'stderroutput.js' + ) + const stdOutPath: string = path.join( + __dirname, + 'scripts', + 'stdoutoutput.js' + ) + + const nodePath: string = await io.which('node', true) + let listenerOut = '' + + const {exitCode: exitCodeOut, stdout} = await exec.getExecOutput( + `"${nodePath}"`, + [stdOutPath], + { + ...getExecOptions(), + listeners: { + stdout: data => { + listenerOut = data.toString() + } + } + } + ) + + expect(exitCodeOut).toBe(0) + expect(stdout).toBe('this is output to stdout') + expect(listenerOut).toBe('this is output to stdout') + + let listenerErr = '' + const {exitCode: exitCodeErr, stderr} = await exec.getExecOutput( + `"${nodePath}"`, + [stdErrPath], + { + ...getExecOptions(), + listeners: { + stderr: data => { + listenerErr = data.toString() + } + } + } + ) + expect(exitCodeErr).toBe(0) + expect(stderr).toBe('this is output to stderr') + expect(listenerErr).toBe('this is output to stderr') + }) + + it('correctly outputs for getExecOutput when total size exceeds buffer size', async () => { + const stdErrPath: string = path.join( + __dirname, + 'scripts', + 'stderroutput.js' + ) + const stdOutPath: string = path.join( + __dirname, + 'scripts', + 'stdoutoutputlarge.js' + ) + + const nodePath: string = await io.which('node', true) + let listenerOut = '' + + const {exitCode: exitCodeOut, stdout} = await exec.getExecOutput( + `"${nodePath}"`, + [stdOutPath], + { + ...getExecOptions(), + listeners: { + stdout: data => { + listenerOut += data.toString() + } + } + } + ) + + expect(exitCodeOut).toBe(0) + expect(Buffer.byteLength(stdout || '', 'utf8')).toBe(2 ** 25) + expect(Buffer.byteLength(listenerOut, 'utf8')).toBe(2 ** 25) + + let listenerErr = '' + const {exitCode: exitCodeErr, stderr} = await exec.getExecOutput( + `"${nodePath}"`, + [stdErrPath], + { + ...getExecOptions(), + listeners: { + stderr: data => { + listenerErr = data.toString() + } + } + } + ) + expect(exitCodeErr).toBe(0) + expect(stderr).toBe('this is output to stderr') + expect(listenerErr).toBe('this is output to stderr') + }) + + it('correctly outputs for getExecOutput with multi-byte characters', async () => { + const stdOutPath: string = path.join( + __dirname, + 'scripts', + 'stdoutputspecial.js' + ) + + const nodePath: string = await io.which('node', true) + let numStdOutBufferCalls = 0 + const {exitCode: exitCodeOut, stdout} = await exec.getExecOutput( + `"${nodePath}"`, + [stdOutPath], + { + ...getExecOptions(), + listeners: { + stdout: () => { + numStdOutBufferCalls += 1 + } + } + } + ) + + expect(exitCodeOut).toBe(0) + //one call for each half of the © character, ensuring it was actually split and not sent together + expect(numStdOutBufferCalls).toBe(2) + expect(stdout).toBe('©') + }) + if (IS_WINDOWS) { it('Exec roots relative tool path using process.cwd (Windows path separator)', async () => { let exitCode: number diff --git a/packages/exec/__tests__/scripts/stdoutoutputlarge.js b/packages/exec/__tests__/scripts/stdoutoutputlarge.js new file mode 100644 index 00000000..c7f7122d --- /dev/null +++ b/packages/exec/__tests__/scripts/stdoutoutputlarge.js @@ -0,0 +1,3 @@ +//Default highWaterMark for readable stream buffers us 64K (2^16) +//so we go over that to get more than a buffer's worth +process.stdout.write('a\n'.repeat(2**24)); diff --git a/packages/exec/__tests__/scripts/stdoutputspecial.js b/packages/exec/__tests__/scripts/stdoutputspecial.js new file mode 100644 index 00000000..21bc5e1f --- /dev/null +++ b/packages/exec/__tests__/scripts/stdoutputspecial.js @@ -0,0 +1,5 @@ +//first half of © character +process.stdout.write(Buffer.from([0xC2]), (err) => { + //write in the callback so that the second byte is sent separately + process.stdout.write(Buffer.from([0xA9])) //second half of © character +}) diff --git a/packages/exec/src/exec.ts b/packages/exec/src/exec.ts index 80a0363c..92b5d8e7 100644 --- a/packages/exec/src/exec.ts +++ b/packages/exec/src/exec.ts @@ -1,7 +1,8 @@ -import {ExecOptions} from './interfaces' +import {StringDecoder} from 'string_decoder' +import {ExecOptions, ExecOutput, ExecListeners} from './interfaces' import * as tr from './toolrunner' -export {ExecOptions} +export {ExecOptions, ExecOutput, ExecListeners} /** * Exec a command. @@ -28,3 +29,63 @@ export async function exec( const runner: tr.ToolRunner = new tr.ToolRunner(toolPath, args, options) return runner.exec() } + +/** + * Exec a command and get the output. + * Output will be streamed to the live console. + * Returns promise with the exit code and collected stdout and stderr + * + * @param commandLine command to execute (can include additional args). Must be correctly escaped. + * @param args optional arguments for tool. Escaping is handled by the lib. + * @param options optional exec options. See ExecOptions + * @returns Promise exit code, stdout, and stderr + */ + +export async function getExecOutput( + commandLine: string, + args?: string[], + options?: ExecOptions +): Promise { + let stdout = '' + let stderr = '' + + //Using string decoder covers the case where a mult-byte character is split + const stdoutDecoder = new StringDecoder('utf8') + const stderrDecoder = new StringDecoder('utf8') + + const originalStdoutListener = options?.listeners?.stdout + const originalStdErrListener = options?.listeners?.stderr + + const stdErrListener = (data: Buffer): void => { + stderr += stderrDecoder.write(data) + if (originalStdErrListener) { + originalStdErrListener(data) + } + } + + const stdOutListener = (data: Buffer): void => { + stdout += stdoutDecoder.write(data) + if (originalStdoutListener) { + originalStdoutListener(data) + } + } + + const listeners: ExecListeners = { + ...options?.listeners, + stdout: stdOutListener, + stderr: stdErrListener + } + + const exitCode = await exec(commandLine, args, {...options, listeners}) + + //flush any remaining characters + stdout += stdoutDecoder.end() + stderr += stderrDecoder.end() + + //return undefined for stdout/stderr if they are empty + return { + exitCode, + stdout, + stderr + } +} diff --git a/packages/exec/src/interfaces.ts b/packages/exec/src/interfaces.ts index 436fc0ac..c86080c4 100644 --- a/packages/exec/src/interfaces.ts +++ b/packages/exec/src/interfaces.ts @@ -34,15 +34,39 @@ export interface ExecOptions { input?: Buffer /** optional. Listeners for output. Callback functions that will be called on these events */ - listeners?: { - stdout?: (data: Buffer) => void - - stderr?: (data: Buffer) => void - - stdline?: (data: string) => void - - errline?: (data: string) => void - - debug?: (data: string) => void - } + listeners?: ExecListeners +} + +/** + * Interface for the output of getExecOutput() + */ +export interface ExecOutput { + /**The exit code of the process */ + exitCode: number + + /**The entire stdout of the process as a string */ + stdout: string + + /**The entire stderr of the process as a string */ + stderr: string +} + +/** + * The user defined listeners for an exec call + */ +export interface ExecListeners { + /** A call back for each buffer of stdout */ + stdout?: (data: Buffer) => void + + /** A call back for each buffer of stderr */ + stderr?: (data: Buffer) => void + + /** A call back for each line of stdout */ + stdline?: (data: string) => void + + /** A call back for each line of stderr */ + errline?: (data: string) => void + + /** A call back for each debug log */ + debug?: (data: string) => void }