164 lines
6.8 KiB
TypeScript
164 lines
6.8 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import { GatewayRegistry } from "../src/modules/trapos-cloud-gateway/registry.js";
|
|
import type { ComputerResponse } from "../src/modules/trapos-cloud-gateway/protocol.js";
|
|
import { handleMcpRequest, type McpDeps } from "../src/modules/mcp/tools.js";
|
|
import { FakeSocket, silentLog } from "./helpers.js";
|
|
|
|
const ACCOUNT = "local";
|
|
|
|
function makeDeps(registry: GatewayRegistry): McpDeps {
|
|
return { registry, accountId: ACCOUNT, defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, probeTimeoutMs: 5_000 };
|
|
}
|
|
|
|
function rpc(method: string, params?: unknown, id: unknown = 1): Record<string, unknown> {
|
|
return { jsonrpc: "2.0", id, method, params };
|
|
}
|
|
|
|
function resultText(reply: unknown): string {
|
|
const result = (reply as { result?: { content?: { text?: string }[] } }).result;
|
|
return result?.content?.[0]?.text ?? "";
|
|
}
|
|
|
|
function response(traposId: string, messageId: string, type: string, ok: boolean, payload: Record<string, unknown> = {}): ComputerResponse {
|
|
return { event: "computer_response", traposSenderId: traposId, messageId, type, ok, payload };
|
|
}
|
|
|
|
// Run a tool call and resolve the single gateway_request it emits with `payload`.
|
|
async function callAndAnswer(
|
|
deps: McpDeps,
|
|
socket: FakeSocket,
|
|
traposId: string,
|
|
toolName: string,
|
|
args: Record<string, unknown>,
|
|
ok: boolean,
|
|
payload: Record<string, unknown>,
|
|
): Promise<string> {
|
|
const pending = handleMcpRequest(rpc("tools/call", { name: toolName, arguments: args }), deps);
|
|
// registry.request sends synchronously; a tick lets the async chain reach the await.
|
|
await Promise.resolve();
|
|
const frame = socket.lastFrame();
|
|
deps.registry.resolveResponse(socket, ACCOUNT, traposId, response(traposId, frame.messageId as string, frame.type as string, ok, payload));
|
|
return resultText(await pending);
|
|
}
|
|
|
|
test("initialize advertises the trap-sandbox server", async () => {
|
|
const reply = await handleMcpRequest(rpc("initialize"), makeDeps(new GatewayRegistry(silentLog)));
|
|
const info = (reply as { result: { serverInfo: { name: string } } }).result.serverInfo;
|
|
assert.equal(info.name, "trap-sandbox");
|
|
});
|
|
|
|
test("tools/list exposes the three tools addressed by traposId", async () => {
|
|
const reply = await handleMcpRequest(rpc("tools/list"), makeDeps(new GatewayRegistry(silentLog)));
|
|
const tools = (reply as { result: { tools: { name: string; inputSchema: { required?: string[] } }[] } }).result.tools;
|
|
assert.deepEqual(tools.map((t) => t.name).sort(), ["exec-lua", "probe-computers", "write-file"]);
|
|
const exec = tools.find((t) => t.name === "exec-lua");
|
|
assert.deepEqual(exec?.inputSchema.required, ["traposId", "code"]);
|
|
});
|
|
|
|
test("notifications/initialized is a no-op (null)", async () => {
|
|
const reply = await handleMcpRequest(rpc("notifications/initialized", undefined, null), makeDeps(new GatewayRegistry(silentLog)));
|
|
assert.equal(reply, null);
|
|
});
|
|
|
|
test("probe-computers reports no computers when registry is empty", async () => {
|
|
const reply = await handleMcpRequest(rpc("tools/call", { name: "probe-computers" }), makeDeps(new GatewayRegistry(silentLog)));
|
|
assert.equal(resultText(reply), "No computers connected.");
|
|
});
|
|
|
|
test("probe-computers pings each connected computer by traposId", async () => {
|
|
const reg = new GatewayRegistry(silentLog);
|
|
const deps = makeDeps(reg);
|
|
const a = new FakeSocket();
|
|
reg.register(ACCOUNT, "7:base", a);
|
|
|
|
const pending = handleMcpRequest(rpc("tools/call", { name: "probe-computers" }), deps);
|
|
await Promise.resolve();
|
|
const frame = a.lastFrame();
|
|
assert.equal(frame.type, "ping");
|
|
reg.resolveResponse(a, ACCOUNT, "7:base", response("7:base", frame.messageId as string, "ping", true, { traposId: "7:base" }));
|
|
assert.equal(resultText(await pending), "ok from 7:base");
|
|
});
|
|
|
|
test("exec-lua formats returns and trimmed output on success", async () => {
|
|
const reg = new GatewayRegistry(silentLog);
|
|
const deps = makeDeps(reg);
|
|
const a = new FakeSocket();
|
|
reg.register(ACCOUNT, "7:base", a);
|
|
|
|
const text = await callAndAnswer(deps, a, "7:base", "exec-lua", { traposId: "7:base", code: "print('hi')" }, true, {
|
|
ok: true,
|
|
returns: [{ type: "number", value: 42 }],
|
|
output: "hi\n",
|
|
});
|
|
|
|
assert.equal(a.lastFrame().type, "exec-lua");
|
|
assert.deepEqual((a.lastFrame().payload as { code: string }).code, "print('hi')");
|
|
assert.equal(text, ["computer: 7:base", "ok: true", `returns: ${JSON.stringify([{ type: "number", value: 42 }])}`, "output:", "hi"].join("\n"));
|
|
});
|
|
|
|
test("exec-lua surfaces an ok:false result with its captured output", async () => {
|
|
const reg = new GatewayRegistry(silentLog);
|
|
const deps = makeDeps(reg);
|
|
const a = new FakeSocket();
|
|
reg.register(ACCOUNT, "7:base", a);
|
|
|
|
const text = await callAndAnswer(deps, a, "7:base", "exec-lua", { traposId: "7:base", code: "error('boom')" }, true, {
|
|
ok: false,
|
|
error: "boom",
|
|
output: "partial",
|
|
});
|
|
|
|
assert.equal(text, ["computer: 7:base", "ok: false", "error: boom", "output:", "partial"].join("\n"));
|
|
});
|
|
|
|
test("exec-lua maps a transport failure to a coded error", async () => {
|
|
const reg = new GatewayRegistry(silentLog);
|
|
const deps = makeDeps(reg);
|
|
// No computer registered for this traposId -> not_connected.
|
|
const reply = await handleMcpRequest(
|
|
rpc("tools/call", { name: "exec-lua", arguments: { traposId: "9:ghost", code: "x" } }),
|
|
deps,
|
|
);
|
|
assert.match(resultText(reply), /^computer: 9:ghost\nok: false\nerror: not_connected:/);
|
|
});
|
|
|
|
test("write-file reports the written path and byte count", async () => {
|
|
const reg = new GatewayRegistry(silentLog);
|
|
const deps = makeDeps(reg);
|
|
const a = new FakeSocket();
|
|
reg.register(ACCOUNT, "7:base", a);
|
|
|
|
const text = await callAndAnswer(
|
|
deps,
|
|
a,
|
|
"7:base",
|
|
"write-file",
|
|
{ traposId: "7:base", path: "/foo.txt", content: "hello" },
|
|
true,
|
|
{ ok: true, path: "/foo.txt", bytes: 5 },
|
|
);
|
|
|
|
assert.equal(a.lastFrame().type, "write-file");
|
|
assert.equal(text, ["computer: 7:base", "ok: true", "path: /foo.txt", "bytes: 5"].join("\n"));
|
|
});
|
|
|
|
test("exec-lua rejects a missing traposId with an invalid-params error", async () => {
|
|
const reply = await handleMcpRequest(
|
|
rpc("tools/call", { name: "exec-lua", arguments: { code: "x" } }),
|
|
makeDeps(new GatewayRegistry(silentLog)),
|
|
);
|
|
const error = (reply as { error?: { code: number; message: string } }).error;
|
|
assert.equal(error?.code, -32602);
|
|
assert.match(error?.message ?? "", /traposId/);
|
|
});
|
|
|
|
test("registry.list returns the traposIds registered for an account", () => {
|
|
const reg = new GatewayRegistry(silentLog);
|
|
reg.register(ACCOUNT, "7:base", new FakeSocket());
|
|
reg.register(ACCOUNT, "8:turtle", new FakeSocket());
|
|
reg.register("other", "9:base", new FakeSocket());
|
|
assert.deepEqual(reg.list(ACCOUNT).sort(), ["7:base", "8:turtle"]);
|
|
assert.deepEqual(reg.list("other"), ["9:base"]);
|
|
});
|