cc-libs/tools/mcp-bridge/test-integration/ai-cli.test.ts

182 lines
5.9 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { WebSocketServer } from "ws";
import { formatFailure, startCraftos } from "./harness.js";
import { startOpencodeProxy } from "../src/opencode-proxy.js";
type RequestLog = {
method: string;
path: string;
body?: unknown;
};
test("ai CLI commands run through the opencode bridge proxy", async () => {
const requests: RequestLog[] = [];
let nextSession = 1;
const opencode = createServer((req, res) => {
void handleOpencodeRequest(req, res, requests, () => {
const id = `ses_${nextSession}`;
nextSession += 1;
return id;
});
});
await new Promise<void>((resolve) => opencode.listen(0, "127.0.0.1", resolve));
const opencodeUrl = `http://127.0.0.1:${serverPort(opencode)}`;
const proxy = startOpencodeProxy({ host: "127.0.0.1", port: 0, opencodeUrl });
await waitForListening(proxy);
const proxyUrl = `ws://127.0.0.1:${wssPort(proxy)}`;
const craftos = startCraftos("ai-cli-check.lua", {
mountRepo: true,
shellArgs: [proxyUrl],
timeoutMs: 20_000,
});
try {
const result = await craftos.done;
const output = result.output;
assert.equal(result.status, 0, formatFailure("expected craftos to exit cleanly", output));
assert.match(output, /ses_existing {2}Existing title/, formatFailure("expected sessions output", output));
assert.match(output, /\bpong\b/, formatFailure("expected ping reply", output));
assert.match(output, /new reply/, formatFailure("expected ai new reply", output));
assert.match(output, /plain reply/, formatFailure("expected plain ai reply", output));
assert.match(output, /SESSION_AFTER_PING=ses_1/, formatFailure("expected ping session to persist", output));
assert.match(output, /SESSION_AFTER_NEW=ses_2/, formatFailure("expected ai new to replace session", output));
assert.match(output, /SESSION_AFTER_ASK=ses_2/, formatFailure("expected plain ai to reuse new session", output));
assert.deepEqual(requests.map((r) => `${r.method} ${r.path}`), [
"GET /session",
"POST /session",
"POST /session/ses_1/message",
"POST /session",
"POST /session/ses_2/message",
"POST /session/ses_2/message",
]);
assert.match(promptText(requests[2].body), /reply with exactly: pong/);
assert.match(promptText(requests[4].body), /User prompt:\nfresh start/);
assert.match(promptText(requests[5].body), /User prompt:\ncontinue please/);
} finally {
craftos.abort();
await craftos.done.catch(() => undefined);
await closeWss(proxy);
await new Promise<void>((resolve) => opencode.close(() => resolve()));
}
});
async function handleOpencodeRequest(
req: IncomingMessage,
res: ServerResponse,
requests: RequestLog[],
createSessionId: () => string,
): Promise<void> {
const bodyText = await readBody(req);
const body: unknown = bodyText === "" ? undefined : JSON.parse(bodyText) as unknown;
const url = new URL(req.url ?? "/", "http://127.0.0.1");
requests.push({ method: req.method ?? "GET", path: url.pathname + url.search, body });
if (req.method === "GET" && url.pathname === "/session") {
respondJson(res, [
{ id: "ses_existing", title: "Existing title", time: { updated: 10 } },
]);
return;
}
if (req.method === "POST" && url.pathname === "/session") {
const id = createSessionId();
respondJson(res, { id, title: "cc-ai" });
return;
}
const messageMatch = url.pathname.match(/^\/session\/([^/]+)\/message$/);
if (req.method === "POST" && messageMatch) {
respondJson(res, {
info: { id: `msg_${requests.length}`, time: { completed: 1 } },
parts: [{ type: "text", text: replyForPrompt(promptText(body)) }],
});
return;
}
respondJson(res, { error: "not found" }, 404);
}
function replyForPrompt(prompt: string): string {
if (prompt.includes("reply with exactly: pong")) {
return "pong";
}
if (prompt.includes("fresh start")) {
return "new reply";
}
if (prompt.includes("continue please")) {
return "plain reply";
}
return `unhandled prompt: ${prompt}`;
}
function promptText(body: unknown): string {
if (!isObject(body) || !Array.isArray(body.parts)) {
return "";
}
const parts = body.parts as unknown[];
const first = parts[0];
if (!isObject(first) || typeof first.text !== "string") {
return "";
}
return first.text;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
return new Promise((resolve, reject) => {
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
function respondJson(res: ServerResponse, body: unknown, status = 200): void {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
function serverPort(server: Server): number {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("server has no TCP address");
}
return address.port;
}
function wssPort(proxy: WebSocketServer): number {
const address = proxy.address();
if (!address || typeof address === "string") {
throw new Error("proxy has no TCP address");
}
return address.port;
}
function waitForListening(proxy: WebSocketServer): Promise<void> {
return new Promise((resolve, reject) => {
if (proxy.address()) {
resolve();
return;
}
proxy.once("listening", () => resolve());
proxy.once("error", reject);
});
}
function closeWss(proxy: WebSocketServer): Promise<void> {
for (const client of proxy.clients) {
client.terminate();
}
return new Promise<void>((resolve) => proxy.close(() => resolve()));
}