finalize
This commit is contained in:
@@ -14,9 +14,11 @@ import {
|
||||
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-"));
|
||||
@@ -166,3 +168,52 @@ test("makeInstanceId is unique across rapid calls", () => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
+103
-1
@@ -1,12 +1,18 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
addMcpServerToConfigFile,
|
||||
installMcpServerConfigToFile,
|
||||
launcherPath,
|
||||
mcpConfigJson,
|
||||
mcpServerConfig,
|
||||
readMcpInstallRecord,
|
||||
removeLauncher,
|
||||
removeMcpServerFromConfigFile,
|
||||
uninstallMcpServerConfigs,
|
||||
} from "../out/agent/setup.js";
|
||||
|
||||
test("mcpServerConfig and mcpConfigJson use the stable launcher", () => {
|
||||
@@ -34,3 +40,99 @@ test("addMcpServerToConfigFile creates and merges config", async () => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("removeMcpServerFromConfigFile removes only our entry and preserves the rest", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-remove-"));
|
||||
try {
|
||||
const file = join(dir, "mcp.json");
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
other: { command: "x" },
|
||||
"ra3-mod-xml": { command: "launcher", args: ["--project", "D:/Mods/A"] },
|
||||
},
|
||||
someOtherKey: 1,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
assert.equal(await removeMcpServerFromConfigFile(file), true);
|
||||
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
||||
assert.ok(parsed.mcpServers.other);
|
||||
assert.equal(parsed.mcpServers["ra3-mod-xml"], undefined);
|
||||
assert.equal(parsed.someOtherKey, 1);
|
||||
|
||||
// Nothing left to remove: second call is a no-op.
|
||||
assert.equal(await removeMcpServerFromConfigFile(file), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("removeMcpServerFromConfigFile handles VS Code's servers key and drops empty containers", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-vscode-"));
|
||||
try {
|
||||
const file = join(dir, "mcp.json");
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ servers: { "ra3-mod-xml": { command: "x" } }, inputs: [] }, null, 2),
|
||||
);
|
||||
assert.equal(await removeMcpServerFromConfigFile(file), true);
|
||||
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
||||
assert.equal(parsed.servers, undefined);
|
||||
assert.deepEqual(parsed.inputs, []);
|
||||
|
||||
// A missing file is "nothing to remove", not an error.
|
||||
assert.equal(await removeMcpServerFromConfigFile(join(dir, "nope.json")), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("installed MCP configs can be uninstalled through the install record", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-setup-home-"));
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-record-"));
|
||||
try {
|
||||
const file = join(dir, "mcp.json");
|
||||
await installMcpServerConfigToFile({
|
||||
filePath: file,
|
||||
launcher: "C:/launcher.cmd",
|
||||
projectDir: "D:/Mods/A",
|
||||
label: "Test client",
|
||||
sourceVersion: "1.2.3",
|
||||
agentHome: home,
|
||||
});
|
||||
|
||||
const record = await readMcpInstallRecord(home);
|
||||
assert.equal(record.length, 1);
|
||||
assert.equal(record[0].label, "Test client");
|
||||
assert.equal(record[0].serverKey, "ra3-mod-xml");
|
||||
assert.equal(record[0].sourceVersion, "1.2.3");
|
||||
|
||||
const removed = await uninstallMcpServerConfigs({ agentHome: home });
|
||||
assert.deepEqual(removed, [file]);
|
||||
assert.equal((await readMcpInstallRecord(home)).length, 0);
|
||||
// Our entry was the only server: the empty container is dropped.
|
||||
assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), {});
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("removeLauncher deletes the stable launcher and tolerates its absence", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-setup-launcher-"));
|
||||
try {
|
||||
writeFileSync(launcherPath(home), "dummy", "utf8");
|
||||
assert.equal(existsSync(launcherPath(home)), true);
|
||||
assert.equal(await removeLauncher(home), true);
|
||||
assert.equal(existsSync(launcherPath(home)), false);
|
||||
// force: true, so removing it again is still a success.
|
||||
assert.equal(await removeLauncher(home), true);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
+130
-4
@@ -1,13 +1,18 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
SKILL_MARKER_FILE,
|
||||
forgetSkillInstallRecords,
|
||||
installSkillToDirectories,
|
||||
installedSkillStatus,
|
||||
readSkillInstallRecord,
|
||||
readSkillMarker,
|
||||
uninstallRecordedSkills,
|
||||
uninstallSkillFromDirectory,
|
||||
writeSkillInstallRecord,
|
||||
writeSkillTo,
|
||||
} from "../out/agent/skill.js";
|
||||
|
||||
@@ -22,6 +27,27 @@ test("writeSkillTo creates SKILL.md and avoids project-doc noise", async () => {
|
||||
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
|
||||
assert.ok(content.includes("find_asset"));
|
||||
assert.ok(!content.includes("codebase-navigation-guide"));
|
||||
|
||||
// The bundled reference must list every tool the MCP server exposes.
|
||||
const guide = readFileSync(
|
||||
join(skillDir, "references", "query-guide.md"),
|
||||
"utf8",
|
||||
);
|
||||
for (const tool of [
|
||||
"get_status",
|
||||
"find_asset",
|
||||
"find_references",
|
||||
"get_asset_references",
|
||||
"list_assets_by_type",
|
||||
"is_file_active",
|
||||
"find_define",
|
||||
"resolve_include",
|
||||
"list_projects",
|
||||
"get_usage_guide",
|
||||
]) {
|
||||
assert.ok(guide.includes(tool), `query-guide.md must mention ${tool}`);
|
||||
}
|
||||
assert.equal(guide.includes("CnC3Types.xsd"), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -44,9 +70,10 @@ test("SKILL.md scopes itself to SAGE/RA3 projects and warns off others", async (
|
||||
assert.match(content, /Do \*\*not\*\* use these tools for unrelated repositories/);
|
||||
assert.ok(content.includes("get_status"));
|
||||
assert.ok(content.includes("projectDir"));
|
||||
// The CnC3 red herring is called out explicitly.
|
||||
assert.ok(content.includes("CnC3Types.xsd"));
|
||||
assert.ok(content.includes("C&C3"));
|
||||
// `CnC3Types.xsd` is the shared SAGE base schema (the RA3 Mod SDK ships it
|
||||
// too), so it must not be presented as evidence in either direction.
|
||||
assert.equal(content.includes("CnC3Types.xsd"), false);
|
||||
assert.equal(content.includes("Tiberium Wars"), false);
|
||||
|
||||
// Second-phase capability must be documented.
|
||||
assert.ok(content.includes("get_asset_references"));
|
||||
@@ -69,12 +96,20 @@ test("SKILL.md explains how to reach the index without MCP", async () => {
|
||||
// The launcher and the bundled CLI are both mentioned.
|
||||
assert.ok(content.includes("ra3-mod-xml-mcp"));
|
||||
assert.ok(content.includes("cli.js"));
|
||||
// The CLI is a Node script, and the skill must say how to run it when Node
|
||||
// is not installed (VS Code's Electron binary as Node).
|
||||
assert.ok(content.includes("ELECTRON_RUN_AS_NODE=1"));
|
||||
// The stdio escape hatch must be shown, since it needs no setup at all.
|
||||
assert.ok(content.includes("tools/call"));
|
||||
// It must not pretend configuring a client takes effect immediately.
|
||||
assert.ok(content.includes("new session"));
|
||||
// And it must refuse to invent results.
|
||||
assert.match(content, /Never fabricate index results/);
|
||||
// The bundled tool reference must be linked from SKILL.md, otherwise
|
||||
// skills-compatible clients never load it (resources load on demand).
|
||||
assert.ok(content.includes("./references/query-guide.md"));
|
||||
// The tool list must stay a real list; a merged bullet hides an item.
|
||||
assert.match(content, /indexed stream\.\n\s+- `find_define\(name\)`/);
|
||||
// Instructions must not send it looking for project-specific docs.
|
||||
assert.equal(content.includes("codebase-navigation-guide"), false);
|
||||
} finally {
|
||||
@@ -101,3 +136,94 @@ test("installSkillToDirectories records managed copies and uninstall removes the
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readSkillMarker requires our marker to describe the same directory", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-marker-"));
|
||||
try {
|
||||
const managed = join(dir, "managed");
|
||||
await writeSkillTo(managed, "1.0.0");
|
||||
const marker = await readSkillMarker(managed);
|
||||
assert.equal(marker?.sourceVersion, "1.0.0");
|
||||
|
||||
// A hand-copied skill without a marker is never treated as ours.
|
||||
const foreign = join(dir, "foreign");
|
||||
mkdirSync(foreign, { recursive: true });
|
||||
writeFileSync(join(foreign, "SKILL.md"), "user content", "utf8");
|
||||
assert.equal(await readSkillMarker(foreign), null);
|
||||
|
||||
// A marker pointing at another directory must not make this one managed.
|
||||
const spoofed = join(dir, "spoofed");
|
||||
mkdirSync(spoofed, { recursive: true });
|
||||
writeFileSync(
|
||||
join(spoofed, SKILL_MARKER_FILE),
|
||||
JSON.stringify({ path: managed, sourceVersion: "1.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
assert.equal(await readSkillMarker(spoofed), null);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uninstallRecordedSkills removes managed copies and skips user-owned ones", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-skill-uninstall-home-"));
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-uninstall-"));
|
||||
try {
|
||||
const managed = join(dir, "managed");
|
||||
await installSkillToDirectories([managed], "1.0.0", home);
|
||||
|
||||
// A directory the user owns: recorded, but with no marker of ours.
|
||||
const foreign = join(dir, "foreign");
|
||||
mkdirSync(foreign, { recursive: true });
|
||||
writeFileSync(join(foreign, "SKILL.md"), "user content", "utf8");
|
||||
const record = await readSkillInstallRecord(home);
|
||||
record.push({ path: foreign, sourceVersion: "0.0.0" });
|
||||
await writeSkillInstallRecord(record, home);
|
||||
|
||||
const status = await installedSkillStatus(home);
|
||||
assert.equal(status.length, 2);
|
||||
assert.deepEqual(
|
||||
status.map((s) => ({ path: s.path, managed: s.managed })),
|
||||
[
|
||||
{ path: managed, managed: true },
|
||||
{ path: foreign, managed: false },
|
||||
],
|
||||
);
|
||||
|
||||
const { removed, skipped } = await uninstallRecordedSkills(
|
||||
[managed, foreign],
|
||||
home,
|
||||
);
|
||||
assert.deepEqual(removed, [managed]);
|
||||
assert.deepEqual(skipped, [foreign]);
|
||||
assert.equal(existsSync(managed), false);
|
||||
assert.equal(readFileSync(join(foreign, "SKILL.md"), "utf8"), "user content");
|
||||
|
||||
// The unmanaged entry stays in the record so it can still be reviewed.
|
||||
const after = await readSkillInstallRecord(home);
|
||||
assert.deepEqual(after.map((r) => r.path), [foreign]);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("installedSkillStatus and forgetSkillInstallRecords handle a missing directory", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-skill-missing-home-"));
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-missing-"));
|
||||
try {
|
||||
const gone = join(dir, "gone");
|
||||
await writeSkillInstallRecord([{ path: gone, sourceVersion: "1.0.0" }], home);
|
||||
|
||||
const status = await installedSkillStatus(home);
|
||||
assert.equal(status.length, 1);
|
||||
assert.equal(status[0].exists, false);
|
||||
assert.equal(status[0].managed, false);
|
||||
|
||||
await forgetSkillInstallRecords([gone], home);
|
||||
assert.equal((await readSkillInstallRecord(home)).length, 0);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
AGENT_FEATURE_VERSION,
|
||||
compareVersions,
|
||||
shouldOfferAgentOnboarding,
|
||||
} from "../out/agent/onboarding.js";
|
||||
|
||||
test("compareVersions orders dotted numeric versions", () => {
|
||||
assert.equal(compareVersions("0.1.26", "0.1.26"), 0);
|
||||
assert.equal(compareVersions("0.1.26", "0.1.25"), 1);
|
||||
assert.equal(compareVersions("0.1.25", "0.1.26"), -1);
|
||||
assert.equal(compareVersions("0.1.26", "0.1"), 1);
|
||||
assert.equal(compareVersions("0.2", "0.1.99"), 1);
|
||||
assert.equal(compareVersions("1.0.0", "2.0.0"), -1);
|
||||
assert.equal(compareVersions("0.1.26-beta", "0.1.26"), 0);
|
||||
assert.equal(compareVersions("dev", "0.1.25"), -1);
|
||||
});
|
||||
|
||||
test("a fresh install is offered the AI Agent introduction once", () => {
|
||||
assert.equal(shouldOfferAgentOnboarding(undefined, "0.1.26"), true);
|
||||
assert.equal(shouldOfferAgentOnboarding({}, "0.1.26"), true);
|
||||
});
|
||||
|
||||
test("upgrading from before the feature informs once", () => {
|
||||
assert.equal(
|
||||
shouldOfferAgentOnboarding({ informedVersion: "0.1.25" }, "0.1.26"),
|
||||
true,
|
||||
);
|
||||
// ...but not again on the same version or later ones.
|
||||
assert.equal(
|
||||
shouldOfferAgentOnboarding({ informedVersion: "0.1.26" }, "0.1.26"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldOfferAgentOnboarding({ informedVersion: "0.1.26" }, "0.1.27"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("upgrading from a build that already had the feature stays silent", () => {
|
||||
// 0.1.26 introduced it; someone informed on 0.1.26 must not be re-prompted.
|
||||
assert.equal(
|
||||
shouldOfferAgentOnboarding({ informedVersion: "0.1.26" }, "0.1.30"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("dismissed state suppresses the prompt forever", () => {
|
||||
assert.equal(
|
||||
shouldOfferAgentOnboarding(
|
||||
{ informedVersion: "0.1.20", dismissed: true },
|
||||
"0.1.26",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldOfferAgentOnboarding(
|
||||
{ informedVersion: AGENT_FEATURE_VERSION, dismissed: true },
|
||||
"0.1.30",
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user