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