feat: file cache, pause queue, hot-reload, and cache CLI commands
- Persistent file cache in ~/.mcpctl/cache/proxymodel/ with LRU eviction - Pause queue for temporarily holding MCP traffic - Hot-reload watcher for custom stages and proxymodel definitions - CLI: mcpctl cache list/clear/stats commands - HTTP endpoints for cache and pause management Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
135
src/mcplocal/tests/hot-reload.test.ts
Normal file
135
src/mcplocal/tests/hot-reload.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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<typeof handler>[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<typeof handler>[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<typeof handler>[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<typeof handler>[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<typeof handler>[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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user