1
0
Fork 0

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
pull/817/head
Luke Tomlinson 2021-05-21 12:12:16 -04:00 committed by GitHub
parent 566ea66979
commit ddd04b6997
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 265 additions and 13 deletions

View File

@ -636,6 +636,165 @@ describe('@actions/exec', () => {
expect(output.trim()).toBe(`args[0]: "hello"${os.EOL}args[1]: "world"`) 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) { if (IS_WINDOWS) {
it('Exec roots relative tool path using process.cwd (Windows path separator)', async () => { it('Exec roots relative tool path using process.cwd (Windows path separator)', async () => {
let exitCode: number let exitCode: number

View File

@ -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));

View File

@ -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
})

View File

@ -1,7 +1,8 @@
import {ExecOptions} from './interfaces' import {StringDecoder} from 'string_decoder'
import {ExecOptions, ExecOutput, ExecListeners} from './interfaces'
import * as tr from './toolrunner' import * as tr from './toolrunner'
export {ExecOptions} export {ExecOptions, ExecOutput, ExecListeners}
/** /**
* Exec a command. * Exec a command.
@ -28,3 +29,63 @@ export async function exec(
const runner: tr.ToolRunner = new tr.ToolRunner(toolPath, args, options) const runner: tr.ToolRunner = new tr.ToolRunner(toolPath, args, options)
return runner.exec() 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
}
}

View File

@ -34,15 +34,39 @@ export interface ExecOptions {
input?: Buffer input?: Buffer
/** optional. Listeners for output. Callback functions that will be called on these events */ /** optional. Listeners for output. Callback functions that will be called on these events */
listeners?: { listeners?: ExecListeners
stdout?: (data: Buffer) => void }
stderr?: (data: Buffer) => void /**
* Interface for the output of getExecOutput()
stdline?: (data: string) => void */
export interface ExecOutput {
errline?: (data: string) => void /**The exit code of the process */
exitCode: number
debug?: (data: string) => void
} /**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
} }