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 { LiveClient, findEndpoint, liveUrlForTool, normalizePath, queryLive, responseProjectMismatch, } from "../out/agent/liveClient.js"; import { writeInstance } from "../out/agent/instances.js"; import { writeEndpoint, writeEndpointForProject } from "../out/agent/endpoint.js"; const PROJECT_A = "D:/Mods/Alpha"; const PROJECT_B = "D:/Mods/Beta"; function home() { return mkdtempSync(join(tmpdir(), "ra3-liveclient-")); } test("live URLs pin the requested project for every tool", () => { const cases = [ ["get_status", {}], ["find_asset", { id: "X" }], ["find_references", { id: "X" }], ["list_assets_by_type", { type: "GameObject" }], ["is_file_active", { path: "D:/f.xml" }], ["find_define", { name: "D" }], ["resolve_include", { source: "DATA:a.xml" }], ["list_projects", {}], ]; for (const [tool, args] of cases) { const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, tool, args); assert.ok(url, `${tool} should have a live URL`); assert.equal( new URL(url).searchParams.get("project"), PROJECT_A, `${tool} must pin the project`, ); } }); test("get_asset_references serialises all of its options", () => { const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, "get_asset_references", { id: "AthenaCannon", type: "GameObject", depth: 2, targetTypes: ["WeaponTemplate", "GameObject"], maxEdges: 25, includeUnresolved: true, }); const q = new URL(url).searchParams; assert.equal(q.get("id"), "AthenaCannon"); assert.equal(q.get("type"), "GameObject"); assert.equal(q.get("depth"), "2"); assert.equal(q.get("targetTypes"), "WeaponTemplate,GameObject"); assert.equal(q.get("maxEdges"), "25"); assert.equal(q.get("includeUnresolved"), "true"); assert.equal(q.get("project"), PROJECT_A); }); test("unknown tools have no live URL", () => { assert.equal(liveUrlForTool("http://127.0.0.1:1", PROJECT_A, "not_a_tool", {}), null); }); test("responseProjectMismatch refuses another project's answer", () => { assert.equal( responseProjectMismatch({ index: { projectDir: "d:/mods/alpha" } }, PROJECT_A), false, ); assert.equal( responseProjectMismatch({ index: { projectDir: PROJECT_B } }, PROJECT_A), true, ); // Nothing to compare against: not a mismatch. assert.equal(responseProjectMismatch({ index: { state: "ready" } }, PROJECT_A), false); assert.equal(responseProjectMismatch({ index: { projectDir: PROJECT_B } }, null), false); }); test("normalizePath ignores case and trailing separators", () => { assert.equal(normalizePath("D:\\Mods\\Alpha\\"), normalizePath("d:/mods/alpha")); }); test("findEndpoint prefers the per-project endpoint", async () => { const h = home(); try { await writeEndpointForProject( PROJECT_A, { url: "http://127.0.0.1:1111", token: "tok-a", processId: process.pid }, h, ); const endpoint = await findEndpoint({ projectDir: PROJECT_A, agentHome: h }); assert.equal(endpoint?.url, "http://127.0.0.1:1111"); } finally { rmSync(h, { recursive: true, force: true }); } }); test("findEndpoint finds a live instance when no per-project file exists", async () => { const h = home(); try { // A window that has not yet written per-project endpoints, only its own // instance file. Discovery must still find it. await writeInstance( { instanceId: "w1", url: "http://127.0.0.1:2222", token: "tok-inst", projects: [PROJECT_A, PROJECT_B], processId: process.pid, }, h, ); const endpoint = await findEndpoint({ projectDir: PROJECT_B, agentHome: h }); assert.equal(endpoint?.url, "http://127.0.0.1:2222"); } finally { rmSync(h, { recursive: true, force: true }); } }); test("findEndpoint ignores instances that do not serve the project", async () => { const h = home(); try { await writeInstance( { instanceId: "other", url: "http://127.0.0.1:3333", token: "tok", projects: ["D:/Mods/Unrelated"], processId: process.pid, }, h, ); assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null); } finally { rmSync(h, { recursive: true, force: true }); } }); test("findEndpoint ignores dead instances", async () => { const h = home(); try { await writeInstance( { instanceId: "dead", url: "http://127.0.0.1:4444", token: "tok", projects: [PROJECT_A], processId: 0x7fffffff, }, h, ); assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null); } finally { rmSync(h, { recursive: true, force: true }); } }); test("findEndpoint does not use a global endpoint recording another project", async () => { const h = home(); try { await writeEndpoint( { url: "http://127.0.0.1:5555", token: "tok", projectDir: PROJECT_B, processId: process.pid }, h, ); assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null); // It is still usable when asked for its own project. assert.ok(await findEndpoint({ projectDir: PROJECT_B, agentHome: h })); } finally { rmSync(h, { recursive: true, force: true }); } }); test("a query with no reachable live instance returns null", async () => { const h = home(); try { assert.equal( await queryLive("get_status", {}, { projectDir: PROJECT_A, agentHome: h }), null, ); } finally { rmSync(h, { recursive: true, force: true }); } }); test("the negative cache suppresses repeated attempts and can be reset", async () => { let now = 1_000_000; const client = new LiveClient({ projectDir: PROJECT_A, now: () => now }); assert.equal(client.suppressed, false); client.markUnavailable(); assert.equal(client.suppressed, true); // Still suppressed just before the cooldown expires. now += 4999; assert.equal(client.suppressed, true); // Expired afterwards. now += 2; assert.equal(client.suppressed, false); client.markUnavailable(); client.reset(); assert.equal(client.suppressed, false); });