mcporter/tests/daemon-client.test.ts
2026-05-28 16:45:31 +01:00

160 lines
5.9 KiB
TypeScript

import fs from 'node:fs/promises';
import os from 'node:os';
import net from 'node:net';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { DaemonClient, resolveDaemonPaths } from '../src/daemon/client.js';
import { makeShortTempDir } from './fixtures/test-helpers.js';
describe('daemon client', () => {
it('uses XDG_STATE_HOME for daemon paths unless MCPORTER_DAEMON_DIR is set', async () => {
const tmpDir = await makeShortTempDir('mcpd-xdg');
const originalDaemonDir = process.env.MCPORTER_DAEMON_DIR;
const originalXdgStateHome = process.env.XDG_STATE_HOME;
const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmpDir, 'home'));
try {
delete process.env.MCPORTER_DAEMON_DIR;
process.env.XDG_STATE_HOME = path.join(tmpDir, 'state');
const xdgPaths = resolveDaemonPaths(path.join(tmpDir, 'config.json'));
expect(xdgPaths.metadataPath).toContain(path.join(tmpDir, 'state', 'mcporter', 'daemon'));
process.env.MCPORTER_DAEMON_DIR = path.join(tmpDir, 'override');
const overridePaths = resolveDaemonPaths(path.join(tmpDir, 'config.json'));
expect(overridePaths.metadataPath).toContain(path.join(tmpDir, 'override', 'daemon'));
} finally {
homedirSpy.mockRestore();
if (originalDaemonDir === undefined) {
delete process.env.MCPORTER_DAEMON_DIR;
} else {
process.env.MCPORTER_DAEMON_DIR = originalDaemonDir;
}
if (originalXdgStateHome === undefined) {
delete process.env.XDG_STATE_HOME;
} else {
process.env.XDG_STATE_HOME = originalXdgStateHome;
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it('keeps stdio sockets open until the daemon responds', async () => {
const tmpDir = await makeShortTempDir('mcpd');
const originalDir = process.env.MCPORTER_DAEMON_DIR;
process.env.MCPORTER_DAEMON_DIR = tmpDir;
const configPath = path.join(tmpDir, 'config.json');
const { socketPath } = resolveDaemonPaths(configPath);
await fs.mkdir(path.dirname(socketPath), { recursive: true });
try {
await fs.unlink(socketPath).catch(() => {});
let clientClosedBeforeResponse = false;
const server = net.createServer((socket) => {
let responded = false;
socket.on('data', () => {
setTimeout(() => {
responded = true;
socket.write(JSON.stringify({ id: 'status', ok: true, result: { pong: true } }), () => {
socket.end();
});
}, 20);
});
socket.on('end', () => {
if (!responded) {
clientClosedBeforeResponse = true;
}
});
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, () => {
server.off('error', reject);
resolve();
});
});
try {
const client = new DaemonClient({ configPath });
const result = await (
client as unknown as { sendRequest: (method: 'status', params: object) => Promise<unknown> }
).sendRequest('status', {});
expect(result).toEqual({ pong: true });
expect(clientClosedBeforeResponse).toBe(false);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await fs.unlink(socketPath).catch(() => {});
}
} finally {
if (originalDir) {
process.env.MCPORTER_DAEMON_DIR = originalDir;
} else {
delete process.env.MCPORTER_DAEMON_DIR;
}
}
});
it('verifies daemon pid before trusting fresh metadata', async () => {
const tmpDir = await makeShortTempDir('mcpd-fresh');
const originalDir = process.env.MCPORTER_DAEMON_DIR;
process.env.MCPORTER_DAEMON_DIR = tmpDir;
const configPath = path.join(tmpDir, 'config.json');
await fs.writeFile(
configPath,
JSON.stringify({ mcpServers: { warm: { command: 'node', args: ['server.js'], lifecycle: 'keep-alive' } } })
);
const { socketPath, metadataPath } = resolveDaemonPaths(configPath);
await fs.mkdir(path.dirname(socketPath), { recursive: true });
await fs.mkdir(path.dirname(metadataPath), { recursive: true });
const configStats = await fs.stat(configPath);
await fs.writeFile(
metadataPath,
JSON.stringify({
pid: process.pid,
socketPath,
configPath,
configLayers: [{ path: configPath, mtimeMs: configStats.mtimeMs }],
startedAt: Date.now(),
})
);
const methods: string[] = [];
const server = net.createServer((socket) => {
let buffer = '';
socket.setEncoding('utf8');
socket.on('data', (chunk) => {
buffer += chunk;
const request = JSON.parse(buffer) as { id: string; method: string };
methods.push(request.method);
const result =
request.method === 'status'
? {
pid: process.pid,
startedAt: Date.now(),
configPath,
configLayers: [{ path: configPath, mtimeMs: configStats.mtimeMs }],
socketPath,
servers: [],
}
: { tools: [] };
socket.end(JSON.stringify({ id: request.id, ok: true, result }));
});
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, () => {
server.off('error', reject);
resolve();
});
});
try {
const client = new DaemonClient({ configPath, configExplicit: true });
await client.listTools({ server: 'warm' });
expect(methods).toEqual(['status', 'listTools']);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await fs.unlink(socketPath).catch(() => {});
if (originalDir) {
process.env.MCPORTER_DAEMON_DIR = originalDir;
} else {
delete process.env.MCPORTER_DAEMON_DIR;
}
}
});
});