Skip to main content
Glama

hypertool-mcp

cache.test.tsโ€ข18.4 kB
/** * PersonaCache Test Suite * * Comprehensive tests for the high-performance persona caching system, * including TTL management, LRU eviction, file system monitoring, * memory management, and performance characteristics. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { promises as fs } from "fs"; import { join } from "path"; import { tmpdir } from "os"; import { PersonaCache, createPersonaCache, defaultPersonaCache, PersonaCacheFactory, EvictionReason, CacheEvents, type CacheMetrics, } from "./cache.js"; import type { LoadedPersona, PersonaConfig, PersonaAssets, ValidationResult, } from "./types.js"; // Mock fs.watch to avoid actual file system watching in tests vi.mock("fs", async () => { const actual = await vi.importActual("fs"); return { ...actual, watch: vi.fn().mockReturnValue({ on: vi.fn(), close: vi.fn(), }), }; }); describe("PersonaCache", () => { let cache: PersonaCache; let testPersonas: LoadedPersona[]; let tempDir: string; beforeEach(async () => { // Create a new cache instance for each test cache = new PersonaCache({ ttl: 1000, // 1 second for faster testing maxSize: 3, enableStats: true, }); // Create temporary directory for test files tempDir = await fs.mkdtemp(join(tmpdir(), "persona-cache-test-")); // Override persona directory to use temp directory for tests process.env.HYPERTOOL_PERSONA_DIR = tempDir; // Create test personas testPersonas = await createTestPersonas(tempDir); }); afterEach(async () => { // Clean up environment variable delete process.env.HYPERTOOL_PERSONA_DIR; // Cleanup cache.destroy(); // Clean up temporary files try { await fs.rmdir(tempDir, { recursive: true }); } catch { // Ignore cleanup errors } }); describe("Basic Cache Operations", () => { it("should store and retrieve a persona", () => { const persona = testPersonas[0]; // Should not be cached initially expect(cache.has(persona.config.name, persona.sourcePath)).toBe(false); expect(cache.get(persona.config.name, persona.sourcePath)).toBeNull(); // Store persona cache.set(persona); // Should now be cached expect(cache.has(persona.config.name, persona.sourcePath)).toBe(true); const retrieved = cache.get(persona.config.name, persona.sourcePath); expect(retrieved).toBe(persona); }); it("should handle cache keys with same name but different paths", () => { const persona1 = testPersonas[0]; const persona2 = { ...testPersonas[1], config: { ...testPersonas[1].config, name: persona1.config.name }, }; cache.set(persona1); cache.set(persona2); expect(cache.get(persona1.config.name, persona1.sourcePath)).toBe( persona1 ); expect(cache.get(persona2.config.name, persona2.sourcePath)).toBe( persona2 ); }); it("should update existing cache entries", () => { const persona = testPersonas[0]; cache.set(persona); const updatedPersona = { ...persona, config: { ...persona.config, description: "Updated description" }, }; cache.set(updatedPersona); const retrieved = cache.get(persona.config.name, persona.sourcePath); expect(retrieved?.config.description).toBe("Updated description"); }); it("should delete specific personas from cache", () => { const persona = testPersonas[0]; cache.set(persona); expect(cache.has(persona.config.name, persona.sourcePath)).toBe(true); const deleted = cache.delete(persona.config.name, persona.sourcePath); expect(deleted).toBe(true); expect(cache.has(persona.config.name, persona.sourcePath)).toBe(false); const deletedAgain = cache.delete( persona.config.name, persona.sourcePath ); expect(deletedAgain).toBe(false); }); it("should clear all cached personas", () => { testPersonas.forEach((persona) => cache.set(persona)); // Cache has maxSize of 3, so it should only have 3 items expect(cache.getStats().size).toBe(3); cache.clear(); expect(cache.getStats().size).toBe(0); testPersonas.forEach((persona) => { expect(cache.has(persona.config.name, persona.sourcePath)).toBe(false); }); }); }); describe("TTL Management", () => { it("should expire personas after TTL", async () => { const persona = testPersonas[0]; cache.set(persona); expect(cache.has(persona.config.name, persona.sourcePath)).toBe(true); // Wait for TTL to expire await new Promise((resolve) => setTimeout(resolve, 1100)); expect(cache.has(persona.config.name, persona.sourcePath)).toBe(false); expect(cache.get(persona.config.name, persona.sourcePath)).toBeNull(); }); it("should refresh TTL when accessed", () => { const persona = testPersonas[0]; cache.set(persona); // Access to refresh TTL const retrieved = cache.get(persona.config.name, persona.sourcePath); expect(retrieved).toBe(persona); // Should still be accessible after some time but before original TTL setTimeout(() => { expect(cache.has(persona.config.name, persona.sourcePath)).toBe(true); }, 500); }); it("should allow manual TTL refresh", () => { const persona = testPersonas[0]; cache.set(persona); const refreshed = cache.refreshTTL( persona.config.name, persona.sourcePath, 5000 ); expect(refreshed).toBe(true); // Should still be accessible after original TTL would have expired setTimeout(() => { expect(cache.has(persona.config.name, persona.sourcePath)).toBe(true); }, 1100); }); it("should not refresh TTL for non-existent personas", () => { const refreshed = cache.refreshTTL("non-existent", "/fake/path"); expect(refreshed).toBe(false); }); }); describe("LRU Eviction", () => { it("should evict least recently used personas when size limit reached", async () => { // Fill cache to capacity testPersonas.slice(0, 3).forEach((persona) => cache.set(persona)); expect(cache.getStats().size).toBe(3); // Access personas to establish LRU order cache.get(testPersonas[1].config.name, testPersonas[1].sourcePath); cache.get(testPersonas[2].config.name, testPersonas[2].sourcePath); // testPersonas[0] is now least recently used // Add one more persona to trigger eviction const extraPersona = await createExtraTestPersona(tempDir); cache.set(extraPersona); // Size should remain at limit expect(cache.getStats().size).toBe(3); // LRU persona (testPersonas[0]) should be evicted expect( cache.has(testPersonas[0].config.name, testPersonas[0].sourcePath) ).toBe(false); expect( cache.has(testPersonas[1].config.name, testPersonas[1].sourcePath) ).toBe(true); expect( cache.has(testPersonas[2].config.name, testPersonas[2].sourcePath) ).toBe(true); expect(cache.has(extraPersona.config.name, extraPersona.sourcePath)).toBe( true ); }); it("should track access order correctly", () => { testPersonas.slice(0, 3).forEach((persona) => cache.set(persona)); // Access in specific order to establish LRU cache.get(testPersonas[0].config.name, testPersonas[0].sourcePath); cache.get(testPersonas[1].config.name, testPersonas[1].sourcePath); cache.get(testPersonas[2].config.name, testPersonas[2].sourcePath); const cachedNames = cache.getCachedPersonaNames(); expect(cachedNames).toHaveLength(3); expect(cachedNames).toContain(testPersonas[0].config.name); expect(cachedNames).toContain(testPersonas[1].config.name); expect(cachedNames).toContain(testPersonas[2].config.name); }); }); describe("Statistics and Metrics", () => { it("should track hit and miss statistics", () => { const persona = testPersonas[0]; // Initial stats let stats = cache.getStats(); expect(stats.hits).toBe(0); expect(stats.misses).toBe(0); expect(stats.hitRate).toBe(0); // Cache miss cache.get(persona.config.name, persona.sourcePath); stats = cache.getStats(); expect(stats.misses).toBe(1); expect(stats.hitRate).toBe(0); // Cache set and hit cache.set(persona); cache.get(persona.config.name, persona.sourcePath); stats = cache.getStats(); expect(stats.hits).toBe(1); expect(stats.misses).toBe(1); expect(stats.hitRate).toBe(0.5); }); it("should track memory usage", () => { const initialStats = cache.getStats(); expect(initialStats.memoryUsage).toBe(0); cache.set(testPersonas[0]); const statsAfterSet = cache.getStats(); expect(statsAfterSet.memoryUsage).toBeGreaterThan(0); cache.clear(); const statsAfterClear = cache.getStats(); expect(statsAfterClear.memoryUsage).toBe(0); }); it("should provide detailed metrics", () => { const persona = testPersonas[0]; // Generate some operations cache.get(persona.config.name, persona.sourcePath); // miss cache.set(persona); cache.get(persona.config.name, persona.sourcePath); // hit const metrics = cache.getMetrics(); expect(metrics.operations).toBe(1); // 1 get operation (set doesn't count as operation) expect(metrics.averageLookupTime).toBeGreaterThan(0); expect(typeof metrics.evictionCounts).toBe("object"); }); it("should track eviction reasons", () => { const persona = testPersonas[0]; cache.set(persona); // Manual eviction cache.delete(persona.config.name, persona.sourcePath); const metrics = cache.getMetrics(); expect(metrics.evictionCounts[EvictionReason.MANUAL_CLEAR]).toBe(1); }); }); describe("Event Emission", () => { it("should emit cache events", () => { return new Promise<void>((resolve) => { const persona = testPersonas[0]; let eventsReceived = 0; cache.on(CacheEvents.CACHE_SET, () => { eventsReceived++; }); cache.on(CacheEvents.CACHE_HIT, () => { eventsReceived++; if (eventsReceived === 2) { resolve(); } }); cache.set(persona); cache.get(persona.config.name, persona.sourcePath); }); }); it("should emit eviction events", () => { return new Promise<void>((resolve) => { const persona = testPersonas[0]; cache.on(CacheEvents.CACHE_EVICTED, (event) => { expect(event.name).toBe(persona.config.name); expect(event.reason).toBe(EvictionReason.MANUAL_CLEAR); resolve(); }); cache.set(persona); cache.delete(persona.config.name, persona.sourcePath); }); }); it("should emit clear events", () => { return new Promise<void>((resolve) => { testPersonas.slice(0, 3).forEach((persona) => cache.set(persona)); // Only add 3 due to cache maxSize const clearHandler = (event: any) => { expect(event.count).toBe(3); // Cache maxSize is 3 cache.removeListener(CacheEvents.CACHE_CLEARED, clearHandler); // Remove listener to avoid duplicate events resolve(); }; cache.on(CacheEvents.CACHE_CLEARED, clearHandler); cache.clear(); }); }); }); describe("Path-based Invalidation", () => { it("should invalidate personas by source path", () => { const persona = testPersonas[0]; cache.set(persona); expect(cache.has(persona.config.name, persona.sourcePath)).toBe(true); const invalidatedCount = cache.invalidateByPath(persona.sourcePath); expect(invalidatedCount).toBe(1); expect(cache.has(persona.config.name, persona.sourcePath)).toBe(false); }); it("should invalidate multiple personas with same path", () => { const basePath = "/shared/path"; const persona1 = { ...testPersonas[0], sourcePath: basePath }; const persona2 = { ...testPersonas[1], sourcePath: basePath }; cache.set(persona1); cache.set(persona2); const invalidatedCount = cache.invalidateByPath(basePath); expect(invalidatedCount).toBe(2); }); }); describe("Memory Management", () => { it("should estimate memory usage for personas", () => { const persona = testPersonas[0]; cache.set(persona); const stats = cache.getStats(); expect(stats.memoryUsage).toBeGreaterThan(0); expect(typeof stats.memoryUsage).toBe("number"); }); it("should handle memory pressure with eviction", () => { // This test would need a more sophisticated setup to actually trigger // memory-based eviction, but we can verify the mechanism exists testPersonas.forEach((persona) => cache.set(persona)); const initialSize = cache.getStats().size; expect(initialSize).toBeLessThanOrEqual(3); // Max size limit }); }); describe("Cleanup and Destruction", () => { it("should cleanup expired entries in background", async () => { const persona = testPersonas[0]; cache.set(persona); expect(cache.getStats().size).toBe(1); // Wait for TTL expiration await new Promise((resolve) => setTimeout(resolve, 1200)); // Manually trigger cleanup (since background timer is unref'd) cache.cleanup(); // Size should be reduced after cleanup expect(cache.getStats().size).toBe(0); }); it("should destroy cache properly", () => { testPersonas.slice(0, 3).forEach((persona) => cache.set(persona)); // Only add 3 due to cache maxSize expect(cache.getStats().size).toBe(3); // Cache maxSize is 3 cache.destroy(); expect(cache.getStats().size).toBe(0); }); }); }); describe("Cache Factory", () => { afterEach(() => { PersonaCacheFactory.destroyAll(); }); it("should create and manage named cache instances", () => { const cache1 = PersonaCacheFactory.getInstance("test1"); const cache2 = PersonaCacheFactory.getInstance("test2"); const cache1Again = PersonaCacheFactory.getInstance("test1"); expect(cache1).not.toBe(cache2); expect(cache1).toBe(cache1Again); const activeInstances = PersonaCacheFactory.getActiveInstances(); expect(activeInstances).toContain("test1"); expect(activeInstances).toContain("test2"); }); it("should destroy specific instances", () => { PersonaCacheFactory.getInstance("test1"); PersonaCacheFactory.getInstance("test2"); expect(PersonaCacheFactory.getActiveInstances()).toHaveLength(2); const destroyed = PersonaCacheFactory.destroyInstance("test1"); expect(destroyed).toBe(true); expect(PersonaCacheFactory.getActiveInstances()).toHaveLength(1); expect(PersonaCacheFactory.getActiveInstances()).toContain("test2"); const destroyedAgain = PersonaCacheFactory.destroyInstance("test1"); expect(destroyedAgain).toBe(false); }); it("should destroy all instances", () => { PersonaCacheFactory.getInstance("test1"); PersonaCacheFactory.getInstance("test2"); PersonaCacheFactory.getInstance("test3"); expect(PersonaCacheFactory.getActiveInstances()).toHaveLength(3); PersonaCacheFactory.destroyAll(); expect(PersonaCacheFactory.getActiveInstances()).toHaveLength(0); }); }); describe("Cache Utilities", () => { it("should create cache with createPersonaCache function", () => { const cache = createPersonaCache({ ttl: 2000, maxSize: 50, enableStats: false, }); expect(cache).toBeInstanceOf(PersonaCache); // Cleanup cache.destroy(); }); it("should use default cache instance", () => { expect(defaultPersonaCache).toBeInstanceOf(PersonaCache); const stats = defaultPersonaCache.getStats(); expect(typeof stats.hits).toBe("number"); expect(typeof stats.misses).toBe("number"); }); }); // Helper functions for test setup async function createTestPersonas(tempDir: string): Promise<LoadedPersona[]> { const personas: LoadedPersona[] = []; for (let i = 0; i < 5; i++) { const personaName = `test-persona-${i}`; const personaDir = join(tempDir, personaName); await fs.mkdir(personaDir, { recursive: true }); const configFile = join(personaDir, "persona.yaml"); await fs.writeFile( configFile, ` name: ${personaName} description: Test persona ${i} toolsets: - name: default toolIds: ["test.tool1", "test.tool2"] ` ); const config: PersonaConfig = { name: personaName, description: `Test persona ${i}`, toolsets: [ { name: "default", toolIds: ["test.tool1", "test.tool2"], }, ], defaultToolset: "default", }; const assets: PersonaAssets = { configFile, }; const validation: ValidationResult = { isValid: true, errors: [], warnings: [], }; const persona: LoadedPersona = { config, assets, validation, loadedAt: new Date(), sourcePath: personaDir, }; personas.push(persona); } return personas; } async function createExtraTestPersona(tempDir: string): Promise<LoadedPersona> { const personaName = "extra-persona"; const personaDir = join(tempDir, personaName); await fs.mkdir(personaDir, { recursive: true }); const configFile = join(personaDir, "persona.yaml"); await fs.writeFile( configFile, ` name: ${personaName} description: Extra test persona toolsets: - name: default toolIds: ["test.tool3", "test.tool4"] ` ); const config: PersonaConfig = { name: personaName, description: "Extra test persona", toolsets: [ { name: "default", toolIds: ["test.tool3", "test.tool4"], }, ], defaultToolset: "default", }; const assets: PersonaAssets = { configFile, }; const validation: ValidationResult = { isValid: true, errors: [], warnings: [], }; return { config, assets, validation, loadedAt: new Date(), sourcePath: personaDir, }; }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/toolprint/hypertool-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server