284 lines
9.0 KiB
JavaScript
284 lines
9.0 KiB
JavaScript
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);
|
|
});
|
|
});
|