test: unit tests for disk skills loader + catalog merge (11 cases)

This commit is contained in:
Prakhar Pandey 2026-07-06 19:42:00 +05:30
parent 43590ca6d9
commit b8b4589ff2
2 changed files with 371 additions and 0 deletions

View file

@ -0,0 +1,223 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// SKILL_ROOTS is computed when disk-loader.js loads, from WorkDir (resolved in
// config.js from ROWBOAT_WORKDIR) and homedir(). Both overrides must be in
// place before the module is imported — hence resetModules + dynamic import
// per test, mirroring filesystem/files.test.ts.
let tmpDir: string;
let workDir: string;
let fakeHomeDir: string;
let rowboatSkillsRoot: string;
let agentsSkillsRoot: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disk-skills-test-"));
workDir = path.join(tmpDir, "rowboat");
fakeHomeDir = path.join(tmpDir, "home");
rowboatSkillsRoot = path.join(workDir, "skills");
agentsSkillsRoot = path.join(fakeHomeDir, ".agents", "skills");
process.env.ROWBOAT_WORKDIR = workDir;
vi.resetModules();
// config.js kicks off these imports on load; stub them so tests stay
// hermetic (no git init or Today.md migration against the temp workdir).
vi.doMock("../../../knowledge/version_history.js", () => ({
commitAll: vi.fn(async () => undefined),
initRepo: vi.fn(async () => undefined),
}));
vi.doMock("../../../knowledge/deprecate_today_note.js", () => ({
deprecateTodayNote: vi.fn(async () => undefined),
}));
// Point homedir() at the temp dir so the ~/.agents/skills root never touches
// the developer's real home directory.
vi.doMock("node:os", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:os")>();
return {
...actual,
homedir: () => fakeHomeDir,
default: { ...actual, homedir: () => fakeHomeDir },
};
});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
delete process.env.ROWBOAT_WORKDIR;
vi.doUnmock("../../../knowledge/version_history.js");
vi.doUnmock("../../../knowledge/deprecate_today_note.js");
vi.doUnmock("node:os");
vi.resetModules();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
async function loadDiskLoader() {
return import("./disk-loader.js");
}
function writeSkill(root: string, folder: string, contents: string): string {
const dir = path.join(root, folder);
fs.mkdirSync(dir, { recursive: true });
const skillFile = path.join(dir, "SKILL.md");
fs.writeFileSync(skillFile, contents);
return skillFile;
}
describe("SKILL_ROOTS", () => {
it("derives the rowboat root from ROWBOAT_WORKDIR and the agents root from homedir", async () => {
const { SKILL_ROOTS } = await loadDiskLoader();
expect(SKILL_ROOTS).toEqual([rowboatSkillsRoot, agentsSkillsRoot]);
});
});
describe("loadDiskSkills", () => {
it("loads a valid skill with id/title/summary, raw content, and absolute paths", async () => {
const raw = [
"---",
"name: Test Skill",
"description: Does test things.",
"---",
"",
"# Test Skill",
"",
"Body of the skill.",
"",
].join("\n");
const skillFile = writeSkill(rowboatSkillsRoot, "test-skill", raw);
const { loadDiskSkills } = await loadDiskLoader();
const skills = loadDiskSkills();
expect(skills).toHaveLength(1);
const skill = skills[0];
expect(skill.id).toBe("test-skill");
expect(skill.title).toBe("Test Skill");
expect(skill.summary).toBe("Does test things.");
expect(skill.content).toBe(raw);
expect(skill.dir).toBe(path.join(rowboatSkillsRoot, "test-skill"));
expect(path.isAbsolute(skill.dir)).toBe(true);
expect(skill.skillFile).toBe(skillFile);
expect(path.isAbsolute(skill.skillFile)).toBe(true);
});
it("handles folded multi-line descriptions and nested metadata blocks", async () => {
const raw = [
"---",
"name: vercel-deploy",
"description: >-",
" Deploy a project to Vercel",
" with zero configuration.",
"metadata:",
" category: deployment",
" tags:",
" - vercel",
" - deploy",
"---",
"",
"Instructions here.",
"",
].join("\n");
writeSkill(rowboatSkillsRoot, "vercel-deploy", raw);
const { loadDiskSkills } = await loadDiskLoader();
const skills = loadDiskSkills();
expect(skills).toHaveLength(1);
expect(skills[0].title).toBe("vercel-deploy");
expect(skills[0].summary).toBe("Deploy a project to Vercel with zero configuration.");
expect(skills[0].content).toBe(raw);
});
it("skips skills missing name or description without throwing", async () => {
writeSkill(rowboatSkillsRoot, "no-description", [
"---",
"name: No Description",
"---",
"Body.",
].join("\n"));
writeSkill(rowboatSkillsRoot, "no-name", [
"---",
"description: Has no name.",
"---",
"Body.",
].join("\n"));
writeSkill(rowboatSkillsRoot, "valid", [
"---",
"name: Valid",
"description: The only loadable one.",
"---",
"Body.",
].join("\n"));
const { loadDiskSkills } = await loadDiskLoader();
const skills = loadDiskSkills();
expect(skills.map((s) => s.id)).toEqual(["valid"]);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("Skipping 'no-description'"),
);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("Skipping 'no-name'"),
);
});
it("skips folders without a SKILL.md", async () => {
fs.mkdirSync(path.join(rowboatSkillsRoot, "empty-folder"), { recursive: true });
fs.writeFileSync(path.join(rowboatSkillsRoot, "stray-file.md"), "not a skill folder");
const { loadDiskSkills } = await loadDiskLoader();
expect(loadDiskSkills()).toEqual([]);
});
it("prefers the rowboat root when the same skill id exists in both roots", async () => {
writeSkill(rowboatSkillsRoot, "dup", [
"---",
"name: Rowboat Dup",
"description: From the rowboat root.",
"---",
"Rowboat body.",
].join("\n"));
writeSkill(agentsSkillsRoot, "dup", [
"---",
"name: Agents Dup",
"description: From the agents root.",
"---",
"Agents body.",
].join("\n"));
const { loadDiskSkills } = await loadDiskLoader();
const skills = loadDiskSkills();
expect(skills).toHaveLength(1);
expect(skills[0].title).toBe("Rowboat Dup");
expect(skills[0].dir).toBe(path.join(rowboatSkillsRoot, "dup"));
});
it("returns [] when the roots do not exist", async () => {
// config.js creates the rowboat skills dir on load, so import first, then
// remove it. The agents root was never created.
const { loadDiskSkills } = await loadDiskLoader();
fs.rmSync(rowboatSkillsRoot, { recursive: true, force: true });
expect(loadDiskSkills()).toEqual([]);
});
it("slugifies folder names into skill ids", async () => {
writeSkill(rowboatSkillsRoot, "My_Skill Name", [
"---",
"name: My Skill",
"description: Slug test.",
"---",
"Body.",
].join("\n"));
const { loadDiskSkills } = await loadDiskLoader();
const skills = loadDiskSkills();
expect(skills).toHaveLength(1);
expect(skills[0].id).toBe("my-skill-name");
});
});

View file

@ -0,0 +1,148 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// index.js scans the disk skill roots at module init, and the roots are fixed
// when disk-loader.js/config.js load — so the ROWBOAT_WORKDIR and homedir
// overrides must be in place before the dynamic import in each test, mirroring
// filesystem/files.test.ts.
let tmpDir: string;
let workDir: string;
let fakeHomeDir: string;
let rowboatSkillsRoot: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "skills-index-test-"));
workDir = path.join(tmpDir, "rowboat");
fakeHomeDir = path.join(tmpDir, "home");
rowboatSkillsRoot = path.join(workDir, "skills");
process.env.ROWBOAT_WORKDIR = workDir;
vi.resetModules();
// config.js kicks off these imports on load; stub them so tests stay
// hermetic (no git init or Today.md migration against the temp workdir).
vi.doMock("../../../knowledge/version_history.js", () => ({
commitAll: vi.fn(async () => undefined),
initRepo: vi.fn(async () => undefined),
}));
vi.doMock("../../../knowledge/deprecate_today_note.js", () => ({
deprecateTodayNote: vi.fn(async () => undefined),
}));
// Point homedir() at the temp dir so the ~/.agents/skills root never touches
// the developer's real home directory.
vi.doMock("node:os", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:os")>();
return {
...actual,
homedir: () => fakeHomeDir,
default: { ...actual, homedir: () => fakeHomeDir },
};
});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
delete process.env.ROWBOAT_WORKDIR;
vi.doUnmock("../../../knowledge/version_history.js");
vi.doUnmock("../../../knowledge/deprecate_today_note.js");
vi.doUnmock("node:os");
vi.resetModules();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
async function loadSkillsIndex() {
return import("./index.js");
}
function writeSkill(folder: string, contents: string): string {
const dir = path.join(rowboatSkillsRoot, folder);
fs.mkdirSync(dir, { recursive: true });
const skillFile = path.join(dir, "SKILL.md");
fs.writeFileSync(skillFile, contents);
return skillFile;
}
describe("skills index (disk skill merge layer)", () => {
it("includes disk skills in the catalog and resolves them by id and file path", async () => {
const raw = [
"---",
"name: Ship It",
"description: Ship the thing to production.",
"---",
"",
"# Ship It",
"",
"Disk skill body.",
"",
].join("\n");
const skillFile = writeSkill("ship-it", raw);
const skills = await loadSkillsIndex();
expect(skills.availableSkills).toContain("ship-it");
const catalog = skills.buildSkillCatalog();
expect(catalog).toContain("## Ship It");
expect(catalog).toContain(skillFile);
expect(catalog).toContain("Ship the thing to production.");
expect(skills.resolveSkill("ship-it")?.content).toBe(raw);
expect(skills.resolveSkill(skillFile)?.content).toBe(raw);
expect(skills.resolveSkill("ship-it")?.catalogPath).toBe(skillFile);
});
it("keeps the bundled skill when a disk skill uses the same id", async () => {
writeSkill("meeting-prep", [
"---",
"name: Shadow Meeting Prep",
"description: Disk impostor.",
"---",
"DISK CONTENT MARKER",
].join("\n"));
const skills = await loadSkillsIndex();
const resolved = skills.resolveSkill("meeting-prep");
expect(resolved?.catalogPath).toBe("src/application/assistant/skills/meeting-prep/skill.ts");
expect(resolved?.content).not.toContain("DISK CONTENT MARKER");
const catalog = skills.buildSkillCatalog();
expect(catalog).toContain("## Meeting Prep");
expect(catalog).not.toContain("Shadow Meeting Prep");
expect(skills.availableSkills.filter((id) => id === "meeting-prep")).toHaveLength(1);
});
it("refreshDiskSkills picks up skills added and removed on disk", async () => {
const skills = await loadSkillsIndex();
expect(skills.resolveSkill("late-arrival")).toBeNull();
expect(skills.availableSkills).not.toContain("late-arrival");
const raw = [
"---",
"name: Late Arrival",
"description: Added after module init.",
"---",
"Late body.",
].join("\n");
const skillFile = writeSkill("late-arrival", raw);
// Not visible until refreshed.
expect(skills.resolveSkill("late-arrival")).toBeNull();
expect(skills.buildSkillCatalog()).not.toContain("## Late Arrival");
expect(skills.refreshDiskSkills()).toBe(1);
expect(skills.availableSkills).toContain("late-arrival");
expect(skills.buildSkillCatalog()).toContain("## Late Arrival");
expect(skills.resolveSkill("late-arrival")?.content).toBe(raw);
expect(skills.resolveSkill(skillFile)?.content).toBe(raw);
fs.rmSync(path.join(rowboatSkillsRoot, "late-arrival"), { recursive: true, force: true });
expect(skills.refreshDiskSkills()).toBe(0);
expect(skills.availableSkills).not.toContain("late-arrival");
expect(skills.buildSkillCatalog()).not.toContain("## Late Arrival");
expect(skills.resolveSkill("late-arrival")).toBeNull();
expect(skills.resolveSkill(skillFile)).toBeNull();
});
});