2026-03-20 15:24:12 +00:00
|
|
|
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<string> {
|
|
|
|
|
public startCalled = false;
|
|
|
|
|
public stopCalled = false;
|
|
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
super('MySubclassService');
|
|
|
|
|
this.optional();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected async serviceStart(): Promise<string> {
|
|
|
|
|
this.startCalled = true;
|
|
|
|
|
return 'hello';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected async serviceStop(): Promise<void> {
|
|
|
|
|
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<string, number> = {};
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-21 10:57:27 +00:00
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
// 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<typeof pool>('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<number>('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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-20 15:24:12 +00:00
|
|
|
export default tap.start();
|