diff --git a/server/src/__tests__/skill-registry-install.test.ts b/server/src/__tests__/skill-registry-install.test.ts new file mode 100644 index 00000000..4a0b347e --- /dev/null +++ b/server/src/__tests__/skill-registry-install.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { existsSync, readdirSync } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { getSkillRegistryDb, resetSkillRegistryDb } from "../services/skill-registry-db.js"; +import { skills, skillVersions, skillFiles } from "../services/skill-registry-schema.js"; +import { skillRegistryService } from "../services/skill-registry.js"; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +let tmpHome: string; +let tmpAgentSkillsDir: string; + +beforeEach(async () => { + // Create isolated temp dirs + tmpHome = await mkdtemp(path.join(os.tmpdir(), "nexus-skill-registry-test-")); + tmpAgentSkillsDir = path.join(tmpHome, "agent-skills"); + await mkdir(tmpAgentSkillsDir, { recursive: true }); + + // Point PAPERCLIP_HOME at temp dir for DB and cache + process.env.PAPERCLIP_HOME = tmpHome; + resetSkillRegistryDb(); +}); + +afterEach(async () => { + resetSkillRegistryDb(); + delete process.env.PAPERCLIP_HOME; + await rm(tmpHome, { recursive: true, force: true }); +}); + +// Seed helpers + +async function seedSkillWithVersion(opts: { + skillId: string; + sourceId: string; + versionId: string; + cacheDir?: string; + fileKind?: string; +}): Promise { + const db = await getSkillRegistryDb(); + const now = Date.now(); + + await db.insert(skills).values({ + id: opts.skillId, + sourceId: opts.sourceId, + name: "Test Skill", + description: "A test skill", + sourceUrl: `https://github.com/test/${opts.skillId}`, + activeVersionId: null, + removedAt: null, + createdAt: now, + updatedAt: now, + }); + + await db.insert(skillVersions).values({ + id: opts.versionId, + skillId: opts.skillId, + version: "abc123", + fetchedAt: now, + cacheDir: opts.cacheDir ?? null, + }); + + await db.insert(skillFiles).values({ + id: `file-${opts.versionId}`, + versionId: opts.versionId, + path: "SKILL.md", + kind: opts.fileKind ?? "skill", + sizeBytes: 100, + }); +} + +async function createFakeCacheDir(cacheDir: string): Promise { + await mkdir(cacheDir, { recursive: true }); + await writeFile(path.join(cacheDir, "SKILL.md"), "# Test Skill\n\nContent.", "utf-8"); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("skillRegistryService", () => { + const svc = skillRegistryService(); + + describe("install — SKILL.md-based skill", () => { + it("Test 1: copies files from cache dir to agentSkillsDir//", async () => { + const skillId = "schwepps-skills/code-review"; + const versionId = `${skillId}@abc123`; + const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "abc123"); + await createFakeCacheDir(cacheDir); + await seedSkillWithVersion({ skillId, sourceId: "schwepps-skills", versionId, cacheDir }); + + const result = await svc.install(skillId, tmpAgentSkillsDir); + + expect(result.type).toBe("installed"); + if (result.type === "installed") { + const slug = "code-review"; + const expectedTarget = path.join(tmpAgentSkillsDir, slug); + expect(result.targetDir).toBe(expectedTarget); + expect(existsSync(path.join(expectedTarget, "SKILL.md"))).toBe(true); + } + }); + + it("Test 2: updates skill active_version_id to the latest version", async () => { + const skillId = "schwepps-skills/code-review"; + const versionId = `${skillId}@abc123`; + const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "abc123"); + await createFakeCacheDir(cacheDir); + await seedSkillWithVersion({ skillId, sourceId: "schwepps-skills", versionId, cacheDir }); + + await svc.install(skillId, tmpAgentSkillsDir); + + const db = await getSkillRegistryDb(); + const rows = await db.select().from(skills).where( + (await import("drizzle-orm")).eq(skills.id, skillId) + ); + expect(rows[0]?.activeVersionId).toBe(versionId); + }); + }); + + describe("install — marketplace plugin", () => { + it("Test 3: returns pending_plugin_install command instead of copying files for plugin kind", async () => { + const skillId = "anthropic-official/my-plugin"; + const versionId = `${skillId}@deadbeef`; + const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "deadbeef"); + await createFakeCacheDir(cacheDir); + await seedSkillWithVersion({ + skillId, + sourceId: "anthropic-official", + versionId, + cacheDir, + fileKind: "plugin", + }); + + const result = await svc.install(skillId, tmpAgentSkillsDir); + + expect(result.type).toBe("pending_plugin_install"); + if (result.type === "pending_plugin_install") { + expect(result.command).toContain("/plugin install"); + expect(result.skillId).toBe(skillId); + expect(result.versionId).toBe(versionId); + } + // No files should be copied to agent dir + const agentFiles = readdirSync(tmpAgentSkillsDir); + expect(agentFiles).toHaveLength(0); + }); + }); + + describe("uninstall", () => { + it("Test 4: sets removed_at timestamp on skills row", async () => { + const skillId = "schwepps-skills/code-review"; + const versionId = `${skillId}@abc123`; + const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "abc123"); + await createFakeCacheDir(cacheDir); + await seedSkillWithVersion({ skillId, sourceId: "schwepps-skills", versionId, cacheDir }); + + const before = Date.now(); + await svc.uninstall(skillId); + const after = Date.now(); + + const db = await getSkillRegistryDb(); + const { eq } = await import("drizzle-orm"); + const rows = await db.select().from(skills).where(eq(skills.id, skillId)); + expect(rows[0]?.removedAt).toBeGreaterThanOrEqual(before); + expect(rows[0]?.removedAt).toBeLessThanOrEqual(after); + }); + + it("Test 5: row still exists and is returned with includeRemoved=true", async () => { + const skillId = "schwepps-skills/code-review"; + const versionId = `${skillId}@abc123`; + const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "abc123"); + await createFakeCacheDir(cacheDir); + await seedSkillWithVersion({ skillId, sourceId: "schwepps-skills", versionId, cacheDir }); + + await svc.uninstall(skillId); + + // Not visible in normal list + const normalList = await svc.list(); + expect(normalList.find((s) => s.id === skillId)).toBeUndefined(); + + // Visible with includeRemoved + const fullList = await svc.list({ includeRemoved: true }); + expect(fullList.find((s) => s.id === skillId)).toBeDefined(); + }); + }); + + describe("rollback", () => { + it("Test 6: copies prior version's cached files to agent skills dir", async () => { + const skillId = "schwepps-skills/code-review"; + const slug = "code-review"; + + // Seed v1 (prior) and v2 (current) + const v1Id = `${skillId}@v1sha`; + const v2Id = `${skillId}@v2sha`; + + const v1CacheDir = path.join(tmpHome, "skills", "cache", skillId, "v1sha"); + const v2CacheDir = path.join(tmpHome, "skills", "cache", skillId, "v2sha"); + + await createFakeCacheDir(v1CacheDir); + await writeFile(path.join(v1CacheDir, "SKILL.md"), "# Version 1", "utf-8"); + await createFakeCacheDir(v2CacheDir); + await writeFile(path.join(v2CacheDir, "SKILL.md"), "# Version 2", "utf-8"); + + // Seed both versions + const db = await getSkillRegistryDb(); + const now = Date.now(); + await db.insert(skills).values({ + id: skillId, + sourceId: "schwepps-skills", + name: "Test", + description: null, + sourceUrl: "https://github.com/test", + activeVersionId: v2Id, + removedAt: null, + createdAt: now, + updatedAt: now, + }); + await db.insert(skillVersions).values({ + id: v1Id, skillId, version: "v1sha", fetchedAt: now - 1000, cacheDir: v1CacheDir, + }); + await db.insert(skillVersions).values({ + id: v2Id, skillId, version: "v2sha", fetchedAt: now, cacheDir: v2CacheDir, + }); + await db.insert(skillFiles).values({ + id: "file-v1", versionId: v1Id, path: "SKILL.md", kind: "skill", sizeBytes: 12, + }); + await db.insert(skillFiles).values({ + id: "file-v2", versionId: v2Id, path: "SKILL.md", kind: "skill", sizeBytes: 12, + }); + + // Install v2 first + const targetDir = path.join(tmpAgentSkillsDir, slug); + await mkdir(targetDir, { recursive: true }); + await writeFile(path.join(targetDir, "SKILL.md"), "# Version 2", "utf-8"); + + // Rollback to v1 + await svc.rollback(skillId, v1Id, tmpAgentSkillsDir); + + // Verify v1 content is in place + const { readFileSync } = await import("node:fs"); + const content = readFileSync(path.join(targetDir, "SKILL.md"), "utf-8"); + expect(content).toBe("# Version 1"); + }); + + it("Test 7: updates active_version_id to the prior version", async () => { + const skillId = "schwepps-skills/code-review"; + const v1Id = `${skillId}@v1sha`; + const v2Id = `${skillId}@v2sha`; + + const v1CacheDir = path.join(tmpHome, "skills", "cache", skillId, "v1sha"); + await createFakeCacheDir(v1CacheDir); + + const db = await getSkillRegistryDb(); + const now = Date.now(); + await db.insert(skills).values({ + id: skillId, sourceId: "test", name: "T", description: null, + sourceUrl: "u", activeVersionId: v2Id, removedAt: null, createdAt: now, updatedAt: now, + }); + await db.insert(skillVersions).values({ + id: v1Id, skillId, version: "v1sha", fetchedAt: now - 1000, cacheDir: v1CacheDir, + }); + + const agentDir = path.join(tmpAgentSkillsDir, "code-review"); + await mkdir(agentDir, { recursive: true }); + await writeFile(path.join(agentDir, "SKILL.md"), "current", "utf-8"); + + await svc.rollback(skillId, v1Id, tmpAgentSkillsDir); + + const { eq } = await import("drizzle-orm"); + const rows = await db.select().from(skills).where(eq(skills.id, skillId)); + expect(rows[0]?.activeVersionId).toBe(v1Id); + }); + }); + + describe("list", () => { + it("Test 8: returns only skills where removed_at IS NULL by default", async () => { + const db = await getSkillRegistryDb(); + const now = Date.now(); + await db.insert(skills).values([ + { + id: "active-skill", sourceId: "test", name: "Active", description: null, + sourceUrl: "u", activeVersionId: null, removedAt: null, createdAt: now, updatedAt: now, + }, + { + id: "removed-skill", sourceId: "test", name: "Removed", description: null, + sourceUrl: "u", activeVersionId: null, removedAt: now - 1000, createdAt: now, updatedAt: now, + }, + ]); + + const result = await svc.list(); + const ids = result.map((s) => s.id); + + expect(ids).toContain("active-skill"); + expect(ids).not.toContain("removed-skill"); + }); + + it("Test 9: list({ includeRemoved: true }) returns all skills", async () => { + const db = await getSkillRegistryDb(); + const now = Date.now(); + await db.insert(skills).values([ + { + id: "active-skill", sourceId: "test", name: "Active", description: null, + sourceUrl: "u", activeVersionId: null, removedAt: null, createdAt: now, updatedAt: now, + }, + { + id: "removed-skill", sourceId: "test", name: "Removed", description: null, + sourceUrl: "u", activeVersionId: null, removedAt: now - 1000, createdAt: now, updatedAt: now, + }, + ]); + + const result = await svc.list({ includeRemoved: true }); + const ids = result.map((s) => s.id); + + expect(ids).toContain("active-skill"); + expect(ids).toContain("removed-skill"); + }); + }); +});