127 lines
4.3 KiB
TypeScript
127 lines
4.3 KiB
TypeScript
|
|
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<T>(urlPath: string, method = 'GET'): Promise<T | null> {
|
||
|
|
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<CacheStats>('/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<CacheStats>('/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<ClearResult>('/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<CacheStats>('/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);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|