diff --git a/server/__tests__/lane-mcp.test.js b/server/__tests__/lane-mcp.test.js new file mode 100644 index 0000000..e69816f --- /dev/null +++ b/server/__tests__/lane-mcp.test.js @@ -0,0 +1,176 @@ +/** + * @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); + }); +}); diff --git a/server/lib/lane-mcp.js b/server/lib/lane-mcp.js new file mode 100644 index 0000000..a11372d --- /dev/null +++ b/server/lib/lane-mcp.js @@ -0,0 +1,138 @@ +/** + * @file Gives a lane the same MCP servers as its source repo: reads the + * source repo's already-configured mcpServers from ~/.claude.json (normal + * Claude Code project-scope config — the human sets this up once, the same + * way they would for any project), relocates any absolute path under the + * source repo to the lane's own directory, pins a Playwright server's proof + * output dir, and writes /.mcp.json. Also seeds the lane's + * Chromium browser profiles from the source repo's own (preserves saved + * logins — a QA account only needs to log in once per machine). + * + * Deliberately does NOT write any permission or settings.local.json content + * — see the F1 design spec's Scope section for why. Plain file I/O; no git + * calls, unlike lane-sync.js (E2) or lane-agents.js (E3). + * @author Nguyễn Ngọc Trí Vĩ + */ + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +/** Read the source repo's mcpServers from ~/.claude.json. Throws + * ENOMCPCONFIG if the file is missing or the project has none declared — + * a lane with zero MCP servers synced would silently break Stage 3/6 much + * later, at a far less debuggable point, so this fails loud and early. */ +function readSourceMcpServers(sourceRepo) { + const claudeJsonPath = path.join(os.homedir(), ".claude.json"); + let cfg; + try { + cfg = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8")); + } catch { + cfg = null; + } + const servers = cfg?.projects?.[sourceRepo]?.mcpServers; + if (!servers || !Object.keys(servers).length) { + throw Object.assign( + new Error( + `no mcpServers configured for source repo ${sourceRepo} in ~/.claude.json — ` + + `configure them there first (see the ship-feature-lane skill's Setup section)` + ), + { code: "ENOMCPCONFIG" } + ); + } + return servers; +} + +/** Deep-walk a server config, replacing every occurrence of `sourceRepo` + * inside a string with `laneDir`. Strings, arrays, and plain objects only + * — an MCP server def never contains anything else. */ +function relocate(value, sourceRepo, laneDir) { + if (typeof value === "string") return value.split(sourceRepo).join(laneDir); + if (Array.isArray(value)) return value.map((v) => relocate(v, sourceRepo, laneDir)); + if (value && typeof value === "object") { + const out = {}; + for (const [k, v] of Object.entries(value)) out[k] = relocate(v, sourceRepo, laneDir); + return out; + } + return value; +} + +/** Pin --output-dir for a @playwright/mcp server that doesn't already + * declare one, so relative proof filenames (proof//...) always + * land under /.playwright-mcp — the one place the dashboard's + * proof gallery reads. */ +function pinPlaywrightOutputDir(servers, laneDir) { + for (const server of Object.values(servers)) { + const args = server.args; + if (!Array.isArray(args)) continue; + const isPlaywright = args.some((a) => typeof a === "string" && a.startsWith("@playwright/mcp")); + if (isPlaywright && !args.includes("--output-dir")) { + server.args = [...args, "--output-dir", path.join(laneDir, ".playwright-mcp")]; + } + } +} + +/** Copy each /.playwright-mcp/profiles// into the lane + * ONLY if that name doesn't already exist there — an existing profile + * means a session already logged in with it; never clobber that. Strips + * Singleton* lock files from the freshly-seeded copy (stale locks from the + * source's own last browser process would make the lane's browser refuse + * to start, thinking another instance already holds the profile). */ +function seedProfiles(sourceRepo, laneDir) { + const srcProfiles = path.join(sourceRepo, ".playwright-mcp", "profiles"); + if (!fs.existsSync(srcProfiles)) return []; + + const seeded = []; + for (const name of fs.readdirSync(srcProfiles)) { + const src = path.join(srcProfiles, name); + if (!fs.statSync(src).isDirectory()) continue; + const dest = path.join(laneDir, ".playwright-mcp", "profiles", name); + if (fs.existsSync(dest)) continue; + + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.cpSync(src, dest, { recursive: true }); + for (const entry of fs.readdirSync(dest)) { + if (entry.startsWith("Singleton")) fs.rmSync(path.join(dest, entry), { force: true }); + } + seeded.push(name); + } + return seeded; +} + +/** Idempotently append a line to /.git/info/exclude — same + * read-existing-then-append-if-missing pattern E2/E3 already use for + * .git/info/attributes and .git/info/exclude. .mcp.json sits at the lane + * root (not inside .git), so this needs no --git-common-dir resolution — + * the exclude file itself is always local to this lane's own working copy. */ +function excludeFromGit(laneDir, line) { + const excludePath = path.join(laneDir, ".git", "info", "exclude"); + fs.mkdirSync(path.dirname(excludePath), { recursive: true }); + const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : ""; + const lines = existing.split("\n").filter(Boolean); + if (!lines.includes(line)) { + lines.push(line); + fs.writeFileSync(excludePath, lines.join("\n") + "\n"); + } +} + +/** + * @param {{cwd: string, source_repo: string}} lane + * @returns {{servers: string[], profilesSeeded: string[]}} + */ +async function syncMcp(lane) { + const sourceServers = readSourceMcpServers(lane.source_repo); + const relocated = relocate(sourceServers, lane.source_repo, lane.cwd); + pinPlaywrightOutputDir(relocated, lane.cwd); + + fs.writeFileSync( + path.join(lane.cwd, ".mcp.json"), + JSON.stringify({ mcpServers: relocated }, null, 2) + "\n" + ); + excludeFromGit(lane.cwd, ".mcp.json"); + + const profilesSeeded = seedProfiles(lane.source_repo, lane.cwd); + + return { servers: Object.keys(relocated), profilesSeeded }; +} + +module.exports = { syncMcp };