230 lines
9.1 KiB
JavaScript
230 lines
9.1 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
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";
|
|
|
|
test("writeSkillTo creates SKILL.md and avoids project-doc noise", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-test-"));
|
|
try {
|
|
const skillDir = join(dir, "ra3-mod-xml");
|
|
await writeSkillTo(skillDir, "0.1.25");
|
|
assert.ok(existsSync(join(skillDir, "SKILL.md")));
|
|
assert.ok(existsSync(join(skillDir, "references", "query-guide.md")));
|
|
assert.ok(existsSync(join(skillDir, SKILL_MARKER_FILE)));
|
|
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 });
|
|
}
|
|
});
|
|
|
|
test("SKILL.md scopes itself to SAGE/RA3 projects and warns off others", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-scope-"));
|
|
try {
|
|
const skillDir = join(dir, "ra3-mod-xml");
|
|
await writeSkillTo(skillDir, "0.1.25");
|
|
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
|
|
|
|
// Must state its applicability and list concrete positive signals.
|
|
assert.match(content, /When this skill applies/);
|
|
assert.ok(content.includes("Data/Mod.xml"));
|
|
assert.ok(content.includes("babproj"));
|
|
assert.ok(content.includes("AssetDeclaration"));
|
|
|
|
// Must give an explicit negative rule and a cheap probe.
|
|
assert.match(content, /Do \*\*not\*\* use these tools for unrelated repositories/);
|
|
assert.ok(content.includes("get_status"));
|
|
assert.ok(content.includes("projectDir"));
|
|
// `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"));
|
|
assert.ok(content.includes("definedIn"));
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("SKILL.md explains how to reach the index without MCP", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-reach-"));
|
|
try {
|
|
const skillDir = join(dir, "ra3-mod-xml");
|
|
await writeSkillTo(skillDir, "0.1.25");
|
|
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
|
|
|
|
assert.match(content, /## Reaching the index/);
|
|
// The discovery manifest is the stable entry point.
|
|
assert.ok(content.includes("~/.ra3modxml/index.json"));
|
|
// 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 {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("installSkillToDirectories records managed copies and uninstall removes them", async () => {
|
|
const home = mkdtempSync(join(tmpdir(), "ra3-skill-home-"));
|
|
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-target-"));
|
|
try {
|
|
const target = join(dir, "ra3-mod-xml");
|
|
const succeeded = await installSkillToDirectories([target], "0.1.25", home);
|
|
assert.deepEqual(succeeded, [target]);
|
|
const record = await readSkillInstallRecord(home);
|
|
assert.equal(record.length, 1);
|
|
assert.equal(record[0].path, target);
|
|
|
|
await uninstallSkillFromDirectory(target, home);
|
|
assert.equal(existsSync(target), false);
|
|
assert.equal((await readSkillInstallRecord(home)).length, 0);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
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 });
|
|
}
|
|
});
|