@oclif/test 4.0.1-dev.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,230 +6,33 @@ test helpers for oclif CLIs
6
6
  [![Downloads/week](https://img.shields.io/npm/dw/@oclif/test.svg)](https://npmjs.org/package/@oclif/test)
7
7
  [![License](https://img.shields.io/npm/l/@oclif/test.svg)](https://github.com/oclif/test/blob/main/package.json)
8
8
 
9
+ ## Migration
10
+
11
+ See the [V4 Migration Guide](./MIGRATION.md) if you are migrating from v3 or older.
12
+
9
13
  ## Usage
10
14
 
11
- `@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.
12
-
13
- The following are the features that `@oclif/test` adds to `fancy-test`.
14
-
15
- ### `.loadConfig()`
16
-
17
- `.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.
18
-
19
- ```typescript
20
- import {join} from 'node:path'
21
- import {expect, test} from '@oclif/test'
22
-
23
- const root = join(__dirname, 'fixtures/test-cli')
24
- test
25
- .loadConfig({root})
26
- .stdout()
27
- .command(['foo:bar'])
28
- .it('should run the command from the given directory', (ctx) => {
29
- expect(ctx.stdout).to.equal('hello world!\n')
30
- expect(ctx.config.root).to.equal(root)
31
- const {name} = ctx.returned as {name: string}
32
- expect(name).to.equal('world')
33
- })
34
- ```
35
-
36
- If you would like to run the same test without using `@oclif/test`:
37
-
38
- ```typescript
39
- import {Config, ux} from '@oclif/core'
40
- import {expect} from 'chai'
41
- import {join} from 'node:path'
42
- import {SinonSandbox, SinonStub, createSandbox} from 'sinon'
43
-
44
- const root = join(__dirname, 'fixtures/test-cli')
45
- describe('non-fancy test', () => {
46
- let sandbox: SinonSandbox
47
- let config: Config
48
- let stdoutStub: SinonStub
49
-
50
- beforeEach(async () => {
51
- sandbox = createSandbox()
52
- stdoutStub = sandbox.stub(ux.write, 'stdout')
53
- config = await Config.load({root})
54
- })
55
-
56
- afterEach(async () => {
57
- sandbox.restore()
58
- })
59
-
60
- it('should run command from the given directory', async () => {
61
- const {name} = await config.runCommand<{name: string}>('foo:bar')
62
- expect(stdoutStub.calledWith('hello world!\n')).to.be.true
63
- expect(config.root).to.equal(root)
64
- expect(name).to.equal('world')
65
- })
66
- })
67
- ```
68
-
69
- ### `.command()`
70
-
71
- `.command()` let's you run a command from your CLI.
72
-
73
- ```typescript
74
- import {expect, test} from '@oclif/test'
75
-
76
- describe('hello world', () => {
77
- test
78
- .stdout()
79
- .command(['hello:world'])
80
- .it('runs hello world cmd', (ctx) => {
81
- expect(ctx.stdout).to.contain('hello world!')
82
- })
83
- })
84
- ```
85
-
86
- For a [single command cli](https://oclif.io/docs/single_command_cli) you would provide `'.'` as the command. For instance:
87
-
88
- ```typescript
89
- import {expect, test} from '@oclif/test'
90
-
91
- describe('hello world', () => {
92
- test
93
- .stdout()
94
- .command(['.'])
95
- .it('runs hello world cmd', (ctx) => {
96
- expect(ctx.stdout).to.contain('hello world!')
97
- })
98
- })
99
- ```
100
-
101
- If you would like to run the same test without using `@oclif/test`:
102
-
103
- ```typescript
104
- import {Config, ux} from '@oclif/core'
105
- import {expect} from 'chai'
106
- import {SinonSandbox, SinonStub, createSandbox} from 'sinon'
107
-
108
- describe('non-fancy test', () => {
109
- let sandbox: SinonSandbox
110
- let config: Config
111
- let stdoutStub: SinonStub
112
-
113
- beforeEach(async () => {
114
- sandbox = createSandbox()
115
- stdoutStub = sandbox.stub(ux.write, 'stdout')
116
- config = await Config.load({root: process.cwd()})
117
- })
118
-
119
- afterEach(async () => {
120
- sandbox.restore()
121
- })
122
-
123
- it('should run command', async () => {
124
- // use '.' for a single command CLI
125
- const {name} = await config.runCommand<{name: string}>('hello:world')
126
- expect(stdoutStub.calledWith('hello world!\n')).to.be.true
127
- expect(name).to.equal('world')
128
- })
129
- })
130
- ```
131
-
132
- ### `.exit()`
133
-
134
- `.exit()` let's you test that a command exited with a certain exit code.
135
-
136
- ```typescript
137
- import {join} from 'node:path'
138
- import {expect, test} from '@oclif/test'
139
-
140
- describe('exit', () => {
141
- test
142
- .loadConfig()
143
- .stdout()
144
- .command(['hello:world', '--code=101'])
145
- .exit(101)
146
- .do((output) => expect(output.stdout).to.equal('exiting with code 101\n'))
147
- .it('should exit with code 101')
148
- })
149
- ```
150
-
151
- If you would like to run the same test without using `@oclif/test`:
152
-
153
- ```typescript
154
- import {Config, Errors, ux} from '@oclif/core'
155
- import {expect} from 'chai'
156
- import {SinonSandbox, createSandbox} from 'sinon'
157
-
158
- describe('non-fancy test', () => {
159
- let sandbox: SinonSandbox
160
- let config: Config
161
-
162
- beforeEach(async () => {
163
- sandbox = createSandbox()
164
- sandbox.stub(ux.write, 'stdout')
165
- config = await Config.load({root: process.cwd()})
166
- })
167
-
168
- afterEach(async () => {
169
- sandbox.restore()
170
- })
171
-
172
- it('should run command from the given directory', async () => {
173
- try {
174
- await config.runCommand('.')
175
- throw new Error('Expected CLIError to be thrown')
176
- } catch (error) {
177
- if (error instanceof Errors.CLIError) {
178
- expect(error.oclif.exit).to.equal(101)
179
- } else {
180
- throw error
181
- }
182
- }
183
- })
184
- })
185
- ```
186
-
187
- ### `.hook()`
188
-
189
- `.hook()` let's you test a hook in your CLI.
190
-
191
- ```typescript
192
- import {join} from 'node:path'
193
-
194
- import {expect, test} from '@oclif/test'
195
-
196
- const root = join(__dirname, 'fixtures/test-cli')
197
-
198
- describe('hooks', () => {
199
- test
200
- .loadConfig({root})
201
- .stdout()
202
- .hook('foo', {argv: ['arg']}, {root})
203
- .do((output) => expect(output.stdout).to.equal('foo hook args: arg\n'))
204
- .it('should run hook')
205
- })
206
- ```
207
-
208
- If you would like to run the same test without using `@oclif/test`:
209
-
210
- ```typescript
211
- import {Config, ux} from '@oclif/core'
212
- import {expect} from 'chai'
213
- import {SinonSandbox, SinonStub, createSandbox} from 'sinon'
214
-
215
- describe('non-fancy test', () => {
216
- let sandbox: SinonSandbox
217
- let config: Config
218
- let stdoutStub: SinonStub
219
-
220
- beforeEach(async () => {
221
- sandbox = createSandbox()
222
- stdoutStub = sandbox.stub(ux.write, 'stdout')
223
- config = await Config.load({root: process.cwd()})
224
- })
225
-
226
- afterEach(async () => {
227
- sandbox.restore()
228
- })
229
-
230
- it('should run hook', async () => {
231
- const {name} = await config.runHook('foo', {argv: ['arg']})
232
- expect(stdoutStub.calledWith('foo hook args: arg\n')).to.be.true
233
- })
234
- })
235
- ```
15
+ `@oclif/test` provides a handful of utilities that make it easy to test your [oclif](https://oclif.io) CLI.
16
+
17
+ ### `captureOutput`
18
+
19
+ `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.
20
+
21
+ **Options**
22
+
23
+ - `print` - Print everything that goes to stdout and stderr.
24
+ - `stripAnsi` - Strip ansi codes from everything that goes to stdout and stderr. Defaults to true.
25
+
26
+ See the [tests](./test/capture-output.test.ts) for example usage.
27
+
28
+ ### `runCommand`
29
+
30
+ `runCommand` allows you to get the stdout, stderr, return value, and error of a command in your CLI.
31
+
32
+ See the [tests](./test/run-command.test.ts) for example usage.
33
+
34
+ ### `runHook`
35
+
36
+ `runHook` allows you to get the stdout, stderr, return value, and error of a hook in your CLI.
37
+
38
+ See the [tests](./test/run-hook.test.ts) for example usage.
package/lib/index.d.ts CHANGED
@@ -3,22 +3,52 @@ type CaptureOptions = {
3
3
  print?: boolean;
4
4
  stripAnsi?: boolean;
5
5
  };
6
- export declare function captureOutput<T>(fn: () => Promise<unknown>, opts?: CaptureOptions): Promise<{
6
+ type CaptureResult<T> = {
7
7
  error?: Error & Partial<Errors.CLIError>;
8
8
  result?: T;
9
9
  stderr: string;
10
10
  stdout: string;
11
- }>;
12
- export declare function runCommand<T>(args: string[], loadOpts?: Interfaces.LoadOptions, captureOpts?: CaptureOptions): Promise<{
13
- error?: Error & Partial<Errors.CLIError>;
14
- result?: T;
15
- stderr: string;
16
- stdout: string;
17
- }>;
18
- export declare function runHook<T>(hook: string, options: Record<string, unknown>, loadOpts?: Interfaces.LoadOptions, recordOpts?: CaptureOptions): Promise<{
19
- error?: Error & Partial<Errors.CLIError>;
20
- result?: T;
21
- stderr: string;
22
- stdout: string;
23
- }>;
11
+ };
12
+ /**
13
+ * Capture the stderr and stdout output of a function
14
+ * @param fn async function to run
15
+ * @param opts options
16
+ * - print: Whether to print the output to the console
17
+ * - stripAnsi: Whether to strip ANSI codes from the output
18
+ * @returns {Promise<CaptureResult<T>>} Captured output
19
+ * - error: Error object if the function throws an error
20
+ * - result: Result of the function if it returns a value and succeeds
21
+ * - stderr: Captured stderr output
22
+ * - stdout: Captured stdout output
23
+ */
24
+ export declare function captureOutput<T>(fn: () => Promise<unknown>, opts?: CaptureOptions): Promise<CaptureResult<T>>;
25
+ /**
26
+ * Capture the stderr and stdout output of a command in your CLI
27
+ * @param args Command arguments, e.g. `['my:command', '--flag']` or `'my:command --flag'`
28
+ * @param loadOpts options for loading oclif `Config`
29
+ * @param captureOpts options for capturing the output
30
+ * - print: Whether to print the output to the console
31
+ * - stripAnsi: Whether to strip ANSI codes from the output
32
+ * @returns {Promise<CaptureResult<T>>} Captured output
33
+ * - error: Error object if the command throws an error
34
+ * - result: Result of the command if it returns a value and succeeds
35
+ * - stderr: Captured stderr output
36
+ * - stdout: Captured stdout output
37
+ */
38
+ export declare function runCommand<T>(args: string | string[], loadOpts?: Interfaces.LoadOptions, captureOpts?: CaptureOptions): Promise<CaptureResult<T>>;
39
+ /**
40
+ * Capture the stderr and stdout output of a hook in your CLI
41
+ * @param hook Hook name
42
+ * @param options options to pass to the hook
43
+ * @param loadOpts options for loading oclif `Config`
44
+ * @param captureOpts options for capturing the output
45
+ * - print: Whether to print the output to the console
46
+ * - stripAnsi: Whether to strip ANSI codes from the output
47
+ * @returns {Promise<CaptureResult<T>>} Captured output
48
+ * - error: Error object if the hook throws an error
49
+ * - result: Result of the hook if it returns a value and succeeds
50
+ * - stderr: Captured stderr output
51
+ * - stdout: Captured stdout output
52
+ */
53
+ export declare function runHook<T>(hook: string, options: Record<string, unknown>, loadOpts?: Interfaces.LoadOptions, captureOpts?: CaptureOptions): Promise<CaptureResult<T>>;
24
54
  export {};
package/lib/index.js CHANGED
@@ -8,48 +8,7 @@ const core_1 = require("@oclif/core");
8
8
  const ansis_1 = __importDefault(require("ansis"));
9
9
  const debug_1 = __importDefault(require("debug"));
10
10
  const node_path_1 = require("node:path");
11
- const debug = (0, debug_1.default)('test');
12
- const RECORD_OPTIONS = {
13
- print: false,
14
- stripAnsi: true,
15
- };
16
- const originals = {
17
- stderr: process.stderr.write,
18
- stdout: process.stdout.write,
19
- };
20
- const output = {
21
- stderr: [],
22
- stdout: [],
23
- };
24
- function mockedStdout(str, encoding, cb) {
25
- output.stdout.push(str);
26
- if (!RECORD_OPTIONS.print)
27
- return true;
28
- if (typeof encoding === 'string') {
29
- return originals.stdout.bind(process.stdout)(str, encoding, cb);
30
- }
31
- return originals.stdout.bind(process.stdout)(str, cb);
32
- }
33
- function mockedStderr(str, encoding, cb) {
34
- output.stderr.push(str);
35
- if (!RECORD_OPTIONS.print)
36
- return true;
37
- if (typeof encoding === 'string') {
38
- return originals.stdout.bind(process.stderr)(str, encoding, cb);
39
- }
40
- return originals.stdout.bind(process.stderr)(str, cb);
41
- }
42
- const restore = () => {
43
- process.stderr.write = originals.stderr;
44
- process.stdout.write = originals.stdout;
45
- };
46
- const reset = () => {
47
- output.stderr = [];
48
- output.stdout = [];
49
- };
50
- const toString = (str) => RECORD_OPTIONS.stripAnsi ? ansis_1.default.strip(str.toString()) : str.toString();
51
- const getStderr = () => output.stderr.map((b) => toString(b)).join('');
52
- const getStdout = () => output.stdout.map((b) => toString(b)).join('');
11
+ const debug = (0, debug_1.default)('oclif-test');
53
12
  function traverseFilePathUntil(filename, predicate) {
54
13
  let current = filename;
55
14
  while (!predicate(current)) {
@@ -57,18 +16,60 @@ function traverseFilePathUntil(filename, predicate) {
57
16
  }
58
17
  return current;
59
18
  }
60
- function makeLoadOptions(loadOpts) {
61
- return (loadOpts ?? {
62
- root: traverseFilePathUntil(
19
+ function findRoot() {
20
+ return (process.env.OCLIF_TEST_ROOT ??
63
21
  // eslint-disable-next-line unicorn/prefer-module
64
- require.main?.path ?? module.path, (p) => !(p.includes('node_modules') || p.includes('.pnpm') || p.includes('.yarn'))),
65
- });
22
+ Object.values(require.cache).find((m) => m?.children.includes(module))?.filename ??
23
+ traverseFilePathUntil(
24
+ // eslint-disable-next-line unicorn/prefer-module
25
+ require.main?.path ?? module.path, (p) => !(p.includes('node_modules') || p.includes('.pnpm') || p.includes('.yarn'))));
26
+ }
27
+ function makeLoadOptions(loadOpts) {
28
+ return loadOpts ?? { root: findRoot() };
66
29
  }
30
+ /**
31
+ * Capture the stderr and stdout output of a function
32
+ * @param fn async function to run
33
+ * @param opts options
34
+ * - print: Whether to print the output to the console
35
+ * - stripAnsi: Whether to strip ANSI codes from the output
36
+ * @returns {Promise<CaptureResult<T>>} Captured output
37
+ * - error: Error object if the function throws an error
38
+ * - result: Result of the function if it returns a value and succeeds
39
+ * - stderr: Captured stderr output
40
+ * - stdout: Captured stdout output
41
+ */
67
42
  async function captureOutput(fn, opts) {
68
- RECORD_OPTIONS.print = opts?.print ?? false;
69
- RECORD_OPTIONS.stripAnsi = opts?.stripAnsi ?? true;
70
- process.stderr.write = mockedStderr;
71
- process.stdout.write = mockedStdout;
43
+ const print = opts?.print ?? false;
44
+ const stripAnsi = opts?.stripAnsi ?? true;
45
+ const originals = {
46
+ NODE_ENV: process.env.NODE_ENV,
47
+ stderr: process.stderr.write,
48
+ stdout: process.stdout.write,
49
+ };
50
+ const output = {
51
+ stderr: [],
52
+ stdout: [],
53
+ };
54
+ const toString = (str) => (stripAnsi ? ansis_1.default.strip(str.toString()) : str.toString());
55
+ const getStderr = () => output.stderr.map((b) => toString(b)).join('');
56
+ const getStdout = () => output.stdout.map((b) => toString(b)).join('');
57
+ const mock = (std) => (str, encoding, cb) => {
58
+ output[std].push(str);
59
+ if (print) {
60
+ if (encoding !== null && typeof encoding === 'function') {
61
+ cb = encoding;
62
+ encoding = undefined;
63
+ }
64
+ originals[std].apply(process[std], [str, encoding, cb]);
65
+ }
66
+ else if (typeof cb === 'function')
67
+ cb();
68
+ return true;
69
+ };
70
+ process.stdout.write = mock('stdout');
71
+ process.stderr.write = mock('stderr');
72
+ process.env.NODE_ENV = 'test';
72
73
  try {
73
74
  const result = await fn();
74
75
  return {
@@ -79,30 +80,62 @@ async function captureOutput(fn, opts) {
79
80
  }
80
81
  catch (error) {
81
82
  return {
82
- ...(error instanceof core_1.Errors.CLIError && { error }),
83
- ...(error instanceof Error && { error }),
83
+ ...(error instanceof core_1.Errors.CLIError && { error: { ...error, message: toString(error.message) } }),
84
+ ...(error instanceof Error && { error: { ...error, message: toString(error.message) } }),
84
85
  stderr: getStderr(),
85
86
  stdout: getStdout(),
86
87
  };
87
88
  }
88
89
  finally {
89
- restore();
90
- reset();
90
+ process.stderr.write = originals.stderr;
91
+ process.stdout.write = originals.stdout;
92
+ process.env.NODE_ENV = originals.NODE_ENV;
91
93
  }
92
94
  }
93
95
  exports.captureOutput = captureOutput;
96
+ /**
97
+ * Capture the stderr and stdout output of a command in your CLI
98
+ * @param args Command arguments, e.g. `['my:command', '--flag']` or `'my:command --flag'`
99
+ * @param loadOpts options for loading oclif `Config`
100
+ * @param captureOpts options for capturing the output
101
+ * - print: Whether to print the output to the console
102
+ * - stripAnsi: Whether to strip ANSI codes from the output
103
+ * @returns {Promise<CaptureResult<T>>} Captured output
104
+ * - error: Error object if the command throws an error
105
+ * - result: Result of the command if it returns a value and succeeds
106
+ * - stderr: Captured stderr output
107
+ * - stdout: Captured stdout output
108
+ */
94
109
  async function runCommand(args, loadOpts, captureOpts) {
95
110
  const loadOptions = makeLoadOptions(loadOpts);
96
- debug('loadOpts: %O', loadOpts);
97
- return captureOutput(async () => (0, core_1.run)(args, loadOptions), captureOpts);
111
+ const argsArray = (Array.isArray(args) ? args : [args]).join(' ').split(' ');
112
+ const [id, ...rest] = argsArray;
113
+ const finalArgs = id === '.' ? rest : argsArray;
114
+ debug('loadOpts: %O', loadOptions);
115
+ debug('args: %O', finalArgs);
116
+ return captureOutput(async () => (0, core_1.run)(finalArgs, loadOptions), captureOpts);
98
117
  }
99
118
  exports.runCommand = runCommand;
100
- async function runHook(hook, options, loadOpts, recordOpts) {
119
+ /**
120
+ * Capture the stderr and stdout output of a hook in your CLI
121
+ * @param hook Hook name
122
+ * @param options options to pass to the hook
123
+ * @param loadOpts options for loading oclif `Config`
124
+ * @param captureOpts options for capturing the output
125
+ * - print: Whether to print the output to the console
126
+ * - stripAnsi: Whether to strip ANSI codes from the output
127
+ * @returns {Promise<CaptureResult<T>>} Captured output
128
+ * - error: Error object if the hook throws an error
129
+ * - result: Result of the hook if it returns a value and succeeds
130
+ * - stderr: Captured stderr output
131
+ * - stdout: Captured stdout output
132
+ */
133
+ async function runHook(hook, options, loadOpts, captureOpts) {
101
134
  const loadOptions = makeLoadOptions(loadOpts);
102
- debug('loadOpts: %O', loadOpts);
135
+ debug('loadOpts: %O', loadOptions);
103
136
  return captureOutput(async () => {
104
137
  const config = await core_1.Config.load(loadOptions);
105
138
  return config.runHook(hook, options);
106
- }, recordOpts);
139
+ }, captureOpts);
107
140
  }
108
141
  exports.runHook = runHook;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oclif/test",
3
3
  "description": "test helpers for oclif components",
4
- "version": "4.0.1-dev.0",
4
+ "version": "4.0.1",
5
5
  "author": "Salesforce",
6
6
  "bugs": "https://github.com/oclif/test/issues",
7
7
  "dependencies": {
@@ -9,15 +9,17 @@
9
9
  "debug": "^4.3.4"
10
10
  },
11
11
  "peerDependencies": {
12
- "@oclif/core": "^4.0.0-beta.6"
12
+ "@oclif/core": ">= 3.0.0"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@commitlint/config-conventional": "^18.6.3",
16
16
  "@oclif/core": "^4.0.0-beta.6",
17
17
  "@oclif/prettier-config": "^0.2.1",
18
+ "@types/chai": "^4.3.16",
18
19
  "@types/debug": "^4.1.12",
19
20
  "@types/mocha": "^10",
20
21
  "@types/node": "^18",
22
+ "chai": "^5.1.1",
21
23
  "commitlint": "^18.6.1",
22
24
  "eslint": "^8.57.0",
23
25
  "eslint-config-oclif": "^5.2.0",
@@ -28,7 +30,7 @@
28
30
  "mocha": "^10",
29
31
  "prettier": "^3.2.5",
30
32
  "shx": "^0.3.3",
31
- "ts-node": "^10.9.2",
33
+ "tsx": "^4.10.2",
32
34
  "typescript": "^5.4.5"
33
35
  },
34
36
  "engines": {