124 lines
4.8 KiB
TypeScript
124 lines
4.8 KiB
TypeScript
/**
|
||
* #15 skill requirement badges: GET /api/skills annotates each skill with
|
||
* `requirements: {satisfied, missingEnv, missingBins} | null` (badge-only v1).
|
||
* Env keys count as present via process.env OR the vault (D2); POST /api/vault
|
||
* invalidates the requirement cache so a new key shows on the next fetch.
|
||
*/
|
||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { MindDB } from '@waggle/core';
|
||
import { clearSkillRequirementsCache } from '@waggle/agent';
|
||
import { buildLocalServer } from '../src/local/index.js';
|
||
import { injectWithAuth } from './test-utils.js';
|
||
import type { FastifyInstance } from 'fastify';
|
||
|
||
const MISSING_BIN = 'waggle-no-such-bin-15';
|
||
const PRESENT_ENV = 'WAGGLE_REQ_TEST_PRESENT_15';
|
||
const MISSING_ENV = 'WAGGLE_REQ_TEST_MISSING_15';
|
||
const VAULT_KEY = 'waggle-req-test-vault-key-15';
|
||
|
||
interface SkillRowShape {
|
||
name: string;
|
||
requirements: { satisfied: boolean; missingEnv: string[]; missingBins: string[] } | null;
|
||
}
|
||
|
||
describe('skill requirements route annotation (#15)', () => {
|
||
let server: FastifyInstance;
|
||
let tmpDir: string;
|
||
|
||
beforeAll(async () => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-skill-req-'));
|
||
new MindDB(path.join(tmpDir, 'personal.mind')).close();
|
||
const skillsDir = path.join(tmpDir, 'skills');
|
||
fs.mkdirSync(skillsDir, { recursive: true });
|
||
fs.writeFileSync(path.join(skillsDir, 'plain.md'), '# Plain\n\nNo requirements declared.', 'utf-8');
|
||
fs.writeFileSync(
|
||
path.join(skillsDir, 'needs-setup.md'),
|
||
`---\ndescription: needs env + bin\nrequires:\n env: [${MISSING_ENV}]\n bins: [${MISSING_BIN}]\n---\n\n# Needs Setup\n\nBody.`,
|
||
'utf-8',
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(skillsDir, 'env-ok.md'),
|
||
`---\ndescription: env satisfied via process.env, bin via PATH\nrequires:\n env: [${PRESENT_ENV}]\n bins: [node]\n---\n\n# Env OK\n\nBody.`,
|
||
'utf-8',
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(skillsDir, 'vault-ok.md'),
|
||
`---\ndescription: env satisfied via vault key\nrequires:\n env: [${VAULT_KEY}]\n---\n\n# Vault OK\n\nBody.`,
|
||
'utf-8',
|
||
);
|
||
server = await buildLocalServer({ dataDir: tmpDir });
|
||
// Full-suite runs saturate the CPU during collect; server boot can exceed
|
||
// vitest's 10s default hook timeout (observed 2026-07-15 full-suite flake).
|
||
}, 60_000);
|
||
|
||
afterAll(async () => {
|
||
await server.close();
|
||
delete process.env[PRESENT_ENV];
|
||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||
}, 60_000);
|
||
|
||
beforeEach(() => {
|
||
clearSkillRequirementsCache();
|
||
});
|
||
|
||
const getSkills = async (): Promise<SkillRowShape[]> => {
|
||
const res = await injectWithAuth(server, { method: 'GET', url: '/api/skills' });
|
||
expect(res.statusCode).toBe(200);
|
||
return JSON.parse(res.body).skills as SkillRowShape[];
|
||
};
|
||
|
||
it('skill without a requires block gets requirements: null', async () => {
|
||
const rows = await getSkills();
|
||
const plain = rows.find(r => r.name === 'plain');
|
||
expect(plain).toBeDefined();
|
||
expect(plain!.requirements).toBeNull();
|
||
});
|
||
|
||
it('missing env key and missing bin are both listed, satisfied=false', async () => {
|
||
const rows = await getSkills();
|
||
const needs = rows.find(r => r.name === 'needs-setup');
|
||
expect(needs!.requirements).toEqual({
|
||
satisfied: false,
|
||
missingEnv: [MISSING_ENV],
|
||
missingBins: [MISSING_BIN],
|
||
});
|
||
});
|
||
|
||
it('env key in process.env + bin on PATH satisfy the requirement', async () => {
|
||
process.env[PRESENT_ENV] = '1';
|
||
const rows = await getSkills();
|
||
const envOk = rows.find(r => r.name === 'env-ok');
|
||
expect(envOk!.requirements).toEqual({ satisfied: true, missingEnv: [], missingBins: [] });
|
||
});
|
||
|
||
it('a vault key satisfies an env requirement (D2: process.env ∪ vault)', async () => {
|
||
server.vault?.delete(VAULT_KEY);
|
||
let rows = await getSkills();
|
||
expect(rows.find(r => r.name === 'vault-ok')!.requirements!.satisfied).toBe(false);
|
||
|
||
server.vault?.set(VAULT_KEY, 'secret-value');
|
||
rows = await getSkills();
|
||
expect(rows.find(r => r.name === 'vault-ok')!.requirements).toEqual({
|
||
satisfied: true, missingEnv: [], missingBins: [],
|
||
});
|
||
});
|
||
|
||
it('POST /api/vault flips the badge on the next fetch', async () => {
|
||
server.vault?.delete(VAULT_KEY);
|
||
let rows = await getSkills();
|
||
expect(rows.find(r => r.name === 'vault-ok')!.requirements!.satisfied).toBe(false);
|
||
|
||
const post = await injectWithAuth(server, {
|
||
method: 'POST', url: '/api/vault',
|
||
payload: { name: VAULT_KEY, value: 'added-via-api' },
|
||
});
|
||
expect(post.statusCode).toBe(200);
|
||
|
||
rows = await getSkills();
|
||
expect(rows.find(r => r.name === 'vault-ok')!.requirements!.satisfied).toBe(true);
|
||
});
|
||
});
|