Skip to content

Commit

Permalink
fix: jsdocs and types
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonnalley committed May 17, 2024
1 parent 0b69ca7 commit ba37ede
Show file tree
Hide file tree
Showing 6 changed files with 89 additions and 32 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ See the [V4 Migration Guide](./MIGRATION.md) if you are migrating from v3 or old

`captureOutput` allows you to get the stdout, stderr, return value, and error of the callback you provide it. This makes it possible to assert that certain strings were printed to stdout and stderr or that the callback failed with the expected error or succeeded with the expected result.

**Options**

- `print` - Print everything that goes to stdout and stderr.
- `stripAnsi` - Strip ansi codes from everything that goes to stdout and stderr. Defaults to true.

See the [tests](./test/capture-output.test.ts) for example usage.

### `runCommand`
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
"author": "Salesforce",
"bugs": "https://github.com/oclif/test/issues",
"dependencies": {
"ansis": "^3.2.0",
"debug": "^4.3.4"
"debug": "^4.3.4",
"strip-ansi": "^7.1.0"
},
"peerDependencies": {
"@oclif/core": "^4.0.0-beta.7"
"@oclif/core": ">= 3.0.0"
},
"devDependencies": {
"@commitlint/config-conventional": "^18.6.3",
Expand Down
79 changes: 53 additions & 26 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {Config, Errors, Interfaces, run} from '@oclif/core'
import ansis from 'ansis'
import makeDebug from 'debug'
import {dirname} from 'node:path'

Expand All @@ -10,6 +9,13 @@ type CaptureOptions = {
stripAnsi?: boolean
}

type CaptureResult<T> = {
error?: Error & Partial<Errors.CLIError>
result?: T
stderr: string
stdout: string
}

type MockedStdout = typeof process.stdout.write
type MockedStderr = typeof process.stderr.write

Expand Down Expand Up @@ -39,17 +45,20 @@ function makeLoadOptions(loadOpts?: Interfaces.LoadOptions): Interfaces.LoadOpti
return loadOpts ?? {root: findRoot()}
}

export async function captureOutput<T>(
fn: () => Promise<unknown>,
opts?: CaptureOptions,
): Promise<{
error?: Error & Partial<Errors.CLIError>
result?: T
stderr: string
stdout: string
}> {
/**
* Capture the stderr and stdout output of a function
* @param fn async function to run
* @param opts options
* - print: Whether to print the output to the console
* - stripAnsi: Whether to strip ANSI codes from the output
* @returns {Promise<CaptureResult<T>>} Captured output
* - error: Error object if the function throws an error
* - result: Result of the function if it returns a value and succeeds
* - stderr: Captured stderr output
* - stdout: Captured stdout output
*/
export async function captureOutput<T>(fn: () => Promise<unknown>, opts?: CaptureOptions): Promise<CaptureResult<T>> {
const print = opts?.print ?? false
const stripAnsi = opts?.stripAnsi ?? true

const originals = {
NODE_ENV: process.env.NODE_ENV,
Expand All @@ -62,7 +71,8 @@ export async function captureOutput<T>(
stdout: [],
}

const toString = (str: Uint8Array | string): string => (stripAnsi ? ansis.strip(str.toString()) : str.toString())
const {default: stripAnsi} = opts?.stripAnsi ?? true ? await import('strip-ansi') : {default: (str: string) => str}
const toString = (str: Uint8Array | string): string => stripAnsi(str.toString())
const getStderr = (): string => output.stderr.map((b) => toString(b)).join('')
const getStdout = (): string => output.stdout.map((b) => toString(b)).join('')

Expand Down Expand Up @@ -108,16 +118,24 @@ export async function captureOutput<T>(
}
}

/**
* Capture the stderr and stdout output of a command in your CLI
* @param args Command arguments, e.g. `['my:command', '--flag']` or `'my:command --flag'`
* @param loadOpts options for loading oclif `Config`
* @param captureOpts options for capturing the output
* - print: Whether to print the output to the console
* - stripAnsi: Whether to strip ANSI codes from the output
* @returns {Promise<CaptureResult<T>>} Captured output
* - error: Error object if the command throws an error
* - result: Result of the command if it returns a value and succeeds
* - stderr: Captured stderr output
* - stdout: Captured stdout output
*/
export async function runCommand<T>(
args: string | string[],
loadOpts?: Interfaces.LoadOptions,
captureOpts?: CaptureOptions,
): Promise<{
error?: Error & Partial<Errors.CLIError>
result?: T
stderr: string
stdout: string
}> {
): Promise<CaptureResult<T>> {
const loadOptions = makeLoadOptions(loadOpts)
const argsArray = (Array.isArray(args) ? args : [args]).join(' ').split(' ')

Expand All @@ -130,23 +148,32 @@ export async function runCommand<T>(
return captureOutput<T>(async () => run(finalArgs, loadOptions), captureOpts)
}

/**
* Capture the stderr and stdout output of a hook in your CLI
* @param hook Hook name
* @param options options to pass to the hook
* @param loadOpts options for loading oclif `Config`
* @param captureOpts options for capturing the output
* - print: Whether to print the output to the console
* - stripAnsi: Whether to strip ANSI codes from the output
* @returns {Promise<CaptureResult<T>>} Captured output
* - error: Error object if the hook throws an error
* - result: Result of the hook if it returns a value and succeeds
* - stderr: Captured stderr output
* - stdout: Captured stdout output
*/
export async function runHook<T>(
hook: string,
options: Record<string, unknown>,
loadOpts?: Interfaces.LoadOptions,
recordOpts?: CaptureOptions,
): Promise<{
error?: Error & Partial<Errors.CLIError>
result?: T
stderr: string
stdout: string
}> {
captureOpts?: CaptureOptions,
): Promise<CaptureResult<T>> {
const loadOptions = makeLoadOptions(loadOpts)

debug('loadOpts: %O', loadOptions)

return captureOutput<T>(async () => {
const config = await Config.load(loadOptions)
return config.runHook(hook, options)
}, recordOpts)
}, captureOpts)
}
16 changes: 14 additions & 2 deletions test/capture-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {expect} from 'chai'

import {captureOutput} from '../src'

const bold = (s: string) => `\u001B[1m${s}\u001B[22m`

class MyCommand extends Command {
static flags = {
channel: Flags.option({
Expand All @@ -20,11 +22,11 @@ class MyCommand extends Command {
if (flags.throw) throw new Errors.CLIError('error', {exit: flags.throw})

if (flags.channel.includes('stdout')) {
this.log('hello world!')
this.log(bold('hello world!'))
}

if (flags.channel.includes('stderr')) {
this.logToStderr('hello world!')
this.logToStderr(bold('hello world!'))
}

return {success: true}
Expand Down Expand Up @@ -66,4 +68,14 @@ describe('captureOutput', () => {
const {error} = await captureOutput(async () => MyCommand.run(['-c=stdout', '--throw=101']))
expect(error?.oclif?.exit).to.equal(101)
})

it('should strip ansi codes by default', async () => {
const {stdout} = await captureOutput(async () => MyCommand.run(['-c=stdout']))
expect(stdout).to.equal('hello world!\n')
})

it('should not strip ansi codes if stripAnsi is false', async () => {
const {stdout} = await captureOutput(async () => MyCommand.run(['-c=stdout']), {stripAnsi: false})
expect(stdout).to.equal('\u001B[1mhello world!\u001B[22m\n')
})
})
13 changes: 13 additions & 0 deletions test/run-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {Config} from '@oclif/core'
import {expect} from 'chai'
import {join} from 'node:path'

Expand Down Expand Up @@ -37,6 +38,18 @@ describe('runCommand', () => {
expect(error?.message).to.equal('EEXIT: 101')
expect(error?.oclif?.exit).to.equal(101)
})

it('should take existing Config instance', async () => {
const config = await Config.load(root)
const {result, stdout} = await runCommand<{name: string}>(['foo:bar'], config)
expect(stdout).to.equal('hello world!\n')
expect(result?.name).to.equal('world')
})

it('should find root dynamically if not provided', async () => {
const {stdout} = await runCommand(['--help'])
expect(stdout).to.include('$ @oclif/test [COMMAND]')
})
})

describe('single command cli', () => {
Expand Down
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,7 @@ ansi-styles@^6.0.0, ansi-styles@^6.2.1:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==

ansis@^3.0.1, ansis@^3.2.0:
ansis@^3.0.1:
version "3.2.0"
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.2.0.tgz#0e050c5be94784f32ffdac4b84fccba064aeae4b"
integrity sha512-Yk3BkHH9U7oPyCN3gL5Tc7CpahG/+UFv/6UG03C311Vy9lzRmA5uoxDTpU9CO3rGHL6KzJz/pdDeXZCZ5Mu/Sg==
Expand Down

0 comments on commit ba37ede

Please sign in to comment.