test: bun backend + store/hardening unit tests (WIP, agents running)

This commit is contained in:
Hibryda 2026-03-22 05:02:02 +01:00
parent dd1d692e7b
commit e75f90407b
12 changed files with 2536 additions and 0 deletions

View file

@ -0,0 +1,312 @@
// Tests for Electrobun agent-store — pure logic and data flow.
// Uses bun:test. Mocks appRpc since store depends on RPC calls.
import { describe, it, expect, beforeEach, mock, spyOn } from 'bun:test';
// ── Replicated types ──────────────────────────────────────────────────────────
type AgentStatus = 'idle' | 'running' | 'done' | 'error';
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result' | 'thinking' | 'system';
interface AgentMessage {
id: string;
seqId: number;
role: MsgRole;
content: string;
toolName?: string;
toolInput?: string;
toolPath?: string;
timestamp: number;
}
interface AgentSession {
sessionId: string;
projectId: string;
provider: string;
status: AgentStatus;
messages: AgentMessage[];
costUsd: number;
inputTokens: number;
outputTokens: number;
model: string;
error?: string;
}
// ── Replicated pure functions ──────────────────────────────────────────────────
function normalizeStatus(status: string): AgentStatus {
if (status === 'running' || status === 'idle' || status === 'done' || status === 'error') {
return status;
}
return 'idle';
}
const BLOCKED_ENV_PREFIXES = ['CLAUDE', 'CODEX', 'OLLAMA', 'ANTHROPIC_'];
function validateExtraEnv(env: Record<string, string> | undefined): Record<string, string> | undefined {
if (!env) return undefined;
const clean: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
const blocked = BLOCKED_ENV_PREFIXES.some(p => key.startsWith(p));
if (blocked) continue;
clean[key] = value;
}
return Object.keys(clean).length > 0 ? clean : undefined;
}
function extractToolPath(name: string, input: Record<string, unknown> | undefined): string | undefined {
if (!input) return undefined;
if (typeof input.file_path === 'string') return input.file_path;
if (typeof input.path === 'string') return input.path;
if (name === 'Bash' && typeof input.command === 'string') {
return (input.command as string).length > 80 ? (input.command as string).slice(0, 80) + '...' : input.command as string;
}
return undefined;
}
function truncateOutput(text: string, maxLines: number): string {
const lines = text.split('\n');
if (lines.length <= maxLines) return text;
return lines.slice(0, maxLines).join('\n') + `\n... (${lines.length - maxLines} more lines)`;
}
// ── seqId monotonic counter ─────────────────────────────────────────────────
function createSeqCounter() {
const counters = new Map<string, number>();
return {
next(sessionId: string): number {
const current = counters.get(sessionId) ?? 0;
const next = current + 1;
counters.set(sessionId, next);
return next;
},
get(sessionId: string): number {
return counters.get(sessionId) ?? 0;
},
set(sessionId: string, value: number): void {
counters.set(sessionId, value);
},
};
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe('normalizeStatus', () => {
it('passes through valid statuses', () => {
expect(normalizeStatus('running')).toBe('running');
expect(normalizeStatus('idle')).toBe('idle');
expect(normalizeStatus('done')).toBe('done');
expect(normalizeStatus('error')).toBe('error');
});
it('normalizes unknown statuses to idle', () => {
expect(normalizeStatus('starting')).toBe('idle');
expect(normalizeStatus('unknown')).toBe('idle');
expect(normalizeStatus('')).toBe('idle');
});
});
describe('validateExtraEnv', () => {
it('returns undefined for undefined input', () => {
expect(validateExtraEnv(undefined)).toBeUndefined();
});
it('strips CLAUDE-prefixed keys', () => {
const result = validateExtraEnv({ CLAUDE_API_KEY: 'secret', MY_VAR: 'ok' });
expect(result).toEqual({ MY_VAR: 'ok' });
});
it('strips CODEX-prefixed keys', () => {
const result = validateExtraEnv({ CODEX_TOKEN: 'secret', SAFE: '1' });
expect(result).toEqual({ SAFE: '1' });
});
it('strips OLLAMA-prefixed keys', () => {
const result = validateExtraEnv({ OLLAMA_HOST: 'localhost', PATH: '/usr/bin' });
expect(result).toEqual({ PATH: '/usr/bin' });
});
it('strips ANTHROPIC_-prefixed keys', () => {
const result = validateExtraEnv({ ANTHROPIC_API_KEY: 'sk-xxx' });
expect(result).toBeUndefined(); // all stripped, empty → undefined
});
it('returns undefined when all keys blocked', () => {
const result = validateExtraEnv({ CLAUDE_KEY: 'a', CODEX_KEY: 'b' });
expect(result).toBeUndefined();
});
it('passes through safe keys', () => {
const result = validateExtraEnv({ BTMSG_AGENT_ID: 'manager', NODE_ENV: 'test' });
expect(result).toEqual({ BTMSG_AGENT_ID: 'manager', NODE_ENV: 'test' });
});
});
describe('seqId counter', () => {
it('starts at 1', () => {
const counter = createSeqCounter();
expect(counter.next('s1')).toBe(1);
});
it('increments monotonically', () => {
const counter = createSeqCounter();
expect(counter.next('s1')).toBe(1);
expect(counter.next('s1')).toBe(2);
expect(counter.next('s1')).toBe(3);
});
it('tracks separate sessions independently', () => {
const counter = createSeqCounter();
expect(counter.next('s1')).toBe(1);
expect(counter.next('s2')).toBe(1);
expect(counter.next('s1')).toBe(2);
expect(counter.next('s2')).toBe(2);
});
it('resumes from set value', () => {
const counter = createSeqCounter();
counter.set('s1', 42);
expect(counter.next('s1')).toBe(43);
});
});
describe('deduplication on restore', () => {
it('removes duplicate seqIds', () => {
const messages = [
{ msgId: 'a', seqId: 1, role: 'user', content: 'hello', timestamp: 1000 },
{ msgId: 'b', seqId: 2, role: 'assistant', content: 'hi', timestamp: 1001 },
{ msgId: 'a-dup', seqId: 1, role: 'user', content: 'hello', timestamp: 1002 }, // duplicate seqId
{ msgId: 'c', seqId: 3, role: 'assistant', content: 'ok', timestamp: 1003 },
];
const seqIdSet = new Set<number>();
const deduplicated: Array<{ msgId: string; seqId: number }> = [];
let maxSeqId = 0;
for (const m of messages) {
const sid = m.seqId ?? 0;
if (sid > 0 && seqIdSet.has(sid)) continue;
if (sid > 0) seqIdSet.add(sid);
if (sid > maxSeqId) maxSeqId = sid;
deduplicated.push({ msgId: m.msgId, seqId: sid });
}
expect(deduplicated).toHaveLength(3);
expect(maxSeqId).toBe(3);
expect(deduplicated.map(m => m.msgId)).toEqual(['a', 'b', 'c']);
});
it('counter resumes from max seqId after restore', () => {
const counter = createSeqCounter();
// Simulate restore
const maxSeqId = 15;
counter.set('s1', maxSeqId);
// Next should be 16
expect(counter.next('s1')).toBe(16);
});
});
describe('extractToolPath', () => {
it('extracts file_path from input', () => {
expect(extractToolPath('Read', { file_path: '/src/main.ts' })).toBe('/src/main.ts');
});
it('extracts path from input', () => {
expect(extractToolPath('Glob', { path: '/src', pattern: '*.ts' })).toBe('/src');
});
it('extracts command from Bash tool', () => {
expect(extractToolPath('Bash', { command: 'ls -la' })).toBe('ls -la');
});
it('truncates long Bash commands at 80 chars', () => {
const longCmd = 'a'.repeat(100);
const result = extractToolPath('Bash', { command: longCmd });
expect(result!.length).toBe(83); // 80 + '...'
});
it('returns undefined for unknown tool without paths', () => {
expect(extractToolPath('Custom', { data: 'value' })).toBeUndefined();
});
});
describe('truncateOutput', () => {
it('returns short text unchanged', () => {
expect(truncateOutput('hello\nworld', 10)).toBe('hello\nworld');
});
it('truncates text exceeding maxLines', () => {
const lines = Array.from({ length: 20 }, (_, i) => `line ${i}`);
const result = truncateOutput(lines.join('\n'), 5);
expect(result.split('\n')).toHaveLength(6); // 5 lines + truncation message
expect(result).toContain('15 more lines');
});
});
describe('double-start guard', () => {
it('startingProjects Set prevents concurrent starts', () => {
const startingProjects = new Set<string>();
// First start: succeeds
expect(startingProjects.has('proj-1')).toBe(false);
startingProjects.add('proj-1');
expect(startingProjects.has('proj-1')).toBe(true);
// Second start: blocked
const blocked = startingProjects.has('proj-1');
expect(blocked).toBe(true);
// After completion: removed
startingProjects.delete('proj-1');
expect(startingProjects.has('proj-1')).toBe(false);
});
});
describe('persistMessages — lastPersistedIndex', () => {
it('only saves new messages from lastPersistedIndex', () => {
const allMessages = [
{ id: 'm1', content: 'a' },
{ id: 'm2', content: 'b' },
{ id: 'm3', content: 'c' },
{ id: 'm4', content: 'd' },
];
const lastPersistedIndex = 2;
const newMsgs = allMessages.slice(lastPersistedIndex);
expect(newMsgs).toHaveLength(2);
expect(newMsgs[0].id).toBe('m3');
expect(newMsgs[1].id).toBe('m4');
});
it('returns empty when nothing new', () => {
const allMessages = [{ id: 'm1', content: 'a' }];
const lastPersistedIndex = 1;
const newMsgs = allMessages.slice(lastPersistedIndex);
expect(newMsgs).toHaveLength(0);
});
});
describe('loadLastSession — active session guard', () => {
it('skips restore if project has running session', () => {
const sessions: Record<string, AgentSession> = {
's1': {
sessionId: 's1', projectId: 'p1', provider: 'claude',
status: 'running', messages: [], costUsd: 0,
inputTokens: 0, outputTokens: 0, model: 'claude-opus-4-5',
},
};
const projectSessionMap = new Map([['p1', 's1']]);
const startingProjects = new Set<string>();
const existingSessionId = projectSessionMap.get('p1');
let shouldSkip = false;
if (existingSessionId) {
const existing = sessions[existingSessionId];
if (existing && (existing.status === 'running' || startingProjects.has('p1'))) {
shouldSkip = true;
}
}
expect(shouldSkip).toBe(true);
});
});