ai agent
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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-"));
|
||||
|
||||
Reference in New Issue
Block a user