220 lines
7.4 KiB
JavaScript
220 lines
7.4 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
INSTANCE_SCHEMA_VERSION,
|
|
clearInstance,
|
|
instanceFileName,
|
|
instancesDir,
|
|
makeInstanceId,
|
|
manifestPath,
|
|
projectsOf,
|
|
pruneInstances,
|
|
readInstances,
|
|
readManifest,
|
|
refreshDiscovery,
|
|
writeInstance,
|
|
writeManifest,
|
|
} from "../out/agent/instances.js";
|
|
import { endpointPath, readEndpoint } from "../out/agent/endpoint.js";
|
|
|
|
function instanceHome() {
|
|
return mkdtempSync(join(tmpdir(), "ra3-instances-"));
|
|
}
|
|
|
|
function makeInstance(id, pid = process.pid) {
|
|
return {
|
|
instanceId: id,
|
|
url: `http://127.0.0.1:${10000 + (pid % 1000)}`,
|
|
token: `tok-${id}`,
|
|
projectDir: "D:/Mods/Alpha",
|
|
projects: ["D:/Mods/Alpha"],
|
|
processId: pid,
|
|
};
|
|
}
|
|
|
|
test("instances are written and read back", async () => {
|
|
const home = instanceHome();
|
|
try {
|
|
const file = await writeInstance(makeInstance("a-1"), home);
|
|
assert.ok(existsSync(file));
|
|
assert.ok(file.includes(instancesDir(home)));
|
|
assert.equal(file.endsWith(instanceFileName("a-1")), true);
|
|
|
|
const all = await readInstances(home);
|
|
assert.equal(all.length, 1);
|
|
assert.equal(all[0].instanceId, "a-1");
|
|
assert.equal(all[0].token, "tok-a-1");
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("concurrent windows do not overwrite each other", async () => {
|
|
const home = instanceHome();
|
|
try {
|
|
// Two windows enabling agent access at the same time: separate files, so
|
|
// there is no read-modify-write race to guard.
|
|
await writeInstance(makeInstance("win-a"), home);
|
|
await writeInstance(makeInstance("win-b"), home);
|
|
const ids = (await readInstances(home)).map((i) => i.instanceId).sort();
|
|
assert.deepEqual(ids, ["win-a", "win-b"]);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("pruneInstances removes entries whose PID is dead", async () => {
|
|
const home = instanceHome();
|
|
try {
|
|
// 0x7fffffff is not a valid Windows PID, so it cannot be alive.
|
|
await writeInstance(makeInstance("alive", process.pid), home);
|
|
await writeInstance(makeInstance("dead", 0x7fffffff), home);
|
|
|
|
const result = await pruneInstances(home);
|
|
assert.deepEqual(result.removed, ["dead"]);
|
|
assert.deepEqual(
|
|
result.kept.map((i) => i.instanceId),
|
|
["alive"],
|
|
);
|
|
// The dead file must actually be gone from disk.
|
|
assert.equal(existsSync(join(instancesDir(home), instanceFileName("dead"))), false);
|
|
assert.equal((await readInstances(home)).length, 1);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("pruneInstances keeps entries with an unknown PID", async () => {
|
|
const home = instanceHome();
|
|
try {
|
|
// An older/simpler instance file without processId must never be pruned.
|
|
await writeInstance(
|
|
{ instanceId: "no-pid", url: "http://127.0.0.1:1", token: "t" },
|
|
home,
|
|
);
|
|
const result = await pruneInstances(home);
|
|
assert.deepEqual(result.removed, []);
|
|
assert.equal(result.kept.length, 1);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("clearInstance only removes its own file", async () => {
|
|
const home = instanceHome();
|
|
try {
|
|
await writeInstance(makeInstance("mine"), home);
|
|
await writeInstance(makeInstance("theirs"), home);
|
|
await clearInstance("mine", home);
|
|
const ids = (await readInstances(home)).map((i) => i.instanceId);
|
|
assert.deepEqual(ids, ["theirs"]);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("readInstances tolerates corrupt and unrelated files", async () => {
|
|
const home = instanceHome();
|
|
try {
|
|
await writeInstance(makeInstance("good"), home);
|
|
writeFileSync(join(instancesDir(home), "broken.json"), "{ not json");
|
|
writeFileSync(join(instancesDir(home), "notes.txt"), "ignore me");
|
|
const all = await readInstances(home);
|
|
assert.deepEqual(all.map((i) => i.instanceId), ["good"]);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("projectsOf unions projects across instances without duplicates", () => {
|
|
const projects = projectsOf([
|
|
{ instanceId: "a", url: "u", token: "t", projectDir: "D:/Mods/Alpha", projects: ["D:/Mods/Alpha"] },
|
|
{ instanceId: "b", url: "u", token: "t", projectDir: "D:/Mods/Beta", projects: ["D:/Mods/beta", "D:/Mods/Gamma"] },
|
|
]);
|
|
// "beta" appears twice with different casing and must collapse to one entry,
|
|
// keeping the first spelling seen.
|
|
assert.deepEqual(projects, ["D:/Mods/Alpha", "D:/Mods/beta", "D:/Mods/Gamma"]);
|
|
});
|
|
|
|
test("writeManifest produces a discovery manifest without tokens", async () => {
|
|
const home = instanceHome();
|
|
try {
|
|
const manifest = await writeManifest(
|
|
[makeInstance("a-1"), makeInstance("b-2")],
|
|
home,
|
|
);
|
|
assert.equal(manifest.schemaVersion, INSTANCE_SCHEMA_VERSION);
|
|
assert.deepEqual(manifest.projects, ["D:/Mods/Alpha"]);
|
|
assert.equal(manifest.instances.length, 2);
|
|
|
|
const raw = readFileSync(manifestPath(home), "utf8");
|
|
// The manifest is for discovery; secrets must not leak into it.
|
|
assert.equal(raw.includes("tok-a-1"), false);
|
|
assert.equal(raw.includes('"token"'), false);
|
|
|
|
const reread = await readManifest(home);
|
|
assert.equal(reread?.instances.length, 2);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("makeInstanceId is unique across rapid calls", () => {
|
|
const ids = new Set();
|
|
for (let i = 0; i < 200; i++) ids.add(makeInstanceId(1234));
|
|
assert.equal(ids.size, 200);
|
|
for (const id of ids) assert.ok(id.startsWith("1234-"), id);
|
|
});
|
|
|
|
test("refreshDiscovery keeps surviving windows discoverable", async () => {
|
|
const home = instanceHome();
|
|
try {
|
|
const mine = makeInstance("mine");
|
|
const other = {
|
|
...makeInstance("other"),
|
|
url: "http://127.0.0.1:19999",
|
|
token: "tok-other",
|
|
projectDir: "D:/Mods/Beta",
|
|
projects: ["D:/Mods/Beta"],
|
|
};
|
|
await writeInstance(mine, home);
|
|
await writeInstance(other, home);
|
|
// This window closes: only its own instance file goes away.
|
|
await clearInstance(mine.instanceId, home);
|
|
|
|
const manifest = await refreshDiscovery(home);
|
|
assert.deepEqual(manifest.projects, ["D:/Mods/Beta"]);
|
|
assert.deepEqual(manifest.instances.map((i) => i.instanceId), ["other"]);
|
|
|
|
// The legacy global pointer must follow the survivor, not be deleted.
|
|
const endpoint = await readEndpoint(home);
|
|
assert.equal(endpoint?.url, other.url);
|
|
assert.equal(endpoint?.token, other.token);
|
|
assert.equal(existsSync(endpointPath(home)), true);
|
|
|
|
// The merged manifest on disk must agree with the returned value.
|
|
const reread = await readManifest(home);
|
|
assert.deepEqual(reread?.instances.map((i) => i.instanceId), ["other"]);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("refreshDiscovery clears the global pointer when the last window closes", async () => {
|
|
const home = instanceHome();
|
|
try {
|
|
await writeInstance(makeInstance("only"), home);
|
|
await clearInstance("only", home);
|
|
|
|
const manifest = await refreshDiscovery(home);
|
|
assert.deepEqual(manifest.instances, []);
|
|
assert.deepEqual(manifest.projects, []);
|
|
assert.equal(existsSync(endpointPath(home)), false);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|