466 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			466 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
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();
 |