moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View 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 });
});
});