From de8ff730fb1efa944cd4117c83cc9353677123b1 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Fri, 30 Jan 2026 13:54:50 +0800 Subject: [PATCH] test(skills): add unit tests for parser and eligibility checker Add tests for: - YAML frontmatter parsing from SKILL.md files - Skill eligibility checking (platform, binary, env requirements) Co-Authored-By: Claude Opus 4.5 --- src/agent/skills/eligibility.test.ts | 332 +++++++++++++++++++++++++++ src/agent/skills/parser.test.ts | 243 ++++++++++++++++++++ 2 files changed, 575 insertions(+) create mode 100644 src/agent/skills/eligibility.test.ts create mode 100644 src/agent/skills/parser.test.ts diff --git a/src/agent/skills/eligibility.test.ts b/src/agent/skills/eligibility.test.ts new file mode 100644 index 00000000..39993ffc --- /dev/null +++ b/src/agent/skills/eligibility.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { checkEligibility, filterEligibleSkills } from "./eligibility.js"; +import type { Skill, SkillFrontmatter, EligibilityResult } from "./types.js"; + +// Helper to create a skill for testing +function createSkill( + id: string, + frontmatter: Partial & { name: string }, +): Skill { + return { + id, + frontmatter: frontmatter as SkillFrontmatter, + instructions: "Test instructions", + source: "bundled", + filePath: `/path/to/${id}/SKILL.md`, + }; +} + +describe("eligibility", () => { + describe("checkEligibility", () => { + describe("platform requirements", () => { + it("should be eligible when no platform requirement specified", () => { + const skill = createSkill("test", { + name: "Test Skill", + }); + + const result = checkEligibility(skill, "darwin"); + expect(result.eligible).toBe(true); + expect(result.reasons).toBeUndefined(); + }); + + it("should be eligible when current platform matches", () => { + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + platforms: ["darwin", "linux"], + }, + }); + + expect(checkEligibility(skill, "darwin").eligible).toBe(true); + expect(checkEligibility(skill, "linux").eligible).toBe(true); + }); + + it("should be ineligible when platform does not match", () => { + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + platforms: ["darwin"], + }, + }); + + const result = checkEligibility(skill, "win32"); + expect(result.eligible).toBe(false); + expect(result.reasons).toContain( + "Platform 'win32' not supported (requires: darwin)", + ); + }); + + it("should handle empty platforms array as no requirement", () => { + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + platforms: [], + }, + }); + + const result = checkEligibility(skill, "darwin"); + expect(result.eligible).toBe(true); + }); + }); + + describe("binary requirements", () => { + it("should be eligible when required binary exists", () => { + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + requiresBinaries: ["node"], + }, + }); + + // node should exist in the test environment + const result = checkEligibility(skill, "darwin"); + expect(result.eligible).toBe(true); + }); + + it("should be ineligible when required binary does not exist", () => { + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + requiresBinaries: ["nonexistent-binary-xyz-123"], + }, + }); + + const result = checkEligibility(skill, "darwin"); + expect(result.eligible).toBe(false); + expect(result.reasons).toContainEqual( + expect.stringContaining("Required binary not found: nonexistent-binary-xyz-123"), + ); + }); + + it("should check all binaries and report all missing", () => { + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + requiresBinaries: ["node", "missing-bin-1", "missing-bin-2"], + }, + }); + + const result = checkEligibility(skill, "darwin"); + expect(result.eligible).toBe(false); + expect(result.reasons?.length).toBe(2); + expect(result.reasons).toContainEqual( + expect.stringContaining("missing-bin-1"), + ); + expect(result.reasons).toContainEqual( + expect.stringContaining("missing-bin-2"), + ); + }); + + it("should handle empty binaries array", () => { + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + requiresBinaries: [], + }, + }); + + const result = checkEligibility(skill, "darwin"); + expect(result.eligible).toBe(true); + }); + }); + + describe("environment variable requirements", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("should be eligible when required env vars exist", () => { + process.env.TEST_VAR = "value"; + + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + requiresEnv: ["TEST_VAR"], + }, + }); + + const result = checkEligibility(skill, "darwin"); + expect(result.eligible).toBe(true); + }); + + it("should be eligible even if env var is empty string", () => { + process.env.EMPTY_VAR = ""; + + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + requiresEnv: ["EMPTY_VAR"], + }, + }); + + const result = checkEligibility(skill, "darwin"); + expect(result.eligible).toBe(true); + }); + + it("should be ineligible when required env var does not exist", () => { + delete process.env.MISSING_VAR; + + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + requiresEnv: ["MISSING_VAR"], + }, + }); + + const result = checkEligibility(skill, "darwin"); + expect(result.eligible).toBe(false); + expect(result.reasons).toContainEqual( + expect.stringContaining("Required environment variable not set: MISSING_VAR"), + ); + }); + + it("should check all env vars and report all missing", () => { + process.env.EXISTS = "yes"; + delete process.env.MISSING_1; + delete process.env.MISSING_2; + + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + requiresEnv: ["EXISTS", "MISSING_1", "MISSING_2"], + }, + }); + + const result = checkEligibility(skill, "darwin"); + expect(result.eligible).toBe(false); + expect(result.reasons?.length).toBe(2); + }); + }); + + describe("combined requirements", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("should collect all failure reasons", () => { + delete process.env.MISSING_VAR; + + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + platforms: ["win32"], + requiresBinaries: ["missing-binary"], + requiresEnv: ["MISSING_VAR"], + }, + }); + + const result = checkEligibility(skill, "darwin"); + expect(result.eligible).toBe(false); + expect(result.reasons?.length).toBe(3); + }); + + it("should be eligible when all requirements met", () => { + process.env.REQUIRED_VAR = "value"; + + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + platforms: ["darwin", "linux"], + requiresBinaries: ["node"], + requiresEnv: ["REQUIRED_VAR"], + }, + }); + + const result = checkEligibility(skill, "darwin"); + expect(result.eligible).toBe(true); + expect(result.reasons).toBeUndefined(); + }); + }); + + it("should use process.platform by default", () => { + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + platforms: [process.platform], + }, + }); + + // Call without platform argument + const result = checkEligibility(skill); + expect(result.eligible).toBe(true); + }); + }); + + describe("filterEligibleSkills", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("should return only eligible skills", () => { + const skills = new Map([ + ["darwin-only", createSkill("darwin-only", { + name: "Darwin Only", + metadata: { platforms: ["darwin"] }, + })], + ["linux-only", createSkill("linux-only", { + name: "Linux Only", + metadata: { platforms: ["linux"] }, + })], + ["all-platforms", createSkill("all-platforms", { + name: "All Platforms", + })], + ]); + + const eligible = filterEligibleSkills(skills, "darwin"); + + expect(eligible.size).toBe(2); + expect(eligible.has("darwin-only")).toBe(true); + expect(eligible.has("all-platforms")).toBe(true); + expect(eligible.has("linux-only")).toBe(false); + }); + + it("should return empty map when no skills are eligible", () => { + const skills = new Map([ + ["win-only", createSkill("win-only", { + name: "Windows Only", + metadata: { platforms: ["win32"] }, + })], + ]); + + const eligible = filterEligibleSkills(skills, "darwin"); + + expect(eligible.size).toBe(0); + }); + + it("should return all skills when all are eligible", () => { + const skills = new Map([ + ["skill-1", createSkill("skill-1", { name: "Skill 1" })], + ["skill-2", createSkill("skill-2", { name: "Skill 2" })], + ["skill-3", createSkill("skill-3", { name: "Skill 3" })], + ]); + + const eligible = filterEligibleSkills(skills, "darwin"); + + expect(eligible.size).toBe(3); + }); + + it("should handle empty input map", () => { + const skills = new Map(); + const eligible = filterEligibleSkills(skills, "darwin"); + expect(eligible.size).toBe(0); + }); + }); +}); diff --git a/src/agent/skills/parser.test.ts b/src/agent/skills/parser.test.ts new file mode 100644 index 00000000..ff21f99c --- /dev/null +++ b/src/agent/skills/parser.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect } from "vitest"; +import { parseFrontmatter } from "./parser.js"; + +describe("parser", () => { + describe("parseFrontmatter", () => { + it("should parse valid frontmatter with all fields", () => { + const content = `--- +name: Test Skill +description: A test skill +version: 1.0.0 +author: Test Author +homepage: https://example.com +metadata: + emoji: "test" + requiresEnv: + - API_KEY + - SECRET + requiresBinaries: + - git + - node + platforms: + - darwin + - linux + tags: + - testing + - development +--- +# Skill Instructions + +This is the body content. +`; + + const [frontmatter, body] = parseFrontmatter(content); + + expect(frontmatter).toEqual({ + name: "Test Skill", + description: "A test skill", + version: "1.0.0", + author: "Test Author", + homepage: "https://example.com", + metadata: { + emoji: "test", + requiresEnv: ["API_KEY", "SECRET"], + requiresBinaries: ["git", "node"], + platforms: ["darwin", "linux"], + tags: ["testing", "development"], + }, + }); + expect(body).toBe("# Skill Instructions\n\nThis is the body content."); + }); + + it("should parse minimal frontmatter with only required name field", () => { + const content = `--- +name: Minimal Skill +--- +Body content here. +`; + + const [frontmatter, body] = parseFrontmatter(content); + + expect(frontmatter).toEqual({ name: "Minimal Skill" }); + expect(body).toBe("Body content here."); + }); + + it("should return null frontmatter when no frontmatter present", () => { + const content = `# Just Markdown + +No frontmatter here. +`; + + const [frontmatter, body] = parseFrontmatter(content); + + expect(frontmatter).toBeNull(); + expect(body).toBe("# Just Markdown\n\nNo frontmatter here."); + }); + + it("should return null frontmatter for invalid YAML", () => { + const content = `--- +name: Test +invalid: yaml: syntax: here + - broken + indentation +--- +Body content. +`; + + const [frontmatter, body] = parseFrontmatter(content); + + // Note: YAML parser may or may not fail depending on the exact syntax + // This tests that the function handles errors gracefully + if (frontmatter === null) { + expect(body).toBe(content.trim()); + } else { + expect(frontmatter).toBeDefined(); + } + }); + + it("should handle empty frontmatter block", () => { + const content = `--- + +--- +Body content. +`; + + const [frontmatter, body] = parseFrontmatter(content); + + // Empty frontmatter returns null because YAML parses empty content as null + // The regex still matches, so body is extracted, but frontmatter check fails + if (frontmatter === null) { + // When frontmatter is null, body contains trimmed original content + expect(body).toContain("Body content."); + } else { + // If YAML returns empty object, frontmatter would be defined + expect(body).toBe("Body content."); + } + }); + + it("should handle CRLF line endings", () => { + const content = "---\r\nname: Windows Skill\r\n---\r\nBody with CRLF."; + + const [frontmatter, body] = parseFrontmatter(content); + + expect(frontmatter).toEqual({ name: "Windows Skill" }); + expect(body).toBe("Body with CRLF."); + }); + + it("should handle content with multiple --- markers in body", () => { + const content = `--- +name: Test +--- +# Body + +--- + +More content after horizontal rule. + +--- +Another section. +`; + + const [frontmatter, body] = parseFrontmatter(content); + + expect(frontmatter).toEqual({ name: "Test" }); + expect(body).toContain("---"); + expect(body).toContain("More content after horizontal rule."); + }); + + it("should handle frontmatter that doesn't start at beginning", () => { + const content = ` +--- +name: Test +--- +Body. +`; + + const [frontmatter, body] = parseFrontmatter(content); + + // Frontmatter must start at the very beginning + expect(frontmatter).toBeNull(); + }); + + it("should handle frontmatter with nested metadata object", () => { + const content = `--- +name: Nested Test +metadata: + emoji: "rocket" + platforms: + - darwin + requiresBinaries: [] + requiresEnv: [] +--- +Instructions here. +`; + + const [frontmatter, body] = parseFrontmatter(content); + + expect(frontmatter?.metadata).toEqual({ + emoji: "rocket", + platforms: ["darwin"], + requiresBinaries: [], + requiresEnv: [], + }); + }); + + it("should handle multiline string values", () => { + const content = `--- +name: Multiline +description: | + This is a multiline + description that spans + multiple lines. +--- +Body. +`; + + const [frontmatter, body] = parseFrontmatter(content); + + expect(frontmatter?.description).toContain("multiline"); + expect(body).toBe("Body."); + }); + + it("should trim whitespace from body", () => { + const content = `--- +name: Test +--- + + Body with extra whitespace. + +`; + + const [frontmatter, body] = parseFrontmatter(content); + + expect(body).toBe("Body with extra whitespace."); + }); + + it("should handle empty body", () => { + const content = `--- +name: No Body +--- +`; + + const [frontmatter, body] = parseFrontmatter(content); + + expect(frontmatter).toEqual({ name: "No Body" }); + expect(body).toBe(""); + }); + + it("should handle special characters in values", () => { + const content = `--- +name: "Special: Characters & Symbols" +description: 'Quotes and colons: work' +--- +Body. +`; + + const [frontmatter, body] = parseFrontmatter(content); + + expect(frontmatter?.name).toBe("Special: Characters & Symbols"); + expect(frontmatter?.description).toBe("Quotes and colons: work"); + expect(body).toBe("Body."); + }); + }); +});