457 lines
11 KiB
TypeScript
457 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).toBe(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');
|
|
const entry: ICacheEntry = {
|
|
path: testPath,
|
|
contents: 'test content',
|
|
tokenCount: 100,
|
|
mtime: Date.now(),
|
|
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).toBe(true);
|
|
|
|
const doesNotHaveIt = await cache.has('/non/existent/path.ts');
|
|
expect(doesNotHaveIt).toBe(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();
|
|
|
|
const entry: ICacheEntry = {
|
|
path: '/test/persistent-file.ts',
|
|
contents: 'persistent content',
|
|
tokenCount: 150,
|
|
mtime: Date.now(),
|
|
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 stats = cache2.getStats();
|
|
expect(stats.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();
|