singularity-forge/src/resources/extensions/sf/tests/skills.test.mjs

395 lines
12 KiB
JavaScript
Raw Normal View History

/**
* Skills system tests.
*
* Purpose: verify skill discovery, frontmatter parsing, validation, and loading.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { discoverSkillDirs, readSkillFile } from "../skills/directory.js";
import {
buildSkillRecord,
parseSkillFrontmatter,
validateSkillFrontmatter,
} from "../skills/frontmatter.js";
import {
getModelInvocableSkills,
getPermittedSkills,
getUserInvocableSkills,
loadSkills,
} from "../skills/loader.js";
import { getSkillSearchDirs } from "../preferences-skills.js";
describe("skill frontmatter", () => {
test("parseSkillFrontmatter_extracts_yaml_and_body", () => {
const content = `---
name: test-skill
description: A test skill
user-invocable: true
model-invocable: false
side-effects: none
permission-profile: restricted
---
# Test Skill
Some instructions.
`;
const parsed = parseSkillFrontmatter(content);
expect(parsed).toBeTruthy();
expect(parsed.frontmatter.name).toBe("test-skill");
expect(parsed.frontmatter.description).toBe("A test skill");
expect(parsed.frontmatter["user-invocable"]).toBe(true);
expect(parsed.frontmatter["model-invocable"]).toBe(false);
expect(parsed.frontmatter["side-effects"]).toBe("none");
expect(parsed.frontmatter["permission-profile"]).toBe("restricted");
expect(parsed.body).toContain("# Test Skill");
});
test("parseSkillFrontmatter_returns_null_without_frontmatter", () => {
const parsed = parseSkillFrontmatter("# Just markdown\n\nNo frontmatter.");
expect(parsed).toBeNull();
});
test("validateSkillFrontmatter_passes_complete_frontmatter", () => {
const result = validateSkillFrontmatter({
name: "test",
description: "desc",
"user-invocable": true,
"model-invocable": false,
"side-effects": "none",
"permission-profile": "normal",
});
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test("validateSkillFrontmatter_passes_minimal_ecosystem_frontmatter", () => {
// Ecosystem standard: only name + description required
const result = validateSkillFrontmatter({
name: "test",
description: "desc",
});
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test("validateSkillFrontmatter_fails_missing_fields", () => {
const result = validateSkillFrontmatter({
name: "test",
});
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors.some((e) => e.includes("description"))).toBe(true);
});
test("validateSkillFrontmatter_fails_invalid_permission_profile", () => {
const result = validateSkillFrontmatter({
name: "test",
description: "desc",
"user-invocable": true,
"model-invocable": false,
"side-effects": "none",
"permission-profile": "invalid",
});
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("permission-profile"))).toBe(
true,
);
});
test("buildSkillRecord_creates_canonical_record", () => {
const record = buildSkillRecord(
"/path/to/skill",
{
name: "test-skill",
description: "A test",
"user-invocable": true,
"model-invocable": true,
"side-effects": "code-edits",
"permission-profile": "normal",
triggers: ["build", "code"],
"max-activations": 5,
},
"# Body",
);
expect(record.name).toBe("test-skill");
expect(record.userInvocable).toBe(true);
expect(record.modelInvocable).toBe(true);
expect(record.sideEffects).toBe("code-edits");
expect(record.permissionProfile).toBe("normal");
expect(record.triggers).toEqual(["build", "code"]);
expect(record.maxActivations).toBe(5);
expect(record.body).toBe("# Body");
expect(record.path).toBe("/path/to/skill");
});
});
describe("skill discovery", () => {
let tmpDir;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "sf-skills-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("discoverSkillDirs_finds_skills_with_skill_md", () => {
const skillDir = join(tmpDir, ".agents", "skills", "test-skill");
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(skillDir, "SKILL.md"),
`---\nname: test-skill\ndescription: Test\nuser-invocable: true\nmodel-invocable: true\nside-effects: none\npermission-profile: restricted\n---\n\n# Test\n`,
);
const dirs = discoverSkillDirs(tmpDir);
expect(dirs).toHaveLength(1);
expect(dirs[0].name).toBe("test-skill");
});
test("discoverSkillDirs_ignores_dirs_without_skill_md", () => {
const skillDir = join(tmpDir, ".agents", "skills", "empty-dir");
mkdirSync(skillDir, { recursive: true });
const dirs = discoverSkillDirs(tmpDir);
expect(dirs).toHaveLength(0);
});
test("readSkillFile_reads_content", () => {
const skillDir = join(tmpDir, ".agents", "skills", "test-skill");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "# Test Skill\n");
const content = readSkillFile(skillDir);
expect(content).toBe("# Test Skill\n");
});
test("readSkillFile_returns_null_for_missing", () => {
const content = readSkillFile(join(tmpDir, "nonexistent"));
expect(content).toBeNull();
});
});
describe("skill loading", () => {
let tmpDir;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "sf-skills-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
function createSkill(name, overrides = {}) {
const skillDir = join(tmpDir, ".agents", "skills", name);
mkdirSync(skillDir, { recursive: true });
const frontmatter = {
name,
description: overrides.description ?? `Description for ${name}`,
"user-invocable": overrides.userInvocable ?? true,
"model-invocable": overrides.modelInvocable ?? true,
"side-effects": overrides.sideEffects ?? "none",
"permission-profile": overrides.permissionProfile ?? "normal",
triggers: overrides.triggers ?? ["*"],
...overrides.extra,
};
const yaml = Object.entries(frontmatter)
.map(([k, v]) => {
if (Array.isArray(v))
return `${k}:\n${v.map((i) => ` - ${i}`).join("\n")}`;
return `${k}: ${v}`;
})
.join("\n");
writeFileSync(
join(skillDir, "SKILL.md"),
`---\n${yaml}\n---\n\n# ${name}\n`,
);
}
test("loadSkills_loads_valid_skills", () => {
createSkill("skill-a");
createSkill("skill-b", { permissionProfile: "trusted" });
const skills = loadSkills(tmpDir, { includeWorkflow: false });
expect(skills).toHaveLength(2);
expect(skills.every((s) => s.valid)).toBe(true);
expect(skills.some((s) => s.name === "skill-a")).toBe(true);
expect(skills.some((s) => s.name === "skill-b")).toBe(true);
});
test("loadSkills_project_skill_uses_repo_overlay_without_hidden_locking", () => {
createSkill("sf-repo-orientation");
const skills = loadSkills(tmpDir, {
includeWorkflow: true,
includeBundled: true,
});
const repoMap = skills.filter((s) => s.name === "sf-repo-orientation");
expect(repoMap).toHaveLength(1);
expect(repoMap[0].source).toBe("project");
});
test("loadSkills_marks_invalid_skills", () => {
createSkill("valid-skill");
const badDir = join(tmpDir, ".agents", "skills", "bad-skill");
mkdirSync(badDir, { recursive: true });
writeFileSync(join(badDir, "SKILL.md"), "No frontmatter here.");
const skills = loadSkills(tmpDir, { includeWorkflow: false });
expect(skills).toHaveLength(2);
const bad = skills.find((s) => s.name === "bad-skill");
expect(bad).toBeTruthy();
expect(bad.valid).toBe(false);
});
test("getPermittedSkills_filters_by_profile", () => {
createSkill("restricted-skill", { permissionProfile: "restricted" });
createSkill("normal-skill", { permissionProfile: "normal" });
createSkill("trusted-skill", { permissionProfile: "trusted" });
const skills = loadSkills(tmpDir, { includeWorkflow: false });
const permitted = getPermittedSkills(skills, "normal");
expect(permitted).toHaveLength(2);
expect(permitted.some((s) => s.name === "restricted-skill")).toBe(true);
expect(permitted.some((s) => s.name === "normal-skill")).toBe(true);
expect(permitted.some((s) => s.name === "trusted-skill")).toBe(false);
});
test("getModelInvocableSkills_filters_by_work_mode", () => {
createSkill("build-skill", { triggers: ["build"], modelInvocable: true });
createSkill("review-skill", { triggers: ["review"], modelInvocable: true });
createSkill("universal-skill", { triggers: ["*"], modelInvocable: true });
createSkill("user-only", { triggers: ["*"], modelInvocable: false });
const skills = loadSkills(tmpDir, { includeWorkflow: false });
const buildSkills = getModelInvocableSkills(skills, "build");
expect(buildSkills).toHaveLength(2);
expect(buildSkills.some((s) => s.name === "build-skill")).toBe(true);
expect(buildSkills.some((s) => s.name === "universal-skill")).toBe(true);
expect(buildSkills.some((s) => s.name === "review-skill")).toBe(false);
expect(buildSkills.some((s) => s.name === "user-only")).toBe(false);
});
test("getUserInvocableSkills_excludes_locked_and_non_canonical_bundled", () => {
createSkill("human-facing", { userInvocable: true });
createSkill("autoresearch", { userInvocable: false });
const badDir = join(tmpDir, ".agents", "skills", "droid-evolved");
mkdirSync(badDir, { recursive: true });
writeFileSync(
join(badDir, "SKILL.md"),
`---\nname: droid-evolved\ndescription: Workflow-only skill\nuser-invocable: true\n---\n\n# Invalid\n`,
);
const visible = getUserInvocableSkills([
...loadSkills(tmpDir, { includeWorkflow: false }),
{
name: "create-skill",
source: "bundled",
valid: true,
userInvocable: true,
locked: false,
},
{
name: "review",
source: "bundled",
valid: true,
userInvocable: true,
locked: false,
},
{
name: "bundled-locked-system",
source: "bundled",
valid: true,
userInvocable: true,
locked: true,
},
{
name: "project-sf-command-surface",
source: "project",
valid: true,
userInvocable: true,
locked: false,
},
]);
// Locked skills are invisible. Only create-skill is a default bundled
// user skill; project/user skills can still appear.
const names = visible.map((s) => s.name).sort();
expect(names).toContain("create-skill");
expect(names).not.toContain("review");
expect(names).toContain("project-sf-command-surface");
expect(names).not.toContain("bundled-locked-system");
});
test("loadSkills_default_catalog_exposes_only_create_skill_as_bundled_user_skill", () => {
const visible = getUserInvocableSkills(
loadSkills(tmpDir, {
includeBundled: true,
}),
).filter((s) => s.source === "bundled");
expect(visible.map((s) => s.name)).toEqual(["create-skill"]);
});
test("loadSkills_workflow_skills_are_hidden_system_records", () => {
const skills = loadSkills(tmpDir, {
includeBundled: false,
includeWorkflow: true,
});
const workflow = skills.filter((s) => s.source === "workflow");
const visible = getUserInvocableSkills(skills);
expect(workflow.length).toBeGreaterThan(0);
expect(workflow.every((s) => s.valid)).toBe(true);
expect(workflow.every((s) => s.userInvocable === false)).toBe(true);
expect(workflow.some((s) => s.name === "sf-repo-orientation")).toBe(true);
expect(visible.some((s) => s.source === "workflow")).toBe(false);
});
test("buildSkillRecord_sets_locked_from_frontmatter", () => {
const locked = buildSkillRecord(
"/p",
{ name: "x", description: "y", locked: true },
"",
);
expect(locked.locked).toBe(true);
const unlocked = buildSkillRecord(
"/p",
{ name: "x", description: "y" },
"",
);
expect(unlocked.locked).toBe(false);
});
test("project_skill_can_use_name_that_matches_hidden_workflow_pattern", () => {
// Hidden workflow patterns are product-owned and not part of the user
// skill catalog, so `.agents/skills/observe-first` remains a normal
// project skill name.
createSkill("observe-first");
const skills = loadSkills(tmpDir, { includeBundled: false });
const observeFirst = skills.filter((s) => s.name === "observe-first");
expect(observeFirst).toHaveLength(1);
expect(observeFirst[0].source).toBe("project");
expect(observeFirst[0].locked).toBe(false);
});
});
describe("skill preference resolution", () => {
test("getSkillSearchDirs_prefers_project_over_user_for_bare_names", () => {
const dirs = getSkillSearchDirs("/repo");
expect(dirs.map((d) => d.method).slice(0, 2)).toEqual([
"project-skill",
"user-skill",
]);
expect(dirs[0].dir).toBe(join("/repo", ".agents", "skills"));
expect(dirs.some((d) => d.dir.includes(".claude"))).toBe(false);
});
});