/** * 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 { 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"); }); });