import { randomBytes } from 'crypto';
import { promises as fsPromises } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { ConfigLoader } from '@src/config/configLoader.js';
import { ConfigWatcher } from '@src/config/configWatcher.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock AgentConfigManager before any tests run
const mockAgentConfig = {
get: vi.fn().mockImplementation((key: string) => {
const config = {
features: {
configReload: true,
envSubstitution: true,
},
configReload: {
debounceMs: 100,
},
};
return key.split('.').reduce((obj: any, k: string) => obj?.[k], config);
}),
};
vi.mock('@src/core/server/agentConfig.js', () => ({
AgentConfigManager: {
getInstance: () => mockAgentConfig,
},
}));
describe('ConfigWatcher', () => {
let tempConfigDir: string;
let configFilePath: string;
let configLoader: ConfigLoader;
let configWatcher: ConfigWatcher;
beforeEach(async () => {
tempConfigDir = join(tmpdir(), `config-watcher-test-${randomBytes(4).toString('hex')}`);
await fsPromises.mkdir(tempConfigDir, { recursive: true });
configFilePath = join(tempConfigDir, 'mcp.json');
// Create initial config
const initialConfig = {
mcpServers: {
'test-server-1': {
command: 'node',
args: ['server1.js'],
tags: ['test', 'server1'],
},
},
};
await fsPromises.writeFile(configFilePath, JSON.stringify(initialConfig, null, 2));
configLoader = new ConfigLoader(configFilePath);
configWatcher = new ConfigWatcher(configFilePath, configLoader);
});
afterEach(async () => {
if (configWatcher) {
configWatcher.stopWatching();
}
try {
await fsPromises.rm(tempConfigDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
vi.clearAllMocks();
});
describe('startWatching and stopWatching', () => {
it('should start watching the config file', () => {
expect(() => configWatcher.startWatching()).not.toThrow();
});
it('should stop watching the config file', () => {
configWatcher.startWatching();
expect(() => configWatcher.stopWatching()).not.toThrow();
});
it('should not error when stopping without starting', () => {
expect(() => configWatcher.stopWatching()).not.toThrow();
});
});
describe('reload event', () => {
it('should emit reload event when config file changes', async () => {
const reloadSpy = vi.fn();
configWatcher.on('reload', reloadSpy);
configWatcher.startWatching();
// Update config file
const updatedConfig = {
mcpServers: {
'test-server-2': {
command: 'python',
args: ['server2.py'],
tags: ['test', 'server2'],
},
},
};
await fsPromises.writeFile(configFilePath, JSON.stringify(updatedConfig, null, 2));
// Wait for debouncing
await new Promise((resolve) => setTimeout(resolve, 200));
// Note: File watching may not work reliably in all test environments
// This test primarily verifies the event structure
expect(configWatcher.listenerCount('reload')).toBeGreaterThan(0);
});
it('should debounce rapid file changes', async () => {
const reloadSpy = vi.fn();
configWatcher.on('reload', reloadSpy);
configWatcher.startWatching();
// Make multiple rapid changes
for (let i = 0; i < 5; i++) {
const config = {
mcpServers: {
[`test-server-${i}`]: {
command: 'node',
args: [`server${i}.js`],
tags: ['test'],
},
},
};
await fsPromises.writeFile(configFilePath, JSON.stringify(config, null, 2));
}
// Wait for debouncing
await new Promise((resolve) => setTimeout(resolve, 200));
// Verify watcher is still set up
expect(configWatcher.listenerCount('reload')).toBe(1);
});
});
describe('isWatching', () => {
it('should return false when not watching', () => {
// Note: isWatching is a private method, this tests the public behavior
expect(configWatcher.listenerCount('reload')).toBe(0);
});
it('should have listeners after starting', () => {
configWatcher.startWatching();
expect(configWatcher.listenerCount('reload')).toBe(0); // Listeners are added by consumers
});
});
});