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.
- package/dist/parser.js +5 -5
- package/dist/types.d.ts +14 -1
- package/package.json +17 -1
- package/src/__tests__/arg-options.test.ts +687 -0
- package/src/__tests__/argfile.test.ts +127 -0
- package/src/__tests__/clap-parity.test.ts +682 -0
- package/src/__tests__/command-options.test.ts +713 -0
- package/src/__tests__/completions.test.ts +423 -0
- package/src/__tests__/config.test.ts +261 -0
- package/src/__tests__/deprecation.test.ts +104 -0
- package/src/__tests__/help.test.ts +312 -0
- package/src/__tests__/install.test.ts +120 -0
- package/src/__tests__/log.test.ts +189 -0
- package/src/__tests__/man.test.ts +135 -0
- package/src/__tests__/markdown.test.ts +114 -0
- package/src/__tests__/output.test.ts +249 -0
- package/src/__tests__/parser.test.ts +627 -0
- package/src/__tests__/plugins.test.ts +182 -0
- package/src/__tests__/progress.test.ts +221 -0
- package/src/__tests__/prompt.test.ts +265 -0
- package/src/__tests__/runner.test.ts +459 -0
- package/src/__tests__/spec.test.ts +107 -0
- package/src/__tests__/testing.test.ts +93 -0
- package/src/__tests__/validation.test.ts +267 -0
- package/src/argfile.ts +188 -0
- package/src/completions.ts +865 -0
- package/src/config.ts +184 -0
- package/src/help.ts +779 -0
- package/src/index.ts +58 -0
- package/src/install.ts +226 -0
- package/src/log.ts +225 -0
- package/src/man.ts +289 -0
- package/src/markdown.ts +210 -0
- package/src/output.ts +453 -0
- package/src/parser.ts +1240 -0
- package/src/plugins.ts +193 -0
- package/src/progress.ts +295 -0
- package/src/prompt.ts +388 -0
- package/src/runner.ts +769 -0
- package/src/spec.ts +197 -0
- package/src/testing.ts +159 -0
- package/src/types.ts +618 -0
- package/src/validation.ts +627 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests verifying that clap-ts matches Rust clap's exact behavior.
|
|
3
|
+
* Each test case corresponds to a specific clap behavior rule.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, test, expect } from 'bun:test';
|
|
7
|
+
import { parseArgs, CliParseError, validate } from '../index.js';
|
|
8
|
+
import { renderHelp, renderUsage, showError } from '../help.js';
|
|
9
|
+
import { stripAnsi } from '../testing.js';
|
|
10
|
+
import type { CommandDef } from '../types.js';
|
|
11
|
+
|
|
12
|
+
// ---- Test Helpers ----
|
|
13
|
+
|
|
14
|
+
function parse(rawArgs: string[], command: CommandDef) {
|
|
15
|
+
return parseArgs(rawArgs, command);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseAndValidate(rawArgs: string[], command: CommandDef) {
|
|
19
|
+
const result = parseArgs(rawArgs, command);
|
|
20
|
+
validate(result, command);
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function expectParseError(rawArgs: string[], command: CommandDef, expectedMsg: string | RegExp) {
|
|
25
|
+
try {
|
|
26
|
+
parseAndValidate(rawArgs, command);
|
|
27
|
+
throw new Error('Expected CliParseError to be thrown');
|
|
28
|
+
} catch (error) {
|
|
29
|
+
expect(error).toBeInstanceOf(CliParseError);
|
|
30
|
+
if (typeof expectedMsg === 'string') {
|
|
31
|
+
expect((error as CliParseError).message).toBe(expectedMsg);
|
|
32
|
+
} else {
|
|
33
|
+
expect((error as CliParseError).message).toMatch(expectedMsg);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---- Shared Command Definitions ----
|
|
39
|
+
|
|
40
|
+
const simpleCmd: CommandDef = {
|
|
41
|
+
meta: { name: 'program', version: '1.0.0', description: 'A test program' },
|
|
42
|
+
args: {
|
|
43
|
+
env: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
short: 'e',
|
|
46
|
+
long: 'env',
|
|
47
|
+
valueName: 'ENV',
|
|
48
|
+
valueParser: ['dev', 'staging', 'prod'],
|
|
49
|
+
description: 'Box API environment',
|
|
50
|
+
},
|
|
51
|
+
user: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
short: 'u',
|
|
54
|
+
long: 'user',
|
|
55
|
+
valueName: 'USER',
|
|
56
|
+
env: 'BOX_USER',
|
|
57
|
+
description: 'User key',
|
|
58
|
+
},
|
|
59
|
+
port: {
|
|
60
|
+
type: 'number',
|
|
61
|
+
short: 'p',
|
|
62
|
+
long: 'port',
|
|
63
|
+
valueName: 'PORT',
|
|
64
|
+
default: 3003,
|
|
65
|
+
description: 'Port number',
|
|
66
|
+
},
|
|
67
|
+
verbose: {
|
|
68
|
+
type: 'boolean',
|
|
69
|
+
long: 'verbose',
|
|
70
|
+
description: 'Verbose output',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ---- 1. Error Format ----
|
|
76
|
+
|
|
77
|
+
describe('clap error format', () => {
|
|
78
|
+
test('error output has correct structure: error + usage + help tip', () => {
|
|
79
|
+
// We test showError indirectly through the error message format
|
|
80
|
+
const cmd: CommandDef = {
|
|
81
|
+
meta: { name: 'myapp', version: '1.0.0' },
|
|
82
|
+
args: {
|
|
83
|
+
name: { type: 'string', long: 'name', required: true },
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// showError takes the sink as its last argument, so nothing global moves.
|
|
88
|
+
let output = '';
|
|
89
|
+
showError('test error message', cmd, undefined, undefined, {
|
|
90
|
+
write: (chunk) => {
|
|
91
|
+
output += chunk;
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const stripped = stripAnsi(output);
|
|
96
|
+
expect(stripped).toContain('error: test error message');
|
|
97
|
+
expect(stripped).toContain('Usage: myapp [OPTIONS]');
|
|
98
|
+
expect(stripped).toContain("For more information, try '--help'.");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---- 2. conflictsWith ----
|
|
103
|
+
|
|
104
|
+
describe('clap conflictsWith behavior', () => {
|
|
105
|
+
const conflictCmd: CommandDef = {
|
|
106
|
+
meta: { name: 'program' },
|
|
107
|
+
args: {
|
|
108
|
+
dev: {
|
|
109
|
+
type: 'boolean',
|
|
110
|
+
long: 'dev',
|
|
111
|
+
conflictsWith: ['env', 'staging', 'prod'],
|
|
112
|
+
},
|
|
113
|
+
staging: {
|
|
114
|
+
type: 'boolean',
|
|
115
|
+
long: 'staging',
|
|
116
|
+
conflictsWith: ['env', 'dev', 'prod'],
|
|
117
|
+
},
|
|
118
|
+
prod: {
|
|
119
|
+
type: 'boolean',
|
|
120
|
+
long: 'prod',
|
|
121
|
+
conflictsWith: ['env', 'dev', 'staging'],
|
|
122
|
+
},
|
|
123
|
+
env: {
|
|
124
|
+
type: 'string',
|
|
125
|
+
long: 'env',
|
|
126
|
+
valueName: 'ENV',
|
|
127
|
+
conflictsWith: ['dev', 'staging', 'prod'],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
test('--dev --staging produces clap-style conflict error', () => {
|
|
133
|
+
expectParseError(
|
|
134
|
+
['--dev', '--staging'],
|
|
135
|
+
conflictCmd,
|
|
136
|
+
"the argument '--dev' cannot be used with '--staging'",
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('--env dev --dev produces conflict error with value name', () => {
|
|
141
|
+
// Object.entries iteration order: dev comes before env, so dev's conflict fires first.
|
|
142
|
+
// dev is boolean so no value name, env has value name <ENV>.
|
|
143
|
+
expectParseError(
|
|
144
|
+
['--env', 'dev', '--dev'],
|
|
145
|
+
conflictCmd,
|
|
146
|
+
"the argument '--dev' cannot be used with '--env <ENV>'",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('no conflict when only one is set', () => {
|
|
151
|
+
const result = parseAndValidate(['--dev'], conflictCmd);
|
|
152
|
+
expect(result.args['dev']).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ---- 3. requires ----
|
|
157
|
+
|
|
158
|
+
describe('clap requires behavior', () => {
|
|
159
|
+
const requiresCmd: CommandDef = {
|
|
160
|
+
meta: { name: 'program' },
|
|
161
|
+
args: {
|
|
162
|
+
tls: {
|
|
163
|
+
type: 'boolean',
|
|
164
|
+
long: 'tls',
|
|
165
|
+
},
|
|
166
|
+
'tls-cert': {
|
|
167
|
+
type: 'string',
|
|
168
|
+
long: 'tls-cert',
|
|
169
|
+
valueName: 'PATH',
|
|
170
|
+
requires: ['tls'],
|
|
171
|
+
},
|
|
172
|
+
'tls-key': {
|
|
173
|
+
type: 'string',
|
|
174
|
+
long: 'tls-key',
|
|
175
|
+
valueName: 'PATH',
|
|
176
|
+
requires: ['tls'],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
test('--tls-cert without --tls says required arguments not provided', () => {
|
|
182
|
+
expectParseError(
|
|
183
|
+
['--tls-cert', 'foo.pem'],
|
|
184
|
+
requiresCmd,
|
|
185
|
+
'the following required arguments were not provided:\n --tls',
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('--tls-cert and --tls-key without --tls lists --tls once', () => {
|
|
190
|
+
// Each arg independently requires --tls, first one to fail throws
|
|
191
|
+
expectParseError(
|
|
192
|
+
['--tls-cert', 'foo.pem', '--tls-key', 'bar.pem'],
|
|
193
|
+
requiresCmd,
|
|
194
|
+
'the following required arguments were not provided:\n --tls',
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('--tls-cert with --tls passes validation', () => {
|
|
199
|
+
const result = parseAndValidate(['--tls', '--tls-cert', 'foo.pem'], requiresCmd);
|
|
200
|
+
expect(result.args['tls']).toBe(true);
|
|
201
|
+
expect(result.args['tls-cert']).toBe('foo.pem');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---- 4. valueParser (possible values) ----
|
|
206
|
+
|
|
207
|
+
describe('clap valueParser behavior', () => {
|
|
208
|
+
test('invalid enum value shows clap-style error with value name', () => {
|
|
209
|
+
expectParseError(
|
|
210
|
+
['--env', 'production'],
|
|
211
|
+
simpleCmd,
|
|
212
|
+
"invalid value 'production' for '--env <ENV>'\n [possible values: dev, staging, prod]",
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('valid enum value passes', () => {
|
|
217
|
+
const result = parseAndValidate(['--env', 'dev'], simpleCmd);
|
|
218
|
+
expect(result.args['env']).toBe('dev');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('all valid enum values pass', () => {
|
|
222
|
+
for (const val of ['dev', 'staging', 'prod']) {
|
|
223
|
+
const result = parseAndValidate(['--env', val], simpleCmd);
|
|
224
|
+
expect(result.args['env']).toBe(val);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ---- 5. Unknown args with Levenshtein suggestion ----
|
|
230
|
+
|
|
231
|
+
describe('clap unknown argument behavior', () => {
|
|
232
|
+
test('unknown flag with similar match gets suggestion', () => {
|
|
233
|
+
expectParseError(
|
|
234
|
+
['--verbos'],
|
|
235
|
+
simpleCmd,
|
|
236
|
+
/unexpected argument '--verbos' found[\s\S]*tip: a similar argument exists: '--verbose'/,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('completely unknown flag without close match', () => {
|
|
241
|
+
expectParseError(['--zzzzz'], simpleCmd, "unexpected argument '--zzzzz' found");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ---- 6. Missing required args ----
|
|
246
|
+
|
|
247
|
+
describe('clap missing required args behavior', () => {
|
|
248
|
+
const requiredCmd: CommandDef = {
|
|
249
|
+
meta: { name: 'program' },
|
|
250
|
+
args: {
|
|
251
|
+
name: {
|
|
252
|
+
type: 'string',
|
|
253
|
+
long: 'name',
|
|
254
|
+
valueName: 'NAME',
|
|
255
|
+
required: true,
|
|
256
|
+
},
|
|
257
|
+
port: {
|
|
258
|
+
type: 'number',
|
|
259
|
+
long: 'port',
|
|
260
|
+
valueName: 'PORT',
|
|
261
|
+
required: true,
|
|
262
|
+
},
|
|
263
|
+
verbose: {
|
|
264
|
+
type: 'boolean',
|
|
265
|
+
long: 'verbose',
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
test('multiple missing required args are all listed', () => {
|
|
271
|
+
expectParseError(
|
|
272
|
+
['--verbose'],
|
|
273
|
+
requiredCmd,
|
|
274
|
+
'the following required arguments were not provided:\n --name\n --port',
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('one missing required arg', () => {
|
|
279
|
+
expectParseError(
|
|
280
|
+
['--name', 'test'],
|
|
281
|
+
requiredCmd,
|
|
282
|
+
'the following required arguments were not provided:\n --port',
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('all required args provided passes', () => {
|
|
287
|
+
const result = parseAndValidate(['--name', 'test', '--port', '8080'], requiredCmd);
|
|
288
|
+
expect(result.args['name']).toBe('test');
|
|
289
|
+
expect(result.args['port']).toBe(8080);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('required positional uses angle bracket format', () => {
|
|
293
|
+
const posCmd: CommandDef = {
|
|
294
|
+
meta: { name: 'program' },
|
|
295
|
+
args: {
|
|
296
|
+
file: {
|
|
297
|
+
type: 'positional',
|
|
298
|
+
valueName: 'FILE',
|
|
299
|
+
required: true,
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
expectParseError([], posCmd, 'the following required arguments were not provided:\n <FILE>');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ---- 7. Help format ----
|
|
309
|
+
|
|
310
|
+
describe('clap help format', () => {
|
|
311
|
+
const helpCmd: CommandDef = {
|
|
312
|
+
meta: { name: 'command', version: '1.0.0', description: 'Description' },
|
|
313
|
+
args: {
|
|
314
|
+
env: {
|
|
315
|
+
type: 'string',
|
|
316
|
+
short: 'e',
|
|
317
|
+
long: 'env',
|
|
318
|
+
valueName: 'ENV',
|
|
319
|
+
valueParser: ['dev', 'staging', 'prod'],
|
|
320
|
+
description: 'Box API environment',
|
|
321
|
+
},
|
|
322
|
+
user: {
|
|
323
|
+
type: 'string',
|
|
324
|
+
short: 'u',
|
|
325
|
+
long: 'user',
|
|
326
|
+
valueName: 'USER',
|
|
327
|
+
env: 'BOX_USER',
|
|
328
|
+
description: 'User key',
|
|
329
|
+
},
|
|
330
|
+
port: {
|
|
331
|
+
type: 'number',
|
|
332
|
+
short: 'p',
|
|
333
|
+
long: 'port',
|
|
334
|
+
valueName: 'PORT',
|
|
335
|
+
default: 3003,
|
|
336
|
+
description: 'Port number',
|
|
337
|
+
},
|
|
338
|
+
verbose: {
|
|
339
|
+
type: 'boolean',
|
|
340
|
+
long: 'verbose',
|
|
341
|
+
description: 'Verbose output',
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
subCommands: {
|
|
345
|
+
proxy: {
|
|
346
|
+
meta: {
|
|
347
|
+
name: 'proxy',
|
|
348
|
+
description: 'Run the proxy server',
|
|
349
|
+
aliases: ['p'],
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
auth: {
|
|
353
|
+
meta: {
|
|
354
|
+
name: 'auth',
|
|
355
|
+
description: 'Run the auth server',
|
|
356
|
+
aliases: ['a'],
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
test('help output has correct structure', () => {
|
|
363
|
+
const help = renderHelp(helpCmd);
|
|
364
|
+
const stripped = stripAnsi(help);
|
|
365
|
+
|
|
366
|
+
// Header: "Description (command v1.0.0)"
|
|
367
|
+
expect(stripped).toContain('Description (command v1.0.0)');
|
|
368
|
+
|
|
369
|
+
// Usage line
|
|
370
|
+
expect(stripped).toContain('Usage: command [OPTIONS] [COMMAND]');
|
|
371
|
+
|
|
372
|
+
// Commands section with aliases in parens
|
|
373
|
+
expect(stripped).toContain('Commands:');
|
|
374
|
+
expect(stripped).toMatch(/proxy \(p\)\s+Run the proxy server/);
|
|
375
|
+
expect(stripped).toMatch(/auth \(a\)\s+Run the auth server/);
|
|
376
|
+
|
|
377
|
+
// Options section
|
|
378
|
+
expect(stripped).toContain('Options:');
|
|
379
|
+
|
|
380
|
+
// Short+long on same line with value name
|
|
381
|
+
expect(stripped).toMatch(/-e, --env <ENV>/);
|
|
382
|
+
expect(stripped).toMatch(/-u, --user <USER>/);
|
|
383
|
+
expect(stripped).toMatch(/-p, --port <PORT>/);
|
|
384
|
+
|
|
385
|
+
// Metadata in brackets (may wrap across lines; normalize whitespace)
|
|
386
|
+
const normalized = stripped.replaceAll(/\s+/g, ' ');
|
|
387
|
+
expect(normalized).toContain('[possible values: dev, staging, prod]');
|
|
388
|
+
expect(normalized).toContain('[env: BOX_USER]');
|
|
389
|
+
expect(normalized).toContain('[default: 3003]');
|
|
390
|
+
|
|
391
|
+
// Flags without short get 4-space indent
|
|
392
|
+
expect(stripped).toMatch(/ --verbose/);
|
|
393
|
+
|
|
394
|
+
// Built-in help and version
|
|
395
|
+
expect(stripped).toMatch(/-h, --help\s+Print help/);
|
|
396
|
+
expect(stripped).toMatch(/-V, --version\s+Print version/);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('subcommand aliases shown in parens', () => {
|
|
400
|
+
const help = renderHelp(helpCmd);
|
|
401
|
+
const stripped = stripAnsi(help);
|
|
402
|
+
expect(stripped).toMatch(/proxy \(p\)/);
|
|
403
|
+
expect(stripped).toMatch(/auth \(a\)/);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ---- 8. --help and --version auto-added ----
|
|
408
|
+
|
|
409
|
+
describe('clap --help and --version auto behavior', () => {
|
|
410
|
+
const minimalCmd: CommandDef = {
|
|
411
|
+
meta: { name: 'tool', version: '2.0.0' },
|
|
412
|
+
args: {},
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
test('--help is recognized even without explicit arg def', () => {
|
|
416
|
+
const result = parse(['--help'], minimalCmd);
|
|
417
|
+
expect(result.helpRequested).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test('-h is recognized as --help', () => {
|
|
421
|
+
const result = parse(['-h'], minimalCmd);
|
|
422
|
+
expect(result.helpRequested).toBe(true);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('--version is recognized even without explicit arg def', () => {
|
|
426
|
+
const result = parse(['--version'], minimalCmd);
|
|
427
|
+
expect(result.versionRequested).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('-V is recognized as --version', () => {
|
|
431
|
+
const result = parse(['-V'], minimalCmd);
|
|
432
|
+
expect(result.versionRequested).toBe(true);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// ---- 9. Subcommand aliases ----
|
|
437
|
+
|
|
438
|
+
describe('clap subcommand alias behavior', () => {
|
|
439
|
+
const subCmd: CommandDef = {
|
|
440
|
+
meta: { name: 'program' },
|
|
441
|
+
args: {},
|
|
442
|
+
subCommands: {
|
|
443
|
+
proxy: {
|
|
444
|
+
meta: {
|
|
445
|
+
name: 'proxy',
|
|
446
|
+
description: 'Run proxy',
|
|
447
|
+
aliases: ['p', 'px'],
|
|
448
|
+
},
|
|
449
|
+
args: {
|
|
450
|
+
port: { type: 'number', short: 'p', long: 'port', default: 3003 },
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
test('help shows alias in parens', () => {
|
|
457
|
+
const help = renderHelp(subCmd);
|
|
458
|
+
const stripped = stripAnsi(help);
|
|
459
|
+
expect(stripped).toMatch(/proxy \(p, px\)/);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ---- 10. Exit codes ----
|
|
464
|
+
|
|
465
|
+
describe('clap exit code behavior', () => {
|
|
466
|
+
test('usage errors should use exit code 2', () => {
|
|
467
|
+
// This is tested indirectly - the runner.ts uses process.exit(2) for CliParseError.
|
|
468
|
+
// We verify the error type is CliParseError so the runner handles it correctly.
|
|
469
|
+
const cmd: CommandDef = {
|
|
470
|
+
meta: { name: 'program' },
|
|
471
|
+
args: {
|
|
472
|
+
name: { type: 'string', long: 'name', required: true },
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
expect(() => parseAndValidate([], cmd)).toThrow(CliParseError);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ---- Additional: env fallback ----
|
|
481
|
+
|
|
482
|
+
describe('env variable fallback', () => {
|
|
483
|
+
test('env var is used when flag not provided', () => {
|
|
484
|
+
const original = process.env['BOX_TEST_PORT'];
|
|
485
|
+
process.env['BOX_TEST_PORT'] = '9999';
|
|
486
|
+
try {
|
|
487
|
+
const cmd: CommandDef = {
|
|
488
|
+
meta: { name: 'program' },
|
|
489
|
+
args: {
|
|
490
|
+
port: { type: 'number', long: 'port', env: 'BOX_TEST_PORT' },
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
const result = parse([], cmd);
|
|
494
|
+
expect(result.args['port']).toBe(9999);
|
|
495
|
+
} finally {
|
|
496
|
+
if (original === undefined) {
|
|
497
|
+
delete process.env['BOX_TEST_PORT'];
|
|
498
|
+
} else {
|
|
499
|
+
process.env['BOX_TEST_PORT'] = original;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('explicit flag overrides env var', () => {
|
|
505
|
+
const original = process.env['BOX_TEST_PORT'];
|
|
506
|
+
process.env['BOX_TEST_PORT'] = '9999';
|
|
507
|
+
try {
|
|
508
|
+
const cmd: CommandDef = {
|
|
509
|
+
meta: { name: 'program' },
|
|
510
|
+
args: {
|
|
511
|
+
port: { type: 'number', long: 'port', env: 'BOX_TEST_PORT' },
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
const result = parse(['--port', '8080'], cmd);
|
|
515
|
+
expect(result.args['port']).toBe(8080);
|
|
516
|
+
} finally {
|
|
517
|
+
if (original === undefined) {
|
|
518
|
+
delete process.env['BOX_TEST_PORT'];
|
|
519
|
+
} else {
|
|
520
|
+
process.env['BOX_TEST_PORT'] = original;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// ---- Additional: default values ----
|
|
527
|
+
|
|
528
|
+
describe('default values', () => {
|
|
529
|
+
test('default is used when not provided', () => {
|
|
530
|
+
const result = parse([], simpleCmd);
|
|
531
|
+
expect(result.args['port']).toBe(3003);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test('explicit value overrides default', () => {
|
|
535
|
+
const result = parse(['--port', '8080'], simpleCmd);
|
|
536
|
+
expect(result.args['port']).toBe(8080);
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// ---- Additional: count action ----
|
|
541
|
+
|
|
542
|
+
describe('count action', () => {
|
|
543
|
+
test('-vvv counts to 3', () => {
|
|
544
|
+
const cmd: CommandDef = {
|
|
545
|
+
meta: { name: 'program' },
|
|
546
|
+
args: {
|
|
547
|
+
verbose: { type: 'boolean', short: 'v', long: 'verbose', action: 'count' },
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
const result = parse(['-v', '-v', '-v'], cmd);
|
|
551
|
+
expect(result.args['verbose']).toBe(3);
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// ---- Additional: append action ----
|
|
556
|
+
|
|
557
|
+
describe('append action', () => {
|
|
558
|
+
test('multiple --header values collected into array', () => {
|
|
559
|
+
const cmd: CommandDef = {
|
|
560
|
+
meta: { name: 'program' },
|
|
561
|
+
args: {
|
|
562
|
+
header: { type: 'string', short: 'H', long: 'header', action: 'append' },
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
const result = parse(['-H', 'X-Foo: bar', '-H', 'X-Baz: qux'], cmd);
|
|
566
|
+
expect(result.args['header']).toEqual(['X-Foo: bar', 'X-Baz: qux']);
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// ---- Additional: -- separator ----
|
|
571
|
+
|
|
572
|
+
describe('-- separator', () => {
|
|
573
|
+
test('args after -- go to rest', () => {
|
|
574
|
+
const cmd: CommandDef = {
|
|
575
|
+
meta: { name: 'program' },
|
|
576
|
+
args: {
|
|
577
|
+
verbose: { type: 'boolean', long: 'verbose' },
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
const result = parse(['--verbose', '--', '--not-a-flag', 'positional'], cmd);
|
|
581
|
+
expect(result.args['verbose']).toBe(true);
|
|
582
|
+
expect(result.rest).toEqual(['--not-a-flag', 'positional']);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// ---- Additional: boolean negation ----
|
|
587
|
+
|
|
588
|
+
describe('boolean negation', () => {
|
|
589
|
+
test('--no-verbose sets verbose to false', () => {
|
|
590
|
+
const cmd: CommandDef = {
|
|
591
|
+
meta: { name: 'program' },
|
|
592
|
+
args: {
|
|
593
|
+
verbose: { type: 'boolean', long: 'verbose', default: true },
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
const result = parse(['--no-verbose'], cmd);
|
|
597
|
+
expect(result.args['verbose']).toBe(false);
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// ---- Additional: kebab to camelCase ----
|
|
602
|
+
|
|
603
|
+
describe('kebab to camelCase mapping', () => {
|
|
604
|
+
test('--config-path is accessible as configPath', () => {
|
|
605
|
+
const cmd: CommandDef = {
|
|
606
|
+
meta: { name: 'program' },
|
|
607
|
+
args: {
|
|
608
|
+
'config-path': { type: 'string', long: 'config-path' },
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
const result = parse(['--config-path', '/etc/config.yaml'], cmd);
|
|
612
|
+
expect(result.args['configPath']).toBe('/etc/config.yaml');
|
|
613
|
+
expect(result.args['config-path']).toBe('/etc/config.yaml');
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// ---- Additional: numArgs validation ----
|
|
618
|
+
|
|
619
|
+
describe('numArgs validation', () => {
|
|
620
|
+
test('too few values rejected', () => {
|
|
621
|
+
const cmd: CommandDef = {
|
|
622
|
+
meta: { name: 'program' },
|
|
623
|
+
args: {
|
|
624
|
+
files: {
|
|
625
|
+
type: 'string',
|
|
626
|
+
long: 'files',
|
|
627
|
+
action: 'append',
|
|
628
|
+
numArgs: { min: 2, max: 5 },
|
|
629
|
+
},
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
expectParseError(
|
|
633
|
+
['--files', 'one'],
|
|
634
|
+
cmd,
|
|
635
|
+
"the argument '--files' requires at least 2 values but 1 were provided",
|
|
636
|
+
);
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// ---- Additional: argument groups ----
|
|
641
|
+
|
|
642
|
+
describe('argument groups', () => {
|
|
643
|
+
const groupCmd: CommandDef = {
|
|
644
|
+
meta: { name: 'program' },
|
|
645
|
+
args: {
|
|
646
|
+
json: { type: 'boolean', long: 'json' },
|
|
647
|
+
yaml: { type: 'boolean', long: 'yaml' },
|
|
648
|
+
table: { type: 'boolean', long: 'table' },
|
|
649
|
+
},
|
|
650
|
+
groups: [
|
|
651
|
+
{
|
|
652
|
+
name: 'output-format',
|
|
653
|
+
args: ['json', 'yaml', 'table'],
|
|
654
|
+
required: false,
|
|
655
|
+
multiple: false,
|
|
656
|
+
},
|
|
657
|
+
],
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
test('mutually exclusive group rejects multiple', () => {
|
|
661
|
+
expectParseError(
|
|
662
|
+
['--json', '--yaml'],
|
|
663
|
+
groupCmd,
|
|
664
|
+
/the following arguments cannot be used together/,
|
|
665
|
+
);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test('single arg from group is fine', () => {
|
|
669
|
+
const result = parseAndValidate(['--json'], groupCmd);
|
|
670
|
+
expect(result.args['json']).toBe(true);
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// ---- Additional: renderUsage ----
|
|
675
|
+
|
|
676
|
+
describe('renderUsage', () => {
|
|
677
|
+
test('renders compact usage line', () => {
|
|
678
|
+
const usage = renderUsage(simpleCmd);
|
|
679
|
+
const stripped = stripAnsi(usage);
|
|
680
|
+
expect(stripped).toContain('Usage: program [OPTIONS]');
|
|
681
|
+
});
|
|
682
|
+
});
|