|
1 | | -import { describe, expect, test } from "bun:test"; |
| 1 | +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; |
2 | 2 | import { mkdtemp, readFile, rm } from "node:fs/promises"; |
3 | | -import { tmpdir, homedir } from "node:os"; |
| 3 | +import * as realOs from "node:os"; |
4 | 4 | import { join } from "node:path"; |
5 | | -import { claudeCodeClient } from "./claude-code.ts"; |
6 | | -import { cursorClient } from "./cursor.ts"; |
7 | | -import { vscodeClient } from "./vscode.ts"; |
8 | | -import { windsurfClient } from "./windsurf.ts"; |
9 | | -import { geminiClient } from "./gemini.ts"; |
10 | 5 | import { useCaptureLog } from "../../../test/lib/stubs.ts"; |
11 | 6 |
|
| 7 | +// Every client writes under the user's home now, so redirect homedir to a |
| 8 | +// tmpdir (Bun's os.homedir() ignores $HOME) — registered before the clients |
| 9 | +// load so paths.ts binds the redirected homedir. |
| 10 | +let mockHome = realOs.tmpdir(); |
| 11 | +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); |
| 12 | +afterAll(() => mock.restore()); |
| 13 | + |
| 14 | +const { claudeCodeClient } = await import("./claude-code.ts"); |
| 15 | +const { cursorClient } = await import("./cursor.ts"); |
| 16 | +const { vscodeClient } = await import("./vscode.ts"); |
| 17 | +const { windsurfClient } = await import("./windsurf.ts"); |
| 18 | +const { geminiClient } = await import("./gemini.ts"); |
| 19 | +const { vscodeUserDir } = await import("./paths.ts"); |
| 20 | + |
12 | 21 | useCaptureLog(); |
13 | 22 |
|
14 | 23 | const URL = "https://mcp.clerk.com/mcp"; |
15 | 24 |
|
16 | | -// Path shape is part of the public contract — each client targets a specific, |
17 | | -// documented config file. Test against the format, not the absolute prefix |
18 | | -// (which depends on cwd/homedir). |
19 | | -const projectClients = [ |
20 | | - { client: claudeCodeClient, suffix: ".mcp.json", topKey: "mcpServers" }, |
21 | | - { client: cursorClient, suffix: join(".cursor", "mcp.json"), topKey: "mcpServers" }, |
22 | | - { client: vscodeClient, suffix: join(".vscode", "mcp.json"), topKey: "servers" }, |
23 | | -]; |
24 | | - |
25 | | -const userClients = [ |
| 25 | +// Path + entry shape are part of the public contract: each client targets a |
| 26 | +// specific user-global config file and encodes the server its own way. |
| 27 | +const cases = [ |
| 28 | + { |
| 29 | + name: "claude-code", |
| 30 | + client: claudeCodeClient, |
| 31 | + expectedPath: () => join(mockHome, ".claude.json"), |
| 32 | + topKey: "mcpServers", |
| 33 | + shape: { type: "http", url: URL }, |
| 34 | + }, |
| 35 | + { |
| 36 | + name: "cursor", |
| 37 | + client: cursorClient, |
| 38 | + expectedPath: () => join(mockHome, ".cursor", "mcp.json"), |
| 39 | + topKey: "mcpServers", |
| 40 | + shape: { url: URL }, |
| 41 | + }, |
26 | 42 | { |
| 43 | + name: "vscode", |
| 44 | + client: vscodeClient, |
| 45 | + expectedPath: () => join(vscodeUserDir(), "mcp.json"), |
| 46 | + topKey: "servers", |
| 47 | + shape: { type: "http", url: URL }, |
| 48 | + }, |
| 49 | + { |
| 50 | + name: "windsurf", |
27 | 51 | client: windsurfClient, |
28 | | - relPath: join(".codeium", "windsurf", "mcp_config.json"), |
| 52 | + expectedPath: () => join(mockHome, ".codeium", "windsurf", "mcp_config.json"), |
| 53 | + topKey: "mcpServers", |
| 54 | + shape: { serverUrl: URL }, |
| 55 | + }, |
| 56 | + { |
| 57 | + name: "gemini", |
| 58 | + client: geminiClient, |
| 59 | + expectedPath: () => join(mockHome, ".gemini", "settings.json"), |
29 | 60 | topKey: "mcpServers", |
| 61 | + shape: { command: "npx", args: ["-y", "mcp-remote", URL] }, |
30 | 62 | }, |
31 | | - { client: geminiClient, relPath: join(".gemini", "settings.json"), topKey: "mcpServers" }, |
32 | 63 | ]; |
33 | 64 |
|
34 | | -describe("project-scope client config paths", () => { |
35 | | - test.each(projectClients)("$client.id resolves under cwd", ({ client, suffix }) => { |
36 | | - const path = client.configPath("/tmp/foo"); |
37 | | - expect(path).toBe(join("/tmp/foo", suffix)); |
38 | | - expect(client.scope).toBe("project"); |
| 65 | +describe("client config paths + encoded shapes (homedir redirected)", () => { |
| 66 | + beforeEach(async () => { |
| 67 | + mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-clients-")); |
39 | 68 | }); |
40 | | -}); |
41 | 69 |
|
42 | | -describe("user-scope client config paths", () => { |
43 | | - test.each(userClients)("$client.id resolves under homedir", ({ client, relPath }) => { |
44 | | - expect(client.configPath("/ignored")).toBe(join(homedir(), relPath)); |
45 | | - expect(client.scope).toBe("user"); |
| 70 | + afterEach(async () => { |
| 71 | + await rm(mockHome, { recursive: true, force: true }); |
46 | 72 | }); |
47 | | -}); |
48 | 73 |
|
49 | | -describe("per-client encoded shape (written JSON)", () => { |
50 | | - // Project clients are easiest to exercise — write into a tmpdir-as-cwd and |
51 | | - // assert what landed under their top-level key. |
52 | | - test.each(projectClients)( |
53 | | - "$client.id writes the expected entry shape", |
54 | | - async ({ client, topKey }) => { |
55 | | - const cwd = await mkdtemp(join(tmpdir(), `clerk-mcp-${client.id}-`)); |
56 | | - try { |
57 | | - await client.upsert({ name: "clerk", url: URL }, cwd, false); |
58 | | - const text = await readFile(client.configPath(cwd), "utf8"); |
59 | | - const parsed = JSON.parse(text) as Record<string, Record<string, unknown>>; |
60 | | - expect(parsed[topKey]).toBeDefined(); |
61 | | - expect(parsed[topKey]?.clerk).toBeDefined(); |
62 | | - } finally { |
63 | | - await rm(cwd, { recursive: true, force: true }); |
64 | | - } |
65 | | - }, |
66 | | - ); |
67 | | - |
68 | | - test("claude-code emits the MCP-spec HTTP transport shape", async () => { |
69 | | - const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cc-shape-")); |
70 | | - try { |
71 | | - await claudeCodeClient.upsert({ name: "clerk", url: URL }, cwd, false); |
72 | | - const parsed = JSON.parse(await readFile(claudeCodeClient.configPath(cwd), "utf8")) as { |
73 | | - mcpServers: { clerk: { type: string; url: string } }; |
74 | | - }; |
75 | | - expect(parsed.mcpServers.clerk).toEqual({ type: "http", url: URL }); |
76 | | - } finally { |
77 | | - await rm(cwd, { recursive: true, force: true }); |
78 | | - } |
| 74 | + test.each(cases)("$name is user-scoped at its documented path", ({ client, expectedPath }) => { |
| 75 | + expect(client.scope).toBe("user"); |
| 76 | + expect(client.configPath("/ignored")).toBe(expectedPath()); |
79 | 77 | }); |
80 | 78 |
|
81 | | - test("cursor emits a bare {url} entry", async () => { |
82 | | - const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cu-shape-")); |
83 | | - try { |
84 | | - await cursorClient.upsert({ name: "clerk", url: URL }, cwd, false); |
85 | | - const parsed = JSON.parse(await readFile(cursorClient.configPath(cwd), "utf8")) as { |
86 | | - mcpServers: { clerk: { url: string } }; |
87 | | - }; |
88 | | - expect(parsed.mcpServers.clerk).toEqual({ url: URL }); |
89 | | - } finally { |
90 | | - await rm(cwd, { recursive: true, force: true }); |
91 | | - } |
| 79 | + test.each(cases)("$name writes the documented entry shape", async ({ client, topKey, shape }) => { |
| 80 | + await client.upsert({ name: "clerk", url: URL }, "/ignored", false); |
| 81 | + const parsed = JSON.parse(await readFile(client.configPath("/ignored"), "utf8")) as Record< |
| 82 | + string, |
| 83 | + Record<string, unknown> |
| 84 | + >; |
| 85 | + expect(parsed[topKey]?.clerk).toEqual(shape); |
92 | 86 | }); |
93 | 87 |
|
94 | | - test("vscode emits under top-level `servers`, not `mcpServers`", async () => { |
95 | | - const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-vs-shape-")); |
96 | | - try { |
97 | | - await vscodeClient.upsert({ name: "clerk", url: URL }, cwd, false); |
98 | | - const parsed = JSON.parse(await readFile(vscodeClient.configPath(cwd), "utf8")) as { |
99 | | - servers: { clerk: { type: string; url: string } }; |
100 | | - mcpServers?: unknown; |
101 | | - }; |
102 | | - expect(parsed.servers.clerk).toEqual({ type: "http", url: URL }); |
103 | | - expect(parsed.mcpServers).toBeUndefined(); |
104 | | - } finally { |
105 | | - await rm(cwd, { recursive: true, force: true }); |
106 | | - } |
| 88 | + test("vscode writes under `servers`, not `mcpServers`", async () => { |
| 89 | + await vscodeClient.upsert({ name: "clerk", url: URL }, "/ignored", false); |
| 90 | + const parsed = JSON.parse(await readFile(vscodeClient.configPath("/ignored"), "utf8")) as { |
| 91 | + servers?: unknown; |
| 92 | + mcpServers?: unknown; |
| 93 | + }; |
| 94 | + expect(parsed.servers).toBeDefined(); |
| 95 | + expect(parsed.mcpServers).toBeUndefined(); |
107 | 96 | }); |
108 | 97 | }); |
0 commit comments