This commit is contained in:
2026-09-10 19:18:15 +02:00
parent 3a3d70efeb
commit 5993da4ce6
21 changed files with 2916 additions and 340 deletions
+187
View File
@@ -0,0 +1,187 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
LIVE_ONLY,
liveArgsFor,
parseArgs,
toolNameFor,
} from "../out/agent/cli.js";
// ── Argument parsing and command mapping (pure) ───────────────────────
test("parseArgs reads the project, snapshot and command", () => {
const options = parseArgs(["--project", "D:/Mods/Alpha", "find", "AthenaCannon", "GameObject"]);
assert.equal(options.projectDir, "D:/Mods/Alpha");
assert.equal(options.command, "find");
assert.deepEqual(options.args, ["AthenaCannon", "GameObject"]);
});
test("parseArgs ignores unknown flags without failing the call", () => {
// An agent probing with a flag we do not know yet must not crash the CLI.
const withCommand = parseArgs(["--json", "status"]);
assert.equal(withCommand.command, "status");
const trailing = parseArgs(["find", "X", "--future-flag"]);
assert.equal(trailing.command, "find");
assert.deepEqual(trailing.args, ["X"]);
// A stray positional is still treated as the command name.
assert.equal(parseArgs([]).command, "status");
});
test("parseArgs reads outgoing-specific options", () => {
const options = parseArgs([
"outgoing", "AthenaCannon", "GameObject",
"--depth", "2",
"--target-types", "WeaponTemplate, GameObject",
"--max-edges", "25",
"--include-unresolved",
]);
assert.equal(options.command, "outgoing");
assert.equal(options.depth, 2);
assert.deepEqual(options.targetTypes, ["WeaponTemplate", "GameObject"]);
assert.equal(options.maxEdges, 25);
assert.equal(options.includeUnresolved, true);
});
test("toolNameFor maps every CLI command to a live tool", () => {
assert.equal(toolNameFor("find"), "find_asset");
assert.equal(toolNameFor("refs"), "find_references");
assert.equal(toolNameFor("outgoing"), "get_asset_references");
assert.equal(toolNameFor("list"), "list_assets_by_type");
assert.equal(toolNameFor("active"), "is_file_active");
assert.equal(toolNameFor("define"), "find_define");
assert.equal(toolNameFor("resolve"), "resolve_include");
assert.equal(toolNameFor("projects"), "list_projects");
assert.equal(toolNameFor("status"), "get_status");
});
test("LIVE_ONLY covers exactly the commands needing DOM/project context", () => {
assert.equal(LIVE_ONLY.has("outgoing"), true);
assert.equal(LIVE_ONLY.has("projects"), true);
assert.equal(LIVE_ONLY.has("find"), false);
assert.equal(LIVE_ONLY.has("status"), false);
});
test("liveArgsFor builds the right payload per command", () => {
assert.deepEqual(
liveArgsFor({ command: "find", args: ["X", "GameObject"] }),
{ id: "X", type: "GameObject" },
);
assert.deepEqual(
liveArgsFor({ command: "active", args: ["D:/f.xml"] }),
{ path: "D:/f.xml" },
);
// Outgoing omits unset options so the server applies its own defaults.
const outgoing = liveArgsFor({ command: "outgoing", args: ["X"] });
assert.equal(outgoing.id, "X");
assert.equal(outgoing.depth, undefined);
assert.equal(outgoing.targetTypes, undefined);
assert.equal(outgoing.maxEdges, undefined);
// An explicit depth of 0 is falsy but must still be forwarded.
const zero = liveArgsFor({ command: "outgoing", args: ["X"], depth: 0 });
assert.equal(zero.depth, 0);
});
// ── Project inference from the current directory ──────────────────────
function makeModProject() {
const root = mkdtempSync(join(tmpdir(), "ra3-cli-proj-"));
mkdirSync(join(root, "Data"), { recursive: true });
writeFileSync(join(root, "Data", "Mod.xml"), "<AssetDeclaration/>");
return root;
}
test("the CLI finds the project root by walking up from the cwd", async () => {
const root = makeModProject();
const nested = join(root, "Data", "Allied", "Units");
mkdirSync(nested, { recursive: true });
const previous = process.cwd();
try {
process.chdir(nested);
const options = parseArgs([]);
const resolved = (await import("../out/agent/cli.js")).resolveProjectDir(options);
assert.equal(resolved?.toLowerCase(), root.toLowerCase());
} finally {
process.chdir(previous);
rmSync(root, { recursive: true, force: true });
}
});
test("resolveProjectDir prefers an explicit --project over the cwd", async () => {
const { resolveProjectDir } = await import("../out/agent/cli.js");
const options = parseArgs(["--project", "D:/Somewhere/Else", "status"]);
assert.equal(resolveProjectDir(options), join("D:\\Somewhere\\Else").replace(/\\/g, "\\"));
});
// ── End-to-end execution (skipped when the sandbox forbids spawning) ──
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();
const cliPath = join(process.cwd(), "dist", "agent", "cli.js");
test("CLI exits 3 with a clear message outside a project", { skip: !spawnable || !existsSync(cliPath) }, (t) => {
const empty = mkdtempSync(join(tmpdir(), "ra3-cli-empty-"));
try {
let code = 0;
let stderr = "";
try {
execFileSync(process.execPath, [cliPath, "status"], {
cwd: empty,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
if (err?.code === "EPERM") {
t.skip("sandbox forbids spawning");
return;
}
code = err.status;
stderr = err.stderr ?? "";
}
assert.equal(code, 3);
assert.match(stderr, /No project found/);
} finally {
rmSync(empty, { recursive: true, force: true });
}
});
test("CLI reports a live-only command as unavailable instead of empty", { skip: !spawnable || !existsSync(cliPath) }, (t) => {
const empty = mkdtempSync(join(tmpdir(), "ra3-cli-liveonly-"));
try {
let stdout = "";
try {
stdout = execFileSync(process.execPath, [cliPath, "--project", "D:/Mods/Nope", "outgoing", "X"], {
cwd: empty,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
if (err?.code === "EPERM") {
t.skip("sandbox forbids spawning");
return;
}
stdout = err.stdout ?? "";
}
const payload = JSON.parse(stdout);
assert.equal(payload.source, "unavailable");
// Must explain why, never look like "this asset has no references".
assert.match(payload.error, /requires a live index/);
} finally {
rmSync(empty, { recursive: true, force: true });
}
});
+168
View File
@@ -0,0 +1,168 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
INSTANCE_SCHEMA_VERSION,
clearInstance,
instanceFileName,
instancesDir,
makeInstanceId,
manifestPath,
projectsOf,
pruneInstances,
readInstances,
readManifest,
writeInstance,
writeManifest,
} from "../out/agent/instances.js";
function instanceHome() {
return mkdtempSync(join(tmpdir(), "ra3-instances-"));
}
function makeInstance(id, pid = process.pid) {
return {
instanceId: id,
url: `http://127.0.0.1:${10000 + (pid % 1000)}`,
token: `tok-${id}`,
projectDir: "D:/Mods/Alpha",
projects: ["D:/Mods/Alpha"],
processId: pid,
};
}
test("instances are written and read back", async () => {
const home = instanceHome();
try {
const file = await writeInstance(makeInstance("a-1"), home);
assert.ok(existsSync(file));
assert.ok(file.includes(instancesDir(home)));
assert.equal(file.endsWith(instanceFileName("a-1")), true);
const all = await readInstances(home);
assert.equal(all.length, 1);
assert.equal(all[0].instanceId, "a-1");
assert.equal(all[0].token, "tok-a-1");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("concurrent windows do not overwrite each other", async () => {
const home = instanceHome();
try {
// Two windows enabling agent access at the same time: separate files, so
// there is no read-modify-write race to guard.
await writeInstance(makeInstance("win-a"), home);
await writeInstance(makeInstance("win-b"), home);
const ids = (await readInstances(home)).map((i) => i.instanceId).sort();
assert.deepEqual(ids, ["win-a", "win-b"]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("pruneInstances removes entries whose PID is dead", async () => {
const home = instanceHome();
try {
// 0x7fffffff is not a valid Windows PID, so it cannot be alive.
await writeInstance(makeInstance("alive", process.pid), home);
await writeInstance(makeInstance("dead", 0x7fffffff), home);
const result = await pruneInstances(home);
assert.deepEqual(result.removed, ["dead"]);
assert.deepEqual(
result.kept.map((i) => i.instanceId),
["alive"],
);
// The dead file must actually be gone from disk.
assert.equal(existsSync(join(instancesDir(home), instanceFileName("dead"))), false);
assert.equal((await readInstances(home)).length, 1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("pruneInstances keeps entries with an unknown PID", async () => {
const home = instanceHome();
try {
// An older/simpler instance file without processId must never be pruned.
await writeInstance(
{ instanceId: "no-pid", url: "http://127.0.0.1:1", token: "t" },
home,
);
const result = await pruneInstances(home);
assert.deepEqual(result.removed, []);
assert.equal(result.kept.length, 1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("clearInstance only removes its own file", async () => {
const home = instanceHome();
try {
await writeInstance(makeInstance("mine"), home);
await writeInstance(makeInstance("theirs"), home);
await clearInstance("mine", home);
const ids = (await readInstances(home)).map((i) => i.instanceId);
assert.deepEqual(ids, ["theirs"]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("readInstances tolerates corrupt and unrelated files", async () => {
const home = instanceHome();
try {
await writeInstance(makeInstance("good"), home);
writeFileSync(join(instancesDir(home), "broken.json"), "{ not json");
writeFileSync(join(instancesDir(home), "notes.txt"), "ignore me");
const all = await readInstances(home);
assert.deepEqual(all.map((i) => i.instanceId), ["good"]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("projectsOf unions projects across instances without duplicates", () => {
const projects = projectsOf([
{ instanceId: "a", url: "u", token: "t", projectDir: "D:/Mods/Alpha", projects: ["D:/Mods/Alpha"] },
{ instanceId: "b", url: "u", token: "t", projectDir: "D:/Mods/Beta", projects: ["D:/Mods/beta", "D:/Mods/Gamma"] },
]);
// "beta" appears twice with different casing and must collapse to one entry,
// keeping the first spelling seen.
assert.deepEqual(projects, ["D:/Mods/Alpha", "D:/Mods/beta", "D:/Mods/Gamma"]);
});
test("writeManifest produces a discovery manifest without tokens", async () => {
const home = instanceHome();
try {
const manifest = await writeManifest(
[makeInstance("a-1"), makeInstance("b-2")],
home,
);
assert.equal(manifest.schemaVersion, INSTANCE_SCHEMA_VERSION);
assert.deepEqual(manifest.projects, ["D:/Mods/Alpha"]);
assert.equal(manifest.instances.length, 2);
const raw = readFileSync(manifestPath(home), "utf8");
// The manifest is for discovery; secrets must not leak into it.
assert.equal(raw.includes("tok-a-1"), false);
assert.equal(raw.includes('"token"'), false);
const reread = await readManifest(home);
assert.equal(reread?.instances.length, 2);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("makeInstanceId is unique across rapid calls", () => {
const ids = new Set();
for (let i = 0; i < 200; i++) ids.add(makeInstanceId(1234));
assert.equal(ids.size, 200);
for (const id of ids) assert.ok(id.startsWith("1234-"), id);
});
+205
View File
@@ -0,0 +1,205 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
LiveClient,
findEndpoint,
liveUrlForTool,
normalizePath,
queryLive,
responseProjectMismatch,
} from "../out/agent/liveClient.js";
import { writeInstance } from "../out/agent/instances.js";
import { writeEndpoint, writeEndpointForProject } from "../out/agent/endpoint.js";
const PROJECT_A = "D:/Mods/Alpha";
const PROJECT_B = "D:/Mods/Beta";
function home() {
return mkdtempSync(join(tmpdir(), "ra3-liveclient-"));
}
test("live URLs pin the requested project for every tool", () => {
const cases = [
["get_status", {}],
["find_asset", { id: "X" }],
["find_references", { id: "X" }],
["list_assets_by_type", { type: "GameObject" }],
["is_file_active", { path: "D:/f.xml" }],
["find_define", { name: "D" }],
["resolve_include", { source: "DATA:a.xml" }],
["list_projects", {}],
];
for (const [tool, args] of cases) {
const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, tool, args);
assert.ok(url, `${tool} should have a live URL`);
assert.equal(
new URL(url).searchParams.get("project"),
PROJECT_A,
`${tool} must pin the project`,
);
}
});
test("get_asset_references serialises all of its options", () => {
const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, "get_asset_references", {
id: "AthenaCannon",
type: "GameObject",
depth: 2,
targetTypes: ["WeaponTemplate", "GameObject"],
maxEdges: 25,
includeUnresolved: true,
});
const q = new URL(url).searchParams;
assert.equal(q.get("id"), "AthenaCannon");
assert.equal(q.get("type"), "GameObject");
assert.equal(q.get("depth"), "2");
assert.equal(q.get("targetTypes"), "WeaponTemplate,GameObject");
assert.equal(q.get("maxEdges"), "25");
assert.equal(q.get("includeUnresolved"), "true");
assert.equal(q.get("project"), PROJECT_A);
});
test("unknown tools have no live URL", () => {
assert.equal(liveUrlForTool("http://127.0.0.1:1", PROJECT_A, "not_a_tool", {}), null);
});
test("responseProjectMismatch refuses another project's answer", () => {
assert.equal(
responseProjectMismatch({ index: { projectDir: "d:/mods/alpha" } }, PROJECT_A),
false,
);
assert.equal(
responseProjectMismatch({ index: { projectDir: PROJECT_B } }, PROJECT_A),
true,
);
// Nothing to compare against: not a mismatch.
assert.equal(responseProjectMismatch({ index: { state: "ready" } }, PROJECT_A), false);
assert.equal(responseProjectMismatch({ index: { projectDir: PROJECT_B } }, null), false);
});
test("normalizePath ignores case and trailing separators", () => {
assert.equal(normalizePath("D:\\Mods\\Alpha\\"), normalizePath("d:/mods/alpha"));
});
test("findEndpoint prefers the per-project endpoint", async () => {
const h = home();
try {
await writeEndpointForProject(
PROJECT_A,
{ url: "http://127.0.0.1:1111", token: "tok-a", processId: process.pid },
h,
);
const endpoint = await findEndpoint({ projectDir: PROJECT_A, agentHome: h });
assert.equal(endpoint?.url, "http://127.0.0.1:1111");
} finally {
rmSync(h, { recursive: true, force: true });
}
});
test("findEndpoint finds a live instance when no per-project file exists", async () => {
const h = home();
try {
// A window that has not yet written per-project endpoints, only its own
// instance file. Discovery must still find it.
await writeInstance(
{
instanceId: "w1",
url: "http://127.0.0.1:2222",
token: "tok-inst",
projects: [PROJECT_A, PROJECT_B],
processId: process.pid,
},
h,
);
const endpoint = await findEndpoint({ projectDir: PROJECT_B, agentHome: h });
assert.equal(endpoint?.url, "http://127.0.0.1:2222");
} finally {
rmSync(h, { recursive: true, force: true });
}
});
test("findEndpoint ignores instances that do not serve the project", async () => {
const h = home();
try {
await writeInstance(
{
instanceId: "other",
url: "http://127.0.0.1:3333",
token: "tok",
projects: ["D:/Mods/Unrelated"],
processId: process.pid,
},
h,
);
assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null);
} finally {
rmSync(h, { recursive: true, force: true });
}
});
test("findEndpoint ignores dead instances", async () => {
const h = home();
try {
await writeInstance(
{
instanceId: "dead",
url: "http://127.0.0.1:4444",
token: "tok",
projects: [PROJECT_A],
processId: 0x7fffffff,
},
h,
);
assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null);
} finally {
rmSync(h, { recursive: true, force: true });
}
});
test("findEndpoint does not use a global endpoint recording another project", async () => {
const h = home();
try {
await writeEndpoint(
{ url: "http://127.0.0.1:5555", token: "tok", projectDir: PROJECT_B, processId: process.pid },
h,
);
assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null);
// It is still usable when asked for its own project.
assert.ok(await findEndpoint({ projectDir: PROJECT_B, agentHome: h }));
} finally {
rmSync(h, { recursive: true, force: true });
}
});
test("a query with no reachable live instance returns null", async () => {
const h = home();
try {
assert.equal(
await queryLive("get_status", {}, { projectDir: PROJECT_A, agentHome: h }),
null,
);
} finally {
rmSync(h, { recursive: true, force: true });
}
});
test("the negative cache suppresses repeated attempts and can be reset", async () => {
let now = 1_000_000;
const client = new LiveClient({ projectDir: PROJECT_A, now: () => now });
assert.equal(client.suppressed, false);
client.markUnavailable();
assert.equal(client.suppressed, true);
// Still suppressed just before the cooldown expires.
now += 4999;
assert.equal(client.suppressed, true);
// Expired afterwards.
now += 2;
assert.equal(client.suppressed, false);
client.markUnavailable();
client.reset();
assert.equal(client.suppressed, false);
});
+297
View File
@@ -0,0 +1,297 @@
/**
* End-to-end live-path test.
*
* Starts a real local HTTP live server (the same module the extension runs),
* registers it exactly the way the extension does (per-project endpoint +
* instance file + merged manifest), and then drives it through the shared
* `LiveClient` that both the MCP server and the CLI use.
*
* This is the in-process equivalent of "start the extension, then query it
* from the CLI/MCP", so it covers the transport, project pinning, project
* resolution and cross-project refusal without needing to spawn anything.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { startLocalServer } from "../out/agent/localServer.js";
import {
clearInstance,
readManifest,
pruneInstances,
writeInstance,
writeManifest,
} from "../out/agent/instances.js";
import { writeEndpointForProject } from "../out/agent/endpoint.js";
import { LiveClient, findEndpoint } from "../out/agent/liveClient.js";
import { parseLoadedXml } from "../out/agent/forwardRefs.js";
const PROJECT_A = "D:/Mods/Alpha";
const PROJECT_B = "D:/Mods/Beta";
function def(type, id, file, line) {
return { type, id, file, line, origin: "project", stream: "static" };
}
const UNIT_FILE = `${PROJECT_A}/Data/AthenaCannon.xml`;
const BASE_FILE = `${PROJECT_A}/Data/BaseCannon.xml`;
const UNIT = def("GameObject", "AthenaCannon", UNIT_FILE, 2);
const BASE = def("GameObject", "BaseCannon", BASE_FILE, 2);
const WEAPON = def("WeaponTemplate", "AthenaCannonWeapon", `${PROJECT_A}/Data/Weapon.xml`, 88);
const ATHENA_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
<CreateObjectDie><CreateObject>AthenaCannon_Die</CreateObject></CreateObjectDie>
</GameObject>
</AssetDeclaration>`;
const BASE_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseCannon">
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="AthenaCannonWeapon" /></WeaponSlotHardpoint></WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const XML_FILES = {
[UNIT_FILE]: ATHENA_XML,
[BASE_FILE]: BASE_XML,
};
function makeIndex(projectDir) {
const assets = new Map([
["GameObject", new Map([["athenacannon", [UNIT]], ["basecannon", [BASE]]])],
["WeaponTemplate", new Map([["athenacannonweapon", [WEAPON]]])],
]);
return {
projectDir,
sdkDir: "",
complete: true,
phase: "art",
stale: false,
assets,
assetsById: new Map([
["athenacannon", [UNIT]],
["basecannon", [BASE]],
["athenacannonweapon", [WEAPON]],
]),
defines: new Map([
["d", [{ name: "D", value: "1", file: UNIT_FILE, line: 1, origin: "project" }]],
]),
files: new Map(),
streams: [
{
name: "static",
entry: `${projectDir}/Data/Mod.xml`,
files: new Set([UNIT_FILE.toLowerCase().replace(/\\/g, "/")]),
},
],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
references: new Map([
[
`GameObject\u0000athenacannon\u0000${UNIT_FILE}\u00002`,
[{ file: `${projectDir}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }],
],
]),
recordsHashes: new Map(),
stats: {
projectDir, sdkDir: "", phase: "art", complete: true,
indexedFiles: 2, parsedFiles: 2, shallowScannedFiles: 0, deferredArtFiles: 0,
shallowCacheHits: 0, recordsCacheHits: 0, resolveCacheHits: 0, resolveCalls: 0,
snapshotHits: 0, snapshotFallbacks: 0, candidatesMs: 0, walkMs: 0, artScanMs: 0,
assetCount: 3, referenceCount: 1, defineCount: 1, manifestFiles: 0,
manifestAssetCount: 0, streams: 1, sourceCandidates: 0, elapsedMs: 1,
},
};
}
/** Boots a live server + registration files, and returns a cleanup handle. */
async function bootLive() {
const agentHome = mkdtempSync(join(tmpdir(), "ra3-e2e-"));
const handle = await startLocalServer({
getIndex: (projectDir) =>
!projectDir || projectDir === PROJECT_A ? makeIndex(PROJECT_A) : null,
listProjects: () => [PROJECT_A, PROJECT_B],
loadFile: async (file) => {
const text = XML_FILES[file];
return text ? parseLoadedXml(text) : null;
},
});
const endpoint = {
instanceId: "e2e-1",
url: `http://127.0.0.1:${handle.port}`,
token: handle.token,
projectDir: PROJECT_A,
projects: [PROJECT_A, PROJECT_B],
processId: process.pid,
updatedAt: new Date().toISOString(),
};
await writeEndpointForProject(PROJECT_A, { ...endpoint, projectDir: PROJECT_A }, agentHome);
await writeInstance(endpoint, agentHome);
await writeManifest([endpoint], agentHome);
return {
agentHome,
endpoint,
async close() {
await handle.close();
rmSync(agentHome, { recursive: true, force: true });
},
};
}
test("LiveClient reaches a real live server and answers every snapshot tool", async () => {
const live = await bootLive();
try {
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
const status = await client.query("get_status");
assert.equal(status?.mismatched, false);
assert.equal(status.payload.state, "ready");
assert.equal(status.payload.projectDir, PROJECT_A);
const found = await client.query("find_asset", { id: "AthenaCannon", type: "GameObject" });
assert.equal(found.payload.data.length, 1);
const refs = await client.query("find_references", { id: "AthenaCannon" });
assert.equal(refs.payload.data.length, 1);
const active = await client.query("is_file_active", { path: UNIT_FILE });
assert.equal(active.payload.data.active, true);
const define = await client.query("find_define", { name: "D" });
assert.equal(define.payload.data.length, 1);
const list = await client.query("list_assets_by_type", { type: "GameObject" });
assert.equal(list.payload.data.length, 2);
} finally {
await live.close();
}
});
test("the live path answers get_asset_references with element provenance", async () => {
const live = await bootLive();
try {
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
const result = await client.query("get_asset_references", {
id: "AthenaCannon",
type: "GameObject",
targetTypes: ["WeaponTemplate"],
});
const data = result.payload.data;
assert.ok(data, "expected edge data");
// The weapon is written in BaseCannon's XML, reached through inheritFrom.
const weapon = data.edges.find((e) => e.to?.id === "AthenaCannonWeapon");
assert.ok(weapon, "expected the inherited weapon edge");
assert.equal(weapon.via.element, "Weapon");
assert.equal(weapon.via.parent, "WeaponSlotHardpoint");
assert.equal(weapon.definedIn.id, "BaseCannon");
assert.equal(weapon.source.file, BASE_FILE);
} finally {
await live.close();
}
});
test("list_projects reports the live project roots", async () => {
const live = await bootLive();
try {
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
const result = await client.query("list_projects");
assert.deepEqual(result.payload.data, [PROJECT_A, PROJECT_B]);
} finally {
await live.close();
}
});
test("a client for an unknown project cannot use another project's answer", async () => {
const live = await bootLive();
try {
// The registered instance only serves PROJECT_A/PROJECT_B, so a client
// pinned to a third project must find nothing at all rather than fall
// back to PROJECT_A's data.
const outsider = new LiveClient({
projectDir: "D:/Mods/Unrelated",
agentHome: live.agentHome,
});
assert.equal(await outsider.query("find_asset", { id: "AthenaCannon" }), null);
} finally {
await live.close();
}
});
test("discovery works through the instance file alone (no per-project endpoint)", async () => {
const live = await bootLive();
try {
// Simulate a window that registered its instance but whose per-project
// endpoint has not been written yet.
rmSync(join(live.agentHome, "endpoints"), { recursive: true, force: true });
const endpoint = await findEndpoint({
projectDir: PROJECT_A,
agentHome: live.agentHome,
});
assert.equal(endpoint?.instanceId, "e2e-1");
} finally {
await live.close();
}
});
test("the merged manifest lists projects for discovery", async () => {
const live = await bootLive();
try {
const manifest = await readManifest(live.agentHome);
assert.ok(manifest);
assert.deepEqual(new Set(manifest.projects), new Set([PROJECT_A, PROJECT_B]));
assert.equal(manifest.instances.length, 1);
} finally {
await live.close();
}
});
test("a crashed instance is pruned by a later instance and then unreachable", async () => {
const live = await bootLive();
try {
// Add a second instance that looks crashed.
await writeInstance(
{
instanceId: "dead-window",
url: "http://127.0.0.1:1",
token: "t",
projects: [PROJECT_A],
processId: 0x7fffffff,
},
live.agentHome,
);
const pruned = await pruneInstances(live.agentHome);
assert.ok(pruned.removed.includes("dead-window"));
// The surviving instance still works after the prune.
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
const status = await client.query("get_status");
assert.equal(status.payload.state, "ready");
} finally {
await live.close();
}
});
test("clearing this window's instance leaves other windows untouched", async () => {
const live = await bootLive();
try {
await writeInstance(
{
instanceId: "other-window",
url: "http://127.0.0.1:9999",
token: "t2",
projects: [PROJECT_A],
processId: process.pid,
},
live.agentHome,
);
await clearInstance("e2e-1", live.agentHome);
const { readInstances } = await import("../out/agent/instances.js");
const ids = (await readInstances(live.agentHome)).map((i) => i.instanceId);
assert.deepEqual(ids, ["other-window"]);
} finally {
await live.close();
}
});
+15 -66
View File
@@ -1,73 +1,22 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
liveUrlForTool,
normalizePath,
responseProjectMismatch,
} from "../out/agent/mcpServer.js";
const PROJECT_A = "D:/Mods/Example";
// The URL building, project pinning and cross-project refusal now live in
// `liveClient` and are covered by agentLiveClient.test.mjs. What remains
// MCP-layer-specific is that the server module is *importable*: it must not
// start its stdio loop as a side effect of being imported, otherwise tests and
// any tool that merely inspects the module would hang waiting on stdin.
test("live URLs always pin the requested project", () => {
const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, "find_asset", {
id: "AthenaCannon",
type: "GameObject",
});
assert.ok(url);
const parsed = new URL(url);
assert.equal(parsed.pathname, "/find_asset");
assert.equal(parsed.searchParams.get("project"), PROJECT_A);
assert.equal(parsed.searchParams.get("id"), "AthenaCannon");
assert.equal(parsed.searchParams.get("type"), "GameObject");
test("the MCP server module imports without starting its stdio loop", async () => {
const mod = await import("../out/agent/mcpServer.js");
assert.equal(typeof mod, "object");
// Reaching this line proves main() did not run on import.
});
test("get_asset_references serialises its list and scalar options", () => {
const url = liveUrlForTool("http://127.0.0.1:1234/", PROJECT_A, "get_asset_references", {
id: "AthenaCannon",
depth: 2,
targetTypes: ["WeaponTemplate", "GameObject"],
maxEdges: 25,
includeUnresolved: true,
});
assert.ok(url);
const parsed = new URL(url);
assert.equal(parsed.pathname, "/get_asset_references");
assert.equal(parsed.searchParams.get("depth"), "2");
assert.equal(parsed.searchParams.get("targetTypes"), "WeaponTemplate,GameObject");
assert.equal(parsed.searchParams.get("maxEdges"), "25");
assert.equal(parsed.searchParams.get("includeUnresolved"), "true");
assert.equal(parsed.searchParams.get("project"), PROJECT_A);
});
test("live URLs omit the project selector when none is configured", () => {
const url = liveUrlForTool("http://127.0.0.1:1234", null, "get_status", {});
assert.equal(url, "http://127.0.0.1:1234/status");
});
test("unknown tools have no live URL", () => {
assert.equal(liveUrlForTool("http://127.0.0.1:1", PROJECT_A, "nope", {}), null);
});
test("responseProjectMismatch flags answers from another project", () => {
// Matching project (case/separator-insensitive) is accepted.
assert.equal(
responseProjectMismatch({ index: { projectDir: "d:/mods/example" } }, PROJECT_A),
false,
);
// A different project must be rejected: this is the multi-window cross-talk guard.
assert.equal(
responseProjectMismatch({ index: { projectDir: "D:/Mods/Other" } }, PROJECT_A),
true,
);
// No project configured, or no projectDir in the payload: nothing to check.
assert.equal(responseProjectMismatch({ index: { state: "ready" } }, PROJECT_A), false);
assert.equal(
responseProjectMismatch({ index: { projectDir: "D:/Mods/Other" } }, null),
false,
);
assert.equal(responseProjectMismatch(null, PROJECT_A), false);
});
test("normalizePath is case- and separator-insensitive", () => {
assert.equal(normalizePath("D:\\Mods\\Example\\"), normalizePath("d:/mods/example"));
test("the CLI module imports without running", async () => {
const mod = await import("../out/agent/cli.js");
assert.equal(typeof mod.parseArgs, "function");
assert.equal(typeof mod.toolNameFor, "function");
assert.equal(typeof mod.liveArgsFor, "function");
assert.ok(mod.LIVE_ONLY instanceof Set);
});
+241
View File
@@ -0,0 +1,241 @@
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 });
}
},
);
+26
View File
@@ -56,6 +56,32 @@ test("SKILL.md scopes itself to SAGE/RA3 projects and warns off others", async (
}
});
test("SKILL.md explains how to reach the index without MCP", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-reach-"));
try {
const skillDir = join(dir, "ra3-mod-xml");
await writeSkillTo(skillDir, "0.1.25");
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
assert.match(content, /## Reaching the index/);
// The discovery manifest is the stable entry point.
assert.ok(content.includes("~/.ra3modxml/index.json"));
// The launcher and the bundled CLI are both mentioned.
assert.ok(content.includes("ra3-mod-xml-mcp"));
assert.ok(content.includes("cli.js"));
// The stdio escape hatch must be shown, since it needs no setup at all.
assert.ok(content.includes("tools/call"));
// It must not pretend configuring a client takes effect immediately.
assert.ok(content.includes("new session"));
// And it must refuse to invent results.
assert.match(content, /Never fabricate index results/);
// Instructions must not send it looking for project-specific docs.
assert.equal(content.includes("codebase-navigation-guide"), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("installSkillToDirectories records managed copies and uninstall removes them", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-skill-home-"));
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-target-"));