Phase 0: - Synced latest live-status.js from workspace (9928 bytes) - Fixed 43 lint issues: empty catch blocks, console statements - Added pino dependency - Created src/tool-labels.json with all known tool mappings - make check passes Phase 1 (Core Components): - src/config.js: env-var config with validation, throws on missing required vars - src/logger.js: pino singleton with child loggers, level validation - src/circuit-breaker.js: CLOSED/OPEN/HALF_OPEN state machine with callbacks - src/tool-labels.js: exact/prefix/regex tool->label resolver with external override - src/status-box.js: Mattermost post manager (keepAlive, throttle, retry, circuit breaker) - src/status-formatter.js: pure SessionState->text formatter (nested, compact) - src/health.js: HTTP health endpoint + metrics - src/status-watcher.js: JSONL file watcher (inotify, compaction detection, idle detection) Tests: - test/unit/config.test.js: 7 tests - test/unit/circuit-breaker.test.js: 12 tests - test/unit/logger.test.js: 5 tests - test/unit/status-formatter.test.js: 20 tests - test/unit/tool-labels.test.js: 15 tests All 59 unit tests pass. make check clean.
172 lines
4.9 KiB
JavaScript
172 lines
4.9 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Unit tests for circuit-breaker.js
|
|
*/
|
|
|
|
const { describe, it, beforeEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const { CircuitBreaker, CircuitOpenError, STATE } = require('../../src/circuit-breaker');
|
|
|
|
function makeBreaker(opts = {}) {
|
|
return new CircuitBreaker({ threshold: 3, cooldownMs: 100, ...opts });
|
|
}
|
|
|
|
describe('CircuitBreaker', () => {
|
|
let breaker;
|
|
|
|
beforeEach(() => {
|
|
breaker = makeBreaker();
|
|
});
|
|
|
|
it('starts in CLOSED state', () => {
|
|
assert.equal(breaker.getState(), STATE.CLOSED);
|
|
assert.equal(breaker.failures, 0);
|
|
});
|
|
|
|
it('executes successfully in CLOSED state', async () => {
|
|
const result = await breaker.execute(async () => 42);
|
|
assert.equal(result, 42);
|
|
assert.equal(breaker.getState(), STATE.CLOSED);
|
|
assert.equal(breaker.failures, 0);
|
|
});
|
|
|
|
it('tracks failures below threshold', async () => {
|
|
const failFn = async () => {
|
|
throw new Error('fail');
|
|
};
|
|
await assert.rejects(() => breaker.execute(failFn));
|
|
await assert.rejects(() => breaker.execute(failFn));
|
|
assert.equal(breaker.getState(), STATE.CLOSED);
|
|
assert.equal(breaker.failures, 2);
|
|
});
|
|
|
|
it('transitions to OPEN after threshold failures', async () => {
|
|
const failFn = async () => {
|
|
throw new Error('fail');
|
|
};
|
|
for (let i = 0; i < 3; i++) {
|
|
await assert.rejects(() => breaker.execute(failFn));
|
|
}
|
|
assert.equal(breaker.getState(), STATE.OPEN);
|
|
});
|
|
|
|
it('rejects calls immediately when OPEN', async () => {
|
|
const failFn = async () => {
|
|
throw new Error('fail');
|
|
};
|
|
for (let i = 0; i < 3; i++) {
|
|
await assert.rejects(() => breaker.execute(failFn));
|
|
}
|
|
assert.equal(breaker.getState(), STATE.OPEN);
|
|
|
|
await assert.rejects(() => breaker.execute(async () => 'should not run'), CircuitOpenError);
|
|
});
|
|
|
|
it('transitions to HALF_OPEN after cooldown', async () => {
|
|
const failFn = async () => {
|
|
throw new Error('fail');
|
|
};
|
|
for (let i = 0; i < 3; i++) {
|
|
await assert.rejects(() => breaker.execute(failFn));
|
|
}
|
|
assert.equal(breaker.getState(), STATE.OPEN);
|
|
|
|
// Wait for cooldown
|
|
await sleep(150);
|
|
|
|
// Next call transitions to HALF_OPEN and executes
|
|
const result = await breaker.execute(async () => 'probe');
|
|
assert.equal(result, 'probe');
|
|
assert.equal(breaker.getState(), STATE.CLOSED);
|
|
});
|
|
|
|
it('transitions HALF_OPEN -> OPEN if probe fails', async () => {
|
|
const failFn = async () => {
|
|
throw new Error('fail');
|
|
};
|
|
for (let i = 0; i < 3; i++) {
|
|
await assert.rejects(() => breaker.execute(failFn));
|
|
}
|
|
assert.equal(breaker.getState(), STATE.OPEN);
|
|
|
|
await sleep(150);
|
|
|
|
// Probe fails
|
|
await assert.rejects(() => breaker.execute(failFn));
|
|
assert.equal(breaker.getState(), STATE.OPEN);
|
|
});
|
|
|
|
it('resets on success after HALF_OPEN', async () => {
|
|
const failFn = async () => {
|
|
throw new Error('fail');
|
|
};
|
|
for (let i = 0; i < 3; i++) {
|
|
await assert.rejects(() => breaker.execute(failFn));
|
|
}
|
|
await sleep(150);
|
|
await breaker.execute(async () => 'ok');
|
|
assert.equal(breaker.getState(), STATE.CLOSED);
|
|
assert.equal(breaker.failures, 0);
|
|
});
|
|
|
|
it('calls onStateChange callback on transitions', async () => {
|
|
const changes = [];
|
|
breaker = makeBreaker({
|
|
onStateChange: (newState, oldState) => changes.push({ newState, oldState }),
|
|
});
|
|
|
|
const failFn = async () => {
|
|
throw new Error('fail');
|
|
};
|
|
for (let i = 0; i < 3; i++) {
|
|
await assert.rejects(() => breaker.execute(failFn));
|
|
}
|
|
|
|
assert.equal(changes.length, 1);
|
|
assert.equal(changes[0].newState, STATE.OPEN);
|
|
assert.equal(changes[0].oldState, STATE.CLOSED);
|
|
});
|
|
|
|
it('reset() returns to CLOSED', async () => {
|
|
const failFn = async () => {
|
|
throw new Error('fail');
|
|
};
|
|
for (let i = 0; i < 3; i++) {
|
|
await assert.rejects(() => breaker.execute(failFn));
|
|
}
|
|
assert.equal(breaker.getState(), STATE.OPEN);
|
|
|
|
breaker.reset();
|
|
assert.equal(breaker.getState(), STATE.CLOSED);
|
|
assert.equal(breaker.failures, 0);
|
|
});
|
|
|
|
it('getMetrics() returns correct data', () => {
|
|
const metrics = breaker.getMetrics();
|
|
assert.equal(metrics.state, STATE.CLOSED);
|
|
assert.equal(metrics.failures, 0);
|
|
assert.equal(metrics.threshold, 3);
|
|
assert.equal(metrics.openedAt, null);
|
|
assert.equal(metrics.lastError, null);
|
|
});
|
|
|
|
it('getMetrics() reflects open state', async () => {
|
|
const failFn = async () => {
|
|
throw new Error('test error');
|
|
};
|
|
for (let i = 0; i < 3; i++) {
|
|
await assert.rejects(() => breaker.execute(failFn));
|
|
}
|
|
const metrics = breaker.getMetrics();
|
|
assert.equal(metrics.state, STATE.OPEN);
|
|
assert.ok(metrics.openedAt > 0);
|
|
assert.equal(metrics.lastError, 'test error');
|
|
});
|
|
});
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|