This commit is contained in:
2026-09-10 17:03:10 +02:00
parent 90dc18a167
commit 3a3d70efeb
27 changed files with 4973 additions and 3 deletions
+102
View File
@@ -0,0 +1,102 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
clearEndpoint,
clearEndpointForProject,
endpointPathForProject,
isProcessAlive,
readEndpoint,
readEndpointForProject,
sameProject,
writeEndpoint,
writeEndpointForProject,
} from "../out/agent/endpoint.js";
const PROJECT_A = "D:/Mods/ExampleA";
const PROJECT_B = "D:/Mods/ExampleB";
test("endpoint file round-trips and clears", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-test-"));
try {
await writeEndpoint(
{ url: "http://127.0.0.1:12345", token: "abc", projectDir: PROJECT_A },
home,
);
const loaded = await readEndpoint(home);
assert.equal(loaded?.url, "http://127.0.0.1:12345");
assert.equal(loaded?.token, "abc");
await clearEndpoint(home);
assert.equal(await readEndpoint(home), null);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("per-project endpoints do not shadow each other", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-multi-"));
try {
// Two "windows" enable agent access for different projects.
await writeEndpointForProject(
PROJECT_A,
{ url: "http://127.0.0.1:1111", token: "token-a", processId: process.pid },
home,
);
await writeEndpointForProject(
PROJECT_B,
{ url: "http://127.0.0.1:2222", token: "token-b", processId: process.pid },
home,
);
const a = await readEndpointForProject(PROJECT_A, home);
const b = await readEndpointForProject(PROJECT_B, home);
assert.equal(a?.url, "http://127.0.0.1:1111");
assert.ok(sameProject(a.projectDir, PROJECT_A));
assert.equal(b?.url, "http://127.0.0.1:2222");
assert.ok(sameProject(b.projectDir, PROJECT_B));
// Clearing one project must not disturb the other.
await clearEndpointForProject(PROJECT_A, home);
assert.equal(await readEndpointForProject(PROJECT_A, home), null);
assert.equal((await readEndpointForProject(PROJECT_B, home))?.token, "token-b");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("a per-project endpoint recording another project is rejected", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-mismatch-"));
try {
const file = endpointPathForProject(PROJECT_A, home);
// Simulate a stale/edited file that claims to serve a different project.
mkdirSync(dirname(file), { recursive: true });
writeFileSync(
file,
JSON.stringify({
url: "http://127.0.0.1:3333",
token: "t",
projectDir: PROJECT_B,
}),
);
assert.equal(await readEndpointForProject(PROJECT_A, home), null);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("isProcessAlive detects dead pids and trusts unknown ones", () => {
assert.equal(isProcessAlive(process.pid), true);
assert.equal(isProcessAlive(undefined), true);
assert.equal(isProcessAlive(0), true);
assert.equal(isProcessAlive(NaN), true);
// Not a valid Windows PID, so it cannot correspond to a running process.
assert.equal(isProcessAlive(0x7fffffff), false);
});
test("sameProject is case-insensitive", () => {
assert.equal(sameProject("D:/Mods/Example", "d:/mods/example"), true);
assert.equal(sameProject("D:/Mods/Example", "D:/Mods/Other"), false);
});
+308
View File
@@ -0,0 +1,308 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
collectAssetReferences,
parseLoadedXml,
} from "../out/agent/forwardRefs.js";
// ── Fixture files ────────────────────────────────────────────────────
// AthenaCannon has no WeaponSetUpdate of its own: it inherits BaseCannon,
// which owns the weapon slot, and has its own die-object content reference.
const ATHENA = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
<CreateObjectDie>
<CreateObject>AthenaCannon_Die</CreateObject>
</CreateObjectDie>
</GameObject>
</AssetDeclaration>`;
const BASE = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseCannon">
<WeaponSetUpdate>
<WeaponSlotHardpoint>
<Weapon Template="AthenaCannonWeapon" />
</WeaponSlotHardpoint>
</WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const DIE = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon_Die">
<WeaponSetUpdate>
<WeaponSlotHardpoint>
<Weapon Template="DieExplosionWeapon" />
</WeaponSlotHardpoint>
</WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const FILES = {
"D:/Mods/Example/Data/AthenaCannon.xml": ATHENA,
"D:/Mods/Example/Data/BaseCannon.xml": BASE,
"D:/Mods/Example/Data/AthenaCannon_Die.xml": DIE,
};
function def(type, id, file, line) {
return { type, id, file, line, origin: "project", stream: "static" };
}
const ATHENA_DEF = def("GameObject", "AthenaCannon", "D:/Mods/Example/Data/AthenaCannon.xml", 3);
const BASE_DEF = def("GameObject", "BaseCannon", "D:/Mods/Example/Data/BaseCannon.xml", 3);
const DIE_DEF = def("GameObject", "AthenaCannon_Die", "D:/Mods/Example/Data/AthenaCannon_Die.xml", 3);
const WEAPON_DEF = def(
"WeaponTemplate",
"AthenaCannonWeapon",
"D:/Mods/Example/Data/Weapon.xml",
88,
);
const DIE_WEAPON_DEF = def(
"WeaponTemplate",
"DieExplosionWeapon",
"D:/Mods/Example/Data/Weapon.xml",
120,
);
function makeIndex() {
const all = [ATHENA_DEF, BASE_DEF, DIE_DEF, WEAPON_DEF, DIE_WEAPON_DEF];
const assetsById = new Map();
for (const d of all) {
const key = d.id.toLowerCase();
if (!assetsById.has(key)) assetsById.set(key, []);
assetsById.get(key).push(d);
}
const assets = new Map([
["GameObject", new Map([["athenacannon", [ATHENA_DEF]], ["basecannon", [BASE_DEF]], ["athenacannon_die", [DIE_DEF]]])],
["WeaponTemplate", new Map([["athenacannonweapon", [WEAPON_DEF]], ["dieexplosionweapon", [DIE_WEAPON_DEF]]])],
]);
return {
projectDir: "D:/Mods/Example",
sdkDir: "",
complete: true,
phase: "art",
assets,
assetsById,
defines: new Map(),
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
references: new Map(),
recordsHashes: new Map(),
stats: {},
};
}
function loader(map = FILES) {
return async (file) => {
const text = map[file];
return text ? parseLoadedXml(text) : null;
};
}
function findEdge(edges, predicate) {
return edges.find(predicate);
}
test("depth 1 returns only the queried asset's own edges", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{},
);
assert.equal(result.roots.length, 1);
// Own content ref + inherited weapon ref + the inheritFrom edge itself.
assert.ok(result.edges.length >= 3);
assert.deepEqual(
[...new Set(result.edges.map((e) => e.depth))],
[1],
"all edges must be at depth 1",
);
assert.equal(result.truncated, false);
});
test("attribute references carry element, parent and attribute provenance", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
);
const weapon = findEdge(
result.edges,
(e) => e.via.kind === "attribute" && e.to?.id === "AthenaCannonWeapon",
);
assert.ok(weapon, "weapon edge should exist");
assert.equal(weapon.via.element, "Weapon");
assert.equal(weapon.via.parent, "WeaponSlotHardpoint");
assert.equal(weapon.via.attribute, "Template");
assert.equal(weapon.to.type, "WeaponTemplate");
assert.equal(weapon.to.line, 88);
assert.ok(weapon.source.file.endsWith("BaseCannon.xml"));
assert.ok(weapon.source.line > 0);
});
test("content references (CreateObjectDie) are reported with kind=content", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
);
const die = findEdge(result.edges, (e) => e.via.kind === "content");
assert.ok(die, "die-object content edge should exist");
assert.equal(die.via.element, "CreateObject");
assert.equal(die.via.parent, "CreateObjectDie");
assert.equal(die.via.attribute, null);
assert.equal(die.to.type, "GameObject");
assert.equal(die.to.id, "AthenaCannon_Die");
assert.ok(die.source.file.endsWith("AthenaCannon.xml"));
});
test("inheritFrom is walked and marked with definedIn", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
);
const inherit = findEdge(result.edges, (e) => e.via.kind === "inheritFrom");
assert.ok(inherit, "inheritFrom edge should exist");
assert.equal(inherit.to.id, "BaseCannon");
assert.equal(inherit.value, "BaseCannon");
assert.equal(inherit.definedIn, undefined, "the inheritFrom edge itself is on AthenaCannon");
const inheritedWeapon = findEdge(
result.edges,
(e) => e.to?.id === "AthenaCannonWeapon",
);
assert.ok(inheritedWeapon, "weapon from the ancestor must still be reported");
assert.deepEqual(inheritedWeapon.definedIn, { type: "GameObject", id: "BaseCannon" });
assert.equal(inheritedWeapon.from.id, "AthenaCannon", "edge is attributed to the queried asset");
});
test("targetTypes filters edges, but inheritFrom edges always survive", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ targetTypes: ["WeaponTemplate"] },
);
assert.ok(result.edges.length > 0);
assert.ok(
result.edges.some((e) => e.to.type === "WeaponTemplate"),
"expected at least one WeaponTemplate edge",
);
for (const edge of result.edges) {
// inheritFrom is kept so the caller can see where the weapon is written.
if (edge.via.kind === "inheritFrom") continue;
assert.equal(edge.to.type, "WeaponTemplate", `${edge.via.element} should be filtered out`);
}
// Nodes mirror the kept edges, so the inheritFrom target may appear too.
const inheritIds = new Set(
result.edges
.filter((e) => e.via.kind === "inheritFrom")
.map((e) => e.to.id.toLowerCase()),
);
for (const node of result.nodes) {
assert.ok(
node.type === "WeaponTemplate" || inheritIds.has(node.id.toLowerCase()),
`unexpected node ${node.type}:${node.id}`,
);
}
});
test("depth 2 expands into referenced assets", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ depth: 2 },
);
const depths = new Set(result.edges.map((e) => e.depth));
assert.ok(depths.has(2), "expected depth-2 edges from the die-object GameObject");
const dieWeapon = findEdge(result.edges, (e) => e.to?.id === "DieExplosionWeapon");
assert.ok(dieWeapon, "the die object's weapon should appear at depth 2");
assert.equal(dieWeapon.depth, 2);
assert.equal(dieWeapon.from.id, "AthenaCannon_Die");
});
test("depth is clamped to the max of 3", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ depth: 99 },
);
for (const edge of result.edges) {
assert.ok(edge.depth <= 3, `depth ${edge.depth} exceeded the clamp`);
}
});
test("maxEdges truncates and reports what was dropped", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ maxEdges: 1 },
);
assert.equal(result.edges.length, 1);
assert.equal(result.truncated, true);
const omitted = Object.values(result.omittedByTargetType).reduce((a, b) => a + b, 0);
assert.ok(omitted >= 1, "expected the dropped edges to be summarised");
});
test("unresolved references are opt-in", async () => {
const text = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="Ghost">
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="DoesNotExist" /></WeaponSlotHardpoint></WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const ghost = def("GameObject", "Ghost", "D:/Mods/Example/Data/Ghost.xml", 2);
const index = makeIndex();
index.assetsById.set("ghost", [ghost]);
const ghostLoader = loader({ "D:/Mods/Example/Data/Ghost.xml": text });
const without = await collectAssetReferences(index, "Ghost", "GameObject", ghostLoader);
assert.equal(without.edges.length, 0);
const withUnresolved = await collectAssetReferences(index, "Ghost", "GameObject", ghostLoader, {
includeUnresolved: true,
});
assert.equal(withUnresolved.edges.length, 1);
assert.equal(withUnresolved.edges[0].to, null);
assert.equal(withUnresolved.edges[0].value, "DoesNotExist");
});
test("unknown ids produce a warning instead of throwing", async () => {
const result = await collectAssetReferences(
makeIndex(),
"NoSuchAsset",
"GameObject",
loader(),
);
assert.equal(result.edges.length, 0);
assert.equal(result.roots.length, 0);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /No definition found/);
});
test("unreadable files produce a warning and no edges", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
async () => null,
);
assert.equal(result.edges.length, 0);
assert.ok(result.warnings.some((w) => w.includes("Could not read")));
});
+283
View File
@@ -0,0 +1,283 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { startLocalServer } from "../out/agent/localServer.js";
import { parseLoadedXml } from "../out/agent/forwardRefs.js";
const PROJECT_A = "D:/Mods/Example";
const PROJECT_B = "D:/Mods/Other";
function makeStats() {
return {
projectDir: "P",
sdkDir: "",
phase: "art",
complete: true,
indexedFiles: 1,
parsedFiles: 1,
shallowScannedFiles: 0,
deferredArtFiles: 0,
shallowCacheHits: 0,
recordsCacheHits: 0,
resolveCacheHits: 0,
resolveCalls: 0,
snapshotHits: 0,
snapshotFallbacks: 0,
candidatesMs: 0,
walkMs: 0,
artScanMs: 0,
assetCount: 1,
referenceCount: 1,
defineCount: 1,
manifestFiles: 0,
manifestAssetCount: 0,
streams: 1,
sourceCandidates: 1,
elapsedMs: 1,
};
}
const CANNON_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 FILES = {
"D:/Mods/Example/Data/AthenaCannon.xml": CANNON_XML,
"D:/Mods/Example/Data/BaseCannon.xml": BASE_XML,
};
function makeIndex(projectDir, unitId, extraDefs = []) {
const file = `${projectDir}/Data/${unitId}.xml`;
const unit = { type: "GameObject", id: unitId, file, line: 2, origin: "project", stream: "static" };
const all = [unit, ...extraDefs];
const assets = new Map();
const assetsById = new Map();
for (const d of all) {
if (!assets.has(d.type)) assets.set(d.type, new Map());
const byId = assets.get(d.type);
if (!byId.has(d.id.toLowerCase())) byId.set(d.id.toLowerCase(), []);
byId.get(d.id.toLowerCase()).push(d);
const key = d.id.toLowerCase();
if (!assetsById.has(key)) assetsById.set(key, []);
assetsById.get(key).push(d);
}
return {
projectDir,
sdkDir: "",
complete: true,
phase: "art",
stale: false,
assets,
assetsById,
defines: new Map([
["exampledefine", [{ name: "ExampleDefine", value: "1", file, line: 2, origin: "project" }]],
]),
files: new Map(),
streams: [
{
name: "static",
entry: `${projectDir}/Data/Mod.xml`,
files: new Set([file.toLowerCase().replace(/\\/g, "/")]),
},
],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
references: new Map([
[
`GameObject\u0000${unitId.toLowerCase()}\u0000${file}\u00002`,
[{ file: `${projectDir}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }],
],
]),
recordsHashes: new Map(),
stats: makeStats(),
};
}
const PROJECT_A_RELATED = [
{
type: "GameObject",
id: "BaseCannon",
file: "D:/Mods/Example/Data/BaseCannon.xml",
line: 2,
origin: "project",
stream: "static",
},
{
type: "GameObject",
id: "AthenaCannon_Die",
file: "D:/Mods/Example/Data/AthenaCannon_Die.xml",
line: 2,
origin: "project",
stream: "static",
},
{
type: "WeaponTemplate",
id: "AthenaCannonWeapon",
file: "D:/Mods/Example/Data/Weapon.xml",
line: 88,
origin: "project",
stream: "static",
},
];
/** Routes to a distinct index per requested project, like the extension does. */
function routedServerOptions() {
const indexes = new Map([
[PROJECT_A, makeIndex(PROJECT_A, "AthenaCannon", PROJECT_A_RELATED)],
[PROJECT_B, makeIndex(PROJECT_B, "OtherUnit")],
]);
return {
getIndex: (projectDir) => (projectDir ? indexes.get(projectDir) ?? null : indexes.get(PROJECT_A)),
listProjects: () => [...indexes.keys()],
loadFile: async (file) => {
const text = FILES[file];
return text ? parseLoadedXml(text) : null;
},
};
}
async function withServer(fn) {
const handle = await startLocalServer({ ...routedServerOptions(), token: "test-token" });
const base = `http://127.0.0.1:${handle.port}`;
const headers = { authorization: "Bearer test-token" };
const get = async (path) => (await fetch(`${base}${path}`, { headers })).json();
try {
await fn({ base, headers, get });
} finally {
await handle.close();
}
}
test("local server requires the bearer token", async () => {
await withServer(async ({ base }) => {
const unauthorized = await fetch(`${base}/status`);
assert.equal(unauthorized.status, 401);
const forbidden = await fetch(`${base}/status`, {
headers: { authorization: "Bearer wrong" },
});
assert.equal(forbidden.status, 401);
});
});
test("local server exposes read-only queries", async () => {
await withServer(async ({ get }) => {
const status = await get(`/status?project=${encodeURIComponent(PROJECT_A)}`);
assert.equal(status.state, "ready");
assert.equal(status.projectDir, PROJECT_A);
const asset = await get(
`/find_asset?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&type=GameObject`,
);
assert.equal(asset.data.length, 1);
const refs = await get(
`/find_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon`,
);
assert.equal(refs.data.length, 1);
const active = await get(
`/is_file_active?project=${encodeURIComponent(PROJECT_A)}&path=D:/Mods/Example/Data/AthenaCannon.xml`,
);
assert.equal(active.data.active, true);
});
});
test("?project= selects the index instead of the active editor's project", async () => {
await withServer(async ({ get }) => {
const a = await get(`/find_asset?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon`);
assert.equal(a.index.projectDir, PROJECT_A);
assert.equal(a.data.length, 1);
const b = await get(`/find_asset?project=${encodeURIComponent(PROJECT_B)}&id=OtherUnit`);
assert.equal(b.index.projectDir, PROJECT_B);
assert.equal(b.data.length, 1);
// The same id must not resolve when asked about the other project.
const cross = await get(
`/find_asset?project=${encodeURIComponent(PROJECT_B)}&id=AthenaCannon`,
);
assert.equal(cross.data.length, 0);
assert.equal(cross.index.projectDir, PROJECT_B);
});
});
test("an unknown project reports no_index without faking a projectDir", async () => {
await withServer(async ({ get }) => {
const unknown = "D:/Mods/Unknown";
const result = await get(`/status?project=${encodeURIComponent(unknown)}`);
assert.equal(result.state, "no_index");
assert.equal(result.projectDir, unknown);
});
});
test("/projects lists the known roots", async () => {
await withServer(async ({ get }) => {
const result = await get("/projects");
assert.deepEqual(new Set(result.data), new Set([PROJECT_A, PROJECT_B]));
});
});
test("/get_asset_references returns provenance-carrying edges", async () => {
await withServer(async ({ get }) => {
const result = await get(
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&type=GameObject`,
);
assert.equal(result.index.state, "ready");
const data = result.data;
assert.ok(data, "expected a data payload");
assert.equal(data.edges.length >= 3, true);
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, "D:/Mods/Example/Data/BaseCannon.xml");
const die = data.edges.find((e) => e.via.kind === "content");
assert.ok(die, "expected the CreateObjectDie content edge");
assert.equal(die.to.id, "AthenaCannon_Die");
});
});
test("/get_asset_references honours targetTypes and depth parameters", async () => {
await withServer(async ({ get }) => {
const filtered = await get(
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&targetTypes=WeaponTemplate`,
);
for (const edge of filtered.data.edges) {
if (edge.via.kind === "inheritFrom") continue;
assert.equal(edge.to.type, "WeaponTemplate");
}
const shallow = await get(
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&depth=1`,
);
for (const edge of shallow.data.edges) {
assert.equal(edge.depth, 1);
}
const deep = await get(
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&depth=2`,
);
for (const edge of deep.data.edges) {
assert.ok(edge.depth <= 2);
}
});
});
test("/get_asset_references explains itself when live data is unavailable", async () => {
await withServer(async ({ get }) => {
const missing = await get(`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=Nope`);
assert.equal(missing.data.roots.length, 0);
assert.ok(missing.data.warnings.length > 0);
});
});
+73
View File
@@ -0,0 +1,73 @@
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";
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("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"));
});
+36
View File
@@ -0,0 +1,36 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
addMcpServerToConfigFile,
mcpConfigJson,
mcpServerConfig,
} from "../out/agent/setup.js";
test("mcpServerConfig and mcpConfigJson use the stable launcher", () => {
const config = mcpServerConfig("C:/Users/me/.ra3modxml/ra3-mod-xml-mcp.cmd", "D:/Mods/Example");
assert.deepEqual(config.mcpServers["ra3-mod-xml"].args, ["--project", "D:/Mods/Example"]);
const json = mcpConfigJson("C:/launcher.cmd", "D:/Proj");
assert.ok(json.includes("C:/launcher.cmd"));
assert.ok(json.includes("D:/Proj"));
});
test("addMcpServerToConfigFile creates and merges config", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-test-"));
try {
const file = join(dir, "mcp.json");
await addMcpServerToConfigFile(file, "C:/launcher.cmd", "D:/Proj");
const first = JSON.parse(readFileSync(file, "utf8"));
assert.ok(first.mcpServers["ra3-mod-xml"]);
writeFileSync(file, JSON.stringify({ mcpServers: { other: { command: "x" } } }, null, 2));
await addMcpServerToConfigFile(file, "C:/launcher.cmd", "D:/Proj");
const merged = JSON.parse(readFileSync(file, "utf8"));
assert.ok(merged.mcpServers.other);
assert.ok(merged.mcpServers["ra3-mod-xml"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+77
View File
@@ -0,0 +1,77 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
SKILL_MARKER_FILE,
installSkillToDirectories,
readSkillInstallRecord,
uninstallSkillFromDirectory,
writeSkillTo,
} from "../out/agent/skill.js";
test("writeSkillTo creates SKILL.md and avoids project-doc noise", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-test-"));
try {
const skillDir = join(dir, "ra3-mod-xml");
await writeSkillTo(skillDir, "0.1.25");
assert.ok(existsSync(join(skillDir, "SKILL.md")));
assert.ok(existsSync(join(skillDir, "references", "query-guide.md")));
assert.ok(existsSync(join(skillDir, SKILL_MARKER_FILE)));
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
assert.ok(content.includes("find_asset"));
assert.ok(!content.includes("codebase-navigation-guide"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("SKILL.md scopes itself to SAGE/RA3 projects and warns off others", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-scope-"));
try {
const skillDir = join(dir, "ra3-mod-xml");
await writeSkillTo(skillDir, "0.1.25");
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
// Must state its applicability and list concrete positive signals.
assert.match(content, /When this skill applies/);
assert.ok(content.includes("Data/Mod.xml"));
assert.ok(content.includes("babproj"));
assert.ok(content.includes("AssetDeclaration"));
// Must give an explicit negative rule and a cheap probe.
assert.match(content, /Do \*\*not\*\* use these tools for unrelated repositories/);
assert.ok(content.includes("get_status"));
assert.ok(content.includes("projectDir"));
// The CnC3 red herring is called out explicitly.
assert.ok(content.includes("CnC3Types.xsd"));
assert.ok(content.includes("C&C3"));
// Second-phase capability must be documented.
assert.ok(content.includes("get_asset_references"));
assert.ok(content.includes("definedIn"));
} 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-"));
try {
const target = join(dir, "ra3-mod-xml");
const succeeded = await installSkillToDirectories([target], "0.1.25", home);
assert.deepEqual(succeeded, [target]);
const record = await readSkillInstallRecord(home);
assert.equal(record.length, 1);
assert.equal(record[0].path, target);
await uninstallSkillFromDirectory(target, home);
assert.equal(existsSync(target), false);
assert.equal((await readSkillInstallRecord(home)).length, 0);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(dir, { recursive: true, force: true });
}
});
+184
View File
@@ -0,0 +1,184 @@
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 { snapshotFromIndex, writeSnapshotFile, readSnapshotFile } from "../out/agent/snapshot.js";
import {
findAssets,
findReferenceGroups,
isFileActive,
listAssetsByType,
resolveIncludeSource,
statusFromSnapshot,
} from "../out/agent/query.js";
function makeStats() {
return {
projectDir: "P",
sdkDir: "",
phase: "art",
complete: true,
indexedFiles: 3,
parsedFiles: 2,
shallowScannedFiles: 1,
deferredArtFiles: 0,
shallowCacheHits: 0,
recordsCacheHits: 0,
resolveCacheHits: 0,
resolveCalls: 0,
snapshotHits: 0,
snapshotFallbacks: 0,
candidatesMs: 0,
walkMs: 0,
artScanMs: 0,
assetCount: 2,
referenceCount: 1,
defineCount: 1,
manifestFiles: 0,
manifestAssetCount: 0,
streams: 1,
sourceCandidates: 1,
elapsedMs: 1,
};
}
function makeIndex() {
const file = "D:/Mods/Example/Data/Units/Example.xml";
const assets = new Map([
[
"GameObject",
new Map([
[
"exampleunit",
[
{
type: "GameObject",
id: "ExampleUnit",
file,
line: 5,
origin: "project",
stream: "static",
},
],
],
]),
],
]);
const references = new Map([
[
"GameObject\u0000exampleunit\u0000D:/Mods/Example/Data/Units/Example.xml\u00005",
[
{
file: "D:/Mods/Example/Data/Other.xml",
line: 3,
start: 10,
end: 21,
kind: "attr",
},
],
],
]);
return {
projectDir: "D:/Mods/Example",
sdkDir: "",
complete: true,
phase: "art",
stale: false,
assets,
assetsById: new Map([
[
"exampleunit",
[
{
type: "GameObject",
id: "ExampleUnit",
file,
line: 5,
origin: "project",
stream: "static",
},
],
],
]),
defines: new Map([
[
"exampledefine",
[
{
name: "ExampleDefine",
value: "1",
file,
line: 2,
origin: "project",
},
],
],
]),
files: new Map(),
streams: [
{
name: "static",
entry: "D:/Mods/Example/Data/Mod.xml",
files: new Set(["d:/mods/example/data/units/example.xml"]),
},
],
manifests: new Map(),
sourceCandidates: [
{
source: "DATA:Units/Example.xml",
path: "D:/Mods/Example/Data/Units/Example.xml",
prefix: "DATA",
baseDir: "D:/Mods/Example/Data",
},
],
diagnostics: [],
references,
recordsHashes: new Map(),
stats: makeStats(),
};
}
test("snapshotFromIndex flattens assets, defines, streams and references", () => {
const snapshot = snapshotFromIndex(makeIndex(), 42);
assert.equal(snapshot.schemaVersion, 1);
assert.equal(snapshot.assets.length, 1);
assert.equal(snapshot.assets[0].id, "ExampleUnit");
assert.equal(snapshot.defines.length, 1);
assert.equal(snapshot.streams.length, 1);
assert.deepEqual(snapshot.streams[0].files, [
"d:/mods/example/data/units/example.xml",
]);
assert.equal(snapshot.references.length, 1);
assert.equal(snapshot.references[0].sites.length, 1);
assert.equal(snapshot.buildId, 42);
});
test("query helpers operate on snapshots", () => {
const snapshot = snapshotFromIndex(makeIndex(), 1);
assert.equal(findAssets(snapshot, "ExampleUnit").length, 1);
assert.equal(findAssets(snapshot, "exampleunit", "GameObject").length, 1);
assert.equal(findAssets(snapshot, "exampleunit", "WeaponTemplate").length, 0);
assert.equal(listAssetsByType(snapshot, "gameobject", "exa").length, 1);
assert.equal(findReferenceGroups(snapshot, "ExampleUnit").length, 1);
assert.equal(isFileActive(snapshot, "D:/Mods/Example/Data/Units/Example.xml"), true);
assert.equal(isFileActive(snapshot, "D:/Mods/Example/Data/Dead.xml"), false);
assert.equal(resolveIncludeSource(snapshot, "data:units/example.xml")?.path, "D:/Mods/Example/Data/Units/Example.xml");
assert.equal(statusFromSnapshot(snapshot).state, "ready");
});
test("snapshot file write/read round-trips", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-agent-test-"));
try {
const file = join(dir, "snapshot.json.gz");
const snapshot = snapshotFromIndex(makeIndex(), 7);
await writeSnapshotFile(file, snapshot);
const loaded = await readSnapshotFile(file);
assert.ok(loaded);
assert.equal(loaded.assets.length, snapshot.assets.length);
assert.equal(loaded.references[0].sites[0].file, "D:/Mods/Example/Data/Other.xml");
assert.equal(loaded.buildId, 7);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});