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,288 @@
// Tests for @agor/stores health store
// The store uses Svelte 5 runes — we replicate and test the pure logic.
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Replicated types from health.svelte.ts ──────────────────────────────────
type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled';
interface ProjectHealth {
projectId: string;
activityState: ActivityState;
activeTool: string | null;
idleDurationMs: number;
burnRatePerHour: number;
contextPressure: number | null;
fileConflictCount: number;
attentionScore: number;
attentionReason: string | null;
}
// ── Replicated pure functions from health.svelte.ts (Electrobun version) ────
const DEFAULT_STALL_THRESHOLD_MS = 15 * 60 * 1000;
const BURN_RATE_WINDOW_MS = 5 * 60 * 1000;
const DEFAULT_CONTEXT_LIMIT = 200_000;
function scoreAttention(
activityState: ActivityState,
contextPressure: number | null,
fileConflictCount: number,
status: string,
): { score: number; reason: string | null } {
if (status === 'error') return { score: 90, reason: 'Agent error' };
if (activityState === 'stalled') return { score: 100, reason: 'Agent stalled (>15 min)' };
if (contextPressure !== null && contextPressure > 0.9) return { score: 80, reason: 'Context >90%' };
if (fileConflictCount > 0) return { score: 70, reason: `${fileConflictCount} file conflict(s)` };
if (contextPressure !== null && contextPressure > 0.75) return { score: 40, reason: 'Context >75%' };
return { score: 0, reason: null };
}
function computeBurnRate(snapshots: Array<[number, number]>): number {
if (snapshots.length < 2) return 0;
const now = Date.now();
const windowStart = now - BURN_RATE_WINDOW_MS;
const recent = snapshots.filter(([ts]) => ts >= windowStart);
if (recent.length < 2) return 0;
const first = recent[0];
const last = recent[recent.length - 1];
const elapsedHours = (last[0] - first[0]) / 3_600_000;
if (elapsedHours < 0.001) return 0;
const costDelta = last[1] - first[1];
return Math.max(0, costDelta / elapsedHours);
}
// Simplified tracker for testing
interface ProjectTracker {
projectId: string;
lastActivityTs: number;
lastToolName: string | null;
toolsInFlight: number;
activeToolMap: Map<string, { startTime: number; count: number }>;
costSnapshots: Array<[number, number]>;
totalTokens: number;
totalCost: number;
status: 'inactive' | 'running' | 'idle' | 'done' | 'error';
}
function makeTracker(overrides: Partial<ProjectTracker> = {}): ProjectTracker {
return {
projectId: 'test-project',
lastActivityTs: Date.now(),
lastToolName: null,
toolsInFlight: 0,
activeToolMap: new Map(),
costSnapshots: [],
totalTokens: 0,
totalCost: 0,
status: 'inactive',
...overrides,
};
}
function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
let activityState: ActivityState;
let idleDurationMs = 0;
let activeTool: string | null = null;
if (tracker.status === 'inactive' || tracker.status === 'done' || tracker.status === 'error') {
activityState = 'inactive';
} else if (tracker.toolsInFlight > 0) {
activityState = 'running';
activeTool = tracker.lastToolName;
} else {
idleDurationMs = now - tracker.lastActivityTs;
activityState = idleDurationMs >= DEFAULT_STALL_THRESHOLD_MS ? 'stalled' : 'idle';
}
let contextPressure: number | null = null;
if (tracker.totalTokens > 0) {
contextPressure = Math.min(1, tracker.totalTokens / DEFAULT_CONTEXT_LIMIT);
}
const burnRatePerHour = computeBurnRate(tracker.costSnapshots);
const attention = scoreAttention(activityState, contextPressure, 0, tracker.status);
return {
projectId: tracker.projectId,
activityState,
activeTool,
idleDurationMs,
burnRatePerHour,
contextPressure,
fileConflictCount: 0,
attentionScore: attention.score,
attentionReason: attention.reason,
};
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe('scoreAttention', () => {
it('returns 0 when no attention needed', () => {
const r = scoreAttention('inactive', null, 0, 'inactive');
expect(r.score).toBe(0);
expect(r.reason).toBeNull();
});
it('scores stalled highest (100)', () => {
const r = scoreAttention('stalled', null, 0, 'running');
expect(r.score).toBe(100);
expect(r.reason).toContain('stalled');
});
it('scores error at 90', () => {
const r = scoreAttention('inactive', null, 0, 'error');
expect(r.score).toBe(90);
expect(r.reason).toContain('error');
});
it('scores context >90% at 80', () => {
const r = scoreAttention('running', 0.95, 0, 'running');
expect(r.score).toBe(80);
expect(r.reason).toContain('90%');
});
it('scores file conflict at 70', () => {
const r = scoreAttention('running', 0.5, 2, 'running');
expect(r.score).toBe(70);
expect(r.reason).toContain('2 file conflict');
});
it('scores context >75% at 40', () => {
const r = scoreAttention('running', 0.8, 0, 'running');
expect(r.score).toBe(40);
expect(r.reason).toContain('75%');
});
it('priority: stalled beats context pressure', () => {
const r = scoreAttention('stalled', 0.95, 3, 'running');
expect(r.score).toBe(100);
});
});
describe('computeBurnRate', () => {
it('returns 0 with fewer than 2 snapshots', () => {
expect(computeBurnRate([])).toBe(0);
expect(computeBurnRate([[Date.now(), 1.0]])).toBe(0);
});
it('calculates $/hr from cost snapshots', () => {
const now = Date.now();
const snapshots: Array<[number, number]> = [
[now - 60_000, 0.50], // 1 min ago, $0.50
[now, 1.50], // now, $1.50
];
const rate = computeBurnRate(snapshots);
// $1.00 over 1 minute = $60/hr
expect(rate).toBeCloseTo(60, 0);
});
it('returns 0 when snapshots outside window', () => {
const now = Date.now();
const snapshots: Array<[number, number]> = [
[now - BURN_RATE_WINDOW_MS - 10_000, 0.50],
[now - BURN_RATE_WINDOW_MS - 5_000, 1.50],
];
expect(computeBurnRate(snapshots)).toBe(0);
});
});
describe('computeHealth', () => {
it('inactive tracker returns inactive state', () => {
const tracker = makeTracker({ status: 'inactive' });
const h = computeHealth(tracker, Date.now());
expect(h.activityState).toBe('inactive');
expect(h.attentionScore).toBe(0);
});
it('running tracker with tool in flight returns running state', () => {
const tracker = makeTracker({ status: 'running', toolsInFlight: 1, lastToolName: 'Bash' });
const h = computeHealth(tracker, Date.now());
expect(h.activityState).toBe('running');
expect(h.activeTool).toBe('Bash');
});
it('detects stall when idle exceeds threshold', () => {
const stalledTs = Date.now() - DEFAULT_STALL_THRESHOLD_MS - 1000;
const tracker = makeTracker({ status: 'running', lastActivityTs: stalledTs });
const h = computeHealth(tracker, Date.now());
expect(h.activityState).toBe('stalled');
expect(h.attentionScore).toBe(100);
});
it('calculates context pressure from tokens', () => {
const tracker = makeTracker({ status: 'running', totalTokens: 180_000, toolsInFlight: 1 });
const h = computeHealth(tracker, Date.now());
expect(h.contextPressure).toBeCloseTo(0.9, 2);
});
it('context pressure capped at 1.0', () => {
const tracker = makeTracker({ status: 'running', totalTokens: 300_000, toolsInFlight: 1 });
const h = computeHealth(tracker, Date.now());
expect(h.contextPressure).toBe(1.0);
});
it('toolsInFlight counter tracks concurrent tools', () => {
const tracker = makeTracker({ status: 'running', toolsInFlight: 3, lastToolName: 'Read' });
const h = computeHealth(tracker, Date.now());
expect(h.activityState).toBe('running');
expect(h.activeTool).toBe('Read');
});
});
describe('tool histogram logic', () => {
it('activeToolMap tracks per-tool starts', () => {
const tracker = makeTracker({ status: 'running' });
// Simulate recordActivity with toolName
tracker.activeToolMap.set('Bash', { startTime: Date.now(), count: 1 });
tracker.toolsInFlight = 1;
tracker.lastToolName = 'Bash';
expect(tracker.activeToolMap.size).toBe(1);
expect(tracker.activeToolMap.get('Bash')!.count).toBe(1);
});
it('incrementing count for same tool', () => {
const tracker = makeTracker({ status: 'running' });
tracker.activeToolMap.set('Bash', { startTime: Date.now(), count: 1 });
// Second Bash call
const entry = tracker.activeToolMap.get('Bash')!;
entry.count++;
tracker.toolsInFlight = 2;
expect(entry.count).toBe(2);
expect(tracker.toolsInFlight).toBe(2);
});
it('decrement removes entry when count reaches 0', () => {
const tracker = makeTracker({ status: 'running' });
tracker.activeToolMap.set('Read', { startTime: Date.now(), count: 1 });
tracker.toolsInFlight = 1;
// Simulate recordToolDone
tracker.toolsInFlight = Math.max(0, tracker.toolsInFlight - 1);
const entry = tracker.activeToolMap.get('Read')!;
entry.count--;
if (entry.count <= 0) tracker.activeToolMap.delete('Read');
expect(tracker.activeToolMap.size).toBe(0);
expect(tracker.toolsInFlight).toBe(0);
});
});
describe('attention queue sorting', () => {
it('sorts by score descending', () => {
const trackers = [
makeTracker({ projectId: 'a', status: 'running', totalTokens: 180_000, toolsInFlight: 1 }), // ctx 90%
makeTracker({ projectId: 'b', status: 'error' }), // error
makeTracker({ projectId: 'c', status: 'inactive' }), // no attention
];
const now = Date.now();
const healths = trackers.map(t => computeHealth(t, now));
const queue = healths
.filter(h => h.attentionScore > 0)
.sort((a, b) => b.attentionScore - a.attentionScore);
expect(queue).toHaveLength(2);
expect(queue[0].projectId).toBe('b'); // error = 90
expect(queue[1].projectId).toBe('a'); // ctx 90% = 80
});
});

View file

@ -0,0 +1,129 @@
// Tests for @agor/stores notification store
// The store uses Svelte 5 runes ($state) so we can't import directly in vitest.
// We test the pure logic and types that can be validated without the Svelte compiler.
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the backend module
vi.mock('../../../src/lib/backend/backend', () => ({
getBackend: vi.fn(() => ({
sendDesktopNotification: vi.fn(),
})),
}));
// Since the store uses $state (Svelte 5 rune), we cannot import it directly.
// Instead we replicate the pure functions and test them.
// ── Replicated pure logic from notifications.svelte.ts ──────────────────────
type ToastType = 'info' | 'success' | 'warning' | 'error';
type NotificationType = 'agent_complete' | 'agent_error' | 'task_review' | 'wake_event' | 'conflict' | 'system';
function notificationTypeToToast(type: NotificationType): ToastType {
switch (type) {
case 'agent_complete': return 'success';
case 'agent_error': return 'error';
case 'task_review': return 'info';
case 'wake_event': return 'info';
case 'conflict': return 'warning';
case 'system': return 'info';
}
}
function notificationUrgency(type: NotificationType): 'low' | 'normal' | 'critical' {
switch (type) {
case 'agent_error': return 'critical';
case 'conflict': return 'normal';
case 'system': return 'normal';
default: return 'low';
}
}
// Rate limiter logic (replicated)
const RATE_LIMIT_WINDOW_MS = 30_000;
const RATE_LIMIT_MAX_PER_TYPE = 3;
function createRateLimiter() {
const recentToasts = new Map<ToastType, number[]>();
return {
isRateLimited(type: ToastType, now = Date.now()): boolean {
const timestamps = recentToasts.get(type) ?? [];
const recent = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
recentToasts.set(type, recent);
if (recent.length >= RATE_LIMIT_MAX_PER_TYPE) return true;
recent.push(now);
return false;
},
};
}
describe('notification type mapping', () => {
it('maps agent_complete to success toast', () => {
expect(notificationTypeToToast('agent_complete')).toBe('success');
});
it('maps agent_error to error toast', () => {
expect(notificationTypeToToast('agent_error')).toBe('error');
});
it('maps conflict to warning toast', () => {
expect(notificationTypeToToast('conflict')).toBe('warning');
});
it('maps system to info toast', () => {
expect(notificationTypeToToast('system')).toBe('info');
});
});
describe('notification urgency', () => {
it('agent_error is critical', () => {
expect(notificationUrgency('agent_error')).toBe('critical');
});
it('conflict is normal', () => {
expect(notificationUrgency('conflict')).toBe('normal');
});
it('task_review is low', () => {
expect(notificationUrgency('task_review')).toBe('low');
});
});
describe('rate limiter', () => {
it('allows first 3 toasts of same type', () => {
const rl = createRateLimiter();
const now = Date.now();
expect(rl.isRateLimited('info', now)).toBe(false);
expect(rl.isRateLimited('info', now + 1)).toBe(false);
expect(rl.isRateLimited('info', now + 2)).toBe(false);
});
it('blocks 4th toast of same type within window', () => {
const rl = createRateLimiter();
const now = Date.now();
rl.isRateLimited('error', now);
rl.isRateLimited('error', now + 1);
rl.isRateLimited('error', now + 2);
expect(rl.isRateLimited('error', now + 3)).toBe(true);
});
it('allows toast of different type', () => {
const rl = createRateLimiter();
const now = Date.now();
rl.isRateLimited('error', now);
rl.isRateLimited('error', now + 1);
rl.isRateLimited('error', now + 2);
// Different type should not be rate limited
expect(rl.isRateLimited('info', now + 3)).toBe(false);
});
it('resets after window expires', () => {
const rl = createRateLimiter();
const now = Date.now();
rl.isRateLimited('warning', now);
rl.isRateLimited('warning', now + 1);
rl.isRateLimited('warning', now + 2);
// After window expires, should allow again
expect(rl.isRateLimited('warning', now + RATE_LIMIT_WINDOW_MS + 1)).toBe(false);
});
});

View file

@ -0,0 +1,85 @@
// Tests for @agor/stores theme store
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the settings-store module that theme.svelte.ts imports
vi.mock('../../../src/lib/stores/settings-store.svelte', () => ({
getSetting: vi.fn().mockResolvedValue(null),
setSetting: vi.fn().mockResolvedValue(undefined),
}));
// Mock the styles/themes module — no DOM needed
vi.mock('../../../src/lib/styles/themes', () => {
const ALL_THEME_IDS = [
'mocha', 'macchiato', 'frappe', 'latte',
'vscode-dark', 'atom-one-dark', 'monokai', 'dracula',
'nord', 'solarized-dark', 'github-dark',
'tokyo-night', 'gruvbox-dark', 'ayu-dark', 'poimandres',
'vesper', 'midnight',
];
return {
ALL_THEME_IDS,
THEME_LIST: ALL_THEME_IDS.map(id => ({ id, label: id, group: 'test', isDark: true })),
applyCssVariables: vi.fn(),
applyPaletteDirect: vi.fn(),
buildXtermTheme: vi.fn(() => ({ background: '#000', foreground: '#fff' })),
buildXtermThemeFromPalette: vi.fn(() => ({ background: '#111', foreground: '#eee' })),
};
});
// Mock custom-themes dynamic import
vi.mock('../../../src/lib/styles/custom-themes', () => ({
loadCustomThemes: vi.fn().mockResolvedValue([]),
}));
// Mock handle-error
vi.mock('../../../src/lib/utils/handle-error', () => ({
handleInfraError: vi.fn(),
}));
import { getSetting, setSetting } from '../../../src/lib/stores/settings-store.svelte';
import { applyCssVariables } from '../../../src/lib/styles/themes';
// We can't import from .svelte.ts in vitest without svelte compiler.
// Test the exported functions indirectly by validating the mocked dependencies.
// Instead, test the module's data/logic we CAN import:
import { ALL_THEME_IDS, THEME_LIST } from '../../../src/lib/styles/themes';
describe('theme store — data integrity', () => {
it('ALL_THEME_IDS has exactly 17 entries', () => {
expect(ALL_THEME_IDS).toHaveLength(17);
});
it('THEME_LIST has 17 entries matching ALL_THEME_IDS', () => {
expect(THEME_LIST).toHaveLength(17);
for (const meta of THEME_LIST) {
expect(ALL_THEME_IDS).toContain(meta.id);
}
});
it('all 4 Catppuccin flavors are present', () => {
const catFlavors = ['mocha', 'macchiato', 'frappe', 'latte'];
for (const f of catFlavors) {
expect(ALL_THEME_IDS).toContain(f);
}
});
it('deep dark themes are present', () => {
const deepDark = ['tokyo-night', 'gruvbox-dark', 'ayu-dark', 'poimandres', 'vesper', 'midnight'];
for (const t of deepDark) {
expect(ALL_THEME_IDS).toContain(t);
}
});
it('editor themes are present', () => {
const editors = ['vscode-dark', 'atom-one-dark', 'monokai', 'dracula', 'nord', 'solarized-dark', 'github-dark'];
for (const t of editors) {
expect(ALL_THEME_IDS).toContain(t);
}
});
it('no duplicate theme IDs', () => {
const unique = new Set(ALL_THEME_IDS);
expect(unique.size).toBe(ALL_THEME_IDS.length);
});
});