import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as path from 'path'; import * as fs from 'fs'; import { ContextCache } from '../ts/context/context-cache.js'; import type { ICacheEntry } from '../ts/context/types.js'; const testProjectRoot = process.cwd(); const testCacheDir = path.join(testProjectRoot, '.nogit', 'test-cache'); // Helper to clean up test cache directory async function cleanupTestCache() { try { await fs.promises.rm(testCacheDir, { recursive: true, force: true }); } catch (error) { // Ignore if directory doesn't exist } } tap.test('ContextCache should create instance with default config', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); expect(cache).toBeInstanceOf(ContextCache); await cleanupTestCache(); }); tap.test('ContextCache.init should create cache directory', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); await cache.init(); // Check that cache directory was created const exists = await fs.promises.access(testCacheDir).then(() => true).catch(() => false); expect(exists).toEqual(true); await cleanupTestCache(); }); tap.test('ContextCache.set should store cache entry', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); await cache.init(); const testPath = path.join(testProjectRoot, 'package.json'); // Get actual file mtime for validation to work const stats = await fs.promises.stat(testPath); const fileMtime = Math.floor(stats.mtimeMs); const entry: ICacheEntry = { path: testPath, contents: 'test content', tokenCount: 100, mtime: fileMtime, cachedAt: Date.now() }; await cache.set(entry); const retrieved = await cache.get(testPath); expect(retrieved).toBeDefined(); expect(retrieved!.contents).toEqual('test content'); expect(retrieved!.tokenCount).toEqual(100); await cleanupTestCache(); }); tap.test('ContextCache.get should return null for non-existent entry', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); await cache.init(); const retrieved = await cache.get('/non/existent/path.ts'); expect(retrieved).toBeNull(); await cleanupTestCache(); }); tap.test('ContextCache.get should invalidate expired entries', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true, ttl: 1 // 1 second TTL }); await cache.init(); const testPath = path.join(testProjectRoot, 'test-file.ts'); const entry: ICacheEntry = { path: testPath, contents: 'test content', tokenCount: 100, mtime: Date.now(), cachedAt: Date.now() - 2000 // Cached 2 seconds ago (expired) }; await cache.set(entry); // Wait a bit to ensure expiration logic runs await new Promise(resolve => setTimeout(resolve, 100)); const retrieved = await cache.get(testPath); expect(retrieved).toBeNull(); // Should be expired await cleanupTestCache(); }); tap.test('ContextCache.get should invalidate entries when file mtime changes', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); await cache.init(); const testPath = path.join(testProjectRoot, 'package.json'); const stats = await fs.promises.stat(testPath); const oldMtime = Math.floor(stats.mtimeMs); const entry: ICacheEntry = { path: testPath, contents: 'test content', tokenCount: 100, mtime: oldMtime - 1000, // Old mtime (file has changed) cachedAt: Date.now() }; await cache.set(entry); const retrieved = await cache.get(testPath); expect(retrieved).toBeNull(); // Should be invalidated due to mtime mismatch await cleanupTestCache(); }); tap.test('ContextCache.has should check if file is cached and valid', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); await cache.init(); const testPath = path.join(testProjectRoot, 'package.json'); const stats = await fs.promises.stat(testPath); const entry: ICacheEntry = { path: testPath, contents: 'test content', tokenCount: 100, mtime: Math.floor(stats.mtimeMs), cachedAt: Date.now() }; await cache.set(entry); const hasIt = await cache.has(testPath); expect(hasIt).toEqual(true); const doesNotHaveIt = await cache.has('/non/existent/path.ts'); expect(doesNotHaveIt).toEqual(false); await cleanupTestCache(); }); tap.test('ContextCache.setMany should store multiple entries', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); await cache.init(); const entries: ICacheEntry[] = [ { path: '/test/file1.ts', contents: 'content 1', tokenCount: 100, mtime: Date.now(), cachedAt: Date.now() }, { path: '/test/file2.ts', contents: 'content 2', tokenCount: 200, mtime: Date.now(), cachedAt: Date.now() } ]; await cache.setMany(entries); const stats = cache.getStats(); expect(stats.entries).toBeGreaterThanOrEqual(2); await cleanupTestCache(); }); tap.test('ContextCache.getStats should return cache statistics', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); await cache.init(); const entry: ICacheEntry = { path: '/test/file.ts', contents: 'test content with some length', tokenCount: 100, mtime: Date.now(), cachedAt: Date.now() }; await cache.set(entry); const stats = cache.getStats(); expect(stats.entries).toEqual(1); expect(stats.totalSize).toBeGreaterThan(0); expect(stats.oldestEntry).toBeDefined(); expect(stats.newestEntry).toBeDefined(); await cleanupTestCache(); }); tap.test('ContextCache.clear should clear all entries', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); await cache.init(); const entry: ICacheEntry = { path: '/test/file.ts', contents: 'test content', tokenCount: 100, mtime: Date.now(), cachedAt: Date.now() }; await cache.set(entry); expect(cache.getStats().entries).toEqual(1); await cache.clear(); expect(cache.getStats().entries).toEqual(0); await cleanupTestCache(); }); tap.test('ContextCache.clearPaths should clear specific entries', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); await cache.init(); const entries: ICacheEntry[] = [ { path: '/test/file1.ts', contents: 'content 1', tokenCount: 100, mtime: Date.now(), cachedAt: Date.now() }, { path: '/test/file2.ts', contents: 'content 2', tokenCount: 200, mtime: Date.now(), cachedAt: Date.now() } ]; await cache.setMany(entries); expect(cache.getStats().entries).toEqual(2); await cache.clearPaths(['/test/file1.ts']); expect(cache.getStats().entries).toEqual(1); await cleanupTestCache(); }); tap.test('ContextCache should enforce max size by evicting oldest entries', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true, maxSize: 0.001 // Very small: 0.001 MB = 1KB }); await cache.init(); // Add entries that exceed the max size const largeContent = 'x'.repeat(500); // 500 bytes const entries: ICacheEntry[] = [ { path: '/test/file1.ts', contents: largeContent, tokenCount: 100, mtime: Date.now(), cachedAt: Date.now() - 3000 // Oldest }, { path: '/test/file2.ts', contents: largeContent, tokenCount: 100, mtime: Date.now(), cachedAt: Date.now() - 2000 }, { path: '/test/file3.ts', contents: largeContent, tokenCount: 100, mtime: Date.now(), cachedAt: Date.now() - 1000 // Newest } ]; await cache.setMany(entries); const stats = cache.getStats(); // Should have evicted oldest entries to stay under size limit expect(stats.totalSize).toBeLessThanOrEqual(1024); // 1KB await cleanupTestCache(); }); tap.test('ContextCache should not cache when disabled', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: false }); await cache.init(); const entry: ICacheEntry = { path: '/test/file.ts', contents: 'test content', tokenCount: 100, mtime: Date.now(), cachedAt: Date.now() }; await cache.set(entry); const retrieved = await cache.get('/test/file.ts'); expect(retrieved).toBeNull(); await cleanupTestCache(); }); tap.test('ContextCache should persist to disk and reload', async () => { await cleanupTestCache(); // Create first cache instance and add entry const cache1 = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); await cache1.init(); // Use a real file that exists so validation passes const testPath = path.join(testProjectRoot, 'package.json'); const stats = await fs.promises.stat(testPath); const fileMtime = Math.floor(stats.mtimeMs); const entry: ICacheEntry = { path: testPath, contents: 'persistent content', tokenCount: 150, mtime: fileMtime, cachedAt: Date.now() }; await cache1.set(entry); // Wait for persist await new Promise(resolve => setTimeout(resolve, 500)); // Create second cache instance (should reload from disk) const cache2 = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); await cache2.init(); const cacheStats = cache2.getStats(); expect(cacheStats.entries).toBeGreaterThan(0); await cleanupTestCache(); }); tap.test('ContextCache should handle invalid cache index gracefully', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); // Create cache dir manually await fs.promises.mkdir(testCacheDir, { recursive: true }); // Write invalid JSON to cache index const cacheIndexPath = path.join(testCacheDir, 'index.json'); await fs.promises.writeFile(cacheIndexPath, 'invalid json {', 'utf-8'); // Should not throw, should just start with empty cache await cache.init(); const stats = cache.getStats(); expect(stats.entries).toEqual(0); await cleanupTestCache(); }); tap.test('ContextCache should return proper stats for empty cache', async () => { await cleanupTestCache(); const cache = new ContextCache(testProjectRoot, { directory: testCacheDir, enabled: true }); await cache.init(); const stats = cache.getStats(); expect(stats.entries).toEqual(0); expect(stats.totalSize).toEqual(0); expect(stats.oldestEntry).toBeNull(); expect(stats.newestEntry).toBeNull(); await cleanupTestCache(); }); export default tap.start();