/** * @file Tests for server/lib/lane-mcp.js: relocating a source repo's * ~/.claude.json MCP server config into a lane's own .mcp.json, pinning * Playwright's proof output dir, and seeding Chromium profiles. Uses a real * temp $HOME (via process.env.HOME override) so the module's own * os.homedir()-based path resolution is exercised, not mocked around. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-mcp-")); const FAKE_HOME = path.join(SUITE_ROOT, "home"); fs.mkdirSync(FAKE_HOME, { recursive: true }); process.env.HOME = FAKE_HOME; const laneMcp = require("../lib/lane-mcp"); after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true })); function writeClaudeJson(projects) { fs.writeFileSync(path.join(FAKE_HOME, ".claude.json"), JSON.stringify({ projects }, null, 2)); } let laneSeq = 0; function makeLane(sourceRepo) { laneSeq += 1; const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`); fs.mkdirSync(cwd, { recursive: true }); return { cwd, source_repo: sourceRepo }; } describe("syncMcp — reading and relocating", () => { it("throws ENOMCPCONFIG when the source repo has no mcpServers", async () => { const sourceRepo = path.join(SUITE_ROOT, "src-none"); fs.mkdirSync(sourceRepo, { recursive: true }); writeClaudeJson({ [sourceRepo]: {} }); await assert.rejects(() => laneMcp.syncMcp(makeLane(sourceRepo)), { code: "ENOMCPCONFIG", }); }); it("throws ENOMCPCONFIG when ~/.claude.json doesn't exist at all", async () => { fs.rmSync(path.join(FAKE_HOME, ".claude.json"), { force: true }); await assert.rejects(() => laneMcp.syncMcp(makeLane(path.join(SUITE_ROOT, "src-missing"))), { code: "ENOMCPCONFIG", }); }); it("relocates absolute paths under source_repo to the lane's cwd", async () => { const sourceRepo = path.join(SUITE_ROOT, "src-relocate"); fs.mkdirSync(sourceRepo, { recursive: true }); writeClaudeJson({ [sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [ "-y", "@playwright/mcp@latest", "--user-data-dir", `${sourceRepo}/.playwright-mcp/profiles/default`, ], }, }, }, }); const lane = makeLane(sourceRepo); const result = await laneMcp.syncMcp(lane); assert.deepEqual(result.servers, ["playwright"]); const written = JSON.parse(fs.readFileSync(path.join(lane.cwd, ".mcp.json"), "utf8")); assert.equal( written.mcpServers.playwright.args[3], `${lane.cwd}/.playwright-mcp/profiles/default` ); }); }); describe("syncMcp — Playwright output-dir pinning", () => { it("pins --output-dir only when a @playwright/mcp server doesn't already declare one", async () => { const sourceRepo = path.join(SUITE_ROOT, "src-pin"); fs.mkdirSync(sourceRepo, { recursive: true }); writeClaudeJson({ [sourceRepo]: { mcpServers: { playwright: { command: "npx", args: ["-y", "@playwright/mcp@latest"] }, "playwright-custom": { command: "npx", args: ["-y", "@playwright/mcp@latest", "--output-dir", "/already/set"], }, "not-playwright": { command: "npx", args: ["-y", "some-other-mcp"] }, }, }, }); const lane = makeLane(sourceRepo); await laneMcp.syncMcp(lane); const written = JSON.parse(fs.readFileSync(path.join(lane.cwd, ".mcp.json"), "utf8")); assert.deepEqual(written.mcpServers.playwright.args.slice(-2), [ "--output-dir", path.join(lane.cwd, ".playwright-mcp"), ]); assert.deepEqual(written.mcpServers["playwright-custom"].args.slice(-2), [ "--output-dir", "/already/set", ]); assert.equal(written.mcpServers["not-playwright"].args.includes("--output-dir"), false); }); }); describe("syncMcp — profile seeding", () => { it("seeds a source profile into the lane and strips Singleton* lock files", async () => { const sourceRepo = path.join(SUITE_ROOT, "src-profiles"); const srcProfileDir = path.join(sourceRepo, ".playwright-mcp", "profiles", "default"); fs.mkdirSync(srcProfileDir, { recursive: true }); fs.writeFileSync(path.join(srcProfileDir, "Cookies"), "fake-cookie-db"); fs.writeFileSync(path.join(srcProfileDir, "SingletonLock"), "stale-lock"); writeClaudeJson({ [sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } }, }); const lane = makeLane(sourceRepo); const result = await laneMcp.syncMcp(lane); assert.deepEqual(result.profilesSeeded, ["default"]); const destDir = path.join(lane.cwd, ".playwright-mcp", "profiles", "default"); assert.ok(fs.existsSync(path.join(destDir, "Cookies"))); assert.ok(!fs.existsSync(path.join(destDir, "SingletonLock"))); }); it("never overwrites a profile that already exists at the destination", async () => { const sourceRepo = path.join(SUITE_ROOT, "src-profiles-2"); const srcProfileDir = path.join(sourceRepo, ".playwright-mcp", "profiles", "default"); fs.mkdirSync(srcProfileDir, { recursive: true }); fs.writeFileSync(path.join(srcProfileDir, "Cookies"), "new-cookie-db"); writeClaudeJson({ [sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } }, }); const lane = makeLane(sourceRepo); const destDir = path.join(lane.cwd, ".playwright-mcp", "profiles", "default"); fs.mkdirSync(destDir, { recursive: true }); fs.writeFileSync(path.join(destDir, "Cookies"), "already-logged-in-cookie-db"); const result = await laneMcp.syncMcp(lane); assert.deepEqual(result.profilesSeeded, []); assert.equal( fs.readFileSync(path.join(destDir, "Cookies"), "utf8"), "already-logged-in-cookie-db" ); }); }); describe("syncMcp — .git/info/exclude idempotency", () => { it("appends .mcp.json once and does not duplicate it on a second call", async () => { const sourceRepo = path.join(SUITE_ROOT, "src-exclude"); fs.mkdirSync(sourceRepo, { recursive: true }); writeClaudeJson({ [sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } }, }); const lane = makeLane(sourceRepo); fs.mkdirSync(path.join(lane.cwd, ".git"), { recursive: true }); await laneMcp.syncMcp(lane); await laneMcp.syncMcp(lane); const exclude = fs.readFileSync(path.join(lane.cwd, ".git", "info", "exclude"), "utf8"); const matches = exclude.split("\n").filter((line) => line === ".mcp.json"); assert.equal(matches.length, 1); }); });