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,182 @@
1
+ /**
2
+ * Tests for discovering subcommands from installed packages.
3
+ */
4
+
5
+ import { describe, test, expect, afterEach, beforeEach } from 'bun:test';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import {
10
+ discoverPlugins,
11
+ pluginSubCommands,
12
+ nodeModulesPaths,
13
+ } from '../plugins.js';
14
+ import type { DiscoveredPlugin } from '../plugins.js';
15
+ import { defineCommand } from '../runner.js';
16
+ import { runCli, captureArgs } from '../testing.js';
17
+ import type { CommandDef } from '../types.js';
18
+
19
+ let dir: string;
20
+
21
+ function makePackage(root: string, name: string, description?: string): void {
22
+ const target = join(root, 'node_modules', name);
23
+ mkdirSync(target, { recursive: true });
24
+ writeFileSync(
25
+ join(target, 'package.json'),
26
+ JSON.stringify({ name, version: '1.0.0', ...(description === undefined ? {} : { description }) }),
27
+ );
28
+ }
29
+
30
+ beforeEach(() => {
31
+ dir = mkdtempSync(join(tmpdir(), 'clap-ts-plugins-'));
32
+ });
33
+ afterEach(() => {
34
+ rmSync(dir, { recursive: true, force: true });
35
+ });
36
+
37
+ describe('nodeModulesPaths', () => {
38
+ test('walks up to the root', () => {
39
+ const paths = nodeModulesPaths('/a/b/c');
40
+ expect(paths[0]).toBe(join('/a/b/c', 'node_modules'));
41
+ expect(paths).toContain(join('/a', 'node_modules'));
42
+ });
43
+ });
44
+
45
+ describe('discoverPlugins', () => {
46
+ test('finds packages matching the prefix', () => {
47
+ makePackage(dir, 'demo-plugin-deploy');
48
+ makePackage(dir, 'demo-plugin-build');
49
+ makePackage(dir, 'unrelated');
50
+ const found = discoverPlugins('demo', { searchPaths: [join(dir, 'node_modules')] });
51
+ expect(found.map((p) => p.name)).toEqual(['build', 'deploy']);
52
+ });
53
+
54
+ test('handles scoped packages', () => {
55
+ makePackage(dir, '@acme/demo-plugin-ship');
56
+ const found = discoverPlugins('demo', { searchPaths: [join(dir, 'node_modules')] });
57
+ expect(found).toHaveLength(1);
58
+ expect(found[0]!.name).toBe('ship');
59
+ expect(found[0]!.packageName).toBe('@acme/demo-plugin-ship');
60
+ });
61
+
62
+ test('ignores a bare prefix with nothing after it', () => {
63
+ makePackage(dir, 'demo-plugin-');
64
+ expect(discoverPlugins('demo', { searchPaths: [join(dir, 'node_modules')] })).toEqual([]);
65
+ });
66
+
67
+ test('a missing search path is not an error', () => {
68
+ expect(discoverPlugins('demo', { searchPaths: [join(dir, 'nope')] })).toEqual([]);
69
+ });
70
+
71
+ test('the nearest copy shadows one further up', () => {
72
+ const near = join(dir, 'near');
73
+ const far = join(dir, 'far');
74
+ makePackage(near, 'demo-plugin-x');
75
+ makePackage(far, 'demo-plugin-x');
76
+ const found = discoverPlugins('demo', {
77
+ searchPaths: [join(near, 'node_modules'), join(far, 'node_modules')],
78
+ });
79
+ expect(found).toHaveLength(1);
80
+ expect(found[0]!.dir).toContain('near');
81
+ });
82
+
83
+ test('the prefix is configurable', () => {
84
+ makePackage(dir, 'demo-ext-thing');
85
+ const found = discoverPlugins('demo', {
86
+ prefix: 'demo-ext-',
87
+ searchPaths: [join(dir, 'node_modules')],
88
+ });
89
+ expect(found.map((p) => p.name)).toEqual(['thing']);
90
+ });
91
+ });
92
+
93
+ describe('pluginSubCommands', () => {
94
+ function withStub(load: (p: DiscoveredPlugin) => CommandDef<any> | undefined) {
95
+ makePackage(dir, 'demo-plugin-deploy', 'Ship it');
96
+ return pluginSubCommands('demo', { searchPaths: [join(dir, 'node_modules')], load });
97
+ }
98
+
99
+ test('names the subcommand after the package suffix', () => {
100
+ const thunk = withStub(() => ({ meta: { name: 'ignored' }, run() {} }));
101
+ expect(Object.keys(thunk())).toEqual(['deploy']);
102
+ expect(thunk()['deploy']!.meta.name).toBe('deploy');
103
+ });
104
+
105
+ test("falls back to the package description for help", () => {
106
+ const thunk = withStub(() => ({ meta: { name: 'x' }, run() {} }));
107
+ expect(thunk()['deploy']!.meta.description).toBe('Ship it');
108
+ });
109
+
110
+ test("a plugin's own description wins", () => {
111
+ const thunk = withStub(() => ({ meta: { name: 'x', description: 'Own' }, run() {} }));
112
+ expect(thunk()['deploy']!.meta.description).toBe('Own');
113
+ });
114
+
115
+ test('a plugin exporting nothing usable is skipped', () => {
116
+ expect(Object.keys(withStub(() => undefined)())).toEqual([]);
117
+ });
118
+
119
+ test('a failing plugin names itself in the error', () => {
120
+ const thunk = withStub(() => {
121
+ throw new Error('boom');
122
+ });
123
+ expect(() => thunk()).toThrow("plugin 'demo-plugin-deploy' failed to load: boom");
124
+ });
125
+
126
+ test('onError can downgrade a failure to a skip', () => {
127
+ const seen: string[] = [];
128
+ makePackage(dir, 'demo-plugin-deploy');
129
+ const thunk = pluginSubCommands('demo', {
130
+ searchPaths: [join(dir, 'node_modules')],
131
+ load: () => {
132
+ throw new Error('boom');
133
+ },
134
+ onError: (plugin) => seen.push(plugin.name),
135
+ });
136
+ expect(Object.keys(thunk())).toEqual([]);
137
+ expect(seen).toEqual(['deploy']);
138
+ });
139
+ });
140
+
141
+ describe('plugins as lazy subcommands', () => {
142
+ test('discovery is deferred until a token could be a subcommand', async () => {
143
+ makePackage(dir, 'demo-plugin-deploy');
144
+ let loads = 0;
145
+ const main = defineCommand({
146
+ meta: { name: 'demo', version: '1.0' },
147
+ args: { verbose: { type: 'boolean' } },
148
+ run() {},
149
+ lazySubCommands: pluginSubCommands('demo', {
150
+ searchPaths: [join(dir, 'node_modules')],
151
+ load: () => {
152
+ loads++;
153
+ return { meta: { name: 'x' }, run() {} };
154
+ },
155
+ }),
156
+ });
157
+
158
+ await runCli(main, ['--verbose']);
159
+ expect(loads).toBe(0);
160
+
161
+ await runCli(main, ['deploy']);
162
+ expect(loads).toBe(1);
163
+ });
164
+
165
+ test('a discovered plugin runs and parses its own args', async () => {
166
+ makePackage(dir, 'demo-plugin-deploy');
167
+ const main = defineCommand({
168
+ meta: { name: 'demo' },
169
+ lazySubCommands: pluginSubCommands('demo', {
170
+ searchPaths: [join(dir, 'node_modules')],
171
+ load: () => ({
172
+ meta: { name: 'x' },
173
+ args: { target: { type: 'string' } },
174
+ run() {},
175
+ }),
176
+ }),
177
+ });
178
+
179
+ const { args } = await captureArgs(main, ['deploy', '--target', 'prod']);
180
+ expect(args['target']).toBe('prod');
181
+ });
182
+ });
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Tests for spinners and progress bars, including that they stay quiet when
3
+ * the destination is not a terminal.
4
+ */
5
+
6
+ import { describe, test, expect } from 'bun:test';
7
+ import { spinner, progressBar } from '../progress.js';
8
+ import type { OutputSink } from '../types.js';
9
+
10
+ function collector(): OutputSink & { text(): string } {
11
+ const chunks: string[] = [];
12
+ return {
13
+ write(chunk: string) {
14
+ chunks.push(chunk);
15
+ },
16
+ text: () => chunks.join(''),
17
+ };
18
+ }
19
+
20
+ const off = { enabled: false as const, color: false };
21
+ const on = { enabled: true as const, color: false, interval: 10_000 };
22
+
23
+ describe('spinner when nothing is watching', () => {
24
+ test('start and update draw nothing', () => {
25
+ const sink = collector();
26
+ spinner('working', { sink, ...off }).start().update('still working');
27
+ expect(sink.text()).toBe('');
28
+ });
29
+
30
+ test('succeed still reports once', () => {
31
+ const sink = collector();
32
+ spinner('working', { sink, ...off }).start().succeed('done');
33
+ expect(sink.text()).toBe('✔ done\n');
34
+ });
35
+
36
+ test('fail and warn use their own marks', () => {
37
+ const failed = collector();
38
+ spinner('', { sink: failed, ...off }).fail('bad');
39
+ expect(failed.text()).toBe('✖ bad\n');
40
+
41
+ const warned = collector();
42
+ spinner('', { sink: warned, ...off }).warn('careful');
43
+ expect(warned.text()).toBe('⚠ careful\n');
44
+ });
45
+
46
+ test('stop leaves nothing behind', () => {
47
+ const sink = collector();
48
+ spinner('working', { sink, ...off }).start().stop();
49
+ expect(sink.text()).toBe('');
50
+ });
51
+
52
+ test('an empty message prints no line at all', () => {
53
+ const sink = collector();
54
+ spinner('', { sink, ...off }).succeed();
55
+ expect(sink.text()).toBe('');
56
+ });
57
+
58
+ test('enabled reports the decision', () => {
59
+ expect(spinner('x', { sink: collector(), ...off }).enabled).toBe(false);
60
+ expect(spinner('x', { sink: collector(), ...on }).enabled).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe('spinner when drawing', () => {
65
+ test('start draws a frame and the message', () => {
66
+ const sink = collector();
67
+ const spin = spinner('working', { sink, ...on }).start();
68
+ expect(sink.text()).toContain('working');
69
+ expect(sink.text()).toContain('\r\x1b[2K');
70
+ spin.stop();
71
+ });
72
+
73
+ test('ascii mode avoids braille frames', () => {
74
+ const sink = collector();
75
+ spinner('working', { sink, ...on, ascii: true }).start().stop();
76
+ expect(sink.text()).not.toMatch(/[⠋⠙⠹]/);
77
+ });
78
+
79
+ test('succeed clears the line before reporting', () => {
80
+ const sink = collector();
81
+ spinner('working', { sink, ...on }).start().succeed('done');
82
+ expect(sink.text().endsWith('✔ done\n')).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe('progressBar', () => {
87
+ test('render reflects the position', () => {
88
+ const bar = progressBar({ total: 10, width: 10, sink: collector(), ...off });
89
+ expect(bar.render()).toContain(' 0% 0/10');
90
+ bar.update(5);
91
+ expect(bar.render()).toContain(' 50% 5/10');
92
+ expect(bar.render()).toContain('█████░░░░░');
93
+ });
94
+
95
+ test('tick moves forward', () => {
96
+ const bar = progressBar({ total: 4, width: 4, sink: collector(), ...off });
97
+ bar.tick().tick(2);
98
+ expect(bar.render()).toContain('3/4');
99
+ });
100
+
101
+ test('the position is clamped at both ends', () => {
102
+ const bar = progressBar({ total: 5, width: 5, sink: collector(), ...off });
103
+ bar.update(-3);
104
+ expect(bar.render()).toContain('0/5');
105
+ bar.update(99);
106
+ expect(bar.render()).toContain('5/5');
107
+ });
108
+
109
+ test('a label is appended', () => {
110
+ const bar = progressBar({ total: 2, width: 2, text: 'files', sink: collector(), ...off });
111
+ expect(bar.render()).toContain('files');
112
+ bar.update(1, 'more files');
113
+ expect(bar.render()).toContain('more files');
114
+ });
115
+
116
+ test('nothing is drawn when disabled', () => {
117
+ const sink = collector();
118
+ progressBar({ total: 3, sink, ...off }).update(1).tick();
119
+ expect(sink.text()).toBe('');
120
+ });
121
+
122
+ test('finish reports the label once when disabled', () => {
123
+ const sink = collector();
124
+ progressBar({ total: 3, sink, ...off }).finish('all done');
125
+ expect(sink.text()).toBe('all done\n');
126
+ });
127
+
128
+ test('finish fills the bar and ends the line when drawing', () => {
129
+ const sink = collector();
130
+ const bar = progressBar({ total: 3, width: 3, sink, ...on });
131
+ bar.finish();
132
+ expect(bar.render()).toContain('100% 3/3');
133
+ expect(sink.text().endsWith('\n')).toBe(true);
134
+ });
135
+
136
+ test('a zero total does not divide by zero', () => {
137
+ // Clamped to one unit, so an empty job renders sanely rather than as NaN.
138
+ const bar = progressBar({ total: 0, width: 4, sink: collector(), ...off });
139
+ expect(bar.render()).toContain('0% 0/1');
140
+ expect(bar.render()).not.toContain('NaN');
141
+ bar.finish();
142
+ expect(bar.render()).toContain('100% 1/1');
143
+ });
144
+ });
145
+
146
+ describe('drawing stays within the terminal', () => {
147
+ test('a bar wider than the terminal is trimmed to fit', () => {
148
+ const sink = { ...collector(), isTTY: true, columns: 40 };
149
+ const bar = progressBar({
150
+ total: 10,
151
+ sink: sink as never,
152
+ enabled: true,
153
+ color: false,
154
+ text: 'a rather long label that will not fit in forty columns',
155
+ });
156
+ bar.update(5);
157
+ expect(bar.render().length).toBeLessThanOrEqual(40);
158
+ });
159
+
160
+ test('a spinner message longer than the terminal is trimmed', () => {
161
+ const sink = { ...collector(), isTTY: true, columns: 20 };
162
+ spinner('x'.repeat(100), { sink: sink as never, ...on })
163
+ .start()
164
+ .stop();
165
+ const longest = Math.max(
166
+ ...sink
167
+ .text()
168
+ .split('\r')
169
+ .map((l) => l.replace(/\x1b\[[0-9?]*[a-zA-Z]/g, '').length),
170
+ );
171
+ expect(longest).toBeLessThanOrEqual(20);
172
+ });
173
+ });
174
+
175
+ describe('redraws are throttled', () => {
176
+ test('a tight update loop does not write once per update', () => {
177
+ const sink = collector();
178
+ const bar = progressBar({ total: 10_000, sink, enabled: true, color: false });
179
+ for (let i = 0; i < 10_000; i++) {
180
+ bar.update(i);
181
+ }
182
+ expect(sink.text().split('\r').length - 1).toBeLessThan(50);
183
+ });
184
+
185
+ test('finish always lands, however recently the last redraw was', () => {
186
+ const sink = collector();
187
+ progressBar({ total: 3, sink, enabled: true, color: false }).update(1).finish();
188
+ expect(sink.text()).toContain('100%');
189
+ });
190
+
191
+ test('throttle 0 draws every update', () => {
192
+ const sink = collector();
193
+ const bar = progressBar({ total: 5, sink, enabled: true, color: false, throttle: 0 });
194
+ for (let i = 0; i < 5; i++) {
195
+ bar.update(i);
196
+ }
197
+ expect(sink.text().split('\r').length - 1).toBe(5);
198
+ });
199
+ });
200
+
201
+ describe('the cursor is put back', () => {
202
+ test('a spinner hides it while animating and restores it on succeed', () => {
203
+ const sink = collector();
204
+ const spin = spinner('working', { sink, ...on }).start();
205
+ expect(sink.text()).toContain('\x1b[?25l');
206
+ spin.succeed('done');
207
+ expect(sink.text()).toContain('\x1b[?25h');
208
+ });
209
+
210
+ test('stop restores it too', () => {
211
+ const sink = collector();
212
+ spinner('working', { sink, ...on }).start().stop();
213
+ expect(sink.text()).toContain('\x1b[?25h');
214
+ });
215
+
216
+ test('nothing is hidden when drawing is off', () => {
217
+ const sink = collector();
218
+ spinner('working', { sink, ...off }).start().stop();
219
+ expect(sink.text()).not.toContain('\x1b[?25l');
220
+ });
221
+ });
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Tests for interactive prompts and filling missing arguments from them.
3
+ */
4
+
5
+ import { describe, test, expect } from 'bun:test';
6
+ import {
7
+ input,
8
+ confirm,
9
+ select,
10
+ multiselect,
11
+ password,
12
+ promptForArg,
13
+ promptMissing,
14
+ scriptedIO,
15
+ terminalIO,
16
+ } from '../prompt.js';
17
+ import { defineCommand } from '../runner.js';
18
+ import { runCli, captureArgs } from '../testing.js';
19
+ import type { ArgDef } from '../types.js';
20
+
21
+ describe('input', () => {
22
+ test('returns the reply', async () => {
23
+ expect(await input('Name', { io: scriptedIO(['alice']) })).toBe('alice');
24
+ });
25
+
26
+ test('trims whitespace', async () => {
27
+ expect(await input('Name', { io: scriptedIO([' alice ']) })).toBe('alice');
28
+ });
29
+
30
+ test('an empty reply takes the default', async () => {
31
+ expect(await input('Name', { io: scriptedIO(['']), defaultValue: 'bob' })).toBe('bob');
32
+ });
33
+
34
+ test('the default is shown in the prompt', async () => {
35
+ const io = scriptedIO(['']);
36
+ await input('Name', { io, defaultValue: 'bob' });
37
+ expect(io.output).toContain('Name (bob):');
38
+ });
39
+ });
40
+
41
+ describe('confirm', () => {
42
+ test('accepts the usual affirmatives', async () => {
43
+ for (const answer of ['y', 'Y', 'yes', 'true', '1']) {
44
+ expect(await confirm('Sure', { io: scriptedIO([answer]) })).toBe(true);
45
+ }
46
+ });
47
+
48
+ test('anything else is no', async () => {
49
+ for (const answer of ['n', 'no', 'nope']) {
50
+ expect(await confirm('Sure', { io: scriptedIO([answer]) })).toBe(false);
51
+ }
52
+ });
53
+
54
+ test('an empty reply takes the default, and the hint shows which', async () => {
55
+ const yes = scriptedIO(['']);
56
+ expect(await confirm('Sure', { io: yes, defaultValue: true })).toBe(true);
57
+ expect(yes.output).toContain('(Y/n)');
58
+
59
+ const no = scriptedIO(['']);
60
+ expect(await confirm('Sure', { io: no })).toBe(false);
61
+ expect(no.output).toContain('(y/N)');
62
+ });
63
+ });
64
+
65
+ describe('select', () => {
66
+ const choices = ['fast', 'safe'];
67
+
68
+ test('takes a number', async () => {
69
+ expect(await select('Mode', choices, { io: scriptedIO(['2']) })).toBe('safe');
70
+ });
71
+
72
+ test('takes the value itself', async () => {
73
+ expect(await select('Mode', choices, { io: scriptedIO(['fast']) })).toBe('fast');
74
+ });
75
+
76
+ test('numbers the options and shows their help', async () => {
77
+ const io = scriptedIO(['1']);
78
+ await select('Mode', [{ name: 'fast', help: 'Skip checks' }], { io });
79
+ expect(io.output).toContain('1) fast Skip checks');
80
+ });
81
+
82
+ test('re-asks after an answer that is not a choice', async () => {
83
+ const io = scriptedIO(['nonsense', '1']);
84
+ expect(await select('Mode', choices, { io })).toBe('fast');
85
+ expect(io.output).toContain('not one of the choices');
86
+ });
87
+
88
+ test('an out of range number is refused', async () => {
89
+ const io = scriptedIO(['9', '1']);
90
+ expect(await select('Mode', choices, { io })).toBe('fast');
91
+ });
92
+
93
+ test('no choices at all is a programming error', async () => {
94
+ await expect(select('Mode', [], { io: scriptedIO([]) })).rejects.toThrow('at least one choice');
95
+ });
96
+ });
97
+
98
+ describe('multiselect', () => {
99
+ test('takes several answers by number or name', async () => {
100
+ const io = scriptedIO(['1, safe']);
101
+ expect(await multiselect('Modes', ['fast', 'safe'], { io })).toEqual(['fast', 'safe']);
102
+ });
103
+
104
+ test('an empty reply picks nothing', async () => {
105
+ expect(await multiselect('Modes', ['a'], { io: scriptedIO(['']) })).toEqual([]);
106
+ });
107
+
108
+ test('one bad entry re-asks for the lot', async () => {
109
+ const io = scriptedIO(['1,nope', '1']);
110
+ expect(await multiselect('Modes', ['fast', 'safe'], { io })).toEqual(['fast']);
111
+ expect(io.output).toContain('not one of the choices');
112
+ });
113
+ });
114
+
115
+ describe('password', () => {
116
+ test('reads without echoing through the io', async () => {
117
+ expect(await password('Token', { io: scriptedIO(['hunter2']) })).toBe('hunter2');
118
+ });
119
+ });
120
+
121
+ describe('promptForArg', () => {
122
+ const ask = (def: ArgDef, answers: string[]) =>
123
+ promptForArg('thing', def, { io: scriptedIO(answers) });
124
+
125
+ test('a boolean asks for confirmation', async () => {
126
+ expect(await ask({ type: 'boolean', description: 'Enable' }, ['y'])).toBe(true);
127
+ });
128
+
129
+ test('possible values become a list', async () => {
130
+ expect(await ask({ type: 'string', valueParser: ['fast', 'safe'] }, ['2'])).toBe('safe');
131
+ });
132
+
133
+ test('an append arg with possible values takes several', async () => {
134
+ const def: ArgDef = { type: 'string', action: 'append', valueParser: ['a', 'b'] };
135
+ expect(await ask(def, ['1,2'])).toEqual(['a', 'b']);
136
+ });
137
+
138
+ test('hidden possible values are not offered', async () => {
139
+ const io = scriptedIO(['1']);
140
+ await promptForArg('thing', {
141
+ type: 'string',
142
+ valueParser: [{ name: 'shown' }, { name: 'secret', hidden: true }],
143
+ }, { io });
144
+ expect(io.output).not.toContain('secret');
145
+ });
146
+
147
+ test('an empty answer is refused and asked again', async () => {
148
+ const io = scriptedIO(['', 'value']);
149
+ expect(await promptForArg('thing', { type: 'string' }, { io })).toBe('value');
150
+ expect(io.output).toContain('a value is required');
151
+ });
152
+
153
+ test('a number that is not a number is refused', async () => {
154
+ const io = scriptedIO(['abc', '8080']);
155
+ expect(await promptForArg('port', { type: 'number' }, { io })).toBe('8080');
156
+ expect(io.output).toContain('expected a number');
157
+ });
158
+
159
+ test("a custom parser's complaint is shown", async () => {
160
+ const io = scriptedIO(['nope', 'ok']);
161
+ const def: ArgDef = {
162
+ type: 'string',
163
+ valueParser: (v) => {
164
+ if (v !== 'ok') {
165
+ throw new Error('must be ok');
166
+ }
167
+ return v;
168
+ },
169
+ };
170
+ expect(await promptForArg('thing', def, { io })).toBe('ok');
171
+ expect(io.output).toContain('must be ok');
172
+ });
173
+
174
+ test('a default is offered', async () => {
175
+ const io = scriptedIO(['']);
176
+ expect(await promptForArg('thing', { type: 'string', default: 'fallback' }, { io })).toBe(
177
+ 'fallback',
178
+ );
179
+ });
180
+ });
181
+
182
+ describe('promptMissing', () => {
183
+ const main = defineCommand({
184
+ meta: { name: 'app' },
185
+ args: {
186
+ name: { type: 'string', required: true, description: 'Your name' },
187
+ port: { type: 'number', required: true, description: 'Port' },
188
+ given: { type: 'string' },
189
+ },
190
+ run() {},
191
+ });
192
+
193
+ test('asks only for what is missing', async () => {
194
+ const io = scriptedIO(['alice', '8080']);
195
+ const { args } = await captureArgs(main, ['--given', 'x'], {
196
+ fillMissing: promptMissing({ io }),
197
+ });
198
+ expect(args['name']).toBe('alice');
199
+ expect(args['port']).toBe(8080);
200
+ expect(args['given']).toBe('x');
201
+ });
202
+
203
+ test('an argument supplied on the command line is not asked about', async () => {
204
+ const io = scriptedIO(['8080']);
205
+ await captureArgs(main, ['--name', 'bob'], { fillMissing: promptMissing({ io }) });
206
+ expect(io.output).not.toContain('Your name');
207
+ expect(io.output).toContain('Port');
208
+ });
209
+
210
+ test('the value is coerced to the argument type', async () => {
211
+ const { args } = await captureArgs(main, [], {
212
+ fillMissing: promptMissing({ io: scriptedIO(['alice', '8080']) }),
213
+ });
214
+ expect(args['port']).toBe(8080);
215
+ });
216
+
217
+ test('the source is reported as prompt', async () => {
218
+ let source: string | undefined;
219
+ const probe = defineCommand({
220
+ meta: { name: 'app' },
221
+ args: { name: { type: 'string', required: true } },
222
+ run({ valueSources }) {
223
+ source = valueSources.get('name');
224
+ },
225
+ });
226
+ await runCli(probe, [], { fillMissing: promptMissing({ io: scriptedIO(['alice']) }) });
227
+ expect(source).toBe('prompt');
228
+ });
229
+
230
+ test('a non-interactive terminal fails as usual rather than hanging', async () => {
231
+ const io = { ...scriptedIO(['alice', '8080']), interactive: false };
232
+ const { plainStderr, exitCode } = await runCli(main, [], { fillMissing: promptMissing({ io }) });
233
+ expect(exitCode).toBe(2);
234
+ expect(plainStderr).toContain('the following required arguments were not provided');
235
+ });
236
+
237
+ test('force asks even when nobody appears to be there', async () => {
238
+ const io = { ...scriptedIO(['alice', '8080']), interactive: false };
239
+ const { exitCode } = await runCli(main, [], {
240
+ fillMissing: promptMissing({ io, force: true }),
241
+ });
242
+ expect(exitCode).toBe(0);
243
+ });
244
+
245
+ test('nothing missing means nothing asked', async () => {
246
+ const io = scriptedIO([]);
247
+ await runCli(main, ['--name', 'a', '--port', '1'], { fillMissing: promptMissing({ io }) });
248
+ expect(io.output).toBe('');
249
+ });
250
+ });
251
+
252
+ describe('terminalIO is shared', () => {
253
+ test('successive calls hand back the same interface', () => {
254
+ // A readline interface owns stdin: a second one finds the stream consumed,
255
+ // so two standalone prompts in a row would fail with "input ended".
256
+ expect(terminalIO()).toBe(terminalIO());
257
+ });
258
+
259
+ test('close releases it and the next call builds a fresh one', () => {
260
+ const first = terminalIO();
261
+ first.close();
262
+ expect(terminalIO()).not.toBe(first);
263
+ terminalIO().close();
264
+ });
265
+ });