UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

582 lines (487 loc) 17.5 kB
import type { Mock, MockedClass, MockedFunction } from 'vitest'; import { vi } from 'vitest'; import { ProcessBuilder } from '../process-builder.js'; import { ProcessStore } from '../process-store.js'; import { ProcessDefinition, Activity, ProcessTrigger, PersonaType } from '../types.js'; // Mock the store vi.mock('../process-store.js'); describe('ProcessBuilder', () => { let builder: ProcessBuilder; let mockStore: vi.Mocked<ProcessStore>; beforeEach(() => { // Create a simple in-memory store for tests const processes = new Map<string, ProcessDefinition>(); mockStore = { saveProcess: vi.fn().mockImplementation(async (process: ProcessDefinition) => { processes.set(process.id, process); }), getProcess: vi.fn().mockImplementation(async (id: string) => { return processes.get(id) || null; }), getAllProcesses: vi.fn().mockImplementation(async () => { return Array.from(processes.values()); }) } as any; builder = new ProcessBuilder(mockStore); }); afterEach(() => { vi.clearAllMocks(); }); describe('createProcess', () => { it('should create a basic process', async () => { const process = await builder.createProcess({ name: 'Test Process', description: 'A test process' }); expect(process).toMatchObject({ id: expect.stringMatching(/^process-/), name: 'Test Process', description: 'A test process', version: '1.0.0', triggers: [], activities: [], variables: {}, metadata: { createdAt: expect.any(String), updatedAt: expect.any(String), executionCount: 0 } }); expect(mockStore.saveProcess).toHaveBeenCalledWith(process); }); it('should create process with persona', async () => { const process = await builder.createProcess({ name: 'Founder Process', persona: 'founder' }); expect(process.persona).toBe('founder'); }); it('should throw error if name is missing', async () => { await expect(builder.createProcess({} as any)) .rejects.toThrow('Process name is required'); }); it('should create process with initial activities', async () => { const activities: Activity[] = [ { id: 'act-1', type: 'tool', name: 'Step 1', config: { toolName: 'test_tool' } } ]; const process = await builder.createProcess({ name: 'Process with Activities', activities }); expect(process.activities).toEqual(activities); }); }); describe('addActivity', () => { let process: ProcessDefinition; beforeEach(async () => { process = await builder.createProcess({ name: 'Test Process' }); }); it('should add activity to process', async () => { const activity: Activity = { id: 'new-activity', type: 'tool', name: 'New Activity', config: { toolName: 'test_tool' } }; const updated = await builder.addActivity(process.id, activity); expect(updated.activities).toHaveLength(1); expect(updated.activities[0]).toEqual(activity); expect(mockStore.saveProcess).toHaveBeenCalledWith(updated); }); it('should insert activity at specific position', async () => { // Add initial activities await builder.addActivity(process.id, { id: 'act-1', type: 'tool', name: 'First', config: { toolName: 'tool1' } }); await builder.addActivity(process.id, { id: 'act-2', type: 'tool', name: 'Third', config: { toolName: 'tool3' } }); // Insert in middle const updated = await builder.addActivity(process.id, { id: 'act-middle', type: 'tool', name: 'Second', config: { toolName: 'tool2' } }, 1); expect(updated.activities).toHaveLength(3); expect(updated.activities[1].id).toBe('act-middle'); }); it('should update metadata when adding activity', async () => { const before = process.metadata.updatedAt; // Wait a bit to ensure timestamp difference await new Promise(resolve => setTimeout(resolve, 10)); const updated = await builder.addActivity(process.id, { id: 'act-1', type: 'tool', name: 'Activity', config: { toolName: 'tool' } }); expect(updated.metadata.updatedAt).not.toBe(before); }); }); describe('removeActivity', () => { let process: ProcessDefinition; beforeEach(async () => { process = await builder.createProcess({ name: 'Test Process', activities: [ { id: 'act-1', type: 'tool', name: 'Activity 1', config: { toolName: 'tool1' } }, { id: 'act-2', type: 'tool', name: 'Activity 2', config: { toolName: 'tool2' } }, { id: 'act-3', type: 'tool', name: 'Activity 3', config: { toolName: 'tool3' } } ] }); }); it('should remove activity by id', async () => { const updated = await builder.removeActivity(process.id, 'act-2'); expect(updated.activities).toHaveLength(2); expect(updated.activities.find(a => a.id === 'act-2')).toBeUndefined(); expect(updated.activities[0].id).toBe('act-1'); expect(updated.activities[1].id).toBe('act-3'); }); it('should throw error if activity not found', async () => { await expect(builder.removeActivity(process.id, 'non-existent')) .rejects.toThrow('Activity not found'); }); }); describe('updateActivity', () => { let process: ProcessDefinition; beforeEach(async () => { process = await builder.createProcess({ name: 'Test Process', activities: [ { id: 'act-1', type: 'tool', name: 'Original', config: { toolName: 'tool1' } } ] }); }); it('should update activity properties', async () => { const updated = await builder.updateActivity(process.id, 'act-1', { name: 'Updated Name', config: { toolName: 'updated_tool', newProp: 'value' } }); expect(updated.activities[0].name).toBe('Updated Name'); expect(updated.activities[0].config).toEqual({ toolName: 'updated_tool', newProp: 'value' }); }); it('should preserve unchanged properties', async () => { const updated = await builder.updateActivity(process.id, 'act-1', { name: 'New Name' }); expect(updated.activities[0].type).toBe('tool'); expect(updated.activities[0].config).toEqual({ toolName: 'tool1' }); }); }); describe('addTrigger', () => { let process: ProcessDefinition; beforeEach(async () => { process = await builder.createProcess({ name: 'Test Process' }); }); it('should add trigger to process', async () => { const trigger: ProcessTrigger = { id: 'trigger-1', type: 'schedule', name: 'Daily Run', enabled: true, config: { cron: '0 9 * * *' } }; const updated = await builder.addTrigger(process.id, trigger); expect(updated.triggers).toHaveLength(1); expect(updated.triggers[0]).toEqual(trigger); }); it('should prevent duplicate trigger ids', async () => { const trigger: ProcessTrigger = { id: 'trigger-1', type: 'manual', name: 'Manual', enabled: true }; await builder.addTrigger(process.id, trigger); await expect(builder.addTrigger(process.id, trigger)) .rejects.toThrow('Trigger with ID trigger-1 already exists'); }); }); describe('updateTrigger', () => { let process: ProcessDefinition; beforeEach(async () => { process = await builder.createProcess({ name: 'Test Process', triggers: [{ id: 'trigger-1', type: 'schedule', name: 'Original', enabled: true, config: { cron: '0 9 * * *' } }] }); }); it('should update trigger properties', async () => { const updated = await builder.updateTrigger(process.id, 'trigger-1', { name: 'Updated Schedule', enabled: false, config: { cron: '0 10 * * *' } }); expect(updated.triggers[0]).toMatchObject({ id: 'trigger-1', type: 'schedule', name: 'Updated Schedule', enabled: false, config: { cron: '0 10 * * *' } }); }); }); describe('removeTrigger', () => { let process: ProcessDefinition; beforeEach(async () => { process = await builder.createProcess({ name: 'Test Process', triggers: [ { id: 'trigger-1', type: 'manual', name: 'Manual', enabled: true }, { id: 'trigger-2', type: 'schedule', name: 'Schedule', enabled: true, config: { cron: '0 9 * * *' } } ] }); }); it('should remove trigger by id', async () => { const updated = await builder.removeTrigger(process.id, 'trigger-1'); expect(updated.triggers).toHaveLength(1); expect(updated.triggers[0].id).toBe('trigger-2'); }); }); describe('setVariables', () => { let process: ProcessDefinition; beforeEach(async () => { process = await builder.createProcess({ name: 'Test Process', variables: { existing: 'value' } }); }); it('should update process variables', async () => { const updated = await builder.setVariables(process.id, { newVar: 'new value', number: 42, nested: { prop: 'value' } }); expect(updated.variables).toEqual({ newVar: 'new value', number: 42, nested: { prop: 'value' } }); }); it('should merge variables when merge=true', async () => { const updated = await builder.setVariables(process.id, { newVar: 'new value' }, true); expect(updated.variables).toEqual({ existing: 'value', newVar: 'new value' }); }); }); describe('validateProcess', () => { it('should validate complete process', async () => { const process = await builder.createProcess({ name: 'Valid Process', activities: [ { id: 'act-1', type: 'tool', name: 'Step 1', config: { toolName: 'tool1' } } ], triggers: [ { id: 'trigger-1', type: 'manual', name: 'Manual', enabled: true } ] }); const validation = await builder.validateProcess(process.id); expect(validation.isValid).toBe(true); expect(validation.errors).toHaveLength(0); expect(validation.warnings).toHaveLength(0); }); it('should warn about process without activities', async () => { const process = await builder.createProcess({ name: 'Empty Process' }); const validation = await builder.validateProcess(process.id); expect(validation.isValid).toBe(true); expect(validation.warnings).toContainEqual( expect.objectContaining({ type: 'warning', message: 'Process has no activities defined' }) ); }); it('should warn about process without triggers', async () => { const process = await builder.createProcess({ name: 'No Triggers', activities: [ { id: 'act-1', type: 'tool', name: 'Activity', config: { toolName: 'tool' } } ] }); const validation = await builder.validateProcess(process.id); expect(validation.warnings).toContainEqual( expect.objectContaining({ message: 'Process has no triggers defined' }) ); }); it('should error on invalid activity config', async () => { const process = await builder.createProcess({ name: 'Invalid Activity', activities: [ { id: 'act-1', type: 'tool', name: 'Bad Tool', config: {} } // Missing toolName ] }); const validation = await builder.validateProcess(process.id); expect(validation.isValid).toBe(false); expect(validation.errors).toContainEqual( expect.objectContaining({ type: 'error', field: 'activities[0].config', message: expect.stringContaining('toolName') }) ); }); it('should error on invalid condition syntax', async () => { const process = await builder.createProcess({ name: 'Bad Condition', activities: [ { id: 'act-1', type: 'tool', name: 'Conditional', condition: 'invalid === syntax @#$', config: { toolName: 'tool' } } ] }); const validation = await builder.validateProcess(process.id); expect(validation.errors).toContainEqual( expect.objectContaining({ field: 'activities[0].condition', message: expect.stringContaining('Invalid condition syntax') }) ); }); it('should validate trigger configurations', async () => { const process = await builder.createProcess({ name: 'Bad Schedule', triggers: [ { id: 'trigger-1', type: 'schedule', name: 'Invalid Cron', enabled: true, config: { cron: 'invalid-cron' } } ] }); const validation = await builder.validateProcess(process.id); expect(validation.errors).toContainEqual( expect.objectContaining({ field: 'triggers[0].config.cron', message: expect.stringContaining('Invalid cron expression') }) ); }); }); describe('cloneProcess', () => { it('should create a copy of existing process', async () => { const original = await builder.createProcess({ name: 'Original Process', activities: [ { id: 'act-1', type: 'tool', name: 'Activity', config: { toolName: 'tool' } } ], triggers: [ { id: 'trigger-1', type: 'manual', name: 'Manual', enabled: true } ], variables: { var1: 'value' } }); const cloned = await builder.cloneProcess(original.id, 'Cloned Process'); expect(cloned.id).not.toBe(original.id); expect(cloned.name).toBe('Cloned Process'); expect(cloned.activities).toHaveLength(1); expect(cloned.activities[0].id).not.toBe(original.activities[0].id); expect(cloned.triggers[0].id).not.toBe(original.triggers[0].id); expect(cloned.variables).toEqual(original.variables); expect(cloned.metadata.executionCount).toBe(0); }); it('should handle cloning with new persona', async () => { const original = await builder.createProcess({ name: 'Original', persona: 'founder' }); const cloned = await builder.cloneProcess(original.id, 'For Engineers', { persona: 'software-engineer' }); expect(cloned.persona).toBe('software-engineer'); }); }); describe('exportProcess', () => { it('should export process as JSON', async () => { const process = await builder.createProcess({ name: 'Export Test', activities: [ { id: 'act-1', type: 'tool', name: 'Activity', config: { toolName: 'tool' } } ] }); const exported = await builder.exportProcess(process.id); expect(exported).toMatchObject({ name: 'Export Test', version: '1.0.0', activities: expect.any(Array), exportedAt: expect.any(String), exportVersion: '1.0' }); // Should not include runtime data expect(exported).not.toHaveProperty('id'); expect(exported).not.toHaveProperty('metadata.executionCount'); }); }); describe('importProcess', () => { it('should import process from JSON', async () => { const exportData = { name: 'Imported Process', version: '1.0.0', activities: [ { id: 'act-1', type: 'tool' as const, name: 'Activity', config: { toolName: 'tool' } } ], triggers: [], variables: { imported: true }, exportVersion: '1.0', exportedAt: new Date().toISOString() }; const imported = await builder.importProcess(exportData); expect(imported.name).toBe('Imported Process'); expect(imported.activities).toHaveLength(1); expect(imported.variables).toEqual({ imported: true }); expect(imported.id).toMatch(/^process-/); expect(imported.metadata.executionCount).toBe(0); }); it('should handle version conflicts on import', async () => { const exportData = { name: 'Old Version', version: '0.1.0', activities: [], triggers: [], exportVersion: '0.5', // Old export version exportedAt: new Date().toISOString() }; const imported = await builder.importProcess(exportData, { namePrefix: '[Imported] ' }); expect(imported.name).toBe('[Imported] Old Version'); }); }); });