This commit is contained in:
177
packages/sdk/tests/plugin-manager.test.ts
Normal file
177
packages/sdk/tests/plugin-manager.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { validatePluginManifest } from '../src/plugin-manifest.js';
|
||||
import { PluginManager } from '../src/plugin-manager.js';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-plugin-test-'));
|
||||
}
|
||||
|
||||
function writePluginJson(dir: string, manifest: Record<string, unknown>): void {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'plugin.json'), JSON.stringify(manifest), 'utf-8');
|
||||
}
|
||||
|
||||
const VALID_MANIFEST = {
|
||||
name: 'test-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'A test plugin',
|
||||
skills: ['summarize'],
|
||||
};
|
||||
|
||||
describe('validatePluginManifest', () => {
|
||||
it('validates a correct manifest', () => {
|
||||
const result = validatePluginManifest(VALID_MANIFEST);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects manifest with missing name', () => {
|
||||
const result = validatePluginManifest({
|
||||
version: '1.0.0',
|
||||
description: 'No name',
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('name'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects manifest with missing version', () => {
|
||||
const result = validatePluginManifest({
|
||||
name: 'test',
|
||||
description: 'No version',
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('version'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects manifest with missing description', () => {
|
||||
const result = validatePluginManifest({
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('description'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects path traversal and separator characters in plugin names', () => {
|
||||
for (const name of ['../escape', 'nested/plugin', 'C:\\escape', '..']) {
|
||||
const result = validatePluginManifest({
|
||||
name,
|
||||
version: '1.0.0',
|
||||
description: 'Unsafe name',
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('filesystem-safe'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates manifest with mcpServers', () => {
|
||||
const result = validatePluginManifest({
|
||||
name: 'mcp-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'Has MCP servers',
|
||||
mcpServers: [{ name: 'server1', command: 'npx', args: ['serve'] }],
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid mcpServers entries', () => {
|
||||
const result = validatePluginManifest({
|
||||
name: 'bad-mcp',
|
||||
version: '1.0.0',
|
||||
description: 'Bad MCP',
|
||||
mcpServers: [{ name: '', command: '' }],
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginManager', () => {
|
||||
let pluginsDir: string;
|
||||
let manager: PluginManager;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
pluginsDir = makeTempDir();
|
||||
tempDirs.push(pluginsDir);
|
||||
manager = new PluginManager(pluginsDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
it('starts with no plugins', () => {
|
||||
expect(manager.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('installs a local plugin', () => {
|
||||
const sourceDir = makeTempDir();
|
||||
tempDirs.push(sourceDir);
|
||||
writePluginJson(sourceDir, VALID_MANIFEST);
|
||||
|
||||
manager.installLocal(sourceDir);
|
||||
|
||||
const plugins = manager.list();
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0].name).toBe('test-plugin');
|
||||
expect(plugins[0].version).toBe('1.0.0');
|
||||
|
||||
// Verify plugin files were copied
|
||||
const copiedManifest = path.join(pluginsDir, 'test-plugin', 'plugin.json');
|
||||
expect(fs.existsSync(copiedManifest)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects installing a plugin with invalid manifest', () => {
|
||||
const sourceDir = makeTempDir();
|
||||
tempDirs.push(sourceDir);
|
||||
writePluginJson(sourceDir, { name: '', version: '1.0.0', description: '' });
|
||||
|
||||
expect(() => manager.installLocal(sourceDir)).toThrow('Invalid plugin manifest');
|
||||
});
|
||||
|
||||
it('rejects installing from directory without plugin.json', () => {
|
||||
const sourceDir = makeTempDir();
|
||||
tempDirs.push(sourceDir);
|
||||
|
||||
expect(() => manager.installLocal(sourceDir)).toThrow('No plugin.json found');
|
||||
});
|
||||
|
||||
it('uninstalls a plugin', () => {
|
||||
const sourceDir = makeTempDir();
|
||||
tempDirs.push(sourceDir);
|
||||
writePluginJson(sourceDir, VALID_MANIFEST);
|
||||
|
||||
manager.installLocal(sourceDir);
|
||||
expect(manager.list()).toHaveLength(1);
|
||||
|
||||
manager.uninstall('test-plugin');
|
||||
expect(manager.list()).toHaveLength(0);
|
||||
|
||||
// Verify plugin directory was removed
|
||||
expect(fs.existsSync(path.join(pluginsDir, 'test-plugin'))).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when uninstalling a plugin that is not installed', () => {
|
||||
expect(() => manager.uninstall('nonexistent')).toThrow('not installed');
|
||||
});
|
||||
|
||||
it('persists registry across instances', () => {
|
||||
const sourceDir = makeTempDir();
|
||||
tempDirs.push(sourceDir);
|
||||
writePluginJson(sourceDir, VALID_MANIFEST);
|
||||
|
||||
manager.installLocal(sourceDir);
|
||||
|
||||
// Create a new manager instance pointing at the same directory
|
||||
const manager2 = new PluginManager(pluginsDir);
|
||||
expect(manager2.list()).toHaveLength(1);
|
||||
expect(manager2.list()[0].name).toBe('test-plugin');
|
||||
});
|
||||
});
|
||||
440
packages/sdk/tests/plugin-runtime.test.ts
Normal file
440
packages/sdk/tests/plugin-runtime.test.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
PluginRuntime,
|
||||
PluginRuntimeManager,
|
||||
webResearchPluginManifest,
|
||||
type PluginManifestWithTools,
|
||||
type PluginLifecycleState,
|
||||
} from '../src/plugin-runtime.js';
|
||||
import { PluginManager } from '../src/plugin-manager.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function simpleManifest(overrides: Partial<PluginManifestWithTools> = {}): PluginManifestWithTools {
|
||||
return {
|
||||
name: 'test-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'A test plugin',
|
||||
skills: ['test-skill'],
|
||||
tools: [
|
||||
{
|
||||
name: 'test_tool',
|
||||
description: 'A test tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginRuntime — single plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('PluginRuntime', () => {
|
||||
it('starts in installed state after construction', () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
expect(runtime.getState()).toBe('installed');
|
||||
});
|
||||
|
||||
it('enable() transitions to enabled then active', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
const states: PluginLifecycleState[] = [];
|
||||
runtime.on('stateChange', (e: { to: PluginLifecycleState }) => states.push(e.to));
|
||||
|
||||
await runtime.enable();
|
||||
|
||||
expect(runtime.getState()).toBe('active');
|
||||
expect(states).toEqual(['enabled', 'active']);
|
||||
});
|
||||
|
||||
it('active plugin exposes contributed tools', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
|
||||
const tools = runtime.getContributedTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('test_tool');
|
||||
expect(tools[0].description).toBe('A test tool');
|
||||
expect(typeof tools[0].execute).toBe('function');
|
||||
});
|
||||
|
||||
it('active plugin exposes contributed skills', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
|
||||
const skills = runtime.getContributedSkills();
|
||||
expect(skills).toEqual(['test-skill']);
|
||||
});
|
||||
|
||||
it('disable() removes tools and skills, transitions to disabled', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
|
||||
runtime.disable();
|
||||
|
||||
expect(runtime.getState()).toBe('disabled');
|
||||
expect(runtime.getContributedTools()).toEqual([]);
|
||||
expect(runtime.getContributedSkills()).toEqual([]);
|
||||
});
|
||||
|
||||
it('re-enable after disable works', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
runtime.disable();
|
||||
expect(runtime.getState()).toBe('disabled');
|
||||
|
||||
await runtime.enable();
|
||||
expect(runtime.getState()).toBe('active');
|
||||
expect(runtime.getContributedTools()).toHaveLength(1);
|
||||
expect(runtime.getContributedSkills()).toEqual(['test-skill']);
|
||||
});
|
||||
|
||||
it('enters error state on activation failure (missing capability)', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest(), {
|
||||
requiredCapabilities: ['network'],
|
||||
availableCapabilities: [],
|
||||
});
|
||||
|
||||
await expect(runtime.enable()).rejects.toThrow('Missing required capability: network');
|
||||
expect(runtime.getState()).toBe('error');
|
||||
});
|
||||
|
||||
it('can re-enable from error state', async () => {
|
||||
// Use a counter-based executor that throws on first activation, succeeds on second
|
||||
let callCount = 0;
|
||||
const flakyExecutor = (def: { name: string; description: string; parameters: Record<string, unknown> }) => {
|
||||
callCount++;
|
||||
if (callCount <= 1) {
|
||||
throw new Error('Transient activation failure');
|
||||
}
|
||||
return async () => 'ok';
|
||||
};
|
||||
|
||||
const runtime = new PluginRuntime(simpleManifest(), {
|
||||
toolExecutor: flakyExecutor,
|
||||
});
|
||||
|
||||
// First enable() fails — toolExecutor throws during activation
|
||||
await expect(runtime.enable()).rejects.toThrow('Transient activation failure');
|
||||
expect(runtime.getState()).toBe('error');
|
||||
|
||||
// Second enable() succeeds — same runtime, error → enabled → active
|
||||
await runtime.enable();
|
||||
expect(runtime.getState()).toBe('active');
|
||||
expect(runtime.getContributedTools()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('enable() on already-active plugin is a no-op', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
expect(runtime.getState()).toBe('active');
|
||||
|
||||
const stateChanges: string[] = [];
|
||||
runtime.on('stateChange', (e: { to: string }) => stateChanges.push(e.to));
|
||||
|
||||
await runtime.enable(); // no-op
|
||||
expect(runtime.getState()).toBe('active');
|
||||
expect(stateChanges).toEqual([]); // no transitions fired
|
||||
});
|
||||
|
||||
it('disable() on already-disabled plugin is a no-op', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
runtime.disable();
|
||||
expect(runtime.getState()).toBe('disabled');
|
||||
|
||||
const stateChanges: string[] = [];
|
||||
runtime.on('stateChange', (e: { to: string }) => stateChanges.push(e.to));
|
||||
|
||||
runtime.disable(); // no-op
|
||||
expect(runtime.getState()).toBe('disabled');
|
||||
expect(stateChanges).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits stateChange events with from/to', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
const events: Array<{ plugin: string; from: string; to: string }> = [];
|
||||
runtime.on('stateChange', (e) => events.push(e));
|
||||
|
||||
await runtime.enable();
|
||||
|
||||
expect(events).toEqual([
|
||||
{ plugin: 'test-plugin', from: 'installed', to: 'enabled' },
|
||||
{ plugin: 'test-plugin', from: 'enabled', to: 'active' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits error event on activation failure', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest(), {
|
||||
requiredCapabilities: ['gpu'],
|
||||
availableCapabilities: [],
|
||||
});
|
||||
|
||||
const errors: Array<{ plugin: string; error: Error }> = [];
|
||||
runtime.on('error', (e) => errors.push(e));
|
||||
|
||||
await runtime.enable().catch(() => {}); // swallow throw
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].plugin).toBe('test-plugin');
|
||||
expect(errors[0].error.message).toContain('gpu');
|
||||
});
|
||||
|
||||
it('uses custom tool executor when provided', async () => {
|
||||
const customExecutor = vi.fn(() => async () => 'custom-result');
|
||||
const runtime = new PluginRuntime(simpleManifest(), { toolExecutor: customExecutor });
|
||||
await runtime.enable();
|
||||
|
||||
const tools = runtime.getContributedTools();
|
||||
expect(customExecutor).toHaveBeenCalledTimes(1);
|
||||
const result = await tools[0].execute({});
|
||||
expect(result).toBe('custom-result');
|
||||
});
|
||||
|
||||
it('default executor returns JSON with tool name and args', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
|
||||
const result = await runtime.getContributedTools()[0].execute({ foo: 'bar' });
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ tool: 'test_tool', args: { foo: 'bar' }, status: 'executed' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginRuntimeManager — multi-plugin management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('PluginRuntimeManager', () => {
|
||||
it('registers a plugin in installed state', () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest());
|
||||
|
||||
const states = mgr.getPluginStates();
|
||||
expect(states['test-plugin']).toBe('installed');
|
||||
});
|
||||
|
||||
it('registers multiple plugins', () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest({ name: 'plugin-a' }));
|
||||
mgr.register(simpleManifest({ name: 'plugin-b' }));
|
||||
|
||||
const states = mgr.getPluginStates();
|
||||
expect(Object.keys(states)).toHaveLength(2);
|
||||
expect(states['plugin-a']).toBe('installed');
|
||||
expect(states['plugin-b']).toBe('installed');
|
||||
});
|
||||
|
||||
it('throws on duplicate registration', () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest());
|
||||
expect(() => mgr.register(simpleManifest())).toThrow('already registered');
|
||||
});
|
||||
|
||||
it('enables and activates a plugin', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest());
|
||||
|
||||
await mgr.enable('test-plugin');
|
||||
expect(mgr.getPluginStates()['test-plugin']).toBe('active');
|
||||
});
|
||||
|
||||
it('disables a plugin', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest());
|
||||
await mgr.enable('test-plugin');
|
||||
|
||||
mgr.disable('test-plugin');
|
||||
expect(mgr.getPluginStates()['test-plugin']).toBe('disabled');
|
||||
});
|
||||
|
||||
it('getActive() returns only active plugins', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest({ name: 'active-one' }));
|
||||
mgr.register(simpleManifest({ name: 'inactive-one' }));
|
||||
|
||||
await mgr.enable('active-one');
|
||||
|
||||
const active = mgr.getActive();
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].getManifest().name).toBe('active-one');
|
||||
});
|
||||
|
||||
it('getAllTools() aggregates tools from all active plugins', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(
|
||||
simpleManifest({
|
||||
name: 'plugin-a',
|
||||
tools: [{ name: 'tool_a', description: 'Tool A', parameters: {} }],
|
||||
})
|
||||
);
|
||||
mgr.register(
|
||||
simpleManifest({
|
||||
name: 'plugin-b',
|
||||
tools: [{ name: 'tool_b', description: 'Tool B', parameters: {} }],
|
||||
})
|
||||
);
|
||||
|
||||
await mgr.enable('plugin-a');
|
||||
await mgr.enable('plugin-b');
|
||||
|
||||
const tools = mgr.getAllTools();
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools.map((t) => t.name).sort()).toEqual(['tool_a', 'tool_b']);
|
||||
});
|
||||
|
||||
it('getAllSkills() aggregates skills from all active plugins', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest({ name: 'p1', skills: ['skill-a'] }));
|
||||
mgr.register(simpleManifest({ name: 'p2', skills: ['skill-b', 'skill-c'] }));
|
||||
|
||||
await mgr.enable('p1');
|
||||
await mgr.enable('p2');
|
||||
|
||||
const skills = mgr.getAllSkills();
|
||||
expect(skills.sort()).toEqual(['skill-a', 'skill-b', 'skill-c']);
|
||||
});
|
||||
|
||||
it('getPluginStates() returns all plugin states', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest({ name: 'a' }));
|
||||
mgr.register(simpleManifest({ name: 'b' }));
|
||||
mgr.register(simpleManifest({ name: 'c' }));
|
||||
|
||||
await mgr.enable('a');
|
||||
await mgr.enable('b');
|
||||
mgr.disable('b');
|
||||
|
||||
expect(mgr.getPluginStates()).toEqual({
|
||||
a: 'active',
|
||||
b: 'disabled',
|
||||
c: 'installed',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards stateChange events from plugins', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest());
|
||||
|
||||
const events: Array<{ plugin: string; to: string }> = [];
|
||||
mgr.on('stateChange', (e) => events.push(e));
|
||||
|
||||
await mgr.enable('test-plugin');
|
||||
|
||||
expect(events).toHaveLength(2); // enabled, active
|
||||
expect(events[0].to).toBe('enabled');
|
||||
expect(events[1].to).toBe('active');
|
||||
});
|
||||
|
||||
it('throws when enabling unregistered plugin', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
await expect(mgr.enable('nonexistent')).rejects.toThrow('not registered');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flagship plugin fixture — web-research
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('webResearchPluginManifest', () => {
|
||||
it('has correct name and tools', () => {
|
||||
expect(webResearchPluginManifest.name).toBe('web-research');
|
||||
expect(webResearchPluginManifest.tools).toHaveLength(2);
|
||||
expect(webResearchPluginManifest.tools!.map((t) => t.name)).toEqual(['web_scrape', 'web_summarize']);
|
||||
});
|
||||
|
||||
it('can be registered and activated via PluginRuntimeManager', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(webResearchPluginManifest);
|
||||
await mgr.enable('web-research');
|
||||
|
||||
const tools = mgr.getAllTools();
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(mgr.getAllSkills()).toEqual(['web-research']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginManager.toRuntimeManager — bridge from filesystem to runtime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('PluginManager.toRuntimeManager', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
function createTestPlugin(pluginsDir: string, name: string): void {
|
||||
const pluginDir = path.join(pluginsDir, name);
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
const manifest = {
|
||||
name,
|
||||
version: '1.0.0',
|
||||
description: `Test plugin ${name}`,
|
||||
skills: [`${name}-skill`],
|
||||
tools: [
|
||||
{ name: `${name}_tool`, description: `Tool from ${name}`, parameters: { type: 'object', properties: {} } },
|
||||
],
|
||||
};
|
||||
fs.writeFileSync(path.join(pluginDir, 'plugin.json'), JSON.stringify(manifest));
|
||||
}
|
||||
|
||||
it('creates a PluginRuntimeManager with all installed plugins', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-pm-bridge-'));
|
||||
const pm = new PluginManager(tmpDir);
|
||||
|
||||
// Install two test plugins
|
||||
const srcA = path.join(tmpDir, '_src_a');
|
||||
const srcB = path.join(tmpDir, '_src_b');
|
||||
createTestPlugin(tmpDir, '_src_a');
|
||||
createTestPlugin(tmpDir, '_src_b');
|
||||
|
||||
// installLocal copies and registers
|
||||
pm.installLocal(srcA);
|
||||
pm.installLocal(srcB);
|
||||
|
||||
const rtm = pm.toRuntimeManager();
|
||||
const states = rtm.getPluginStates();
|
||||
|
||||
expect(Object.keys(states)).toHaveLength(2);
|
||||
expect(states['_src_a']).toBe('installed');
|
||||
expect(states['_src_b']).toBe('installed');
|
||||
|
||||
// Clean up
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('runtime manager plugins can be enabled and contribute tools', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-pm-bridge2-'));
|
||||
const pm = new PluginManager(tmpDir);
|
||||
|
||||
const srcDir = path.join(tmpDir, '_src_plug');
|
||||
createTestPlugin(tmpDir, '_src_plug');
|
||||
pm.installLocal(srcDir);
|
||||
|
||||
const rtm = pm.toRuntimeManager();
|
||||
await rtm.enable('_src_plug');
|
||||
|
||||
const tools = rtm.getAllTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('_src_plug_tool');
|
||||
|
||||
// Clean up
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns empty manager when no plugins installed', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-pm-empty-'));
|
||||
const pm = new PluginManager(tmpDir);
|
||||
|
||||
const rtm = pm.toRuntimeManager();
|
||||
expect(Object.keys(rtm.getPluginStates())).toHaveLength(0);
|
||||
expect(rtm.getAllTools()).toEqual([]);
|
||||
|
||||
// Clean up
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
110
packages/sdk/tests/starter-skills.test.ts
Normal file
110
packages/sdk/tests/starter-skills.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
listStarterSkills,
|
||||
installStarterSkills,
|
||||
getStarterSkillsDir,
|
||||
} from '../src/starter-skills/index.js';
|
||||
|
||||
describe('starter-skills', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-starter-skills-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('getStarterSkillsDir() returns a directory that exists', () => {
|
||||
const dir = getStarterSkillsDir();
|
||||
expect(fs.existsSync(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it('listStarterSkills() returns 18 skill names', () => {
|
||||
const skills = listStarterSkills();
|
||||
expect(skills).toHaveLength(18);
|
||||
});
|
||||
|
||||
it('all expected skill names are present', () => {
|
||||
const skills = listStarterSkills();
|
||||
const expected = [
|
||||
'brainstorm',
|
||||
'catch-up',
|
||||
'code-review',
|
||||
'compare-docs',
|
||||
'daily-plan',
|
||||
'decision-matrix',
|
||||
'draft-memo',
|
||||
'explain-concept',
|
||||
'extract-actions',
|
||||
'meeting-prep',
|
||||
'plan-execute',
|
||||
'research-synthesis',
|
||||
'research-team',
|
||||
'retrospective',
|
||||
'review-pair',
|
||||
'risk-assessment',
|
||||
'status-update',
|
||||
'task-breakdown',
|
||||
];
|
||||
expect(skills).toEqual(expected);
|
||||
});
|
||||
|
||||
it('each starter skill file exists and has content (> 50 chars)', () => {
|
||||
const dir = getStarterSkillsDir();
|
||||
const skills = listStarterSkills();
|
||||
for (const name of skills) {
|
||||
const filePath = path.join(dir, `${name}.md`);
|
||||
expect(fs.existsSync(filePath), `${name}.md should exist`).toBe(true);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content.length, `${name}.md should have > 50 chars`).toBeGreaterThan(50);
|
||||
}
|
||||
});
|
||||
|
||||
it('all skill names are valid (alphanumeric + hyphens only)', () => {
|
||||
const skills = listStarterSkills();
|
||||
for (const name of skills) {
|
||||
expect(name, `${name} should match [a-z0-9-]+`).toMatch(/^[a-z0-9-]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('installStarterSkills() copies files to target directory', () => {
|
||||
const installed = installStarterSkills(tmpDir);
|
||||
expect(installed).toHaveLength(18);
|
||||
|
||||
// Verify files exist in target
|
||||
for (const name of installed) {
|
||||
const filePath = path.join(tmpDir, `${name}.md`);
|
||||
expect(fs.existsSync(filePath), `${name}.md should be installed`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('installStarterSkills() does NOT overwrite existing skills', () => {
|
||||
// Pre-create a skill with custom content
|
||||
const customContent = '# Custom catch-up skill\nMy custom version.';
|
||||
fs.writeFileSync(path.join(tmpDir, 'catch-up.md'), customContent, 'utf-8');
|
||||
|
||||
const installed = installStarterSkills(tmpDir);
|
||||
|
||||
// catch-up should NOT be in the installed list (was skipped)
|
||||
expect(installed).not.toContain('catch-up');
|
||||
expect(installed).toHaveLength(17);
|
||||
|
||||
// Verify custom content was preserved
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'catch-up.md'), 'utf-8');
|
||||
expect(content).toBe(customContent);
|
||||
});
|
||||
|
||||
it('installStarterSkills() creates target directory if missing', () => {
|
||||
const nestedDir = path.join(tmpDir, 'nested', 'skills');
|
||||
expect(fs.existsSync(nestedDir)).toBe(false);
|
||||
|
||||
const installed = installStarterSkills(nestedDir);
|
||||
expect(installed).toHaveLength(18);
|
||||
expect(fs.existsSync(nestedDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
284
packages/sdk/tests/validate-skill.test.ts
Normal file
284
packages/sdk/tests/validate-skill.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateSkillMd,
|
||||
isValidSemver,
|
||||
compareSemver,
|
||||
checkSkillDependencies,
|
||||
checkVersionDowngrade,
|
||||
} from '../src/validate-skill.js';
|
||||
|
||||
describe('validateSkillMd', () => {
|
||||
it('parses valid SKILL.md', () => {
|
||||
const content = `---
|
||||
name: summarizer
|
||||
description: Summarizes long documents
|
||||
version: 1.0.0
|
||||
author: waggle-team
|
||||
---
|
||||
|
||||
You are an expert summarizer. Given a document, produce a concise summary.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
expect(result.metadata).toBeDefined();
|
||||
expect(result.metadata!.name).toBe('summarizer');
|
||||
expect(result.metadata!.description).toBe('Summarizes long documents');
|
||||
expect(result.metadata!.version).toBe('1.0.0');
|
||||
expect(result.metadata!.author).toBe('waggle-team');
|
||||
expect(result.metadata!.systemPrompt).toBe(
|
||||
'You are an expert summarizer. Given a document, produce a concise summary.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects missing name', () => {
|
||||
const content = `---
|
||||
description: A skill without a name
|
||||
---
|
||||
|
||||
Some prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required field: name');
|
||||
});
|
||||
|
||||
it('rejects missing description', () => {
|
||||
const content = `---
|
||||
name: no-desc
|
||||
---
|
||||
|
||||
Some prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required field: description');
|
||||
});
|
||||
|
||||
it('rejects missing frontmatter', () => {
|
||||
const content = `Just a plain markdown file with no frontmatter.`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Missing YAML frontmatter (must be wrapped in --- delimiters)',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts system prompt from body', () => {
|
||||
const content = `---
|
||||
name: coder
|
||||
description: Writes code
|
||||
---
|
||||
|
||||
You are a coding assistant.
|
||||
|
||||
Always use TypeScript.
|
||||
Write clean, tested code.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.metadata!.systemPrompt).toBe(
|
||||
'You are a coding assistant.\n\nAlways use TypeScript.\nWrite clean, tested code.',
|
||||
);
|
||||
});
|
||||
|
||||
// F18: Version validation tests
|
||||
it('warns on invalid semver version', () => {
|
||||
const content = `---
|
||||
name: bad-version
|
||||
description: Has invalid version
|
||||
version: abc
|
||||
---
|
||||
|
||||
Prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true); // still valid, just warns
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain('Invalid semver version');
|
||||
expect(result.warnings[0]).toContain('abc');
|
||||
});
|
||||
|
||||
it('accepts valid semver with pre-release', () => {
|
||||
const content = `---
|
||||
name: prerelease
|
||||
description: Has pre-release version
|
||||
version: 2.0.0-beta.1
|
||||
---
|
||||
|
||||
Prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
expect(result.metadata!.version).toBe('2.0.0-beta.1');
|
||||
});
|
||||
|
||||
// F18: Dependency parsing tests
|
||||
it('parses dependencies from bracket syntax', () => {
|
||||
const content = `---
|
||||
name: researcher
|
||||
description: Research skill
|
||||
dependencies: [search_memory, save_memory, query_knowledge]
|
||||
---
|
||||
|
||||
Research prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.metadata!.dependencies).toEqual(['search_memory', 'save_memory', 'query_knowledge']);
|
||||
});
|
||||
|
||||
it('parses dependencies from comma-separated syntax', () => {
|
||||
const content = `---
|
||||
name: researcher
|
||||
description: Research skill
|
||||
dependencies: search_memory, save_memory
|
||||
---
|
||||
|
||||
Research prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.metadata!.dependencies).toEqual(['search_memory', 'save_memory']);
|
||||
});
|
||||
|
||||
it('returns undefined dependencies when field is absent', () => {
|
||||
const content = `---
|
||||
name: no-deps
|
||||
description: No dependencies
|
||||
---
|
||||
|
||||
Prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.metadata!.dependencies).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidSemver', () => {
|
||||
it('accepts standard semver', () => {
|
||||
expect(isValidSemver('1.0.0')).toBe(true);
|
||||
expect(isValidSemver('0.1.0')).toBe(true);
|
||||
expect(isValidSemver('12.34.56')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts semver with pre-release', () => {
|
||||
expect(isValidSemver('1.0.0-alpha')).toBe(true);
|
||||
expect(isValidSemver('1.0.0-beta.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts semver with build metadata', () => {
|
||||
expect(isValidSemver('1.0.0+build.123')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid semver', () => {
|
||||
expect(isValidSemver('1.0')).toBe(false);
|
||||
expect(isValidSemver('abc')).toBe(false);
|
||||
expect(isValidSemver('v1.0.0')).toBe(false);
|
||||
expect(isValidSemver('1')).toBe(false);
|
||||
expect(isValidSemver('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareSemver', () => {
|
||||
it('compares equal versions', () => {
|
||||
expect(compareSemver('1.0.0', '1.0.0')).toBe(0);
|
||||
});
|
||||
|
||||
it('compares different major versions', () => {
|
||||
expect(compareSemver('2.0.0', '1.0.0')).toBe(1);
|
||||
expect(compareSemver('1.0.0', '2.0.0')).toBe(-1);
|
||||
});
|
||||
|
||||
it('compares different minor versions', () => {
|
||||
expect(compareSemver('1.2.0', '1.1.0')).toBe(1);
|
||||
expect(compareSemver('1.0.0', '1.1.0')).toBe(-1);
|
||||
});
|
||||
|
||||
it('compares different patch versions', () => {
|
||||
expect(compareSemver('1.0.2', '1.0.1')).toBe(1);
|
||||
expect(compareSemver('1.0.0', '1.0.1')).toBe(-1);
|
||||
});
|
||||
|
||||
it('ignores pre-release when comparing', () => {
|
||||
expect(compareSemver('1.0.0-alpha', '1.0.0-beta')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSkillDependencies', () => {
|
||||
it('returns no warnings when all dependencies are available', () => {
|
||||
const metadata = {
|
||||
name: 'test-skill',
|
||||
description: 'test',
|
||||
dependencies: ['search_memory', 'save_memory'],
|
||||
systemPrompt: '',
|
||||
};
|
||||
const warnings = checkSkillDependencies(metadata, ['search_memory', 'save_memory', 'get_identity']);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warns about missing dependency tools', () => {
|
||||
const metadata = {
|
||||
name: 'test-skill',
|
||||
description: 'test',
|
||||
dependencies: ['search_memory', 'nonexistent_tool'],
|
||||
systemPrompt: '',
|
||||
};
|
||||
const warnings = checkSkillDependencies(metadata, ['search_memory', 'save_memory']);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('nonexistent_tool');
|
||||
expect(warnings[0]).toContain('not available');
|
||||
});
|
||||
|
||||
it('returns no warnings when no dependencies declared', () => {
|
||||
const metadata = {
|
||||
name: 'test-skill',
|
||||
description: 'test',
|
||||
systemPrompt: '',
|
||||
};
|
||||
const warnings = checkSkillDependencies(metadata, ['search_memory']);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkVersionDowngrade', () => {
|
||||
it('detects downgrade', () => {
|
||||
const warning = checkVersionDowngrade('my-skill', '2.0.0', '1.0.0');
|
||||
expect(warning).toContain('Downgrade detected');
|
||||
expect(warning).toContain('1.0.0 < 2.0.0');
|
||||
});
|
||||
|
||||
it('detects same version re-install', () => {
|
||||
const warning = checkVersionDowngrade('my-skill', '1.0.0', '1.0.0');
|
||||
expect(warning).toContain('Same version re-install');
|
||||
});
|
||||
|
||||
it('returns null for upgrade', () => {
|
||||
const warning = checkVersionDowngrade('my-skill', '1.0.0', '2.0.0');
|
||||
expect(warning).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when versions are missing', () => {
|
||||
expect(checkVersionDowngrade('s', undefined, '1.0.0')).toBeNull();
|
||||
expect(checkVersionDowngrade('s', '1.0.0', undefined)).toBeNull();
|
||||
expect(checkVersionDowngrade('s', undefined, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid semver', () => {
|
||||
expect(checkVersionDowngrade('s', 'abc', '1.0.0')).toBeNull();
|
||||
expect(checkVersionDowngrade('s', '1.0.0', 'xyz')).toBeNull();
|
||||
});
|
||||
});
|
||||
124
packages/sdk/tests/wave-g-capability-surface.test.ts
Normal file
124
packages/sdk/tests/wave-g-capability-surface.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Wave G — Capability Surface Productization tests.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Pack catalog structure and completeness
|
||||
* 2. Pack-to-skill relationships are valid
|
||||
* 3. Product language consistency (no internal categories leaked)
|
||||
* 4. Capability summary data shape
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { listCapabilityPacks, getPackManifest } from '../src/capability-packs/index.js';
|
||||
|
||||
describe('Wave G: Capability Surface', () => {
|
||||
describe('Pack Catalog', () => {
|
||||
it('has exactly 5 curated packs', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
expect(packs).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('all packs have required fields', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
for (const pack of packs) {
|
||||
expect(pack.id).toBeTruthy();
|
||||
expect(pack.name).toBeTruthy();
|
||||
expect(pack.description).toBeTruthy();
|
||||
expect(pack.skills.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the 5 core pack IDs', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
const ids = packs.map(p => p.id);
|
||||
expect(ids).toContain('writing-suite');
|
||||
expect(ids).toContain('research-workflow');
|
||||
expect(ids).toContain('planning-master');
|
||||
expect(ids).toContain('decision-framework');
|
||||
expect(ids).toContain('team-collaboration');
|
||||
});
|
||||
|
||||
it('each pack has user-facing name (not slug)', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
for (const pack of packs) {
|
||||
// Names should have spaces and be title-cased, not slugs
|
||||
expect(pack.name).toMatch(/[A-Z]/); // Contains uppercase
|
||||
expect(pack.name).not.toMatch(/^[a-z-]+$/); // Not a slug
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pack-to-Skill Relationships', () => {
|
||||
it('each pack references valid skill IDs', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
for (const pack of packs) {
|
||||
for (const skillId of pack.skills) {
|
||||
expect(typeof skillId).toBe('string');
|
||||
expect(skillId.length).toBeGreaterThan(0);
|
||||
// Skill IDs should be kebab-case
|
||||
expect(skillId).toMatch(/^[a-z][a-z0-9-]*$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('getPackManifest returns correct pack by ID', () => {
|
||||
const pack = getPackManifest('writing-suite');
|
||||
expect(pack).not.toBeNull();
|
||||
expect(pack!.name).toBe('Writing Suite');
|
||||
expect(pack!.skills).toContain('draft-memo');
|
||||
});
|
||||
|
||||
it('getPackManifest returns null for unknown pack', () => {
|
||||
const pack = getPackManifest('nonexistent-pack');
|
||||
expect(pack).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Product Language', () => {
|
||||
it('pack descriptions use user-facing language (no internal terms)', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
const internalTerms = ['plugin', 'MCP', 'hook', 'mcp_server'];
|
||||
for (const pack of packs) {
|
||||
for (const term of internalTerms) {
|
||||
expect(pack.description.toLowerCase()).not.toContain(term.toLowerCase());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('pack names do not reference implementation details', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
for (const pack of packs) {
|
||||
expect(pack.name.toLowerCase()).not.toContain('plugin');
|
||||
expect(pack.name.toLowerCase()).not.toContain('mcp');
|
||||
expect(pack.name.toLowerCase()).not.toContain('hook');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Capability Summary Shape', () => {
|
||||
it('can construct a unified capability count from component data', () => {
|
||||
// Simulates what the UI does: merge implementation counts into product counts
|
||||
const mockCapabilities = {
|
||||
tools: { count: 25, native: 20, plugin: 3, mcp: 2 },
|
||||
skills: [{ name: 'draft-memo' }, { name: 'research-synthesis' }],
|
||||
workflows: [{ name: 'research-team' }],
|
||||
plugins: [{ name: 'p1', state: 'active' }],
|
||||
mcpServers: [{ name: 'm1', healthy: true }],
|
||||
commands: [{ name: '/help' }, { name: '/cost' }],
|
||||
};
|
||||
|
||||
// Product-level counts (what user sees)
|
||||
const toolCount = mockCapabilities.tools.count;
|
||||
const skillCount = mockCapabilities.skills.length;
|
||||
const workflowCount = mockCapabilities.workflows.length;
|
||||
const extensionCount = mockCapabilities.plugins.length + mockCapabilities.mcpServers.length;
|
||||
const commandCount = mockCapabilities.commands.length;
|
||||
|
||||
expect(toolCount).toBe(25);
|
||||
expect(skillCount).toBe(2);
|
||||
expect(workflowCount).toBe(1);
|
||||
expect(extensionCount).toBe(2); // unified plugins + MCP
|
||||
expect(commandCount).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user