Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions src/__tests__/commands/alias.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Tests for src/commands/alias.ts
*/

import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleAlias } from "../../commands/alias.js";
import type { AliasDeps } from "../../commands/alias.js";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeDeps(overrides: Partial<AliasDeps> = {}): AliasDeps {
return {
getAliases: vi.fn().mockResolvedValue({}),
setAlias: vi.fn().mockResolvedValue(undefined),
removeAlias: vi.fn().mockResolvedValue(undefined),
output: vi.fn(),
...overrides,
};
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("handleAlias", () => {
beforeEach(() => {
vi.resetAllMocks();
});

describe("--list", () => {
it("shows empty message when no aliases exist", async () => {
const deps = makeDeps();
await handleAlias([], { list: true }, deps);
const output = (deps.output as ReturnType<typeof vi.fn>).mock.calls.flat().join(" ");
expect(output).toMatch(/No aliases/i);
});

it("displays aliases in a table", async () => {
const deps = makeDeps({
getAliases: vi.fn().mockResolvedValue({
fs: "io.github.domdomegg/filesystem-mcp",
gh: "io.github.modelcontextprotocol/github",
}),
});
await handleAlias([], { list: true }, deps);
const output = (deps.output as ReturnType<typeof vi.fn>).mock.calls.flat().join(" ");
expect(output).toContain("fs");
expect(output).toContain("io.github.domdomegg/filesystem-mcp");
});
});

describe("--remove", () => {
it("removes an existing alias", async () => {
const deps = makeDeps();
await handleAlias([], { remove: "fs" }, deps);
expect(deps.removeAlias).toHaveBeenCalledWith("fs");
});

it("outputs confirmation after removal", async () => {
const deps = makeDeps();
await handleAlias([], { remove: "fs" }, deps);
const output = (deps.output as ReturnType<typeof vi.fn>).mock.calls.flat().join(" ");
expect(output).toMatch(/Removed.*fs/);
});
});

describe("set alias", () => {
it("sets an alias with two positional args", async () => {
const deps = makeDeps();
await handleAlias(["fs", "io.github.domdomegg/filesystem-mcp"], {}, deps);
expect(deps.setAlias).toHaveBeenCalledWith("fs", "io.github.domdomegg/filesystem-mcp");
});

it("outputs the alias mapping", async () => {
const deps = makeDeps();
await handleAlias(["fs", "io.github.domdomegg/filesystem-mcp"], {}, deps);
const output = (deps.output as ReturnType<typeof vi.fn>).mock.calls.flat().join(" ");
expect(output).toContain("fs");
expect(output).toContain("io.github.domdomegg/filesystem-mcp");
});

it("throws when insufficient arguments", async () => {
const deps = makeDeps();
await expect(handleAlias(["fs"], {}, deps)).rejects.toThrow(/Usage/);
});

it("throws when alias contains /", async () => {
const deps = makeDeps();
await expect(
handleAlias(["bad/name", "some-server"], {}, deps)
).rejects.toThrow(/letters.*digits.*hyphens.*underscores/);
});

it("throws when alias contains .", async () => {
const deps = makeDeps();
await expect(
handleAlias(["bad.name", "some-server"], {}, deps)
).rejects.toThrow(/letters.*digits.*hyphens.*underscores/);
});

it("throws when alias is empty", async () => {
const deps = makeDeps();
await expect(
handleAlias(["", "some-server"], {}, deps)
).rejects.toThrow(/must not be empty/);
});

it("throws when alias contains shell metacharacters", async () => {
const deps = makeDeps();
await expect(
handleAlias(["bad$name", "some-server"], {}, deps)
).rejects.toThrow(/letters.*digits.*hyphens.*underscores/);
});

it("throws when alias exceeds max length", async () => {
const deps = makeDeps();
const longName = "a".repeat(65);
await expect(
handleAlias([longName, "some-server"], {}, deps)
).rejects.toThrow(/at most 64/);
});

it("throws when server name is empty", async () => {
const deps = makeDeps();
await expect(
handleAlias(["fs", ""], {}, deps)
).rejects.toThrow(/must not be empty/);
});

it("throws when server name is __proto__", async () => {
const deps = makeDeps();
await expect(
handleAlias(["fs", "__proto__"], {}, deps)
).rejects.toThrow(/not allowed/);
});
});

describe("--remove validation", () => {
it("validates alias name before removing", async () => {
const deps = makeDeps();
await expect(
handleAlias([], { remove: "bad$name" }, deps)
).rejects.toThrow(/letters.*digits.*hyphens.*underscores/);
expect(deps.removeAlias).not.toHaveBeenCalled();
});
});
});
54 changes: 54 additions & 0 deletions src/__tests__/commands/completions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Tests for src/commands/completions.ts
*/

import { describe, it, expect, vi } from "vitest";
import { handleCompletions } from "../../commands/completions.js";
import type { ShellType } from "../../commands/completions.js";

describe("handleCompletions", () => {
it("generates bash completions containing mcpm commands", () => {
const output = vi.fn();
handleCompletions("bash", { output });
expect(output).toHaveBeenCalledOnce();
const script = output.mock.calls[0][0] as string;
expect(script).toContain("_mcpm_completions");
expect(script).toContain("search");
expect(script).toContain("install");
expect(script).toContain("disable");
expect(script).toContain("enable");
expect(script).toContain("complete -F");
});

it("generates zsh completions with command descriptions", () => {
const output = vi.fn();
handleCompletions("zsh", { output });
const script = output.mock.calls[0][0] as string;
expect(script).toContain("compdef _mcpm mcpm");
expect(script).toContain("search:Search");
expect(script).toContain("disable:Disable");
});

it("generates fish completions with subcommand completions", () => {
const output = vi.fn();
handleCompletions("fish", { output });
const script = output.mock.calls[0][0] as string;
expect(script).toContain("complete -c mcpm");
expect(script).toContain("__fish_use_subcommand");
expect(script).toContain("search");
expect(script).toContain("disable");
});

it("includes client IDs in completions", () => {
const shells: ShellType[] = ["bash", "zsh", "fish"];
for (const shell of shells) {
const output = vi.fn();
handleCompletions(shell, { output });
const script = output.mock.calls[0][0] as string;
expect(script).toContain("claude-desktop");
expect(script).toContain("cursor");
expect(script).toContain("vscode");
expect(script).toContain("windsurf");
}
});
});
165 changes: 165 additions & 0 deletions src/__tests__/commands/disable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* Tests for src/commands/disable.ts
*/

import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ClientId } from "../../config/paths.js";
import type { ConfigAdapter, McpServerEntry } from "../../config/adapters/index.js";
import { handleDisable } from "../../commands/disable.js";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeAdapter(
clientId: ClientId,
servers: Record<string, McpServerEntry> = {}
): ConfigAdapter {
return {
clientId,
read: vi.fn().mockResolvedValue(servers),
addServer: vi.fn().mockResolvedValue(undefined),
removeServer: vi.fn().mockResolvedValue(undefined),
setServerDisabled: vi.fn().mockResolvedValue(undefined),
};
}

interface DisableDeps {
detectClients: () => Promise<ClientId[]>;
getAdapter: (clientId: ClientId) => ConfigAdapter;
getConfigPath: (clientId: ClientId) => string;
output: (text: string) => void;
}

function makeDeps(overrides: Partial<DisableDeps> = {}): DisableDeps {
return {
detectClients: vi.fn().mockResolvedValue(["claude-desktop"] as ClientId[]),
getAdapter: vi.fn().mockImplementation((id: ClientId) =>
makeAdapter(id, { "my-server": { command: "npx", args: ["-y", "my-server"] } })
),
getConfigPath: vi.fn().mockImplementation((id: ClientId) => `/fake/${id}/config.json`),
output: vi.fn(),
...overrides,
};
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("handleDisable", () => {
beforeEach(() => {
vi.resetAllMocks();
});

it("disables a server in the detected client", async () => {
const adapter = makeAdapter("claude-desktop", {
"my-server": { command: "npx" },
});
const deps = makeDeps({ getAdapter: vi.fn().mockReturnValue(adapter) });
await handleDisable("my-server", {}, deps);
expect(adapter.setServerDisabled).toHaveBeenCalledWith(
"/fake/claude-desktop/config.json",
"my-server",
true
);
});

it("outputs success message", async () => {
const deps = makeDeps();
await handleDisable("my-server", {}, deps);
const output = (deps.output as ReturnType<typeof vi.fn>).mock.calls.flat().join(" ");
expect(output).toMatch(/Disabled.*my-server/);
});

it("throws when server not found", async () => {
const adapter = makeAdapter("claude-desktop", {});
const deps = makeDeps({ getAdapter: vi.fn().mockReturnValue(adapter) });
await expect(handleDisable("ghost", {}, deps)).rejects.toThrow(/not found/i);
});

it("reports already disabled when server has disabled: true", async () => {
const adapter = makeAdapter("claude-desktop", {
"my-server": { command: "npx", disabled: true },
});
const deps = makeDeps({ getAdapter: vi.fn().mockReturnValue(adapter) });
await handleDisable("my-server", {}, deps);
const output = (deps.output as ReturnType<typeof vi.fn>).mock.calls.flat().join(" ");
expect(output).toMatch(/already disabled/i);
expect(adapter.setServerDisabled).not.toHaveBeenCalled();
});

it("respects --client filter", async () => {
const claudeAdapter = makeAdapter("claude-desktop", {
"my-server": { command: "npx" },
});
const cursorAdapter = makeAdapter("cursor", {
"my-server": { command: "npx" },
});
const deps = makeDeps({
detectClients: vi.fn().mockResolvedValue(["claude-desktop", "cursor"] as ClientId[]),
getAdapter: vi.fn().mockImplementation((id: ClientId) =>
id === "claude-desktop" ? claudeAdapter : cursorAdapter
),
});
await handleDisable("my-server", { client: "cursor" }, deps);
expect(cursorAdapter.setServerDisabled).toHaveBeenCalled();
expect(claudeAdapter.setServerDisabled).not.toHaveBeenCalled();
});

it("throws when --client is not installed", async () => {
const deps = makeDeps({
detectClients: vi.fn().mockResolvedValue(["claude-desktop"] as ClientId[]),
});
await expect(
handleDisable("my-server", { client: "vscode" }, deps)
).rejects.toThrow(/vscode.*not.*installed/i);
});

it("throws when --client is an invalid client id", async () => {
const deps = makeDeps();
await expect(
handleDisable("my-server", { client: "invalid-client" }, deps)
).rejects.toThrow(/Unknown client.*invalid-client/);
});

it("only disables the enabled client when mixed state", async () => {
const claudeAdapter = makeAdapter("claude-desktop", {
"my-server": { command: "npx", disabled: true },
});
const cursorAdapter = makeAdapter("cursor", {
"my-server": { command: "npx" },
});
const deps = makeDeps({
detectClients: vi.fn().mockResolvedValue(["claude-desktop", "cursor"] as ClientId[]),
getAdapter: vi.fn().mockImplementation((id: ClientId) =>
id === "claude-desktop" ? claudeAdapter : cursorAdapter
),
});
await handleDisable("my-server", {}, deps);
expect(claudeAdapter.setServerDisabled).not.toHaveBeenCalled();
expect(cursorAdapter.setServerDisabled).toHaveBeenCalledWith(
"/fake/cursor/config.json",
"my-server",
true
);
});

it("disables across multiple clients", async () => {
const claudeAdapter = makeAdapter("claude-desktop", {
"my-server": { command: "npx" },
});
const cursorAdapter = makeAdapter("cursor", {
"my-server": { command: "npx" },
});
const deps = makeDeps({
detectClients: vi.fn().mockResolvedValue(["claude-desktop", "cursor"] as ClientId[]),
getAdapter: vi.fn().mockImplementation((id: ClientId) =>
id === "claude-desktop" ? claudeAdapter : cursorAdapter
),
});
await handleDisable("my-server", {}, deps);
expect(claudeAdapter.setServerDisabled).toHaveBeenCalled();
expect(cursorAdapter.setServerDisabled).toHaveBeenCalled();
});
});
Loading
Loading