From b8b4589ff255ef97cf05921c5ef74d8c71848210 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Mon, 6 Jul 2026 19:42:00 +0530 Subject: [PATCH] test: unit tests for disk skills loader + catalog merge (11 cases) --- .../assistant/skills/disk-loader.test.ts | 223 ++++++++++++++++++ .../assistant/skills/index.test.ts | 148 ++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 apps/x/packages/core/src/application/assistant/skills/disk-loader.test.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/index.test.ts diff --git a/apps/x/packages/core/src/application/assistant/skills/disk-loader.test.ts b/apps/x/packages/core/src/application/assistant/skills/disk-loader.test.ts new file mode 100644 index 00000000..b0682c62 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/disk-loader.test.ts @@ -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(); + 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"); + }); +}); diff --git a/apps/x/packages/core/src/application/assistant/skills/index.test.ts b/apps/x/packages/core/src/application/assistant/skills/index.test.ts new file mode 100644 index 00000000..e8b4e6ed --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/index.test.ts @@ -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(); + 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(); + }); +});