import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { FileCache, parseMaxSize } from '../src/proxymodel/file-cache.js'; function makeTmpDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'mcpctl-cache-test-')); } function rmrf(dir: string): void { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ } } describe('FileCache', () => { let tmpDir: string; beforeEach(() => { tmpDir = makeTmpDir(); }); afterEach(() => { rmrf(tmpDir); }); // -- Basic get/set -- it('returns null on cache miss', async () => { const cache = new FileCache('test-ns', { dir: tmpDir }); expect(await cache.get('missing')).toBeNull(); }); it('stores and retrieves values', async () => { const cache = new FileCache('test-ns', { dir: tmpDir }); await cache.set('key1', 'hello world'); expect(await cache.get('key1')).toBe('hello world'); }); it('getOrCompute computes on miss, returns cached on hit', async () => { const cache = new FileCache('test-ns', { dir: tmpDir }); let calls = 0; const compute = async () => { calls++; return 'computed'; }; const v1 = await cache.getOrCompute('k', compute); const v2 = await cache.getOrCompute('k', compute); expect(v1).toBe('computed'); expect(v2).toBe('computed'); expect(calls).toBe(1); }); it('hash produces consistent 16-char hex strings', () => { const cache = new FileCache('test-ns', { dir: tmpDir }); const h1 = cache.hash('hello'); const h2 = cache.hash('hello'); const h3 = cache.hash('world'); expect(h1).toBe(h2); expect(h1).not.toBe(h3); expect(h1).toHaveLength(16); expect(/^[0-9a-f]+$/.test(h1)).toBe(true); }); // -- Persistence (L2 disk) -- it('persists values to disk across instances', async () => { const cache1 = new FileCache('persist-ns', { dir: tmpDir }); await cache1.set('pk', 'persistent-value'); // New instance, same namespace — should find it on disk const cache2 = new FileCache('persist-ns', { dir: tmpDir }); expect(await cache2.get('pk')).toBe('persistent-value'); }); it('creates .dat files on disk', async () => { const cache = new FileCache('disk-ns', { dir: tmpDir }); await cache.set('mykey', 'data'); const nsDir = path.join(tmpDir, 'disk-ns'); const files = fs.readdirSync(nsDir).filter((f) => f.endsWith('.dat')); expect(files.length).toBe(1); }); // -- Namespace isolation -- it('different namespaces are isolated', async () => { const cacheA = new FileCache('ns-alpha', { dir: tmpDir }); const cacheB = new FileCache('ns-beta', { dir: tmpDir }); await cacheA.set('shared-key', 'alpha-value'); await cacheB.set('shared-key', 'beta-value'); expect(await cacheA.get('shared-key')).toBe('alpha-value'); expect(await cacheB.get('shared-key')).toBe('beta-value'); }); it('provider--model--proxymodel namespaces are separate', async () => { const ns1 = 'openai--gpt-4o--content-pipeline'; const ns2 = 'anthropic--claude-sonnet-4-20250514--content-pipeline'; const ns3 = 'openai--gpt-4o--default'; const c1 = new FileCache(ns1, { dir: tmpDir }); const c2 = new FileCache(ns2, { dir: tmpDir }); const c3 = new FileCache(ns3, { dir: tmpDir }); await c1.set('k', 'from-gpt4o-pipeline'); await c2.set('k', 'from-claude-pipeline'); await c3.set('k', 'from-gpt4o-default'); expect(await c1.get('k')).toBe('from-gpt4o-pipeline'); expect(await c2.get('k')).toBe('from-claude-pipeline'); expect(await c3.get('k')).toBe('from-gpt4o-default'); // Verify separate directories on disk const dirs = fs.readdirSync(tmpDir); expect(dirs.length).toBe(3); }); // -- L1 memory cache -- it('L1 memory cache has LRU eviction', async () => { const cache = new FileCache('lru-ns', { dir: tmpDir, maxMemoryEntries: 3 }); await cache.set('a', '1'); await cache.set('b', '2'); await cache.set('c', '3'); expect(cache.memorySize).toBe(3); await cache.set('d', '4'); expect(cache.memorySize).toBe(3); // 'a' evicted from memory but still on disk cache.clearMemory(); expect(await cache.get('a')).toBe('1'); // restored from disk }); it('get refreshes LRU position in memory', async () => { const cache = new FileCache('lru2-ns', { dir: tmpDir, maxMemoryEntries: 3 }); await cache.set('a', '1'); await cache.set('b', '2'); await cache.set('c', '3'); // Access 'a' to refresh it await cache.get('a'); // Adding 'd' should evict 'b' (now oldest), not 'a' await cache.set('d', '4'); // 'a' should still be in memory cache.clearMemory(); // All values still on disk regardless expect(await cache.get('b')).toBe('2'); }); it('clearMemory only clears L1, not disk', async () => { const cache = new FileCache('clear-ns', { dir: tmpDir }); await cache.set('k', 'val'); expect(cache.memorySize).toBe(1); cache.clearMemory(); expect(cache.memorySize).toBe(0); // Still on disk expect(await cache.get('k')).toBe('val'); expect(cache.memorySize).toBe(1); // re-loaded into L1 }); // -- Static: stats -- it('stats returns empty for non-existent dir', () => { const stats = FileCache.stats(path.join(tmpDir, 'nonexistent')); expect(stats.totalEntries).toBe(0); expect(stats.totalSize).toBe(0); expect(stats.namespaces).toHaveLength(0); }); it('stats reports per-namespace breakdown', async () => { const c1 = new FileCache('ns-one', { dir: tmpDir }); const c2 = new FileCache('ns-two', { dir: tmpDir }); await c1.set('a', 'hello'); await c1.set('b', 'world'); await c2.set('x', 'data'); const stats = FileCache.stats(tmpDir); expect(stats.totalEntries).toBe(3); expect(stats.namespaces).toHaveLength(2); const one = stats.namespaces.find((ns) => ns.name === 'ns-one'); const two = stats.namespaces.find((ns) => ns.name === 'ns-two'); expect(one?.entries).toBe(2); expect(two?.entries).toBe(1); expect(stats.totalSize).toBeGreaterThan(0); }); // -- Static: clear -- it('clear removes all entries', async () => { const c = new FileCache('clear-all', { dir: tmpDir }); await c.set('a', '1'); await c.set('b', '2'); const result = FileCache.clear({ rootDir: tmpDir }); expect(result.removed).toBe(2); expect(result.freedBytes).toBeGreaterThan(0); const stats = FileCache.stats(tmpDir); expect(stats.totalEntries).toBe(0); }); it('clear with namespace only removes that namespace', async () => { const c1 = new FileCache('keep-me', { dir: tmpDir }); const c2 = new FileCache('delete-me', { dir: tmpDir }); await c1.set('a', '1'); await c2.set('b', '2'); const result = FileCache.clear({ rootDir: tmpDir, namespace: 'delete-me' }); expect(result.removed).toBe(1); const stats = FileCache.stats(tmpDir); expect(stats.totalEntries).toBe(1); const withEntries = stats.namespaces.filter((ns) => ns.entries > 0); expect(withEntries).toHaveLength(1); expect(withEntries[0].name).toBe('keep-me'); }); // -- Static: cleanup (TTL + size limit) -- it('cleanup evicts entries exceeding maxSizeBytes', async () => { // Create entries that exceed a 50-byte limit const c = new FileCache('big-ns', { dir: tmpDir }); await c.set('a', 'x'.repeat(30)); await c.set('b', 'y'.repeat(30)); await c.set('c', 'z'.repeat(30)); const before = FileCache.stats(tmpDir); expect(before.totalEntries).toBe(3); // Cleanup with 50-byte limit (well below 90 bytes of content) const result = FileCache.cleanup(tmpDir, 50, 365 * 24 * 60 * 60 * 1000); expect(result.removed).toBeGreaterThan(0); const after = FileCache.stats(tmpDir); expect(after.totalSize).toBeLessThanOrEqual(50); }); // -- Keys with special characters -- it('handles keys with colons and special chars', async () => { const cache = new FileCache('special-ns', { dir: tmpDir }); await cache.set('summary:abc123:200', 'summarized content'); expect(await cache.get('summary:abc123:200')).toBe('summarized content'); }); it('handles very long keys', async () => { const cache = new FileCache('long-ns', { dir: tmpDir }); const longKey = 'a'.repeat(500); await cache.set(longKey, 'value'); expect(await cache.get(longKey)).toBe('value'); }); }); // -- parseMaxSize -- describe('parseMaxSize', () => { it('passes through numbers directly', () => { expect(parseMaxSize(1024)).toBe(1024); expect(parseMaxSize(0)).toBe(0); }); it('parses byte units', () => { expect(parseMaxSize('100B')).toBe(100); expect(parseMaxSize('1KB')).toBe(1024); expect(parseMaxSize('256MB')).toBe(256 * 1024 * 1024); expect(parseMaxSize('1GB')).toBe(1024 * 1024 * 1024); expect(parseMaxSize('2TB')).toBe(2 * 1024 * 1024 * 1024 * 1024); }); it('handles fractional values', () => { expect(parseMaxSize('1.5GB')).toBe(Math.floor(1.5 * 1024 * 1024 * 1024)); expect(parseMaxSize('0.5MB')).toBe(Math.floor(0.5 * 1024 * 1024)); }); it('is case-insensitive', () => { expect(parseMaxSize('256mb')).toBe(256 * 1024 * 1024); expect(parseMaxSize('1gb')).toBe(1024 * 1024 * 1024); expect(parseMaxSize('1Gb')).toBe(1024 * 1024 * 1024); }); it('trims whitespace', () => { expect(parseMaxSize(' 256MB ')).toBe(256 * 1024 * 1024); expect(parseMaxSize(' 1 GB ')).toBe(1024 * 1024 * 1024); }); it('parses plain number strings', () => { expect(parseMaxSize('1048576')).toBe(1048576); }); it('parses percentage (resolves against filesystem)', () => { // We can't predict the exact value, but it should be a positive number const result = parseMaxSize('10%', '/tmp'); expect(result).toBeGreaterThan(0); expect(typeof result).toBe('number'); }); it('percentage of 100% equals full partition', () => { const full = parseMaxSize('100%', '/tmp'); const half = parseMaxSize('50%', '/tmp'); // 50% should be roughly half of 100% (within rounding) expect(Math.abs(half - full / 2)).toBeLessThan(1024); }); it('throws on invalid specs', () => { expect(() => parseMaxSize('abc')).toThrow(); expect(() => parseMaxSize('')).toThrow(); expect(() => parseMaxSize('0%')).toThrow(); expect(() => parseMaxSize('101%')).toThrow(); expect(() => parseMaxSize('-5MB')).toThrow(); }); }); // -- Namespace isolation for LLM provider/model/proxymodel combos -- describe('FileCache namespace isolation', () => { let tmpDir: string; beforeEach(() => { tmpDir = makeTmpDir(); }); afterEach(() => { rmrf(tmpDir); }); const combos = [ { provider: 'openai', model: 'gpt-4o', proxyModel: 'content-pipeline' }, { provider: 'openai', model: 'gpt-4o-mini', proxyModel: 'content-pipeline' }, { provider: 'anthropic', model: 'claude-sonnet-4-20250514', proxyModel: 'content-pipeline' }, { provider: 'openai', model: 'gpt-4o', proxyModel: 'default' }, { provider: 'vllm', model: 'qwen-72b', proxyModel: 'content-pipeline' }, ]; it('each provider--model--proxymodel combo gets its own cache', async () => { const caches = combos.map( (c) => new FileCache(`${c.provider}--${c.model}--${c.proxyModel}`, { dir: tmpDir }), ); // Write same key with different values to each cache for (let i = 0; i < caches.length; i++) { await caches[i].set('summary-key', `value-from-combo-${i}`); } // Each reads back its own value for (let i = 0; i < caches.length; i++) { expect(await caches[i].get('summary-key')).toBe(`value-from-combo-${i}`); } // Verify stats show correct number of namespaces const stats = FileCache.stats(tmpDir); expect(stats.namespaces).toHaveLength(combos.length); expect(stats.totalEntries).toBe(combos.length); }); it('changing only the model creates a separate cache', async () => { const c1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir }); const c2 = new FileCache('openai--gpt-4o-mini--content-pipeline', { dir: tmpDir }); await c1.set('k', 'gpt4o-result'); await c2.set('k', 'mini-result'); expect(await c1.get('k')).toBe('gpt4o-result'); expect(await c2.get('k')).toBe('mini-result'); }); it('changing only the provider creates a separate cache', async () => { const c1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir }); const c2 = new FileCache('anthropic--gpt-4o--content-pipeline', { dir: tmpDir }); await c1.set('k', 'openai-result'); await c2.set('k', 'anthropic-result'); expect(await c1.get('k')).toBe('openai-result'); expect(await c2.get('k')).toBe('anthropic-result'); }); it('changing only the proxyModel creates a separate cache', async () => { const c1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir }); const c2 = new FileCache('openai--gpt-4o--default', { dir: tmpDir }); await c1.set('k', 'pipeline-result'); await c2.set('k', 'default-result'); expect(await c1.get('k')).toBe('pipeline-result'); expect(await c2.get('k')).toBe('default-result'); }); it('clearing one namespace leaves others intact', async () => { const c1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir }); const c2 = new FileCache('anthropic--claude-sonnet-4-20250514--content-pipeline', { dir: tmpDir }); await c1.set('k', 'v1'); await c2.set('k', 'v2'); FileCache.clear({ rootDir: tmpDir, namespace: 'openai--gpt-4o--content-pipeline' }); // c1's namespace cleared, c2 intact const fresh1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir }); const fresh2 = new FileCache('anthropic--claude-sonnet-4-20250514--content-pipeline', { dir: tmpDir }); expect(await fresh1.get('k')).toBeNull(); expect(await fresh2.get('k')).toBe('v2'); }); });