import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { clearExtensionCache, loadExtensions, loadExtensionsCached } from "../../../src/core/extensions/loader.ts"; import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts"; interface TestState { moduleLoads?: number; factoryRuns?: number; } function state(): TestState { const global = globalThis as typeof globalThis & { __extensionFactoryCacheTest?: TestState }; if (!global.__extensionFactoryCacheTest) { global.__extensionFactoryCacheTest = {}; } return global.__extensionFactoryCacheTest; } function resetState(): void { delete (globalThis as typeof globalThis & { __extensionFactoryCacheTest?: TestState }).__extensionFactoryCacheTest; } function writeCountingExtension(filePath: string): void { writeFileSync( filePath, ` const state = (globalThis.__extensionFactoryCacheTest ??= {}); state.moduleLoads = (state.moduleLoads ?? 0) + 1; export default function () { state.factoryRuns = (state.factoryRuns ?? 0) + 1; } `, "utf-8", ); } describe("extension factory cache", () => { const roots: string[] = []; function fixture(name: string) { const root = join(tmpdir(), `pi-extension-cache-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`); const cwd = join(root, "project"); const agentDir = join(root, "agent"); mkdirSync(cwd, { recursive: true }); mkdirSync(agentDir, { recursive: true }); roots.push(root); return { root, cwd, agentDir }; } beforeEach(() => { resetState(); clearExtensionCache(); }); afterEach(() => { while (roots.length > 0) { const root = roots.pop(); if (root && existsSync(root)) { rmSync(root, { recursive: true, force: true }); } } resetState(); clearExtensionCache(); }); it("caches extension modules for cached same-cwd loads but reruns factories", async () => { const { root, cwd } = fixture("same-cwd"); const extensionPath = join(root, "counting.ts"); writeCountingExtension(extensionPath); const first = await loadExtensionsCached([extensionPath], cwd); const second = await loadExtensionsCached([extensionPath], cwd); expect(state().moduleLoads).toBe(1); expect(state().factoryRuns).toBe(2); expect(first.extensions[0]).not.toBe(second.extensions[0]); expect(first.runtime).not.toBe(second.runtime); }); it("does not cache direct loadExtensions calls", async () => { const { root, cwd } = fixture("direct"); const extensionPath = join(root, "counting.ts"); writeCountingExtension(extensionPath); await loadExtensions([extensionPath], cwd); await loadExtensions([extensionPath], cwd); expect(state().moduleLoads).toBe(2); expect(state().factoryRuns).toBe(2); }); it("clears the cache on resource loader reload", async () => { const { cwd, agentDir } = fixture("reload"); const extensionDir = join(agentDir, "extensions"); mkdirSync(extensionDir, { recursive: true }); writeCountingExtension(join(extensionDir, "counting.ts")); const loader = new DefaultResourceLoader({ cwd, agentDir, noSkills: true, noPromptTemplates: true, noThemes: true, }); await loader.reload(); await loader.reload(); expect(state().moduleLoads).toBe(2); expect(state().factoryRuns).toBe(2); }); it("keeps the cache scoped to one cwd", async () => { const { root } = fixture("cross-cwd"); const firstCwd = join(root, "first"); const secondCwd = join(root, "second"); mkdirSync(firstCwd, { recursive: true }); mkdirSync(secondCwd, { recursive: true }); const extensionPath = join(root, "counting.ts"); writeCountingExtension(extensionPath); await loadExtensionsCached([extensionPath], firstCwd); await loadExtensionsCached([extensionPath], secondCwd); await loadExtensionsCached([extensionPath], secondCwd); expect(state().moduleLoads).toBe(2); expect(state().factoryRuns).toBe(3); }); });