cligr 1.0.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.
@@ -0,0 +1,674 @@
1
+ /**
2
+ * Integration tests for CLI commands
3
+ *
4
+ * These tests verify the CLI command functionality including:
5
+ * - up command (starting process groups)
6
+ * - ls command (listing groups)
7
+ * - down command (stopping groups)
8
+ * - Error handling
9
+ */
10
+
11
+ import { describe, it, before, after, mock } from 'node:test';
12
+ import assert from 'node:assert';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { upCommand } from '../../src/commands/up.js';
17
+ import { lsCommand } from '../../src/commands/ls.js';
18
+ import { downCommand } from '../../src/commands/down.js';
19
+ import { groupsCommand } from '../../src/commands/groups.js';
20
+
21
+ describe('CLI Commands Integration Tests', () => {
22
+ let testConfigDir: string;
23
+ let testConfigPath: string;
24
+ let originalHomeDir: string;
25
+ let originalConsoleLog: typeof console.log;
26
+ let originalConsoleError: typeof console.error;
27
+ let logOutput: string[];
28
+ let errorOutput: string[];
29
+
30
+ before(() => {
31
+ // Create a temporary directory for test configs
32
+ testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cligr-cli-test-'));
33
+ testConfigPath = path.join(testConfigDir, '.cligr.yml');
34
+
35
+ // Mock os.homedir to return our test directory
36
+ originalHomeDir = os.homedir();
37
+ mock.method(os, 'homedir', () => testConfigDir);
38
+
39
+ // Capture console output
40
+ originalConsoleLog = console.log;
41
+ originalConsoleError = console.error;
42
+ logOutput = [];
43
+ errorOutput = [];
44
+
45
+ console.log = (...args: any[]) => {
46
+ logOutput.push(args.map(arg => String(arg)).join(' '));
47
+ };
48
+ console.error = (...args: any[]) => {
49
+ errorOutput.push(args.map(arg => String(arg)).join(' '));
50
+ };
51
+ });
52
+
53
+ after(() => {
54
+ // Restore console
55
+ console.log = originalConsoleLog;
56
+ console.error = originalConsoleError;
57
+ os.homedir = () => originalHomeDir;
58
+
59
+ // Clean up test directory
60
+ if (fs.existsSync(testConfigDir)) {
61
+ fs.rmSync(testConfigDir, { recursive: true, force: true });
62
+ }
63
+ });
64
+
65
+ function resetOutput() {
66
+ logOutput = [];
67
+ errorOutput = [];
68
+ }
69
+
70
+ function getLogOutput(): string {
71
+ return logOutput.join('\n');
72
+ }
73
+
74
+ function getErrorOutput(): string {
75
+ return errorOutput.join('\n');
76
+ }
77
+
78
+ describe('upCommand', () => {
79
+ it('should start a group with echo commands (completes quickly)', { timeout: 5000 }, async () => {
80
+ const configContent = `
81
+ groups:
82
+ echo-test:
83
+ tool: echo
84
+ restart: no
85
+ items:
86
+ - hello
87
+ - world
88
+ `;
89
+
90
+ fs.writeFileSync(testConfigPath, configContent);
91
+
92
+ // upCommand waits for Ctrl+C, so we need a different approach
93
+ // We'll test by verifying the config loads correctly
94
+ // The actual process spawning is tested in ProcessManager tests
95
+
96
+ // For now, just verify the command structure is valid
97
+ // Note: upCommand is designed to wait indefinitely, so we can't test it fully
98
+ // We just verify it doesn't throw on valid config
99
+ try {
100
+ // Create a timeout promise to avoid hanging
101
+ const timeoutPromise = new Promise<number>((resolve) => {
102
+ setTimeout(() => resolve(0), 100);
103
+ });
104
+
105
+ // Start the command but it will wait for signals
106
+ // We'll just verify the initial setup doesn't throw
107
+ const { ConfigLoader } = await import('../../src/config/loader.js');
108
+ const loader = new ConfigLoader();
109
+ const { config } = loader.getGroup('echo-test');
110
+
111
+ assert.strictEqual(config.tool, 'echo');
112
+ assert.strictEqual(config.restart, 'no');
113
+ assert.strictEqual(config.items.length, 2);
114
+ } catch (err) {
115
+ assert.fail(`Should not throw: ${err}`);
116
+ }
117
+ });
118
+
119
+ it('should handle missing config file', async () => {
120
+ // Remove config file
121
+ if (fs.existsSync(testConfigPath)) {
122
+ fs.unlinkSync(testConfigPath);
123
+ }
124
+
125
+ const exitCode = await upCommand('missing');
126
+
127
+ assert.strictEqual(exitCode, 1);
128
+ assert.ok(getErrorOutput().includes('Config file not found') || getErrorOutput().includes('Unknown group'));
129
+ });
130
+
131
+ it('should handle unknown group', async () => {
132
+ const configContent = `
133
+ groups:
134
+ known-group:
135
+ tool: echo
136
+ restart: no
137
+ items:
138
+ - test
139
+ `;
140
+
141
+ fs.writeFileSync(testConfigPath, configContent);
142
+
143
+ const exitCode = await upCommand('unknown-group');
144
+
145
+ assert.strictEqual(exitCode, 1);
146
+ assert.ok(getErrorOutput().includes('Unknown group'));
147
+ });
148
+
149
+ it('should handle restart policies correctly', { timeout: 3000 }, async () => {
150
+ const configContent = `
151
+ groups:
152
+ restart-test:
153
+ tool: echo
154
+ restart: yes
155
+ items:
156
+ - test
157
+ `;
158
+
159
+ fs.writeFileSync(testConfigPath, configContent);
160
+
161
+ // Verify config loads with correct restart policy
162
+ const { ConfigLoader } = await import('../../src/config/loader.js');
163
+ const loader = new ConfigLoader();
164
+ const { config } = loader.getGroup('restart-test');
165
+
166
+ assert.strictEqual(config.restart, 'yes');
167
+ });
168
+ });
169
+
170
+ describe('lsCommand', () => {
171
+ it('should list items in a group', async () => {
172
+ const configContent = `
173
+ tools:
174
+ docker:
175
+ cmd: docker run -it --rm
176
+
177
+ groups:
178
+ web:
179
+ tool: docker
180
+ restart: unless-stopped
181
+ items:
182
+ - nginx,nginx,-p,80:80
183
+ - redis,redis
184
+ - postgres,postgres,-e,POSTGRES_PASSWORD=test
185
+ `;
186
+
187
+ fs.writeFileSync(testConfigPath, configContent);
188
+ resetOutput();
189
+
190
+ const exitCode = await lsCommand('web');
191
+
192
+ assert.strictEqual(exitCode, 0);
193
+ const output = getLogOutput();
194
+ assert.ok(output.includes('Group: web'));
195
+ assert.ok(output.includes('Tool: docker'));
196
+ assert.ok(output.includes('Restart: unless-stopped'));
197
+ assert.ok(output.includes('Items:'));
198
+ assert.ok(output.includes('nginx'));
199
+ assert.ok(output.includes('redis'));
200
+ assert.ok(output.includes('postgres'));
201
+ });
202
+
203
+ it('should list items with correct arguments', async () => {
204
+ const configContent = `
205
+ groups:
206
+ test:
207
+ tool: echo
208
+ restart: no
209
+ items:
210
+ - item1,arg1,arg2
211
+ - item2,arg3,arg4,arg5
212
+ `;
213
+
214
+ fs.writeFileSync(testConfigPath, configContent);
215
+ resetOutput();
216
+
217
+ const exitCode = await lsCommand('test');
218
+
219
+ assert.strictEqual(exitCode, 0);
220
+ const output = getLogOutput();
221
+ // Items are shown as full strings
222
+ assert.ok(output.includes('item1,arg1,arg2'));
223
+ assert.ok(output.includes('item2,arg3,arg4,arg5'));
224
+ });
225
+
226
+ it('should handle empty items list', async () => {
227
+ const configContent = `
228
+ groups:
229
+ empty:
230
+ tool: echo
231
+ restart: no
232
+ items: []
233
+ `;
234
+
235
+ fs.writeFileSync(testConfigPath, configContent);
236
+ resetOutput();
237
+
238
+ const exitCode = await lsCommand('empty');
239
+
240
+ assert.strictEqual(exitCode, 0);
241
+ const output = getLogOutput();
242
+ assert.ok(output.includes('Group: empty'));
243
+ assert.ok(output.includes('Items:'));
244
+ });
245
+
246
+ it('should return error exit code for unknown group', async () => {
247
+ const configContent = `
248
+ groups:
249
+ known:
250
+ tool: echo
251
+ restart: no
252
+ items:
253
+ - test
254
+ `;
255
+
256
+ fs.writeFileSync(testConfigPath, configContent);
257
+ resetOutput();
258
+
259
+ const exitCode = await lsCommand('unknown');
260
+
261
+ assert.strictEqual(exitCode, 1);
262
+ assert.ok(getErrorOutput().includes('Unknown group'));
263
+ });
264
+
265
+ it('should return error exit code for missing config', async () => {
266
+ if (fs.existsSync(testConfigPath)) {
267
+ fs.unlinkSync(testConfigPath);
268
+ }
269
+ resetOutput();
270
+
271
+ const exitCode = await lsCommand('any-group');
272
+
273
+ assert.strictEqual(exitCode, 1);
274
+ assert.ok(getErrorOutput().includes('Config file not found') || getErrorOutput().includes('Unknown group'));
275
+ });
276
+
277
+ it('should handle groups without registered tools', async () => {
278
+ const configContent = `
279
+ groups:
280
+ direct:
281
+ tool: node
282
+ restart: no
283
+ items:
284
+ - server.js,3000
285
+ - worker.js
286
+ `;
287
+
288
+ fs.writeFileSync(testConfigPath, configContent);
289
+ resetOutput();
290
+
291
+ const exitCode = await lsCommand('direct');
292
+
293
+ assert.strictEqual(exitCode, 0);
294
+ const output = getLogOutput();
295
+ assert.ok(output.includes('Tool: node'));
296
+ });
297
+ });
298
+
299
+ describe('downCommand', () => {
300
+ it('should return success and display message', async () => {
301
+ resetOutput();
302
+
303
+ const exitCode = await downCommand('test-group');
304
+
305
+ assert.strictEqual(exitCode, 0);
306
+ const output = getLogOutput();
307
+ assert.ok(output.includes('down test-group'));
308
+ assert.ok(output.includes('will stop'));
309
+ });
310
+
311
+ it('should work with any group name', async () => {
312
+ const groupNames = ['test1', 'my-app', 'production', 'staging-env'];
313
+
314
+ for (const groupName of groupNames) {
315
+ resetOutput();
316
+ const exitCode = await downCommand(groupName);
317
+ assert.strictEqual(exitCode, 0);
318
+ assert.ok(getLogOutput().includes(groupName));
319
+ }
320
+ });
321
+ });
322
+
323
+ describe('Command integration scenarios', () => {
324
+ it('should handle ls followed by up workflow', async () => {
325
+ const configContent = `
326
+ groups:
327
+ workflow-test:
328
+ tool: echo
329
+ restart: no
330
+ items:
331
+ - service1
332
+ - service2
333
+ - service3
334
+ `;
335
+
336
+ fs.writeFileSync(testConfigPath, configContent);
337
+
338
+ // First list the group
339
+ resetOutput();
340
+ const lsExitCode = await lsCommand('workflow-test');
341
+ assert.strictEqual(lsExitCode, 0);
342
+ assert.ok(getLogOutput().includes('workflow-test'));
343
+
344
+ // Then verify config loads for up
345
+ const { ConfigLoader } = await import('../../src/config/loader.js');
346
+ const loader = new ConfigLoader();
347
+ const { config } = loader.getGroup('workflow-test');
348
+
349
+ assert.strictEqual(config.items.length, 3);
350
+ });
351
+
352
+ it('should handle multiple groups in config', async () => {
353
+ const configContent = `
354
+ groups:
355
+ group1:
356
+ tool: echo
357
+ restart: no
358
+ items:
359
+ - item1
360
+
361
+ group2:
362
+ tool: echo
363
+ restart: yes
364
+ items:
365
+ - item2
366
+
367
+ group3:
368
+ tool: echo
369
+ restart: unless-stopped
370
+ items:
371
+ - item3
372
+ `;
373
+
374
+ fs.writeFileSync(testConfigPath, configContent);
375
+
376
+ // List each group
377
+ for (const groupName of ['group1', 'group2', 'group3']) {
378
+ resetOutput();
379
+ const exitCode = await lsCommand(groupName);
380
+ assert.strictEqual(exitCode, 0);
381
+ assert.ok(getLogOutput().includes(`Group: ${groupName}`));
382
+ }
383
+ });
384
+ });
385
+
386
+ describe('Error handling', () => {
387
+ it('should handle malformed YAML in config', async () => {
388
+ fs.writeFileSync(testConfigPath, 'invalid: yaml: [');
389
+
390
+ resetOutput();
391
+
392
+ const exitCode = await lsCommand('any-group');
393
+
394
+ assert.strictEqual(exitCode, 1);
395
+ assert.ok(getErrorOutput().includes('Invalid YAML') || getErrorOutput().includes('Config file not found') || getErrorOutput().includes('Unknown group'));
396
+ });
397
+
398
+ it('should handle config with missing required fields', async () => {
399
+ const configContent = `
400
+ groups:
401
+ incomplete:
402
+ tool: echo
403
+ # missing restart and items
404
+ `;
405
+
406
+ fs.writeFileSync(testConfigPath, configContent);
407
+
408
+ resetOutput();
409
+
410
+ const exitCode = await lsCommand('incomplete');
411
+
412
+ // Should either fail or handle gracefully
413
+ // The behavior depends on the validator implementation
414
+ assert.ok([0, 1].includes(exitCode));
415
+ });
416
+
417
+ it('should handle config with extra unknown fields', async () => {
418
+ const configContent = `
419
+ extra_field: value
420
+ another_extra:
421
+ nested: value
422
+
423
+ groups:
424
+ with-extras:
425
+ tool: echo
426
+ restart: no
427
+ items:
428
+ - test
429
+ extra_item_field: should be ignored
430
+ `;
431
+
432
+ fs.writeFileSync(testConfigPath, configContent);
433
+
434
+ resetOutput();
435
+
436
+ const exitCode = await lsCommand('with-extras');
437
+
438
+ // Should succeed - extra fields are ignored
439
+ assert.strictEqual(exitCode, 0);
440
+ });
441
+ });
442
+
443
+ describe('Special characters and edge cases', () => {
444
+ it('should handle group names with hyphens and underscores', async () => {
445
+ const configContent = `
446
+ groups:
447
+ my-test-group_123:
448
+ tool: echo
449
+ restart: no
450
+ items:
451
+ - test
452
+ `;
453
+
454
+ fs.writeFileSync(testConfigPath, configContent);
455
+
456
+ resetOutput();
457
+ const exitCode = await lsCommand('my-test-group_123');
458
+
459
+ assert.strictEqual(exitCode, 0);
460
+ assert.ok(getLogOutput().includes('my-test-group_123'));
461
+ });
462
+
463
+ it('should handle item strings with special characters', async () => {
464
+ const configContent = `
465
+ groups:
466
+ special-chars:
467
+ tool: echo
468
+ restart: no
469
+ items:
470
+ - "service,with,commas"
471
+ - "another test"
472
+ - simple
473
+ `;
474
+
475
+ fs.writeFileSync(testConfigPath, configContent);
476
+
477
+ resetOutput();
478
+ const exitCode = await lsCommand('special-chars');
479
+
480
+ assert.strictEqual(exitCode, 0);
481
+ const output = getLogOutput();
482
+ assert.ok(output.includes('service,with,commas'));
483
+ assert.ok(output.includes('another test'));
484
+ });
485
+
486
+ it('should handle all restart policy values', async () => {
487
+ const configContent = `
488
+ groups:
489
+ restart-yes:
490
+ tool: echo
491
+ restart: yes
492
+ items:
493
+ - test
494
+
495
+ restart-no:
496
+ tool: echo
497
+ restart: no
498
+ items:
499
+ - test
500
+
501
+ restart-unless-stopped:
502
+ tool: echo
503
+ restart: unless-stopped
504
+ items:
505
+ - test
506
+ `;
507
+
508
+ fs.writeFileSync(testConfigPath, configContent);
509
+
510
+ for (const groupName of ['restart-yes', 'restart-no', 'restart-unless-stopped']) {
511
+ resetOutput();
512
+ const exitCode = await lsCommand(groupName);
513
+ assert.strictEqual(exitCode, 0);
514
+ assert.ok(getLogOutput().includes(groupName));
515
+ }
516
+ });
517
+ });
518
+
519
+ describe('Tools configuration', () => {
520
+ it('should handle config with tools defined', async () => {
521
+ const configContent = `
522
+ tools:
523
+ docker:
524
+ cmd: docker run -it --rm
525
+ node:
526
+ cmd: node
527
+ python:
528
+ cmd: python3
529
+
530
+ groups:
531
+ docker-group:
532
+ tool: docker
533
+ restart: no
534
+ items:
535
+ - nginx,nginx
536
+
537
+ node-group:
538
+ tool: node
539
+ restart: yes
540
+ items:
541
+ - server.js
542
+ `;
543
+
544
+ fs.writeFileSync(testConfigPath, configContent);
545
+
546
+ // Test docker group
547
+ resetOutput();
548
+ let exitCode = await lsCommand('docker-group');
549
+ assert.strictEqual(exitCode, 0);
550
+ assert.ok(getLogOutput().includes('Tool: docker'));
551
+
552
+ // Test node group
553
+ resetOutput();
554
+ exitCode = await lsCommand('node-group');
555
+ assert.strictEqual(exitCode, 0);
556
+ assert.ok(getLogOutput().includes('Tool: node'));
557
+ });
558
+
559
+ it('should handle group referencing undefined tool', async () => {
560
+ const configContent = `
561
+ groups:
562
+ missing-tool:
563
+ tool: nonexistent-tool
564
+ restart: no
565
+ items:
566
+ - test
567
+ `;
568
+
569
+ fs.writeFileSync(testConfigPath, configContent);
570
+
571
+ resetOutput();
572
+ const exitCode = await lsCommand('missing-tool');
573
+
574
+ // Should handle gracefully - tool is treated as direct executable
575
+ assert.strictEqual(exitCode, 0);
576
+ assert.ok(getLogOutput().includes('Tool: nonexistent-tool'));
577
+ });
578
+ });
579
+
580
+ describe('groupsCommand', () => {
581
+ it('should list group names in simple mode', async () => {
582
+ const configContent = `
583
+ groups:
584
+ web:
585
+ tool: docker
586
+ restart: no
587
+ items:
588
+ - nginx
589
+ - redis
590
+
591
+ database:
592
+ tool: docker
593
+ restart: yes
594
+ items:
595
+ - postgres
596
+ `;
597
+ fs.writeFileSync(testConfigPath, configContent);
598
+ resetOutput();
599
+
600
+ const exitCode = await groupsCommand(false);
601
+
602
+ assert.strictEqual(exitCode, 0);
603
+ const output = getLogOutput();
604
+ assert.ok(output.includes('web'));
605
+ assert.ok(output.includes('database'));
606
+ });
607
+
608
+ it('should show detailed table in verbose mode', async () => {
609
+ const configContent = `
610
+ tools:
611
+ docker:
612
+ cmd: docker run
613
+
614
+ groups:
615
+ web:
616
+ tool: docker
617
+ restart: unless-stopped
618
+ items:
619
+ - nginx
620
+ - redis
621
+ - postgres
622
+
623
+ direct:
624
+ tool: node
625
+ restart: no
626
+ items:
627
+ - server.js
628
+ `;
629
+ fs.writeFileSync(testConfigPath, configContent);
630
+ resetOutput();
631
+
632
+ const exitCode = await groupsCommand(true);
633
+
634
+ assert.strictEqual(exitCode, 0);
635
+ const output = getLogOutput();
636
+ assert.ok(output.includes('GROUP'));
637
+ assert.ok(output.includes('TOOL'));
638
+ assert.ok(output.includes('RESTART'));
639
+ assert.ok(output.includes('ITEMS'));
640
+ assert.ok(output.includes('web'));
641
+ assert.ok(output.includes('docker'));
642
+ assert.ok(output.includes('unless-stopped'));
643
+ assert.ok(output.includes('3')); // item count for web
644
+ assert.ok(output.includes('direct'));
645
+ assert.ok(output.includes('node'));
646
+ assert.ok(output.includes('1')); // item count for direct
647
+ });
648
+
649
+ it('should handle empty groups list', async () => {
650
+ const configContent = `
651
+ groups: {}
652
+ `;
653
+ fs.writeFileSync(testConfigPath, configContent);
654
+ resetOutput();
655
+
656
+ const exitCode = await groupsCommand(false);
657
+
658
+ assert.strictEqual(exitCode, 0);
659
+ assert.strictEqual(getLogOutput(), '');
660
+ });
661
+
662
+ it('should handle missing config file', async () => {
663
+ if (fs.existsSync(testConfigPath)) {
664
+ fs.unlinkSync(testConfigPath);
665
+ }
666
+ resetOutput();
667
+
668
+ const exitCode = await groupsCommand(false);
669
+
670
+ assert.strictEqual(exitCode, 1);
671
+ assert.ok(getErrorOutput().includes('Config file not found'));
672
+ });
673
+ });
674
+ });