952e8584f0
- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js) cuts git's fresh hunk down to it, folding the other blocks into the side being checked; LaneDiffView hangs one review bar per block (unified + split) and its arrows walk blocks. - git exclude: entries AgentHub writes for a lane go to the COMMON git dir with the lane's path prefix (server/lib/git-exclude.js, used by router-apply and lane-mcp) — a linked worktree's own info/exclude is never read by git, and a subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree lane or creates a stray .git in a non-repo lane. - codex lanes carry no pipeline: codex-ingest mirrors only idle/running, codex-nudge is removed, agents install writes nothing for codex, the Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and every server start clears leftover codex stage data and the old AGENTS.md stage block. - Changes panel: a whole file opens for reading and enters the editor on a click on its text (caret at the clicked line), not automatically. - macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by scripts/service-manager.js). - Adds the AgentHub introduction slides and their image-embedding script.
241 lines
8.9 KiB
JavaScript
241 lines
8.9 KiB
JavaScript
/**
|
|
* @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 { execFileSync } = require("node:child_process");
|
|
|
|
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-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`
|
|
);
|
|
});
|
|
|
|
it("falls back to the lane's own cwd when source_repo is null (adopted lane)", async () => {
|
|
const lane = makeLane(null);
|
|
writeClaudeJson({ [lane.cwd]: { mcpServers: { playwright: { command: "npx", args: [] } } } });
|
|
const result = await laneMcp.syncMcp(lane);
|
|
assert.deepEqual(result.servers, ["playwright"]);
|
|
});
|
|
});
|
|
|
|
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 — keeping .mcp.json out of git", () => {
|
|
// Real repos, asserted through `git status`: git reads info/exclude from the
|
|
// COMMON git dir only, which a fake `.git` dir cannot show.
|
|
// A pre-commit hook running this suite exports GIT_INDEX_FILE & co., which
|
|
// would point these fixture calls at the outer repo.
|
|
const git = (cwd, ...args) => {
|
|
const env = { ...process.env };
|
|
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) {
|
|
delete env[name];
|
|
}
|
|
return execFileSync("git", args, { cwd, env, encoding: "utf8" });
|
|
};
|
|
const untracked = (cwd) => git(cwd, "status", "--porcelain").split("\n").filter(Boolean);
|
|
|
|
function mcpLane(cwd) {
|
|
const sourceRepo = path.join(SUITE_ROOT, "src-exclude");
|
|
fs.mkdirSync(sourceRepo, { recursive: true });
|
|
writeClaudeJson({
|
|
[sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } },
|
|
});
|
|
return { cwd, source_repo: sourceRepo };
|
|
}
|
|
|
|
function makeRepo() {
|
|
const repo = makeLane(null).cwd;
|
|
git(repo, "init", "-q");
|
|
git(
|
|
repo,
|
|
"-c",
|
|
"user.name=t",
|
|
"-c",
|
|
"user.email=t@t",
|
|
"commit",
|
|
"-q",
|
|
"--allow-empty",
|
|
"-m",
|
|
"init"
|
|
);
|
|
return repo;
|
|
}
|
|
|
|
it("hides .mcp.json, once, however many times it syncs", async () => {
|
|
const repo = makeRepo();
|
|
await laneMcp.syncMcp(mcpLane(repo));
|
|
await laneMcp.syncMcp(mcpLane(repo));
|
|
|
|
assert.deepEqual(untracked(repo), []);
|
|
const exclude = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8");
|
|
assert.equal(exclude.split("\n").filter((line) => line.includes(".mcp.json")).length, 1);
|
|
});
|
|
|
|
it("hides it in a worktree lane, whose .git is a pointer file", async () => {
|
|
const repo = makeRepo();
|
|
const wt = path.join(SUITE_ROOT, `wt-${laneSeq}`);
|
|
git(repo, "worktree", "add", "-q", "-b", "lane1", wt);
|
|
await laneMcp.syncMcp(mcpLane(wt));
|
|
assert.deepEqual(untracked(wt), []);
|
|
});
|
|
|
|
it("hides it in a lane that is a subdirectory of its repo", async () => {
|
|
const repo = makeRepo();
|
|
const sub = path.join(repo, "packages", "app");
|
|
fs.mkdirSync(sub, { recursive: true });
|
|
await laneMcp.syncMcp(mcpLane(sub));
|
|
assert.deepEqual(untracked(repo), []);
|
|
});
|
|
|
|
it("creates no .git in a lane that is not a repo", async () => {
|
|
const cwd = makeLane(null).cwd;
|
|
await laneMcp.syncMcp(mcpLane(cwd));
|
|
assert.ok(fs.existsSync(path.join(cwd, ".mcp.json")));
|
|
assert.equal(fs.existsSync(path.join(cwd, ".git")), false);
|
|
});
|
|
});
|