Files
obsidian_ollama/tests/workflow-engine.test.ts
fegger 2f739c6f21 Refactor test mocks to access engine instances directly
Update mock retrieval to use actual instances from WorkflowEngine instead of
mocking class constructors. Flatten search result fixtures to match updated
VaultIndexer return shape.
2026-05-20 20:52:23 +02:00

1702 lines
47 KiB
TypeScript

// tests/workflow-engine.test.ts
import { WorkflowEngine } from '../src/workflow-engine/workflow-engine';
import {
WorkflowDefinition,
WorkflowStep,
WorkflowStepResult,
WorkflowExecutionContext,
WorkflowExecutionResult,
OllamaMessage,
OllamaTool,
} from '../src/types';
import { VaultIndexer } from '../src/vault-indexer';
import { ToolExecutor } from '../src/tool-executor';
import { OllamaClient } from '../src/ollama-client';
import { ConversationStateManager } from '../src/conversation-state';
import { Vault, App, Workspace, WorkspaceLeaf, TFile } from '../__mocks__/obsidian';
// ==================== Mock Setup ====================
jest.mock('../src/vault-indexer');
jest.mock('../src/tool-executor');
jest.mock('../src/ollama-client');
jest.mock('../src/conversation-state');
// ==================== Helpers ====================
function createMockVault(): Vault {
const mockVault = new Vault();
mockVault.getMarkdownFiles = jest.fn(() => []);
return mockVault;
}
function createMockApp(): App {
const mockApp = new App();
mockApp.vault = createMockVault();
return mockApp;
}
function createMockWorkflowEngine(
mockVault?: Vault,
mockApp?: App
): {
engine: WorkflowEngine;
mockVaultIndexer: jest.Mocked<VaultIndexer>;
mockToolExecutor: jest.Mocked<ToolExecutor>;
mockOllamaClient: jest.Mocked<OllamaClient>;
mockConversationStateManager: jest.Mocked<ConversationStateManager>;
} {
const vault = mockVault ?? createMockVault();
const app = mockApp ?? createMockApp();
const engine = new WorkflowEngine(vault as any, app as any, 'http://localhost:11434', 'llama3');
// Access the actual mock instances created inside WorkflowEngine constructor
const mockVaultIndexer = (engine as any).vaultIndexer as jest.Mocked<VaultIndexer>;
const mockToolExecutor = (engine as any).toolExecutor as jest.Mocked<ToolExecutor>;
const mockOllamaClient = (engine as any).ollamaClient as jest.Mocked<OllamaClient>;
const mockConversationStateManager = (engine as any).conversationStateManager as jest.Mocked<ConversationStateManager>;
return {
engine,
mockVaultIndexer,
mockToolExecutor,
mockOllamaClient,
mockConversationStateManager,
};
}
// ==================== Tests ====================
describe('WorkflowEngine', () => {
let engine: WorkflowEngine;
let mockVaultIndexer: jest.Mocked<VaultIndexer>;
let mockToolExecutor: jest.Mocked<ToolExecutor>;
let mockOllamaClient: jest.Mocked<OllamaClient>;
let mockConversationStateManager: jest.Mocked<ConversationStateManager>;
beforeEach(() => {
jest.clearAllMocks();
const mocks = createMockWorkflowEngine();
engine = mocks.engine;
mockVaultIndexer = mocks.mockVaultIndexer;
mockToolExecutor = mocks.mockToolExecutor;
mockOllamaClient = mocks.mockOllamaClient;
mockConversationStateManager = mocks.mockConversationStateManager;
// Setup default mock behaviors
mockVaultIndexer.searchVault = jest.fn().mockResolvedValue([]);
mockToolExecutor.executeTool = jest.fn().mockResolvedValue({
success: true,
message: 'Tool executed successfully',
data: null,
});
mockOllamaClient.chat = jest.fn().mockResolvedValue({
role: 'assistant',
content: 'Mock LLM response',
tool_calls: [],
});
mockConversationStateManager.updateShortTermContext = jest.fn();
mockConversationStateManager.clear = jest.fn();
});
// ==================== Validation Tests ====================
describe('validateWorkflow (via executeWorkflow)', () => {
it('should reject workflow with missing id', async () => {
const definition: WorkflowDefinition = {
id: '',
name: 'Test Workflow',
description: 'Test',
steps: [],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe('Workflow must have an id');
expect(result.workflowId).toBe('');
});
it('should reject workflow with missing name', async () => {
const definition: WorkflowDefinition = {
id: 'test-workflow',
name: '',
description: 'Test',
steps: [],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe('Workflow must have a name');
});
it('should reject workflow with missing steps array', async () => {
const definition: WorkflowDefinition = {
id: 'test-workflow',
name: 'Test Workflow',
description: 'Test',
steps: [],
};
// Test with empty steps
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe('Workflow must have at least one step');
});
it('should reject workflow with step missing id', async () => {
const definition: WorkflowDefinition = {
id: 'test-workflow',
name: 'Test Workflow',
description: 'Test',
steps: [
{
id: '',
type: 'llm',
name: 'Test Step',
config: {
type: 'llm',
userPrompt: 'Hello',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe('Each step must have an id');
});
it('should reject workflow with step missing type', async () => {
const definition: WorkflowDefinition = {
id: 'test-workflow',
name: 'Test Workflow',
description: 'Test',
steps: [
{
id: 'step_1',
type: '' as any,
name: 'Test Step',
config: {} as any,
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe("Step 'step_1' must have a type");
});
it('should reject workflow with step missing name', async () => {
const definition: WorkflowDefinition = {
id: 'test-workflow',
name: 'Test Workflow',
description: 'Test',
steps: [
{
id: 'step_1',
type: 'llm',
name: '',
config: {
type: 'llm',
userPrompt: 'Hello',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe("Step 'step_1' must have a name");
});
it('should reject workflow with step missing config', async () => {
const definition: WorkflowDefinition = {
id: 'test-workflow',
name: 'Test Workflow',
description: 'Test',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'Test Step',
config: null as unknown as never,
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe("Step 'step_1' must have a config");
});
it('should reject workflow with duplicate step ids', async () => {
const definition: WorkflowDefinition = {
id: 'test-workflow',
name: 'Test Workflow',
description: 'Test',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'Step 1',
config: {
type: 'llm',
userPrompt: 'Hello',
},
},
{
id: 'step_1',
type: 'format',
name: 'Step 2',
config: {
type: 'format',
template: 'Test',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe("Duplicate step id: 'step_1'");
});
it('should reject workflow with invalid step type', async () => {
const definition: WorkflowDefinition = {
id: 'test-workflow',
name: 'Test Workflow',
description: 'Test',
steps: [
{
id: 'step_1',
type: 'invalid_type' as any,
name: 'Test Step',
config: {} as any,
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe("Step 'step_1' has invalid type: 'invalid_type'");
});
it('should reject workflow with dependsOn referencing unknown step', async () => {
const definition: WorkflowDefinition = {
id: 'test-workflow',
name: 'Test Workflow',
description: 'Test',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'Step 1',
config: {
type: 'llm',
userPrompt: 'Hello',
},
},
{
id: 'step_2',
type: 'format',
name: 'Step 2',
config: {
type: 'format',
template: 'Test',
},
dependsOn: 'nonexistent_step',
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe("Step 'step_2' depends on unknown step: 'nonexistent_step'");
});
it('should reject LLM step without userPrompt', async () => {
const definition: WorkflowDefinition = {
id: 'test-workflow',
name: 'Test Workflow',
description: 'Test',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'LLM Step',
config: {
type: 'llm',
userPrompt: '',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe("LLM step 'step_1' requires a userPrompt");
});
it('should reject vault_search step without query', async () => {
const definition: WorkflowDefinition = {
id: 'test-workflow',
name: 'Test Workflow',
description: 'Test',
steps: [
{
id: 'step_1',
type: 'vault_search',
name: 'Search Step',
config: {
type: 'vault_search',
query: '',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe("Vault search step 'step_1' requires a query");
});
it('should reject tool step without toolName', async () => {
const definition: WorkflowDefinition = {
id: 'test-workflow',
name: 'Test Workflow',
description: 'Test',
steps: [
{
id: 'step_1',
type: 'tool',
name: 'Tool Step',
config: {
type: 'tool',
toolName: '',
args: {},
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe("Tool step 'step_1' requires a toolName");
});
it('should reject format step without template', async () => {
const definition: WorkflowDefinition = {
id: 'test-workflow',
name: 'Test Workflow',
description: 'Test',
steps: [
{
id: 'step_1',
type: 'format',
name: 'Format Step',
config: {
type: 'format',
template: '',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toBe("Format step 'step_1' requires a template");
});
});
// ==================== Step Execution Tests ====================
describe('LLM Step Execution', () => {
it('should execute a simple LLM step successfully', async () => {
mockOllamaClient.chat.mockResolvedValueOnce({
role: 'assistant',
content: 'This is the LLM response',
tool_calls: [],
});
const definition: WorkflowDefinition = {
id: 'llm-workflow',
name: 'LLM Workflow',
description: 'Test LLM step',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'LLM Step',
config: {
type: 'llm',
userPrompt: 'Hello, world!',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
expect(result.stepResults).toHaveLength(1);
expect(result.stepResults[0].stepId).toBe('step_1');
expect(result.stepResults[0].success).toBe(true);
expect(result.stepResults[0].data).toBe('This is the LLM response');
expect(result.finalOutput).toBe('This is the LLM response');
});
it('should include system prompt in LLM step', async () => {
mockOllamaClient.chat.mockResolvedValueOnce({
role: 'assistant',
content: 'Response with system prompt',
tool_calls: [],
});
const definition: WorkflowDefinition = {
id: 'llm-workflow',
name: 'LLM Workflow',
description: 'Test LLM step with system prompt',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'LLM Step',
config: {
type: 'llm',
systemPrompt: 'You are a helpful assistant.',
userPrompt: 'Hello!',
},
},
],
};
await engine.executeWorkflow(definition);
expect(mockOllamaClient.chat).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
role: 'system',
content: 'You are a helpful assistant.',
}),
]),
[]
);
});
it('should handle LLM step failure gracefully', async () => {
mockOllamaClient.chat.mockRejectedValueOnce(new Error('LLM API error'));
const definition: WorkflowDefinition = {
id: 'llm-workflow',
name: 'LLM Workflow',
description: 'Test LLM step failure',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'LLM Step',
config: {
type: 'llm',
userPrompt: 'Hello!',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.stepResults[0].success).toBe(false);
expect(result.stepResults[0].error).toBe('LLM API error');
});
});
describe('Vault Search Step Execution', () => {
it('should execute vault search step successfully', async () => {
const mockEntries = [
{
path: 'meeting-note.md',
title: 'Team Meeting',
content: 'Meeting notes content',
score: 10,
tags: 'meeting,team',
},
{
path: 'project-update.md',
title: 'Project Update',
content: 'Project progress notes',
score: 8,
tags: 'meeting,project',
},
];
mockVaultIndexer.searchVault.mockResolvedValueOnce(mockEntries as any);
const definition: WorkflowDefinition = {
id: 'search-workflow',
name: 'Search Workflow',
description: 'Test vault search',
steps: [
{
id: 'step_1',
type: 'vault_search',
name: 'Find Meeting Notes',
config: {
type: 'vault_search',
query: 'meeting',
limit: 5,
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
expect(result.stepResults[0].success).toBe(true);
expect(Array.isArray(result.stepResults[0].data)).toBe(true);
expect(result.stepResults[0].data as any[]).toHaveLength(2);
expect((result.stepResults[0].data as any[])[0].path).toBe('meeting-note.md');
});
it('should apply tag filter in vault search', async () => {
const mockEntries = [
{
path: 'meeting-note.md',
title: 'Team Meeting',
content: 'Meeting notes',
score: 10,
tags: 'meeting,team',
},
{
path: 'personal-note.md',
title: 'Personal Note',
content: 'Personal thoughts',
score: 8,
tags: 'personal,daily',
},
];
mockVaultIndexer.searchVault.mockResolvedValueOnce(mockEntries as any);
const definition: WorkflowDefinition = {
id: 'search-workflow',
name: 'Search Workflow',
description: 'Test vault search with tag filter',
steps: [
{
id: 'step_1',
type: 'vault_search',
name: 'Find Meeting Notes',
config: {
type: 'vault_search',
query: 'meeting',
limit: 10,
tagFilter: 'meeting',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
const data = result.stepResults[0].data as any[];
expect(data).toHaveLength(1);
expect(data[0].path).toBe('meeting-note.md');
});
it('should use default limit of 5 in vault search', async () => {
mockVaultIndexer.searchVault.mockResolvedValueOnce([]);
const definition: WorkflowDefinition = {
id: 'search-workflow',
name: 'Search Workflow',
description: 'Test vault search default limit',
steps: [
{
id: 'step_1',
type: 'vault_search',
name: 'Search',
config: {
type: 'vault_search',
query: 'test',
},
},
],
};
await engine.executeWorkflow(definition);
expect(mockVaultIndexer.searchVault).toHaveBeenCalledWith('test', 5);
});
});
describe('Tool Step Execution', () => {
it('should execute tool step successfully', async () => {
mockToolExecutor.executeTool.mockResolvedValueOnce({
success: true,
message: 'File created',
data: { path: 'new-file.md', size: 100 },
});
const definition: WorkflowDefinition = {
id: 'tool-workflow',
name: 'Tool Workflow',
description: 'Test tool execution',
steps: [
{
id: 'step_1',
type: 'tool',
name: 'Create File',
config: {
type: 'tool',
toolName: 'create_file',
args: {
path: 'new-file.md',
content: 'Hello, world!',
},
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
expect(result.stepResults[0].success).toBe(true);
expect(result.stepResults[0].data).toEqual({
success: true,
message: 'File created',
data: { path: 'new-file.md', size: 100 },
});
});
it('should handle tool execution failure', async () => {
mockToolExecutor.executeTool.mockRejectedValueOnce(new Error('Tool execution failed'));
const definition: WorkflowDefinition = {
id: 'tool-workflow',
name: 'Tool Workflow',
description: 'Test tool failure',
steps: [
{
id: 'step_1',
type: 'tool',
name: 'Create File',
config: {
type: 'tool',
toolName: 'create_file',
args: {
path: 'new-file.md',
content: 'Hello!',
},
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.stepResults[0].success).toBe(false);
expect(result.stepResults[0].error).toBe('Tool execution failed');
});
});
describe('Format Step Execution', () => {
it('should execute format step with variable interpolation', async () => {
mockVaultIndexer.searchVault.mockResolvedValueOnce([
{
path: 'note.md',
title: 'Test Note',
content: 'Note content',
score: 10,
frontmatter: {},
},
] as any);
const definition: WorkflowDefinition = {
id: 'format-workflow',
name: 'Format Workflow',
description: 'Test format step',
steps: [
{
id: 'step_1',
type: 'vault_search',
name: 'Search',
config: {
type: 'vault_search',
query: 'test',
},
},
{
id: 'step_2',
type: 'format',
name: 'Format Results',
config: {
type: 'format',
template: 'Search results: {{step_1.output}}',
},
dependsOn: 'step_1',
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
expect(result.stepResults[1].success).toBe(true);
expect(typeof result.stepResults[1].data).toBe('string');
expect(result.stepResults[1].data as string).toContain('Search results:');
});
it('should format output as JSON when outputFormat is json', async () => {
mockOllamaClient.chat.mockResolvedValueOnce({
role: 'assistant',
content: 'LLM output',
tool_calls: [],
});
const definition: WorkflowDefinition = {
id: 'format-workflow',
name: 'Format Workflow',
description: 'Test JSON format',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'LLM Step',
config: {
type: 'llm',
userPrompt: 'Hello!',
},
},
{
id: 'step_2',
type: 'format',
name: 'Format as JSON',
config: {
type: 'format',
template: '{"result": "{{step_1.output}}"}',
outputFormat: 'json',
},
dependsOn: 'step_1',
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
const formattedOutput = result.stepResults[1].data as string;
expect(formattedOutput).toContain('"result"');
expect(formattedOutput).toContain('LLM output');
});
it('should handle non-JSON template when outputFormat is json', async () => {
const definition: WorkflowDefinition = {
id: 'format-workflow',
name: 'Format Workflow',
description: 'Test JSON format with invalid JSON',
steps: [
{
id: 'step_1',
type: 'format',
name: 'Format Step',
config: {
type: 'format',
template: 'Not valid JSON {{{',
outputFormat: 'json',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
expect(result.stepResults[0].data).toBe('Not valid JSON {{{');
});
});
// ==================== Multi-Step Workflow Tests ====================
describe('Multi-Step Workflow Execution', () => {
it('should execute multiple steps in sequence', async () => {
mockVaultIndexer.searchVault.mockResolvedValueOnce([
{
path: 'note.md',
title: 'Test Note',
content: 'Content',
score: 10,
frontmatter: {},
},
] as any);
mockOllamaClient.chat.mockResolvedValueOnce({
role: 'assistant',
content: 'Extracted: Key decision from meeting',
tool_calls: [],
});
const definition: WorkflowDefinition = {
id: 'multi-step-workflow',
name: 'Multi-Step Workflow',
description: 'Test multi-step execution',
steps: [
{
id: 'step_1',
type: 'vault_search',
name: 'Find Notes',
config: {
type: 'vault_search',
query: 'meeting',
limit: 5,
},
},
{
id: 'step_2',
type: 'llm',
name: 'Extract Decisions',
config: {
type: 'llm',
systemPrompt: 'Extract decisions from meeting notes.',
userPrompt: '{{step_1.output}}',
},
dependsOn: 'step_1',
},
{
id: 'step_3',
type: 'format',
name: 'Format Output',
config: {
type: 'format',
template: '# Summary\n\n{{step_2.output}}',
outputFormat: 'markdown',
},
dependsOn: 'step_2',
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
expect(result.stepResults).toHaveLength(3);
expect(result.stepResults[0].success).toBe(true);
expect(result.stepResults[1].success).toBe(true);
expect(result.stepResults[2].success).toBe(true);
expect(typeof result.finalOutput).toBe('string');
expect(result.finalOutput as string).toContain('# Summary');
});
it('should stop execution when a dependency fails', async () => {
mockVaultIndexer.searchVault.mockRejectedValueOnce(new Error('Search failed'));
const definition: WorkflowDefinition = {
id: 'dependency-workflow',
name: 'Dependency Workflow',
description: 'Test dependency failure',
steps: [
{
id: 'step_1',
type: 'vault_search',
name: 'Search',
config: {
type: 'vault_search',
query: 'test',
},
},
{
id: 'step_2',
type: 'llm',
name: 'Process',
config: {
type: 'llm',
userPrompt: '{{step_1.output}}',
},
dependsOn: 'step_1',
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.stepResults).toHaveLength(2);
expect(result.stepResults[0].success).toBe(false);
expect(result.stepResults[0].error).toBe('Search failed');
// Step 2 should be skipped due to failed dependency
expect(result.stepResults[1].success).toBe(false);
expect(result.stepResults[1].error).toContain("Dependency 'step_1' failed");
});
it('should pass data between steps via variable interpolation', async () => {
mockOllamaClient.chat.mockResolvedValueOnce({
role: 'assistant',
content: 'Processed data',
tool_calls: [],
});
const definition: WorkflowDefinition = {
id: 'interpolation-workflow',
name: 'Interpolation Workflow',
description: 'Test variable interpolation between steps',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'Generate Data',
config: {
type: 'llm',
userPrompt: 'Generate some data',
},
},
{
id: 'step_2',
type: 'format',
name: 'Use Data',
config: {
type: 'format',
template: 'Previous result: {{step_1.output}}',
},
dependsOn: 'step_1',
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
expect(result.stepResults[1].data as string).toContain('Previous result: Processed data');
});
it('should handle initial variables correctly', async () => {
mockVaultIndexer.searchVault.mockResolvedValueOnce([
{
path: 'note.md',
title: 'Meeting',
content: 'Content',
score: 10,
frontmatter: {},
},
] as any);
const definition: WorkflowDefinition = {
id: 'initial-vars-workflow',
name: 'Initial Variables Workflow',
description: 'Test initial variables',
steps: [
{
id: 'step_1',
type: 'vault_search',
name: 'Search',
config: {
type: 'vault_search',
query: '{{original_query}}',
},
},
],
};
await engine.executeWorkflow(definition, { original_query: 'my search term' });
expect(mockVaultIndexer.searchVault).toHaveBeenCalledWith('my search term', 5);
});
it('should mark workflow as partial success when some steps fail', async () => {
mockOllamaClient.chat.mockResolvedValueOnce({
role: 'assistant',
content: 'First response',
tool_calls: [],
});
mockOllamaClient.chat.mockRejectedValueOnce(new Error('Second LLM call failed'));
const definition: WorkflowDefinition = {
id: 'partial-workflow',
name: 'Partial Workflow',
description: 'Test partial success',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'First LLM Call',
config: {
type: 'llm',
userPrompt: 'First prompt',
},
},
{
id: 'step_2',
type: 'llm',
name: 'Second LLM Call',
config: {
type: 'llm',
userPrompt: 'Second prompt',
},
},
{
id: 'step_3',
type: 'format',
name: 'Format',
config: {
type: 'format',
template: 'Done',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.stepResults).toHaveLength(3);
expect(result.stepResults[0].success).toBe(true);
expect(result.stepResults[1].success).toBe(false);
expect(result.stepResults[2].success).toBe(true);
});
});
// ==================== Variable Interpolation Tests ====================
describe('Variable Interpolation', () => {
it('should interpolate simple variables', async () => {
mockVaultIndexer.searchVault.mockResolvedValueOnce([
{
path: 'note.md',
title: 'Note',
content: 'Content',
score: 10,
frontmatter: {},
},
] as any);
const definition: WorkflowDefinition = {
id: 'interpolation-workflow',
name: 'Interpolation Test',
description: 'Test variable interpolation',
steps: [
{
id: 'step_1',
type: 'vault_search',
name: 'Search',
config: {
type: 'vault_search',
query: 'test',
},
},
{
id: 'step_2',
type: 'llm',
name: 'Process',
config: {
type: 'llm',
userPrompt: 'Here are the results: {{step_1.output}}',
},
dependsOn: 'step_1',
},
],
};
await engine.executeWorkflow(definition);
// Verify the LLM was called with interpolated content
const chatCall = mockOllamaClient.chat.mock.calls[0];
const messages = chatCall[0] as OllamaMessage[];
const userMessage = messages.find((m) => m.role === 'user');
expect(userMessage?.content).toContain('Here are the results:');
});
it('should handle nested property access in interpolation', async () => {
mockToolExecutor.executeTool.mockResolvedValueOnce({
success: true,
message: 'Success',
data: {
path: 'file.md',
content: 'File content',
metadata: { author: 'John' },
},
});
const definition: WorkflowDefinition = {
id: 'nested-workflow',
name: 'Nested Interpolation',
description: 'Test nested property access',
steps: [
{
id: 'step_1',
type: 'tool',
name: 'Read File',
config: {
type: 'tool',
toolName: 'read_vault_file',
args: { path: 'file.md' },
},
},
{
id: 'step_2',
type: 'format',
name: 'Extract Author',
config: {
type: 'format',
template: 'Author: {{step_1.output.data.metadata.author}}',
},
dependsOn: 'step_1',
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
const output = result.stepResults[1].data as string;
expect(output).toContain('Author:');
});
it('should keep placeholder when variable not found', async () => {
const definition: WorkflowDefinition = {
id: 'missing-var-workflow',
name: 'Missing Variable',
description: 'Test missing variable handling',
steps: [
{
id: 'step_1',
type: 'format',
name: 'Format',
config: {
type: 'format',
template: 'Hello {{nonexistent.output}}!',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
expect(result.stepResults[0].data).toBe('Hello {{nonexistent.output}}!');
});
it('should interpolate variables in tool args', async () => {
mockOllamaClient.chat.mockResolvedValueOnce({
role: 'assistant',
content: 'Some data',
tool_calls: [],
});
const definition: WorkflowDefinition = {
id: 'tool-args-workflow',
name: 'Tool Args Interpolation',
description: 'Test variable interpolation in tool args',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'Generate Content',
config: {
type: 'llm',
userPrompt: 'Generate content',
},
},
{
id: 'step_2',
type: 'tool',
name: 'Create File',
config: {
type: 'tool',
toolName: 'create_file',
args: {
path: 'output.md',
content: '{{step_1.output}}',
},
},
dependsOn: 'step_1',
},
],
};
await engine.executeWorkflow(definition);
// Verify tool was called with interpolated args
expect(mockToolExecutor.executeTool).toHaveBeenCalledWith(
'create_file',
expect.objectContaining({
content: 'Some data',
})
);
});
});
// ==================== Topological Sort Tests ====================
describe('Step Ordering (Topological Sort)', () => {
it('should execute steps in correct order based on dependencies', async () => {
const executionOrder: string[] = [];
mockOllamaClient.chat = jest.fn().mockImplementation(() => {
return Promise.resolve({
role: 'assistant',
content: 'Response',
tool_calls: [],
});
});
const definition: WorkflowDefinition = {
id: 'order-workflow',
name: 'Order Test',
description: 'Test step ordering',
steps: [
{
id: 'step_c',
type: 'format',
name: 'Final Format',
config: {
type: 'format',
template: '{{step_a.output}} + {{step_b.output}}',
},
dependsOn: 'step_b',
},
{
id: 'step_a',
type: 'llm',
name: 'First LLM',
config: {
type: 'llm',
userPrompt: 'First',
},
},
{
id: 'step_b',
type: 'llm',
name: 'Second LLM',
config: {
type: 'llm',
userPrompt: 'Second',
},
dependsOn: 'step_a',
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
// Verify step_a was executed before step_b
const stepAIndex = result.stepResults.findIndex((r) => r.stepId === 'step_a');
const stepBIndex = result.stepResults.findIndex((r) => r.stepId === 'step_b');
const stepCIndex = result.stepResults.findIndex((r) => r.stepId === 'step_c');
expect(stepAIndex).toBeLessThan(stepBIndex);
expect(stepBIndex).toBeLessThan(stepCIndex);
});
it('should detect circular dependencies', async () => {
const definition: WorkflowDefinition = {
id: 'circular-workflow',
name: 'Circular Test',
description: 'Test circular dependency detection',
steps: [
{
id: 'step_a',
type: 'llm',
name: 'Step A',
config: {
type: 'llm',
userPrompt: 'A',
},
dependsOn: 'step_b',
},
{
id: 'step_b',
type: 'llm',
name: 'Step B',
config: {
type: 'llm',
userPrompt: 'B',
},
dependsOn: 'step_a',
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toContain('Circular dependency');
});
});
// ==================== Built-in Workflows Tests ====================
describe('Built-in Workflows', () => {
it('should provide meeting summary workflow', () => {
const workflows = WorkflowEngine.getBuiltInWorkflows();
const meetingWorkflow = workflows.find((w) => w.id === 'meeting-summary');
expect(meetingWorkflow).toBeDefined();
expect(meetingWorkflow?.name).toBe('Meeting Notes Summary');
expect(meetingWorkflow?.steps).toHaveLength(3);
});
it('should provide note analyzer workflow', () => {
const workflows = WorkflowEngine.getBuiltInWorkflows();
const analyzerWorkflow = workflows.find((w) => w.id === 'note-analyzer');
expect(analyzerWorkflow).toBeDefined();
expect(analyzerWorkflow?.name).toBe('Note Analyzer');
expect(analyzerWorkflow?.steps).toHaveLength(3);
});
it('should return multiple built-in workflows', () => {
const workflows = WorkflowEngine.getBuiltInWorkflows();
expect(workflows.length).toBeGreaterThanOrEqual(2);
});
});
// ==================== Timeout and Limits Tests ====================
describe('Timeout and Limits', () => {
it('should respect maxSteps limit', async () => {
// Create engine with very low max steps
const limitedEngine = new WorkflowEngine(
createMockVault() as any,
createMockApp() as any,
'http://localhost:11434',
'llama3',
{ maxSteps: 2 }
);
mockOllamaClient.chat = jest.fn().mockResolvedValue({
role: 'assistant',
content: 'Response',
tool_calls: [],
});
const definition: WorkflowDefinition = {
id: 'many-steps-workflow',
name: 'Many Steps',
description: 'Test max steps limit',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'Step 1',
config: { type: 'llm', userPrompt: '1' },
},
{
id: 'step_2',
type: 'llm',
name: 'Step 2',
config: { type: 'llm', userPrompt: '2' },
},
{
id: 'step_3',
type: 'llm',
name: 'Step 3',
config: { type: 'llm', userPrompt: '3' },
},
],
};
const result = await limitedEngine.executeWorkflow(definition);
expect(result.success).toBe(false);
expect(result.error).toContain('exceeded maximum step count');
});
});
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it('should handle empty workflow result data', async () => {
mockVaultIndexer.searchVault.mockResolvedValueOnce([]);
const definition: WorkflowDefinition = {
id: 'empty-result-workflow',
name: 'Empty Results',
description: 'Test empty search results',
steps: [
{
id: 'step_1',
type: 'vault_search',
name: 'Search',
config: {
type: 'vault_search',
query: 'nonexistent',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
expect(result.stepResults[0].data).toEqual([]);
});
it('should handle LLM returning empty content', async () => {
mockOllamaClient.chat.mockResolvedValueOnce({
role: 'assistant',
content: '',
tool_calls: [],
});
const definition: WorkflowDefinition = {
id: 'empty-llm-workflow',
name: 'Empty LLM',
description: 'Test empty LLM response',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'LLM',
config: {
type: 'llm',
userPrompt: 'Hello',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
expect(result.stepResults[0].data).toBe('');
});
it('should handle tool returning null data', async () => {
mockToolExecutor.executeTool.mockResolvedValueOnce({
success: true,
message: 'Done',
data: null,
});
const definition: WorkflowDefinition = {
id: 'null-data-workflow',
name: 'Null Data',
description: 'Test null tool data',
steps: [
{
id: 'step_1',
type: 'tool',
name: 'Tool',
config: {
type: 'tool',
toolName: 'some_tool',
args: {},
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
expect(result.stepResults[0].data).toEqual({
success: true,
message: 'Done',
data: null,
});
});
it('should include workflow metadata in result', async () => {
mockOllamaClient.chat.mockResolvedValueOnce({
role: 'assistant',
content: 'Response',
tool_calls: [],
});
const definition: WorkflowDefinition = {
id: 'metadata-workflow',
name: 'Metadata Test',
description: 'Test workflow metadata',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'LLM',
config: {
type: 'llm',
userPrompt: 'Hello',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.workflowId).toBe('metadata-workflow');
expect(result.workflowName).toBe('Metadata Test');
expect(result.stepResults[0].timestamp).toBeGreaterThan(0);
});
it('should handle steps without dependsOn executing in definition order', async () => {
mockOllamaClient.chat = jest.fn().mockResolvedValue({
role: 'assistant',
content: 'Response',
tool_calls: [],
});
const definition: WorkflowDefinition = {
id: 'no-deps-workflow',
name: 'No Dependencies',
description: 'Test execution without dependencies',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'Step 1',
config: { type: 'llm', userPrompt: '1' },
},
{
id: 'step_2',
type: 'llm',
name: 'Step 2',
config: { type: 'llm', userPrompt: '2' },
},
{
id: 'step_3',
type: 'llm',
name: 'Step 3',
config: { type: 'llm', userPrompt: '3' },
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.success).toBe(true);
expect(result.stepResults).toHaveLength(3);
expect(mockOllamaClient.chat).toHaveBeenCalledTimes(3);
});
it('should handle array interpolation in tool args', async () => {
mockOllamaClient.chat.mockResolvedValueOnce({
role: 'assistant',
content: 'First LLM output',
tool_calls: [],
});
const definition: WorkflowDefinition = {
id: 'array-args-workflow',
name: 'Array Args',
description: 'Test array interpolation in tool args',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'Generate',
config: {
type: 'llm',
userPrompt: 'Generate content',
},
},
{
id: 'step_2',
type: 'tool',
name: 'Create',
config: {
type: 'tool',
toolName: 'create_file',
args: {
path: 'output.md',
content: '{{step_1.output}}',
},
},
dependsOn: 'step_1',
},
],
};
await engine.executeWorkflow(definition);
expect(mockToolExecutor.executeTool).toHaveBeenCalled();
});
});
// ==================== Workflow Execution Result Tests ====================
describe('WorkflowExecutionResult', () => {
it('should return finalOutput from last successful step', async () => {
mockOllamaClient.chat.mockResolvedValueOnce({
role: 'assistant',
content: 'Intermediate response',
tool_calls: [],
});
const definition: WorkflowDefinition = {
id: 'final-output-workflow',
name: 'Final Output Test',
description: 'Test final output',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'LLM',
config: {
type: 'llm',
userPrompt: 'Hello',
},
},
{
id: 'step_2',
type: 'format',
name: 'Format',
config: {
type: 'format',
template: '# Final\n\n{{step_1.output}}',
},
dependsOn: 'step_1',
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.finalOutput).toBe('# Final\n\nIntermediate response');
});
it('should return null finalOutput when all steps fail', async () => {
mockOllamaClient.chat.mockRejectedValueOnce(new Error('LLM failed'));
const definition: WorkflowDefinition = {
id: 'failed-workflow',
name: 'Failed Workflow',
description: 'Test all steps failing',
steps: [
{
id: 'step_1',
type: 'llm',
name: 'LLM',
config: {
type: 'llm',
userPrompt: 'Hello',
},
},
],
};
const result = await engine.executeWorkflow(definition);
expect(result.finalOutput).toBeNull();
});
});
});