Skip to content

Commit d3b0329

Browse files
authored
test: add MCP end-to-end coverage (#25)
1 parent 596249c commit d3b0329

1 file changed

Lines changed: 188 additions & 0 deletions

File tree

test/mcp-e2e.test.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7+
8+
const repoRoot = process.cwd();
9+
const sourceEntrypoint = join(repoRoot, "src/main.tsx");
10+
const tempDirs: string[] = [];
11+
const ttyToolsAvailable = Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], {
12+
stdin: "ignore",
13+
stdout: "ignore",
14+
stderr: "ignore",
15+
}).exitCode === 0;
16+
17+
function cleanupTempDirs() {
18+
while (tempDirs.length > 0) {
19+
const dir = tempDirs.pop();
20+
if (dir) {
21+
rmSync(dir, { recursive: true, force: true });
22+
}
23+
}
24+
}
25+
26+
function shellQuote(value: string) {
27+
return `'${value.replaceAll("'", "'\\''")}'`;
28+
}
29+
30+
function stripTerminalControl(text: string) {
31+
return text
32+
.replace(/^Script started.*?\n/s, "")
33+
.replace(/\nScript done.*$/s, "")
34+
.replace(/\x1bP[\s\S]*?\x1b\\/g, "")
35+
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "")
36+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
37+
.replace(/\x1b[@-_]/g, "");
38+
}
39+
40+
function createFixtureFiles() {
41+
const dir = mkdtempSync(join(tmpdir(), "hunk-mcp-e2e-"));
42+
tempDirs.push(dir);
43+
44+
const before = join(dir, "before.ts");
45+
const after = join(dir, "after.ts");
46+
const transcript = join(dir, "transcript.txt");
47+
48+
writeFileSync(before, ["export const alpha = 1;", "export const keep = true;", ""].join("\n"));
49+
writeFileSync(after, ["export const alpha = 2;", "export const keep = true;", "export const gamma = true;", ""].join("\n"));
50+
51+
return { dir, before, after, transcript };
52+
}
53+
54+
async function waitUntil<T>(label: string, fn: () => Promise<T | null>, timeoutMs = 10_000, intervalMs = 150) {
55+
const deadline = Date.now() + timeoutMs;
56+
57+
for (;;) {
58+
const value = await fn();
59+
if (value !== null) {
60+
return value;
61+
}
62+
63+
if (Date.now() >= deadline) {
64+
throw new Error(`Timed out waiting for ${label}.`);
65+
}
66+
67+
await Bun.sleep(intervalMs);
68+
}
69+
}
70+
71+
async function waitForHealth(port: number) {
72+
return waitUntil("MCP daemon health endpoint", async () => {
73+
try {
74+
const response = await fetch(`http://127.0.0.1:${port}/health`);
75+
if (!response.ok) {
76+
return null;
77+
}
78+
79+
return await response.json();
80+
} catch {
81+
return null;
82+
}
83+
});
84+
}
85+
86+
afterEach(() => {
87+
cleanupTempDirs();
88+
});
89+
90+
describe("MCP end-to-end", () => {
91+
test("daemon comment calls reach a live Hunk TTY session and render inline notes", async () => {
92+
if (!ttyToolsAvailable) {
93+
return;
94+
}
95+
96+
const fixture = createFixtureFiles();
97+
const port = 48000 + Math.floor(Math.random() * 1000);
98+
const daemonProc = Bun.spawn(["bun", "run", sourceEntrypoint, "--", "mcp", "serve"], {
99+
cwd: repoRoot,
100+
stdin: "ignore",
101+
stdout: "pipe",
102+
stderr: "pipe",
103+
env: {
104+
...process.env,
105+
HUNK_MCP_PORT: String(port),
106+
},
107+
});
108+
109+
const hunkCommand = [
110+
`(sleep 6; printf q) | timeout 8 script -q -f -e -c`,
111+
shellQuote(`bun run ${shellQuote(sourceEntrypoint)} diff ${shellQuote(fixture.before)} ${shellQuote(fixture.after)}`),
112+
shellQuote(fixture.transcript),
113+
].join(" ");
114+
const hunkProc = Bun.spawn(["bash", "-lc", hunkCommand], {
115+
cwd: fixture.dir,
116+
stdin: "ignore",
117+
stdout: "pipe",
118+
stderr: "pipe",
119+
env: {
120+
...process.env,
121+
TERM: "xterm-256color",
122+
COLUMNS: "120",
123+
LINES: "24",
124+
HUNK_MCP_PORT: String(port),
125+
},
126+
});
127+
128+
let client: Client | null = null;
129+
let transport: StreamableHTTPClientTransport | null = null;
130+
131+
try {
132+
await waitForHealth(port);
133+
134+
client = new Client({ name: "mcp-e2e-test", version: "1.0.0" });
135+
transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`));
136+
await client.connect(transport);
137+
138+
const listed = await waitUntil("registered Hunk session", async () => {
139+
const result = await client!.callTool({
140+
name: "list_sessions",
141+
arguments: {},
142+
});
143+
const sessions = (result.structuredContent as { sessions?: Array<{ sessionId: string; title: string }> } | undefined)?.sessions;
144+
145+
if (!sessions || sessions.length === 0) {
146+
return null;
147+
}
148+
149+
return sessions;
150+
});
151+
152+
const targetSession = listed.find((session) => session.title.includes("after.ts")) ?? listed[0]!;
153+
const commentResult = await client.callTool({
154+
name: "comment",
155+
arguments: {
156+
sessionId: targetSession.sessionId,
157+
filePath: "after.ts",
158+
side: "new",
159+
line: 2,
160+
summary: "MCP e2e note",
161+
rationale: "Injected from the automated MCP integration test.",
162+
author: "Pi",
163+
reveal: true,
164+
},
165+
});
166+
167+
const structured = commentResult.structuredContent as { result?: { filePath?: string; line?: number } } | undefined;
168+
expect(structured?.result?.filePath).toBe("after.ts");
169+
expect(structured?.result?.line).toBe(2);
170+
171+
const hunkExitCode = await hunkProc.exited;
172+
expect([0, 124]).toContain(hunkExitCode);
173+
174+
const transcript = stripTerminalControl(await Bun.file(fixture.transcript).text());
175+
expect(transcript).toContain("MCP e2e note");
176+
expect(transcript).toContain("Injected from the automated");
177+
} finally {
178+
if (transport) {
179+
await transport.close().catch(() => undefined);
180+
}
181+
182+
hunkProc.kill();
183+
daemonProc.kill();
184+
await hunkProc.exited.catch(() => undefined);
185+
await daemonProc.exited.catch(() => undefined);
186+
}
187+
}, 20_000);
188+
});

0 commit comments

Comments
 (0)