test(09-02): add failing tests for skill-registry-fetcher
- 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)
This commit is contained in:
parent
4ed3cce0a1
commit
b79fc29d1a
1 changed files with 404 additions and 0 deletions
404
server/src/__tests__/skill-registry-fetch.test.ts
Normal file
404
server/src/__tests__/skill-registry-fetch.test.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue