Skip to content

Commit

Permalink
Add getExecOutput function (#814)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
luketomlinson committed May 21, 2021
1 parent 566ea66 commit ddd04b6
Show file tree
Hide file tree
Showing 5 changed files with 261 additions and 9 deletions.
159 changes: 159 additions & 0 deletions packages/exec/__tests__/exec.test.ts
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions 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));
5 changes: 5 additions & 0 deletions 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
})
65 changes: 63 additions & 2 deletions 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.
Expand All @@ -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<ExecOutput> exit code, stdout, and stderr
*/

export async function getExecOutput(
commandLine: string,
args?: string[],
options?: ExecOptions
): Promise<ExecOutput> {
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
}
}
38 changes: 31 additions & 7 deletions packages/exec/src/interfaces.ts
Expand Up @@ -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
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

stderr?: (data: Buffer) => void
/** A call back for each buffer of stderr */
stderr?: (data: Buffer) => void

stdline?: (data: string) => void
/** A call back for each line of stdout */
stdline?: (data: string) => void

errline?: (data: string) => void
/** A call back for each line of stderr */
errline?: (data: string) => void

debug?: (data: string) => void
}
/** A call back for each debug log */
debug?: (data: string) => void
}

0 comments on commit ddd04b6

Please sign in to comment.