import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { loadSkillsAcross } from './skills/load'; import { loadAffordancesAcross } from './affordances/load'; import { loadPluginEntries } from './notifications/catalogue'; /** * A plugin's capabilities are READ where they live rather than copied into `capabilities/` * (docs/plugin-capabilities.md). These pin the three properties that were not obvious. * * `capabilityRoots` itself is not tested here — it reads the real database and the real plugin * directory, so a test of it would assert whatever this machine happens to have installed. What is worth * pinning is what the loaders do with a list once they have one, and that is pure. */ const seed = (files: Record): string => { const dir = mkdtempSync(join(tmpdir(), 'zz-caps-')); for (const [rel, body] of Object.entries(files)) { const abs = join(dir, rel); mkdirSync(join(abs, '..'), { recursive: true }); writeFileSync(abs, body); } return dir; }; const skill = (name: string, desc: string) => `---\nname: ${name}\ndescription: ${desc}\n---\n\nbody\n`; describe('skills merge across sources', () => { test('a plugin directory contributes its skills', () => { const owner = seed({ 'mine/SKILL.md': skill('Mine', 'the owner wrote this') }); const plugin = seed({ 'theirs/SKILL.md': skill('Theirs', 'a plugin shipped this') }); try { const ids = loadSkillsAcross([owner, plugin]).map((s) => s.id).sort(); expect(ids).toEqual(['mine', 'theirs']); } finally { rmSync(owner, { recursive: true, force: true }); rmSync(plugin, { recursive: true, force: true }); } }); // Precedence, and the reason for it: a plugin must not be able to take over the meaning of a name its // owner is already using, just by picking the same directory name. test("the owner's own wins a name collision", () => { const owner = seed({ 'deploy/SKILL.md': skill('Deploy', 'the owner version') }); const plugin = seed({ 'deploy/SKILL.md': skill('Deploy', 'the plugin version') }); try { const found = loadSkillsAcross([owner, plugin]); expect(found).toHaveLength(1); expect(found[0]!.description).toBe('the owner version'); } finally { rmSync(owner, { recursive: true, force: true }); rmSync(plugin, { recursive: true, force: true }); } }); // Which is what makes disable mean disable: the caller simply stops passing that root. test('dropping a source drops its skills', () => { const owner = seed({ 'mine/SKILL.md': skill('Mine', 'kept') }); const plugin = seed({ 'theirs/SKILL.md': skill('Theirs', 'gone when disabled') }); try { expect(loadSkillsAcross([owner, plugin])).toHaveLength(2); expect(loadSkillsAcross([owner])).toHaveLength(1); } finally { rmSync(owner, { recursive: true, force: true }); rmSync(plugin, { recursive: true, force: true }); } }); test('a source that does not exist is skipped, not fatal', () => { const owner = seed({ 'mine/SKILL.md': skill('Mine', 'kept') }); try { expect(loadSkillsAcross([owner, join(tmpdir(), 'zz-caps-nope')])).toHaveLength(1); } finally { rmSync(owner, { recursive: true, force: true }); } }); }); describe('affordances are not de-duplicated', () => { // Unlike skills: an affordance is guidance, several can be true at once, and two plugins describing the // same surface differently is information rather than a collision. test('both sources contribute even with the same file name', () => { const a = seed({ 'x/one.md': '---\nname: A\ndescription: from a\n---\nbody a\n' }); const b = seed({ 'y/one.md': '---\nname: B\ndescription: from b\n---\nbody b\n' }); try { expect(loadAffordancesAcross([a, b])).toHaveLength(2); } finally { rmSync(a, { recursive: true, force: true }); rmSync(b, { recursive: true, force: true }); } }); }); describe('notification codes come from the manifest, not the path', () => { // The whole reason loadPluginEntries exists. Copied to capabilities/ the path carried // /; read in place it carries nothing, so the identity has to travel separately. test('a plugin in its own directory still gets a namespaced code', () => { const dir = seed({ 'notifications/catalogue.yaml': "nowPlaying:\n title: 'Now playing'\n" }); try { const entries = loadPluginEntries({ appName: 'music', dir, manifest: { publisher: 'officerdev' } }); expect(Object.keys(entries)).toEqual(['officerdev/music:nowPlaying']); expect(entries['officerdev/music:nowPlaying']!.thread).toBe('officerdev/music'); } finally { rmSync(dir, { recursive: true, force: true }); } }); // The property that must survive the move: a plugin cannot claim the platform's voice by writing a // core-looking key into its own yaml. test('a yaml key cannot claim another namespace', () => { const dir = seed({ 'notifications/c.yaml': "officer/core:test:\n title: 'Nice try'\n" }); try { const keys = Object.keys(loadPluginEntries({ appName: 'x', dir, manifest: { publisher: 'someone' } })); expect(keys).toEqual(['someone/x:officer/core:test']); } finally { rmSync(dir, { recursive: true, force: true }); } }); });