test: bun backend + store/hardening unit tests (WIP, agents running)
This commit is contained in:
parent
dd1d692e7b
commit
e75f90407b
12 changed files with 2536 additions and 0 deletions
288
packages/stores/__tests__/health.test.ts
Normal file
288
packages/stores/__tests__/health.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
129
packages/stores/__tests__/notifications.test.ts
Normal file
129
packages/stores/__tests__/notifications.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
85
packages/stores/__tests__/theme.test.ts
Normal file
85
packages/stores/__tests__/theme.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue