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.
@@ -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
- - alpine,sh
57
- - nginx,nginx,-p,80:80
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
- - server.js
64
- - worker.js
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
- - hello
153
- - world
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
- - nginx,nginx,-p,80:80
180
- - redis,redis
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
- - server.js,3000
187
- - worker.js
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.config.items.length, 2);
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.config.items.length, 2);
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
- - test1
239
+ test1: test1
239
240
 
240
241
  group2:
241
242
  tool: echo
242
243
  restart: no
243
244
  items:
244
- - test2
245
+ test2: test2
245
246
 
246
247
  group3:
247
248
  tool: echo
248
249
  restart: no
249
250
  items:
250
- - test3
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
- - test
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
- - from-home
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
- - from-current
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 array', () => {
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 config = loader.load();
478
+ const result = loader.getGroup('empty');
369
479
 
370
- assert.strictEqual(config.groups.empty.items.length, 0);
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
- - "hello, world"
381
- - "test,with,commas"
382
- - "spaces test"
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.config.items.length, 3);
391
- assert.strictEqual(result.config.items[0], 'hello, world');
392
- assert.strictEqual(result.config.items[1], 'test,with,commas');
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
- - test
512
+ test: test
403
513
 
404
514
  restart-no:
405
515
  tool: echo
406
516
  restart: no
407
517
  items:
408
- - test
518
+ test: test
409
519
 
410
520
  restart-unless-stopped:
411
521
  tool: echo
412
522
  restart: unless-stopped
413
523
  items:
414
- - test
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: process.platform === 'win32' ? 'exit 0' : 'true' }
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
+ });