@oclif/test 4.0.1-dev.0 → 4.0.2

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,75 @@ 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 ??
21
+ // eslint-disable-next-line unicorn/prefer-module
22
+ Object.values(require.cache).find((m) => m?.children.includes(module))?.filename ??
23
+ traverseFilePathUntil(
63
24
  // 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
- });
25
+ require.main?.path ?? module.path, (p) => !(p.includes('node_modules') || p.includes('.pnpm') || p.includes('.yarn'))));
66
26
  }
27
+ function makeLoadOptions(loadOpts) {
28
+ return loadOpts ?? { root: findRoot() };
29
+ }
30
+ /**
31
+ * Split a string into an array of strings, preserving quoted substrings
32
+ *
33
+ * @example
34
+ * splitString('foo bar --name "foo"') // ['foo bar', '--name', 'foo']
35
+ * splitString('foo bar --name "foo bar"') // ['foo bar', '--name', 'foo bar']
36
+ * splitString('foo bar --name="foo bar"') // ['foo bar', '--name=foo bar']
37
+ * splitString('foo bar --name=foo bar') // ['foo bar', '--name=foo', 'bar']
38
+ *
39
+ * @param str input string
40
+ * @returns array of strings with quotes removed
41
+ */
42
+ function splitString(str) {
43
+ return (str.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []).map((s) => s.replaceAll(/^"|"$|(?<==)"/g, ''));
44
+ }
45
+ /**
46
+ * Capture the stderr and stdout output of a function
47
+ * @param fn async function to run
48
+ * @param opts options
49
+ * - print: Whether to print the output to the console
50
+ * - stripAnsi: Whether to strip ANSI codes from the output
51
+ * @returns {Promise<CaptureResult<T>>} Captured output
52
+ * - error: Error object if the function throws an error
53
+ * - result: Result of the function if it returns a value and succeeds
54
+ * - stderr: Captured stderr output
55
+ * - stdout: Captured stdout output
56
+ */
67
57
  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;
58
+ const print = opts?.print ?? false;
59
+ const stripAnsi = opts?.stripAnsi ?? true;
60
+ const originals = {
61
+ NODE_ENV: process.env.NODE_ENV,
62
+ stderr: process.stderr.write,
63
+ stdout: process.stdout.write,
64
+ };
65
+ const output = {
66
+ stderr: [],
67
+ stdout: [],
68
+ };
69
+ const toString = (str) => (stripAnsi ? ansis_1.default.strip(str.toString()) : str.toString());
70
+ const getStderr = () => output.stderr.map((b) => toString(b)).join('');
71
+ const getStdout = () => output.stdout.map((b) => toString(b)).join('');
72
+ const mock = (std) => (str, encoding, cb) => {
73
+ output[std].push(str);
74
+ if (print) {
75
+ if (encoding !== null && typeof encoding === 'function') {
76
+ cb = encoding;
77
+ encoding = undefined;
78
+ }
79
+ originals[std].apply(process[std], [str, encoding, cb]);
80
+ }
81
+ else if (typeof cb === 'function')
82
+ cb();
83
+ return true;
84
+ };
85
+ process.stdout.write = mock('stdout');
86
+ process.stderr.write = mock('stderr');
87
+ process.env.NODE_ENV = 'test';
72
88
  try {
73
89
  const result = await fn();
74
90
  return {
@@ -79,30 +95,62 @@ async function captureOutput(fn, opts) {
79
95
  }
80
96
  catch (error) {
81
97
  return {
82
- ...(error instanceof core_1.Errors.CLIError && { error }),
83
- ...(error instanceof Error && { error }),
98
+ ...(error instanceof core_1.Errors.CLIError && { error: { ...error, message: toString(error.message) } }),
99
+ ...(error instanceof Error && { error: { ...error, message: toString(error.message) } }),
84
100
  stderr: getStderr(),
85
101
  stdout: getStdout(),
86
102
  };
87
103
  }
88
104
  finally {
89
- restore();
90
- reset();
105
+ process.stderr.write = originals.stderr;
106
+ process.stdout.write = originals.stdout;
107
+ process.env.NODE_ENV = originals.NODE_ENV;
91
108
  }
92
109
  }
93
110
  exports.captureOutput = captureOutput;
111
+ /**
112
+ * Capture the stderr and stdout output of a command in your CLI
113
+ * @param args Command arguments, e.g. `['my:command', '--flag']` or `'my:command --flag'`
114
+ * @param loadOpts options for loading oclif `Config`
115
+ * @param captureOpts options for capturing the output
116
+ * - print: Whether to print the output to the console
117
+ * - stripAnsi: Whether to strip ANSI codes from the output
118
+ * @returns {Promise<CaptureResult<T>>} Captured output
119
+ * - error: Error object if the command throws an error
120
+ * - result: Result of the command if it returns a value and succeeds
121
+ * - stderr: Captured stderr output
122
+ * - stdout: Captured stdout output
123
+ */
94
124
  async function runCommand(args, loadOpts, captureOpts) {
95
125
  const loadOptions = makeLoadOptions(loadOpts);
96
- debug('loadOpts: %O', loadOpts);
97
- return captureOutput(async () => (0, core_1.run)(args, loadOptions), captureOpts);
126
+ const argsArray = splitString((Array.isArray(args) ? args : [args]).join(' '));
127
+ const [id, ...rest] = argsArray;
128
+ const finalArgs = id === '.' ? rest : argsArray;
129
+ debug('loadOpts: %O', loadOptions);
130
+ debug('args: %O', finalArgs);
131
+ return captureOutput(async () => (0, core_1.run)(finalArgs, loadOptions), captureOpts);
98
132
  }
99
133
  exports.runCommand = runCommand;
100
- async function runHook(hook, options, loadOpts, recordOpts) {
134
+ /**
135
+ * Capture the stderr and stdout output of a hook in your CLI
136
+ * @param hook Hook name
137
+ * @param options options to pass to the hook
138
+ * @param loadOpts options for loading oclif `Config`
139
+ * @param captureOpts options for capturing the output
140
+ * - print: Whether to print the output to the console
141
+ * - stripAnsi: Whether to strip ANSI codes from the output
142
+ * @returns {Promise<CaptureResult<T>>} Captured output
143
+ * - error: Error object if the hook throws an error
144
+ * - result: Result of the hook if it returns a value and succeeds
145
+ * - stderr: Captured stderr output
146
+ * - stdout: Captured stdout output
147
+ */
148
+ async function runHook(hook, options, loadOpts, captureOpts) {
101
149
  const loadOptions = makeLoadOptions(loadOpts);
102
- debug('loadOpts: %O', loadOpts);
150
+ debug('loadOpts: %O', loadOptions);
103
151
  return captureOutput(async () => {
104
152
  const config = await core_1.Config.load(loadOptions);
105
153
  return config.runHook(hook, options);
106
- }, recordOpts);
154
+ }, captureOpts);
107
155
  }
108
156
  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.2",
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": {