diff --git a/__tests__/config-core.test.js b/__tests__/config-core.test.js new file mode 100644 index 000000000..2db313297 --- /dev/null +++ b/__tests__/config-core.test.js @@ -0,0 +1,190 @@ +"use strict"; + +jest.mock("fs") +jest.mock("os", () => ({ + homedir: jest.fn(() => "/home/user"), + hostname: jest.fn(() => "host"), + type: jest.fn(() => "Linux"), + release: jest.fn(() => "5.0"), + arch: jest.fn(() => "x64"), + userInfo: jest.fn(() => ({ username: "user" })), +})) +jest.mock("../cli/plugins-store", () => ({ + getEffectivePluginCommands: jest.fn(() => []), +})) + +const { normalizeMcpServerEntry } = require("../cli/config-core") + +describe("normalizeMcpServerEntry", () => { + describe("name validation", () => { + test("returns null for null name", () => { + expect(normalizeMcpServerEntry(null, { url: "http://x" })).toBeNull() + }) + + test("returns null for empty string name", () => { + expect(normalizeMcpServerEntry("", { url: "http://x" })).toBeNull() + }) + + test("returns null for numeric name", () => { + expect(normalizeMcpServerEntry(42, { url: "http://x" })).toBeNull() + }) + + test("returns null for undefined name", () => { + expect(normalizeMcpServerEntry(undefined, { url: "http://x" })).toBeNull() + }) + }) + + describe("entry validation", () => { + test("returns null for null entry", () => { + expect(normalizeMcpServerEntry("srv", null)).toBeNull() + }) + + test("returns null for array entry", () => { + expect(normalizeMcpServerEntry("srv", ["http://x"])).toBeNull() + }) + + test("returns null for non-object entry", () => { + expect(normalizeMcpServerEntry("srv", "http://x")).toBeNull() + }) + + test("returns null for undefined entry", () => { + expect(normalizeMcpServerEntry("srv", undefined)).toBeNull() + }) + }) + + describe("url and command fields", () => { + test("includes url when it is a string", () => { + const out = normalizeMcpServerEntry("srv", { url: "http://example.com" }) + expect(out).toEqual({ name: "srv", url: "http://example.com" }) + }) + + test("omits url when it is not a string", () => { + const out = normalizeMcpServerEntry("srv", { url: 1234 }) + expect(out).not.toHaveProperty("url") + }) + + test("includes command when it is a string", () => { + const out = normalizeMcpServerEntry("srv", { command: "node" }) + expect(out).toEqual({ name: "srv", command: "node" }) + }) + + test("omits command when it is not a string", () => { + const out = normalizeMcpServerEntry("srv", { command: true }) + expect(out).not.toHaveProperty("command") + }) + }) + + describe("args and commandArgs filtering", () => { + test("includes only string elements from args", () => { + const out = normalizeMcpServerEntry("srv", { args: ["a", 2, null, "b"] }) + expect(out.args).toEqual(["a", "b"]) + }) + + test("omits args entirely when not an array", () => { + const out = normalizeMcpServerEntry("srv", { args: "bad" }) + expect(out).not.toHaveProperty("args") + }) + + test("includes only string elements from commandArgs", () => { + const out = normalizeMcpServerEntry("srv", { commandArgs: ["x", 0, "y"] }) + expect(out.commandArgs).toEqual(["x", "y"]) + }) + + test("omits commandArgs when not an array", () => { + const out = normalizeMcpServerEntry("srv", { commandArgs: {} }) + expect(out).not.toHaveProperty("commandArgs") + }) + }) + + describe("headers filtering", () => { + test("keeps string-to-string header entries", () => { + const out = normalizeMcpServerEntry("srv", { headers: { Auth: "Bearer x", Bad: 123 } }) + expect(out.headers).toEqual({ Auth: "Bearer x" }) + }) + + test("omits headers when they are an array", () => { + const out = normalizeMcpServerEntry("srv", { headers: ["bad"] }) + expect(out).not.toHaveProperty("headers") + }) + + test("omits headers when not an object", () => { + const out = normalizeMcpServerEntry("srv", { headers: "bad" }) + expect(out).not.toHaveProperty("headers") + }) + }) + + describe("env filtering", () => { + test("keeps string-to-string env entries", () => { + const out = normalizeMcpServerEntry("srv", { env: { TOKEN: "abc", NUM: 1 } }) + expect(out.env).toEqual({ TOKEN: "abc" }) + }) + + test("omits env when it is an array", () => { + const out = normalizeMcpServerEntry("srv", { env: ["bad"] }) + expect(out).not.toHaveProperty("env") + }) + }) + + describe("timeout_ms", () => { + test("includes timeout_ms for positive number", () => { + const out = normalizeMcpServerEntry("srv", { timeout_ms: 5000 }) + expect(out.timeout_ms).toBe(5000) + }) + + test("omits timeout_ms when zero", () => { + const out = normalizeMcpServerEntry("srv", { timeout_ms: 0 }) + expect(out).not.toHaveProperty("timeout_ms") + }) + + test("omits timeout_ms when negative", () => { + const out = normalizeMcpServerEntry("srv", { timeout_ms: -1 }) + expect(out).not.toHaveProperty("timeout_ms") + }) + + test("omits timeout_ms when it is a string", () => { + const out = normalizeMcpServerEntry("srv", { timeout_ms: "5000" }) + expect(out).not.toHaveProperty("timeout_ms") + }) + }) + + describe("stateful flag", () => { + test("includes stateful when exactly true", () => { + const out = normalizeMcpServerEntry("srv", { stateful: true }) + expect(out.stateful).toBe(true) + }) + + test("omits stateful when it is the string 'true'", () => { + const out = normalizeMcpServerEntry("srv", { stateful: "true" }) + expect(out).not.toHaveProperty("stateful") + }) + + test("omits stateful when false", () => { + const out = normalizeMcpServerEntry("srv", { stateful: false }) + expect(out).not.toHaveProperty("stateful") + }) + }) + + describe("combined entry", () => { + test("normalizes a full valid entry correctly", () => { + const out = normalizeMcpServerEntry("my-server", { + url: "http://mcp.example.com", + command: "node", + args: ["server.js", 0], + headers: { Authorization: "Bearer tok", "X-Num": 1 }, + env: { API_KEY: "key" }, + timeout_ms: 3000, + stateful: true, + }) + expect(out).toEqual({ + name: "my-server", + url: "http://mcp.example.com", + command: "node", + args: ["server.js"], + headers: { Authorization: "Bearer tok" }, + env: { API_KEY: "key" }, + timeout_ms: 3000, + stateful: true, + }) + }) + }) +}) diff --git a/__tests__/plugins-store.test.js b/__tests__/plugins-store.test.js index e51e2dabb..97248b6a0 100644 --- a/__tests__/plugins-store.test.js +++ b/__tests__/plugins-store.test.js @@ -9,11 +9,17 @@ jest.mock("os", () => ({ const { SUPERCLI_PLUGINS_DIR, SUPERCLI_LOCAL_LOCK_FILE, + SUPERCLI_SERVER_LOCK_FILE, LEGACY_PLUGINS_FILE, readPluginsLock, writePluginsLock, + readServerPluginsLock, + writeServerPluginsLock, listInstalledPlugins, + listServerInstalledPlugins, + getPlugin, getInstalledPluginCommands, + getServerPluginCommands, getEffectivePluginCommands, } = require("../cli/plugins-store") const path = require("path") @@ -147,4 +153,105 @@ describe("plugins-store", () => { expect(commands).toEqual([{ id: "local" }, { id: "server" }]) }) }) + + describe("readServerPluginsLock (readLockFile edge cases)", () => { + test("returns empty lock if server lock file does not exist", () => { + fs.existsSync.mockReturnValue(false) + const lock = readServerPluginsLock() + expect(lock).toEqual({ version: 1, installed: {} }) + }) + + test("returns parsed server lock if file exists", () => { + fs.existsSync.mockReturnValue(true) + const mockLock = { version: 1, installed: { s1: { name: "s1" } } } + fs.readFileSync.mockReturnValue(JSON.stringify(mockLock)) + const lock = readServerPluginsLock() + expect(lock).toEqual(mockLock) + }) + + test("returns empty lock if server lock file contains invalid JSON", () => { + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue("{broken") + const lock = readServerPluginsLock() + expect(lock).toEqual({ version: 1, installed: {} }) + }) + + test("resets installed to {} when installed is null", () => { + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue(JSON.stringify({ version: 2, installed: null })) + const lock = readServerPluginsLock() + expect(lock.installed).toEqual({}) + }) + + test("resets installed to {} when installed is a string", () => { + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue(JSON.stringify({ version: 2, installed: "bad" })) + const lock = readServerPluginsLock() + expect(lock.installed).toEqual({}) + }) + }) + + describe("writeServerPluginsLock", () => { + test("writes server lock file and ensures plugins dir exists", () => { + fs.existsSync.mockReturnValue(false) + const lock = { version: 1, installed: { s1: { name: "s1" } } } + const result = writeServerPluginsLock(lock) + expect(fs.mkdirSync).toHaveBeenCalledWith(SUPERCLI_PLUGINS_DIR, { recursive: true }) + expect(fs.writeFileSync).toHaveBeenCalledWith( + SUPERCLI_SERVER_LOCK_FILE, + expect.stringContaining('"name": "s1"') + ) + expect(result).toEqual(lock) + }) + }) + + describe("listServerInstalledPlugins", () => { + test("returns array of server-installed plugins", () => { + fs.existsSync.mockReturnValue(true) + const s1 = { name: "s1" } + fs.readFileSync.mockReturnValue(JSON.stringify({ installed: { s1 } })) + expect(listServerInstalledPlugins()).toEqual([s1]) + }) + + test("returns empty array when server lock has no plugins", () => { + fs.existsSync.mockReturnValue(false) + expect(listServerInstalledPlugins()).toEqual([]) + }) + }) + + describe("getPlugin", () => { + test("returns plugin by name when it exists", () => { + fs.existsSync.mockReturnValue(true) + const p1 = { name: "p1", version: "1.0.0" } + fs.readFileSync.mockReturnValue(JSON.stringify({ installed: { p1 } })) + expect(getPlugin("p1")).toEqual(p1) + }) + + test("returns null when plugin does not exist", () => { + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue(JSON.stringify({ installed: {} })) + expect(getPlugin("missing")).toBeNull() + }) + + test("returns null when lock file is absent", () => { + fs.existsSync.mockReturnValue(false) + expect(getPlugin("any")).toBeNull() + }) + }) + + describe("getServerPluginCommands", () => { + test("returns flattened commands from server plugins", () => { + fs.existsSync.mockReturnValue(true) + const c1 = { id: "sc1" } + fs.readFileSync.mockReturnValue(JSON.stringify({ + installed: { s1: { name: "s1", commands: [c1] } } + })) + expect(getServerPluginCommands()).toEqual([c1]) + }) + + test("returns empty array when server lock is empty", () => { + fs.existsSync.mockReturnValue(false) + expect(getServerPluginCommands()).toEqual([]) + }) + }) })