repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/intellij/__tests__/harness.test.ts
Rawimport {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { createServer, type Server as HttpServer } from "node:http";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { type ExtensionAPI, initTheme } from "@earendil-works/pi-coding-agent";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { Type } from "typebox";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestSession, type TestSession } from "../../../test/harness";
import intellijExtension from "../index";
function occupiedToolExtension(pi: ExtensionAPI): void {
pi.registerTool({
name: "intellij_echo",
label: "Occupied",
description: "Occupy the default bridge name.",
parameters: Type.Object({}),
async execute() {
return { content: [{ type: "text", text: "occupied" }] };
},
});
}
async function fakeJetBrainsServer(
calls: Array<Record<string, unknown>>,
catalog: { tools: string[]; noProjectPathTools?: string[] },
projectOpen: () => boolean | Promise<boolean> = () => true,
): Promise<{
server: HttpServer;
url: string;
}> {
const server = createServer(async (request, response) => {
if (request.method !== "POST") {
response.writeHead(405).end();
return;
}
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const mcp = new Server(
{ name: "JetBrains IDE", version: "2025.2" },
{ capabilities: { tools: {} } },
);
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "get_project_modules",
description: "Resolve the open project",
inputSchema: {
type: "object",
properties: { projectPath: { type: "string" } },
required: ["projectPath"],
},
},
...catalog.tools.map((name) => {
const acceptsProjectPath =
!catalog.noProjectPathTools?.includes(name);
return {
name,
description: "Echo project routing and an image",
inputSchema: {
type: "object" as const,
properties: {
...(acceptsProjectPath
? { projectPath: { type: "string" } }
: {}),
value: { type: "string" },
},
required: ["value"],
},
};
}),
],
}));
mcp.setRequestHandler(CallToolRequestSchema, async ({ params }, extra) => {
calls.push({ name: params.name, arguments: params.arguments });
if (params.name === "get_project_modules")
return (await projectOpen())
? { content: [{ type: "text", text: "project open" }] }
: {
content: [{ type: "text", text: "project missing" }],
isError: true,
};
if (params.name === "get_file_problems")
return {
content: [
{
type: "text",
text: JSON.stringify({
filePath: params.arguments?.filePath,
errors:
params.arguments?.value === "problems"
? [
{
severity: "WARNING",
description: "Nullable result is unchecked",
line: 7,
column: 2,
},
]
: [],
}),
},
],
};
if (params.arguments?.value === "semantic-fail")
return {
content: [
{
type: "text",
text: JSON.stringify({
isSuccess: false,
problems: [
{
kind: "ERROR",
message: "Cannot resolve symbol",
file: "src/Main.java",
line: 7,
},
],
}),
},
],
};
if (params.arguments?.value === "structured")
return {
content: [{ type: "text", text: "summary" }],
structuredContent: { answer: 42 },
};
if (params.arguments?.value === "fail")
return {
content: [{ type: "text", text: "remote failure" }],
isError: true,
};
if (params.arguments?.value === "progress")
await extra.sendNotification({
method: "notifications/progress",
params: {
progressToken: extra._meta?.progressToken ?? "progress",
progress: 1,
total: 1,
message: "indexed",
},
});
if (params.arguments?.value === "wait")
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, 5_000);
extra.signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(new Error("remote cancelled"));
},
{ once: true },
);
});
return {
content: [
{ type: "text", text: `echo ${params.arguments?.value}` },
{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" },
{
type: "resource_link",
uri: "file:///project/result.txt",
name: "result",
},
],
};
});
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await mcp.connect(transport);
await transport.handleRequest(request, response, body);
response.once("close", () => {
void transport.close();
void mcp.close();
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string")
throw new Error("missing address");
return { server, url: `http://127.0.0.1:${address.port}/mcp` };
}
async function writeFakeLauncher(bin: string, marker: string): Promise<void> {
await mkdir(bin, { recursive: true });
if (process.platform === "win32") {
await writeFile(
join(bin, "idea.cmd"),
`@echo off\r\n>>"${marker}" echo(%~1\r\n`,
);
return;
}
const launcher = join(bin, "idea");
await writeFile(launcher, `#!/bin/sh\nprintf '%s\\n' "$1" >> '${marker}'\n`);
await chmod(launcher, 0o700);
}
async function readEventually(file: string): Promise<string> {
for (let attempt = 0; attempt < 50; attempt++) {
try {
return await readFile(file, "utf8");
} catch {
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
throw new Error(`Timed out waiting for ${file}`);
}
describe("intellij pi-test-harness", () => {
let t: TestSession | undefined;
let server: HttpServer | undefined;
const roots: string[] = [];
afterEach(async () => {
t?.dispose();
t = undefined;
if (server)
await new Promise<void>((resolve, reject) =>
server?.close((error) => (error ? reject(error) : resolve())),
);
server = undefined;
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
it("loads disconnected with no startup IntelliJ tools", async () => {
t = await createTestSession({ extensionFactories: [intellijExtension] });
const [extension] = t.session.extensionRunner.extensions;
expect(extension.commands.has("intellij")).toBe(true);
expect([...extension.tools.keys()]).toEqual([]);
expect(
t.session
.getActiveToolNames()
.some((name) => name.startsWith("intellij_")),
).toBe(false);
});
it("cancels an in-flight connection without launching IntelliJ", async () => {
const root = await mkdtemp(join(tmpdir(), "intellij-cancel-"));
roots.push(root);
const bin = join(root, "bin");
const marker = join(root, "launcher-args.txt");
await writeFakeLauncher(bin, marker);
let requestArrived!: () => void;
const received = new Promise<void>((resolve) => {
requestArrived = resolve;
});
server = createServer(() => requestArrived());
await new Promise<void>((resolve) =>
server?.listen(0, "127.0.0.1", resolve),
);
const address = server.address();
if (!address || typeof address === "string")
throw new Error("missing address");
t = await createTestSession({
extensionFactories: [intellijExtension],
env: { PATH: `${bin}${delimiter}${process.env.PATH ?? ""}` },
});
const [extension] = t.session.extensionRunner.extensions;
const command = extension.commands.get("intellij");
const ctx = t.session.extensionRunner.createCommandContext();
const connecting = command.handler(
`connect http://127.0.0.1:${address.port}/mcp`,
ctx,
);
await received;
await command.handler("disconnect", ctx);
await connecting;
await expect(readFile(marker, "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
expect(
t.session
.getActiveToolNames()
.some((name) => name.startsWith("intellij_")),
).toBe(false);
});
it("launches a missing project once, retains the catalog, and reports a stale saved URL", async () => {
const root = await mkdtemp(join(tmpdir(), "intellij-project-sync-"));
roots.push(root);
const project = join(root, "project & exact");
const bin = join(root, "bin");
const marker = join(root, "launcher-args.txt");
await mkdir(project);
await writeFakeLauncher(bin, marker);
const calls: Array<Record<string, unknown>> = [];
const fake = await fakeJetBrainsServer(
calls,
{ tools: ["echo"] },
async () => {
try {
return (await readFile(marker, "utf8")).trim() === project;
} catch {
return false;
}
},
);
server = fake.server;
t = await createTestSession({
cwd: project,
extensionFactories: [intellijExtension],
env: { PATH: `${bin}${delimiter}${process.env.PATH ?? ""}` },
});
const [extension] = t.session.extensionRunner.extensions;
const command = extension.commands.get("intellij");
const ctx = t.session.extensionRunner.createCommandContext();
await command.handler(`connect ${fake.url}`, ctx);
expect((await readEventually(marker)).trim().split(/\r?\n/)).toEqual([
project,
]);
expect(t.session.getActiveToolNames()).toContain("intellij_echo");
await command.handler("disconnect", ctx);
await command.handler("tools", ctx);
expect(t.events.uiCallsFor("notify").at(-1)?.args[0]).toContain(
"stale intellij_echo",
);
await new Promise<void>((resolve, reject) =>
server?.close((error) => (error ? reject(error) : resolve())),
);
server = undefined;
await command.handler("reconnect", ctx);
const failure = String(t.events.uiCallsFor("notify").at(-1)?.args[0]);
expect(failure).toContain("The saved URL is stale.");
expect(failure).toContain("Copy HTTP Stream Config");
expect(t.session.getActiveToolNames()).not.toContain("intellij_echo");
expect((await readEventually(marker)).trim().split(/\r?\n/)).toHaveLength(
1,
);
});
it("launches IntelliJ and retries the saved endpoint when the IDE is closed", async () => {
const root = await mkdtemp(join(tmpdir(), "intellij-closed-"));
roots.push(root);
const project = join(root, "project");
const bin = join(root, "bin");
const marker = join(root, "launcher-args.txt");
await mkdir(project);
await writeFakeLauncher(bin, marker);
const fake = await fakeJetBrainsServer([], { tools: ["echo"] });
const port = Number(new URL(fake.url).port);
await new Promise<void>((resolve, reject) =>
fake.server.close((error) => (error ? reject(error) : resolve())),
);
server = fake.server;
let reopening = false;
const watcher = setInterval(async () => {
if (reopening) return;
try {
await readFile(marker);
reopening = true;
server?.listen(port, "127.0.0.1");
} catch {
// Launcher has not run yet.
}
}, 20);
try {
t = await createTestSession({
cwd: project,
extensionFactories: [intellijExtension],
env: { PATH: `${bin}${delimiter}${process.env.PATH ?? ""}` },
});
const [extension] = t.session.extensionRunner.extensions;
const command = extension.commands.get("intellij");
await command.handler(
`connect ${fake.url}`,
t.session.extensionRunner.createCommandContext(),
);
expect(t.session.getActiveToolNames()).toContain("intellij_echo");
expect((await readEventually(marker)).trim()).toBe(project);
} finally {
clearInterval(watcher);
}
});
it("resolves tool-name collisions deterministically", async () => {
const fake = await fakeJetBrainsServer([], { tools: ["echo"] });
server = fake.server;
t = await createTestSession({
extensionFactories: [occupiedToolExtension, intellijExtension],
});
const extension = t.session.extensionRunner.extensions[1];
await extension.commands
.get("intellij")
.handler(
`connect ${fake.url}`,
t.session.extensionRunner.createCommandContext(),
);
expect(extension.tools.has("intellij_echo_2")).toBe(true);
expect(t.session.getActiveToolNames()).toContain("intellij_echo_2");
await extension.commands
.get("intellij")
.handler("disconnect", t.session.extensionRunner.createCommandContext());
expect(t.session.getActiveToolNames()).toContain("intellij_echo");
expect(t.session.getActiveToolNames()).not.toContain("intellij_echo_2");
});
it("routes concurrent session clients without cross-project leakage", async () => {
const calls: Array<Record<string, unknown>> = [];
const fake = await fakeJetBrainsServer(calls, { tools: ["echo"] });
server = fake.server;
const first = await createTestSession({
extensionFactories: [intellijExtension],
});
const second = await createTestSession({
extensionFactories: [intellijExtension],
});
try {
await Promise.all(
[first, second].map(async (session) => {
const [extension] = session.session.extensionRunner.extensions;
const ctx = session.session.extensionRunner.createCommandContext();
await extension.commands
.get("intellij")
.handler(`connect ${fake.url}`, ctx);
await extension.tools
.get("intellij_echo")
.definition.execute(
"call",
{ value: session.cwd },
undefined,
undefined,
ctx,
);
}),
);
const echoCalls = calls
.filter((call) => call.name === "echo")
.sort((left, right) =>
String(
(left.arguments as Record<string, unknown>).value,
).localeCompare(
String((right.arguments as Record<string, unknown>).value),
),
);
const expected = [first, second]
.map((session) => ({
name: "echo",
arguments: { value: session.cwd, projectPath: session.cwd },
}))
.sort((left, right) =>
left.arguments.value.localeCompare(right.arguments.value),
);
expect(echoCalls).toEqual(expected);
} finally {
second.dispose();
first.dispose();
}
});
it("presents routed tools and inspection results with useful compact detail", async () => {
const fake = await fakeJetBrainsServer([], {
tools: ["get_file_problems", "global_status"],
noProjectPathTools: ["global_status"],
});
server = fake.server;
t = await createTestSession({ extensionFactories: [intellijExtension] });
const [extension] = t.session.extensionRunner.extensions;
const command = extension.commands.get("intellij");
const ctx = t.session.extensionRunner.createCommandContext();
await command.handler(`connect ${fake.url}`, ctx);
const status = String(t.events.uiCallsFor("setStatus").at(-1)?.args[1]);
expect(status).toContain(" ✓");
expect(status).not.toContain("tools");
const probe = extension.tools.get(
"intellij_get_project_modules",
).definition;
expect(probe.promptGuidelines).toEqual([
expect.stringContaining("intellij_*"),
expect.stringContaining("available intellij_* inspection tools"),
]);
const definition = extension.tools.get(
"intellij_get_file_problems",
).definition;
expect(definition.description).toContain(
"injects projectPath automatically",
);
expect(definition.parameters.properties).not.toHaveProperty("projectPath");
expect(definition.parameters.required).toEqual(["value"]);
const globalStatus = extension.tools.get(
"intellij_global_status",
).definition;
expect(globalStatus.description).toContain("connected IntelliJ session");
expect(globalStatus.description).not.toContain("projectPath");
expect(globalStatus.parameters.properties).not.toHaveProperty(
"projectPath",
);
const args = {
value: "problems",
filePath: "src/Main.java",
};
const result = await definition.execute(
"problems",
args,
undefined,
undefined,
ctx,
);
initTheme("default", false);
const theme = {
fg: (_style: string, text: string) => text,
bold: (text: string) => text,
};
const call = definition
.renderCall(args, theme as never, { expanded: false } as never)
.render(120)
.join("\n");
const collapsed = definition
.renderResult(
result,
{ expanded: false } as never,
theme as never,
{ isError: false } as never,
)
.render(120)
.join("\n");
const expanded = definition
.renderResult(
result,
{ expanded: true } as never,
theme as never,
{ isError: false } as never,
)
.render(120)
.join("\n");
expect(call).toContain("get file problems");
expect(call).toContain("src/Main.java");
expect(collapsed).toContain("1 warning");
expect(collapsed).not.toContain("Nullable result");
expect(expanded).toContain("WARNING src/Main.java:7:2");
expect(expanded).toContain("Nullable result is unchecked");
const progress = {
content: [
{
type: "text",
text: "IntelliJ progress: indexing END-PROGRESS-DIAGNOSTIC",
},
],
details: { source: "intellij", tool: "get_file_problems" },
};
const collapsedProgress = definition
.renderResult(
progress,
{ expanded: false, isPartial: true } as never,
theme as never,
{} as never,
)
.render(120)
.join("\n");
const expandedProgress = definition
.renderResult(
progress,
{ expanded: true, isPartial: true } as never,
theme as never,
{} as never,
)
.render(120)
.join("\n");
expect(collapsedProgress).toContain("to expand");
expect(collapsedProgress).not.toContain("END-PROGRESS-DIAGNOSTIC");
expect(expandedProgress).toContain("END-PROGRESS-DIAGNOSTIC");
});
it("deactivates the live catalog before rejecting a replacement URL", async () => {
const fake = await fakeJetBrainsServer([], { tools: ["echo"] });
server = fake.server;
t = await createTestSession({ extensionFactories: [intellijExtension] });
const [extension] = t.session.extensionRunner.extensions;
const command = extension.commands.get("intellij");
const ctx = t.session.extensionRunner.createCommandContext();
await command.handler(`connect ${fake.url}`, ctx);
expect(t.session.getActiveToolNames()).toContain("intellij_echo");
await command.handler("connect https://example.com/mcp", ctx);
expect(t.session.getActiveToolNames()).not.toContain("intellij_echo");
expect(String(t.events.uiCallsFor("notify").at(-1)?.args[0])).toContain(
"must use HTTP",
);
});
it("connects to a trusted project URL without persisting it globally", async () => {
const fake = await fakeJetBrainsServer([], { tools: ["echo"] });
server = fake.server;
t = await createTestSession({ extensionFactories: [intellijExtension] });
const userFile = join(
t.env.HOME as string,
".pi",
"agent",
"settings.json",
);
const user = { intellij: { url: "http://127.0.0.1:1/user" } };
await mkdir(join(t.env.HOME as string, ".pi", "agent"), {
recursive: true,
});
await writeFile(userFile, JSON.stringify(user));
await mkdir(join(t.cwd, ".pi"), { recursive: true });
await writeFile(
join(t.cwd, ".pi", "settings.json"),
JSON.stringify({ intellij: { url: fake.url } }),
);
const [extension] = t.session.extensionRunner.extensions;
await extension.commands
.get("intellij")
.handler("connect", t.session.extensionRunner.createCommandContext());
expect(t.session.getActiveToolNames()).toContain("intellij_echo");
expect(JSON.parse(await readFile(userFile, "utf8"))).toEqual(user);
});
it("connects through Streamable HTTP, pins projectPath, maps content, and disconnects", async () => {
const calls: Array<Record<string, unknown>> = [];
const catalog = { tools: ["echo.image"] };
const fake = await fakeJetBrainsServer(calls, catalog);
server = fake.server;
t = await createTestSession({ extensionFactories: [intellijExtension] });
const settingsDir = join(t.env.HOME as string, ".pi", "agent");
const settingsFile = join(settingsDir, "settings.json");
await mkdir(settingsDir, { recursive: true });
await writeFile(
settingsFile,
JSON.stringify({ theme: "nugu", intellij: { keep: true } }),
);
const [extension] = t.session.extensionRunner.extensions;
const command = extension.commands.get("intellij");
const ctx = t.session.extensionRunner.createCommandContext();
await command.handler(`connect ${fake.url}`, ctx);
expect(JSON.parse(await readFile(settingsFile, "utf8"))).toEqual({
theme: "nugu",
intellij: { keep: true, url: fake.url },
});
expect(t.session.getActiveToolNames()).toContain("intellij_echo_image");
const definition = extension.tools.get("intellij_echo_image").definition;
const result = await definition.execute(
"call-1",
{ value: "hello", projectPath: "C:/wrong-project" },
undefined,
undefined,
ctx,
);
expect(calls.at(-1)).toEqual({
name: "echo.image",
arguments: { value: "hello", projectPath: t.cwd },
});
expect(result.content).toEqual([
{ type: "text", text: "echo hello" },
{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" },
{
type: "text",
text: '{"name":"result","uri":"file:///project/result.txt","type":"resource_link"}',
},
]);
initTheme("default", false);
const theme = {
fg: (_style: string, text: string) => text,
bold: (text: string) => text,
};
const collapsed = definition
.renderResult(
result,
{ expanded: false } as never,
theme as never,
{} as never,
)
.render(120)
.join("\n");
const expanded = definition
.renderResult(
result,
{ expanded: true } as never,
theme as never,
{} as never,
)
.render(120)
.join("\n");
expect(collapsed).not.toContain("echo hello");
expect(expanded).toContain("echo hello");
const onUpdate = vi.fn();
await definition.execute(
"progress",
{ value: "progress" },
undefined,
onUpdate,
ctx,
);
expect(onUpdate).toHaveBeenCalledWith(
expect.objectContaining({
content: [
expect.objectContaining({ text: expect.stringContaining("indexed") }),
],
}),
);
const abort = new AbortController();
const waiting = definition.execute(
"cancel",
{ value: "wait" },
abort.signal,
undefined,
ctx,
);
abort.abort();
await expect(waiting).rejects.toThrow();
await expect(
definition.execute(
"failure",
{ value: "fail" },
undefined,
undefined,
ctx,
),
).rejects.toThrow("remote failure");
await expect(
definition.execute(
"semantic-failure",
{ value: "semantic-fail" },
undefined,
undefined,
ctx,
),
).rejects.toThrow(
/IntelliJ echo\.image failed[\s\S]*Cannot resolve symbol[\s\S]*Next:/,
);
const structured = await definition.execute(
"structured",
{ value: "structured" },
undefined,
undefined,
ctx,
);
expect(structured.content).toEqual([
{ type: "text", text: "summary" },
{ type: "text", text: '{"answer":42}' },
]);
await expect(
definition.execute(
"outside",
{ value: "blocked" },
undefined,
undefined,
{ ...ctx, cwd: join(t.cwd, "other") },
),
).rejects.toThrow("outside this session cwd");
catalog.tools = ["new_tool"];
await command.handler("reconnect", ctx);
expect(t.session.getActiveToolNames()).not.toContain("intellij_echo_image");
expect(t.session.getActiveToolNames()).toContain("intellij_new_tool");
await expect(
definition.execute(
"call-2",
{ value: "later" },
undefined,
undefined,
ctx,
),
).rejects.toThrow("disconnected or stale");
await command.handler("disconnect", ctx);
expect(t.session.getActiveToolNames()).not.toContain("intellij_new_tool");
expect(t.events.uiCallsFor("setStatus").at(-1)?.args).toEqual([
"intellij",
undefined,
]);
});
});