Skip to content

Commit 84889c8

Browse files
authored
test: cover multi-session MCP routing (#33)
* test: cover multi-session MCP routing * test: stabilize MCP and wrapped-row coverage
1 parent 9fc0f7d commit 84889c8

2 files changed

Lines changed: 186 additions & 47 deletions

File tree

test/mcp-e2e.test.ts

Lines changed: 178 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,28 @@ const ttyToolsAvailable = Bun.spawnSync(["bash", "-lc", "command -v script >/dev
1414
stderr: "ignore",
1515
}).exitCode === 0;
1616

17+
interface HealthResponse {
18+
ok: boolean;
19+
pid: number;
20+
sessions: number;
21+
}
22+
23+
interface ListedSessionSummary {
24+
sessionId: string;
25+
title: string;
26+
files: Array<{
27+
path: string;
28+
}>;
29+
}
30+
31+
interface FixtureFiles {
32+
dir: string;
33+
before: string;
34+
after: string;
35+
transcript: string;
36+
afterName: string;
37+
}
38+
1739
function cleanupTempDirs() {
1840
while (tempDirs.length > 0) {
1941
const dir = tempDirs.pop();
@@ -37,18 +59,42 @@ function stripTerminalControl(text: string) {
3759
.replace(/\x1b[@-_]/g, "");
3860
}
3961

40-
function createFixtureFiles() {
41-
const dir = mkdtempSync(join(tmpdir(), "hunk-mcp-e2e-"));
62+
function createFixtureFiles(name: string, beforeLines: string[], afterLines: string[]): FixtureFiles {
63+
const dir = mkdtempSync(join(tmpdir(), `hunk-mcp-e2e-${name}-`));
4264
tempDirs.push(dir);
4365

44-
const before = join(dir, "before.ts");
45-
const after = join(dir, "after.ts");
46-
const transcript = join(dir, "transcript.txt");
66+
const beforeName = `${name}-before.ts`;
67+
const afterName = `${name}-after.ts`;
68+
const before = join(dir, beforeName);
69+
const after = join(dir, afterName);
70+
const transcript = join(dir, `${name}-transcript.txt`);
71+
72+
writeFileSync(before, [...beforeLines, ""].join("\n"));
73+
writeFileSync(after, [...afterLines, ""].join("\n"));
74+
75+
return { dir, before, after, transcript, afterName };
76+
}
4777

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"));
78+
function spawnHunkSession(fixture: FixtureFiles, port: number) {
79+
const hunkCommand = [
80+
`(sleep 6; printf q) | timeout 8 script -q -f -e -c`,
81+
shellQuote(`bun run ${shellQuote(sourceEntrypoint)} diff ${shellQuote(fixture.before)} ${shellQuote(fixture.after)}`),
82+
shellQuote(fixture.transcript),
83+
].join(" ");
5084

51-
return { dir, before, after, transcript };
85+
return Bun.spawn(["bash", "-lc", hunkCommand], {
86+
cwd: fixture.dir,
87+
stdin: "ignore",
88+
stdout: "pipe",
89+
stderr: "pipe",
90+
env: {
91+
...process.env,
92+
TERM: "xterm-256color",
93+
COLUMNS: "120",
94+
LINES: "24",
95+
HUNK_MCP_PORT: String(port),
96+
},
97+
});
5298
}
5399

54100
async function waitUntil<T>(label: string, fn: () => Promise<T | null>, timeoutMs = 10_000, intervalMs = 150) {
@@ -76,13 +122,22 @@ async function waitForHealth(port: number) {
76122
return null;
77123
}
78124

79-
return (await response.json()) as { ok: boolean; pid: number; sessions: number };
125+
return (await response.json()) as HealthResponse;
80126
} catch {
81127
return null;
82128
}
83129
});
84130
}
85131

132+
async function listSessions(client: Client) {
133+
const result = await client.callTool({
134+
name: "list_sessions",
135+
arguments: {},
136+
});
137+
138+
return ((result.structuredContent as { sessions?: ListedSessionSummary[] } | undefined)?.sessions ?? []);
139+
}
140+
86141
afterEach(() => {
87142
cleanupTempDirs();
88143
});
@@ -93,60 +148,37 @@ describe("MCP end-to-end", () => {
93148
return;
94149
}
95150

96-
const fixture = createFixtureFiles();
151+
const fixture = createFixtureFiles(
152+
"single",
153+
["export const alpha = 1;", "export const keep = true;"],
154+
["export const alpha = 2;", "export const keep = true;", "export const gamma = true;"],
155+
);
97156
const port = 48000 + Math.floor(Math.random() * 1000);
98-
const hunkCommand = [
99-
`(sleep 6; printf q) | timeout 8 script -q -f -e -c`,
100-
shellQuote(`bun run ${shellQuote(sourceEntrypoint)} diff ${shellQuote(fixture.before)} ${shellQuote(fixture.after)}`),
101-
shellQuote(fixture.transcript),
102-
].join(" ");
103-
const hunkProc = Bun.spawn(["bash", "-lc", hunkCommand], {
104-
cwd: fixture.dir,
105-
stdin: "ignore",
106-
stdout: "pipe",
107-
stderr: "pipe",
108-
env: {
109-
...process.env,
110-
TERM: "xterm-256color",
111-
COLUMNS: "120",
112-
LINES: "24",
113-
HUNK_MCP_PORT: String(port),
114-
},
115-
});
157+
const hunkProc = spawnHunkSession(fixture, port);
116158

117159
let daemonPid: number | null = null;
118-
let client: Client | null = null;
119160
let transport: StreamableHTTPClientTransport | null = null;
120161

121162
try {
122163
const health = await waitForHealth(port);
123164
daemonPid = health.pid;
124165
expect(health.ok).toBe(true);
125166

126-
client = new Client({ name: "mcp-e2e-test", version: "1.0.0" });
167+
const client = new Client({ name: "mcp-e2e-test", version: "1.0.0" });
127168
transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`));
128169
await client.connect(transport);
129170

130171
const listed = await waitUntil("registered Hunk session", async () => {
131-
const result = await client!.callTool({
132-
name: "list_sessions",
133-
arguments: {},
134-
});
135-
const sessions = (result.structuredContent as { sessions?: Array<{ sessionId: string; title: string }> } | undefined)?.sessions;
136-
137-
if (!sessions || sessions.length === 0) {
138-
return null;
139-
}
140-
141-
return sessions;
172+
const sessions = await listSessions(client);
173+
return sessions.length > 0 ? sessions : null;
142174
});
143175

144-
const targetSession = listed.find((session) => session.title.includes("after.ts")) ?? listed[0]!;
176+
const targetSession = listed.find((session) => session.files.some((file) => file.path === fixture.afterName)) ?? listed[0]!;
145177
const commentResult = await client.callTool({
146178
name: "comment",
147179
arguments: {
148180
sessionId: targetSession.sessionId,
149-
filePath: "after.ts",
181+
filePath: fixture.afterName,
150182
side: "new",
151183
line: 2,
152184
summary: "MCP autostart note",
@@ -157,7 +189,7 @@ describe("MCP end-to-end", () => {
157189
});
158190

159191
const structured = commentResult.structuredContent as { result?: { filePath?: string; line?: number } } | undefined;
160-
expect(structured?.result?.filePath).toBe("after.ts");
192+
expect(structured?.result?.filePath).toBe(fixture.afterName);
161193
expect(structured?.result?.line).toBe(2);
162194

163195
const hunkExitCode = await hunkProc.exited;
@@ -183,4 +215,106 @@ describe("MCP end-to-end", () => {
183215
}
184216
}
185217
}, 20_000);
218+
219+
test("one daemon routes comments to the correct Hunk session when multiple local sessions are open", async () => {
220+
if (!ttyToolsAvailable) {
221+
return;
222+
}
223+
224+
const fixtureA = createFixtureFiles(
225+
"alpha",
226+
["export const alpha = 1;", "export const shared = true;"],
227+
["export const alpha = 2;", "export const shared = true;", "export const onlyAlpha = true;"],
228+
);
229+
const fixtureB = createFixtureFiles(
230+
"beta",
231+
["export const beta = 1;", "export const shared = true;"],
232+
["export const beta = 2;", "export const shared = true;", "export const onlyBeta = true;"],
233+
);
234+
const port = 49000 + Math.floor(Math.random() * 1000);
235+
const hunkProcA = spawnHunkSession(fixtureA, port);
236+
const hunkProcB = spawnHunkSession(fixtureB, port);
237+
238+
let daemonPid: number | null = null;
239+
let transport: StreamableHTTPClientTransport | null = null;
240+
241+
try {
242+
const health = await waitForHealth(port);
243+
daemonPid = health.pid;
244+
expect(health.ok).toBe(true);
245+
246+
const client = new Client({ name: "mcp-multisession-test", version: "1.0.0" });
247+
transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`));
248+
await client.connect(transport);
249+
250+
const sessions = await waitUntil("two registered Hunk sessions", async () => {
251+
const listed = await listSessions(client);
252+
return listed.length === 2 ? listed : null;
253+
});
254+
255+
const sessionA = sessions.find((session) => session.files.some((file) => file.path === fixtureA.afterName));
256+
const sessionB = sessions.find((session) => session.files.some((file) => file.path === fixtureB.afterName));
257+
expect(sessionA).toBeDefined();
258+
expect(sessionB).toBeDefined();
259+
260+
await client.callTool({
261+
name: "comment",
262+
arguments: {
263+
sessionId: sessionA!.sessionId,
264+
filePath: fixtureA.afterName,
265+
side: "new",
266+
line: 2,
267+
summary: "Alpha note",
268+
rationale: "Delivered only to the alpha Hunk session.",
269+
author: "Pi",
270+
reveal: true,
271+
},
272+
});
273+
274+
await client.callTool({
275+
name: "comment",
276+
arguments: {
277+
sessionId: sessionB!.sessionId,
278+
filePath: fixtureB.afterName,
279+
side: "new",
280+
line: 2,
281+
summary: "Beta note",
282+
rationale: "Delivered only to the beta Hunk session.",
283+
author: "Pi",
284+
reveal: true,
285+
},
286+
});
287+
288+
const [exitCodeA, exitCodeB] = await Promise.all([hunkProcA.exited, hunkProcB.exited]);
289+
expect([0, 124]).toContain(exitCodeA);
290+
expect([0, 124]).toContain(exitCodeB);
291+
292+
const transcriptA = stripTerminalControl(await Bun.file(fixtureA.transcript).text());
293+
const transcriptB = stripTerminalControl(await Bun.file(fixtureB.transcript).text());
294+
295+
expect(transcriptA).toContain("Alpha note");
296+
expect(transcriptA).toContain("Delivered only to the alpha");
297+
expect(transcriptA).not.toContain("Beta note");
298+
299+
expect(transcriptB).toContain("Beta note");
300+
expect(transcriptB).toContain("Delivered only to the beta");
301+
expect(transcriptB).not.toContain("Alpha note");
302+
} finally {
303+
if (transport) {
304+
await transport.close().catch(() => undefined);
305+
}
306+
307+
hunkProcA.kill();
308+
hunkProcB.kill();
309+
await Promise.allSettled([hunkProcA.exited, hunkProcB.exited]);
310+
311+
if (daemonPid) {
312+
try {
313+
process.kill(daemonPid, "SIGTERM");
314+
} catch {
315+
// Ignore daemons that already exited during cleanup.
316+
}
317+
}
318+
}
319+
}, 20_000);
186320
});

test/ui-components.test.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -520,10 +520,15 @@ describe("UI components", () => {
520520
18,
521521
);
522522

523+
const addedLines = frame
524+
.split("\n")
525+
.filter((line) => line.includes("export const message = 'this is a very") || /^\s{6,}\S/.test(line));
526+
523527
expect(frame).toContain("1 - export const message = 'short';");
524-
expect(frame).toContain("1 + export const message = 'this is a very l");
525-
expect(frame).toContain("ong wrapped line for");
526-
expect(frame).toContain("erage';");
528+
expect(addedLines[0]).toContain("1 + export const message = 'this is a very l");
529+
expect(addedLines.length).toBeGreaterThanOrEqual(3);
530+
expect(addedLines.slice(1).some((line) => line.includes("ong wrapped line"))).toBe(true);
531+
expect(addedLines.slice(1).some((line) => line.includes("age';"))).toBe(true);
527532
});
528533

529534
test("PierreDiffView anchors range-less notes to the first visible row when hunk headers are hidden", async () => {

0 commit comments

Comments
 (0)