260 lines
9.7 KiB
TypeScript
260 lines
9.7 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { MCPORTER_VERSION } from '../src/runtime.js';
|
|
|
|
process.env.MCPORTER_DISABLE_AUTORUN = '1';
|
|
const cliModulePromise = import('../src/cli.js');
|
|
|
|
describe('mcporter CLI config fallback', () => {
|
|
let tempDir: string;
|
|
let originalCwd: string;
|
|
let previousNoForceExit: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
originalCwd = process.cwd();
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-config-'));
|
|
process.chdir(tempDir);
|
|
previousNoForceExit = process.env.MCPORTER_NO_FORCE_EXIT;
|
|
process.env.MCPORTER_NO_FORCE_EXIT = '1';
|
|
});
|
|
|
|
afterEach(async () => {
|
|
process.chdir(originalCwd);
|
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
process.exitCode = undefined;
|
|
if (previousNoForceExit === undefined) {
|
|
delete process.env.MCPORTER_NO_FORCE_EXIT;
|
|
} else {
|
|
process.env.MCPORTER_NO_FORCE_EXIT = previousNoForceExit;
|
|
}
|
|
});
|
|
|
|
it('lists servers even when the config directory is missing', async () => {
|
|
const { runCli } = await cliModulePromise;
|
|
const logs: string[] = [];
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => {
|
|
if (typeof value === 'string') {
|
|
logs.push(value);
|
|
}
|
|
});
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
await expect(runCli(['config', 'list'])).resolves.not.toThrow();
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('warns once and continues when the default config is corrupt', async () => {
|
|
const { runCli } = await cliModulePromise;
|
|
await fs.mkdir(path.join(tempDir, 'config'), { recursive: true });
|
|
const configPath = path.join(tempDir, 'config', 'mcporter.json');
|
|
await fs.writeFile(configPath, '{ invalid : json', 'utf8');
|
|
const logs: string[] = [];
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => {
|
|
if (typeof value === 'string') {
|
|
logs.push(value);
|
|
}
|
|
});
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
await expect(runCli(['config', 'list'])).resolves.not.toThrow();
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
const message = warnSpy.mock.calls[0]?.[0]?.toString() ?? '';
|
|
expect(message).toContain('Ignoring config');
|
|
expect(message).toContain(configPath);
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('prints the doctor banner even when config is missing', async () => {
|
|
const { runCli } = await cliModulePromise;
|
|
const logs: string[] = [];
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => {
|
|
if (typeof value === 'string') {
|
|
logs.push(value);
|
|
}
|
|
});
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
await expect(runCli(['config', 'doctor'])).resolves.not.toThrow();
|
|
expect(logs[0]).toBe(`MCPorter ${MCPORTER_VERSION}`);
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('doctor warns once and keeps running when the config is corrupt', async () => {
|
|
const { runCli } = await cliModulePromise;
|
|
await fs.mkdir(path.join(tempDir, 'config'), { recursive: true });
|
|
const configPath = path.join(tempDir, 'config', 'mcporter.json');
|
|
await fs.writeFile(configPath, '{ not valid }', 'utf8');
|
|
const logs: string[] = [];
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => {
|
|
if (typeof value === 'string') {
|
|
logs.push(value);
|
|
}
|
|
});
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
await expect(runCli(['config', 'doctor'])).resolves.not.toThrow();
|
|
expect(logs[0]).toBe(`MCPorter ${MCPORTER_VERSION}`);
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
expect(warnSpy.mock.calls[0]?.[0]).toContain(configPath);
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('mcporter CLI with completely empty environment (ENOENT regression)', () => {
|
|
let tempDir: string;
|
|
let originalCwd: string;
|
|
let previousNoForceExit: string | undefined;
|
|
let homedirSpy: { mockRestore(): void } | undefined;
|
|
let previousEnv: Record<string, string | undefined> = {};
|
|
|
|
beforeEach(async () => {
|
|
originalCwd = process.cwd();
|
|
// Create a completely empty temp directory - no config files anywhere
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-empty-env-'));
|
|
process.chdir(tempDir);
|
|
previousNoForceExit = process.env.MCPORTER_NO_FORCE_EXIT;
|
|
process.env.MCPORTER_NO_FORCE_EXIT = '1';
|
|
// Mock homedir to an empty directory to ensure no home config exists
|
|
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tempDir);
|
|
// Save and override env vars that could point to config files
|
|
previousEnv = {
|
|
HOME: process.env.HOME,
|
|
USERPROFILE: process.env.USERPROFILE,
|
|
APPDATA: process.env.APPDATA,
|
|
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
|
MCPORTER_CONFIG: process.env.MCPORTER_CONFIG,
|
|
};
|
|
process.env.HOME = tempDir;
|
|
process.env.USERPROFILE = tempDir;
|
|
process.env.APPDATA = path.join(tempDir, 'AppData', 'Roaming');
|
|
process.env.XDG_CONFIG_HOME = path.join(tempDir, '.config');
|
|
delete process.env.MCPORTER_CONFIG;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
homedirSpy?.mockRestore();
|
|
process.chdir(originalCwd);
|
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
process.exitCode = undefined;
|
|
if (previousNoForceExit === undefined) {
|
|
delete process.env.MCPORTER_NO_FORCE_EXIT;
|
|
} else {
|
|
process.env.MCPORTER_NO_FORCE_EXIT = previousNoForceExit;
|
|
}
|
|
// Restore env vars
|
|
for (const [key, value] of Object.entries(previousEnv)) {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('list command succeeds with no config files anywhere (regression test)', async () => {
|
|
// This is the key regression test: before the fix, this would crash with:
|
|
// ENOENT: no such file or directory, open '.../config/mcporter.json'
|
|
const { runCli } = await cliModulePromise;
|
|
const logs: string[] = [];
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => {
|
|
if (typeof value === 'string') {
|
|
logs.push(value);
|
|
}
|
|
});
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
// This should NOT throw - it should gracefully handle missing config
|
|
await expect(runCli(['list'])).resolves.not.toThrow();
|
|
|
|
// Should not have any warnings about missing config (since it's optional)
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
// Should show "No MCP servers configured" or similar empty state
|
|
const output = logs.join('\n');
|
|
expect(output).toContain('No MCP servers configured');
|
|
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it('list --json succeeds with no config files anywhere', async () => {
|
|
const { runCli } = await cliModulePromise;
|
|
const logs: string[] = [];
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => {
|
|
if (typeof value === 'string') {
|
|
logs.push(value);
|
|
}
|
|
});
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
await expect(runCli(['list', '--json'])).resolves.not.toThrow();
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
|
|
// Should output valid JSON with empty servers array
|
|
const jsonOutput = JSON.parse(logs[logs.length - 1] ?? '{}');
|
|
expect(jsonOutput).toHaveProperty('servers');
|
|
expect(jsonOutput.servers).toEqual([]);
|
|
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('config list succeeds with no config files anywhere', async () => {
|
|
const { runCli } = await cliModulePromise;
|
|
const logs: string[] = [];
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => {
|
|
if (typeof value === 'string') {
|
|
logs.push(value);
|
|
}
|
|
});
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
await expect(runCli(['config', 'list'])).resolves.not.toThrow();
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('config doctor succeeds with no config files anywhere', async () => {
|
|
const { runCli } = await cliModulePromise;
|
|
const logs: string[] = [];
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => {
|
|
if (typeof value === 'string') {
|
|
logs.push(value);
|
|
}
|
|
});
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
await expect(runCli(['config', 'doctor'])).resolves.not.toThrow();
|
|
expect(logs[0]).toBe(`MCPorter ${MCPORTER_VERSION}`);
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('daemon start no-ops gracefully with no config files anywhere', async () => {
|
|
const { runCli } = await cliModulePromise;
|
|
const logs: string[] = [];
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => {
|
|
if (typeof value === 'string') {
|
|
logs.push(value);
|
|
}
|
|
});
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
await expect(runCli(['daemon', 'start'])).resolves.not.toThrow();
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
expect(logs.join('\n')).toContain('No MCP servers are configured for keep-alive; daemon not started.');
|
|
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
});
|
|
});
|