Files
wifi-densepose/npm/packages/ruvbot/tests/unit/domain/skill.test.ts
ruv d803bfe2b1 Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector
git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
2026-02-28 14:39:40 -05:00

763 lines
20 KiB
TypeScript

/**
* Skill Domain Entity - Unit Tests
*
* Tests for Skill registration, execution, and validation
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createSkill, type Skill } from '../../factories';
// Skill Types
interface SkillDefinition {
id: string;
name: string;
version: string;
description: string;
inputSchema: JSONSchema;
outputSchema: JSONSchema;
executor: string;
timeout: number;
retries: number;
metadata: SkillMetadata;
}
interface JSONSchema {
type: string;
properties?: Record<string, unknown>;
required?: string[];
additionalProperties?: boolean;
}
interface SkillMetadata {
author: string;
createdAt: Date;
updatedAt: Date;
usageCount: number;
averageLatency: number;
successRate: number;
tags: string[];
}
interface SkillExecutionContext {
tenantId: string;
sessionId: string;
agentId: string;
timeout?: number;
}
interface SkillExecutionResult {
success: boolean;
output: unknown;
error?: string;
latency: number;
tokensUsed?: number;
}
// Mock Skill Registry class for testing
class SkillRegistry {
private skills: Map<string, SkillDefinition> = new Map();
private executors: Map<string, (input: unknown, context: SkillExecutionContext) => Promise<unknown>> = new Map();
async register(skill: Omit<SkillDefinition, 'metadata'> & { metadata?: Partial<SkillMetadata> }): Promise<SkillDefinition> {
if (this.skills.has(skill.id)) {
throw new Error(`Skill ${skill.id} is already registered`);
}
this.validateSchema(skill.inputSchema);
this.validateSchema(skill.outputSchema);
const fullSkill: SkillDefinition = {
...skill,
metadata: {
author: skill.metadata?.author || 'unknown',
createdAt: skill.metadata?.createdAt || new Date(),
updatedAt: new Date(),
usageCount: skill.metadata?.usageCount || 0,
averageLatency: skill.metadata?.averageLatency || 0,
successRate: skill.metadata?.successRate || 1,
tags: skill.metadata?.tags || []
}
};
this.skills.set(skill.id, fullSkill);
return fullSkill;
}
async unregister(skillId: string): Promise<boolean> {
return this.skills.delete(skillId);
}
async get(skillId: string): Promise<SkillDefinition | null> {
return this.skills.get(skillId) || null;
}
async getByName(name: string, version?: string): Promise<SkillDefinition | null> {
for (const skill of this.skills.values()) {
if (skill.name === name) {
if (!version || skill.version === version) {
return skill;
}
}
}
return null;
}
async list(tags?: string[]): Promise<SkillDefinition[]> {
let skills = Array.from(this.skills.values());
if (tags && tags.length > 0) {
skills = skills.filter(s =>
tags.some(tag => s.metadata.tags.includes(tag))
);
}
return skills;
}
async listByExecutorType(type: string): Promise<SkillDefinition[]> {
return Array.from(this.skills.values()).filter(s =>
s.executor.startsWith(type)
);
}
registerExecutor(
pattern: string,
executor: (input: unknown, context: SkillExecutionContext) => Promise<unknown>
): void {
this.executors.set(pattern, executor);
}
async execute(
skillId: string,
input: unknown,
context: SkillExecutionContext
): Promise<SkillExecutionResult> {
const skill = await this.get(skillId);
if (!skill) {
return {
success: false,
output: null,
error: `Skill ${skillId} not found`,
latency: 0
};
}
// Validate input
const validationError = this.validateInput(input, skill.inputSchema);
if (validationError) {
return {
success: false,
output: null,
error: validationError,
latency: 0
};
}
// Find executor
const executor = this.findExecutor(skill.executor);
if (!executor) {
return {
success: false,
output: null,
error: `No executor found for ${skill.executor}`,
latency: 0
};
}
// Execute with timeout
const startTime = performance.now();
const timeout = context.timeout || skill.timeout;
try {
const result = await Promise.race([
executor(input, context),
this.createTimeout(timeout)
]);
// Use performance.now() for sub-millisecond precision, ensure minimum 0.001ms
const latency = Math.max(performance.now() - startTime, 0.001);
// Update metrics
this.updateMetrics(skill, true, latency);
return {
success: true,
output: result,
latency
};
} catch (error) {
const latency = Math.max(performance.now() - startTime, 0.001);
// Update metrics
this.updateMetrics(skill, false, latency);
return {
success: false,
output: null,
error: error instanceof Error ? error.message : 'Unknown error',
latency
};
}
}
async executeWithRetry(
skillId: string,
input: unknown,
context: SkillExecutionContext,
maxRetries?: number
): Promise<SkillExecutionResult> {
const skill = await this.get(skillId);
const retries = maxRetries ?? skill?.retries ?? 0;
let lastResult: SkillExecutionResult | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
const result = await this.execute(skillId, input, context);
if (result.success) {
return result;
}
lastResult = result;
// Exponential backoff
if (attempt < retries) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100));
}
}
return lastResult!;
}
size(): number {
return this.skills.size;
}
clear(): void {
this.skills.clear();
}
private validateSchema(schema: JSONSchema): void {
if (!schema.type) {
throw new Error('Schema must have a type');
}
const validTypes = ['object', 'array', 'string', 'number', 'boolean', 'null'];
if (!validTypes.includes(schema.type)) {
throw new Error(`Invalid schema type: ${schema.type}`);
}
}
private validateInput(input: unknown, schema: JSONSchema): string | null {
if (schema.type === 'object') {
if (typeof input !== 'object' || input === null) {
return 'Input must be an object';
}
const inputObj = input as Record<string, unknown>;
// Check required fields
if (schema.required) {
for (const field of schema.required) {
if (!(field in inputObj)) {
return `Missing required field: ${field}`;
}
}
}
// Validate property types if defined
if (schema.properties) {
for (const [key, propSchema] of Object.entries(schema.properties)) {
if (key in inputObj) {
const propError = this.validateProperty(inputObj[key], propSchema as JSONSchema);
if (propError) {
return `Invalid ${key}: ${propError}`;
}
}
}
}
}
return null;
}
private validateProperty(value: unknown, schema: JSONSchema): string | null {
const type = schema.type;
switch (type) {
case 'string':
if (typeof value !== 'string') return 'must be a string';
break;
case 'number':
if (typeof value !== 'number') return 'must be a number';
break;
case 'boolean':
if (typeof value !== 'boolean') return 'must be a boolean';
break;
case 'array':
if (!Array.isArray(value)) return 'must be an array';
break;
case 'object':
if (typeof value !== 'object' || value === null) return 'must be an object';
break;
}
return null;
}
private findExecutor(
executorUri: string
): ((input: unknown, context: SkillExecutionContext) => Promise<unknown>) | null {
for (const [pattern, executor] of this.executors) {
if (executorUri.startsWith(pattern)) {
return executor;
}
}
return null;
}
private async createTimeout(ms: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('Skill execution timed out')), ms);
});
}
private updateMetrics(skill: SkillDefinition, success: boolean, latency: number): void {
const previousCount = skill.metadata.usageCount;
const totalExecutions = previousCount + 1;
const totalLatency = skill.metadata.averageLatency * previousCount + latency;
// Calculate success count from previous executions
const previousSuccessCount = skill.metadata.successRate * previousCount;
const newSuccessCount = success ? previousSuccessCount + 1 : previousSuccessCount;
skill.metadata.usageCount = totalExecutions;
skill.metadata.averageLatency = totalLatency / totalExecutions;
skill.metadata.successRate = newSuccessCount / totalExecutions;
skill.metadata.updatedAt = new Date();
}
}
// Tests
describe('Skill Registry', () => {
let registry: SkillRegistry;
beforeEach(() => {
registry = new SkillRegistry();
});
describe('Registration', () => {
it('should register a skill', async () => {
const skill = await registry.register({
id: 'skill-001',
name: 'test-skill',
version: '1.0.0',
description: 'A test skill',
inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
outputSchema: { type: 'object', properties: { output: { type: 'string' } } },
executor: 'native://test',
timeout: 30000,
retries: 3
});
expect(skill.id).toBe('skill-001');
expect(skill.name).toBe('test-skill');
expect(skill.metadata.usageCount).toBe(0);
});
it('should throw error when registering duplicate skill', async () => {
await registry.register({
id: 'skill-001',
name: 'test',
version: '1.0.0',
description: 'Test',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
executor: 'native://test',
timeout: 30000,
retries: 0
});
await expect(registry.register({
id: 'skill-001',
name: 'duplicate',
version: '1.0.0',
description: 'Duplicate',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
executor: 'native://test',
timeout: 30000,
retries: 0
})).rejects.toThrow('already registered');
});
it('should throw error for invalid schema type', async () => {
await expect(registry.register({
id: 'skill-001',
name: 'test',
version: '1.0.0',
description: 'Test',
inputSchema: { type: 'invalid' as any },
outputSchema: { type: 'object' },
executor: 'native://test',
timeout: 30000,
retries: 0
})).rejects.toThrow('Invalid schema type');
});
it('should unregister skill', async () => {
await registry.register({
id: 'skill-001',
name: 'test',
version: '1.0.0',
description: 'Test',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
executor: 'native://test',
timeout: 30000,
retries: 0
});
const result = await registry.unregister('skill-001');
const skill = await registry.get('skill-001');
expect(result).toBe(true);
expect(skill).toBeNull();
});
});
describe('Retrieval', () => {
beforeEach(async () => {
await registry.register({
id: 'skill-001',
name: 'code-gen',
version: '1.0.0',
description: 'Generate code',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
executor: 'wasm://code-gen',
timeout: 30000,
retries: 0,
metadata: { tags: ['code', 'generation'] }
});
await registry.register({
id: 'skill-002',
name: 'code-gen',
version: '2.0.0',
description: 'Generate code v2',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
executor: 'wasm://code-gen-v2',
timeout: 30000,
retries: 0,
metadata: { tags: ['code', 'generation', 'v2'] }
});
await registry.register({
id: 'skill-003',
name: 'test-gen',
version: '1.0.0',
description: 'Generate tests',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
executor: 'native://test-gen',
timeout: 60000,
retries: 2,
metadata: { tags: ['testing', 'generation'] }
});
});
it('should get skill by ID', async () => {
const skill = await registry.get('skill-001');
expect(skill?.name).toBe('code-gen');
});
it('should get skill by name', async () => {
const skill = await registry.getByName('code-gen');
expect(skill).not.toBeNull();
expect(skill?.name).toBe('code-gen');
});
it('should get skill by name and version', async () => {
const skill = await registry.getByName('code-gen', '2.0.0');
expect(skill?.id).toBe('skill-002');
});
it('should list all skills', async () => {
const skills = await registry.list();
expect(skills).toHaveLength(3);
});
it('should list skills by tag', async () => {
const skills = await registry.list(['testing']);
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe('test-gen');
});
it('should list skills by executor type', async () => {
const wasmSkills = await registry.listByExecutorType('wasm://');
const nativeSkills = await registry.listByExecutorType('native://');
expect(wasmSkills).toHaveLength(2);
expect(nativeSkills).toHaveLength(1);
});
});
describe('Execution', () => {
const context: SkillExecutionContext = {
tenantId: 'tenant-001',
sessionId: 'session-001',
agentId: 'agent-001'
};
beforeEach(async () => {
await registry.register({
id: 'skill-001',
name: 'echo',
version: '1.0.0',
description: 'Echo input',
inputSchema: {
type: 'object',
properties: { message: { type: 'string' } },
required: ['message']
},
outputSchema: { type: 'object' },
executor: 'native://echo',
timeout: 5000,
retries: 2
});
registry.registerExecutor('native://echo', async (input) => {
return { echoed: (input as any).message };
});
});
it('should execute skill successfully', async () => {
const result = await registry.execute(
'skill-001',
{ message: 'Hello' },
context
);
expect(result.success).toBe(true);
expect(result.output).toEqual({ echoed: 'Hello' });
expect(result.latency).toBeGreaterThan(0);
});
it('should fail for non-existent skill', async () => {
const result = await registry.execute(
'non-existent',
{},
context
);
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('should validate required input fields', async () => {
const result = await registry.execute(
'skill-001',
{},
context
);
expect(result.success).toBe(false);
expect(result.error).toContain('Missing required field');
});
it('should fail without executor', async () => {
await registry.register({
id: 'skill-no-executor',
name: 'no-executor',
version: '1.0.0',
description: 'No executor',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
executor: 'unknown://test',
timeout: 5000,
retries: 0
});
const result = await registry.execute(
'skill-no-executor',
{},
context
);
expect(result.success).toBe(false);
expect(result.error).toContain('No executor found');
});
it('should handle execution errors', async () => {
await registry.register({
id: 'skill-error',
name: 'error',
version: '1.0.0',
description: 'Throws error',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
executor: 'native://error',
timeout: 5000,
retries: 0
});
registry.registerExecutor('native://error', async () => {
throw new Error('Execution failed');
});
const result = await registry.execute(
'skill-error',
{},
context
);
expect(result.success).toBe(false);
expect(result.error).toBe('Execution failed');
});
it('should update metrics after execution', async () => {
await registry.execute(
'skill-001',
{ message: 'test' },
context
);
const skill = await registry.get('skill-001');
expect(skill?.metadata.usageCount).toBe(1);
expect(skill?.metadata.averageLatency).toBeGreaterThan(0);
expect(skill?.metadata.successRate).toBe(1);
});
it('should update success rate on failure', async () => {
await registry.register({
id: 'skill-flaky',
name: 'flaky',
version: '1.0.0',
description: 'Flaky skill',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
executor: 'native://flaky',
timeout: 5000,
retries: 0
});
let callCount = 0;
registry.registerExecutor('native://flaky', async () => {
callCount++;
if (callCount === 1) {
throw new Error('First call fails');
}
return { success: true };
});
// First call fails
await registry.execute('skill-flaky', {}, context);
// Second call succeeds
await registry.execute('skill-flaky', {}, context);
const skill = await registry.get('skill-flaky');
expect(skill?.metadata.usageCount).toBe(2);
expect(skill?.metadata.successRate).toBe(0.5);
});
});
describe('Retry Mechanism', () => {
const context: SkillExecutionContext = {
tenantId: 'tenant-001',
sessionId: 'session-001',
agentId: 'agent-001'
};
it('should retry failed executions', async () => {
await registry.register({
id: 'skill-retry',
name: 'retry',
version: '1.0.0',
description: 'Retry skill',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
executor: 'native://retry',
timeout: 5000,
retries: 2
});
let attempts = 0;
registry.registerExecutor('native://retry', async () => {
attempts++;
if (attempts < 3) {
throw new Error(`Attempt ${attempts} failed`);
}
return { success: true };
});
const result = await registry.executeWithRetry(
'skill-retry',
{},
context
);
expect(result.success).toBe(true);
expect(attempts).toBe(3);
});
it('should fail after max retries', async () => {
await registry.register({
id: 'skill-always-fail',
name: 'always-fail',
version: '1.0.0',
description: 'Always fails',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
executor: 'native://always-fail',
timeout: 5000,
retries: 2
});
registry.registerExecutor('native://always-fail', async () => {
throw new Error('Always fails');
});
const result = await registry.executeWithRetry(
'skill-always-fail',
{},
context
);
expect(result.success).toBe(false);
});
});
});
describe('Skill Factory Integration', () => {
let registry: SkillRegistry;
beforeEach(() => {
registry = new SkillRegistry();
});
it('should register skill from factory data', async () => {
const factorySkill = createSkill({
name: 'factory-skill',
description: 'Created from factory'
});
const skill = await registry.register({
id: factorySkill.id,
name: factorySkill.name,
version: factorySkill.version,
description: factorySkill.description,
inputSchema: factorySkill.inputSchema as any,
outputSchema: factorySkill.outputSchema as any,
executor: factorySkill.executor,
timeout: factorySkill.timeout,
retries: 0
});
expect(skill.name).toBe('factory-skill');
expect(skill.description).toBe('Created from factory');
});
});