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