- 7 tests covering fetch from Anthropic marketplace and GitHub tree sources - Tests DB insertion, file caching, idempotency, and BUILT_IN_SOURCES config - All tests fail with ERR_MODULE_NOT_FOUND (expected TDD RED state)
404 lines
15 KiB
TypeScript
404 lines
15 KiB
TypeScript
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers for building mock responses
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function mockMarketplaceJson(skills: Array<{ path: string }>) {
|
|
return JSON.stringify({ skills });
|
|
}
|
|
|
|
function mockGitHubTree(paths: string[]) {
|
|
return JSON.stringify({
|
|
tree: paths.map((p) => ({ path: p, type: "blob", size: 100 })),
|
|
});
|
|
}
|
|
|
|
function mockSkillMd(name: string, description: string) {
|
|
return `---
|
|
name: ${name}
|
|
description: ${description}
|
|
---
|
|
|
|
# ${name}
|
|
|
|
A skill for testing.
|
|
`;
|
|
}
|
|
|
|
function mockCommitSha(sha = "abc1234567890abcdef1234567890abcdef123456") {
|
|
return JSON.stringify({ sha });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("skill-registry-fetch", () => {
|
|
let tmpDir: string;
|
|
let originalPaperclipHome: string | undefined;
|
|
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(path.join(os.tmpdir(), "skill-fetch-test-"));
|
|
originalPaperclipHome = process.env.PAPERCLIP_HOME;
|
|
process.env.PAPERCLIP_HOME = tmpDir;
|
|
|
|
// Mock global.fetch
|
|
fetchSpy = vi.fn();
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
|
|
const { resetSkillRegistryDb } = await import("../services/skill-registry-db.js");
|
|
resetSkillRegistryDb();
|
|
|
|
if (originalPaperclipHome === undefined) {
|
|
delete process.env.PAPERCLIP_HOME;
|
|
} else {
|
|
process.env.PAPERCLIP_HOME = originalPaperclipHome;
|
|
}
|
|
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 1: Anthropic marketplace source inserts skills rows with sourceId
|
|
// -------------------------------------------------------------------------
|
|
it("Test 1: fetchAllSources() with mocked Anthropic marketplace.json inserts skills rows with sourceId='anthropic-official'", async () => {
|
|
const sha = "abc1234567890abcdef1234567890abcdef123456";
|
|
|
|
// Mock all fetch calls
|
|
fetchSpy.mockImplementation(async (url: string, opts?: RequestInit) => {
|
|
const urlStr = String(url);
|
|
|
|
// marketplace.json
|
|
if (urlStr.includes("marketplace.json")) {
|
|
return {
|
|
ok: true,
|
|
text: async () => mockMarketplaceJson([{ path: "coding/my-skill" }]),
|
|
json: async () => ({ skills: [{ path: "coding/my-skill" }] }),
|
|
};
|
|
}
|
|
// commit SHA lookup
|
|
if (urlStr.includes("/commits/")) {
|
|
return {
|
|
ok: true,
|
|
text: async () => mockCommitSha(sha),
|
|
json: async () => ({ sha }),
|
|
};
|
|
}
|
|
// SKILL.md raw content
|
|
if (urlStr.includes("SKILL.md")) {
|
|
return {
|
|
ok: true,
|
|
text: async () => mockSkillMd("My Skill", "A test skill from Anthropic"),
|
|
json: async () => ({}),
|
|
};
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found", json: async () => ({}) };
|
|
});
|
|
|
|
const { fetchAllSources, BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
|
|
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
|
|
const { skills } = await import("../services/skill-registry-schema.js");
|
|
|
|
// Fetch only the Anthropic source
|
|
const anthropicSource = BUILT_IN_SOURCES.find((s) => s.id === "anthropic-official")!;
|
|
const result = await fetchAllSources([anthropicSource]);
|
|
|
|
expect(result.errors).toHaveLength(0);
|
|
expect(result.fetched).toBeGreaterThan(0);
|
|
|
|
const db = await getSkillRegistryDb();
|
|
const rows = await db.select().from(skills);
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
expect(rows[0]!.sourceId).toBe("anthropic-official");
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 2: Community GitHub source inserts skills rows with correct sourceId
|
|
// -------------------------------------------------------------------------
|
|
it("Test 2: fetchAllSources() with mocked GitHub tree API for community repo inserts skills rows with correct sourceId", async () => {
|
|
const sha = "deadbeef1234567890abcdef1234567890abcdef";
|
|
|
|
fetchSpy.mockImplementation(async (url: string) => {
|
|
const urlStr = String(url);
|
|
|
|
if (urlStr.includes("/git/trees/")) {
|
|
return {
|
|
ok: true,
|
|
text: async () => mockGitHubTree(["code-review/SKILL.md", "code-review/rules/rules.md"]),
|
|
json: async () => ({
|
|
tree: [
|
|
{ path: "code-review/SKILL.md", type: "blob", size: 200 },
|
|
{ path: "code-review/rules/rules.md", type: "blob", size: 500 },
|
|
],
|
|
}),
|
|
};
|
|
}
|
|
if (urlStr.includes("/commits/")) {
|
|
return {
|
|
ok: true,
|
|
text: async () => mockCommitSha(sha),
|
|
json: async () => ({ sha }),
|
|
};
|
|
}
|
|
if (urlStr.includes("SKILL.md")) {
|
|
return {
|
|
ok: true,
|
|
text: async () => mockSkillMd("Code Review", "Reviews code for quality"),
|
|
json: async () => ({}),
|
|
};
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found", json: async () => ({}) };
|
|
});
|
|
|
|
const { fetchAllSources, BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
|
|
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
|
|
const { skills } = await import("../services/skill-registry-schema.js");
|
|
|
|
const communitySource = BUILT_IN_SOURCES.find((s) => s.id === "schwepps-skills")!;
|
|
const result = await fetchAllSources([communitySource]);
|
|
|
|
expect(result.errors).toHaveLength(0);
|
|
|
|
const db = await getSkillRegistryDb();
|
|
const rows = await db.select().from(skills);
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
expect(rows[0]!.sourceId).toBe("schwepps-skills");
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 3: Each fetched skill has a skill_versions row with commit SHA
|
|
// -------------------------------------------------------------------------
|
|
it("Test 3: Each fetched skill has a skill_versions row with the commit SHA as version", async () => {
|
|
const sha = "cafebabe1234567890abcdef1234567890abcdef";
|
|
|
|
fetchSpy.mockImplementation(async (url: string) => {
|
|
const urlStr = String(url);
|
|
|
|
if (urlStr.includes("/git/trees/")) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
tree: [{ path: "test-skill/SKILL.md", type: "blob", size: 100 }],
|
|
}),
|
|
};
|
|
}
|
|
if (urlStr.includes("/commits/")) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ sha }),
|
|
};
|
|
}
|
|
if (urlStr.includes("SKILL.md")) {
|
|
return {
|
|
ok: true,
|
|
text: async () => mockSkillMd("Test Skill", "A test skill"),
|
|
};
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found", json: async () => ({}) };
|
|
});
|
|
|
|
const { fetchAllSources, BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
|
|
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
|
|
const { skillVersions } = await import("../services/skill-registry-schema.js");
|
|
|
|
const source = BUILT_IN_SOURCES.find((s) => s.id === "schwepps-skills")!;
|
|
await fetchAllSources([source]);
|
|
|
|
const db = await getSkillRegistryDb();
|
|
const versions = await db.select().from(skillVersions);
|
|
expect(versions.length).toBeGreaterThan(0);
|
|
expect(versions[0]!.version).toBe(sha);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 4: SKILL.md written to cache dir
|
|
// -------------------------------------------------------------------------
|
|
it("Test 4: Each fetched skill's SKILL.md content is written to cache dir at <instance-root>/skills/cache/<skill-id>/<sha>/SKILL.md", async () => {
|
|
const sha = "feedfeed1234567890abcdef1234567890abcdef";
|
|
const skillMdContent = mockSkillMd("Cached Skill", "Written to disk");
|
|
|
|
fetchSpy.mockImplementation(async (url: string) => {
|
|
const urlStr = String(url);
|
|
|
|
if (urlStr.includes("/git/trees/")) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
tree: [{ path: "cached-skill/SKILL.md", type: "blob", size: 100 }],
|
|
}),
|
|
};
|
|
}
|
|
if (urlStr.includes("/commits/")) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ sha }),
|
|
};
|
|
}
|
|
if (urlStr.includes("SKILL.md")) {
|
|
return {
|
|
ok: true,
|
|
text: async () => skillMdContent,
|
|
};
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found", json: async () => ({}) };
|
|
});
|
|
|
|
const { fetchAllSources, BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
|
|
const { resolveSkillCacheDir } = await import("../home-paths.js");
|
|
|
|
const source = BUILT_IN_SOURCES.find((s) => s.id === "schwepps-skills")!;
|
|
await fetchAllSources([source]);
|
|
|
|
// The skill id should be schwepps-skills/cached-skill
|
|
const skillId = "schwepps-skills/cached-skill";
|
|
const cacheDir = resolveSkillCacheDir(skillId, sha);
|
|
const cachedPath = path.join(cacheDir, "SKILL.md");
|
|
|
|
expect(existsSync(cachedPath)).toBe(true);
|
|
const content = await readFile(cachedPath, "utf-8");
|
|
expect(content).toBe(skillMdContent);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 5: skill_files rows inserted for each cached file
|
|
// -------------------------------------------------------------------------
|
|
it("Test 5: skill_files rows are inserted for each cached file with path, kind, and size_bytes", async () => {
|
|
const sha = "aabbccdd1234567890abcdef1234567890abcdef";
|
|
|
|
fetchSpy.mockImplementation(async (url: string) => {
|
|
const urlStr = String(url);
|
|
|
|
if (urlStr.includes("/git/trees/")) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
tree: [{ path: "files-skill/SKILL.md", type: "blob", size: 350 }],
|
|
}),
|
|
};
|
|
}
|
|
if (urlStr.includes("/commits/")) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ sha }),
|
|
};
|
|
}
|
|
if (urlStr.includes("SKILL.md")) {
|
|
return {
|
|
ok: true,
|
|
text: async () => mockSkillMd("Files Skill", "Tests skill_files rows"),
|
|
};
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found", json: async () => ({}) };
|
|
});
|
|
|
|
const { fetchAllSources, BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
|
|
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
|
|
const { skillFiles } = await import("../services/skill-registry-schema.js");
|
|
|
|
const source = BUILT_IN_SOURCES.find((s) => s.id === "schwepps-skills")!;
|
|
await fetchAllSources([source]);
|
|
|
|
const db = await getSkillRegistryDb();
|
|
const files = await db.select().from(skillFiles);
|
|
expect(files.length).toBeGreaterThan(0);
|
|
|
|
const skillMdFile = files.find((f) => f.path.endsWith("SKILL.md"));
|
|
expect(skillMdFile).toBeDefined();
|
|
expect(skillMdFile!.kind).toBe("skill");
|
|
expect(skillMdFile!.sizeBytes).toBeGreaterThan(0);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 6: Re-fetching with same SHA is idempotent
|
|
// -------------------------------------------------------------------------
|
|
it("Test 6: Re-fetching with same SHA skips re-download (idempotent)", async () => {
|
|
const sha = "idemidemidem1234567890abcdef1234567890ab";
|
|
|
|
let fetchCallCount = 0;
|
|
fetchSpy.mockImplementation(async (url: string) => {
|
|
const urlStr = String(url);
|
|
fetchCallCount++;
|
|
|
|
if (urlStr.includes("/git/trees/")) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
tree: [{ path: "idem-skill/SKILL.md", type: "blob", size: 100 }],
|
|
}),
|
|
};
|
|
}
|
|
if (urlStr.includes("/commits/")) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ sha }),
|
|
};
|
|
}
|
|
if (urlStr.includes("SKILL.md")) {
|
|
return {
|
|
ok: true,
|
|
text: async () => mockSkillMd("Idem Skill", "Tests idempotency"),
|
|
};
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found", json: async () => ({}) };
|
|
});
|
|
|
|
const { fetchAllSources, BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
|
|
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
|
|
const { skillVersions } = await import("../services/skill-registry-schema.js");
|
|
|
|
const source = BUILT_IN_SOURCES.find((s) => s.id === "schwepps-skills")!;
|
|
|
|
// First fetch
|
|
await fetchAllSources([source]);
|
|
const firstCount = fetchCallCount;
|
|
|
|
// Second fetch with same SHA — should not re-download SKILL.md
|
|
fetchCallCount = 0;
|
|
await fetchAllSources([source]);
|
|
|
|
const db = await getSkillRegistryDb();
|
|
const versions = await db.select().from(skillVersions);
|
|
|
|
// Only 1 version row for the same SHA (idempotent insert)
|
|
const idemVersions = versions.filter((v) => v.version === sha);
|
|
expect(idemVersions.length).toBe(1);
|
|
|
|
// Second run should have fewer fetch calls (no SKILL.md re-download)
|
|
expect(fetchCallCount).toBeLessThan(firstCount);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 7: BUILT_IN_SOURCES has exactly 3 entries
|
|
// -------------------------------------------------------------------------
|
|
it("Test 7: BUILT_IN_SOURCES contains 3 entries (anthropic-official, schwepps-skills, daymade-skills)", async () => {
|
|
const { BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
|
|
|
|
expect(BUILT_IN_SOURCES).toHaveLength(3);
|
|
|
|
const ids = BUILT_IN_SOURCES.map((s) => s.id);
|
|
expect(ids).toContain("anthropic-official");
|
|
expect(ids).toContain("schwepps-skills");
|
|
expect(ids).toContain("daymade-skills");
|
|
|
|
// Verify all sources have required fields
|
|
for (const source of BUILT_IN_SOURCES) {
|
|
expect(source.id).toBeTruthy();
|
|
expect(source.type).toMatch(/^(anthropic-marketplace|github-tree)$/);
|
|
expect(source.owner).toBeTruthy();
|
|
expect(source.repo).toBeTruthy();
|
|
expect(source.ref).toBeTruthy();
|
|
expect(source.label).toBeTruthy();
|
|
}
|
|
});
|
|
});
|