352 lines
10 KiB
TypeScript
352 lines
10 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { parseSkillFrontmatter, nextScope, serializeFrontmatter, SKILL_SCOPE_ORDER } from '../src/skill-frontmatter.js';
|
|
|
|
describe('parseSkillFrontmatter', () => {
|
|
it('parses frontmatter with name and description', () => {
|
|
const content = `---
|
|
name: Deploy Helper
|
|
description: Helps deploy applications
|
|
---
|
|
|
|
# Deploy Helper
|
|
Some content here.`;
|
|
|
|
const { frontmatter, body } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.name).toBe('Deploy Helper');
|
|
expect(frontmatter.description).toBe('Helps deploy applications');
|
|
});
|
|
|
|
it('parses permissions block correctly', () => {
|
|
const content = `---
|
|
permissions:
|
|
network: true
|
|
codeExecution: true
|
|
fileSystem: false
|
|
---
|
|
|
|
# Skill`;
|
|
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.permissions).toBeDefined();
|
|
expect(frontmatter.permissions!.network).toBe(true);
|
|
expect(frontmatter.permissions!.codeExecution).toBe(true);
|
|
expect(frontmatter.permissions!.fileSystem).toBe(false);
|
|
});
|
|
|
|
it('returns empty frontmatter when no delimiters', () => {
|
|
const content = `# Just a Skill
|
|
|
|
No frontmatter here.`;
|
|
|
|
const { frontmatter, body } = parseSkillFrontmatter(content);
|
|
expect(frontmatter).toEqual({});
|
|
expect(body).toBe(content);
|
|
});
|
|
|
|
it('returns empty frontmatter when no closing delimiter', () => {
|
|
const content = `---
|
|
name: Broken
|
|
description: Missing closing delimiter
|
|
|
|
# Skill Content`;
|
|
|
|
const { frontmatter, body } = parseSkillFrontmatter(content);
|
|
expect(frontmatter).toEqual({});
|
|
expect(body).toBe(content);
|
|
});
|
|
|
|
it('body is content after frontmatter', () => {
|
|
const content = `---
|
|
name: Test
|
|
---
|
|
# The Body
|
|
|
|
This is the body content.`;
|
|
|
|
const { body } = parseSkillFrontmatter(content);
|
|
expect(body).toContain('# The Body');
|
|
expect(body).toContain('This is the body content.');
|
|
expect(body).not.toContain('name: Test');
|
|
});
|
|
|
|
it('mixed true/false permissions parsed correctly', () => {
|
|
const content = `---
|
|
permissions:
|
|
fileSystem: true
|
|
network: false
|
|
codeExecution: true
|
|
externalServices: false
|
|
secrets: true
|
|
browserAutomation: false
|
|
---
|
|
|
|
# Skill`;
|
|
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
const perms = frontmatter.permissions!;
|
|
expect(perms.fileSystem).toBe(true);
|
|
expect(perms.network).toBe(false);
|
|
expect(perms.codeExecution).toBe(true);
|
|
expect(perms.externalServices).toBe(false);
|
|
expect(perms.secrets).toBe(true);
|
|
expect(perms.browserAutomation).toBe(false);
|
|
});
|
|
|
|
it('ignores comments in frontmatter', () => {
|
|
const content = `---
|
|
# This is a comment
|
|
name: My Skill
|
|
# Another comment
|
|
description: Does things
|
|
---
|
|
|
|
# Content`;
|
|
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.name).toBe('My Skill');
|
|
expect(frontmatter.description).toBe('Does things');
|
|
});
|
|
|
|
it('works with content that has no frontmatter (backward compat)', () => {
|
|
const content = `# Research Team -- Multi-Agent Investigation
|
|
|
|
A coordinated 3-agent research workflow.
|
|
|
|
## When to use
|
|
- Complex research questions`;
|
|
|
|
const { frontmatter, body } = parseSkillFrontmatter(content);
|
|
expect(frontmatter).toEqual({});
|
|
expect(body).toBe(content);
|
|
});
|
|
|
|
it('parses all fields together (name, description, permissions)', () => {
|
|
const content = `---
|
|
name: Full Skill
|
|
description: A skill with everything
|
|
permissions:
|
|
network: true
|
|
secrets: true
|
|
---
|
|
|
|
# Full Skill
|
|
Content.`;
|
|
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.name).toBe('Full Skill');
|
|
expect(frontmatter.description).toBe('A skill with everything');
|
|
expect(frontmatter.permissions!.network).toBe(true);
|
|
expect(frontmatter.permissions!.secrets).toBe(true);
|
|
});
|
|
|
|
it('handles permissions block followed by other keys', () => {
|
|
const content = `---
|
|
permissions:
|
|
fileSystem: true
|
|
name: After Permissions
|
|
---
|
|
|
|
# Skill`;
|
|
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.permissions!.fileSystem).toBe(true);
|
|
expect(frontmatter.name).toBe('After Permissions');
|
|
});
|
|
|
|
// Skills 2.0 gap E — scope + promotion metadata
|
|
describe('scope + promoted_from (gap E)', () => {
|
|
it('parses an explicit scope field', () => {
|
|
const content = `---
|
|
name: Team Skill
|
|
scope: team
|
|
---
|
|
|
|
# body`;
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.scope).toBe('team');
|
|
});
|
|
|
|
it('ignores an invalid scope value', () => {
|
|
const content = `---
|
|
name: X
|
|
scope: nonsense
|
|
---
|
|
body`;
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.scope).toBeUndefined();
|
|
});
|
|
|
|
it('parses promoted_from as a JSON-array', () => {
|
|
const content = `---
|
|
name: Promoted
|
|
scope: team
|
|
promoted_from: [personal, workspace]
|
|
---
|
|
body`;
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.promoted_from).toEqual(['personal', 'workspace']);
|
|
});
|
|
|
|
it('tolerates missing scope (backwards-compat)', () => {
|
|
const content = `---
|
|
name: Legacy
|
|
description: no scope field
|
|
---
|
|
body`;
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.scope).toBeUndefined();
|
|
expect(frontmatter.name).toBe('Legacy');
|
|
});
|
|
});
|
|
|
|
describe('provenance — initiator + source (P5/D4)', () => {
|
|
it('parses initiator and source', () => {
|
|
const content = `---
|
|
name: Agent Skill
|
|
initiator: agent
|
|
source: chat
|
|
---
|
|
body`;
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.initiator).toBe('agent');
|
|
expect(frontmatter.source).toBe('chat');
|
|
});
|
|
|
|
it('ignores an invalid initiator value', () => {
|
|
const content = `---
|
|
name: X
|
|
initiator: robot
|
|
---
|
|
body`;
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.initiator).toBeUndefined();
|
|
});
|
|
|
|
it('leaves initiator/source undefined when absent (legacy = user)', () => {
|
|
const content = `---
|
|
name: Legacy
|
|
---
|
|
body`;
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.initiator).toBeUndefined();
|
|
expect(frontmatter.source).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// #15 requirement badges — requires: { env, bins }
|
|
describe('requires — env + bins (#15)', () => {
|
|
it('parses a requires block with env and bins', () => {
|
|
const content = `---
|
|
name: Video Tool
|
|
requires:
|
|
env: [OPENAI_API_KEY, REPLICATE_API_TOKEN]
|
|
bins: [ffmpeg, git]
|
|
---
|
|
body`;
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.requires).toEqual({
|
|
env: ['OPENAI_API_KEY', 'REPLICATE_API_TOKEN'],
|
|
bins: ['ffmpeg', 'git'],
|
|
});
|
|
});
|
|
|
|
it('parses an env-only requires block (comma-separated form)', () => {
|
|
const content = `---
|
|
requires:
|
|
env: OPENAI_API_KEY, TAVILY_API_KEY
|
|
---
|
|
body`;
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.requires).toEqual({ env: ['OPENAI_API_KEY', 'TAVILY_API_KEY'] });
|
|
});
|
|
|
|
it('leaves requires undefined when absent or empty', () => {
|
|
const { frontmatter: absent } = parseSkillFrontmatter('---\nname: X\n---\nbody');
|
|
expect(absent.requires).toBeUndefined();
|
|
const { frontmatter: empty } = parseSkillFrontmatter('---\nrequires:\nname: X\n---\nbody');
|
|
expect(empty.requires).toBeUndefined();
|
|
expect(empty.name).toBe('X');
|
|
});
|
|
|
|
it('requires block coexists with a permissions block', () => {
|
|
const content = `---
|
|
permissions:
|
|
network: true
|
|
requires:
|
|
bins: [docker]
|
|
name: Both
|
|
---
|
|
body`;
|
|
const { frontmatter } = parseSkillFrontmatter(content);
|
|
expect(frontmatter.permissions!.network).toBe(true);
|
|
expect(frontmatter.requires).toEqual({ bins: ['docker'] });
|
|
expect(frontmatter.name).toBe('Both');
|
|
});
|
|
});
|
|
|
|
describe('nextScope', () => {
|
|
it('returns the next scope up', () => {
|
|
expect(nextScope('personal')).toBe('workspace');
|
|
expect(nextScope('workspace')).toBe('team');
|
|
expect(nextScope('team')).toBe('enterprise');
|
|
});
|
|
it('returns null at the top', () => {
|
|
expect(nextScope('enterprise')).toBeNull();
|
|
});
|
|
it('SKILL_SCOPE_ORDER is stable', () => {
|
|
expect(SKILL_SCOPE_ORDER).toEqual(['personal', 'workspace', 'team', 'enterprise']);
|
|
});
|
|
});
|
|
|
|
describe('serializeFrontmatter', () => {
|
|
it('round-trips scope + promoted_from through parse', () => {
|
|
const out = serializeFrontmatter(
|
|
{ name: 'RT', description: 'round-trip', scope: 'team', promoted_from: ['personal', 'workspace'] },
|
|
'# body line 1',
|
|
);
|
|
const { frontmatter, body } = parseSkillFrontmatter(out);
|
|
expect(frontmatter.name).toBe('RT');
|
|
expect(frontmatter.scope).toBe('team');
|
|
expect(frontmatter.promoted_from).toEqual(['personal', 'workspace']);
|
|
expect(body).toBe('# body line 1');
|
|
});
|
|
|
|
it('omits scope/promoted_from when absent', () => {
|
|
const out = serializeFrontmatter({ name: 'NoScope' }, 'body');
|
|
expect(out).not.toContain('scope:');
|
|
expect(out).not.toContain('promoted_from:');
|
|
expect(out).toContain('name: NoScope');
|
|
});
|
|
|
|
it('round-trips initiator + source through parse (P5/D4)', () => {
|
|
const out = serializeFrontmatter(
|
|
{ name: 'Prov', initiator: 'agent', source: 'chat' },
|
|
'# body',
|
|
);
|
|
const { frontmatter } = parseSkillFrontmatter(out);
|
|
expect(frontmatter.initiator).toBe('agent');
|
|
expect(frontmatter.source).toBe('chat');
|
|
});
|
|
|
|
it('omits initiator/source when absent', () => {
|
|
const out = serializeFrontmatter({ name: 'NoProv' }, 'body');
|
|
expect(out).not.toContain('initiator:');
|
|
expect(out).not.toContain('source:');
|
|
});
|
|
|
|
it('round-trips requires through parse (#15)', () => {
|
|
const out = serializeFrontmatter(
|
|
{ name: 'Req', requires: { env: ['OPENAI_API_KEY'], bins: ['ffmpeg', 'git'] } },
|
|
'# body',
|
|
);
|
|
const { frontmatter } = parseSkillFrontmatter(out);
|
|
expect(frontmatter.requires).toEqual({ env: ['OPENAI_API_KEY'], bins: ['ffmpeg', 'git'] });
|
|
});
|
|
|
|
it('omits requires when absent or empty (#15)', () => {
|
|
expect(serializeFrontmatter({ name: 'NoReq' }, 'body')).not.toContain('requires:');
|
|
expect(serializeFrontmatter({ name: 'EmptyReq', requires: {} }, 'body')).not.toContain('requires:');
|
|
});
|
|
});
|
|
});
|