import { describe, it, expect, beforeAll } from 'vitest'; import http from 'node:http'; import { isMcplocalRunning, mcpctl } from './mcp-client.js'; const MCPLOCAL_URL = process.env['MCPLOCAL_URL'] ?? 'http://localhost:3200'; let available = false; function fetchJson(urlPath: string, method = 'GET'): Promise { return new Promise((resolve) => { const req = http.request(`${MCPLOCAL_URL}${urlPath}`, { method, timeout: 5000 }, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString()) as T); } catch { resolve(null); } }); }); req.on('error', () => resolve(null)); req.on('timeout', () => { req.destroy(); resolve(null); }); req.end(); }); } beforeAll(async () => { available = await isMcplocalRunning(); }); interface CacheStats { rootDir: string; totalSize: number; totalEntries: number; namespaces: Array<{ name: string; entries: number; size: number }>; } interface ClearResult { removed: number; freedBytes: number; } describe('Cache smoke tests', () => { describe('mcplocal /cache endpoints', () => { it('GET /cache/stats returns valid stats structure', async () => { if (!available) return; const stats = await fetchJson('/cache/stats'); expect(stats).not.toBeNull(); expect(stats).toHaveProperty('rootDir'); expect(stats).toHaveProperty('totalSize'); expect(stats).toHaveProperty('totalEntries'); expect(stats).toHaveProperty('namespaces'); expect(Array.isArray(stats!.namespaces)).toBe(true); expect(typeof stats!.totalSize).toBe('number'); expect(typeof stats!.totalEntries).toBe('number'); }); it('namespaces use provider--model--proxymodel format', async () => { if (!available) return; const stats = await fetchJson('/cache/stats'); if (!stats || stats.namespaces.length === 0) return; // Each namespace should contain -- separators for (const ns of stats.namespaces) { expect(ns.name).toBeTruthy(); expect(typeof ns.entries).toBe('number'); expect(typeof ns.size).toBe('number'); } }); it('DELETE /cache returns clear result', async () => { if (!available) return; // This clears the cache, but it's non-destructive for a smoke test const result = await fetchJson('/cache', 'DELETE'); expect(result).not.toBeNull(); expect(result).toHaveProperty('removed'); expect(result).toHaveProperty('freedBytes'); expect(typeof result!.removed).toBe('number'); expect(typeof result!.freedBytes).toBe('number'); }); }); describe('mcpctl cache CLI', () => { it('mcpctl cache stats shows cache statistics', async () => { if (!available) return; const output = await mcpctl('cache stats'); // Should either show table or "Cache is empty." expect(output.length).toBeGreaterThan(0); const hasTable = output.includes('NAMESPACE'); const isEmpty = output.includes('Cache is empty'); expect(hasTable || isEmpty).toBe(true); }); it('mcpctl cache clear runs without error', async () => { if (!available) return; const output = await mcpctl('cache clear'); // Should report what was cleared, or that cache is empty expect(output).toMatch(/[Cc]lear|empty/i); }); }); describe('cache namespace isolation', () => { it('stats show separate namespaces per llm provider/model/proxymodel combo', async () => { if (!available) return; // After any project MCP sessions have run, check that namespaces // follow the provider--model--proxymodel convention const stats = await fetchJson('/cache/stats'); if (!stats || stats.namespaces.length === 0) return; // Namespaces with -- separators indicate proper isolation const separated = stats.namespaces.filter((ns) => ns.name.includes('--')); // If there are namespaces, at least some should have the separator format // (the 'dynamic' namespace from hot-swap is an exception) if (stats.namespaces.length > 1) { expect(separated.length).toBeGreaterThan(0); } }); }); });