cligr 1.0.5 → 1.0.7
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/README.md +1 -1
- package/dist/index.js +192 -39
- package/docs/plans/2026-02-25-named-items-design.md +164 -0
- package/docs/plans/2026-02-25-named-items-implementation.md +460 -0
- package/docs/superpowers/plans/2026-04-13-serve-command.md +1299 -0
- package/docs/superpowers/specs/2026-04-13-serve-command-design.md +93 -0
- package/package.json +3 -3
- package/scripts/test.js +17 -13
- package/src/commands/groups.ts +1 -1
- package/src/commands/ls.ts +10 -6
- package/src/commands/serve.ts +360 -0
- package/src/commands/up.ts +5 -5
- package/src/config/loader.ts +116 -5
- package/src/config/types.ts +7 -1
- package/src/index.ts +10 -3
- package/src/process/manager.ts +36 -2
- package/src/process/template.ts +13 -24
- package/tests/integration/commands.test.ts +65 -41
- package/tests/integration/config-loader.test.ts +143 -33
- package/tests/integration/process-manager.test.ts +107 -1
- package/tests/integration/serve.test.ts +245 -0
- package/tests/integration/template-expander.test.ts +101 -93
- package/.claude/settings.local.json +0 -13
- package/dist/commands/config.js +0 -102
- package/dist/commands/down.js +0 -26
- package/dist/commands/groups.js +0 -43
- package/dist/commands/ls.js +0 -23
- package/dist/commands/up.js +0 -39
- package/dist/config/loader.js +0 -82
- package/dist/config/types.js +0 -0
- package/dist/process/manager.js +0 -226
- package/dist/process/pid-store.js +0 -141
- package/dist/process/template.js +0 -53
|
@@ -14,6 +14,7 @@ import fs from 'node:fs';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import { ConfigLoader, ConfigError } from '../../src/config/loader.js';
|
|
17
|
+
import yaml from 'js-yaml';
|
|
17
18
|
|
|
18
19
|
describe('ConfigLoader Integration Tests', () => {
|
|
19
20
|
let testConfigDir: string;
|
|
@@ -53,15 +54,15 @@ groups:
|
|
|
53
54
|
tool: docker
|
|
54
55
|
restart: yes
|
|
55
56
|
items:
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
alpine: alpine,sh
|
|
58
|
+
nginx: nginx,nginx,-p,80:80
|
|
58
59
|
|
|
59
60
|
test2:
|
|
60
61
|
tool: node
|
|
61
62
|
restart: no
|
|
62
63
|
items:
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
server: server.js
|
|
65
|
+
worker: worker.js
|
|
65
66
|
`;
|
|
66
67
|
|
|
67
68
|
fs.writeFileSync(testConfigPath, configContent);
|
|
@@ -74,9 +75,9 @@ groups:
|
|
|
74
75
|
assert.strictEqual(Object.keys(config.groups).length, 2);
|
|
75
76
|
assert.strictEqual(config.groups.test1.tool, 'docker');
|
|
76
77
|
assert.strictEqual(config.groups.test1.restart, 'yes');
|
|
77
|
-
assert.strictEqual(config.groups.test1.items.length, 2);
|
|
78
|
+
assert.strictEqual(Object.keys(config.groups.test1.items).length, 2);
|
|
78
79
|
assert.strictEqual(config.groups.test2.tool, 'node');
|
|
79
|
-
assert.strictEqual(config.groups.test2.items.length, 2);
|
|
80
|
+
assert.strictEqual(Object.keys(config.groups.test2.items).length, 2);
|
|
80
81
|
});
|
|
81
82
|
|
|
82
83
|
it('should throw ConfigError when config file does not exist', () => {
|
|
@@ -149,8 +150,8 @@ groups:
|
|
|
149
150
|
tool: echo
|
|
150
151
|
restart: no
|
|
151
152
|
items:
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
hello: hello
|
|
154
|
+
world: world
|
|
154
155
|
`;
|
|
155
156
|
|
|
156
157
|
fs.writeFileSync(testConfigPath, configContent);
|
|
@@ -176,15 +177,15 @@ groups:
|
|
|
176
177
|
tool: docker
|
|
177
178
|
restart: unless-stopped
|
|
178
179
|
items:
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
nginx: nginx,nginx,-p,80:80
|
|
181
|
+
redis: redis,redis
|
|
181
182
|
|
|
182
183
|
api:
|
|
183
184
|
tool: node
|
|
184
185
|
restart: yes
|
|
185
186
|
items:
|
|
186
|
-
|
|
187
|
-
|
|
187
|
+
server: server.js,3000
|
|
188
|
+
worker: worker.js
|
|
188
189
|
`;
|
|
189
190
|
|
|
190
191
|
fs.writeFileSync(testConfigPath, configContent);
|
|
@@ -198,7 +199,7 @@ groups:
|
|
|
198
199
|
assert.strictEqual(result.config.restart, 'unless-stopped');
|
|
199
200
|
assert.strictEqual(result.tool, 'docker');
|
|
200
201
|
assert.strictEqual(result.toolTemplate, 'docker run -it --rm');
|
|
201
|
-
assert.strictEqual(result.
|
|
202
|
+
assert.strictEqual(result.items.length, 2);
|
|
202
203
|
});
|
|
203
204
|
|
|
204
205
|
it('should retrieve an existing group without registered tool', () => {
|
|
@@ -209,7 +210,7 @@ groups:
|
|
|
209
210
|
assert.strictEqual(result.config.restart, 'yes');
|
|
210
211
|
assert.strictEqual(result.tool, null); // No registered tool
|
|
211
212
|
assert.strictEqual(result.toolTemplate, null);
|
|
212
|
-
assert.strictEqual(result.
|
|
213
|
+
assert.strictEqual(result.items.length, 2);
|
|
213
214
|
});
|
|
214
215
|
|
|
215
216
|
it('should throw ConfigError for unknown group', () => {
|
|
@@ -235,19 +236,19 @@ groups:
|
|
|
235
236
|
tool: echo
|
|
236
237
|
restart: no
|
|
237
238
|
items:
|
|
238
|
-
|
|
239
|
+
test1: test1
|
|
239
240
|
|
|
240
241
|
group2:
|
|
241
242
|
tool: echo
|
|
242
243
|
restart: no
|
|
243
244
|
items:
|
|
244
|
-
|
|
245
|
+
test2: test2
|
|
245
246
|
|
|
246
247
|
group3:
|
|
247
248
|
tool: echo
|
|
248
249
|
restart: no
|
|
249
250
|
items:
|
|
250
|
-
|
|
251
|
+
test3: test3
|
|
251
252
|
`;
|
|
252
253
|
|
|
253
254
|
fs.writeFileSync(testConfigPath, configContent);
|
|
@@ -284,7 +285,7 @@ groups:
|
|
|
284
285
|
tool: echo
|
|
285
286
|
restart: no
|
|
286
287
|
items:
|
|
287
|
-
|
|
288
|
+
test: test
|
|
288
289
|
`;
|
|
289
290
|
|
|
290
291
|
fs.writeFileSync(customConfigPath, configContent);
|
|
@@ -311,6 +312,115 @@ groups:
|
|
|
311
312
|
});
|
|
312
313
|
});
|
|
313
314
|
|
|
315
|
+
describe('saveConfig()', () => {
|
|
316
|
+
it('should save config back to file', () => {
|
|
317
|
+
const configContent = `
|
|
318
|
+
groups:
|
|
319
|
+
test1:
|
|
320
|
+
tool: echo
|
|
321
|
+
restart: no
|
|
322
|
+
items:
|
|
323
|
+
hello: hello
|
|
324
|
+
`;
|
|
325
|
+
fs.writeFileSync(testConfigPath, configContent);
|
|
326
|
+
|
|
327
|
+
const loader = new ConfigLoader();
|
|
328
|
+
const config = loader.load();
|
|
329
|
+
config.groups.test1.restart = 'yes';
|
|
330
|
+
|
|
331
|
+
loader.saveConfig(config);
|
|
332
|
+
|
|
333
|
+
const saved = fs.readFileSync(testConfigPath, 'utf-8');
|
|
334
|
+
const parsed = yaml.load(saved) as any;
|
|
335
|
+
assert.strictEqual(parsed.groups.test1.restart, 'yes');
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe('toggleItem()', () => {
|
|
340
|
+
it('should add item to disabledItems when disabling', () => {
|
|
341
|
+
const configContent = `
|
|
342
|
+
groups:
|
|
343
|
+
test1:
|
|
344
|
+
tool: echo
|
|
345
|
+
restart: no
|
|
346
|
+
items:
|
|
347
|
+
hello: hello
|
|
348
|
+
world: world
|
|
349
|
+
`;
|
|
350
|
+
fs.writeFileSync(testConfigPath, configContent);
|
|
351
|
+
|
|
352
|
+
const loader = new ConfigLoader();
|
|
353
|
+
loader.toggleItem('test1', 'hello', false);
|
|
354
|
+
|
|
355
|
+
const reloaded = new ConfigLoader().load();
|
|
356
|
+
assert.deepStrictEqual(reloaded.groups.test1.disabledItems, ['hello']);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should remove item from disabledItems when enabling', () => {
|
|
360
|
+
const configContent = `
|
|
361
|
+
groups:
|
|
362
|
+
test1:
|
|
363
|
+
tool: echo
|
|
364
|
+
restart: no
|
|
365
|
+
disabledItems:
|
|
366
|
+
- hello
|
|
367
|
+
items:
|
|
368
|
+
hello: hello
|
|
369
|
+
world: world
|
|
370
|
+
`;
|
|
371
|
+
fs.writeFileSync(testConfigPath, configContent);
|
|
372
|
+
|
|
373
|
+
const loader = new ConfigLoader();
|
|
374
|
+
loader.toggleItem('test1', 'hello', true);
|
|
375
|
+
|
|
376
|
+
const reloaded = new ConfigLoader().load();
|
|
377
|
+
assert.strictEqual(reloaded.groups.test1.disabledItems, undefined);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe('disabledItems filtering', () => {
|
|
382
|
+
it('should filter disabled items from getGroup result', () => {
|
|
383
|
+
const configContent = `
|
|
384
|
+
groups:
|
|
385
|
+
test1:
|
|
386
|
+
tool: echo
|
|
387
|
+
restart: no
|
|
388
|
+
disabledItems:
|
|
389
|
+
- hello
|
|
390
|
+
items:
|
|
391
|
+
hello: hello
|
|
392
|
+
world: world
|
|
393
|
+
`;
|
|
394
|
+
fs.writeFileSync(testConfigPath, configContent);
|
|
395
|
+
|
|
396
|
+
const loader = new ConfigLoader();
|
|
397
|
+
const result = loader.getGroup('test1');
|
|
398
|
+
|
|
399
|
+
assert.strictEqual(result.items.length, 1);
|
|
400
|
+
assert.strictEqual(result.items[0].name, 'world');
|
|
401
|
+
assert.strictEqual(result.config.disabledItems?.length, 1);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('should include all items when disabledItems is empty', () => {
|
|
405
|
+
const configContent = `
|
|
406
|
+
groups:
|
|
407
|
+
test1:
|
|
408
|
+
tool: echo
|
|
409
|
+
restart: no
|
|
410
|
+
disabledItems: []
|
|
411
|
+
items:
|
|
412
|
+
hello: hello
|
|
413
|
+
world: world
|
|
414
|
+
`;
|
|
415
|
+
fs.writeFileSync(testConfigPath, configContent);
|
|
416
|
+
|
|
417
|
+
const loader = new ConfigLoader();
|
|
418
|
+
const result = loader.getGroup('test1');
|
|
419
|
+
|
|
420
|
+
assert.strictEqual(result.items.length, 2);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
314
424
|
describe('Config precedence', () => {
|
|
315
425
|
it('should prefer home directory config over current directory', () => {
|
|
316
426
|
const homeConfigPath = path.join(testConfigDir, '.cligr.yml');
|
|
@@ -322,7 +432,7 @@ groups:
|
|
|
322
432
|
tool: echo
|
|
323
433
|
restart: no
|
|
324
434
|
items:
|
|
325
|
-
|
|
435
|
+
fromHome: from-home
|
|
326
436
|
`;
|
|
327
437
|
|
|
328
438
|
const currentContent = `
|
|
@@ -331,7 +441,7 @@ groups:
|
|
|
331
441
|
tool: echo
|
|
332
442
|
restart: no
|
|
333
443
|
items:
|
|
334
|
-
|
|
444
|
+
fromCurrent: from-current
|
|
335
445
|
`;
|
|
336
446
|
|
|
337
447
|
// Write home config (mocked to testConfigDir)
|
|
@@ -353,21 +463,21 @@ groups:
|
|
|
353
463
|
});
|
|
354
464
|
|
|
355
465
|
describe('Edge cases', () => {
|
|
356
|
-
it('should handle empty items
|
|
466
|
+
it('should handle empty items object', () => {
|
|
357
467
|
const configContent = `
|
|
358
468
|
groups:
|
|
359
469
|
empty:
|
|
360
470
|
tool: echo
|
|
361
471
|
restart: no
|
|
362
|
-
items:
|
|
472
|
+
items: {}
|
|
363
473
|
`;
|
|
364
474
|
|
|
365
475
|
fs.writeFileSync(testConfigPath, configContent);
|
|
366
476
|
|
|
367
477
|
const loader = new ConfigLoader();
|
|
368
|
-
const
|
|
478
|
+
const result = loader.getGroup('empty');
|
|
369
479
|
|
|
370
|
-
assert.strictEqual(
|
|
480
|
+
assert.strictEqual(result.items.length, 0);
|
|
371
481
|
});
|
|
372
482
|
|
|
373
483
|
it('should handle special characters in item strings', () => {
|
|
@@ -377,9 +487,9 @@ groups:
|
|
|
377
487
|
tool: echo
|
|
378
488
|
restart: no
|
|
379
489
|
items:
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
490
|
+
hello: "hello, world"
|
|
491
|
+
test: "test,with,commas"
|
|
492
|
+
spaces: "spaces test"
|
|
383
493
|
`;
|
|
384
494
|
|
|
385
495
|
fs.writeFileSync(testConfigPath, configContent);
|
|
@@ -387,9 +497,9 @@ groups:
|
|
|
387
497
|
const loader = new ConfigLoader();
|
|
388
498
|
const result = loader.getGroup('special');
|
|
389
499
|
|
|
390
|
-
assert.strictEqual(result.
|
|
391
|
-
assert.strictEqual(result.
|
|
392
|
-
assert.strictEqual(result.
|
|
500
|
+
assert.strictEqual(result.items.length, 3);
|
|
501
|
+
assert.strictEqual(result.items[0].value, 'hello, world');
|
|
502
|
+
assert.strictEqual(result.items[1].value, 'test,with,commas');
|
|
393
503
|
});
|
|
394
504
|
|
|
395
505
|
it('should handle all restart policy values', () => {
|
|
@@ -399,19 +509,19 @@ groups:
|
|
|
399
509
|
tool: echo
|
|
400
510
|
restart: yes
|
|
401
511
|
items:
|
|
402
|
-
|
|
512
|
+
test: test
|
|
403
513
|
|
|
404
514
|
restart-no:
|
|
405
515
|
tool: echo
|
|
406
516
|
restart: no
|
|
407
517
|
items:
|
|
408
|
-
|
|
518
|
+
test: test
|
|
409
519
|
|
|
410
520
|
restart-unless-stopped:
|
|
411
521
|
tool: echo
|
|
412
522
|
restart: unless-stopped
|
|
413
523
|
items:
|
|
414
|
-
|
|
524
|
+
test: test
|
|
415
525
|
`;
|
|
416
526
|
|
|
417
527
|
fs.writeFileSync(testConfigPath, configContent);
|
|
@@ -263,8 +263,9 @@ describe('ProcessManager Integration Tests', () => {
|
|
|
263
263
|
describe('Restart policies', () => {
|
|
264
264
|
it('should not restart processes with restart=no', async () => {
|
|
265
265
|
// Create a process that exits immediately
|
|
266
|
+
// Use node -e "process.exit(0)" for cross-platform compatibility
|
|
266
267
|
const items: ProcessItem[] = [
|
|
267
|
-
{ name: 'no-restart', args: [], fullCmd:
|
|
268
|
+
{ name: 'no-restart', args: [], fullCmd: 'node -e "process.exit(0)"' }
|
|
268
269
|
];
|
|
269
270
|
|
|
270
271
|
manager.spawnGroup('no-restart-group', items, 'no');
|
|
@@ -359,6 +360,7 @@ describe('ProcessManager Integration Tests', () => {
|
|
|
359
360
|
it('should work with Windows-specific commands', async function skipOnNonWindows() {
|
|
360
361
|
if (process.platform !== 'win32') {
|
|
361
362
|
this.skip();
|
|
363
|
+
return; // Ensure we don't continue execution after skip
|
|
362
364
|
}
|
|
363
365
|
|
|
364
366
|
const items: ProcessItem[] = [
|
|
@@ -375,6 +377,7 @@ describe('ProcessManager Integration Tests', () => {
|
|
|
375
377
|
it('should work with Unix-specific commands', async function skipOnWindows() {
|
|
376
378
|
if (process.platform === 'win32') {
|
|
377
379
|
this.skip();
|
|
380
|
+
return; // Ensure we don't continue execution after skip
|
|
378
381
|
}
|
|
379
382
|
|
|
380
383
|
const items: ProcessItem[] = [
|
|
@@ -388,4 +391,107 @@ describe('ProcessManager Integration Tests', () => {
|
|
|
388
391
|
await manager.killGroup('unix-test');
|
|
389
392
|
});
|
|
390
393
|
});
|
|
394
|
+
|
|
395
|
+
describe('restartGroup()', () => {
|
|
396
|
+
it('should restart a running group', async () => {
|
|
397
|
+
const sleepCmd = process.platform === 'win32' ? 'timeout' : 'sleep';
|
|
398
|
+
const sleepFlag = process.platform === 'win32' ? '/t' : '';
|
|
399
|
+
|
|
400
|
+
const items: ProcessItem[] = [
|
|
401
|
+
{ name: 'p1', args: ['5'], fullCmd: `${sleepCmd} ${sleepFlag} 5` }
|
|
402
|
+
];
|
|
403
|
+
|
|
404
|
+
manager.spawnGroup('restart-group', items, 'no');
|
|
405
|
+
assert.strictEqual(manager.isGroupRunning('restart-group'), true);
|
|
406
|
+
|
|
407
|
+
await manager.restartGroup('restart-group', items, 'no');
|
|
408
|
+
assert.strictEqual(manager.isGroupRunning('restart-group'), true);
|
|
409
|
+
|
|
410
|
+
await manager.killGroup('restart-group');
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe('events', () => {
|
|
415
|
+
it('should emit group-started when spawning', async () => {
|
|
416
|
+
let emitted = false;
|
|
417
|
+
manager.once('group-started', (name) => {
|
|
418
|
+
assert.strictEqual(name, 'event-group');
|
|
419
|
+
emitted = true;
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const items: ProcessItem[] = [
|
|
423
|
+
{ name: 'p1', args: [], fullCmd: 'echo hello' }
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
manager.spawnGroup('event-group', items, 'no');
|
|
427
|
+
assert.strictEqual(emitted, true);
|
|
428
|
+
|
|
429
|
+
await manager.killGroup('event-group');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('should emit group-stopped when killing', async () => {
|
|
433
|
+
const items: ProcessItem[] = [
|
|
434
|
+
{ name: 'p1', args: ['5'], fullCmd: process.platform === 'win32' ? 'timeout /t 5' : 'sleep 5' }
|
|
435
|
+
];
|
|
436
|
+
|
|
437
|
+
manager.spawnGroup('stop-group', items, 'no');
|
|
438
|
+
|
|
439
|
+
let emitted = false;
|
|
440
|
+
manager.once('group-stopped', (name) => {
|
|
441
|
+
assert.strictEqual(name, 'stop-group');
|
|
442
|
+
emitted = true;
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
await manager.killGroup('stop-group');
|
|
446
|
+
assert.strictEqual(emitted, true);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('should emit process-log events', async () => {
|
|
450
|
+
const items: ProcessItem[] = [
|
|
451
|
+
{ name: 'logger', args: [], fullCmd: 'echo test-log' }
|
|
452
|
+
];
|
|
453
|
+
|
|
454
|
+
const logs: Array<{ group: string; item: string; line: string; isError: boolean }> = [];
|
|
455
|
+
const handler = (group: string, item: string, line: string, isError: boolean) => {
|
|
456
|
+
logs.push({ group, item, line, isError });
|
|
457
|
+
};
|
|
458
|
+
manager.once('process-log', handler);
|
|
459
|
+
|
|
460
|
+
manager.spawnGroup('log-group', items, 'no');
|
|
461
|
+
|
|
462
|
+
// Wait for process-log event or a small timeout
|
|
463
|
+
await Promise.race([
|
|
464
|
+
new Promise<void>(resolve => manager.once('process-log', () => resolve())),
|
|
465
|
+
new Promise<void>(resolve => setTimeout(resolve, 200))
|
|
466
|
+
]);
|
|
467
|
+
|
|
468
|
+
assert.ok(logs.some(l => l.group === 'log-group' && l.item === 'logger' && l.line.includes('test-log')));
|
|
469
|
+
|
|
470
|
+
manager.off('process-log', handler);
|
|
471
|
+
await manager.killGroup('log-group');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('should emit item-restarted when a process restarts', { timeout: 3000 }, async () => {
|
|
475
|
+
const items: ProcessItem[] = [
|
|
476
|
+
{ name: 'quick-exit', args: [], fullCmd: 'node -e "process.exit(0)"' }
|
|
477
|
+
];
|
|
478
|
+
|
|
479
|
+
let restartedGroup = '';
|
|
480
|
+
let restartedItem = '';
|
|
481
|
+
manager.once('item-restarted', (groupName, itemName) => {
|
|
482
|
+
restartedGroup = groupName;
|
|
483
|
+
restartedItem = itemName;
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
manager.spawnGroup('restart-event-group', items, 'yes');
|
|
487
|
+
|
|
488
|
+
// Wait for the process to exit and restart (1-second delay + margin)
|
|
489
|
+
await new Promise(resolve => setTimeout(resolve, 2500));
|
|
490
|
+
|
|
491
|
+
assert.strictEqual(restartedGroup, 'restart-event-group');
|
|
492
|
+
assert.strictEqual(restartedItem, 'quick-exit');
|
|
493
|
+
|
|
494
|
+
await manager.killGroup('restart-event-group');
|
|
495
|
+
});
|
|
496
|
+
});
|
|
391
497
|
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, it, before, after, afterEach, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import net from 'node:net';
|
|
8
|
+
|
|
9
|
+
let serverProcess: import('child_process').ChildProcess | null = null;
|
|
10
|
+
|
|
11
|
+
describe('serve command integration tests', () => {
|
|
12
|
+
let testConfigDir: string;
|
|
13
|
+
let testConfigPath: string;
|
|
14
|
+
let originalHomeDir: string;
|
|
15
|
+
|
|
16
|
+
before(async () => {
|
|
17
|
+
testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cligr-serve-test-'));
|
|
18
|
+
testConfigPath = path.join(testConfigDir, '.cligr.yml');
|
|
19
|
+
originalHomeDir = os.homedir();
|
|
20
|
+
mock.method(os, 'homedir', () => testConfigDir);
|
|
21
|
+
|
|
22
|
+
// Build dist/index.js
|
|
23
|
+
const { spawnSync } = await import('child_process');
|
|
24
|
+
const result = spawnSync('npm', ['run', 'build'], { cwd: process.cwd(), stdio: 'pipe', shell: true });
|
|
25
|
+
if (result.status !== 0) {
|
|
26
|
+
throw new Error('Build failed: ' + result.stderr?.toString());
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
after(async () => {
|
|
31
|
+
mock.method(os, 'homedir', () => originalHomeDir);
|
|
32
|
+
if (fs.existsSync(testConfigDir)) {
|
|
33
|
+
fs.rmSync(testConfigDir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
await stopServer();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
await stopServer();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
async function stopServer() {
|
|
43
|
+
const proc = serverProcess;
|
|
44
|
+
if (!proc) return;
|
|
45
|
+
serverProcess = null;
|
|
46
|
+
await new Promise<void>((resolve) => {
|
|
47
|
+
proc.once('exit', () => resolve());
|
|
48
|
+
proc.kill();
|
|
49
|
+
const timeout = setTimeout(() => {
|
|
50
|
+
if (!proc.killed) {
|
|
51
|
+
proc.kill('SIGKILL');
|
|
52
|
+
}
|
|
53
|
+
resolve();
|
|
54
|
+
}, 500);
|
|
55
|
+
proc.once('exit', () => clearTimeout(timeout));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function getFreePort(): Promise<number> {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const srv = net.createServer();
|
|
62
|
+
srv.listen(0, () => {
|
|
63
|
+
const addr = srv.address() as net.AddressInfo;
|
|
64
|
+
srv.close(() => resolve(addr.port));
|
|
65
|
+
});
|
|
66
|
+
srv.on('error', reject);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeConfig() {
|
|
71
|
+
const configContent = `
|
|
72
|
+
tools:
|
|
73
|
+
node:
|
|
74
|
+
cmd: node -e "console.log('$1')"
|
|
75
|
+
groups:
|
|
76
|
+
web:
|
|
77
|
+
tool: node
|
|
78
|
+
restart: no
|
|
79
|
+
disabledItems:
|
|
80
|
+
- worker
|
|
81
|
+
items:
|
|
82
|
+
server: server
|
|
83
|
+
worker: worker
|
|
84
|
+
`;
|
|
85
|
+
fs.writeFileSync(testConfigPath, configContent);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function startServer(port: number) {
|
|
89
|
+
const { spawn } = await import('child_process');
|
|
90
|
+
const env = { ...process.env, USERPROFILE: testConfigDir, HOME: testConfigDir };
|
|
91
|
+
serverProcess = spawn('node', ['dist/index.js', 'serve', String(port)], {
|
|
92
|
+
cwd: process.cwd(),
|
|
93
|
+
stdio: 'pipe',
|
|
94
|
+
env
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Wait for server to be ready
|
|
98
|
+
await new Promise<void>((resolve, reject) => {
|
|
99
|
+
let output = '';
|
|
100
|
+
const onData = (data: Buffer) => {
|
|
101
|
+
output += data.toString();
|
|
102
|
+
if (output.includes('cligr serve running at')) {
|
|
103
|
+
cleanup();
|
|
104
|
+
resolve();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const onError = (data: Buffer) => {
|
|
108
|
+
output += data.toString();
|
|
109
|
+
};
|
|
110
|
+
serverProcess!.stdout!.on('data', onData);
|
|
111
|
+
serverProcess!.stderr!.on('data', onError);
|
|
112
|
+
|
|
113
|
+
const timeout = setTimeout(() => {
|
|
114
|
+
cleanup();
|
|
115
|
+
reject(new Error('Server startup timeout. Output: ' + output));
|
|
116
|
+
}, 5000);
|
|
117
|
+
|
|
118
|
+
const cleanup = () => {
|
|
119
|
+
clearTimeout(timeout);
|
|
120
|
+
serverProcess!.stdout!.off('data', onData);
|
|
121
|
+
serverProcess!.stderr!.off('data', onError);
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function httpGet(url: string): Promise<{ status: number; body: string }> {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const req = http.get(url, (res) => {
|
|
129
|
+
let body = '';
|
|
130
|
+
res.on('data', (chunk) => body += chunk);
|
|
131
|
+
res.on('end', () => resolve({ status: res.statusCode || 0, body }));
|
|
132
|
+
});
|
|
133
|
+
req.on('error', reject);
|
|
134
|
+
req.setTimeout(2000, () => reject(new Error('HTTP timeout')));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function httpPost(url: string, body: object): Promise<{ status: number; body: string }> {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
const data = JSON.stringify(body);
|
|
141
|
+
const req = http.request(url, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
|
|
144
|
+
}, (res) => {
|
|
145
|
+
let responseBody = '';
|
|
146
|
+
res.on('data', (chunk) => responseBody += chunk);
|
|
147
|
+
res.on('end', () => resolve({ status: res.statusCode || 0, body: responseBody }));
|
|
148
|
+
});
|
|
149
|
+
req.on('error', reject);
|
|
150
|
+
req.write(data);
|
|
151
|
+
req.end();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it('should serve the HTML UI', { timeout: 15000 }, async () => {
|
|
156
|
+
writeConfig();
|
|
157
|
+
const port = await getFreePort();
|
|
158
|
+
await startServer(port);
|
|
159
|
+
|
|
160
|
+
const res = await httpGet(`http://localhost:${port}/`);
|
|
161
|
+
assert.strictEqual(res.status, 200);
|
|
162
|
+
assert.ok(res.body.includes('cligr serve'));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should return groups via API', { timeout: 15000 }, async () => {
|
|
166
|
+
writeConfig();
|
|
167
|
+
const port = await getFreePort();
|
|
168
|
+
await startServer(port);
|
|
169
|
+
|
|
170
|
+
const res = await httpGet(`http://localhost:${port}/api/groups`);
|
|
171
|
+
assert.strictEqual(res.status, 200);
|
|
172
|
+
const data = JSON.parse(res.body);
|
|
173
|
+
assert.ok(Array.isArray(data.groups));
|
|
174
|
+
assert.strictEqual(data.groups.length, 1);
|
|
175
|
+
assert.strictEqual(data.groups[0].name, 'web');
|
|
176
|
+
assert.strictEqual(data.groups[0].running, false);
|
|
177
|
+
assert.strictEqual(data.groups[0].items.length, 2);
|
|
178
|
+
const serverItem = data.groups[0].items.find((i: any) => i.name === 'server');
|
|
179
|
+
const workerItem = data.groups[0].items.find((i: any) => i.name === 'worker');
|
|
180
|
+
assert.strictEqual(serverItem.enabled, true);
|
|
181
|
+
assert.strictEqual(workerItem.enabled, false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should toggle group via API', { timeout: 15000 }, async () => {
|
|
185
|
+
writeConfig();
|
|
186
|
+
const port = await getFreePort();
|
|
187
|
+
await startServer(port);
|
|
188
|
+
|
|
189
|
+
const postRes = await httpPost(`http://localhost:${port}/api/groups/web/toggle`, { enabled: true });
|
|
190
|
+
assert.strictEqual(postRes.status, 200);
|
|
191
|
+
|
|
192
|
+
await new Promise(r => setTimeout(r, 300));
|
|
193
|
+
|
|
194
|
+
const getRes = await httpGet(`http://localhost:${port}/api/groups`);
|
|
195
|
+
const data = JSON.parse(getRes.body);
|
|
196
|
+
const web = data.groups.find((g: any) => g.name === 'web');
|
|
197
|
+
assert.strictEqual(web.running, true);
|
|
198
|
+
|
|
199
|
+
await httpPost(`http://localhost:${port}/api/groups/web/toggle`, { enabled: false });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should toggle item via API', { timeout: 15000 }, async () => {
|
|
203
|
+
writeConfig();
|
|
204
|
+
const port = await getFreePort();
|
|
205
|
+
await startServer(port);
|
|
206
|
+
|
|
207
|
+
const postRes = await httpPost(`http://localhost:${port}/api/groups/web/items/worker/toggle`, { enabled: true });
|
|
208
|
+
assert.strictEqual(postRes.status, 200);
|
|
209
|
+
|
|
210
|
+
const getRes = await httpGet(`http://localhost:${port}/api/groups`);
|
|
211
|
+
const data = JSON.parse(getRes.body);
|
|
212
|
+
const web = data.groups.find((g: any) => g.name === 'web');
|
|
213
|
+
const worker = web.items.find((i: any) => i.name === 'worker');
|
|
214
|
+
assert.strictEqual(worker.enabled, true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should stream SSE events', { timeout: 15000 }, async () => {
|
|
218
|
+
writeConfig();
|
|
219
|
+
const port = await getFreePort();
|
|
220
|
+
await startServer(port);
|
|
221
|
+
|
|
222
|
+
return new Promise<void>((resolve, reject) => {
|
|
223
|
+
const req = http.get(`http://localhost:${port}/api/events`, (res) => {
|
|
224
|
+
let buffer = '';
|
|
225
|
+
res.on('data', (chunk) => {
|
|
226
|
+
buffer += chunk.toString();
|
|
227
|
+
if (buffer.includes('event: status')) {
|
|
228
|
+
req.destroy();
|
|
229
|
+
resolve();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
req.on('error', reject);
|
|
234
|
+
|
|
235
|
+
setTimeout(() => {
|
|
236
|
+
httpPost(`http://localhost:${port}/api/groups/web/toggle`, { enabled: true }).catch(() => {});
|
|
237
|
+
}, 200);
|
|
238
|
+
|
|
239
|
+
setTimeout(() => {
|
|
240
|
+
req.destroy();
|
|
241
|
+
reject(new Error('SSE timeout'));
|
|
242
|
+
}, 3000);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|