@nexical/cli-core 0.1.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/.github/workflows/deploy.yml +34 -0
- package/LICENSE +201 -0
- package/index.ts +9 -0
- package/package.json +33 -0
- package/src/BaseCommand.ts +73 -0
- package/src/CLI.ts +316 -0
- package/src/CommandInterface.ts +22 -0
- package/src/CommandLoader.ts +83 -0
- package/src/commands/help.ts +159 -0
- package/src/utils/config.ts +43 -0
- package/src/utils/logger.ts +12 -0
- package/src/utils/shell.ts +21 -0
- package/test/e2e/basic.e2e.test.ts +16 -0
- package/test/integration/help.integration.test.ts +102 -0
- package/test/unit/commands/help.test.ts +345 -0
- package/test/unit/core/BaseCommand.test.ts +131 -0
- package/test/unit/core/CLI.config.test.ts +87 -0
- package/test/unit/core/CLI.test.ts +828 -0
- package/test/unit/core/CommandLoader.test.ts +200 -0
- package/test/unit/utils/config.test.ts +63 -0
- package/test/unit/utils/logger.test.ts +27 -0
- package/test/unit/utils/shell.test.ts +81 -0
- package/test/utils/integration-helpers.ts +22 -0
- package/tsconfig.json +25 -0
- package/tsup.config.ts +18 -0
- package/vitest.config.ts +15 -0
- package/vitest.e2e.config.ts +10 -0
- package/vitest.integration.config.ts +17 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import HelpCommand from '../../../src/commands/help.js';
|
|
3
|
+
|
|
4
|
+
// Mock logger
|
|
5
|
+
vi.mock('../../../src/utils/logger.js', () => ({
|
|
6
|
+
logger: {
|
|
7
|
+
success: vi.fn(),
|
|
8
|
+
info: vi.fn(),
|
|
9
|
+
warn: vi.fn(),
|
|
10
|
+
error: vi.fn(),
|
|
11
|
+
}
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { logger } from '../../../src/utils/logger.js';
|
|
15
|
+
|
|
16
|
+
// Mock picocolors to return strings as-is for easy assertion
|
|
17
|
+
vi.mock('picocolors', () => ({
|
|
18
|
+
default: {
|
|
19
|
+
bold: (s: string) => s,
|
|
20
|
+
cyan: (s: string) => s,
|
|
21
|
+
yellow: (s: string) => s,
|
|
22
|
+
dim: (s: string) => s,
|
|
23
|
+
red: (s: string) => s,
|
|
24
|
+
}
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
describe('HelpCommand', () => {
|
|
28
|
+
let mockCli: any;
|
|
29
|
+
let mockRawCli: any;
|
|
30
|
+
let consoleLogSpy: any;
|
|
31
|
+
let processExitSpy: any;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.clearAllMocks();
|
|
35
|
+
|
|
36
|
+
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
37
|
+
processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
|
38
|
+
|
|
39
|
+
mockRawCli = {
|
|
40
|
+
outputHelp: vi.fn(),
|
|
41
|
+
commands: []
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
mockCli = {
|
|
45
|
+
name: 'app',
|
|
46
|
+
getCommands: vi.fn(),
|
|
47
|
+
getRawCLI: vi.fn().mockReturnValue(mockRawCli)
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should display global help if no args provided', async () => {
|
|
52
|
+
const cmd = new HelpCommand(mockCli);
|
|
53
|
+
|
|
54
|
+
// Mock commands for global list
|
|
55
|
+
mockCli.getCommands.mockReturnValue([
|
|
56
|
+
{ command: 'init', class: { description: 'Init desc' } },
|
|
57
|
+
{ command: 'undocumented', class: { description: undefined } } // No desc
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
await cmd.run({ command: [] }); // No args
|
|
61
|
+
|
|
62
|
+
// expect(mockRawCli.outputHelp).toHaveBeenCalled(); // No longer called
|
|
63
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Usage: app'));
|
|
64
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Commands:'));
|
|
65
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('init'));
|
|
66
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Init desc'));
|
|
67
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('undocumented'));
|
|
68
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('--help'));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should handle undefined command option safely', async () => {
|
|
72
|
+
const cmd = new HelpCommand(mockCli);
|
|
73
|
+
|
|
74
|
+
// Mock commands
|
|
75
|
+
mockCli.getCommands.mockReturnValue([]);
|
|
76
|
+
|
|
77
|
+
await cmd.run({}); // No options object keys
|
|
78
|
+
|
|
79
|
+
// expect(mockRawCli.outputHelp).toHaveBeenCalled();
|
|
80
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Usage: app'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should display exact command help if matched', async () => {
|
|
84
|
+
const cmd = new HelpCommand(mockCli);
|
|
85
|
+
|
|
86
|
+
// Mock loaded commands with args definition
|
|
87
|
+
mockCli.getCommands.mockReturnValue([
|
|
88
|
+
{
|
|
89
|
+
command: 'init',
|
|
90
|
+
class: {
|
|
91
|
+
description: 'Initialize project',
|
|
92
|
+
args: {
|
|
93
|
+
args: [
|
|
94
|
+
{ name: 'name', description: 'Project Name', required: true },
|
|
95
|
+
{ name: 'optional', description: undefined, required: false }
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
// Mock CAC commands structure
|
|
103
|
+
mockRawCli.commands = [
|
|
104
|
+
{
|
|
105
|
+
name: 'init',
|
|
106
|
+
rawName: 'init <name>',
|
|
107
|
+
description: 'Initialize project',
|
|
108
|
+
options: [
|
|
109
|
+
{ name: 'force', rawName: '--force', description: 'Force overwrite', config: { default: false } }
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
await cmd.run({ command: ['init'] });
|
|
115
|
+
|
|
116
|
+
// Should not call global help
|
|
117
|
+
expect(mockRawCli.outputHelp).not.toHaveBeenCalled();
|
|
118
|
+
|
|
119
|
+
// Should print usage
|
|
120
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Usage: init <name>'));
|
|
121
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Initialize project'));
|
|
122
|
+
// Arguments
|
|
123
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Arguments:'));
|
|
124
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Project Name'));
|
|
125
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('(required)'));
|
|
126
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('optional'));
|
|
127
|
+
// Options
|
|
128
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('--force'));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should display subcommand help when CAC command is missing (fallback to LoadedCommand)', async () => {
|
|
132
|
+
const cmd = new HelpCommand(mockCli);
|
|
133
|
+
|
|
134
|
+
mockCli.getCommands.mockReturnValue([{
|
|
135
|
+
command: 'module add',
|
|
136
|
+
class: {
|
|
137
|
+
usage: 'module add <url>',
|
|
138
|
+
description: 'Add a module',
|
|
139
|
+
args: {
|
|
140
|
+
args: [{ name: 'url', required: true, description: 'Module URL' }]
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}]);
|
|
144
|
+
mockRawCli.commands = []; // CAC doesn't know about it
|
|
145
|
+
|
|
146
|
+
await cmd.run({ command: ['module', 'add'] });
|
|
147
|
+
|
|
148
|
+
// Should print usage using the fallback logic
|
|
149
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Usage: module add <url>'));
|
|
150
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Add a module'));
|
|
151
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Arguments:'));
|
|
152
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Module URL'));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should display subcommand help with options when CAC command is missing', async () => {
|
|
156
|
+
const cmd = new HelpCommand(mockCli);
|
|
157
|
+
|
|
158
|
+
mockCli.getCommands.mockReturnValue([{
|
|
159
|
+
command: 'module custom',
|
|
160
|
+
class: {
|
|
161
|
+
usage: 'module custom',
|
|
162
|
+
description: 'Custom module cmd',
|
|
163
|
+
args: {
|
|
164
|
+
options: [
|
|
165
|
+
{ name: '--flag', description: 'A flag', default: false }
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}]);
|
|
170
|
+
mockRawCli.commands = [];
|
|
171
|
+
|
|
172
|
+
await cmd.run({ command: ['module', 'custom'] });
|
|
173
|
+
|
|
174
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('--flag'));
|
|
175
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('A flag'));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should display command help with options having defaults', async () => {
|
|
179
|
+
const cmd = new HelpCommand(mockCli);
|
|
180
|
+
|
|
181
|
+
mockCli.getCommands.mockReturnValue([{ command: 'build', class: {} }]);
|
|
182
|
+
mockRawCli.commands = [{
|
|
183
|
+
name: 'build',
|
|
184
|
+
rawName: 'build',
|
|
185
|
+
description: 'Build project',
|
|
186
|
+
options: [
|
|
187
|
+
{ name: 'out', rawName: '--out', description: 'Output', config: { default: 'dist' } },
|
|
188
|
+
{ name: 'quiet', rawName: '--quiet', description: undefined, config: {} } // No description option
|
|
189
|
+
]
|
|
190
|
+
}];
|
|
191
|
+
|
|
192
|
+
await cmd.run({ command: ['build'] });
|
|
193
|
+
|
|
194
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('(default: dist)'));
|
|
195
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('--quiet'));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should display command help without options', async () => {
|
|
199
|
+
const cmd = new HelpCommand(mockCli);
|
|
200
|
+
|
|
201
|
+
mockCli.getCommands.mockReturnValue([{ command: 'info', class: {} }]);
|
|
202
|
+
mockRawCli.commands = [{
|
|
203
|
+
name: 'info',
|
|
204
|
+
rawName: 'info',
|
|
205
|
+
description: 'Info',
|
|
206
|
+
options: [] // No options
|
|
207
|
+
}];
|
|
208
|
+
|
|
209
|
+
await cmd.run({ command: ['info'] });
|
|
210
|
+
|
|
211
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Info'));
|
|
212
|
+
expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining('Options:'));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should display namespace commands', async () => {
|
|
216
|
+
const cmd = new HelpCommand(mockCli);
|
|
217
|
+
|
|
218
|
+
mockCli.getCommands.mockReturnValue([
|
|
219
|
+
{ command: 'module add', class: { description: 'Add module' } },
|
|
220
|
+
{ command: 'module remove', class: { description: 'Remove module' } },
|
|
221
|
+
{ command: 'module secret', class: {} }, // No description
|
|
222
|
+
{ command: 'init', class: {} }
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
await cmd.run({ command: ['module'] });
|
|
226
|
+
|
|
227
|
+
expect(mockRawCli.outputHelp).not.toHaveBeenCalled();
|
|
228
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Commands for module:'));
|
|
229
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('module add'));
|
|
230
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('module add'));
|
|
231
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('module remove'));
|
|
232
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('module secret'));
|
|
233
|
+
expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining('init'));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should auto-generate usage if static usage is missing and CAC command is missing', async () => {
|
|
237
|
+
const cmd = new HelpCommand(mockCli);
|
|
238
|
+
|
|
239
|
+
mockCli.getCommands.mockReturnValue([{
|
|
240
|
+
command: 'test cmd',
|
|
241
|
+
class: {
|
|
242
|
+
description: 'Test command',
|
|
243
|
+
args: {
|
|
244
|
+
args: [
|
|
245
|
+
{ name: 'arg1', required: true },
|
|
246
|
+
{ name: 'arg2', required: false },
|
|
247
|
+
{ name: 'variadic...', required: false }
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}]);
|
|
252
|
+
mockRawCli.commands = [];
|
|
253
|
+
|
|
254
|
+
await cmd.run({ command: ['test', 'cmd'] });
|
|
255
|
+
|
|
256
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Usage: test cmd <arg1> [arg2] [...variadic]'));
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// NEW TEST CASES FOR 100% COVERAGE
|
|
260
|
+
|
|
261
|
+
it('should generate usage correctly for command with no args definitions', async () => {
|
|
262
|
+
const cmd = new HelpCommand(mockCli);
|
|
263
|
+
|
|
264
|
+
// Class has no 'args' property at all
|
|
265
|
+
mockCli.getCommands.mockReturnValue([{
|
|
266
|
+
command: 'simple',
|
|
267
|
+
class: {}
|
|
268
|
+
}]);
|
|
269
|
+
mockRawCli.commands = [];
|
|
270
|
+
|
|
271
|
+
await cmd.run({ command: ['simple'] });
|
|
272
|
+
|
|
273
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Usage: simple'));
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should fallback to CAC description if Class description is missing', async () => {
|
|
277
|
+
const cmd = new HelpCommand(mockCli);
|
|
278
|
+
|
|
279
|
+
mockCli.getCommands.mockReturnValue([{
|
|
280
|
+
command: 'mixed',
|
|
281
|
+
class: { usage: 'mixed' } // has usage, no desc
|
|
282
|
+
}]);
|
|
283
|
+
mockRawCli.commands = [{
|
|
284
|
+
name: 'mixed',
|
|
285
|
+
description: 'CAC Description',
|
|
286
|
+
options: []
|
|
287
|
+
}];
|
|
288
|
+
|
|
289
|
+
await cmd.run({ command: ['mixed'] });
|
|
290
|
+
|
|
291
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('CAC Description'));
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('should default to empty description if both Class and CAC descriptions are missing', async () => {
|
|
295
|
+
const cmd = new HelpCommand(mockCli);
|
|
296
|
+
|
|
297
|
+
mockCli.getCommands.mockReturnValue([{
|
|
298
|
+
command: 'nodesc',
|
|
299
|
+
class: { usage: 'nodesc' }
|
|
300
|
+
}]);
|
|
301
|
+
// CAC command undefined scenario or CAC command with no desc
|
|
302
|
+
mockRawCli.commands = [{ name: 'nodesc', options: [] }]; // no desc
|
|
303
|
+
|
|
304
|
+
await cmd.run({ command: ['nodesc'] });
|
|
305
|
+
|
|
306
|
+
// Cannot easily check for "empty line" specific to description unless we check call order or strict output
|
|
307
|
+
// But verifying it doesn't crash is good.
|
|
308
|
+
// We can check valid output presence
|
|
309
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Usage: nodesc'));
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should handle required variadic arguments in usage generation', async () => {
|
|
313
|
+
const cmd = new HelpCommand(mockCli);
|
|
314
|
+
|
|
315
|
+
mockCli.getCommands.mockReturnValue([{
|
|
316
|
+
command: 'variadic',
|
|
317
|
+
class: {
|
|
318
|
+
args: {
|
|
319
|
+
args: [
|
|
320
|
+
{ name: 'files...', required: true }
|
|
321
|
+
]
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}]);
|
|
325
|
+
mockRawCli.commands = [];
|
|
326
|
+
|
|
327
|
+
await cmd.run({ command: ['variadic'] });
|
|
328
|
+
|
|
329
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Usage: variadic <...files>'));
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should error on unknown command', async () => {
|
|
333
|
+
const cmd = new HelpCommand(mockCli);
|
|
334
|
+
|
|
335
|
+
mockCli.getCommands.mockReturnValue([]);
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
await cmd.run({ command: ['unknown'] });
|
|
339
|
+
} catch (e: any) {
|
|
340
|
+
expect(e.message).toBe('EXIT');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Unknown command: unknown'));
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { CLI } from '../../../src/CLI.js';
|
|
3
|
+
import { BaseCommand } from '../../../src/BaseCommand.js';
|
|
4
|
+
import * as ConfigUtils from '../../../src/utils/config.js';
|
|
5
|
+
import { logger } from '../../../src/utils/logger.js';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
|
|
8
|
+
vi.mock('../../../src/utils/config.js');
|
|
9
|
+
vi.mock('../../../src/utils/logger.js');
|
|
10
|
+
|
|
11
|
+
class TestCommand extends BaseCommand {
|
|
12
|
+
async run() { }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class ProjectRequiredCommand extends BaseCommand {
|
|
16
|
+
static requiresProject = true;
|
|
17
|
+
async run() { }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('BaseCommand', () => {
|
|
21
|
+
let processExitSpy: any;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { }) as any);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
processExitSpy.mockRestore();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should initialize with default options', () => {
|
|
33
|
+
const cli = new CLI({ commandName: 'app' });
|
|
34
|
+
const cmd = new TestCommand(cli);
|
|
35
|
+
expect((cmd as any).globalOptions).toEqual({});
|
|
36
|
+
expect((cmd as any).projectRoot).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should use provided rootDir', async () => {
|
|
40
|
+
const cli = new CLI({ commandName: 'app' });
|
|
41
|
+
const cmd = new TestCommand(cli, { rootDir: '/custom/root' });
|
|
42
|
+
await cmd.init();
|
|
43
|
+
expect((cmd as any).projectRoot).toBe('/custom/root');
|
|
44
|
+
expect(ConfigUtils.findProjectRoot).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should find project root if not provided', async () => {
|
|
48
|
+
(ConfigUtils.findProjectRoot as any).mockResolvedValue('/found/root');
|
|
49
|
+
const cli = new CLI({ commandName: 'app' });
|
|
50
|
+
const cmd = new TestCommand(cli, {});
|
|
51
|
+
await cmd.init();
|
|
52
|
+
expect((cmd as any).projectRoot).toBe('/found/root');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should load config if project root exists', async () => {
|
|
56
|
+
(ConfigUtils.findProjectRoot as any).mockResolvedValue('/found/root');
|
|
57
|
+
(ConfigUtils.loadConfig as any).mockResolvedValue({ loaded: true });
|
|
58
|
+
|
|
59
|
+
const cli = new CLI({ commandName: 'app' });
|
|
60
|
+
const cmd = new TestCommand(cli, {});
|
|
61
|
+
await cmd.init();
|
|
62
|
+
expect((cmd as any).config).toEqual({ loaded: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should error if project required but not found', async () => {
|
|
66
|
+
(ConfigUtils.findProjectRoot as any).mockResolvedValue(null);
|
|
67
|
+
|
|
68
|
+
const cli = new CLI({ commandName: 'app' });
|
|
69
|
+
const cmd = new ProjectRequiredCommand(cli, {});
|
|
70
|
+
await cmd.init();
|
|
71
|
+
|
|
72
|
+
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('requires to be run within an app project'));
|
|
73
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should log success', () => {
|
|
77
|
+
const cli = new CLI({ commandName: 'app' });
|
|
78
|
+
const cmd = new TestCommand(cli);
|
|
79
|
+
cmd.success('test');
|
|
80
|
+
expect(logger.success).toHaveBeenCalledWith('test');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should log info', () => {
|
|
84
|
+
const cli = new CLI({ commandName: 'app' });
|
|
85
|
+
const cmd = new TestCommand(cli);
|
|
86
|
+
cmd.info('test');
|
|
87
|
+
expect(logger.info).toHaveBeenCalledWith('test');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should log warn', () => {
|
|
91
|
+
const cli = new CLI({ commandName: 'app' });
|
|
92
|
+
const cmd = new TestCommand(cli);
|
|
93
|
+
cmd.warn('test');
|
|
94
|
+
expect(logger.warn).toHaveBeenCalledWith('test');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should log error string and exit', () => {
|
|
98
|
+
const cli = new CLI({ commandName: 'app' });
|
|
99
|
+
const cmd = new TestCommand(cli);
|
|
100
|
+
cmd.error('fail', 1);
|
|
101
|
+
expect(logger.error).toHaveBeenCalledWith('fail');
|
|
102
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should log error object and exit', () => {
|
|
106
|
+
const cli = new CLI({ commandName: 'app' });
|
|
107
|
+
const cmd = new TestCommand(cli);
|
|
108
|
+
const err = new Error('fail');
|
|
109
|
+
cmd.error(err, 2);
|
|
110
|
+
expect(logger.error).toHaveBeenCalledWith('fail');
|
|
111
|
+
expect(process.exit).toHaveBeenCalledWith(2);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should log error object stack in debug mode', () => {
|
|
115
|
+
const cli = new CLI({ commandName: 'app' });
|
|
116
|
+
const cmd = new TestCommand(cli, { debug: true });
|
|
117
|
+
const err = new Error('fail');
|
|
118
|
+
cmd.error(err);
|
|
119
|
+
expect(logger.error).toHaveBeenCalledWith('fail');
|
|
120
|
+
expect(logger.error).toHaveBeenCalledTimes(2); // One for message, one for stack
|
|
121
|
+
});
|
|
122
|
+
it('should skip config loading if project root is not found', async () => {
|
|
123
|
+
(ConfigUtils.findProjectRoot as any).mockResolvedValue(null);
|
|
124
|
+
const cli = new CLI({ commandName: 'app' });
|
|
125
|
+
const cmd = new TestCommand(cli, {});
|
|
126
|
+
await cmd.init();
|
|
127
|
+
expect((cmd as any).projectRoot).toBeNull();
|
|
128
|
+
expect(ConfigUtils.loadConfig).not.toHaveBeenCalled();
|
|
129
|
+
expect((cmd as any).config).toEqual({});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { CLI } from '../../../src/CLI.js';
|
|
3
|
+
import { CommandLoader } from '../../../src/CommandLoader.js';
|
|
4
|
+
import { cac } from 'cac';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
|
|
7
|
+
vi.mock('cac');
|
|
8
|
+
vi.mock('../../../src/CommandLoader.js');
|
|
9
|
+
vi.mock('node:fs');
|
|
10
|
+
vi.mock('../../../src/utils/logger.js', () => ({
|
|
11
|
+
logger: {
|
|
12
|
+
debug: vi.fn(),
|
|
13
|
+
error: vi.fn(),
|
|
14
|
+
info: vi.fn(),
|
|
15
|
+
warn: vi.fn(),
|
|
16
|
+
},
|
|
17
|
+
setDebugMode: vi.fn()
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
describe('CLI Configuration', () => {
|
|
21
|
+
let mockCac: any;
|
|
22
|
+
let mockLoad: any;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
|
|
27
|
+
mockCac = {
|
|
28
|
+
command: vi.fn().mockReturnThis(),
|
|
29
|
+
action: vi.fn().mockReturnThis(),
|
|
30
|
+
allowUnknownOptions: vi.fn().mockReturnThis(),
|
|
31
|
+
option: vi.fn().mockReturnThis(),
|
|
32
|
+
help: vi.fn(),
|
|
33
|
+
version: vi.fn(),
|
|
34
|
+
parse: vi.fn(),
|
|
35
|
+
};
|
|
36
|
+
(cac as any).mockReturnValue(mockCac);
|
|
37
|
+
|
|
38
|
+
mockLoad = vi.fn().mockResolvedValue([]);
|
|
39
|
+
(CommandLoader as any).mockImplementation(function () {
|
|
40
|
+
return {
|
|
41
|
+
load: mockLoad,
|
|
42
|
+
getCommands: () => []
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
(fs.existsSync as any).mockReturnValue(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should use default command name "app" if no config provided', () => {
|
|
49
|
+
new CLI();
|
|
50
|
+
expect(cac).toHaveBeenCalledWith('app');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should use configured command name', () => {
|
|
54
|
+
new CLI({ commandName: 'my-cli' });
|
|
55
|
+
expect(cac).toHaveBeenCalledWith('my-cli');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should search in configured search directories', async () => {
|
|
59
|
+
const cli = new CLI({ searchDirectories: ['/custom/path/1', '/custom/path/2'] });
|
|
60
|
+
(fs.existsSync as any).mockReturnValue(true);
|
|
61
|
+
|
|
62
|
+
await cli.start();
|
|
63
|
+
|
|
64
|
+
expect(mockLoad).toHaveBeenCalledTimes(2);
|
|
65
|
+
expect(mockLoad).toHaveBeenCalledWith('/custom/path/1');
|
|
66
|
+
expect(mockLoad).toHaveBeenCalledWith('/custom/path/2');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should fallback to default logic if searchDirectories is empty', async () => {
|
|
70
|
+
const cli = new CLI({ searchDirectories: [] });
|
|
71
|
+
(fs.existsSync as any).mockReturnValue(true);
|
|
72
|
+
|
|
73
|
+
await cli.start();
|
|
74
|
+
|
|
75
|
+
// Should try default paths. Since we mock existsSync to true, it stops at the first one.
|
|
76
|
+
expect(mockLoad).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should fallback to default logic if searchDirectories is undefined', async () => {
|
|
80
|
+
const cli = new CLI({});
|
|
81
|
+
(fs.existsSync as any).mockReturnValue(true);
|
|
82
|
+
|
|
83
|
+
await cli.start();
|
|
84
|
+
|
|
85
|
+
expect(mockLoad).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
});
|