test(mcp): isolate bridge integration ports

This commit is contained in:
Guillaume ARM 2026-06-11 03:03:41 +02:00
parent dd9db5e61d
commit dc4162c0fb
11 changed files with 83 additions and 25 deletions

View File

@ -433,8 +433,11 @@ ci *args: check-craftos check check-packages npm-build npm-test
test *args: build npm-test test *args: build npm-test
@just _craftos-test {{args}} @just _craftos-test {{args}}
# Run end-to-end tests that span the MCP bridge and headless CraftOS-PC.
e2e: npm-test-integration
# Run integration and harness tests that are too broad for unit test targets. # Run integration and harness tests that are too broad for unit test targets.
test-integration: npm-test-integration test-timeout test-integration: e2e test-timeout
# Run CraftOS-PC headless integration tests. Pass `--pretty` for grouped output. # Run CraftOS-PC headless integration tests. Pass `--pretty` for grouped output.
[positional-arguments] [positional-arguments]

View File

@ -9,6 +9,7 @@ Start here when looking up ComputerCraft-related APIs, CraftOS-PC behavior, peri
- [`advanced_peripherals_glossary.md`](advanced_peripherals_glossary.md) - Advanced Peripherals 0.7 guides, peripherals, turtles, integrations, and changelog pages. - [`advanced_peripherals_glossary.md`](advanced_peripherals_glossary.md) - Advanced Peripherals 0.7 guides, peripherals, turtles, integrations, and changelog pages.
- [`create_cc_tweaked_glossary.md`](create_cc_tweaked_glossary.md) - Create CC:Tweaked integration pages. - [`create_cc_tweaked_glossary.md`](create_cc_tweaked_glossary.md) - Create CC:Tweaked integration pages.
- [`opencode_server_guide.md`](opencode_server_guide.md) - Running `opencode serve` for the TrapOS `ai` client. - [`opencode_server_guide.md`](opencode_server_guide.md) - Running `opencode serve` for the TrapOS `ai` client.
- [`ingame-trapos-ai-mcp-guide.md`](ingame-trapos-ai-mcp-guide.md) - Concise in-game checklist for installing TrapOS, connecting `ai`, and linking MCP.
- [`opencode_api.md`](opencode_api.md) - Minimal opencode HTTP API reference used by TrapOS. - [`opencode_api.md`](opencode_api.md) - Minimal opencode HTTP API reference used by TrapOS.
- [`public-ports.md`](public-ports.md) - Public production TCP port allocation for TrapOS services. - [`public-ports.md`](public-ports.md) - Public production TCP port allocation for TrapOS services.
- [`adrs/`](adrs/) - Lightweight Architecture Decision Records for this repository. - [`adrs/`](adrs/) - Lightweight Architecture Decision Records for this repository.

View File

@ -12,22 +12,22 @@ const HERE = dirname(fileURLToPath(import.meta.url));
const LUA_DIR = join(HERE, "lua"); const LUA_DIR = join(HERE, "lua");
const REPO_ROOT = join(HERE, "../../.."); const REPO_ROOT = join(HERE, "../../..");
export const MCP_PORT = 2000;
export const LINK_PORT = 2001;
export const MCP_URL = `http://127.0.0.1:${MCP_PORT}`;
export type Bridge = { export type Bridge = {
registry: LinkRegistry; registry: LinkRegistry;
mcpUrl: string;
linkUrl: string;
close: () => Promise<void>; close: () => Promise<void>;
}; };
export async function startBridge(probeTimeoutMs = 500): Promise<Bridge> { export async function startBridge(probeTimeoutMs = 500): Promise<Bridge> {
const registry = new LinkRegistry(); const registry = new LinkRegistry();
const mcpServer = startMcpServer({ host: "127.0.0.1", port: MCP_PORT, probeTimeoutMs, registry }); const mcpServer = startMcpServer({ host: "127.0.0.1", port: 0, probeTimeoutMs, registry });
const linkServer = startLinkServer({ host: "127.0.0.1", port: LINK_PORT, registry }); const linkServer = startLinkServer({ host: "127.0.0.1", port: 0, registry });
await Promise.all([waitForListening(mcpServer), waitForListening(linkServer)]); await Promise.all([waitForListening(mcpServer), waitForListening(linkServer)]);
return { return {
registry, registry,
mcpUrl: `http://127.0.0.1:${serverPort(mcpServer)}`,
linkUrl: `ws://127.0.0.1:${serverPort(linkServer)}`,
close: () => closeBridge(mcpServer, linkServer), close: () => closeBridge(mcpServer, linkServer),
}; };
} }
@ -43,14 +43,14 @@ export async function waitForComputers(registry: LinkRegistry, count: number, ti
throw new Error(`waitForComputers timed out (expected ${count}, got ${registry.count()})`); throw new Error(`waitForComputers timed out (expected ${count}, got ${registry.count()})`);
} }
export async function callProbeComputers(): Promise<string> { export async function callProbeComputers(mcpUrl: string): Promise<string> {
const body = { const body = {
jsonrpc: "2.0", jsonrpc: "2.0",
id: 1, id: 1,
method: "tools/call", method: "tools/call",
params: { name: "probe-computers", arguments: {} }, params: { name: "probe-computers", arguments: {} },
}; };
const response = await fetch(MCP_URL, { const response = await fetch(mcpUrl, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
@ -78,7 +78,7 @@ export type CraftosHandle = {
export function startCraftos( export function startCraftos(
luaName: string, luaName: string,
opts: { mountRepo?: boolean; shellArgs?: string[]; timeoutMs?: number } = {}, opts: { computerId?: number; computerLabel?: string; mountRepo?: boolean; shellArgs?: string[]; timeoutMs?: number } = {},
): CraftosHandle { ): CraftosHandle {
const timeoutMs = opts.timeoutMs ?? 15_000; const timeoutMs = opts.timeoutMs ?? 15_000;
const controller = new AbortController(); const controller = new AbortController();
@ -88,6 +88,9 @@ export function startCraftos(
const watchdog = setTimeout(() => controller.abort(), timeoutMs); const watchdog = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const args: string[] = ["--directory", dataDir, "--headless", "--mount-ro", `/staging=${LUA_DIR}`]; const args: string[] = ["--directory", dataDir, "--headless", "--mount-ro", `/staging=${LUA_DIR}`];
if (opts.computerId !== undefined) {
args.push("--id", String(opts.computerId));
}
if (opts.mountRepo) { if (opts.mountRepo) {
args.push( args.push(
"--mount-ro", `/trapos=${REPO_ROOT}`, "--mount-ro", `/trapos=${REPO_ROOT}`,
@ -98,7 +101,7 @@ export function startCraftos(
if (process.platform === "darwin") { if (process.platform === "darwin") {
args.push("--rom", "/Applications/CraftOS-PC.app/Contents/Resources"); args.push("--rom", "/Applications/CraftOS-PC.app/Contents/Resources");
} }
args.push("--exec", buildExecCode(luaName, opts.shellArgs ?? [])); args.push("--exec", buildExecCode(luaName, opts.shellArgs ?? [], opts.computerLabel));
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
const child = spawn("craftos", args, { signal: controller.signal }); const child = spawn("craftos", args, { signal: controller.signal });
@ -125,10 +128,11 @@ export function formatFailure(message: string, craftosOutput: string): string {
return lines.join("\n"); return lines.join("\n");
} }
function buildExecCode(luaName: string, shellArgs: string[]): string { function buildExecCode(luaName: string, shellArgs: string[], computerLabel?: string): string {
const programPath = luaName.startsWith("/") ? luaName : `/staging/${luaName}`; const programPath = luaName.startsWith("/") ? luaName : `/staging/${luaName}`;
const parts = [luaQuote(programPath), ...shellArgs.map(luaQuote)]; const parts = [luaQuote(programPath), ...shellArgs.map(luaQuote)];
return `shell.run(${parts.join(", ")})`; const setup = computerLabel === undefined ? "" : `os.setComputerLabel(${luaQuote(computerLabel)}); `;
return `${setup}shell.run(${parts.join(", ")})`;
} }
function luaQuote(value: string): string { function luaQuote(value: string): string {
@ -147,6 +151,14 @@ function waitForListening(server: Server | WebSocketServer): Promise<void> {
}); });
} }
function serverPort(server: Server | WebSocketServer): number {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("server does not have a TCP address");
}
return address.port;
}
async function closeBridge(mcpServer: Server, linkServer: WebSocketServer): Promise<void> { async function closeBridge(mcpServer: Server, linkServer: WebSocketServer): Promise<void> {
for (const client of linkServer.clients) { for (const client of linkServer.clients) {
client.terminate(); client.terminate();

View File

@ -1,6 +1,7 @@
-- Integration-test helper: open two websocket connections in parallel, each -- Integration-test helper: open two websocket connections in parallel, each
-- presenting itself as a distinct logical computer. Used by probe-multi. -- presenting itself as a distinct logical computer. Used by probe-multi.
local urlBase = "ws://127.0.0.1:2001"; local args = {...};
local urlBase = args[1] or "ws://127.0.0.1:2001";
local function connect(id, label, duration) local function connect(id, label, duration)
local url = urlBase .. "/?id=" .. id; local url = urlBase .. "/?id=" .. id;

View File

@ -5,9 +5,10 @@ local args = {...};
local id = tonumber(args[1]) or 99; local id = tonumber(args[1]) or 99;
local label = args[2] or "silent"; local label = args[2] or "silent";
local duration = tonumber(args[3]) or 5; local urlBase = args[3] or "ws://127.0.0.1:2001";
local duration = tonumber(args[4]) or 5;
local url = "ws://127.0.0.1:2001/?id=" .. id; local url = urlBase .. "/?id=" .. id;
local ws, err = http.websocket(url); local ws, err = http.websocket(url);
if not ws then if not ws then
print("websocket failed: " .. tostring(err)); print("websocket failed: " .. tostring(err));

View File

@ -5,7 +5,7 @@ import { callProbeComputers, startBridge } from "./harness.js";
test("probe-computers returns the no-computers message when nothing is connected", async () => { test("probe-computers returns the no-computers message when nothing is connected", async () => {
const bridge = await startBridge(); const bridge = await startBridge();
try { try {
assert.equal(await callProbeComputers(), "No computers connected."); assert.equal(await callProbeComputers(bridge.mcpUrl), "No computers connected.");
} finally { } finally {
await bridge.close(); await bridge.close();
} }

View File

@ -4,10 +4,10 @@ import { callProbeComputers, formatFailure, startBridge, startCraftos, waitForCo
test("probe-computers aggregates a single CraftOS echo computer", async () => { test("probe-computers aggregates a single CraftOS echo computer", async () => {
const bridge = await startBridge(); const bridge = await startBridge();
const craftos = startCraftos("echo-client.lua", { shellArgs: ["1", "echo-1", "ws://127.0.0.1:2001", "8"] }); const craftos = startCraftos("echo-client.lua", { shellArgs: ["1", "echo-1", bridge.linkUrl, "8"] });
try { try {
await waitForComputers(bridge.registry, 1, 12_000); await waitForComputers(bridge.registry, 1, 12_000);
const text = await callProbeComputers(); const text = await callProbeComputers(bridge.mcpUrl);
assert.equal(text, "pong from 1 (Label: echo-1)"); assert.equal(text, "pong from 1 (Label: echo-1)");
} catch (error) { } catch (error) {
craftos.abort(); craftos.abort();

View File

@ -4,10 +4,10 @@ import { callProbeComputers, formatFailure, startBridge, startCraftos, waitForCo
test("probe-computers aggregates two CraftOS computers from the same headless process", async () => { test("probe-computers aggregates two CraftOS computers from the same headless process", async () => {
const bridge = await startBridge(); const bridge = await startBridge();
const craftos = startCraftos("multi-echo-client.lua", { timeoutMs: 15_000 }); const craftos = startCraftos("multi-echo-client.lua", { shellArgs: [bridge.linkUrl], timeoutMs: 15_000 });
try { try {
await waitForComputers(bridge.registry, 2, 12_000); await waitForComputers(bridge.registry, 2, 12_000);
const text = await callProbeComputers(); const text = await callProbeComputers(bridge.mcpUrl);
const lines = text.split("\n").sort(); const lines = text.split("\n").sort();
assert.deepEqual(lines, [ assert.deepEqual(lines, [
"pong from 1001 (Label: echo-A)", "pong from 1001 (Label: echo-A)",

View File

@ -6,12 +6,12 @@ test("probe-computers talks to the real TrapOS mcp-computer program", async () =
const bridge = await startBridge(); const bridge = await startBridge();
const craftos = startCraftos("/programs/mcp-computer.lua", { const craftos = startCraftos("/programs/mcp-computer.lua", {
mountRepo: true, mountRepo: true,
shellArgs: ["ws://127.0.0.1:2001"], shellArgs: [bridge.linkUrl],
timeoutMs: 15_000, timeoutMs: 15_000,
}); });
try { try {
await waitForComputers(bridge.registry, 1, 12_000); await waitForComputers(bridge.registry, 1, 12_000);
const text = await callProbeComputers(); const text = await callProbeComputers(bridge.mcpUrl);
assert.equal(text, "pong from 0 (Label: null)"); assert.equal(text, "pong from 0 (Label: null)");
} catch (error) { } catch (error) {
craftos.abort(); craftos.abort();

View File

@ -4,10 +4,10 @@ import { callProbeComputers, formatFailure, startBridge, startCraftos, waitForCo
test("probe-computers reports timeout for a connected computer that never replies", async () => { test("probe-computers reports timeout for a connected computer that never replies", async () => {
const bridge = await startBridge(300); const bridge = await startBridge(300);
const craftos = startCraftos("silent-client.lua", { shellArgs: ["7", "silent-7", "8"] }); const craftos = startCraftos("silent-client.lua", { shellArgs: ["7", "silent-7", bridge.linkUrl, "8"] });
try { try {
await waitForComputers(bridge.registry, 1, 12_000); await waitForComputers(bridge.registry, 1, 12_000);
const text = await callProbeComputers(); const text = await callProbeComputers(bridge.mcpUrl);
assert.equal(text, "timeout from 7 (Label: silent-7)"); assert.equal(text, "timeout from 7 (Label: silent-7)");
} catch (error) { } catch (error) {
craftos.abort(); craftos.abort();

View File

@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
import { callProbeComputers, formatFailure, startBridge, startCraftos, waitForComputers } from "./harness.js";
test("probe-computers aggregates two real TrapOS mcp-computer programs", async () => {
const bridge = await startBridge();
const craftosA = startCraftos("/programs/mcp-computer.lua", {
computerId: 101,
computerLabel: "trap-A",
mountRepo: true,
shellArgs: [bridge.linkUrl],
timeoutMs: 15_000,
});
const craftosB = startCraftos("/programs/mcp-computer.lua", {
computerId: 202,
computerLabel: "trap-B",
mountRepo: true,
shellArgs: [bridge.linkUrl],
timeoutMs: 15_000,
});
try {
await waitForComputers(bridge.registry, 2, 12_000);
const lines = (await callProbeComputers(bridge.mcpUrl)).split("\n");
assert.equal(lines.length, 2);
assert(lines.some((line) => /^pong from \d+ \(Label: trap-A\)$/.test(line)));
assert(lines.some((line) => /^pong from \d+ \(Label: trap-B\)$/.test(line)));
} catch (error) {
craftosA.abort();
craftosB.abort();
const [resultA, resultB] = await Promise.all([craftosA.done, craftosB.done]);
const output = ["--- craftos A ---", resultA.output, "--- craftos B ---", resultB.output].join("\n");
throw new Error(formatFailure(error instanceof Error ? error.message : String(error), output), { cause: error });
} finally {
craftosA.abort();
craftosB.abort();
await Promise.all([craftosA.done, craftosB.done]);
await bridge.close();
}
});