clap-ts 0.3.0 → 0.4.0

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.
Files changed (43) hide show
  1. package/dist/parser.js +5 -5
  2. package/dist/types.d.ts +14 -1
  3. package/package.json +17 -1
  4. package/src/__tests__/arg-options.test.ts +687 -0
  5. package/src/__tests__/argfile.test.ts +127 -0
  6. package/src/__tests__/clap-parity.test.ts +682 -0
  7. package/src/__tests__/command-options.test.ts +713 -0
  8. package/src/__tests__/completions.test.ts +423 -0
  9. package/src/__tests__/config.test.ts +261 -0
  10. package/src/__tests__/deprecation.test.ts +104 -0
  11. package/src/__tests__/help.test.ts +312 -0
  12. package/src/__tests__/install.test.ts +120 -0
  13. package/src/__tests__/log.test.ts +189 -0
  14. package/src/__tests__/man.test.ts +135 -0
  15. package/src/__tests__/markdown.test.ts +114 -0
  16. package/src/__tests__/output.test.ts +249 -0
  17. package/src/__tests__/parser.test.ts +627 -0
  18. package/src/__tests__/plugins.test.ts +182 -0
  19. package/src/__tests__/progress.test.ts +221 -0
  20. package/src/__tests__/prompt.test.ts +265 -0
  21. package/src/__tests__/runner.test.ts +459 -0
  22. package/src/__tests__/spec.test.ts +107 -0
  23. package/src/__tests__/testing.test.ts +93 -0
  24. package/src/__tests__/validation.test.ts +267 -0
  25. package/src/argfile.ts +188 -0
  26. package/src/completions.ts +865 -0
  27. package/src/config.ts +184 -0
  28. package/src/help.ts +779 -0
  29. package/src/index.ts +58 -0
  30. package/src/install.ts +226 -0
  31. package/src/log.ts +225 -0
  32. package/src/man.ts +289 -0
  33. package/src/markdown.ts +210 -0
  34. package/src/output.ts +453 -0
  35. package/src/parser.ts +1240 -0
  36. package/src/plugins.ts +193 -0
  37. package/src/progress.ts +295 -0
  38. package/src/prompt.ts +388 -0
  39. package/src/runner.ts +769 -0
  40. package/src/spec.ts +197 -0
  41. package/src/testing.ts +159 -0
  42. package/src/types.ts +618 -0
  43. package/src/validation.ts +627 -0
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Runner tests: defineCommand and friends, subcommand dispatch, global argument
3
+ * inheritance, lazy subcommands and the introspection helpers.
4
+ */
5
+
6
+ import { describe, test, expect } from 'bun:test';
7
+ import { defineCommand, defineArgs, defineArg, runCommand } from '../runner.js';
8
+ import { parseArgs, subCommandsOf, hasSubCommands } from '../parser.js';
9
+ import { validate } from '../validation.js';
10
+ import { renderHelp } from '../help.js';
11
+ import { runCli } from '../testing.js';
12
+ import type { ArgsDef, CommandDef, ParsedArgs } from '../types.js';
13
+
14
+ // ---- defineCommand ----
15
+
16
+ describe('defineCommand', () => {
17
+ test('returns same object', () => {
18
+ const input = {
19
+ meta: { name: 'test', version: '1.0.0' },
20
+ args: { verbose: { type: 'boolean' as const } },
21
+ };
22
+ const result = defineCommand(input);
23
+ expect(result).toBe(input);
24
+ });
25
+
26
+ test('preserves type inference on args', () => {
27
+ const cmd = defineCommand({
28
+ meta: { name: 'test' },
29
+ args: {
30
+ port: { type: 'number' as const, default: 3000 },
31
+ name: { type: 'string' as const },
32
+ verbose: { type: 'boolean' as const },
33
+ },
34
+ });
35
+ // Type-level check: cmd.args should be defined
36
+ expect(cmd.args).toBeDefined();
37
+ expect(cmd.args!.port.type).toBe('number');
38
+ expect(cmd.args!.name.type).toBe('string');
39
+ expect(cmd.args!.verbose.type).toBe('boolean');
40
+ });
41
+ });
42
+
43
+ // ---- defineArgs ----
44
+
45
+ describe('defineArgs', () => {
46
+ test('returns same object with preserved types', () => {
47
+ const input = {
48
+ env: { type: 'string' as const, valueParser: ['dev', 'staging'] as const },
49
+ verbose: { type: 'boolean' as const },
50
+ };
51
+ const result = defineArgs(input);
52
+ expect(result).toBe(input);
53
+ });
54
+ });
55
+
56
+ // ---- defineArg ----
57
+
58
+ describe('defineArg', () => {
59
+ test('returns same object', () => {
60
+ const input = { type: 'number' as const, short: 'p', default: 3003 };
61
+ const result = defineArg(input);
62
+ expect(result).toBe(input);
63
+ });
64
+ });
65
+
66
+ // ---- runCommand ----
67
+
68
+ describe('runCommand', () => {
69
+ test('calls setup -> run -> cleanup in order', async () => {
70
+ const order: string[] = [];
71
+ const cmd = defineCommand({
72
+ meta: { name: 'test' },
73
+ args: {},
74
+ setup() {
75
+ order.push('setup');
76
+ },
77
+ run() {
78
+ order.push('run');
79
+ },
80
+ cleanup() {
81
+ order.push('cleanup');
82
+ },
83
+ });
84
+
85
+ await runCommand(cmd, {} as ParsedArgs<ArgsDef>);
86
+ expect(order).toEqual(['setup', 'run', 'cleanup']);
87
+ });
88
+
89
+ test('calls cleanup even on run error', async () => {
90
+ const order: string[] = [];
91
+ const cmd = defineCommand({
92
+ meta: { name: 'test' },
93
+ args: {},
94
+ run() {
95
+ order.push('run');
96
+ throw new Error('run failed');
97
+ },
98
+ cleanup() {
99
+ order.push('cleanup');
100
+ },
101
+ });
102
+
103
+ await expect(runCommand(cmd, {} as ParsedArgs<ArgsDef>)).rejects.toThrow('run failed');
104
+ expect(order).toEqual(['run', 'cleanup']);
105
+ });
106
+
107
+ test('cleanup error does not mask run error', async () => {
108
+ const cmd = defineCommand({
109
+ meta: { name: 'test' },
110
+ args: {},
111
+ run() {
112
+ throw new Error('run failed');
113
+ },
114
+ cleanup() {
115
+ throw new Error('cleanup failed');
116
+ },
117
+ });
118
+
119
+ await expect(runCommand(cmd, {} as ParsedArgs<ArgsDef>)).rejects.toThrow('run failed');
120
+ });
121
+
122
+ test('cleanup error thrown when run succeeds', async () => {
123
+ const cmd = defineCommand({
124
+ meta: { name: 'test' },
125
+ args: {},
126
+ run() {
127
+ // success
128
+ },
129
+ cleanup() {
130
+ throw new Error('cleanup failed');
131
+ },
132
+ });
133
+
134
+ await expect(runCommand(cmd, {} as ParsedArgs<ArgsDef>)).rejects.toThrow('cleanup failed');
135
+ });
136
+
137
+ test('works with no lifecycle hooks', async () => {
138
+ const cmd = defineCommand({
139
+ meta: { name: 'test' },
140
+ args: {},
141
+ });
142
+
143
+ // Should not throw
144
+ await runCommand(cmd, {} as ParsedArgs<ArgsDef>);
145
+ });
146
+
147
+ test('passes args to run handler', async () => {
148
+ let receivedArgs: unknown;
149
+ const cmd = defineCommand({
150
+ meta: { name: 'test' },
151
+ args: { port: { type: 'number' as const } },
152
+ run({ args }) {
153
+ receivedArgs = args;
154
+ },
155
+ });
156
+
157
+ await runCommand(cmd, { port: 3003 } as any);
158
+ expect((receivedArgs as { port: number }).port).toBe(3003);
159
+ });
160
+ });
161
+
162
+ // ---- Subcommand Resolution ----
163
+
164
+ describe('subcommand resolution', () => {
165
+ test('resolves subcommand and runs it', async () => {
166
+ let ranSubcommand = false;
167
+ const root = defineCommand({
168
+ meta: { name: 'app' },
169
+ subCommands: {
170
+ serve: defineCommand({
171
+ meta: { name: 'serve' },
172
+ args: { port: { type: 'number' as const, default: 3000 } },
173
+ run({ args }) {
174
+ ranSubcommand = true;
175
+ expect(args.port).toBe(8080);
176
+ },
177
+ }),
178
+ },
179
+ });
180
+
181
+ await runCli(root, ['serve', '--port', '8080']);
182
+ expect(ranSubcommand).toBe(true);
183
+ });
184
+
185
+ test('resolves subcommand by alias', async () => {
186
+ let ranSubcommand = false;
187
+ const root = defineCommand({
188
+ meta: { name: 'app' },
189
+ subCommands: {
190
+ serve: defineCommand({
191
+ meta: { name: 'serve', aliases: ['s'] },
192
+ run() {
193
+ ranSubcommand = true;
194
+ },
195
+ }),
196
+ },
197
+ });
198
+
199
+ await runCli(root, ['s']);
200
+ expect(ranSubcommand).toBe(true);
201
+ });
202
+ });
203
+
204
+ // ---- Dispatch regressions ----
205
+
206
+ describe('flags before a subcommand', () => {
207
+ test('parent flags do not block dispatch', async () => {
208
+ const seen: (number | undefined)[] = [];
209
+ const root = defineCommand({
210
+ meta: { name: 'app' },
211
+ args: { verbose: { type: 'boolean', short: 'v' } },
212
+ subCommands: {
213
+ serve: defineCommand({
214
+ meta: { name: 'serve' },
215
+ args: { port: { type: 'number', default: 80 } },
216
+ run({ args }) {
217
+ seen.push(args.port);
218
+ },
219
+ }),
220
+ },
221
+ });
222
+
223
+ for (const argv of [['serve'], ['--verbose', 'serve'], ['-v', 'serve']]) {
224
+ await runCli(root, argv);
225
+ }
226
+ expect(seen).toEqual([80, 80, 80]);
227
+ });
228
+ });
229
+
230
+ describe('global args inheritance', () => {
231
+ test('a global on an intermediate command reaches a grandchild', async () => {
232
+ let received: Record<string, unknown> = {};
233
+ const leaf = defineCommand({
234
+ meta: { name: 'leaf' },
235
+ run({ args }) {
236
+ received = args as Record<string, unknown>;
237
+ },
238
+ });
239
+ const mid = defineCommand({
240
+ meta: { name: 'mid' },
241
+ args: { color: { type: 'string', global: true } },
242
+ subCommands: { leaf },
243
+ });
244
+ const root = defineCommand({
245
+ meta: { name: 'app' },
246
+ args: { debug: { type: 'boolean', global: true } },
247
+ subCommands: { mid },
248
+ });
249
+
250
+ await runCli(root, ['mid', 'leaf', '--color', 'red', '--debug']);
251
+ expect(received['color']).toBe('red');
252
+ expect(received['debug']).toBe(true);
253
+ });
254
+ });
255
+
256
+ describe('error output names the failing command', () => {
257
+ const root = defineCommand({
258
+ meta: { name: 'app' },
259
+ args: { verbose: { type: 'boolean' } },
260
+ subCommands: {
261
+ serve: defineCommand({
262
+ meta: { name: 'serve' },
263
+ args: { port: { type: 'number', required: true } },
264
+ run() {},
265
+ }),
266
+ },
267
+ });
268
+
269
+ test('missing required arg shows the subcommand usage', async () => {
270
+ const { plainStderr, exitCode } = await runCli(root, ['serve']);
271
+ expect(plainStderr).toContain('Usage: app serve');
272
+ expect(plainStderr).not.toContain('Usage: app [OPTIONS]');
273
+ expect(exitCode).toBe(2);
274
+ });
275
+
276
+ test('unknown flag shows the subcommand usage and a suggestion', async () => {
277
+ const { plainStderr } = await runCli(root, ['serve', '--prot', '3']);
278
+ expect(plainStderr).toContain('Usage: app serve');
279
+ expect(plainStderr).toContain("a similar argument exists: '--port'");
280
+ });
281
+ });
282
+
283
+ describe('low-level API accepts a defineCommand result', () => {
284
+ test('parseArgs, validate and renderHelp take a command with typed hooks', () => {
285
+ // A regression guard for hook contravariance: CommandDef<{port: ...}> has to
286
+ // stay assignable to the CommandDef<ArgsDef> these three declare. This is a
287
+ // compile-time assertion; tsconfig.test.json is what enforces it.
288
+ const command = defineCommand({
289
+ meta: { name: 'tool' },
290
+ args: { port: { type: 'number', default: 3000 } },
291
+ setup() {},
292
+ run({ args }) {
293
+ void args.port;
294
+ },
295
+ cleanup() {},
296
+ });
297
+
298
+ const result = parseArgs(['--port', '8080'], command);
299
+ validate(result, command);
300
+
301
+ expect(result.args.port).toBe(8080);
302
+ expect(renderHelp(command)).toContain('--port');
303
+ });
304
+ });
305
+
306
+ describe('global arg values reach the running command', () => {
307
+ const leaf = defineCommand({
308
+ meta: { name: 'leaf' },
309
+ args: { local: { type: 'string' } },
310
+ run({ args }) {
311
+ seen = args as Record<string, unknown>;
312
+ },
313
+ });
314
+ const mid = defineCommand({
315
+ meta: { name: 'mid' },
316
+ args: { color: { type: 'string', global: true } },
317
+ subCommands: { leaf },
318
+ });
319
+ const root = defineCommand({
320
+ meta: { name: 'app' },
321
+ args: { verbose: { type: 'boolean', short: 'v', global: true } },
322
+ subCommands: { mid },
323
+ });
324
+ let seen: Record<string, unknown> = {};
325
+
326
+ test('a global given before the subcommand still arrives', async () => {
327
+ seen = {};
328
+ await runCli(root, ['--verbose', 'mid', 'leaf']);
329
+ expect(seen['verbose']).toBe(true);
330
+ });
331
+
332
+ test('a global from an intermediate level arrives too', async () => {
333
+ seen = {};
334
+ await runCli(root, ['-v', 'mid', '--color', 'red', 'leaf']);
335
+ expect(seen['verbose']).toBe(true);
336
+ expect(seen['color']).toBe('red');
337
+ });
338
+
339
+ test('the deepest occurrence wins', async () => {
340
+ seen = {};
341
+ await runCli(root, ['mid', '--color', 'red', 'leaf', '--color', 'blue']);
342
+ expect(seen['color']).toBe('blue');
343
+ });
344
+
345
+ test('a required global satisfied at an ancestor level passes validation', async () => {
346
+ let ran = false;
347
+ const child = defineCommand({ meta: { name: 'child' }, run() { ran = true; } });
348
+ const parent = defineCommand({
349
+ meta: { name: 'parent' },
350
+ args: { token: { type: 'string', global: true, required: true } },
351
+ subCommands: { child },
352
+ });
353
+ await runCli(parent, ['--token', 'abc', 'child']);
354
+ expect(ran).toBe(true);
355
+ });
356
+ });
357
+
358
+ describe('lazy subcommands', () => {
359
+ test('the thunk runs only when a subcommand is actually needed', async () => {
360
+ let built = 0;
361
+ const root = defineCommand({
362
+ meta: { name: 'app', version: '1.0' },
363
+ lazySubCommands: () => {
364
+ built++;
365
+ return { serve: defineCommand({ meta: { name: 'serve' }, run() {} }) };
366
+ },
367
+ args: { verbose: { type: 'boolean' } },
368
+ run() {},
369
+ });
370
+
371
+ // A plain flag parse never touches the subcommand tree.
372
+ await runCli(root, ['--verbose']);
373
+ expect(built).toBe(0);
374
+
375
+ await runCli(root, ['serve']);
376
+ expect(built).toBe(1);
377
+ });
378
+
379
+ test('the thunk is built at most once per command', () => {
380
+ let built = 0;
381
+ const root: CommandDef = {
382
+ meta: { name: 'app' },
383
+ lazySubCommands: () => {
384
+ built++;
385
+ return { serve: { meta: { name: 'serve' } } };
386
+ },
387
+ };
388
+ expect(parseArgs(['serve'], root).subCommand).toBe('serve');
389
+ expect(parseArgs(['serve'], root).subCommand).toBe('serve');
390
+ expect(built).toBe(1);
391
+ });
392
+
393
+ test('eager subCommands win a name collision and both are reachable', () => {
394
+ const root: CommandDef = {
395
+ meta: { name: 'app' },
396
+ subCommands: { serve: { meta: { name: 'serve-eager' } } },
397
+ lazySubCommands: () => ({
398
+ serve: { meta: { name: 'serve-lazy' } },
399
+ build: { meta: { name: 'build' } },
400
+ }),
401
+ };
402
+ expect(parseArgs(['serve'], root).subCommand).toBe('serve');
403
+ expect(parseArgs(['build'], root).subCommand).toBe('build');
404
+ expect(renderHelp(root)).toContain('serve');
405
+ expect(renderHelp(root)).toContain('build');
406
+ });
407
+ });
408
+
409
+ describe('externalSubcommandValueParser', () => {
410
+ test('each external argument goes through the parser', async () => {
411
+ let received: readonly string[] = [];
412
+ const root = defineCommand({
413
+ meta: { name: 'git', allowExternalSubcommands: true },
414
+ externalSubcommandValueParser: (v) => v.toUpperCase(),
415
+ run({ rawArgs }) {
416
+ received = rawArgs;
417
+ },
418
+ });
419
+ await runCli(root, ['my-plugin', 'a', 'b']);
420
+ expect(received).toEqual(['A', 'B']);
421
+ });
422
+
423
+ test('a throwing parser surfaces as a parse error', async () => {
424
+ const root = defineCommand({
425
+ meta: { name: 'git', allowExternalSubcommands: true },
426
+ externalSubcommandValueParser: () => {
427
+ throw new Error('nope');
428
+ },
429
+ run() {},
430
+ });
431
+ const { plainStderr } = await runCli(root, ['plugin', 'x']);
432
+ expect(plainStderr).toContain("invalid value 'x': nope");
433
+ });
434
+ });
435
+
436
+ describe('public introspection helpers', () => {
437
+ test('subCommandsOf resolves lazy and eager alike', () => {
438
+ const eager: CommandDef = { meta: { name: 'a' }, subCommands: { x: { meta: { name: 'x' } } } };
439
+ const lazy: CommandDef = { meta: { name: 'b' }, lazySubCommands: () => ({ y: { meta: { name: 'y' } } }) };
440
+ const none: CommandDef = { meta: { name: 'c' } };
441
+ expect(Object.keys(subCommandsOf(eager))).toEqual(['x']);
442
+ expect(Object.keys(subCommandsOf(lazy))).toEqual(['y']);
443
+ expect(Object.keys(subCommandsOf(none))).toEqual([]);
444
+ });
445
+
446
+ test('hasSubCommands answers without running the thunk', () => {
447
+ let built = 0;
448
+ const lazy: CommandDef = {
449
+ meta: { name: 'b' },
450
+ lazySubCommands: () => {
451
+ built++;
452
+ return { y: { meta: { name: 'y' } } };
453
+ },
454
+ };
455
+ expect(hasSubCommands(lazy)).toBe(true);
456
+ expect(built).toBe(0);
457
+ expect(hasSubCommands({ meta: { name: 'c' } })).toBe(false);
458
+ });
459
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Tests for the machine-readable command tree dump.
3
+ */
4
+
5
+ import { describe, test, expect } from 'bun:test';
6
+ import { toSpec, toSpecJson, walkSpec } from '../spec.js';
7
+ import type { CommandDef } from '../types.js';
8
+
9
+ const command: CommandDef = {
10
+ meta: { name: 'my-app', version: '3.0', description: 'Does things', author: 'Salama Ashoush' },
11
+ args: {
12
+ config: { type: 'string', short: 'c', description: 'Config', env: 'APP_CONFIG' },
13
+ mode: {
14
+ type: 'string',
15
+ valueParser: [{ name: 'fast', help: 'Skip checks' }, 'safe'],
16
+ description: 'Mode',
17
+ },
18
+ port: { type: 'number', default: 8080, numArgs: { min: 1, max: 1 } },
19
+ custom: { type: 'string', valueParser: (v) => v.trim() },
20
+ old: { type: 'string', deprecated: 'gone soon', replacedBy: 'config' },
21
+ secret: { type: 'string', hidden: true },
22
+ file: { type: 'positional', required: true, valueName: 'FILE' },
23
+ },
24
+ subCommands: {
25
+ test: { meta: { name: 'test', description: 'Tests', aliases: ['t'] } },
26
+ ghost: { meta: { name: 'ghost', hidden: true } },
27
+ },
28
+ };
29
+
30
+ const spec = toSpec(command);
31
+
32
+ describe('toSpec', () => {
33
+ test('roots the path at the command name', () => {
34
+ expect(spec.name).toBe('my-app');
35
+ expect(spec.path).toEqual(['my-app']);
36
+ expect(spec.subcommands[0]?.path).toEqual(['my-app', 'test']);
37
+ });
38
+
39
+ test('carries the command metadata', () => {
40
+ expect(spec.version).toBe('3.0');
41
+ expect(spec.author).toBe('Salama Ashoush');
42
+ expect(spec.description).toBe('Does things');
43
+ });
44
+
45
+ test('builds a usage line', () => {
46
+ expect(spec.usage).toBe('my-app [OPTIONS] <FILE> [COMMAND]');
47
+ });
48
+
49
+ test('flattens possible values, mixing plain strings and records', () => {
50
+ const mode = spec.args.find((a) => a.name === 'mode')!;
51
+ expect(mode.possibleValues).toEqual([{ name: 'fast', help: 'Skip checks' }, { name: 'safe' }]);
52
+ });
53
+
54
+ test('reports a function parser as a flag rather than dropping it silently', () => {
55
+ expect(spec.args.find((a) => a.name === 'custom')!.hasCustomParser).toBe(true);
56
+ expect(spec.args.find((a) => a.name === 'mode')!.hasCustomParser).toBeUndefined();
57
+ });
58
+
59
+ test('normalises deprecation', () => {
60
+ const old = spec.args.find((a) => a.name === 'old')!;
61
+ expect(old.deprecated).toBe('gone soon');
62
+ expect(old.replacedBy).toBe('config');
63
+ });
64
+
65
+ test('omits hidden args and commands by default', () => {
66
+ expect(spec.args.map((a) => a.name)).not.toContain('secret');
67
+ expect(spec.subcommands.map((c) => c.name)).not.toContain('ghost');
68
+ });
69
+
70
+ test('includeHidden brings them back', () => {
71
+ const full = toSpec(command, { includeHidden: true });
72
+ expect(full.args.map((a) => a.name)).toContain('secret');
73
+ expect(full.subcommands.map((c) => c.name)).toContain('ghost');
74
+ });
75
+
76
+ test('drops undefined keys rather than emitting nulls', () => {
77
+ const port = spec.args.find((a) => a.name === 'port')!;
78
+ expect('env' in port).toBe(false);
79
+ expect(port.default).toBe(8080);
80
+ });
81
+
82
+ test('resolves lazy subcommands', () => {
83
+ const lazy: CommandDef = {
84
+ meta: { name: 'app' },
85
+ lazySubCommands: () => ({ built: { meta: { name: 'built' } } }),
86
+ };
87
+ expect(toSpec(lazy).subcommands.map((c) => c.name)).toEqual(['built']);
88
+ });
89
+ });
90
+
91
+ describe('serialisation', () => {
92
+ test('round-trips through JSON unchanged', () => {
93
+ expect(JSON.parse(toSpecJson(command))).toEqual(JSON.parse(JSON.stringify(spec)));
94
+ });
95
+
96
+ test('nothing in the output is a function', () => {
97
+ const seen = JSON.stringify(spec);
98
+ expect(seen).not.toContain('=>');
99
+ expect(seen).not.toContain('function');
100
+ });
101
+ });
102
+
103
+ describe('walkSpec', () => {
104
+ test('yields the root then every descendant', () => {
105
+ expect([...walkSpec(spec)].map((c) => c.path.join(' '))).toEqual(['my-app', 'my-app test']);
106
+ });
107
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Tests for the test helpers themselves.
3
+ */
4
+
5
+ import { describe, test, expect } from 'bun:test';
6
+ import { runCli, captureArgs, stripAnsi } from '../testing.js';
7
+ import { defineCommand } from '../runner.js';
8
+
9
+ const main = defineCommand({
10
+ meta: { name: 'demo', version: '1.0.0', description: 'A demo' },
11
+ args: {
12
+ port: { type: 'number', default: 3000, description: 'Port' },
13
+ name: { type: 'string', required: true, description: 'Name' },
14
+ },
15
+ run({ args, stdout }) {
16
+ stdout.write(`hello ${args.name} on ${String(args.port)}\n`);
17
+ },
18
+ });
19
+
20
+ describe('runCli', () => {
21
+ test('captures stdout and a clean exit', async () => {
22
+ const result = await runCli(main, ['--name', 'world']);
23
+ expect(result.stdout).toBe('hello world on 3000\n');
24
+ expect(result.stderr).toBe('');
25
+ expect(result.exitCode).toBe(0);
26
+ expect(result.error).toBeUndefined();
27
+ });
28
+
29
+ test('captures a usage error and its exit code', async () => {
30
+ const result = await runCli(main, []);
31
+ expect(result.exitCode).toBe(2);
32
+ expect(result.plainStderr).toContain('the following required arguments were not provided');
33
+ expect(result.plainStderr).toContain('Usage: demo');
34
+ expect(result.stdout).toBe('');
35
+ });
36
+
37
+ test('captures help without exiting the process', async () => {
38
+ const result = await runCli(main, ['--help']);
39
+ expect(result.exitCode).toBe(0);
40
+ expect(result.plainStdout).toContain('Usage: demo');
41
+ expect(result.plainStdout).toContain('--port');
42
+ });
43
+
44
+ test('captures version', async () => {
45
+ const result = await runCli(main, ['--version']);
46
+ expect(result.plainStdout.trim()).toBe('demo 1.0.0');
47
+ });
48
+
49
+ test('reports an error thrown by the handler', async () => {
50
+ const boom = defineCommand({
51
+ meta: { name: 'boom' },
52
+ run() {
53
+ throw new Error('kaboom');
54
+ },
55
+ });
56
+ const result = await runCli(boom, []);
57
+ expect(result.exitCode).toBe(1);
58
+ expect(result.plainStderr).toContain('kaboom');
59
+ });
60
+
61
+ test('leaves the real streams alone', async () => {
62
+ const before = process.stdout.write;
63
+ await runCli(main, ['--name', 'x']);
64
+ expect(process.stdout.write).toBe(before);
65
+ });
66
+
67
+ test('plain variants strip colour', async () => {
68
+ const coloured = defineCommand({
69
+ meta: { name: 'c', color: 'always', description: 'D' },
70
+ args: { flag: { type: 'boolean', description: 'F' } },
71
+ });
72
+ const result = await runCli(coloured, ['--help']);
73
+ expect(result.stdout).not.toBe(result.plainStdout);
74
+ expect(result.plainStdout).toBe(stripAnsi(result.stdout));
75
+ });
76
+ });
77
+
78
+ describe('captureArgs', () => {
79
+ test('reports the parsed args without running the body', async () => {
80
+ let ran = false;
81
+ const cmd = defineCommand({
82
+ meta: { name: 'x' },
83
+ args: { port: { type: 'number', default: 80 } },
84
+ run() {
85
+ ran = true;
86
+ },
87
+ });
88
+ const { args, result } = await captureArgs(cmd, ['--port', '9']);
89
+ expect(args['port']).toBe(9);
90
+ expect(ran).toBe(false);
91
+ expect(result.exitCode).toBe(0);
92
+ });
93
+ });