test(mcp): isolate bridge integration ports
This commit is contained in:
parent
dd9db5e61d
commit
dc4162c0fb
5
Justfile
5
Justfile
@ -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]
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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)",
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user