- 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
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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/<slug>/", 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, tmpAgentSkillsDir);
|
|
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, tmpAgentSkillsDir);
|
|
|
|
// 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");
|
|
});
|
|
});
|
|
});
|