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.
This commit is contained in:
171
test/unit/circuit-breaker.test.js
Normal file
171
test/unit/circuit-breaker.test.js
Normal file
@@ -0,0 +1,171 @@
|
||||
'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));
|
||||
}
|
||||
135
test/unit/config.test.js
Normal file
135
test/unit/config.test.js
Normal file
@@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Unit tests for config.js
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { buildConfig, resetConfig } = require('../../src/config');
|
||||
|
||||
describe('config.js', () => {
|
||||
const originalEnv = {};
|
||||
|
||||
beforeEach(() => {
|
||||
// Save and clear relevant env vars
|
||||
const keys = [
|
||||
'MM_BOT_TOKEN',
|
||||
'MM_BASE_URL',
|
||||
'MM_MAX_SOCKETS',
|
||||
'TRANSCRIPT_DIR',
|
||||
'THROTTLE_MS',
|
||||
'IDLE_TIMEOUT_S',
|
||||
'SESSION_POLL_MS',
|
||||
'MAX_ACTIVE_SESSIONS',
|
||||
'MAX_MESSAGE_CHARS',
|
||||
'MAX_STATUS_LINES',
|
||||
'MAX_RETRIES',
|
||||
'CIRCUIT_BREAKER_THRESHOLD',
|
||||
'CIRCUIT_BREAKER_COOLDOWN_S',
|
||||
'HEALTH_PORT',
|
||||
'LOG_LEVEL',
|
||||
'PID_FILE',
|
||||
'OFFSET_FILE',
|
||||
'TOOL_LABELS_FILE',
|
||||
'DEFAULT_CHANNEL',
|
||||
'ENABLE_FS_WATCH',
|
||||
'MM_PORT',
|
||||
];
|
||||
for (const k of keys) {
|
||||
originalEnv[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
resetConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env
|
||||
for (const [k, v] of Object.entries(originalEnv)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
resetConfig();
|
||||
});
|
||||
|
||||
it('throws if MM_BOT_TOKEN is missing', () => {
|
||||
assert.throws(() => buildConfig(), /MM_BOT_TOKEN/);
|
||||
});
|
||||
|
||||
it('builds config with only required vars', () => {
|
||||
process.env.MM_BOT_TOKEN = 'test-token';
|
||||
const cfg = buildConfig();
|
||||
assert.equal(cfg.mm.token, 'test-token');
|
||||
assert.equal(cfg.mm.baseUrl, 'https://slack.solio.tech');
|
||||
assert.equal(cfg.throttleMs, 500);
|
||||
assert.equal(cfg.idleTimeoutS, 60);
|
||||
assert.equal(cfg.maxActiveSessions, 20);
|
||||
assert.equal(cfg.healthPort, 9090);
|
||||
assert.equal(cfg.logLevel, 'info');
|
||||
});
|
||||
|
||||
it('reads all env vars correctly', () => {
|
||||
process.env.MM_BOT_TOKEN = 'mytoken';
|
||||
process.env.MM_BASE_URL = 'https://mm.example.com';
|
||||
process.env.MM_MAX_SOCKETS = '8';
|
||||
process.env.THROTTLE_MS = '250';
|
||||
process.env.IDLE_TIMEOUT_S = '120';
|
||||
process.env.MAX_ACTIVE_SESSIONS = '10';
|
||||
process.env.MAX_MESSAGE_CHARS = '5000';
|
||||
process.env.LOG_LEVEL = 'debug';
|
||||
process.env.HEALTH_PORT = '8080';
|
||||
process.env.ENABLE_FS_WATCH = 'false';
|
||||
|
||||
const cfg = buildConfig();
|
||||
assert.equal(cfg.mm.token, 'mytoken');
|
||||
assert.equal(cfg.mm.baseUrl, 'https://mm.example.com');
|
||||
assert.equal(cfg.mm.maxSockets, 8);
|
||||
assert.equal(cfg.throttleMs, 250);
|
||||
assert.equal(cfg.idleTimeoutS, 120);
|
||||
assert.equal(cfg.maxActiveSessions, 10);
|
||||
assert.equal(cfg.maxMessageChars, 5000);
|
||||
assert.equal(cfg.logLevel, 'debug');
|
||||
assert.equal(cfg.healthPort, 8080);
|
||||
assert.equal(cfg.enableFsWatch, false);
|
||||
});
|
||||
|
||||
it('throws on invalid MM_BASE_URL', () => {
|
||||
process.env.MM_BOT_TOKEN = 'token';
|
||||
process.env.MM_BASE_URL = 'not-a-url';
|
||||
assert.throws(() => buildConfig(), /MM_BASE_URL/);
|
||||
});
|
||||
|
||||
it('throws on non-integer THROTTLE_MS', () => {
|
||||
process.env.MM_BOT_TOKEN = 'token';
|
||||
process.env.THROTTLE_MS = 'abc';
|
||||
assert.throws(() => buildConfig(), /THROTTLE_MS/);
|
||||
});
|
||||
|
||||
it('ENABLE_FS_WATCH accepts "1", "true", "yes"', () => {
|
||||
process.env.MM_BOT_TOKEN = 'token';
|
||||
|
||||
process.env.ENABLE_FS_WATCH = '1';
|
||||
assert.equal(buildConfig().enableFsWatch, true);
|
||||
resetConfig();
|
||||
|
||||
process.env.ENABLE_FS_WATCH = 'true';
|
||||
assert.equal(buildConfig().enableFsWatch, true);
|
||||
resetConfig();
|
||||
|
||||
process.env.ENABLE_FS_WATCH = 'yes';
|
||||
assert.equal(buildConfig().enableFsWatch, true);
|
||||
resetConfig();
|
||||
|
||||
process.env.ENABLE_FS_WATCH = '0';
|
||||
assert.equal(buildConfig().enableFsWatch, false);
|
||||
resetConfig();
|
||||
});
|
||||
|
||||
it('nullish defaults for optional string fields', () => {
|
||||
process.env.MM_BOT_TOKEN = 'token';
|
||||
const cfg = buildConfig();
|
||||
assert.equal(cfg.toolLabelsFile, null);
|
||||
assert.equal(cfg.defaultChannel, null);
|
||||
});
|
||||
});
|
||||
57
test/unit/logger.test.js
Normal file
57
test/unit/logger.test.js
Normal file
@@ -0,0 +1,57 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Unit tests for logger.js
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { getLogger, sessionLogger, resetLogger } = require('../../src/logger');
|
||||
|
||||
describe('logger.js', () => {
|
||||
beforeEach(() => {
|
||||
resetLogger();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
});
|
||||
|
||||
it('getLogger() returns a pino logger', () => {
|
||||
const logger = getLogger();
|
||||
assert.ok(logger);
|
||||
assert.equal(typeof logger.info, 'function');
|
||||
assert.equal(typeof logger.warn, 'function');
|
||||
assert.equal(typeof logger.error, 'function');
|
||||
assert.equal(typeof logger.debug, 'function');
|
||||
});
|
||||
|
||||
it('getLogger() returns the same instance each time (singleton)', () => {
|
||||
const a = getLogger();
|
||||
const b = getLogger();
|
||||
assert.equal(a, b);
|
||||
});
|
||||
|
||||
it('respects LOG_LEVEL env var', () => {
|
||||
const original = process.env.LOG_LEVEL;
|
||||
process.env.LOG_LEVEL = 'warn';
|
||||
const logger = getLogger();
|
||||
assert.equal(logger.level, 'warn');
|
||||
process.env.LOG_LEVEL = original;
|
||||
resetLogger();
|
||||
});
|
||||
|
||||
it('sessionLogger() returns a child logger', () => {
|
||||
const child = sessionLogger('agent:main:test');
|
||||
assert.ok(child);
|
||||
assert.equal(typeof child.info, 'function');
|
||||
});
|
||||
|
||||
it('resetLogger() clears the singleton', () => {
|
||||
const a = getLogger();
|
||||
resetLogger();
|
||||
const b = getLogger();
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
});
|
||||
207
test/unit/status-formatter.test.js
Normal file
207
test/unit/status-formatter.test.js
Normal file
@@ -0,0 +1,207 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Unit tests for status-formatter.js
|
||||
*/
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
format,
|
||||
formatElapsed,
|
||||
formatTokens,
|
||||
statusIcon,
|
||||
truncateLine,
|
||||
extractAgentId,
|
||||
} = require('../../src/status-formatter');
|
||||
|
||||
const NOW = Date.now();
|
||||
|
||||
function makeState(overrides = {}) {
|
||||
return {
|
||||
sessionKey: 'agent:main:mattermost:channel:abc:thread:xyz',
|
||||
status: 'active',
|
||||
startTime: NOW - 38000, // 38s ago
|
||||
lines: [],
|
||||
children: [],
|
||||
agentId: 'main',
|
||||
depth: 0,
|
||||
tokenCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('status-formatter.js', () => {
|
||||
describe('format()', () => {
|
||||
it('formats active session with header', () => {
|
||||
const state = makeState();
|
||||
const result = format(state);
|
||||
assert.ok(result.includes('[ACTIVE]'));
|
||||
assert.ok(result.includes('main'));
|
||||
assert.ok(result.match(/\d+s/));
|
||||
});
|
||||
|
||||
it('formats done session with footer', () => {
|
||||
const state = makeState({ status: 'done' });
|
||||
const result = format(state);
|
||||
assert.ok(result.includes('[DONE]'));
|
||||
});
|
||||
|
||||
it('formats error session', () => {
|
||||
const state = makeState({ status: 'error' });
|
||||
const result = format(state);
|
||||
assert.ok(result.includes('[ERROR]'));
|
||||
});
|
||||
|
||||
it('formats interrupted session', () => {
|
||||
const state = makeState({ status: 'interrupted' });
|
||||
const result = format(state);
|
||||
assert.ok(result.includes('[INTERRUPTED]'));
|
||||
});
|
||||
|
||||
it('includes status lines', () => {
|
||||
const state = makeState({
|
||||
lines: ['Reading files...', ' exec: ls [OK]', 'Writing results...'],
|
||||
});
|
||||
const result = format(state);
|
||||
assert.ok(result.includes('Reading files...'));
|
||||
assert.ok(result.includes('exec: ls [OK]'));
|
||||
assert.ok(result.includes('Writing results...'));
|
||||
});
|
||||
|
||||
it('limits status lines to maxLines', () => {
|
||||
const lines = Array.from({ length: 30 }, (_, i) => `Line ${i + 1}`);
|
||||
const state = makeState({ lines });
|
||||
const result = format(state, { maxLines: 5 });
|
||||
// Only last 5 lines should appear
|
||||
assert.ok(result.includes('Line 26'));
|
||||
assert.ok(result.includes('Line 30'));
|
||||
assert.ok(!result.includes('Line 1'));
|
||||
});
|
||||
|
||||
it('includes token count in done footer', () => {
|
||||
const state = makeState({ status: 'done', tokenCount: 12400 });
|
||||
const result = format(state);
|
||||
assert.ok(result.includes('12.4k'));
|
||||
});
|
||||
|
||||
it('no token count in footer when zero', () => {
|
||||
const state = makeState({ status: 'done', tokenCount: 0 });
|
||||
const result = format(state);
|
||||
// Should not include "tokens" for zero count
|
||||
assert.ok(!result.includes('tokens'));
|
||||
});
|
||||
|
||||
it('renders nested child sessions', () => {
|
||||
const child = makeState({
|
||||
sessionKey: 'agent:main:subagent:uuid-1',
|
||||
agentId: 'proj035-planner',
|
||||
depth: 1,
|
||||
status: 'done',
|
||||
lines: ['Reading protocol...'],
|
||||
});
|
||||
const parent = makeState({
|
||||
lines: ['Starting plan...'],
|
||||
children: [child],
|
||||
});
|
||||
const result = format(parent);
|
||||
assert.ok(result.includes('proj035-planner'));
|
||||
assert.ok(result.includes('Reading protocol...'));
|
||||
// Child should be indented
|
||||
const childLine = result.split('\n').find((l) => l.includes('proj035-planner'));
|
||||
assert.ok(childLine && childLine.startsWith(' '));
|
||||
});
|
||||
|
||||
it('active session has no done footer', () => {
|
||||
const state = makeState({ status: 'active' });
|
||||
const result = format(state);
|
||||
const lines = result.split('\n');
|
||||
// No line should contain [DONE], [ERROR], [INTERRUPTED]
|
||||
assert.ok(!lines.some((l) => /\[(DONE|ERROR|INTERRUPTED)\]/.test(l)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatElapsed()', () => {
|
||||
it('formats seconds', () => {
|
||||
assert.equal(formatElapsed(0), '0s');
|
||||
assert.equal(formatElapsed(1000), '1s');
|
||||
assert.equal(formatElapsed(59000), '59s');
|
||||
});
|
||||
|
||||
it('formats minutes', () => {
|
||||
assert.equal(formatElapsed(60000), '1m0s');
|
||||
assert.equal(formatElapsed(90000), '1m30s');
|
||||
assert.equal(formatElapsed(3599000), '59m59s');
|
||||
});
|
||||
|
||||
it('formats hours', () => {
|
||||
assert.equal(formatElapsed(3600000), '1h0m');
|
||||
assert.equal(formatElapsed(7260000), '2h1m');
|
||||
});
|
||||
|
||||
it('handles negative values', () => {
|
||||
assert.equal(formatElapsed(-1000), '0s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTokens()', () => {
|
||||
it('formats small counts', () => {
|
||||
assert.equal(formatTokens(0), '0');
|
||||
assert.equal(formatTokens(999), '999');
|
||||
});
|
||||
|
||||
it('formats thousands', () => {
|
||||
assert.equal(formatTokens(1000), '1.0k');
|
||||
assert.equal(formatTokens(12400), '12.4k');
|
||||
assert.equal(formatTokens(999900), '999.9k');
|
||||
});
|
||||
|
||||
it('formats millions', () => {
|
||||
assert.equal(formatTokens(1000000), '1.0M');
|
||||
assert.equal(formatTokens(2500000), '2.5M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusIcon()', () => {
|
||||
it('returns correct icons', () => {
|
||||
assert.equal(statusIcon('active'), '[ACTIVE]');
|
||||
assert.equal(statusIcon('done'), '[DONE]');
|
||||
assert.equal(statusIcon('error'), '[ERROR]');
|
||||
assert.equal(statusIcon('interrupted'), '[INTERRUPTED]');
|
||||
assert.equal(statusIcon('unknown'), '[UNKNOWN]');
|
||||
assert.equal(statusIcon(''), '[UNKNOWN]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateLine()', () => {
|
||||
it('does not truncate short lines', () => {
|
||||
const line = 'Short line';
|
||||
assert.equal(truncateLine(line), line);
|
||||
});
|
||||
|
||||
it('truncates long lines', () => {
|
||||
const line = 'x'.repeat(200);
|
||||
const result = truncateLine(line);
|
||||
assert.ok(result.length <= 120);
|
||||
assert.ok(result.endsWith('...'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAgentId()', () => {
|
||||
it('extracts agent ID from session key', () => {
|
||||
assert.equal(extractAgentId('agent:main:mattermost:channel:abc'), 'main');
|
||||
assert.equal(extractAgentId('agent:coder-agent:session:123'), 'coder-agent');
|
||||
});
|
||||
|
||||
it('handles non-standard keys', () => {
|
||||
assert.equal(extractAgentId('main'), 'main');
|
||||
assert.equal(extractAgentId(''), 'unknown');
|
||||
});
|
||||
|
||||
it('handles null/undefined', () => {
|
||||
assert.equal(extractAgentId(null), 'unknown');
|
||||
assert.equal(extractAgentId(undefined), 'unknown');
|
||||
});
|
||||
});
|
||||
});
|
||||
185
test/unit/tool-labels.test.js
Normal file
185
test/unit/tool-labels.test.js
Normal file
@@ -0,0 +1,185 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Unit tests for tool-labels.js
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const { loadLabels, resolve, resetLabels } = require('../../src/tool-labels');
|
||||
|
||||
describe('tool-labels.js', () => {
|
||||
beforeEach(() => {
|
||||
resetLabels();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLabels();
|
||||
});
|
||||
|
||||
describe('exact match', () => {
|
||||
it('resolves known tools by exact name', () => {
|
||||
loadLabels(null);
|
||||
assert.equal(resolve('exec'), 'Running command...');
|
||||
assert.equal(resolve('Read'), 'Reading file...');
|
||||
assert.equal(resolve('Write'), 'Writing file...');
|
||||
assert.equal(resolve('Edit'), 'Editing file...');
|
||||
assert.equal(resolve('web_search'), 'Searching the web...');
|
||||
assert.equal(resolve('web_fetch'), 'Fetching URL...');
|
||||
assert.equal(resolve('message'), 'Sending message...');
|
||||
assert.equal(resolve('tts'), 'Generating speech...');
|
||||
assert.equal(resolve('subagents'), 'Managing sub-agents...');
|
||||
assert.equal(resolve('image'), 'Analyzing image...');
|
||||
assert.equal(resolve('process'), 'Managing process...');
|
||||
assert.equal(resolve('browser'), 'Controlling browser...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefix match', () => {
|
||||
it('resolves camofox_ tools via prefix', () => {
|
||||
loadLabels(null);
|
||||
assert.equal(resolve('camofox_create_tab'), 'Opening browser tab...'); // exact takes priority
|
||||
assert.equal(resolve('camofox_some_new_tool'), 'Using browser...');
|
||||
});
|
||||
|
||||
it('resolves claude_code_ tools via prefix', () => {
|
||||
loadLabels(null);
|
||||
assert.equal(resolve('claude_code_start'), 'Starting Claude Code task...'); // exact takes priority
|
||||
assert.equal(resolve('claude_code_something_new'), 'Running Claude Code...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default label', () => {
|
||||
it('returns default for unknown tools', () => {
|
||||
loadLabels(null);
|
||||
assert.equal(resolve('some_unknown_tool'), 'Working...');
|
||||
assert.equal(resolve(''), 'Working...');
|
||||
assert.equal(resolve('xyz'), 'Working...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('external override', () => {
|
||||
let tmpFile;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpFile = path.join(os.tmpdir(), `tool-labels-test-${Date.now()}.json`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.unlinkSync(tmpFile);
|
||||
} catch (_e) {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
it('external exact overrides built-in', () => {
|
||||
fs.writeFileSync(
|
||||
tmpFile,
|
||||
JSON.stringify({
|
||||
exact: { exec: 'Custom exec label...' },
|
||||
prefix: {},
|
||||
}),
|
||||
);
|
||||
loadLabels(tmpFile);
|
||||
assert.equal(resolve('exec'), 'Custom exec label...');
|
||||
// Non-overridden built-in still works
|
||||
assert.equal(resolve('Read'), 'Reading file...');
|
||||
});
|
||||
|
||||
it('external prefix adds new prefix', () => {
|
||||
fs.writeFileSync(
|
||||
tmpFile,
|
||||
JSON.stringify({
|
||||
exact: {},
|
||||
prefix: { my_tool_: 'My custom tool...' },
|
||||
}),
|
||||
);
|
||||
loadLabels(tmpFile);
|
||||
assert.equal(resolve('my_tool_do_something'), 'My custom tool...');
|
||||
});
|
||||
|
||||
it('external default overrides built-in default', () => {
|
||||
fs.writeFileSync(
|
||||
tmpFile,
|
||||
JSON.stringify({
|
||||
exact: {},
|
||||
prefix: {},
|
||||
default: 'Custom default...',
|
||||
}),
|
||||
);
|
||||
loadLabels(tmpFile);
|
||||
assert.equal(resolve('completely_unknown'), 'Custom default...');
|
||||
});
|
||||
|
||||
it('handles missing external file gracefully', () => {
|
||||
loadLabels('/nonexistent/path/tool-labels.json');
|
||||
// Should fall back to built-in
|
||||
assert.equal(resolve('exec'), 'Running command...');
|
||||
});
|
||||
|
||||
it('handles malformed external JSON gracefully', () => {
|
||||
fs.writeFileSync(tmpFile, 'not valid json {{{');
|
||||
loadLabels(tmpFile);
|
||||
// Should fall back to built-in
|
||||
assert.equal(resolve('exec'), 'Running command...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex match', () => {
|
||||
let tmpFile;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpFile = path.join(os.tmpdir(), `tool-labels-regex-${Date.now()}.json`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.unlinkSync(tmpFile);
|
||||
} catch (_e) {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves via regex pattern', () => {
|
||||
fs.writeFileSync(
|
||||
tmpFile,
|
||||
JSON.stringify({
|
||||
exact: {},
|
||||
prefix: {},
|
||||
regex: [{ pattern: '/^my_api_/', label: 'Calling API...' }],
|
||||
}),
|
||||
);
|
||||
loadLabels(tmpFile);
|
||||
assert.equal(resolve('my_api_create'), 'Calling API...');
|
||||
assert.equal(resolve('my_api_update'), 'Calling API...');
|
||||
assert.equal(resolve('other_tool'), 'Working...');
|
||||
});
|
||||
|
||||
it('handles invalid regex gracefully', () => {
|
||||
fs.writeFileSync(
|
||||
tmpFile,
|
||||
JSON.stringify({
|
||||
exact: {},
|
||||
prefix: {},
|
||||
regex: [{ pattern: '/[invalid(/', label: 'oops' }],
|
||||
}),
|
||||
);
|
||||
loadLabels(tmpFile);
|
||||
// Invalid regex skipped — returns default
|
||||
assert.equal(resolve('anything'), 'Working...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-load', () => {
|
||||
it('auto-loads built-in labels on first resolve call', () => {
|
||||
// resetLabels was called in beforeEach — no explicit loadLabels call
|
||||
const label = resolve('exec');
|
||||
assert.equal(label, 'Running command...');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user