tests / qemuMcpClient.test.cjs
"use strict";
/**
* Unit tests for qemuMcpClient.ts (MCP-over-stdio client).
*
* These tests use a fake MCP server (fakeMcpServer.cjs) to simulate the
* qemu-mcp-server Rust binary's stdio-based JSON-RPC protocol. The test
* helper (testHelper.cjs) manages swapping the real binary with the fake
* server.
*
* Run with: node --test tests/qemuMcpClient.test.cjs
*/
const { test, before, after } = require("node:test");
const { strict: assert } = require("node:assert");
const { spawn } = require("child_process");
const { existsSync, writeFileSync, chmodSync, unlinkSync } = require("fs");
const { resolve } = require("path");
const { invokeTool } = require("../dist/core/qemuMcpClient.js");
const {
withFakeBinary,
withoutBinary,
BIN_PATH,
BACKUP_PATH,
} = require("./testHelper.cjs");
// ──────────────────────────────────────────────────────────────────────────
// Fixtures
// ──────────────────────────────────────────────────────────────────────────
const DEFAULT_OPTS = {
qmpSocketPath: "/tmp/test-qmp-sock",
serialSocketPath: "/tmp/test-serial-sock",
};
// ──────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────
test("invokeTool execute_qmp returns tool response text", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
DEFAULT_OPTS
);
assert.ok(typeof r === "string", "result should be a string");
assert.ok(
r.includes("running"),
"result should contain 'running' from simulated query-status"
);
return r;
});
assert.ok(result);
});
test("invokeTool execute_qmp with eject command", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"execute_qmp",
{ qmp_command: "eject", qmp_arguments: '{"device": "ide-cd0"}' },
DEFAULT_OPTS
);
assert.ok(r.includes("Ejected"), "result should contain 'Ejected'");
assert.ok(r.includes("ide-cd0"), "result should contain 'ide-cd0'");
return r;
});
assert.ok(result);
});
test("invokeTool execute_qmp with stop command", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"execute_qmp",
{ qmp_command: "stop", qmp_arguments: "{}" },
DEFAULT_OPTS
);
assert.ok(r.includes("stopped"), "result should contain 'stopped'");
return r;
});
assert.ok(result);
});
test("invokeTool read_serial returns response text", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"read_serial",
{ wait_for: "Login:", timeout_ms: 5000, max_chars: 100 },
DEFAULT_OPTS
);
assert.ok(typeof r === "string");
assert.ok(r.includes("waiting for"), "should include wait info");
assert.ok(r.includes("Login:"), "should include wait_for value");
return r;
});
assert.ok(result);
});
test("invokeTool write_serial returns response text", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"write_serial",
{ data: "hello", newline: true },
DEFAULT_OPTS
);
assert.ok(typeof r === "string");
assert.ok(r.includes("5 byte(s)"), "should include byte count");
assert.ok(r.includes("with newline"), "should mention newline");
return r;
});
assert.ok(result);
});
test("invokeTool handles unknown tool name with error message", async (t) => {
await withFakeBinary(async () => {
let err;
try {
await invokeTool(
"nonexistent_tool",
{},
DEFAULT_OPTS
);
} catch (e) {
err = e;
}
assert.ok(err, "should have thrown an error");
assert.ok(
err.message.includes("Tool not found"),
"error should mention 'Tool not found'"
);
});
});
test("invokeTool abort signal cancels in-flight call", async (t) => {
await withFakeBinary(async () => {
const controller = new AbortController();
const { signal } = controller;
// Abort immediately (before the call completes)
controller.abort();
await assert.rejects(
invokeTool(
"read_serial",
{ wait_for: "slow-prompt", timeout_ms: 30000 },
{ ...DEFAULT_OPTS, abortSignal: signal }
),
/aborted/i,
"should reject with abort message"
);
});
});
test("invokeTool binary not found throws descriptive error", async (t) => {
await withoutBinary(async () => {
let err;
try {
await invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
DEFAULT_OPTS
);
} catch (e) {
err = e;
}
assert.ok(err, "should have thrown an error");
assert.ok(
err.message.includes("qemu-mcp-server binary not found"),
"error should mention binary not found"
);
});
});
test("invokeTool QMP_SOCKET_PATH env var is passed to server", async (t) => {
const result = await withFakeBinary(async () => {
const customSocket = "/tmp/custom-qmp-sock";
const r = await invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
{ ...DEFAULT_OPTS, qmpSocketPath: customSocket }
);
// The fake server doesn't actually use the socket path, but we verify
// the call succeeds with the custom config.
assert.ok(typeof r === "string");
return r;
});
assert.ok(result);
});
test("invokeTool SERIAL_SOCKET_PATH env var is passed to server", async (t) => {
const result = await withFakeBinary(async () => {
const customSerial = "/tmp/custom-serial-sock";
const r = await invokeTool(
"read_serial",
{ wait_for: "test", timeout_ms: 1000 },
{ ...DEFAULT_OPTS, serialSocketPath: customSerial }
);
assert.ok(typeof r === "string");
return r;
});
assert.ok(result);
});
test("invokeTool output truncation at MAX_OUTPUT_LENGTH (2000 chars)", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
DEFAULT_OPTS
);
assert.ok(r.length <= 2000 + 20, "result should be within truncated limit");
return r;
});
assert.ok(result);
});
test("invokeTool malformed JSON in response is handled gracefully", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
DEFAULT_OPTS
);
assert.ok(typeof r === "string");
return r;
});
assert.ok(result);
});
test("invokeTool multiple sequential calls work correctly", async (t) => {
const results = await withFakeBinary(async () => {
const r1 = await invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
DEFAULT_OPTS
);
const r2 = await invokeTool(
"write_serial",
{ data: "test", newline: false },
DEFAULT_OPTS
);
const r3 = await invokeTool(
"read_serial",
{ timeout_ms: 1000 },
DEFAULT_OPTS
);
assert.ok(r1.includes("running"));
assert.ok(r2.includes("4 byte(s)"), "should include '4 byte(s)' for 'test'");
assert.ok(r3.includes("no output pending"));
return { r1, r2, r3 };
});
assert.ok(results);
});
test("invokeTool abort signal already aborted before call", async (t) => {
await withFakeBinary(async () => {
const controller = new AbortController();
controller.abort();
await assert.rejects(
invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
{ ...DEFAULT_OPTS, abortSignal: controller.signal }
),
/aborted/i,
"should reject immediately when already aborted"
);
});
});
tests / qemuMcpClient.test.cjs
"use strict";
/**
* Unit tests for qemuMcpClient.ts (MCP-over-stdio client).
*
* These tests use a fake MCP server (fakeMcpServer.cjs) to simulate the
* qemu-mcp-server Rust binary's stdio-based JSON-RPC protocol. The test
* helper (testHelper.cjs) manages swapping the real binary with the fake
* server.
*
* Run with: node --test tests/qemuMcpClient.test.cjs
*/
const { test, before, after } = require("node:test");
const { strict: assert } = require("node:assert");
const { spawn } = require("child_process");
const { existsSync, writeFileSync, chmodSync, unlinkSync } = require("fs");
const { resolve } = require("path");
const { invokeTool } = require("../dist/core/qemuMcpClient.js");
const {
withFakeBinary,
withoutBinary,
BIN_PATH,
BACKUP_PATH,
} = require("./testHelper.cjs");
// ──────────────────────────────────────────────────────────────────────────
// Fixtures
// ──────────────────────────────────────────────────────────────────────────
const DEFAULT_OPTS = {
qmpSocketPath: "/tmp/test-qmp-sock",
serialSocketPath: "/tmp/test-serial-sock",
};
// ──────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────
test("invokeTool execute_qmp returns tool response text", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
DEFAULT_OPTS
);
assert.ok(typeof r === "string", "result should be a string");
assert.ok(
r.includes("running"),
"result should contain 'running' from simulated query-status"
);
return r;
});
assert.ok(result);
});
test("invokeTool execute_qmp with eject command", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"execute_qmp",
{ qmp_command: "eject", qmp_arguments: '{"device": "ide-cd0"}' },
DEFAULT_OPTS
);
assert.ok(r.includes("Ejected"), "result should contain 'Ejected'");
assert.ok(r.includes("ide-cd0"), "result should contain 'ide-cd0'");
return r;
});
assert.ok(result);
});
test("invokeTool execute_qmp with stop command", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"execute_qmp",
{ qmp_command: "stop", qmp_arguments: "{}" },
DEFAULT_OPTS
);
assert.ok(r.includes("stopped"), "result should contain 'stopped'");
return r;
});
assert.ok(result);
});
test("invokeTool read_serial returns response text", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"read_serial",
{ wait_for: "Login:", timeout_ms: 5000, max_chars: 100 },
DEFAULT_OPTS
);
assert.ok(typeof r === "string");
assert.ok(r.includes("waiting for"), "should include wait info");
assert.ok(r.includes("Login:"), "should include wait_for value");
return r;
});
assert.ok(result);
});
test("invokeTool write_serial returns response text", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"write_serial",
{ data: "hello", newline: true },
DEFAULT_OPTS
);
assert.ok(typeof r === "string");
assert.ok(r.includes("5 byte(s)"), "should include byte count");
assert.ok(r.includes("with newline"), "should mention newline");
return r;
});
assert.ok(result);
});
test("invokeTool handles unknown tool name with error message", async (t) => {
await withFakeBinary(async () => {
let err;
try {
await invokeTool(
"nonexistent_tool",
{},
DEFAULT_OPTS
);
} catch (e) {
err = e;
}
assert.ok(err, "should have thrown an error");
assert.ok(
err.message.includes("Tool not found"),
"error should mention 'Tool not found'"
);
});
});
test("invokeTool abort signal cancels in-flight call", async (t) => {
await withFakeBinary(async () => {
const controller = new AbortController();
const { signal } = controller;
// Abort immediately (before the call completes)
controller.abort();
await assert.rejects(
invokeTool(
"read_serial",
{ wait_for: "slow-prompt", timeout_ms: 30000 },
{ ...DEFAULT_OPTS, abortSignal: signal }
),
/aborted/i,
"should reject with abort message"
);
});
});
test("invokeTool binary not found throws descriptive error", async (t) => {
await withoutBinary(async () => {
let err;
try {
await invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
DEFAULT_OPTS
);
} catch (e) {
err = e;
}
assert.ok(err, "should have thrown an error");
assert.ok(
err.message.includes("qemu-mcp-server binary not found"),
"error should mention binary not found"
);
});
});
test("invokeTool QMP_SOCKET_PATH env var is passed to server", async (t) => {
const result = await withFakeBinary(async () => {
const customSocket = "/tmp/custom-qmp-sock";
const r = await invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
{ ...DEFAULT_OPTS, qmpSocketPath: customSocket }
);
// The fake server doesn't actually use the socket path, but we verify
// the call succeeds with the custom config.
assert.ok(typeof r === "string");
return r;
});
assert.ok(result);
});
test("invokeTool SERIAL_SOCKET_PATH env var is passed to server", async (t) => {
const result = await withFakeBinary(async () => {
const customSerial = "/tmp/custom-serial-sock";
const r = await invokeTool(
"read_serial",
{ wait_for: "test", timeout_ms: 1000 },
{ ...DEFAULT_OPTS, serialSocketPath: customSerial }
);
assert.ok(typeof r === "string");
return r;
});
assert.ok(result);
});
test("invokeTool output truncation at MAX_OUTPUT_LENGTH (2000 chars)", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
DEFAULT_OPTS
);
assert.ok(r.length <= 2000 + 20, "result should be within truncated limit");
return r;
});
assert.ok(result);
});
test("invokeTool malformed JSON in response is handled gracefully", async (t) => {
const result = await withFakeBinary(async () => {
const r = await invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
DEFAULT_OPTS
);
assert.ok(typeof r === "string");
return r;
});
assert.ok(result);
});
test("invokeTool multiple sequential calls work correctly", async (t) => {
const results = await withFakeBinary(async () => {
const r1 = await invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
DEFAULT_OPTS
);
const r2 = await invokeTool(
"write_serial",
{ data: "test", newline: false },
DEFAULT_OPTS
);
const r3 = await invokeTool(
"read_serial",
{ timeout_ms: 1000 },
DEFAULT_OPTS
);
assert.ok(r1.includes("running"));
assert.ok(r2.includes("4 byte(s)"), "should include '4 byte(s)' for 'test'");
assert.ok(r3.includes("no output pending"));
return { r1, r2, r3 };
});
assert.ok(results);
});
test("invokeTool abort signal already aborted before call", async (t) => {
await withFakeBinary(async () => {
const controller = new AbortController();
controller.abort();
await assert.rejects(
invokeTool(
"execute_qmp",
{ qmp_command: "query-status", qmp_arguments: "{}" },
{ ...DEFAULT_OPTS, abortSignal: controller.signal }
),
/aborted/i,
"should reject immediately when already aborted"
);
});
});