242 lines
8.5 KiB
JavaScript
242 lines
8.5 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
electronExecutableCandidates,
|
|
electronRuntime,
|
|
findElectronExecutable,
|
|
isElectronHost,
|
|
isNodeFreeLauncher,
|
|
launcherScript,
|
|
looksLikeElectronExecutable,
|
|
resolveRuntime,
|
|
} from "../out/agent/runtime.js";
|
|
import { writeLauncher } from "../out/agent/setup.js";
|
|
|
|
const WIN = "win32";
|
|
const LINUX = "linux";
|
|
|
|
// ── Pure launcher generation ──────────────────────────────────────────
|
|
|
|
test("electron runtime launcher runs without Node on PATH", () => {
|
|
const script = launcherScript(
|
|
{
|
|
runtime: electronRuntime("C:\\Apps\\VSCode\\Code.exe"),
|
|
serverPath: "C:\\ext\\dist\\agent\\mcpServer.js",
|
|
projectDir: "D:\\Mods\\Example",
|
|
},
|
|
WIN,
|
|
);
|
|
assert.ok(script.startsWith("@echo off"));
|
|
assert.ok(script.includes("set ELECTRON_RUN_AS_NODE=1"));
|
|
assert.ok(script.includes("C:\\Apps\\VSCode\\Code.exe"));
|
|
assert.ok(script.includes("C:\\ext\\dist\\agent\\mcpServer.js"));
|
|
// The Node path exists but must be guarded by the runtime-existence jump.
|
|
assert.ok(script.includes('if not exist "%RA3_RUNTIME%" goto :ra3_node'));
|
|
assert.ok(script.includes(":ra3_node"));
|
|
assert.equal(isNodeFreeLauncher(script, WIN), true);
|
|
});
|
|
|
|
test("Windows launcher avoids the parse-time %errorlevel% batch pitfall", () => {
|
|
const script = launcherScript(
|
|
{
|
|
runtime: electronRuntime("C:\\Apps\\VSCode\\Code.exe"),
|
|
serverPath: "C:\\ext\\mcpServer.js",
|
|
projectDir: "D:\\P",
|
|
},
|
|
WIN,
|
|
);
|
|
// Inside a parenthesised block %errorlevel% would expand at parse time.
|
|
assert.ok(!/^if exist .*\(\s*$/m.test(script), "must not use an if (...) block");
|
|
assert.equal(
|
|
(script.match(/exit \/b %errorlevel%/g) ?? []).length,
|
|
2,
|
|
"both Electron and Node endings should propagate the exit code",
|
|
);
|
|
});
|
|
|
|
test("node runtime launcher is generated when Electron is unavailable", () => {
|
|
const script = launcherScript(
|
|
{
|
|
runtime: { kind: "node", executable: "node", env: {}, viaPath: true },
|
|
serverPath: "C:\\ext\\mcpServer.js",
|
|
projectDir: "D:\\P",
|
|
},
|
|
WIN,
|
|
);
|
|
assert.ok(!script.includes("ELECTRON_RUN_AS_NODE"));
|
|
assert.ok(script.includes('set "RA3_NODE=node"'));
|
|
assert.equal(isNodeFreeLauncher(script, WIN), false);
|
|
});
|
|
|
|
test("POSIX launcher prefers Electron and falls back to Node", () => {
|
|
const script = launcherScript(
|
|
{
|
|
runtime: electronRuntime("/usr/share/code/code"),
|
|
serverPath: "/ext/mcpServer.js",
|
|
projectDir: "/mods/example",
|
|
},
|
|
LINUX,
|
|
);
|
|
assert.ok(script.startsWith("#!/usr/bin/env sh"));
|
|
assert.ok(script.includes("ELECTRON_RUN_AS_NODE=1 exec"));
|
|
assert.ok(script.includes('if [ -x "$RA3_RUNTIME" ]'));
|
|
assert.equal(isNodeFreeLauncher(script, LINUX), true);
|
|
});
|
|
|
|
test("launcher quotes paths safely", () => {
|
|
const win = launcherScript(
|
|
{
|
|
runtime: electronRuntime("C:\\Program Files\\VS Code\\Code.exe"),
|
|
serverPath: "C:\\my ext\\mcpServer.js",
|
|
projectDir: "D:\\My Mods\\Example",
|
|
},
|
|
WIN,
|
|
);
|
|
assert.ok(win.includes('set "RA3_RUNTIME=C:\\Program Files\\VS Code\\Code.exe"'));
|
|
assert.ok(win.includes('set "RA3_SERVER=C:\\my ext\\mcpServer.js"'));
|
|
|
|
const sh = launcherScript(
|
|
{
|
|
runtime: electronRuntime("/opt/it's here/code"),
|
|
serverPath: "/tmp/server.js",
|
|
projectDir: "/tmp/proj",
|
|
},
|
|
LINUX,
|
|
);
|
|
// Single quotes inside a single-quoted POSIX string must be escaped.
|
|
assert.ok(sh.includes(`'/opt/it'\\''s here/code'`));
|
|
});
|
|
|
|
test("looksLikeElectronExecutable recognises VS Code-family binaries", () => {
|
|
assert.equal(looksLikeElectronExecutable("C:\\...\\Microsoft VS Code\\Code.exe"), true);
|
|
assert.equal(looksLikeElectronExecutable("/usr/share/code/code"), true);
|
|
assert.equal(looksLikeElectronExecutable("/Applications/Visual Studio Code.app/Contents/MacOS/Electron"), true);
|
|
assert.equal(looksLikeElectronExecutable("C:\\Windows\\System32\\cmd.exe"), false);
|
|
});
|
|
|
|
test("runtime resolution falls back to Node outside an Electron host", () => {
|
|
// Tests run under plain Node, so the resolved runtime must be Node.
|
|
assert.equal(isElectronHost(), false);
|
|
const runtime = resolveRuntime();
|
|
assert.equal(runtime.kind, "node");
|
|
assert.equal(runtime.viaPath, true);
|
|
assert.deepEqual(runtime.env, {});
|
|
});
|
|
|
|
test("electronRuntime carries the ELECTRON_RUN_AS_NODE env", () => {
|
|
const runtime = electronRuntime("C:\\Code.exe");
|
|
assert.equal(runtime.kind, "electron");
|
|
assert.equal(runtime.executable, "C:\\Code.exe");
|
|
assert.deepEqual(runtime.env, { ELECTRON_RUN_AS_NODE: "1" });
|
|
assert.equal(runtime.viaPath, false);
|
|
});
|
|
|
|
test("electronExecutableCandidates returns platform-appropriate probes", () => {
|
|
const candidates = electronExecutableCandidates();
|
|
assert.ok(Array.isArray(candidates));
|
|
if (process.platform === "win32") {
|
|
assert.ok(candidates.every((c) => c.endsWith(".exe")));
|
|
}
|
|
// findElectronExecutable must never throw and returns a string or null.
|
|
const found = findElectronExecutable();
|
|
assert.ok(found === null || typeof found === "string");
|
|
});
|
|
|
|
// ── writeLauncher integration ─────────────────────────────────────────
|
|
|
|
test("writeLauncher writes an executable launcher and reports node-freeness", async () => {
|
|
const home = mkdtempSync(join(tmpdir(), "ra3-runtime-test-"));
|
|
try {
|
|
const result = await writeLauncher("C:\\ext", "D:\\Mods\\Example", home, electronRuntime("C:\\Code.exe"));
|
|
assert.ok(existsSync(result.path));
|
|
assert.equal(result.nodeFree, true);
|
|
assert.equal(result.runtime.kind, "electron");
|
|
const text = readFileSync(result.path, "utf8");
|
|
assert.ok(text.includes("ELECTRON_RUN_AS_NODE=1"));
|
|
assert.ok(text.includes("mcpServer.js"));
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ── Live launcher execution (skipped when the sandbox forbids spawning) ──
|
|
|
|
const electron = findElectronExecutable();
|
|
|
|
/**
|
|
* True when this process may launch the launcher, i.e. spawn a shell.
|
|
*
|
|
* Some sandboxes allow spawning `node` directly but deny `cmd.exe`/`sh`, so
|
|
* the probe must exercise the same capability the test needs instead of just
|
|
* spawning any child process.
|
|
*/
|
|
function canSpawnShell() {
|
|
try {
|
|
if (process.platform === "win32") {
|
|
execFileSync("cmd.exe", ["/d", "/c", "exit 0"], { stdio: "ignore" });
|
|
} else {
|
|
execFileSync("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" });
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const spawnable = canSpawnShell();
|
|
|
|
test(
|
|
"generated launcher completes an MCP session on the Electron runtime",
|
|
{ skip: !spawnable || !electron },
|
|
(t) => {
|
|
const home = mkdtempSync(join(tmpdir(), "ra3-launcher-run-"));
|
|
try {
|
|
const serverPath = join(process.cwd(), "dist", "agent", "mcpServer.js");
|
|
const launcher = join(home, process.platform === "win32" ? "launch.cmd" : "launch.sh");
|
|
writeFileSync(
|
|
launcher,
|
|
launcherScript({
|
|
runtime: electronRuntime(electron),
|
|
serverPath,
|
|
projectDir: "D:/Mods/Example",
|
|
// Point the Node fallback at a path that cannot exist, so a
|
|
// successful session proves the Electron branch was taken and the
|
|
// launcher really is Node-free.
|
|
nodeFallback: join(home, "no-such-node"),
|
|
}),
|
|
"utf8",
|
|
);
|
|
const requests = [
|
|
JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
|
JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
|
|
].join("\n");
|
|
let stdout;
|
|
try {
|
|
stdout = execFileSync(launcher, [], {
|
|
input: requests,
|
|
encoding: "utf8",
|
|
timeout: 60000,
|
|
shell: process.platform === "win32",
|
|
});
|
|
} catch (err) {
|
|
if (err?.code === "EPERM") {
|
|
t.skip("sandbox forbids spawning a shell");
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
const lines = stdout.split(/\r?\n/).filter((l) => l.trim());
|
|
const init = JSON.parse(lines[0]);
|
|
assert.equal(init.result.serverInfo.name, "ra3-mod-xml");
|
|
const tools = JSON.parse(lines[1]);
|
|
assert.ok(tools.result.tools.length >= 9);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|