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