141 lines
5.2 KiB
JavaScript
141 lines
5.2 KiB
JavaScript
import fs from 'node:fs/promises';
|
|||
|
|
import os from 'node:os';
|
||
|
|
import path from 'node:path';
|
||
|
|
import { pathToFileURL } from 'node:url';
|
||
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
|
|
|
||
|
|
import { createMcpReconnectRuntime } from './runtime.js';
|
||
|
|
|
||
|
|
const temporaryDirectories = [];
|
||
|
|
const disposers = [];
|
||
|
|
|
||
|
|
afterEach(async () => {
|
||
|
|
await Promise.all(disposers.splice(0).map((dispose) => dispose()));
|
||
|
|
vi.useRealTimers();
|
||
|
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||
|
|
});
|
||
|
|
|
||
|
|
const materialize = async (rawConfig = '{}') => {
|
||
|
|
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-mcp-reconnect-'));
|
||
|
|
temporaryDirectories.push(dataDir);
|
||
|
|
const runtime = createMcpReconnectRuntime({ fsPromises: fs, path, dataDir });
|
||
|
|
const prepared = await runtime.prepareManagedOpenCodeEnv(rawConfig);
|
||
|
|
const pluginPath = path.join(dataDir, 'mcp-reconnect', 'openchamber-mcp-reconnect-plugin.js');
|
||
|
|
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}-${Math.random()}`);
|
||
|
|
return { prepared, pluginPath, plugin: pluginModule.OpenChamberMcpReconnectPlugin };
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A stand-in for the SDK client OpenCode hands to plugins. `statuses` is live
|
||
|
|
* state the test mutates; `connect` records when each attempt happened.
|
||
|
|
*/
|
||
|
|
const createClient = (statuses, { onConnect } = {}) => {
|
||
|
|
const attempts = [];
|
||
|
|
return {
|
||
|
|
attempts,
|
||
|
|
statuses,
|
||
|
|
mcp: {
|
||
|
|
status: vi.fn(async () => ({ data: { ...statuses } })),
|
||
|
|
connect: vi.fn(async ({ path: { name } }) => {
|
||
|
|
attempts.push({ name, at: Date.now() });
|
||
|
|
onConnect?.(name);
|
||
|
|
return { data: true };
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const start = async (plugin, client) => {
|
||
|
|
const hooks = await plugin({ client });
|
||
|
|
disposers.push(hooks.dispose);
|
||
|
|
return hooks;
|
||
|
|
};
|
||
|
|
|
||
|
|
describe('managed MCP reconnect runtime', () => {
|
||
|
|
it('materializes the plugin and preserves existing plugin entries', async () => {
|
||
|
|
const { prepared, pluginPath } = await materialize('{ "plugin": ["file:///existing.js"], "model": "test/model" }');
|
||
|
|
const config = JSON.parse(prepared.OPENCODE_CONFIG_CONTENT);
|
||
|
|
expect(config.model).toBe('test/model');
|
||
|
|
expect(config.plugin).toEqual(['file:///existing.js', pathToFileURL(pluginPath).href]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reconnects only servers in the failed state', async () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
const { plugin } = await materialize();
|
||
|
|
const client = createClient({
|
||
|
|
broken: { status: 'failed', error: 'spawn ENOENT' },
|
||
|
|
healthy: { status: 'connected' },
|
||
|
|
off: { status: 'disabled' },
|
||
|
|
login: { status: 'needs_auth' },
|
||
|
|
registration: { status: 'needs_client_registration', error: 'no client id' },
|
||
|
|
});
|
||
|
|
await start(plugin, client);
|
||
|
|
|
||
|
|
await vi.advanceTimersByTimeAsync(1000);
|
||
|
|
|
||
|
|
expect(client.attempts.map((attempt) => attempt.name)).toEqual(['broken']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('backs off per server up to a cap while it keeps failing', async () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
const { plugin } = await materialize();
|
||
|
|
const client = createClient({ broken: { status: 'failed', error: 'refused' } });
|
||
|
|
const started = Date.now();
|
||
|
|
await start(plugin, client);
|
||
|
|
|
||
|
|
await vi.advanceTimersByTimeAsync(92_000);
|
||
|
|
|
||
|
|
expect(client.attempts.map((attempt) => attempt.at - started)).toEqual([
|
||
|
|
1000, 2000, 4000, 8000, 16_000, 32_000, 62_000, 92_000,
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('stops retrying once a server is back and starts fresh when it drops again', async () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
const { plugin } = await materialize();
|
||
|
|
const statuses = { flaky: { status: 'failed', error: 'refused' } };
|
||
|
|
const client = createClient(statuses, {
|
||
|
|
onConnect: () => { statuses.flaky = { status: 'connected' }; },
|
||
|
|
});
|
||
|
|
const hooks = await start(plugin, client);
|
||
|
|
|
||
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
||
|
|
expect(client.attempts).toHaveLength(1);
|
||
|
|
|
||
|
|
statuses.flaky = { status: 'failed', error: 'Connection closed' };
|
||
|
|
await hooks.event({ event: { type: 'mcp.tools.changed', properties: { server: 'flaky' } } });
|
||
|
|
await vi.advanceTimersByTimeAsync(1000);
|
||
|
|
|
||
|
|
expect(client.attempts).toHaveLength(2);
|
||
|
|
expect(client.attempts[1].at - client.attempts[0].at).toBeGreaterThan(30_000);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('keeps going when status is temporarily unavailable', async () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
const { plugin } = await materialize();
|
||
|
|
const client = createClient({ broken: { status: 'failed', error: 'refused' } });
|
||
|
|
client.mcp.status.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||
|
|
await start(plugin, client);
|
||
|
|
|
||
|
|
await vi.advanceTimersByTimeAsync(1000);
|
||
|
|
expect(client.attempts).toHaveLength(0);
|
||
|
|
|
||
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
||
|
|
expect(client.attempts).toHaveLength(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does nothing after dispose', async () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
const { plugin } = await materialize();
|
||
|
|
const client = createClient({ broken: { status: 'failed', error: 'refused' } });
|
||
|
|
const hooks = await plugin({ client });
|
||
|
|
|
||
|
|
await hooks.dispose();
|
||
|
|
await hooks.event({ event: { type: 'mcp.tools.changed', properties: { server: 'broken' } } });
|
||
|
|
await vi.advanceTimersByTimeAsync(120_000);
|
||
|
|
|
||
|
|
expect(client.mcp.status).not.toHaveBeenCalled();
|
||
|
|
expect(client.attempts).toHaveLength(0);
|
||
|
|
});
|
||
|
|
});
|