@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
446 lines (388 loc) • 13.4 kB
text/typescript
import type { Mock, MockedClass, MockedFunction } from 'vitest';
import { vi } from 'vitest';
import { ActivityExecutor } from '../activity-executor.js';
import {
Activity,
ProcessExecution,
ConditionalBranch,
ActivityConfig
} from '../types.js';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
// Mock dependencies
vi.mock('@modelcontextprotocol/sdk/server/index.js');
describe('ActivityExecutor', () => {
let executor: ActivityExecutor;
let mockProcessEngine: any;
let mockServer: vi.Mocked<Server>;
let mockAgentOrchestrator: any;
let mockExecution: ProcessExecution;
beforeEach(() => {
mockProcessEngine = {
emit: vi.fn()
};
mockServer = {
// Add any server methods that might be called
} as any;
// Create a simple mock for agent orchestrator
mockAgentOrchestrator = {
executeSubAgent: vi.fn(),
createAgent: vi.fn().mockReturnValue({
id: 'test-agent-123',
name: 'analyzer-agent',
type: 'analyzer'
}),
destroyAgent: vi.fn(),
createSession: vi.fn().mockReturnValue({
id: 'test-session-123',
name: 'Process Activity',
status: 'active'
}),
createTask: vi.fn().mockReturnValue({
id: 'test-task-456',
agentTask: 'Analyze code quality',
capabilities: {},
dependencies: []
}),
executeTask: vi.fn().mockResolvedValue({
success: true,
output: 'Analysis completed successfully',
artifacts: [],
performance: {
duration: 1000
}
}),
completeSession: vi.fn().mockReturnValue({ success: true })
};
executor = new ActivityExecutor(mockProcessEngine, mockAgentOrchestrator);
executor.setServer(mockServer);
mockExecution = createMockExecution();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('execute', () => {
describe('tool activities', () => {
it('should execute a tool activity successfully', async () => {
const activity: Activity = {
id: 'tool-1',
type: 'tool',
name: 'Run Tool',
config: {
toolName: 'test_tool',
toolArgs: { param: 'value' }
}
};
const result = await executor.execute(activity, {}, mockExecution);
expect(result).toMatchObject({
success: true,
toolName: 'test_tool',
executedAt: expect.any(String)
});
});
it('should throw error if toolName is missing', async () => {
const activity: Activity = {
id: 'tool-1',
type: 'tool',
name: 'Invalid Tool',
config: {} // Missing toolName
};
await expect(executor.execute(activity, {}, mockExecution))
.rejects.toThrow('Tool activity missing toolName');
});
it('should throw error if server not initialized', async () => {
const executorNoServer = new ActivityExecutor(mockProcessEngine, mockAgentOrchestrator);
const activity = createToolActivity();
await expect(executorNoServer.execute(activity, {}, mockExecution))
.rejects.toThrow('Server not initialized');
});
});
describe('human activities', () => {
it('should execute human activity and wait for input', async () => {
const activity: Activity = {
id: 'human-1',
type: 'human',
name: 'Approval Required',
config: {
prompt: 'Please approve this action',
assignTo: ['user1', 'user2'],
approvalType: 'any'
}
};
const result = await executor.execute(activity, {}, mockExecution);
expect(mockExecution.status).toBe('running'); // Should be set back after wait
expect(mockProcessEngine.emit).toHaveBeenCalledWith(
'execution:waiting',
expect.objectContaining({ execution: mockExecution, activity })
);
expect(result).toMatchObject({
approved: true,
approvedBy: 'user1',
approvedAt: expect.any(String)
});
});
it('should handle form fields in human activities', async () => {
const activity: Activity = {
id: 'human-2',
type: 'human',
name: 'Data Entry',
config: {
prompt: 'Enter project details',
formFields: [
{ name: 'projectName', type: 'text', label: 'Project Name', required: true },
{ name: 'priority', type: 'select', label: 'Priority', options: ['high', 'medium', 'low'] }
]
}
};
const result = await executor.execute(activity, {}, mockExecution);
expect(result.approved).toBe(true);
});
});
describe('agent activities', () => {
it('should return fallback result for agent activities (currently not supported)', async () => {
const activity: Activity = {
id: 'agent-1',
type: 'agent',
name: 'AI Analysis',
config: {
agentType: 'analyzer',
agentTask: 'Analyze code quality',
agentConfig: { threshold: 0.8 }
}
};
const result = await executor.execute(activity, {}, mockExecution);
expect(result).toMatchObject({
agentType: 'analyzer',
agentId: 'fallback-agent',
taskId: 'fallback-task',
taskCompleted: false,
error: 'Agent activities are not currently supported',
results: {
analysis: 'Agent execution failed - simulated fallback',
recommendations: ['Check agent orchestration configuration']
}
});
});
});
describe('conditional activities', () => {
it('should execute matching branch', async () => {
const activity: Activity = {
id: 'cond-1',
type: 'conditional',
name: 'Branch Decision',
config: {
conditions: [
{
condition: 'status === "active"',
activities: [createToolActivity('branch1-tool')]
},
{
condition: 'status === "inactive"',
activities: [createToolActivity('branch2-tool')]
}
]
}
};
mockExecution.variables.status = 'active';
const result = await executor.execute(activity, {}, mockExecution);
expect(result).toMatchObject({
success: true,
toolName: 'test_tool'
});
});
it('should execute default branch when no conditions match', async () => {
const activity: Activity = {
id: 'cond-2',
type: 'conditional',
name: 'Branch with Default',
config: {
conditions: [
{
condition: 'false',
activities: [createToolActivity('never-runs')]
}
],
defaultBranch: [createToolActivity('default-tool')]
}
};
const result = await executor.execute(activity, {}, mockExecution);
expect(result).toMatchObject({
success: true,
toolName: 'test_tool'
});
});
it('should return conditionMatched false when no branch matches', async () => {
const activity: Activity = {
id: 'cond-3',
type: 'conditional',
name: 'No Match',
config: {
conditions: [
{
condition: 'false',
activities: []
}
]
}
};
const result = await executor.execute(activity, {}, mockExecution);
expect(result).toEqual({}); // Returns empty object when no conditions match
});
});
describe('loop activities', () => {
it('should iterate over collection', async () => {
const activity: Activity = {
id: 'loop-1',
type: 'loop',
name: 'Process Items',
config: {
collection: 'items',
itemVariable: 'currentItem',
activities: [createToolActivity('process-item')]
}
};
mockExecution.variables.items = ['item1', 'item2', 'item3'];
const result = await executor.execute(activity, {}, mockExecution);
expect(result).toMatchObject({
loopCompleted: true,
iterations: 3,
results: expect.arrayContaining([
expect.objectContaining({ success: true }),
expect.objectContaining({ success: true }),
expect.objectContaining({ success: true })
])
});
});
it('should respect maxIterations limit', async () => {
const activity: Activity = {
id: 'loop-2',
type: 'loop',
name: 'Limited Loop',
config: {
collection: 'bigList',
maxIterations: 2,
activities: [createToolActivity('process')]
}
};
mockExecution.variables.bigList = [1, 2, 3, 4, 5];
const result = await executor.execute(activity, {}, mockExecution);
expect(result.iterations).toBe(2);
expect(result.results).toHaveLength(2);
});
});
describe('parallel activities', () => {
it('should execute all branches in parallel', async () => {
const activity: Activity = {
id: 'parallel-1',
type: 'parallel',
name: 'Parallel Tasks',
config: {
branches: [
[createToolActivity('branch1-act1'), createToolActivity('branch1-act2')],
[createToolActivity('branch2-act1')],
[createToolActivity('branch3-act1')]
],
waitForAll: true
}
};
const result = await executor.execute(activity, {}, mockExecution);
expect(result).toMatchObject({
parallelCompleted: true,
branches: expect.arrayContaining([
expect.objectContaining({ success: true, branchIndex: 0 }),
expect.objectContaining({ success: true, branchIndex: 1 }),
expect.objectContaining({ success: true, branchIndex: 2 })
])
});
});
it('should return first completed when waitForAll is false', async () => {
const activity: Activity = {
id: 'parallel-2',
type: 'parallel',
name: 'Race Condition',
config: {
branches: [
[createToolActivity('slow')],
[createToolActivity('fast')],
],
waitForAll: false
}
};
const result = await executor.execute(activity, {}, mockExecution);
expect(result).toMatchObject({
parallelCompleted: true,
firstCompleted: expect.objectContaining({ success: true })
});
});
});
describe('external activities', () => {
it('should make external HTTP request', async () => {
const activity: Activity = {
id: 'ext-1',
type: 'external',
name: 'Call API',
config: {
url: 'https://api.example.com/data',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: 'value' }
}
};
const result = await executor.execute(activity, {}, mockExecution);
expect(result).toMatchObject({
status: 200,
response: expect.objectContaining({
message: expect.any(String),
data: expect.any(Object)
})
});
});
it('should throw error if URL is missing', async () => {
const activity: Activity = {
id: 'ext-2',
type: 'external',
name: 'Invalid External',
config: {} // Missing URL
};
await expect(executor.execute(activity, {}, mockExecution))
.rejects.toThrow('External activity missing URL');
});
});
describe('error handling', () => {
it('should throw error for unknown activity type', async () => {
const activity = {
id: 'unknown-1',
type: 'unknown' as any,
name: 'Unknown Type',
config: {}
};
await expect(executor.execute(activity, {}, mockExecution))
.rejects.toThrow('Unknown activity type: unknown');
});
});
});
});
// Helper functions
function createMockExecution(): ProcessExecution {
return {
id: 'exec-123',
processId: 'process-123',
processVersion: '1.0.0',
status: 'running',
triggeredBy: 'manual',
startedAt: new Date().toISOString(),
variables: {},
activityResults: [],
logs: []
};
}
function createToolActivity(id: string = 'tool-1'): Activity {
return {
id,
type: 'tool',
name: 'Test Tool',
config: {
toolName: 'test_tool',
toolArgs: {}
}
};
}