/** * 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 = ` AthenaCannon_Die `; const BASE_XML = ` `; 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(); } });