This commit is contained in:
61
tests/sidecar/mcp-manager.test.ts
Normal file
61
tests/sidecar/mcp-manager.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { McpManager, type McpServerConfig } from '../../sidecar/src/mcp-manager.js';
|
||||
|
||||
describe('MCP Manager', () => {
|
||||
let manager: McpManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new McpManager();
|
||||
});
|
||||
|
||||
it('starts with no servers', () => {
|
||||
expect(manager.listServers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds a server config', () => {
|
||||
const config: McpServerConfig = {
|
||||
id: 'filesystem',
|
||||
name: 'File System',
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'],
|
||||
};
|
||||
manager.addServer(config);
|
||||
expect(manager.listServers()).toHaveLength(1);
|
||||
expect(manager.listServers()[0].id).toBe('filesystem');
|
||||
});
|
||||
|
||||
it('removes a server config', () => {
|
||||
manager.addServer({ id: 'test', name: 'Test', command: 'echo', args: ['hello'] });
|
||||
expect(manager.listServers()).toHaveLength(1);
|
||||
manager.removeServer('test');
|
||||
expect(manager.listServers()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('prevents duplicate server IDs', () => {
|
||||
manager.addServer({ id: 'dup', name: 'First', command: 'a', args: [] });
|
||||
expect(() => {
|
||||
manager.addServer({ id: 'dup', name: 'Second', command: 'b', args: [] });
|
||||
}).toThrow('already exists');
|
||||
});
|
||||
|
||||
it('gets server by ID', () => {
|
||||
manager.addServer({ id: 'fs', name: 'FS', command: 'npx', args: [] });
|
||||
const server = manager.getServer('fs');
|
||||
expect(server?.name).toBe('FS');
|
||||
});
|
||||
|
||||
it('returns undefined for unknown server', () => {
|
||||
expect(manager.getServer('nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('serializes and deserializes config', () => {
|
||||
manager.addServer({ id: 'a', name: 'A', command: 'x', args: ['1'] });
|
||||
manager.addServer({ id: 'b', name: 'B', command: 'y', args: ['2'], env: { FOO: 'bar' } });
|
||||
|
||||
const json = manager.toJSON();
|
||||
const restored = McpManager.fromJSON(json);
|
||||
|
||||
expect(restored.listServers()).toHaveLength(2);
|
||||
expect(restored.getServer('b')?.env).toEqual({ FOO: 'bar' });
|
||||
});
|
||||
});
|
||||
107
tests/sidecar/rpc-handler.test.ts
Normal file
107
tests/sidecar/rpc-handler.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { RpcHandler } from '../../sidecar/src/rpc-handler.js';
|
||||
import { MindDB, IdentityLayer } from '@waggle/core';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
describe('RPC Handler', () => {
|
||||
let handler: RpcHandler;
|
||||
let dbPath: string;
|
||||
let db: MindDB;
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = path.join(os.tmpdir(), `waggle-rpc-test-${Date.now()}.mind`);
|
||||
db = new MindDB(dbPath);
|
||||
handler = new RpcHandler(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
try {
|
||||
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
|
||||
} catch {
|
||||
// Windows file lock
|
||||
}
|
||||
});
|
||||
|
||||
it('handles ping method', async () => {
|
||||
const result = await handler.handle({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ping',
|
||||
id: 1,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
result: { status: 'ok' },
|
||||
id: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles mind.getIdentity', async () => {
|
||||
const identity = new IdentityLayer(db);
|
||||
identity.create({
|
||||
name: 'TestBot',
|
||||
role: 'assistant',
|
||||
department: '',
|
||||
personality: '',
|
||||
capabilities: '',
|
||||
system_prompt: '',
|
||||
});
|
||||
|
||||
const result = await handler.handle({
|
||||
jsonrpc: '2.0',
|
||||
method: 'mind.getIdentity',
|
||||
id: 2,
|
||||
});
|
||||
expect(result.result).toContain('TestBot');
|
||||
});
|
||||
|
||||
it('handles mind.getAwareness', async () => {
|
||||
const result = await handler.handle({
|
||||
jsonrpc: '2.0',
|
||||
method: 'mind.getAwareness',
|
||||
id: 3,
|
||||
});
|
||||
expect(result.result).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns error for unknown method', async () => {
|
||||
const result = await handler.handle({
|
||||
jsonrpc: '2.0',
|
||||
method: 'nonexistent',
|
||||
id: 4,
|
||||
});
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error!.code).toBe(-32601);
|
||||
});
|
||||
|
||||
it('handles chat.send with stub response', async () => {
|
||||
const result = await handler.handle({
|
||||
jsonrpc: '2.0',
|
||||
method: 'chat.send',
|
||||
params: { message: 'hello' },
|
||||
id: 5,
|
||||
});
|
||||
expect(result.result).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles settings.get', async () => {
|
||||
const result = await handler.handle({
|
||||
jsonrpc: '2.0',
|
||||
method: 'settings.get',
|
||||
id: 6,
|
||||
});
|
||||
expect(result.result).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles settings.set', async () => {
|
||||
const result = await handler.handle({
|
||||
jsonrpc: '2.0',
|
||||
method: 'settings.set',
|
||||
params: { key: 'model', value: 'claude-sonnet-4-6' },
|
||||
id: 7,
|
||||
});
|
||||
expect(result.result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
120
tests/sidecar/skill-loader.test.ts
Normal file
120
tests/sidecar/skill-loader.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { SkillLoader, type Skill } from '../../sidecar/src/skill-loader.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
describe('Skill Loader', () => {
|
||||
let skillDir: string;
|
||||
let loader: SkillLoader;
|
||||
|
||||
beforeEach(() => {
|
||||
skillDir = path.join(os.tmpdir(), `waggle-skills-${Date.now()}`);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
loader = new SkillLoader([skillDir]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(skillDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('discovers skills from SKILL.md files', () => {
|
||||
const skillPath = path.join(skillDir, 'summarize');
|
||||
fs.mkdirSync(skillPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillPath, 'SKILL.md'), `---
|
||||
name: summarize
|
||||
description: Summarize text into key points
|
||||
model: haiku
|
||||
---
|
||||
|
||||
You are a summarization expert. Given text, produce a concise summary.
|
||||
`);
|
||||
const skills = loader.discover();
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe('summarize');
|
||||
expect(skills[0].description).toBe('Summarize text into key points');
|
||||
expect(skills[0].model).toBe('haiku');
|
||||
expect(skills[0].prompt).toContain('summarization expert');
|
||||
});
|
||||
|
||||
it('discovers skills from multiple directories', () => {
|
||||
const dir2 = path.join(os.tmpdir(), `waggle-skills2-${Date.now()}`);
|
||||
fs.mkdirSync(dir2, { recursive: true });
|
||||
|
||||
fs.mkdirSync(path.join(skillDir, 'skill-a'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'skill-a', 'SKILL.md'), `---
|
||||
name: skill-a
|
||||
description: First skill
|
||||
---
|
||||
Prompt A
|
||||
`);
|
||||
|
||||
fs.mkdirSync(path.join(dir2, 'skill-b'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir2, 'skill-b', 'SKILL.md'), `---
|
||||
name: skill-b
|
||||
description: Second skill
|
||||
---
|
||||
Prompt B
|
||||
`);
|
||||
|
||||
const multiLoader = new SkillLoader([skillDir, dir2]);
|
||||
const skills = multiLoader.discover();
|
||||
expect(skills).toHaveLength(2);
|
||||
fs.rmSync(dir2, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('ignores directories without SKILL.md', () => {
|
||||
fs.mkdirSync(path.join(skillDir, 'empty-dir'), { recursive: true });
|
||||
const skills = loader.discover();
|
||||
expect(skills).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles invalid YAML frontmatter gracefully', () => {
|
||||
fs.mkdirSync(path.join(skillDir, 'bad-skill'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'bad-skill', 'SKILL.md'), `No frontmatter here.`);
|
||||
const skills = loader.discover();
|
||||
expect(skills).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('parses optional tools field', () => {
|
||||
fs.mkdirSync(path.join(skillDir, 'researcher'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'researcher', 'SKILL.md'), `---
|
||||
name: researcher
|
||||
description: Research topics
|
||||
tools: WebSearch, WebFetch
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
Research the given topic.
|
||||
`);
|
||||
const skills = loader.discover();
|
||||
expect(skills[0].tools).toEqual(['WebSearch', 'WebFetch']);
|
||||
});
|
||||
|
||||
it('deduplicates skills by name (later directory wins)', () => {
|
||||
const dir2 = path.join(os.tmpdir(), `waggle-skills-dup-${Date.now()}`);
|
||||
fs.mkdirSync(dir2, { recursive: true });
|
||||
|
||||
fs.mkdirSync(path.join(skillDir, 'dupe'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'dupe', 'SKILL.md'), `---
|
||||
name: dupe
|
||||
description: First version
|
||||
---
|
||||
First
|
||||
`);
|
||||
|
||||
fs.mkdirSync(path.join(dir2, 'dupe'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir2, 'dupe', 'SKILL.md'), `---
|
||||
name: dupe
|
||||
description: Second version
|
||||
---
|
||||
Second
|
||||
`);
|
||||
|
||||
const multiLoader = new SkillLoader([skillDir, dir2]);
|
||||
const skills = multiLoader.discover();
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].description).toBe('Second version');
|
||||
fs.rmSync(dir2, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user