Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add getExecOutput function #814

Merged
merged 7 commits into from May 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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)
luketomlinson marked this conversation as resolved.
Show resolved Hide resolved
//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
luketomlinson marked this conversation as resolved.
Show resolved Hide resolved
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
}