feat(lanes): add lane-mcp sync core (F1)
This commit is contained in:
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 <lane.cwd>/.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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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/<feature>/...) always
|
||||
* land under <laneDir>/.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 <sourceRepo>/.playwright-mcp/profiles/<name>/ 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 <laneDir>/.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 };
|
||||
Reference in New Issue
Block a user