diff --git a/packages/opencode/test/mcp/config.test.ts b/packages/opencode/test/mcp/config.test.ts new file mode 100644 index 0000000000..4a958c2b78 --- /dev/null +++ b/packages/opencode/test/mcp/config.test.ts @@ -0,0 +1,166 @@ +import { describe, test, expect } from "bun:test" +import { tmpdir } from "../fixture/fixture" +import { mkdir, writeFile, readFile } from "fs/promises" +import path from "path" +import { + resolveConfigPath, + addMcpToConfig, + removeMcpFromConfig, + listMcpInConfig, + findAllConfigPaths, +} from "../../src/mcp/config" + +describe("MCP config: resolveConfigPath", () => { + test("returns .altimate-code subdir config when it exists", async () => { + await using tmp = await tmpdir() + const configDir = path.join(tmp.path, ".altimate-code") + await mkdir(configDir, { recursive: true }) + await writeFile(path.join(configDir, "altimate-code.json"), "{}") + const result = await resolveConfigPath(tmp.path) + expect(result).toBe(path.join(configDir, "altimate-code.json")) + }) + + test("prefers .altimate-code over .opencode subdir", async () => { + await using tmp = await tmpdir() + await mkdir(path.join(tmp.path, ".altimate-code"), { recursive: true }) + await writeFile(path.join(tmp.path, ".altimate-code", "altimate-code.json"), "{}") + await mkdir(path.join(tmp.path, ".opencode"), { recursive: true }) + await writeFile(path.join(tmp.path, ".opencode", "opencode.json"), "{}") + const result = await resolveConfigPath(tmp.path) + expect(result).toBe(path.join(tmp.path, ".altimate-code", "altimate-code.json")) + }) + + test("falls back to root-level config", async () => { + await using tmp = await tmpdir() + await writeFile(path.join(tmp.path, "opencode.json"), "{}") + const result = await resolveConfigPath(tmp.path) + expect(result).toBe(path.join(tmp.path, "opencode.json")) + }) + + test("returns first candidate path when no config exists", async () => { + await using tmp = await tmpdir() + const result = await resolveConfigPath(tmp.path) + expect(result).toBe(path.join(tmp.path, ".altimate-code", "altimate-code.json")) + }) + + test("global=true skips subdirectory configs", async () => { + await using tmp = await tmpdir() + await mkdir(path.join(tmp.path, ".altimate-code"), { recursive: true }) + await writeFile(path.join(tmp.path, ".altimate-code", "altimate-code.json"), "{}") + await writeFile(path.join(tmp.path, "opencode.json"), "{}") + const result = await resolveConfigPath(tmp.path, true) + expect(result).toBe(path.join(tmp.path, "opencode.json")) + }) +}) + +describe("MCP config: addMcpToConfig + removeMcpFromConfig round-trip", () => { + test("adds MCP server to empty config", async () => { + await using tmp = await tmpdir() + const configPath = path.join(tmp.path, "opencode.json") + await addMcpToConfig("test-server", { type: "local", command: ["node", "server.js"] } as any, configPath) + const content = JSON.parse(await readFile(configPath, "utf-8")) + expect(content.mcp["test-server"]).toMatchObject({ type: "local", command: ["node", "server.js"] }) + }) + + test("adds MCP server to existing config preserving other fields", async () => { + await using tmp = await tmpdir() + const configPath = path.join(tmp.path, "opencode.json") + await writeFile(configPath, JSON.stringify({ provider: { default: "anthropic" } })) + await addMcpToConfig("my-server", { type: "remote", url: "https://example.com" } as any, configPath) + const content = JSON.parse(await readFile(configPath, "utf-8")) + expect(content.provider.default).toBe("anthropic") + expect(content.mcp["my-server"].url).toBe("https://example.com") + }) + + test("remove returns false for nonexistent config file", async () => { + await using tmp = await tmpdir() + const result = await removeMcpFromConfig("nope", path.join(tmp.path, "missing.json")) + expect(result).toBe(false) + }) + + test("remove returns false for nonexistent server name", async () => { + await using tmp = await tmpdir() + const configPath = path.join(tmp.path, "opencode.json") + await writeFile(configPath, JSON.stringify({ mcp: { existing: { type: "local", command: ["x"] } } })) + const result = await removeMcpFromConfig("nonexistent", configPath) + expect(result).toBe(false) + }) + + test("add then remove round-trips correctly", async () => { + await using tmp = await tmpdir() + const configPath = path.join(tmp.path, "opencode.json") + await addMcpToConfig("ephemeral", { type: "local", command: ["test"] } as any, configPath) + const listed = await listMcpInConfig(configPath) + expect(listed).toContain("ephemeral") + const removed = await removeMcpFromConfig("ephemeral", configPath) + expect(removed).toBe(true) + const after = await listMcpInConfig(configPath) + expect(after).not.toContain("ephemeral") + }) +}) + +describe("MCP config: listMcpInConfig", () => { + test("returns empty array for missing file", async () => { + await using tmp = await tmpdir() + const result = await listMcpInConfig(path.join(tmp.path, "nope.json")) + expect(result).toEqual([]) + }) + + test("returns empty array for config without mcp key", async () => { + await using tmp = await tmpdir() + const configPath = path.join(tmp.path, "opencode.json") + await writeFile(configPath, JSON.stringify({ provider: {} })) + const result = await listMcpInConfig(configPath) + expect(result).toEqual([]) + }) + + test("lists all server names", async () => { + await using tmp = await tmpdir() + const configPath = path.join(tmp.path, "opencode.json") + await writeFile( + configPath, + JSON.stringify({ + mcp: { + alpha: { type: "local", command: ["a"] }, + beta: { type: "remote", url: "https://b.com" }, + }, + }), + ) + const result = await listMcpInConfig(configPath) + expect(result).toEqual(expect.arrayContaining(["alpha", "beta"])) + expect(result).toHaveLength(2) + }) +}) + +describe("MCP config: findAllConfigPaths", () => { + test("returns paths from both project and global dirs", async () => { + await using projTmp = await tmpdir() + await using globalTmp = await tmpdir() + await writeFile(path.join(projTmp.path, "opencode.json"), "{}") + await writeFile(path.join(globalTmp.path, "altimate-code.json"), "{}") + const result = await findAllConfigPaths(projTmp.path, globalTmp.path) + expect(result).toContain(path.join(projTmp.path, "opencode.json")) + expect(result).toContain(path.join(globalTmp.path, "altimate-code.json")) + }) + + test("includes project subdirs but not global subdirs", async () => { + await using projTmp = await tmpdir() + await using globalTmp = await tmpdir() + // Create config in project .opencode subdir + await mkdir(path.join(projTmp.path, ".opencode"), { recursive: true }) + await writeFile(path.join(projTmp.path, ".opencode", "opencode.json"), "{}") + // Create config in global .opencode subdir (should NOT be found) + await mkdir(path.join(globalTmp.path, ".opencode"), { recursive: true }) + await writeFile(path.join(globalTmp.path, ".opencode", "opencode.json"), "{}") + const result = await findAllConfigPaths(projTmp.path, globalTmp.path) + expect(result).toContain(path.join(projTmp.path, ".opencode", "opencode.json")) + expect(result).not.toContain(path.join(globalTmp.path, ".opencode", "opencode.json")) + }) + + test("returns empty when no config files exist", async () => { + await using projTmp = await tmpdir() + await using globalTmp = await tmpdir() + const result = await findAllConfigPaths(projTmp.path, globalTmp.path) + expect(result).toEqual([]) + }) +}) diff --git a/packages/opencode/test/util/locale.test.ts b/packages/opencode/test/util/locale.test.ts new file mode 100644 index 0000000000..9a0791983f --- /dev/null +++ b/packages/opencode/test/util/locale.test.ts @@ -0,0 +1,81 @@ +import { describe, test, expect } from "bun:test" +import { Locale } from "../../src/util/locale" + +describe("Locale.number", () => { + test("formats millions", () => { + expect(Locale.number(1500000)).toBe("1.5M") + expect(Locale.number(1000000)).toBe("1.0M") + }) + + test("formats thousands", () => { + expect(Locale.number(1500)).toBe("1.5K") + expect(Locale.number(1000)).toBe("1.0K") + }) + + test("boundary: 999999 renders as K not M", () => { + expect(Locale.number(999999)).toBe("1000.0K") + }) + + test("returns raw string for small numbers", () => { + expect(Locale.number(999)).toBe("999") + expect(Locale.number(0)).toBe("0") + }) +}) + +describe("Locale.duration", () => { + test("milliseconds", () => { + expect(Locale.duration(500)).toBe("500ms") + expect(Locale.duration(0)).toBe("0ms") + }) + + test("seconds", () => { + expect(Locale.duration(1500)).toBe("1.5s") + expect(Locale.duration(2500)).toBe("2.5s") + }) + + test("minutes and seconds", () => { + expect(Locale.duration(90000)).toBe("1m 30s") + expect(Locale.duration(3599999)).toBe("59m 59s") + }) + + test("hours and minutes", () => { + expect(Locale.duration(3600000)).toBe("1h 0m") + expect(Locale.duration(5400000)).toBe("1h 30m") + }) + + // BUG: Locale.duration >=24h has swapped days/hours calculation. + // hours = Math.floor(input / 3600000) gives total hours (25), not remainder. + // days = Math.floor((input % 3600000) / 86400000) always yields 0. + // Correct: days = Math.floor(input / 86400000), hours = Math.floor((input % 86400000) / 3600000) + // 90000000ms = 25h = 1d 1h — should display "1d 1h" + // See: https://github.com/AltimateAI/altimate-code/issues/368 + test.skip("FIXME: days and hours for >=24h are calculated correctly", () => { + expect(Locale.duration(90000000)).toBe("1d 1h") + }) +}) + +describe("Locale.truncateMiddle", () => { + test("returns original if short enough", () => { + expect(Locale.truncateMiddle("hello", 35)).toBe("hello") + }) + + test("truncates long strings with ellipsis in middle", () => { + const long = "abcdefghijklmnopqrstuvwxyz1234567890abcdef" + const result = Locale.truncateMiddle(long, 20) + expect(result.length).toBe(20) + expect(result).toContain("\u2026") + expect(result.startsWith("abcdefghij")).toBe(true) + expect(result.endsWith("bcdef")).toBe(true) + }) +}) + +describe("Locale.pluralize", () => { + test("uses singular for count=1", () => { + expect(Locale.pluralize(1, "{} item", "{} items")).toBe("1 item") + }) + + test("uses plural for count!=1", () => { + expect(Locale.pluralize(0, "{} item", "{} items")).toBe("0 items") + expect(Locale.pluralize(5, "{} item", "{} items")).toBe("5 items") + }) +})