test(09-03): add failing tests for skillRegistryService install/rollback/uninstall/list

This commit is contained in:
Mikkel Georgsen 2026-04-01 01:10:51 +02:00 committed by Nexus Dev
parent e443ea9411
commit 136155ad27

View file

@ -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<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);
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");
});
});
});