mirror of
https://github.com/xtr-dev/payload-automation.git
synced 2025-12-11 01:03:23 +00:00
Major Features: • Add persistent error tracking for timeout/network failures that bypasses PayloadCMS output limitations • Implement smart error classification (timeout, DNS, connection, network) with duration-based detection • Add comprehensive test infrastructure with MongoDB in-memory testing and enhanced mocking • Fix HTTP request handler error preservation with detailed context storage • Add independent execution tracking with success/failure status and duration metrics Technical Improvements: • Update JSONPath documentation to use correct $.trigger.doc syntax across all step types • Fix PayloadCMS job execution to use runByID instead of run() for reliable task processing • Add enhanced HTTP error handling that preserves outputs for 4xx/5xx status codes • Implement proper nock configuration with undici for Node.js 22 fetch interception • Add comprehensive unit tests for WorkflowExecutor with mocked PayloadCMS instances Developer Experience: • Add detailed error information in workflow context with URL, method, timeout, attempts • Update README with HTTP error handling patterns and enhanced error tracking examples • Add test helpers and setup infrastructure for reliable integration testing • Fix workflow step validation and JSONPath field descriptions Breaking Changes: None - fully backward compatible 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
472 lines
14 KiB
TypeScript
472 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { WorkflowExecutor } from '../core/workflow-executor.js'
|
|
import type { Payload } from 'payload'
|
|
|
|
describe('WorkflowExecutor', () => {
|
|
let mockPayload: Payload
|
|
let mockLogger: any
|
|
let executor: WorkflowExecutor
|
|
|
|
beforeEach(() => {
|
|
mockLogger = {
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn()
|
|
}
|
|
|
|
mockPayload = {
|
|
jobs: {
|
|
queue: vi.fn().mockResolvedValue({ id: 'job-123' }),
|
|
run: vi.fn().mockResolvedValue(undefined)
|
|
},
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
find: vi.fn()
|
|
} as any
|
|
|
|
executor = new WorkflowExecutor(mockPayload, mockLogger)
|
|
})
|
|
|
|
describe('resolveJSONPathValue', () => {
|
|
it('should resolve simple JSONPath expressions', () => {
|
|
const context = {
|
|
trigger: {
|
|
doc: { id: 'test-id', title: 'Test Title' }
|
|
},
|
|
steps: {}
|
|
}
|
|
|
|
const result = (executor as any).resolveJSONPathValue('$.trigger.doc.id', context)
|
|
expect(result).toBe('test-id')
|
|
})
|
|
|
|
it('should resolve nested JSONPath expressions', () => {
|
|
const context = {
|
|
trigger: {
|
|
doc: {
|
|
id: 'test-id',
|
|
nested: { value: 'nested-value' }
|
|
}
|
|
},
|
|
steps: {}
|
|
}
|
|
|
|
const result = (executor as any).resolveJSONPathValue('$.trigger.doc.nested.value', context)
|
|
expect(result).toBe('nested-value')
|
|
})
|
|
|
|
it('should return original value for non-JSONPath strings', () => {
|
|
const context = { trigger: {}, steps: {} }
|
|
const result = (executor as any).resolveJSONPathValue('plain-string', context)
|
|
expect(result).toBe('plain-string')
|
|
})
|
|
|
|
it('should handle missing JSONPath gracefully', () => {
|
|
const context = { trigger: {}, steps: {} }
|
|
const result = (executor as any).resolveJSONPathValue('$.trigger.missing.field', context)
|
|
expect(result).toBe('$.trigger.missing.field') // Should return original if resolution fails
|
|
})
|
|
})
|
|
|
|
describe('resolveStepInput', () => {
|
|
it('should resolve all JSONPath expressions in step config', () => {
|
|
const config = {
|
|
url: '$.trigger.webhook.url',
|
|
message: 'Static message',
|
|
data: {
|
|
id: '$.trigger.doc.id',
|
|
title: '$.trigger.doc.title'
|
|
}
|
|
}
|
|
|
|
const context = {
|
|
trigger: {
|
|
doc: { id: 'doc-123', title: 'Doc Title' },
|
|
webhook: { url: 'https://example.com/webhook' }
|
|
},
|
|
steps: {}
|
|
}
|
|
|
|
const result = (executor as any).resolveStepInput(config, context)
|
|
|
|
expect(result).toEqual({
|
|
url: 'https://example.com/webhook',
|
|
message: 'Static message',
|
|
data: {
|
|
id: 'doc-123',
|
|
title: 'Doc Title'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('should handle arrays with JSONPath expressions', () => {
|
|
const config = {
|
|
items: ['$.trigger.doc.id', 'static-value', '$.trigger.doc.title']
|
|
}
|
|
|
|
const context = {
|
|
trigger: {
|
|
doc: { id: 'doc-123', title: 'Doc Title' }
|
|
},
|
|
steps: {}
|
|
}
|
|
|
|
const result = (executor as any).resolveStepInput(config, context)
|
|
|
|
expect(result).toEqual({
|
|
items: ['doc-123', 'static-value', 'Doc Title']
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('resolveExecutionOrder', () => {
|
|
it('should handle steps without dependencies', () => {
|
|
const steps = [
|
|
{ name: 'step1', step: 'http-request' },
|
|
{ name: 'step2', step: 'create-document' },
|
|
{ name: 'step3', step: 'http-request' }
|
|
]
|
|
|
|
const result = (executor as any).resolveExecutionOrder(steps)
|
|
|
|
expect(result).toHaveLength(1) // All in one batch
|
|
expect(result[0]).toHaveLength(3) // All steps in first batch
|
|
})
|
|
|
|
it('should handle steps with dependencies', () => {
|
|
const steps = [
|
|
{ name: 'step1', step: 'http-request' },
|
|
{ name: 'step2', step: 'create-document', dependencies: ['step1'] },
|
|
{ name: 'step3', step: 'http-request', dependencies: ['step2'] }
|
|
]
|
|
|
|
const result = (executor as any).resolveExecutionOrder(steps)
|
|
|
|
expect(result).toHaveLength(3) // Three batches
|
|
expect(result[0]).toHaveLength(1) // step1 first
|
|
expect(result[1]).toHaveLength(1) // step2 second
|
|
expect(result[2]).toHaveLength(1) // step3 third
|
|
})
|
|
|
|
it('should handle parallel execution with partial dependencies', () => {
|
|
const steps = [
|
|
{ name: 'step1', step: 'http-request' },
|
|
{ name: 'step2', step: 'create-document' },
|
|
{ name: 'step3', step: 'http-request', dependencies: ['step1'] },
|
|
{ name: 'step4', step: 'create-document', dependencies: ['step1'] }
|
|
]
|
|
|
|
const result = (executor as any).resolveExecutionOrder(steps)
|
|
|
|
expect(result).toHaveLength(2) // Two batches
|
|
expect(result[0]).toHaveLength(2) // step1 and step2 in parallel
|
|
expect(result[1]).toHaveLength(2) // step3 and step4 in parallel
|
|
})
|
|
|
|
it('should detect circular dependencies', () => {
|
|
const steps = [
|
|
{ name: 'step1', step: 'http-request', dependencies: ['step2'] },
|
|
{ name: 'step2', step: 'create-document', dependencies: ['step1'] }
|
|
]
|
|
|
|
expect(() => {
|
|
(executor as any).resolveExecutionOrder(steps)
|
|
}).toThrow('Circular dependency detected')
|
|
})
|
|
})
|
|
|
|
describe('evaluateCondition', () => {
|
|
it('should evaluate simple equality conditions', () => {
|
|
const context = {
|
|
trigger: {
|
|
doc: { status: 'published' }
|
|
},
|
|
steps: {}
|
|
}
|
|
|
|
const result = (executor as any).evaluateCondition('$.trigger.doc.status == "published"', context)
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
it('should evaluate inequality conditions', () => {
|
|
const context = {
|
|
trigger: {
|
|
doc: { count: 5 }
|
|
},
|
|
steps: {}
|
|
}
|
|
|
|
const result = (executor as any).evaluateCondition('$.trigger.doc.count > 3', context)
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
it('should return false for invalid conditions', () => {
|
|
const context = { trigger: {}, steps: {} }
|
|
const result = (executor as any).evaluateCondition('invalid condition syntax', context)
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
it('should handle missing context gracefully', () => {
|
|
const context = { trigger: {}, steps: {} }
|
|
const result = (executor as any).evaluateCondition('$.trigger.doc.status == "published"', context)
|
|
expect(result).toBe(false) // Missing values should fail condition
|
|
})
|
|
})
|
|
|
|
describe('safeSerialize', () => {
|
|
it('should serialize simple objects', () => {
|
|
const obj = { name: 'test', value: 123 }
|
|
const result = (executor as any).safeSerialize(obj)
|
|
expect(result).toBe('{"name":"test","value":123}')
|
|
})
|
|
|
|
it('should handle circular references', () => {
|
|
const obj: any = { name: 'test' }
|
|
obj.self = obj // Create circular reference
|
|
|
|
const result = (executor as any).safeSerialize(obj)
|
|
expect(result).toContain('"name":"test"')
|
|
expect(result).toContain('"self":"[Circular]"')
|
|
})
|
|
|
|
it('should handle undefined and null values', () => {
|
|
const obj = {
|
|
defined: 'value',
|
|
undefined: undefined,
|
|
null: null
|
|
}
|
|
|
|
const result = (executor as any).safeSerialize(obj)
|
|
const parsed = JSON.parse(result)
|
|
expect(parsed.defined).toBe('value')
|
|
expect(parsed.null).toBe(null)
|
|
expect(parsed).not.toHaveProperty('undefined') // undefined props are omitted
|
|
})
|
|
})
|
|
|
|
describe('executeWorkflow', () => {
|
|
it('should execute workflow with single step', async () => {
|
|
const workflow = {
|
|
id: 'test-workflow',
|
|
steps: [
|
|
{
|
|
name: 'test-step',
|
|
step: 'http-request-step',
|
|
url: 'https://example.com',
|
|
method: 'GET'
|
|
}
|
|
]
|
|
}
|
|
|
|
const context = {
|
|
trigger: { doc: { id: 'test-doc' } },
|
|
steps: {}
|
|
}
|
|
|
|
// Mock step task
|
|
const mockStepTask = {
|
|
taskSlug: 'http-request-step',
|
|
handler: vi.fn().mockResolvedValue({
|
|
output: { status: 200, body: 'success' },
|
|
state: 'succeeded'
|
|
})
|
|
}
|
|
|
|
// Mock the step tasks registry
|
|
const originalStepTasks = (executor as any).stepTasks
|
|
;(executor as any).stepTasks = [mockStepTask]
|
|
|
|
const result = await (executor as any).executeWorkflow(workflow, context)
|
|
|
|
expect(result.status).toBe('completed')
|
|
expect(result.context.steps['test-step']).toBeDefined()
|
|
expect(result.context.steps['test-step'].state).toBe('succeeded')
|
|
expect(mockStepTask.handler).toHaveBeenCalledOnce()
|
|
|
|
// Restore original step tasks
|
|
;(executor as any).stepTasks = originalStepTasks
|
|
})
|
|
|
|
it('should handle step execution failures', async () => {
|
|
const workflow = {
|
|
id: 'test-workflow',
|
|
steps: [
|
|
{
|
|
name: 'failing-step',
|
|
step: 'http-request-step',
|
|
url: 'https://invalid-url',
|
|
method: 'GET'
|
|
}
|
|
]
|
|
}
|
|
|
|
const context = {
|
|
trigger: { doc: { id: 'test-doc' } },
|
|
steps: {}
|
|
}
|
|
|
|
// Mock failing step task
|
|
const mockStepTask = {
|
|
taskSlug: 'http-request-step',
|
|
handler: vi.fn().mockRejectedValue(new Error('Network error'))
|
|
}
|
|
|
|
const originalStepTasks = (executor as any).stepTasks
|
|
;(executor as any).stepTasks = [mockStepTask]
|
|
|
|
const result = await (executor as any).executeWorkflow(workflow, context)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.error).toContain('Network error')
|
|
expect(result.context.steps['failing-step']).toBeDefined()
|
|
expect(result.context.steps['failing-step'].state).toBe('failed')
|
|
|
|
;(executor as any).stepTasks = originalStepTasks
|
|
})
|
|
|
|
it('should execute steps with dependencies in correct order', async () => {
|
|
const workflow = {
|
|
id: 'test-workflow',
|
|
steps: [
|
|
{
|
|
name: 'step1',
|
|
step: 'http-request-step',
|
|
url: 'https://example.com/1',
|
|
method: 'GET'
|
|
},
|
|
{
|
|
name: 'step2',
|
|
step: 'http-request-step',
|
|
url: 'https://example.com/2',
|
|
method: 'GET',
|
|
dependencies: ['step1']
|
|
},
|
|
{
|
|
name: 'step3',
|
|
step: 'http-request-step',
|
|
url: 'https://example.com/3',
|
|
method: 'GET',
|
|
dependencies: ['step1']
|
|
}
|
|
]
|
|
}
|
|
|
|
const context = {
|
|
trigger: { doc: { id: 'test-doc' } },
|
|
steps: {}
|
|
}
|
|
|
|
const executionOrder: string[] = []
|
|
const mockStepTask = {
|
|
taskSlug: 'http-request-step',
|
|
handler: vi.fn().mockImplementation(async ({ input }) => {
|
|
executionOrder.push(input.stepName)
|
|
return {
|
|
output: { status: 200, body: 'success' },
|
|
state: 'succeeded'
|
|
}
|
|
})
|
|
}
|
|
|
|
const originalStepTasks = (executor as any).stepTasks
|
|
;(executor as any).stepTasks = [mockStepTask]
|
|
|
|
const result = await (executor as any).executeWorkflow(workflow, context)
|
|
|
|
expect(result.status).toBe('completed')
|
|
expect(executionOrder[0]).toBe('step1') // First step executed first
|
|
expect(executionOrder.slice(1)).toContain('step2') // Dependent steps after
|
|
expect(executionOrder.slice(1)).toContain('step3')
|
|
|
|
;(executor as any).stepTasks = originalStepTasks
|
|
})
|
|
})
|
|
|
|
describe('findStepTask', () => {
|
|
it('should find registered step task by slug', () => {
|
|
const mockStepTask = {
|
|
taskSlug: 'test-step',
|
|
handler: vi.fn()
|
|
}
|
|
|
|
const originalStepTasks = (executor as any).stepTasks
|
|
;(executor as any).stepTasks = [mockStepTask]
|
|
|
|
const result = (executor as any).findStepTask('test-step')
|
|
expect(result).toBe(mockStepTask)
|
|
|
|
;(executor as any).stepTasks = originalStepTasks
|
|
})
|
|
|
|
it('should return undefined for unknown step type', () => {
|
|
const result = (executor as any).findStepTask('unknown-step')
|
|
expect(result).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('validateStepConfiguration', () => {
|
|
it('should validate step with required fields', () => {
|
|
const step = {
|
|
name: 'valid-step',
|
|
step: 'http-request-step',
|
|
url: 'https://example.com',
|
|
method: 'GET'
|
|
}
|
|
|
|
expect(() => {
|
|
(executor as any).validateStepConfiguration(step)
|
|
}).not.toThrow()
|
|
})
|
|
|
|
it('should throw error for step without name', () => {
|
|
const step = {
|
|
step: 'http-request-step',
|
|
url: 'https://example.com',
|
|
method: 'GET'
|
|
}
|
|
|
|
expect(() => {
|
|
(executor as any).validateStepConfiguration(step)
|
|
}).toThrow('Step name is required')
|
|
})
|
|
|
|
it('should throw error for step without type', () => {
|
|
const step = {
|
|
name: 'test-step',
|
|
url: 'https://example.com',
|
|
method: 'GET'
|
|
}
|
|
|
|
expect(() => {
|
|
(executor as any).validateStepConfiguration(step)
|
|
}).toThrow('Step type is required')
|
|
})
|
|
})
|
|
|
|
describe('createExecutionContext', () => {
|
|
it('should create context with trigger data', () => {
|
|
const triggerContext = {
|
|
operation: 'create',
|
|
doc: { id: 'test-id', title: 'Test Doc' },
|
|
collection: 'posts'
|
|
}
|
|
|
|
const result = (executor as any).createExecutionContext(triggerContext)
|
|
|
|
expect(result.trigger).toEqual(triggerContext)
|
|
expect(result.steps).toEqual({})
|
|
expect(result.metadata).toBeDefined()
|
|
expect(result.metadata.startedAt).toBeDefined()
|
|
})
|
|
|
|
it('should include metadata in context', () => {
|
|
const triggerContext = { doc: { id: 'test' } }
|
|
const result = (executor as any).createExecutionContext(triggerContext)
|
|
|
|
expect(result.metadata).toHaveProperty('startedAt')
|
|
expect(result.metadata).toHaveProperty('executionId')
|
|
expect(typeof result.metadata.executionId).toBe('string')
|
|
})
|
|
})
|
|
}) |