@oclif/test
Advanced tools
Comparing version 4.0.1-dev.0 to 4.0.1
@@ -6,3 +6,3 @@ import { Errors, Interfaces } from '@oclif/core'; | ||
}; | ||
export declare function captureOutput<T>(fn: () => Promise<unknown>, opts?: CaptureOptions): Promise<{ | ||
type CaptureResult<T> = { | ||
error?: Error & Partial<Errors.CLIError>; | ||
@@ -12,15 +12,45 @@ result?: T; | ||
stdout: string; | ||
}>; | ||
export declare function runCommand<T>(args: string[], loadOpts?: Interfaces.LoadOptions, captureOpts?: CaptureOptions): Promise<{ | ||
error?: Error & Partial<Errors.CLIError>; | ||
result?: T; | ||
stderr: string; | ||
stdout: string; | ||
}>; | ||
export declare 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; | ||
}>; | ||
}; | ||
/** | ||
* 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 declare function captureOutput<T>(fn: () => Promise<unknown>, opts?: CaptureOptions): Promise<CaptureResult<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 declare function runCommand<T>(args: string | string[], loadOpts?: Interfaces.LoadOptions, captureOpts?: CaptureOptions): Promise<CaptureResult<T>>; | ||
/** | ||
* 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 declare function runHook<T>(hook: string, options: Record<string, unknown>, loadOpts?: Interfaces.LoadOptions, captureOpts?: CaptureOptions): Promise<CaptureResult<T>>; | ||
export {}; |
153
lib/index.js
@@ -11,44 +11,3 @@ "use strict"; | ||
const node_path_1 = require("node:path"); | ||
const debug = (0, debug_1.default)('test'); | ||
const RECORD_OPTIONS = { | ||
print: false, | ||
stripAnsi: true, | ||
}; | ||
const originals = { | ||
stderr: process.stderr.write, | ||
stdout: process.stdout.write, | ||
}; | ||
const output = { | ||
stderr: [], | ||
stdout: [], | ||
}; | ||
function mockedStdout(str, encoding, cb) { | ||
output.stdout.push(str); | ||
if (!RECORD_OPTIONS.print) | ||
return true; | ||
if (typeof encoding === 'string') { | ||
return originals.stdout.bind(process.stdout)(str, encoding, cb); | ||
} | ||
return originals.stdout.bind(process.stdout)(str, cb); | ||
} | ||
function mockedStderr(str, encoding, cb) { | ||
output.stderr.push(str); | ||
if (!RECORD_OPTIONS.print) | ||
return true; | ||
if (typeof encoding === 'string') { | ||
return originals.stdout.bind(process.stderr)(str, encoding, cb); | ||
} | ||
return originals.stdout.bind(process.stderr)(str, cb); | ||
} | ||
const restore = () => { | ||
process.stderr.write = originals.stderr; | ||
process.stdout.write = originals.stdout; | ||
}; | ||
const reset = () => { | ||
output.stderr = []; | ||
output.stdout = []; | ||
}; | ||
const toString = (str) => RECORD_OPTIONS.stripAnsi ? ansis_1.default.strip(str.toString()) : str.toString(); | ||
const getStderr = () => output.stderr.map((b) => toString(b)).join(''); | ||
const getStdout = () => output.stdout.map((b) => toString(b)).join(''); | ||
const debug = (0, debug_1.default)('oclif-test'); | ||
function traverseFilePathUntil(filename, predicate) { | ||
@@ -61,14 +20,56 @@ let current = filename; | ||
} | ||
function makeLoadOptions(loadOpts) { | ||
return (loadOpts ?? { | ||
root: traverseFilePathUntil( | ||
function findRoot() { | ||
return (process.env.OCLIF_TEST_ROOT ?? | ||
// eslint-disable-next-line unicorn/prefer-module | ||
require.main?.path ?? module.path, (p) => !(p.includes('node_modules') || p.includes('.pnpm') || p.includes('.yarn'))), | ||
}); | ||
Object.values(require.cache).find((m) => m?.children.includes(module))?.filename ?? | ||
traverseFilePathUntil( | ||
// eslint-disable-next-line unicorn/prefer-module | ||
require.main?.path ?? module.path, (p) => !(p.includes('node_modules') || p.includes('.pnpm') || p.includes('.yarn')))); | ||
} | ||
function makeLoadOptions(loadOpts) { | ||
return loadOpts ?? { root: findRoot() }; | ||
} | ||
/** | ||
* 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 | ||
*/ | ||
async function captureOutput(fn, opts) { | ||
RECORD_OPTIONS.print = opts?.print ?? false; | ||
RECORD_OPTIONS.stripAnsi = opts?.stripAnsi ?? true; | ||
process.stderr.write = mockedStderr; | ||
process.stdout.write = mockedStdout; | ||
const print = opts?.print ?? false; | ||
const stripAnsi = opts?.stripAnsi ?? true; | ||
const originals = { | ||
NODE_ENV: process.env.NODE_ENV, | ||
stderr: process.stderr.write, | ||
stdout: process.stdout.write, | ||
}; | ||
const output = { | ||
stderr: [], | ||
stdout: [], | ||
}; | ||
const toString = (str) => (stripAnsi ? ansis_1.default.strip(str.toString()) : str.toString()); | ||
const getStderr = () => output.stderr.map((b) => toString(b)).join(''); | ||
const getStdout = () => output.stdout.map((b) => toString(b)).join(''); | ||
const mock = (std) => (str, encoding, cb) => { | ||
output[std].push(str); | ||
if (print) { | ||
if (encoding !== null && typeof encoding === 'function') { | ||
cb = encoding; | ||
encoding = undefined; | ||
} | ||
originals[std].apply(process[std], [str, encoding, cb]); | ||
} | ||
else if (typeof cb === 'function') | ||
cb(); | ||
return true; | ||
}; | ||
process.stdout.write = mock('stdout'); | ||
process.stderr.write = mock('stderr'); | ||
process.env.NODE_ENV = 'test'; | ||
try { | ||
@@ -84,4 +85,4 @@ const result = await fn(); | ||
return { | ||
...(error instanceof core_1.Errors.CLIError && { error }), | ||
...(error instanceof Error && { error }), | ||
...(error instanceof core_1.Errors.CLIError && { error: { ...error, message: toString(error.message) } }), | ||
...(error instanceof Error && { error: { ...error, message: toString(error.message) } }), | ||
stderr: getStderr(), | ||
@@ -92,21 +93,53 @@ stdout: getStdout(), | ||
finally { | ||
restore(); | ||
reset(); | ||
process.stderr.write = originals.stderr; | ||
process.stdout.write = originals.stdout; | ||
process.env.NODE_ENV = originals.NODE_ENV; | ||
} | ||
} | ||
exports.captureOutput = captureOutput; | ||
/** | ||
* 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 | ||
*/ | ||
async function runCommand(args, loadOpts, captureOpts) { | ||
const loadOptions = makeLoadOptions(loadOpts); | ||
debug('loadOpts: %O', loadOpts); | ||
return captureOutput(async () => (0, core_1.run)(args, loadOptions), captureOpts); | ||
const argsArray = (Array.isArray(args) ? args : [args]).join(' ').split(' '); | ||
const [id, ...rest] = argsArray; | ||
const finalArgs = id === '.' ? rest : argsArray; | ||
debug('loadOpts: %O', loadOptions); | ||
debug('args: %O', finalArgs); | ||
return captureOutput(async () => (0, core_1.run)(finalArgs, loadOptions), captureOpts); | ||
} | ||
exports.runCommand = runCommand; | ||
async function runHook(hook, options, loadOpts, recordOpts) { | ||
/** | ||
* 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 | ||
*/ | ||
async function runHook(hook, options, loadOpts, captureOpts) { | ||
const loadOptions = makeLoadOptions(loadOpts); | ||
debug('loadOpts: %O', loadOpts); | ||
debug('loadOpts: %O', loadOptions); | ||
return captureOutput(async () => { | ||
const config = await core_1.Config.load(loadOptions); | ||
return config.runHook(hook, options); | ||
}, recordOpts); | ||
}, captureOpts); | ||
} | ||
exports.runHook = runHook; |
{ | ||
"name": "@oclif/test", | ||
"description": "test helpers for oclif components", | ||
"version": "4.0.1-dev.0", | ||
"version": "4.0.1", | ||
"author": "Salesforce", | ||
@@ -12,3 +12,3 @@ "bugs": "https://github.com/oclif/test/issues", | ||
"peerDependencies": { | ||
"@oclif/core": "^4.0.0-beta.6" | ||
"@oclif/core": ">= 3.0.0" | ||
}, | ||
@@ -19,5 +19,7 @@ "devDependencies": { | ||
"@oclif/prettier-config": "^0.2.1", | ||
"@types/chai": "^4.3.16", | ||
"@types/debug": "^4.1.12", | ||
"@types/mocha": "^10", | ||
"@types/node": "^18", | ||
"chai": "^5.1.1", | ||
"commitlint": "^18.6.1", | ||
@@ -33,3 +35,3 @@ "eslint": "^8.57.0", | ||
"shx": "^0.3.3", | ||
"ts-node": "^10.9.2", | ||
"tsx": "^4.10.2", | ||
"typescript": "^5.4.5" | ||
@@ -36,0 +38,0 @@ }, |
229
README.md
@@ -9,228 +9,31 @@ # @oclif/test | ||
## Usage | ||
## Migration | ||
`@oclif/test` is an extension of [fancy-test](https://github.com/oclif/fancy-test). Please see the [fancy-test documentation](https://github.com/oclif/fancy-test#fancy-test) for all the features that are available. | ||
See the [V4 Migration Guide](./MIGRATION.md) if you are migrating from v3 or older. | ||
The following are the features that `@oclif/test` adds to `fancy-test`. | ||
## Usage | ||
### `.loadConfig()` | ||
`@oclif/test` provides a handful of utilities that make it easy to test your [oclif](https://oclif.io) CLI. | ||
`.loadConfig()` creates and returns a new [`Config`](https://github.com/oclif/core/blob/main/src/config/config.ts) instance. This instance will be available on the `ctx` variable that's provided in the callback. | ||
### `captureOutput` | ||
```typescript | ||
import {join} from 'node:path' | ||
import {expect, test} from '@oclif/test' | ||
`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. | ||
const root = join(__dirname, 'fixtures/test-cli') | ||
test | ||
.loadConfig({root}) | ||
.stdout() | ||
.command(['foo:bar']) | ||
.it('should run the command from the given directory', (ctx) => { | ||
expect(ctx.stdout).to.equal('hello world!\n') | ||
expect(ctx.config.root).to.equal(root) | ||
const {name} = ctx.returned as {name: string} | ||
expect(name).to.equal('world') | ||
}) | ||
``` | ||
**Options** | ||
If you would like to run the same test without using `@oclif/test`: | ||
- `print` - Print everything that goes to stdout and stderr. | ||
- `stripAnsi` - Strip ansi codes from everything that goes to stdout and stderr. Defaults to true. | ||
```typescript | ||
import {Config, ux} from '@oclif/core' | ||
import {expect} from 'chai' | ||
import {join} from 'node:path' | ||
import {SinonSandbox, SinonStub, createSandbox} from 'sinon' | ||
See the [tests](./test/capture-output.test.ts) for example usage. | ||
const root = join(__dirname, 'fixtures/test-cli') | ||
describe('non-fancy test', () => { | ||
let sandbox: SinonSandbox | ||
let config: Config | ||
let stdoutStub: SinonStub | ||
### `runCommand` | ||
beforeEach(async () => { | ||
sandbox = createSandbox() | ||
stdoutStub = sandbox.stub(ux.write, 'stdout') | ||
config = await Config.load({root}) | ||
}) | ||
`runCommand` allows you to get the stdout, stderr, return value, and error of a command in your CLI. | ||
afterEach(async () => { | ||
sandbox.restore() | ||
}) | ||
See the [tests](./test/run-command.test.ts) for example usage. | ||
it('should run command from the given directory', async () => { | ||
const {name} = await config.runCommand<{name: string}>('foo:bar') | ||
expect(stdoutStub.calledWith('hello world!\n')).to.be.true | ||
expect(config.root).to.equal(root) | ||
expect(name).to.equal('world') | ||
}) | ||
}) | ||
``` | ||
### `runHook` | ||
### `.command()` | ||
`runHook` allows you to get the stdout, stderr, return value, and error of a hook in your CLI. | ||
`.command()` let's you run a command from your CLI. | ||
```typescript | ||
import {expect, test} from '@oclif/test' | ||
describe('hello world', () => { | ||
test | ||
.stdout() | ||
.command(['hello:world']) | ||
.it('runs hello world cmd', (ctx) => { | ||
expect(ctx.stdout).to.contain('hello world!') | ||
}) | ||
}) | ||
``` | ||
For a [single command cli](https://oclif.io/docs/single_command_cli) you would provide `'.'` as the command. For instance: | ||
```typescript | ||
import {expect, test} from '@oclif/test' | ||
describe('hello world', () => { | ||
test | ||
.stdout() | ||
.command(['.']) | ||
.it('runs hello world cmd', (ctx) => { | ||
expect(ctx.stdout).to.contain('hello world!') | ||
}) | ||
}) | ||
``` | ||
If you would like to run the same test without using `@oclif/test`: | ||
```typescript | ||
import {Config, ux} from '@oclif/core' | ||
import {expect} from 'chai' | ||
import {SinonSandbox, SinonStub, createSandbox} from 'sinon' | ||
describe('non-fancy test', () => { | ||
let sandbox: SinonSandbox | ||
let config: Config | ||
let stdoutStub: SinonStub | ||
beforeEach(async () => { | ||
sandbox = createSandbox() | ||
stdoutStub = sandbox.stub(ux.write, 'stdout') | ||
config = await Config.load({root: process.cwd()}) | ||
}) | ||
afterEach(async () => { | ||
sandbox.restore() | ||
}) | ||
it('should run command', async () => { | ||
// use '.' for a single command CLI | ||
const {name} = await config.runCommand<{name: string}>('hello:world') | ||
expect(stdoutStub.calledWith('hello world!\n')).to.be.true | ||
expect(name).to.equal('world') | ||
}) | ||
}) | ||
``` | ||
### `.exit()` | ||
`.exit()` let's you test that a command exited with a certain exit code. | ||
```typescript | ||
import {join} from 'node:path' | ||
import {expect, test} from '@oclif/test' | ||
describe('exit', () => { | ||
test | ||
.loadConfig() | ||
.stdout() | ||
.command(['hello:world', '--code=101']) | ||
.exit(101) | ||
.do((output) => expect(output.stdout).to.equal('exiting with code 101\n')) | ||
.it('should exit with code 101') | ||
}) | ||
``` | ||
If you would like to run the same test without using `@oclif/test`: | ||
```typescript | ||
import {Config, Errors, ux} from '@oclif/core' | ||
import {expect} from 'chai' | ||
import {SinonSandbox, createSandbox} from 'sinon' | ||
describe('non-fancy test', () => { | ||
let sandbox: SinonSandbox | ||
let config: Config | ||
beforeEach(async () => { | ||
sandbox = createSandbox() | ||
sandbox.stub(ux.write, 'stdout') | ||
config = await Config.load({root: process.cwd()}) | ||
}) | ||
afterEach(async () => { | ||
sandbox.restore() | ||
}) | ||
it('should run command from the given directory', async () => { | ||
try { | ||
await config.runCommand('.') | ||
throw new Error('Expected CLIError to be thrown') | ||
} catch (error) { | ||
if (error instanceof Errors.CLIError) { | ||
expect(error.oclif.exit).to.equal(101) | ||
} else { | ||
throw error | ||
} | ||
} | ||
}) | ||
}) | ||
``` | ||
### `.hook()` | ||
`.hook()` let's you test a hook in your CLI. | ||
```typescript | ||
import {join} from 'node:path' | ||
import {expect, test} from '@oclif/test' | ||
const root = join(__dirname, 'fixtures/test-cli') | ||
describe('hooks', () => { | ||
test | ||
.loadConfig({root}) | ||
.stdout() | ||
.hook('foo', {argv: ['arg']}, {root}) | ||
.do((output) => expect(output.stdout).to.equal('foo hook args: arg\n')) | ||
.it('should run hook') | ||
}) | ||
``` | ||
If you would like to run the same test without using `@oclif/test`: | ||
```typescript | ||
import {Config, ux} from '@oclif/core' | ||
import {expect} from 'chai' | ||
import {SinonSandbox, SinonStub, createSandbox} from 'sinon' | ||
describe('non-fancy test', () => { | ||
let sandbox: SinonSandbox | ||
let config: Config | ||
let stdoutStub: SinonStub | ||
beforeEach(async () => { | ||
sandbox = createSandbox() | ||
stdoutStub = sandbox.stub(ux.write, 'stdout') | ||
config = await Config.load({root: process.cwd()}) | ||
}) | ||
afterEach(async () => { | ||
sandbox.restore() | ||
}) | ||
it('should run hook', async () => { | ||
const {name} = await config.runHook('foo', {argv: ['arg']}) | ||
expect(stdoutStub.calledWith('foo hook args: arg\n')).to.be.true | ||
}) | ||
}) | ||
``` | ||
See the [tests](./test/run-hook.test.ts) for example usage. |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
195
0
12281
20
39
5