Skip to content

Commit 14551d8

Browse files
AlbinoGeekclaude
andcommitted
test(execute-handlers): close coverage gap via fake-server harness
FastMCP has no injectable transport API, so the execute handlers were unreachable from tests. Adds test-harness.ts: a duck-typed fake server that captures addTool definitions and calls execute directly, bypassing the MCP protocol while exercising the full handler path. 17 new execute-handler tests cover: happy paths (markdown + JSON format), error propagation (not_a_git_repo, path_escapes_repository, stage_failed), filter/truncation behavior (fileFilter, maxLinesPerFile, maxCommits, paths, since), and partial-failure semantics for batch_commit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2ee8e5f commit 14551d8

4 files changed

Lines changed: 387 additions & 1 deletion

File tree

src/server/batch-commit-tool.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import { tmpdir } from "node:os";
2323
import { join } from "node:path";
2424

2525
import { isStrictlyUnderGitTop, resolvePathForRepo } from "../repo-paths.js";
26+
import { registerBatchCommitTool } from "./batch-commit-tool.js";
2627
import { spawnGitAsync } from "./git.js";
28+
import { captureTool } from "./test-harness.js";
2729

2830
// ---------------------------------------------------------------------------
2931
// Mirrors the SHA extraction regex from batch-commit-tool.ts
@@ -215,4 +217,99 @@ describe("batch_commit integration", () => {
215217
});
216218
});
217219

220+
// ---------------------------------------------------------------------------
221+
// Execute handler: end-to-end via fake server harness
222+
// ---------------------------------------------------------------------------
223+
224+
describe("batch_commit execute handler", () => {
225+
test("happy path: single commit returns markdown with sha and success header", async () => {
226+
const dir = makeRepo();
227+
writeFileSync(join(dir, "base.ts"), "const b = 0;\n");
228+
gitCmd(dir, "add", "base.ts");
229+
gitCmd(dir, "commit", "-m", "chore: base");
230+
writeFileSync(join(dir, "new.ts"), "export const x = 1;\n");
231+
232+
const run = captureTool(registerBatchCommitTool);
233+
const text = await run({
234+
workspaceRoot: dir,
235+
commits: [{ message: "feat: add new", files: ["new.ts"] }],
236+
});
237+
expect(text).toContain("1/1 committed");
238+
expect(text).toContain("feat: add new");
239+
});
240+
241+
test("json format returns structured result with ok:true", async () => {
242+
const dir = makeRepo();
243+
writeFileSync(join(dir, "base.ts"), "const b = 0;\n");
244+
gitCmd(dir, "add", "base.ts");
245+
gitCmd(dir, "commit", "-m", "chore: base");
246+
writeFileSync(join(dir, "a.ts"), "const a = 1;\n");
247+
248+
const run = captureTool(registerBatchCommitTool);
249+
const text = await run({
250+
workspaceRoot: dir,
251+
format: "json",
252+
commits: [{ message: "feat: json", files: ["a.ts"] }],
253+
});
254+
const parsed = JSON.parse(text) as { ok: boolean; committed: number; total: number };
255+
expect(parsed.ok).toBe(true);
256+
expect(parsed.committed).toBe(1);
257+
expect(parsed.total).toBe(1);
258+
});
259+
260+
test("path_escapes_repository: dotdot path → error in json response", async () => {
261+
const dir = makeRepo();
262+
263+
const run = captureTool(registerBatchCommitTool);
264+
const text = await run({
265+
workspaceRoot: dir,
266+
format: "json",
267+
commits: [{ message: "bad", files: ["../../etc/passwd"] }],
268+
});
269+
const parsed = JSON.parse(text) as { ok: boolean; results: Array<{ error: string }> };
270+
expect(parsed.ok).toBe(false);
271+
expect(parsed.results[0]?.error).toBe("path_escapes_repository");
272+
});
273+
274+
test("non-git workspaceRoot → not_a_git_repository error", async () => {
275+
const plain = mkdtempSync(join(tmpdir(), "mcp-plain-"));
276+
277+
const run = captureTool(registerBatchCommitTool);
278+
const text = await run({
279+
workspaceRoot: plain,
280+
format: "json",
281+
commits: [{ message: "noop", files: ["x.ts"] }],
282+
});
283+
const parsed = JSON.parse(text) as { error: string };
284+
expect(parsed.error).toBe("not_a_git_repository");
285+
});
286+
287+
test("multiple commits: stops on first failure, reports partial progress", async () => {
288+
const dir = makeRepo();
289+
writeFileSync(join(dir, "base.ts"), "const b = 0;\n");
290+
gitCmd(dir, "add", "base.ts");
291+
gitCmd(dir, "commit", "-m", "chore: base");
292+
writeFileSync(join(dir, "ok.ts"), "const ok = 1;\n");
293+
294+
const run = captureTool(registerBatchCommitTool);
295+
const text = await run({
296+
workspaceRoot: dir,
297+
format: "json",
298+
commits: [
299+
{ message: "feat: ok", files: ["ok.ts"] },
300+
{ message: "feat: bad", files: ["nonexistent.ts"] },
301+
],
302+
});
303+
const parsed = JSON.parse(text) as {
304+
ok: boolean;
305+
committed: number;
306+
results: Array<{ ok: boolean; error?: string }>;
307+
};
308+
expect(parsed.ok).toBe(false);
309+
expect(parsed.committed).toBe(1);
310+
expect(parsed.results[0]?.ok).toBe(true);
311+
expect(parsed.results[1]?.ok).toBe(false);
312+
});
313+
});
314+
218315
let _seq = 0;

src/server/git-diff-summary-tool.test.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ import { type ExecSyncOptionsWithStringEncoding, execFileSync } from "node:child
1919
import { mkdtempSync, writeFileSync } from "node:fs";
2020
import { tmpdir } from "node:os";
2121
import { join, matchesGlob } from "node:path";
22-
2322
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
23+
import { registerGitDiffSummaryTool } from "./git-diff-summary-tool.js";
24+
import { captureTool } from "./test-harness.js";
2425

2526
// ---------------------------------------------------------------------------
2627
// Local copies of private helpers (mirrors git-diff-summary-tool.ts)
@@ -507,3 +508,110 @@ describe("git diff integration", () => {
507508
expect(matchesAnyPattern("src/index.ts", DEFAULT_EXCLUDE_PATTERNS)).toBe(false);
508509
});
509510
});
511+
512+
// ---------------------------------------------------------------------------
513+
// Execute handler: end-to-end via fake server harness
514+
// ---------------------------------------------------------------------------
515+
516+
describe("git_diff_summary execute handler", () => {
517+
test("unstaged changes appear in markdown output", async () => {
518+
const dir = makeRepo();
519+
addCommit(dir, "foo.ts", "const x = 1;\n", "chore: initial");
520+
writeFileSync(join(dir, "foo.ts"), "const x = 2;\n");
521+
522+
const run = captureTool(registerGitDiffSummaryTool);
523+
const text = await run({ workspaceRoot: dir });
524+
expect(text).toContain("foo.ts");
525+
});
526+
527+
test("json format returns structured DiffSummary", async () => {
528+
const dir = makeRepo();
529+
addCommit(dir, "a.ts", "const a = 1;\n", "chore: initial");
530+
writeFileSync(join(dir, "a.ts"), "const a = 99;\n");
531+
532+
const run = captureTool(registerGitDiffSummaryTool);
533+
const text = await run({ workspaceRoot: dir, format: "json" });
534+
const parsed = JSON.parse(text) as {
535+
range: string;
536+
totalFiles: number;
537+
files: Array<{ path: string; status: string }>;
538+
};
539+
expect(parsed.totalFiles).toBe(1);
540+
expect(parsed.files[0]?.path).toBe("a.ts");
541+
expect(parsed.files[0]?.status).toBe("modified");
542+
expect(parsed.range).toBe("unstaged changes");
543+
});
544+
545+
test("staged range shows only staged files", async () => {
546+
const dir = makeRepo();
547+
addCommit(dir, "staged.ts", "const s = 1;\n", "chore: initial");
548+
writeFileSync(join(dir, "staged.ts"), "const s = 2;\n");
549+
gitCmd(dir, "add", "staged.ts");
550+
writeFileSync(join(dir, "unstaged.ts"), "const u = 9;\n");
551+
552+
const run = captureTool(registerGitDiffSummaryTool);
553+
const text = await run({ workspaceRoot: dir, format: "json", range: "staged" });
554+
const parsed = JSON.parse(text) as {
555+
range: string;
556+
files: Array<{ path: string }>;
557+
};
558+
expect(parsed.range).toBe("staged changes");
559+
const paths = parsed.files.map((f) => f.path);
560+
expect(paths).toContain("staged.ts");
561+
expect(paths).not.toContain("unstaged.ts");
562+
});
563+
564+
test("fileFilter restricts output to matching files only", async () => {
565+
const dir = makeRepo();
566+
addCommit(dir, "foo.ts", "const f = 1;\n", "chore: initial");
567+
addCommit(dir, "bar.md", "# Doc\n", "docs: add readme");
568+
writeFileSync(join(dir, "foo.ts"), "const f = 2;\n");
569+
writeFileSync(join(dir, "bar.md"), "# Updated\n");
570+
571+
const run = captureTool(registerGitDiffSummaryTool);
572+
const text = await run({ workspaceRoot: dir, format: "json", fileFilter: "*.ts" });
573+
const parsed = JSON.parse(text) as { files: Array<{ path: string }> };
574+
const paths = parsed.files.map((f) => f.path);
575+
expect(paths).toContain("foo.ts");
576+
expect(paths).not.toContain("bar.md");
577+
});
578+
579+
test("clean working tree returns empty files array", async () => {
580+
const dir = makeRepo();
581+
addCommit(dir, "clean.ts", "const c = 1;\n", "chore: initial");
582+
583+
const run = captureTool(registerGitDiffSummaryTool);
584+
const text = await run({ workspaceRoot: dir, format: "json" });
585+
const parsed = JSON.parse(text) as { totalFiles: number; files: unknown[] };
586+
expect(parsed.totalFiles).toBe(0);
587+
expect(parsed.files).toHaveLength(0);
588+
});
589+
590+
test("non-git workspaceRoot → not_a_git_repository error", async () => {
591+
const plain = mkdtempSync(join(tmpdir(), "mcp-plain-diff-"));
592+
593+
const run = captureTool(registerGitDiffSummaryTool);
594+
const text = await run({ workspaceRoot: plain, format: "json" });
595+
const parsed = JSON.parse(text) as { error: string };
596+
expect(parsed.error).toBe("not_a_git_repository");
597+
});
598+
599+
test("maxLinesPerFile truncates long diffs", async () => {
600+
const dir = makeRepo();
601+
addCommit(
602+
dir,
603+
"big.ts",
604+
`${Array.from({ length: 5 }, (_, i) => `const v${i} = ${i};`).join("\n")}\n`,
605+
"chore: initial",
606+
);
607+
writeFileSync(
608+
join(dir, "big.ts"),
609+
`${Array.from({ length: 5 }, (_, i) => `const v${i} = ${i * 10};`).join("\n")}\n`,
610+
);
611+
612+
const run = captureTool(registerGitDiffSummaryTool);
613+
const text = await run({ workspaceRoot: dir, format: "json", maxLinesPerFile: 2 });
614+
const parsed = JSON.parse(text) as { files: Array<{ truncated: boolean }> };
615+
expect(parsed.files[0]?.truncated).toBe(true);
616+
});
617+
});

src/server/git-log-tool.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { tmpdir } from "node:os";
2424
import { join } from "node:path";
2525

2626
import { gitTopLevel, spawnGitAsync } from "./git.js";
27+
import { registerGitLogTool } from "./git-log-tool.js";
28+
import { captureTool } from "./test-harness.js";
2729

2830
// ---------------------------------------------------------------------------
2931
// Separators — must match git-log-tool.ts constants
@@ -307,3 +309,91 @@ describe("git_log integration", () => {
307309
expect(records[0]?.[2]).toBe("fix: handle 100% edge case");
308310
});
309311
});
312+
313+
// ---------------------------------------------------------------------------
314+
// Execute handler: end-to-end via fake server harness
315+
// ---------------------------------------------------------------------------
316+
317+
// JSON output shape: { groups: [{ workspace_root, repo, commits, truncated? }] }
318+
// Commits use GIT_AUTHOR_DATE=2025-01-01 — must pass since beyond 7-day default.
319+
const SINCE_WIDE = "2.years";
320+
321+
describe("git_log execute handler", () => {
322+
test("returns commits in json format", async () => {
323+
const dir = makeRepo();
324+
addCommit(dir, "a.txt", "feat: commit A");
325+
addCommit(dir, "b.txt", "feat: commit B");
326+
327+
const run = captureTool(registerGitLogTool);
328+
const text = await run({ workspaceRoot: dir, format: "json", since: SINCE_WIDE });
329+
const parsed = JSON.parse(text) as {
330+
groups: Array<{ commits: Array<{ subject: string }> }>;
331+
};
332+
const commits = parsed.groups[0]?.commits ?? [];
333+
expect(commits.length).toBeGreaterThanOrEqual(2);
334+
const subjects = commits.map((c) => c.subject);
335+
expect(subjects).toContain("feat: commit B");
336+
expect(subjects).toContain("feat: commit A");
337+
});
338+
339+
test("markdown output contains commit subjects", async () => {
340+
const dir = makeRepo();
341+
addCommit(dir, "x.txt", "chore: markdown test");
342+
343+
const run = captureTool(registerGitLogTool);
344+
const text = await run({ workspaceRoot: dir, since: SINCE_WIDE });
345+
expect(text).toContain("chore: markdown test");
346+
});
347+
348+
test("maxCommits caps number of commits returned", async () => {
349+
const dir = makeRepo();
350+
for (let i = 1; i <= 5; i++) {
351+
addCommit(dir, `f${i}.txt`, `chore: commit ${i}`);
352+
}
353+
354+
const run = captureTool(registerGitLogTool);
355+
const text = await run({
356+
workspaceRoot: dir,
357+
format: "json",
358+
since: SINCE_WIDE,
359+
maxCommits: 3,
360+
});
361+
const parsed = JSON.parse(text) as {
362+
groups: Array<{ commits: Array<{ subject: string }>; truncated?: boolean }>;
363+
};
364+
const group = parsed.groups[0];
365+
expect(group?.commits.length).toBe(3);
366+
expect(group?.truncated).toBe(true);
367+
});
368+
369+
test("non-git workspaceRoot → not_a_git_repo error in group", async () => {
370+
const plain = mkdtempSync(join(tmpdir(), "mcp-plain-log-"));
371+
372+
const run = captureTool(registerGitLogTool);
373+
const text = await run({ workspaceRoot: plain, format: "json" });
374+
const parsed = JSON.parse(text) as {
375+
groups: Array<{ error?: string }>;
376+
};
377+
expect(parsed.groups[0]?.error).toBe("not_a_git_repo");
378+
});
379+
380+
test("paths filter limits commits to those touching a specific file", async () => {
381+
const dir = makeRepo();
382+
addCommit(dir, "important.ts", "feat: touch important");
383+
addCommit(dir, "other.ts", "chore: unrelated");
384+
385+
const run = captureTool(registerGitLogTool);
386+
const text = await run({
387+
workspaceRoot: dir,
388+
format: "json",
389+
since: SINCE_WIDE,
390+
paths: ["important.ts"],
391+
});
392+
const parsed = JSON.parse(text) as {
393+
groups: Array<{ commits: Array<{ subject: string }> }>;
394+
};
395+
const commits = parsed.groups[0]?.commits ?? [];
396+
expect(commits.length).toBe(1);
397+
expect(commits[0]?.subject).toContain("important");
398+
});
399+
});

0 commit comments

Comments
 (0)