import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, writeFileSync, mkdirSync, rmSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { getStage, listStages, loadCustomStages, clearCustomStages } from '../src/proxymodel/stage-registry.js'; let tempDir: string; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-hotreload-')); clearCustomStages(); }); afterEach(() => { clearCustomStages(); rmSync(tempDir, { recursive: true, force: true }); }); describe('Hot-reload: stage registry cache busting', () => { it('loadCustomStages loads .js files', async () => { writeFileSync(join(tempDir, 'echo.js'), ` export default async function(content, ctx) { return { content: 'v1:' + content }; } `); await loadCustomStages(tempDir); const handler = getStage('echo'); expect(handler).not.toBeNull(); const result = await handler!('hello', {} as Parameters[1]); expect(result.content).toBe('v1:hello'); }); it('reloading picks up file changes via cache busting', async () => { writeFileSync(join(tempDir, 'transform.js'), ` export default async function(content) { return { content: 'v1:' + content }; } `); await loadCustomStages(tempDir); let handler = getStage('transform'); let result = await handler!('test', {} as Parameters[1]); expect(result.content).toBe('v1:test'); // Overwrite the file with a new version writeFileSync(join(tempDir, 'transform.js'), ` export default async function(content) { return { content: 'v2:' + content }; } `); // Reload — should pick up the new version due to cache busting await loadCustomStages(tempDir); handler = getStage('transform'); result = await handler!('test', {} as Parameters[1]); expect(result.content).toBe('v2:test'); }); it('removing a file removes the stage on reload', async () => { writeFileSync(join(tempDir, 'temp.js'), ` export default async function(content) { return { content }; } `); await loadCustomStages(tempDir); expect(getStage('temp')).not.toBeNull(); unlinkSync(join(tempDir, 'temp.js')); await loadCustomStages(tempDir); expect(getStage('temp')).toBeNull(); }); it('adding a new file makes it available on reload', async () => { await loadCustomStages(tempDir); expect(getStage('newstage')).toBeNull(); writeFileSync(join(tempDir, 'newstage.js'), ` export default async function(content) { return { content: 'new:' + content }; } `); await loadCustomStages(tempDir); const handler = getStage('newstage'); expect(handler).not.toBeNull(); const result = await handler!('x', {} as Parameters[1]); expect(result.content).toBe('new:x'); }); it('syntax errors in stage files do not crash reload', async () => { writeFileSync(join(tempDir, 'good.js'), ` export default async function(content) { return { content }; } `); writeFileSync(join(tempDir, 'bad.js'), 'this is not valid javascript{{{'); await loadCustomStages(tempDir); // Good stage should still load expect(getStage('good')).not.toBeNull(); // Bad stage should not be present expect(getStage('bad')).toBeNull(); }); it('supports .mjs files', async () => { writeFileSync(join(tempDir, 'mjs-stage.mjs'), ` export default async function(content) { return { content: 'mjs:' + content }; } `); await loadCustomStages(tempDir); const handler = getStage('mjs-stage'); expect(handler).not.toBeNull(); const result = await handler!('hi', {} as Parameters[1]); expect(result.content).toBe('mjs:hi'); }); it('listStages shows custom stages as local', async () => { writeFileSync(join(tempDir, 'custom.js'), ` export default async function(content) { return { content }; } `); await loadCustomStages(tempDir); const stages = listStages(); const custom = stages.find((s) => s.name === 'custom'); expect(custom).toBeDefined(); expect(custom!.source).toBe('local'); }); });