mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
test: unit tests for disk skills loader + catalog merge (11 cases)
This commit is contained in:
parent
43590ca6d9
commit
b8b4589ff2
2 changed files with 371 additions and 0 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue