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(); }); // ═══════════════════════════════════════════════════════════ // NEW TESTS: Improvements 1-5 // ═══════════════════════════════════════════════════════════ // ── Test 19: service.instance stores start result ────────── tap.test('should store start result as service.instance', async () => { const pool = { query: (sql: string) => `result: ${sql}` }; const service = new taskbuffer.Service('DB') .withStart(async () => pool) .withStop(async (inst) => {}); expect(service.instance).toBeUndefined(); const result = await service.start(); expect(result).toEqual(pool); expect(service.instance).toEqual(pool); expect(service.instance!.query('SELECT 1')).toEqual('result: SELECT 1'); // Status should report hasInstance expect(service.getStatus().hasInstance).toBeTrue(); await service.stop(); expect(service.instance).toBeUndefined(); expect(service.getStatus().hasInstance).toBeFalse(); }); // ── Test 20: stop receives the instance ──────────────────── tap.test('should pass instance to stop function', async () => { let receivedInstance: any = null; const service = new taskbuffer.Service<{ id: number }>('InstanceStop') .withStart(async () => ({ id: 42 })) .withStop(async (inst) => { receivedInstance = inst; }); await service.start(); await service.stop(); expect(receivedInstance).toBeTruthy(); expect(receivedInstance.id).toEqual(42); }); // ── Test 21: healthCheck receives the instance ───────────── tap.test('should pass instance to health check function', async () => { let checkedInstance: any = null; const service = new taskbuffer.Service<{ healthy: boolean }>('InstanceHealth') .withStart(async () => ({ healthy: true })) .withStop(async () => {}) .withHealthCheck(async (inst) => { checkedInstance = inst; return inst.healthy; }, { intervalMs: 60000 }); // long interval so it doesn't auto-run await service.start(); // Manually trigger health check const ok = await service.checkHealth(); expect(ok).toBeTrue(); expect(checkedInstance).toBeTruthy(); expect(checkedInstance.healthy).toBeTrue(); await service.stop(); }); // ── Test 22: double start returns existing instance ──────── tap.test('should return existing instance on double start', async () => { let callCount = 0; const service = new taskbuffer.Service('DoubleInstance') .withStart(async () => { callCount++; return callCount; }) .withStop(async () => {}); const first = await service.start(); const second = await service.start(); expect(first).toEqual(1); expect(second).toEqual(1); // should return same instance, not call start again expect(callCount).toEqual(1); await service.stop(); }); // ── Test 23: Labels on Service ───────────────────────────── tap.test('should support labels on services', async () => { const service = new taskbuffer.Service('LabeledService') .withLabels({ type: 'database', env: 'production' }) .withStart(async () => {}) .withStop(async () => {}); expect(service.hasLabel('type')).toBeTrue(); expect(service.hasLabel('type', 'database')).toBeTrue(); expect(service.hasLabel('type', 'cache')).toBeFalse(); expect(service.getLabel('env')).toEqual('production'); service.setLabel('region', 'eu-west'); expect(service.hasLabel('region')).toBeTrue(); service.removeLabel('env'); expect(service.hasLabel('env')).toBeFalse(); // Labels in status const status = service.getStatus(); expect(status.labels).toBeTruthy(); expect(status.labels!.type).toEqual('database'); expect(status.labels!.region).toEqual('eu-west'); }); // ── Test 24: Labels via IServiceOptions ──────────────────── tap.test('should accept labels in options constructor', async () => { const service = new taskbuffer.Service({ name: 'OptionsLabeled', start: async () => {}, stop: async () => {}, labels: { kind: 'cache' }, }); expect(service.hasLabel('kind', 'cache')).toBeTrue(); }); // ── Test 25: ServiceManager.getServicesByLabel ───────────── tap.test('should query services by label in ServiceManager', async () => { const manager = new taskbuffer.ServiceManager({ name: 'LabelQueryTest' }); manager.addService( new taskbuffer.Service('Redis') .withLabels({ type: 'cache', tier: 'fast' }) .withStart(async () => {}) .withStop(async () => {}), ); manager.addService( new taskbuffer.Service('Memcached') .withLabels({ type: 'cache', tier: 'fast' }) .withStart(async () => {}) .withStop(async () => {}), ); manager.addService( new taskbuffer.Service('Postgres') .withLabels({ type: 'database', tier: 'slow' }) .withStart(async () => {}) .withStop(async () => {}), ); const caches = manager.getServicesByLabel('type', 'cache'); expect(caches.length).toEqual(2); const databases = manager.getServicesByLabel('type', 'database'); expect(databases.length).toEqual(1); expect(databases[0].name).toEqual('Postgres'); const statuses = manager.getServicesStatusByLabel('tier', 'fast'); expect(statuses.length).toEqual(2); await manager.stop(); }); // ── Test 26: waitForState / waitForRunning ────────────────── tap.test('should support waitForState and waitForRunning', async () => { const service = new taskbuffer.Service('WaitService') .withStart(async () => { await smartdelay.delayFor(50); }) .withStop(async () => {}); // Start in background const startPromise = service.start(); // Wait for running state await service.waitForRunning(2000); expect(service.state).toEqual('running'); await startPromise; // Now wait for stopped const stopPromise = service.stop(); await service.waitForStopped(2000); expect(service.state).toEqual('stopped'); await stopPromise; }); // ── Test 27: waitForState timeout ────────────────────────── tap.test('should timeout when waiting for a state that never comes', async () => { const service = new taskbuffer.Service('TimeoutWait') .withStart(async () => {}) .withStop(async () => {}); // Service is stopped, wait for 'running' with a short timeout let caught = false; try { await service.waitForState('running', 100); } catch (err) { caught = true; expect((err as Error).message).toInclude('timed out'); expect((err as Error).message).toInclude('running'); } expect(caught).toBeTrue(); }); // ── Test 28: waitForState already in target state ────────── tap.test('should resolve immediately if already in target state', async () => { const service = new taskbuffer.Service('AlreadyThere') .withStart(async () => {}) .withStop(async () => {}); // Already stopped await service.waitForState('stopped', 100); // should not throw await service.start(); await service.waitForState('running', 100); // should not throw await service.stop(); }); // ── Test 29: Per-service startup timeout ─────────────────── tap.test('should timeout when serviceStart hangs', async () => { const service = new taskbuffer.Service('SlowStart') .withStart(async () => { await smartdelay.delayFor(5000); // very slow }) .withStop(async () => {}) .withStartupTimeout(100); // 100ms timeout let caught = false; try { await service.start(); } catch (err) { caught = true; expect((err as Error).message).toInclude('startup timed out'); expect((err as Error).message).toInclude('100ms'); } expect(caught).toBeTrue(); expect(service.state).toEqual('failed'); }); // ── Test 30: Startup timeout via options ──────────────────── tap.test('should accept startupTimeoutMs in options constructor', async () => { const service = new taskbuffer.Service({ name: 'TimeoutOptions', start: async () => { await smartdelay.delayFor(5000); }, stop: async () => {}, startupTimeoutMs: 100, }); let caught = false; try { await service.start(); } catch (err) { caught = true; expect((err as Error).message).toInclude('startup timed out'); } expect(caught).toBeTrue(); }); // ── Test 31: Auto-restart on health check failure ────────── tap.test('should auto-restart when health checks fail', async () => { let startCount = 0; let healthy = false; const service = new taskbuffer.Service('AutoRestart') .withStart(async () => { startCount++; // After first restart, become healthy if (startCount >= 2) { healthy = true; } }) .withStop(async () => {}) .withHealthCheck(async () => healthy, { intervalMs: 30000, // we'll call checkHealth manually failuresBeforeDegraded: 1, failuresBeforeFailed: 2, autoRestart: true, maxAutoRestarts: 3, autoRestartDelayMs: 50, autoRestartBackoffFactor: 1, }); await service.start(); expect(startCount).toEqual(1); // Fail health checks manually to push to failed await service.checkHealth(); // 1 failure -> degraded expect(service.state).toEqual('degraded'); await service.checkHealth(); // 2 failures -> failed + auto-restart scheduled expect(service.state).toEqual('failed'); // Wait for auto-restart to complete await service.waitForRunning(2000); expect(service.state).toEqual('running'); expect(startCount).toEqual(2); await service.stop(); }); // ── Test 32: Auto-restart max attempts ───────────────────── tap.test('should stop auto-restarting after max attempts', async () => { let startCount = 0; const events: string[] = []; const service = new taskbuffer.Service('MaxRestart') .withStart(async () => { startCount++; if (startCount > 1) { throw new Error('always fails'); } }) .withStop(async () => {}) .withHealthCheck(async () => false, { intervalMs: 60000, failuresBeforeDegraded: 1, failuresBeforeFailed: 2, autoRestart: true, maxAutoRestarts: 2, autoRestartDelayMs: 50, autoRestartBackoffFactor: 1, }); service.eventSubject.subscribe((e) => events.push(e.type)); await service.start(); expect(startCount).toEqual(1); // Push to failed await service.checkHealth(); await service.checkHealth(); // Wait for auto-restart attempts to exhaust await smartdelay.delayFor(500); // Should have emitted autoRestarting events expect(events).toContain('autoRestarting'); // Service should be in failed state (restarts exhausted) expect(service.state).toEqual('failed'); // Clean up await service.stop(); }); // ── Test 33: Builder chaining with new methods ───────────── tap.test('should chain all new builder methods', async () => { const service = new taskbuffer.Service('FullBuilder') .critical() .dependsOn('A') .withStart(async () => ({ conn: true })) .withStop(async (inst) => {}) .withHealthCheck(async (inst) => inst.conn) .withRetry({ maxRetries: 3 }) .withStartupTimeout(5000) .withLabels({ env: 'test' }); expect(service.criticality).toEqual('critical'); expect(service.startupTimeoutMs).toEqual(5000); expect(service.hasLabel('env', 'test')).toBeTrue(); }); export default tap.start();