/** * #15 skill requirement badges (v1 badge-only): extraction from frontmatter + * presence checks with injectable deps + the bin-lookup TTL cache. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { extractSkillRequirements, checkSkillRequirements, clearSkillRequirementsCache, } from '../src/skill-requirements.js'; beforeEach(() => { clearSkillRequirementsCache(); }); describe('extractSkillRequirements', () => { it('returns null when the skill declares no requires block', () => { expect(extractSkillRequirements('---\nname: Plain\n---\n# body')).toBeNull(); expect(extractSkillRequirements('# no frontmatter at all')).toBeNull(); }); it('normalizes a requires block to always-present env/bins arrays', () => { const content = `--- name: Video requires: bins: [ffmpeg] --- body`; expect(extractSkillRequirements(content)).toEqual({ env: [], bins: ['ffmpeg'] }); }); }); describe('checkSkillRequirements', () => { it('reports satisfied when every env key and bin is present', async () => { const status = await checkSkillRequirements( { env: ['A_KEY'], bins: ['ffmpeg'] }, { hasEnv: () => true, hasBin: async () => true }, ); expect(status).toEqual({ satisfied: true, missingEnv: [], missingBins: [] }); }); it('lists missing env keys', async () => { const status = await checkSkillRequirements( { env: ['PRESENT', 'ABSENT'], bins: [] }, { hasEnv: (k) => k === 'PRESENT', hasBin: async () => true }, ); expect(status.satisfied).toBe(false); expect(status.missingEnv).toEqual(['ABSENT']); expect(status.missingBins).toEqual([]); }); it('lists missing bins', async () => { const status = await checkSkillRequirements( { env: [], bins: ['git', 'not-a-bin'] }, { hasEnv: () => true, hasBin: async (n) => n === 'git' }, ); expect(status.satisfied).toBe(false); expect(status.missingBins).toEqual(['not-a-bin']); }); it('empty requirements are trivially satisfied', async () => { const status = await checkSkillRequirements({ env: [], bins: [] }); expect(status).toEqual({ satisfied: true, missingEnv: [], missingBins: [] }); }); it('default hasEnv reads process.env', async () => { process.env.WAGGLE_TEST_REQ_15 = '1'; try { const status = await checkSkillRequirements({ env: ['WAGGLE_TEST_REQ_15'], bins: [] }); expect(status.satisfied).toBe(true); } finally { delete process.env.WAGGLE_TEST_REQ_15; } }); it('caches bin lookups within the TTL', async () => { const hasBin = vi.fn(async () => true); await checkSkillRequirements({ env: [], bins: ['ffmpeg'] }, { hasBin }); await checkSkillRequirements({ env: [], bins: ['ffmpeg'] }, { hasBin }); expect(hasBin).toHaveBeenCalledTimes(1); }); it('re-runs bin lookups after the TTL expires', async () => { const hasBin = vi.fn(async () => false); let clock = 1_000_000; const now = () => clock; await checkSkillRequirements({ env: [], bins: ['ffmpeg'] }, { hasBin, now }); clock += 5 * 60_000 + 1; // past the 5-min TTL await checkSkillRequirements({ env: [], bins: ['ffmpeg'] }, { hasBin, now }); expect(hasBin).toHaveBeenCalledTimes(2); }); it('clearSkillRequirementsCache forces a fresh bin lookup', async () => { const hasBin = vi.fn(async () => false); await checkSkillRequirements({ env: [], bins: ['ffmpeg'] }, { hasBin }); clearSkillRequirementsCache(); await checkSkillRequirements({ env: [], bins: ['ffmpeg'] }, { hasBin }); expect(hasBin).toHaveBeenCalledTimes(2); }); });