- skill-registry-adapter-install.test.ts: 9 tests covering install/uninstall/rollback/assignGroup/removeGroup - hermes-dual-source.test.ts: 7 tests covering syncHermesNativeSkills idempotency and listAgentSkills object shape - Fix skill-registry-install.test.ts: update uninstall() callers to pass agentSkillsDir (new required param) - Fix removeGroup() bug: removed incorrect 'individualSkills' guard that prevented file removal for group-installed skills (rule 1 auto-fix: group-installed skills were never removed because they appeared in agentSkills with no way to distinguish from direct installs) - All 16 new tests pass, all existing tests still pass
214 lines
7.5 KiB
TypeScript
214 lines
7.5 KiB
TypeScript
/**
|
|
* Tests for Hermes dual-source skill tracking.
|
|
* Verifies syncHermesNativeSkills and listAgentSkills typed return shape.
|
|
*
|
|
* Uses real temp directories instead of mocking readdir (ESM limitation prevents
|
|
* direct spying on node:fs/promises named exports in Vitest).
|
|
*
|
|
* Covers: HERM-01, HERM-02, HERM-03
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { mkdtemp, rm, mkdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import { getSkillRegistryDb, resetSkillRegistryDb } from "../services/skill-registry-db.js";
|
|
import { skills, agentSkills } from "../services/skill-registry-schema.js";
|
|
import { skillRegistryService } from "../services/skill-registry.js";
|
|
import { skillGroupService } from "../services/skill-registry-groups.js";
|
|
import { eq } from "drizzle-orm";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let tmpHome: string;
|
|
let fakeHermesSkillsDir: string;
|
|
let originalHome: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
tmpHome = await mkdtemp(path.join(os.tmpdir(), "nexus-hermes-test-"));
|
|
process.env.PAPERCLIP_HOME = tmpHome;
|
|
|
|
// Override HOME so syncHermesNativeSkills reads our fake ~/.hermes/skills/
|
|
originalHome = process.env.HOME;
|
|
process.env.HOME = tmpHome;
|
|
|
|
fakeHermesSkillsDir = path.join(tmpHome, ".hermes", "skills");
|
|
resetSkillRegistryDb();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
resetSkillRegistryDb();
|
|
delete process.env.PAPERCLIP_HOME;
|
|
if (originalHome !== undefined) {
|
|
process.env.HOME = originalHome;
|
|
} else {
|
|
delete process.env.HOME;
|
|
}
|
|
await rm(tmpHome, { recursive: true, force: true });
|
|
});
|
|
|
|
/** Create fake skill directories inside ~/.hermes/skills/ */
|
|
async function createFakeHermesSkills(skillNames: string[]): Promise<void> {
|
|
await mkdir(fakeHermesSkillsDir, { recursive: true });
|
|
for (const name of skillNames) {
|
|
await mkdir(path.join(fakeHermesSkillsDir, name), { recursive: true });
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// syncHermesNativeSkills
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("syncHermesNativeSkills()", () => {
|
|
it("Test 1: creates skills stub rows with sourceId 'hermes-native'", async () => {
|
|
await createFakeHermesSkills(["code-reviewer", "test-writer"]);
|
|
|
|
const svc = skillRegistryService();
|
|
await svc.syncHermesNativeSkills("agent-hermes-1");
|
|
|
|
const db = await getSkillRegistryDb();
|
|
const skillRows = await db.select().from(skills)
|
|
.where(eq(skills.sourceId, "hermes-native"));
|
|
|
|
expect(skillRows.length).toBe(2);
|
|
expect(skillRows.map((r) => r.id).sort()).toEqual([
|
|
"hermes-native/code-reviewer",
|
|
"hermes-native/test-writer",
|
|
].sort());
|
|
expect(skillRows.every((r) => r.sourceId === "hermes-native")).toBe(true);
|
|
});
|
|
|
|
it("Test 2: creates agentSkills rows with source 'native'", async () => {
|
|
await createFakeHermesSkills(["code-reviewer"]);
|
|
|
|
const svc = skillRegistryService();
|
|
await svc.syncHermesNativeSkills("agent-hermes-2");
|
|
|
|
const db = await getSkillRegistryDb();
|
|
const agentSkillRows = await db.select().from(agentSkills)
|
|
.where(eq(agentSkills.agentId, "agent-hermes-2"));
|
|
|
|
expect(agentSkillRows.length).toBe(1);
|
|
expect(agentSkillRows[0]!.skillId).toBe("hermes-native/code-reviewer");
|
|
expect(agentSkillRows[0]!.source).toBe("native");
|
|
});
|
|
|
|
it("Test 3: is idempotent — running twice does not duplicate rows", async () => {
|
|
await createFakeHermesSkills(["code-reviewer"]);
|
|
|
|
const svc = skillRegistryService();
|
|
// Run twice
|
|
await svc.syncHermesNativeSkills("agent-hermes-3");
|
|
await svc.syncHermesNativeSkills("agent-hermes-3");
|
|
|
|
const db = await getSkillRegistryDb();
|
|
const agentSkillRows = await db.select().from(agentSkills)
|
|
.where(eq(agentSkills.agentId, "agent-hermes-3"));
|
|
|
|
// Should only have ONE row despite calling sync twice
|
|
expect(agentSkillRows.length).toBe(1);
|
|
});
|
|
|
|
it("Test 4: handles missing ~/.hermes/skills/ gracefully (no throw)", async () => {
|
|
// Do NOT create the hermes skills dir — simulate it not existing
|
|
|
|
const svc = skillRegistryService();
|
|
// Must not throw
|
|
await expect(svc.syncHermesNativeSkills("agent-hermes-4")).resolves.toBeUndefined();
|
|
|
|
const db = await getSkillRegistryDb();
|
|
const agentSkillRows = await db.select().from(agentSkills)
|
|
.where(eq(agentSkills.agentId, "agent-hermes-4"));
|
|
expect(agentSkillRows.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// listAgentSkills — typed object shape
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("listAgentSkills() — returns objects with source field", () => {
|
|
it("Test 5: returns Array<{skillId, source, installedAt}> not string[]", async () => {
|
|
const db = await getSkillRegistryDb();
|
|
const agentId = "agent-shape-test";
|
|
const now = Date.now();
|
|
|
|
// Insert a managed skill directly
|
|
await db.insert(agentSkills).values({
|
|
agentId,
|
|
skillId: "nexus-skills/code-review",
|
|
installedAt: now,
|
|
source: "managed",
|
|
});
|
|
|
|
const grpSvc = skillGroupService();
|
|
const result = await grpSvc.listAgentSkills(agentId);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
expect(result.length).toBe(1);
|
|
|
|
const entry = result[0]!;
|
|
// Must have the 3-field object shape
|
|
expect(typeof entry.skillId).toBe("string");
|
|
expect(typeof entry.source).toBe("string");
|
|
expect(typeof entry.installedAt).toBe("number");
|
|
|
|
// Must NOT be a plain string (old API)
|
|
expect(typeof entry).toBe("object");
|
|
});
|
|
|
|
it("Test 6: returns skills with correct source values", async () => {
|
|
const db = await getSkillRegistryDb();
|
|
const agentId = "agent-source-values";
|
|
const now = Date.now();
|
|
|
|
await db.insert(agentSkills).values([
|
|
{ agentId, skillId: "nexus-skills/managed-skill", installedAt: now, source: "managed" },
|
|
{ agentId, skillId: "hermes-native/native-skill", installedAt: now, source: "native" },
|
|
]);
|
|
|
|
const grpSvc = skillGroupService();
|
|
const result = await grpSvc.listAgentSkills(agentId);
|
|
|
|
expect(result.length).toBe(2);
|
|
const managedEntry = result.find((r) => r.skillId === "nexus-skills/managed-skill");
|
|
const nativeEntry = result.find((r) => r.skillId === "hermes-native/native-skill");
|
|
|
|
expect(managedEntry?.source).toBe("managed");
|
|
expect(nativeEntry?.source).toBe("native");
|
|
});
|
|
|
|
it("Test 7: listAgentSkills includes both managed and native skills for a Hermes agent", async () => {
|
|
await createFakeHermesSkills(["native-skill"]);
|
|
|
|
const agentId = "agent-hermes-combined";
|
|
const db = await getSkillRegistryDb();
|
|
const now = Date.now();
|
|
|
|
// Insert a managed skill
|
|
await db.insert(agentSkills).values({
|
|
agentId,
|
|
skillId: "nexus-skills/managed",
|
|
installedAt: now,
|
|
source: "managed",
|
|
});
|
|
|
|
// Sync native skills
|
|
const svc = skillRegistryService();
|
|
await svc.syncHermesNativeSkills(agentId);
|
|
|
|
const grpSvc = skillGroupService();
|
|
const result = await grpSvc.listAgentSkills(agentId);
|
|
|
|
expect(result.length).toBe(2);
|
|
|
|
const managed = result.filter((r) => r.source === "managed");
|
|
const native = result.filter((r) => r.source === "native");
|
|
|
|
expect(managed.length).toBe(1);
|
|
expect(native.length).toBe(1);
|
|
expect(managed[0]!.skillId).toBe("nexus-skills/managed");
|
|
expect(native[0]!.skillId).toBe("hermes-native/native-skill");
|
|
});
|
|
});
|