import { expect, tap } from '@git.zone/tstest/tapbundle'; import * as taskbuffer from '../ts/index.js'; import * as smartdelay from '@push.rocks/smartdelay'; // ── Test 1: Basic service start/stop lifecycle ───────────── tap.test('should start and stop a simple service', async () => { let started = false; let stopped = false; const service = new taskbuffer.Service('TestService') .withStart(async () => { started = true; }) .withStop(async () => { stopped = true; }); expect(service.state).toEqual('stopped'); await service.start(); expect(service.state).toEqual('running'); expect(started).toBeTrue(); await service.stop(); expect(service.state).toEqual('stopped'); expect(stopped).toBeTrue(); }); // ── Test 2: Builder pattern chaining ─────────────────────── tap.test('should support builder pattern chaining', async () => { const service = new taskbuffer.Service('ChainedService') .critical() .dependsOn('Dep1', 'Dep2') .withStart(async () => 'result') .withStop(async () => {}) .withRetry({ maxRetries: 5, baseDelayMs: 100 }); expect(service.criticality).toEqual('critical'); expect(service.dependencies).toEqual(['Dep1', 'Dep2']); expect(service.retryConfig).toBeTruthy(); expect(service.retryConfig!.maxRetries).toEqual(5); }); // ── Test 3: Subclass pattern ─────────────────────────────── tap.test('should support subclass pattern', async () => { class MyService extends taskbuffer.Service { public startCalled = false; public stopCalled = false; constructor() { super('MySubclassService'); this.optional(); } protected async serviceStart(): Promise { this.startCalled = true; return 'hello'; } protected async serviceStop(): Promise { this.stopCalled = true; } } const service = new MyService(); const result = await service.start(); expect(service.startCalled).toBeTrue(); expect(service.state).toEqual('running'); await service.stop(); expect(service.stopCalled).toBeTrue(); expect(service.state).toEqual('stopped'); }); // ── Test 4: Constructor with options object ──────────────── tap.test('should accept options object in constructor', async () => { let started = false; const service = new taskbuffer.Service({ name: 'OptionsService', criticality: 'critical', dependencies: ['A', 'B'], start: async () => { started = true; }, stop: async () => {}, retry: { maxRetries: 10 }, }); expect(service.name).toEqual('OptionsService'); expect(service.criticality).toEqual('critical'); expect(service.dependencies).toEqual(['A', 'B']); await service.start(); expect(started).toBeTrue(); await service.stop(); }); // ── Test 5: ServiceManager dependency ordering ───────────── tap.test('should start services in dependency order', async () => { const order: string[] = []; const manager = new taskbuffer.ServiceManager({ name: 'TestManager' }); manager.addService( new taskbuffer.Service('C') .dependsOn('B') .withStart(async () => { order.push('C'); }) .withStop(async () => {}), ); manager.addService( new taskbuffer.Service('A') .withStart(async () => { order.push('A'); }) .withStop(async () => {}), ); manager.addService( new taskbuffer.Service('B') .dependsOn('A') .withStart(async () => { order.push('B'); }) .withStop(async () => {}), ); await manager.start(); // A must come before B, B must come before C expect(order.indexOf('A')).toBeLessThan(order.indexOf('B')); expect(order.indexOf('B')).toBeLessThan(order.indexOf('C')); await manager.stop(); }); // ── Test 6: Critical service failure aborts startup ──────── tap.test('should abort startup when a critical service fails', async () => { const manager = new taskbuffer.ServiceManager({ name: 'CriticalTest' }); let serviceCStopped = false; manager.addService( new taskbuffer.Service('Working') .critical() .withStart(async () => {}) .withStop(async () => {}), ); manager.addService( new taskbuffer.Service('Broken') .critical() .withStart(async () => { throw new Error('boom'); }) .withStop(async () => {}) .withRetry({ maxRetries: 0 }), ); manager.addService( new taskbuffer.Service('AfterBroken') .dependsOn('Broken') .withStart(async () => { serviceCStopped = true; }) .withStop(async () => {}), ); let caught = false; try { await manager.start(); } catch (err) { caught = true; expect((err as Error).message).toInclude('Broken'); } expect(caught).toBeTrue(); // AfterBroken should never have started because Broken (its dep) failed expect(serviceCStopped).toBeFalse(); }); // ── Test 7: Optional service failure continues startup ───── tap.test('should continue startup when an optional service fails', async () => { const manager = new taskbuffer.ServiceManager({ name: 'OptionalTest' }); let criticalStarted = false; manager.addService( new taskbuffer.Service('Critical') .critical() .withStart(async () => { criticalStarted = true; }) .withStop(async () => {}) .withRetry({ maxRetries: 0 }), ); manager.addService( new taskbuffer.Service('Optional') .optional() .withStart(async () => { throw new Error('oops'); }) .withStop(async () => {}) .withRetry({ maxRetries: 0 }), ); // Should NOT throw await manager.start(); expect(criticalStarted).toBeTrue(); const health = manager.getHealth(); expect(health.overall).toEqual('degraded'); const optionalStatus = manager.getServiceStatus('Optional'); expect(optionalStatus!.state).toEqual('failed'); await manager.stop(); }); // ── Test 8: Retry with backoff for optional services ─────── tap.test('should retry failed optional services', async () => { const manager = new taskbuffer.ServiceManager({ name: 'RetryTest' }); let attempts = 0; manager.addService( new taskbuffer.Service('Flaky') .optional() .withStart(async () => { attempts++; if (attempts < 3) { throw new Error(`attempt ${attempts} failed`); } }) .withStop(async () => {}) .withRetry({ maxRetries: 5, baseDelayMs: 50, maxDelayMs: 100, backoffFactor: 1 }), ); await manager.start(); expect(attempts).toEqual(3); const status = manager.getServiceStatus('Flaky'); expect(status!.state).toEqual('running'); await manager.stop(); }); // ── Test 9: Reverse-order shutdown ───────────────────────── tap.test('should stop services in reverse dependency order', async () => { const order: string[] = []; const manager = new taskbuffer.ServiceManager({ name: 'ShutdownTest' }); manager.addService( new taskbuffer.Service('Base') .withStart(async () => {}) .withStop(async () => { order.push('Base'); }), ); manager.addService( new taskbuffer.Service('Middle') .dependsOn('Base') .withStart(async () => {}) .withStop(async () => { order.push('Middle'); }), ); manager.addService( new taskbuffer.Service('Top') .dependsOn('Middle') .withStart(async () => {}) .withStop(async () => { order.push('Top'); }), ); await manager.start(); await manager.stop(); // Top should stop before Middle, Middle before Base expect(order.indexOf('Top')).toBeLessThan(order.indexOf('Middle')); expect(order.indexOf('Middle')).toBeLessThan(order.indexOf('Base')); }); // ── Test 10: Circular dependency detection ───────────────── tap.test('should throw on circular dependency', async () => { const manager = new taskbuffer.ServiceManager({ name: 'CycleTest' }); manager.addService( new taskbuffer.Service('A') .dependsOn('B') .withStart(async () => {}) .withStop(async () => {}), ); manager.addService( new taskbuffer.Service('B') .dependsOn('A') .withStart(async () => {}) .withStop(async () => {}), ); let caught = false; try { await manager.start(); } catch (err) { caught = true; expect((err as Error).message).toInclude('Circular dependency'); } expect(caught).toBeTrue(); }); // ── Test 11: restartService cascades to dependents ───────── tap.test('should restart service and its dependents', async () => { const startOrder: string[] = []; const stopOrder: string[] = []; const manager = new taskbuffer.ServiceManager({ name: 'RestartTest' }); manager.addService( new taskbuffer.Service('Base') .withStart(async () => { startOrder.push('Base'); }) .withStop(async () => { stopOrder.push('Base'); }) .withRetry({ maxRetries: 0 }), ); manager.addService( new taskbuffer.Service('Dep') .dependsOn('Base') .withStart(async () => { startOrder.push('Dep'); }) .withStop(async () => { stopOrder.push('Dep'); }) .withRetry({ maxRetries: 0 }), ); await manager.start(); expect(startOrder).toEqual(['Base', 'Dep']); // Clear tracking startOrder.length = 0; stopOrder.length = 0; await manager.restartService('Base'); // Dep should be stopped first, then Base, then Base restarted, then Dep expect(stopOrder).toEqual(['Dep', 'Base']); expect(startOrder).toEqual(['Base', 'Dep']); await manager.stop(); }); // ── Test 12: getHealth returns correct aggregated status ─── tap.test('should return correct health aggregation', async () => { const manager = new taskbuffer.ServiceManager({ name: 'HealthTest' }); manager.addService( new taskbuffer.Service('OK') .critical() .withStart(async () => {}) .withStop(async () => {}) .withRetry({ maxRetries: 0 }), ); manager.addService( new taskbuffer.Service('AlsoOK') .optional() .withStart(async () => {}) .withStop(async () => {}) .withRetry({ maxRetries: 0 }), ); await manager.start(); const health = manager.getHealth(); expect(health.overall).toEqual('healthy'); expect(health.services.length).toEqual(2); expect(health.startedAt).toBeTruthy(); expect(health.uptime).toBeGreaterThanOrEqual(0); await manager.stop(); }); // ── Test 13: Events emitted on state transitions ─────────── tap.test('should emit events on state transitions', async () => { const events: taskbuffer.IServiceEvent[] = []; const manager = new taskbuffer.ServiceManager({ name: 'EventTest' }); manager.addService( new taskbuffer.Service('Svc') .withStart(async () => {}) .withStop(async () => {}) .withRetry({ maxRetries: 0 }), ); manager.serviceSubject.subscribe((event) => { events.push(event); }); await manager.start(); await manager.stop(); const types = events.map((e) => e.type); expect(types).toContain('started'); expect(types).toContain('stopped'); }); // ── Test 14: Parallel startup of independent services ────── tap.test('should start independent services in parallel', async () => { const manager = new taskbuffer.ServiceManager({ name: 'ParallelTest' }); const startTimes: Record = {}; manager.addService( new taskbuffer.Service('A') .withStart(async () => { startTimes['A'] = Date.now(); await smartdelay.delayFor(100); }) .withStop(async () => {}), ); manager.addService( new taskbuffer.Service('B') .withStart(async () => { startTimes['B'] = Date.now(); await smartdelay.delayFor(100); }) .withStop(async () => {}), ); await manager.start(); // Both should start at roughly the same time (within 50ms) const diff = Math.abs(startTimes['A'] - startTimes['B']); expect(diff).toBeLessThan(50); await manager.stop(); }); // ── Test 15: getStatus snapshot ──────────────────────────── tap.test('should return accurate status snapshot', async () => { const service = new taskbuffer.Service('StatusTest') .critical() .dependsOn('X') .withStart(async () => {}) .withStop(async () => {}); const statusBefore = service.getStatus(); expect(statusBefore.state).toEqual('stopped'); expect(statusBefore.name).toEqual('StatusTest'); expect(statusBefore.criticality).toEqual('critical'); expect(statusBefore.dependencies).toEqual(['X']); expect(statusBefore.errorCount).toEqual(0); await service.start(); const statusAfter = service.getStatus(); expect(statusAfter.state).toEqual('running'); expect(statusAfter.startedAt).toBeTruthy(); expect(statusAfter.uptime).toBeGreaterThanOrEqual(0); await service.stop(); }); // ── Test 16: Missing dependency detection ────────────────── tap.test('should throw when a dependency is not registered', async () => { const manager = new taskbuffer.ServiceManager({ name: 'MissingDepTest' }); manager.addService( new taskbuffer.Service('Lonely') .dependsOn('Ghost') .withStart(async () => {}) .withStop(async () => {}), ); let caught = false; try { await manager.start(); } catch (err) { caught = true; expect((err as Error).message).toInclude('Ghost'); expect((err as Error).message).toInclude('not registered'); } expect(caught).toBeTrue(); }); // ── Test 17: No-op on double start/stop ──────────────────── tap.test('should be a no-op when starting an already-running service', async () => { let startCount = 0; const service = new taskbuffer.Service('DoubleStart') .withStart(async () => { startCount++; }) .withStop(async () => {}); await service.start(); await service.start(); // should be no-op expect(startCount).toEqual(1); await service.stop(); await service.stop(); // should be no-op }); // ── Test 18: addServiceFromOptions convenience ───────────── tap.test('should support addServiceFromOptions', async () => { const manager = new taskbuffer.ServiceManager({ name: 'ConvenienceTest' }); let started = false; const service = manager.addServiceFromOptions({ name: 'Easy', start: async () => { started = true; }, stop: async () => {}, }); expect(service).toBeInstanceOf(taskbuffer.Service); expect(service.name).toEqual('Easy'); await manager.start(); expect(started).toBeTrue(); await manager.stop(); }); export default tap.start();