@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
582 lines (487 loc) • 17.5 kB
text/typescript
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');
});
});
});