Files
MATTERMOST_OPENCLAW_LIVESTATUS/test/unit/circuit-breaker.test.js
sol 43cfebee96 feat: Phase 0+1 — repo sync, pino, lint fixes, core components
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.
2026-03-07 17:26:53 +00:00

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));
}