11 Commits

Author SHA1 Message Date
Bas
13968904c0 "Claude Code Review workflow" 2025-09-12 16:25:57 +02:00
Bas
e3b79710ba "Claude PR Assistant workflow" 2025-09-12 16:25:56 +02:00
10a4ca1b35 0.0.40 2025-09-11 21:38:05 +02:00
4c513aa730 Update documentation for v0.0.39 Handlebars template system
- Replace JSONPath references with Handlebars syntax
- Add comprehensive template examples and type conversion docs
- Update CHANGELOG with v0.0.39 breaking changes and migration notes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 21:37:55 +02:00
f29f08972b 0.0.39 2025-09-11 21:33:15 +02:00
9c75b28cd7 Add WorkflowBuilder component and related modules
- Introduce `WorkflowBuilder` for visual workflow configuration
- Add child components: `WorkflowToolbar`, `StepConfigurationForm`, and `StepNode`
- Implement `WorkflowBuilderField` for integration with PayloadCMS
- Provide dynamic step type handling and JSON-based configuration editing
- Enhance UI with drag-and-drop functionality and step dependencies management
2025-09-11 21:32:55 +02:00
243bff2de3 Update CLAUDE.md: streamline testing sections and remove outdated references
- Simplify test command descriptions for clarity (`pnpm test`, `pnpm test:int`, `pnpm test:e2e`)
- Remove obsolete testing strategy details and associated dependencies (Vitest, Playwright, MongoDB Memory Server)
- Refine documentation to improve maintainability and reflect the current testing setup
2025-09-11 13:06:45 +02:00
705ed331fa Remove migration guide, test helpers, and test setup files
- Delete `MIGRATION-v0.0.37.md` as it is no longer necessary
- Remove outdated files: `test-helpers.ts`, `test-setup.ts`, `test-trigger.ts`, and `vitest.config.ts`
- Streamline project by eliminating obsolete and unused test-related files and configurations
2025-09-11 13:03:07 +02:00
e0b13d3515 Remove obsolete test files and their associated cases
- Delete unused test files: `basic.test.ts`, `condition-fix.spec.ts`, `create-document-step.test.ts`, and `error-scenarios.spec.ts`
- Streamline codebase by eliminating redundant and outdated test cases
- Improve maintainability by keeping only relevant and up-to-date tests
2025-09-10 21:12:05 +02:00
0da87dbda7 0.0.38 2025-09-10 19:01:04 +02:00
508f4c418a Add migration document for v0.0.37 and update parameter field
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 19:01:01 +02:00
31 changed files with 1426 additions and 2945 deletions

View File

@@ -0,0 +1,78 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# model: "claude-opus-4-1-20250805"
# Direct prompt for automated review (no @claude mention needed)
direct_prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Be constructive and helpful in your feedback.
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
# use_sticky_comment: true
# Optional: Customize review based on file types
# direct_prompt: |
# Review this PR focusing on:
# - For TypeScript files: Type safety and proper interface usage
# - For API endpoints: Security, input validation, and error handling
# - For React components: Performance, accessibility, and best practices
# - For tests: Coverage, edge cases, and test quality
# Optional: Different prompts for different authors
# direct_prompt: |
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
# Optional: Add specific tools for running tests or linting
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
# Optional: Skip review for certain conditions
# if: |
# !contains(github.event.pull_request.title, '[skip-review]') &&
# !contains(github.event.pull_request.title, '[WIP]')

64
.github/workflows/claude.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# model: "claude-opus-4-1-20250805"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test

View File

@@ -5,6 +5,58 @@ All notable changes to the PayloadCMS Automation Plugin will be documented in th
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.0.39] - 2025-09-11
### Changed
- **Breaking Change**: Replaced JSONPath with Handlebars template system for better string interpolation
- Automatic type conversion for numeric and boolean fields based on field names
- Enhanced condition evaluation with Handlebars template support
- Simplified data resolution syntax: `{{steps.stepName.output.field}}` instead of `$.steps.stepName.output.field`
### Removed
- **Breaking Change**: Removed JSONPath dependency (`jsonpath-plus`) and all backward compatibility
- Removed `resolveJSONPathValue` and `parseConditionValue` methods
### Added
- Handlebars template engine for dynamic data interpolation
- Smart type conversion: strings to numbers/booleans based on field patterns
- Enhanced template examples and documentation
- Support for complex string building: `"Post {{trigger.doc.title}} was updated"`
### Migration Notes
- Update all workflow configurations to use Handlebars syntax:
- `$.steps.stepName.output.id``{{steps.stepName.output.id}}`
- `$.trigger.doc.status == 'published'``{{trigger.doc.status}} == 'published'`
- String interpolation now works naturally: `"Message: {{steps.step1.output.result}}"`
- Numeric fields (`timeout`, `retries`, etc.) are automatically converted from strings to numbers
## [0.0.38] - 2025-09-10
### Changed
- Updated dependencies to PayloadCMS 3.45.0
- Enhanced plugin configuration and stability
## [0.0.37] - 2025-09-XX
### Removed
- **Breaking Change**: Removed built-in cron trigger implementation in favor of webhook-based scheduling
- Removed unused plugin modules and associated tests
- Removed `initCollectionHooks` and associated migration guides
### Changed
- Refactored triggers to TriggerConfig pattern
- Simplified executor architecture by removing executorRegistry pattern
- Updated to on-demand workflow execution creation
### Added
- Migration guide for v0.0.37 (MIGRATION-v0.0.37.md)
- Enhanced parameter field configuration
### Migration Notes
- Built-in cron triggers are no longer supported. Use webhook triggers with external cron services (GitHub Actions, Vercel Cron, etc.)
- Update trigger configurations to use the new TriggerConfig pattern
- See MIGRATION-v0.0.37.md for detailed migration steps
## [0.0.16] - 2025-09-01
### Fixed

View File

@@ -15,9 +15,9 @@ A local copy of the PayloadCMS documentation is available at `./payload-docs/` f
### Essential Commands
- `pnpm dev` - Start development server with Next.js (runs on http://localhost:3000, fallback ports used if occupied)
- `pnpm build` - Build the plugin for production (runs copyfiles, build:types, build:swc)
- `pnpm test` - Run all tests (integration + e2e)
- `pnpm test:int` - Run integration tests with Vitest
- `pnpm test:e2e` - Run end-to-end tests with Playwright
- `pnpm test` - Run tests (currently configured for integration and e2e)
- `pnpm test:int` - Run integration tests
- `pnpm test:e2e` - Run end-to-end tests
- `pnpm lint` - Run ESLint
- `pnpm lint:fix` - Auto-fix ESLint issues
@@ -111,14 +111,8 @@ Steps support a `dependencies` field (array of step names) that:
### Database Configuration
- **Development**: SQLite adapter for simplicity
- **Testing**: MongoDB Memory Server for isolation
- Database selection in `dev/payload.config.ts`
### Testing Strategy
- **Integration Tests** (`dev/int.spec.ts`): Vitest with 30-second timeouts
- **E2E Tests** (`dev/e2e.spec.ts`): Playwright testing against development server
- **Test Database**: MongoDB Memory Server for isolated testing
### Plugin Development Pattern
- Uses spread syntax to extend existing PayloadCMS config
- Maintains database schema consistency when plugin is disabled
@@ -169,7 +163,6 @@ The plugin registers hooks for collections and globals specified in the plugin c
### Key Development Dependencies
- Next.js 15.4.4 for development server
- Vitest + Playwright for testing
- SWC for fast transpilation
- Various PayloadCMS adapters (SQLite, MongoDB, PostgreSQL)
@@ -178,5 +171,4 @@ The plugin registers hooks for collections and globals specified in the plugin c
- `src/plugin/index.ts` - Main plugin configuration and extension logic
- `src/core/workflow-executor.ts` - Core execution engine with dependency resolution
- `src/collections/Workflow.ts` - Workflow collection schema and configuration
- `dev/payload.config.ts` - Development configuration showing plugin integration
- `dev/int.spec.ts` and `dev/e2e.spec.ts` - Testing patterns and setup
- `dev/payload.config.ts` - Development configuration showing plugin integration

View File

@@ -12,7 +12,7 @@ A comprehensive workflow automation plugin for PayloadCMS 3.x that enables visua
-**Scheduled Workflows** - Use webhook triggers with external cron services
- 📊 **Execution Tracking** - Complete history and monitoring of workflow runs
- 🔧 **Extensible Steps** - HTTP requests, document CRUD, email notifications
- 🔍 **JSONPath Integration** - Dynamic data interpolation and transformation
- 🔧 **Handlebars Templates** - Dynamic data interpolation with automatic type conversion
## Installation
@@ -56,7 +56,10 @@ The plugin uses separate exports to avoid bundling server-side code in client bu
import { workflowsPlugin } from '@xtr-dev/payload-automation/server'
// Client-side components
import { TriggerWorkflowButton } from '@xtr-dev/payload-automation/client'
import { StatusCell, ErrorDisplay } from '@xtr-dev/payload-automation/client'
// Helper utilities
import { /* helpers */ } from '@xtr-dev/payload-automation/helpers'
// Types only (safe for both server and client)
import type { WorkflowsPluginConfig } from '@xtr-dev/payload-automation'
@@ -71,7 +74,7 @@ Make external API calls with comprehensive error handling and retry logic.
- Support for GET, POST, PUT, DELETE, PATCH methods
- Authentication: Bearer token, Basic auth, API key headers
- Configurable timeouts and retry logic
- JSONPath integration for dynamic URLs and request bodies
- Handlebars templates for dynamic URLs and request bodies
**Error Handling:**
HTTP Request steps use a **response-based success model** rather than status-code-based failures:
@@ -92,7 +95,7 @@ HTTP Request steps use a **response-based success model** rather than status-cod
}
// Use in workflow conditions:
// "$.steps.apiRequest.output.status >= 400" to handle errors
// "{{steps.apiRequest.output.status}} >= 400" to handle errors
```
This design allows workflows to handle HTTP errors gracefully rather than failing completely, enabling robust error handling and retry logic.
@@ -129,8 +132,8 @@ For network failures (timeouts, DNS errors, connection failures), the plugin pro
}
// Access in workflow conditions:
// "$.steps.httpStep.errorDetails.errorType == 'timeout'"
// "$.steps.httpStep.errorDetails.duration > 5000"
// "{{steps.httpStep.errorDetails.errorType}} == 'timeout'"
// "{{steps.httpStep.errorDetails.duration}} > 5000"
```
### Document Operations
@@ -144,11 +147,39 @@ For network failures (timeouts, DNS errors, connection failures), the plugin pro
## Data Resolution
Use JSONPath to access workflow data:
Use Handlebars templates to access workflow data:
- `$.trigger.doc.id` - Access trigger document
- `$.steps.stepName.output` - Use previous step outputs
- `$.context` - Access workflow context
- `{{trigger.doc.id}}` - Access trigger document
- `{{steps.stepName.output}}` - Use previous step outputs
- `{{context}}` - Access workflow context
### Template Examples
```json
{
"url": "https://api.example.com/posts/{{steps.createPost.output.id}}",
"message": "Post {{trigger.doc.title}} was updated by {{trigger.req.user.email}}",
"timeout": "{{steps.configStep.output.timeoutMs}}"
}
```
### Automatic Type Conversion
Handlebars templates automatically convert string results to appropriate types based on field names:
- **Numbers**: `timeout`, `retries`, `delay`, `port`, `count`, etc. → converted to numbers
- **Booleans**: `enabled`, `active`, `success`, `complete`, etc. → converted to booleans
- **Numeric strings**: `"5000"``5000`, `"3.14"``3.14`
### Conditions
Conditions support Handlebars templates with comparison operators:
```json
{
"condition": "{{trigger.doc.status}} == 'published'"
}
```
## Requirements
@@ -216,6 +247,8 @@ export default async function handler(req, res) {
**Benefits**: Better reliability, proper process isolation, easier debugging, and leverages existing infrastructure.
**Note**: Built-in cron triggers have been removed in v0.0.37+ to focus on webhook-based scheduling which provides better reliability and debugging capabilities.
## Documentation
Full documentation coming soon. For now, explore the development environment in the repository for examples and patterns.

View File

@@ -1,113 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getTestPayload, cleanDatabase } from './test-setup.js'
describe('Workflow Condition Fix Test', () => {
beforeEach(async () => {
await cleanDatabase()
})
afterEach(async () => {
await cleanDatabase()
})
it('should correctly evaluate trigger conditions with $.trigger.doc path', async () => {
const payload = getTestPayload()
// Create a workflow with a condition using the correct JSONPath
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Condition Evaluation',
description: 'Tests that $.trigger.doc.content conditions work',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create',
condition: '$.trigger.doc.content == "TRIGGER_ME"'
}
],
steps: [
{
name: 'audit-step',
step: 'create-document',
collectionSlug: 'auditLog',
data: {
post: '$.trigger.doc.id',
message: 'Condition was met and workflow triggered'
}
}
]
}
})
console.log('Created workflow with condition: $.trigger.doc.content == "TRIGGER_ME"')
// Create a post that SHOULD NOT trigger
const post1 = await payload.create({
collection: 'posts',
data: {
content: 'This should not trigger'
}
})
// Create a post that SHOULD trigger
const post2 = await payload.create({
collection: 'posts',
data: {
content: 'TRIGGER_ME'
}
})
// Wait for workflow execution
await new Promise(resolve => setTimeout(resolve, 5000))
// Check workflow runs - should have exactly 1
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
}
})
console.log(`Found ${runs.totalDocs} workflow runs`)
if (runs.totalDocs > 0) {
console.log('Run statuses:', runs.docs.map(r => r.status))
}
// Should have exactly 1 run for the matching condition
expect(runs.totalDocs).toBe(1)
// Check audit logs - should only have one for post2
const auditLogs = await payload.find({
collection: 'auditLog',
where: {
post: {
equals: post2.id
}
}
})
if (runs.docs[0].status === 'completed') {
expect(auditLogs.totalDocs).toBe(1)
expect(auditLogs.docs[0].message).toBe('Condition was met and workflow triggered')
}
// Verify no audit log for the first post
const noAuditLogs = await payload.find({
collection: 'auditLog',
where: {
post: {
equals: post1.id
}
}
})
expect(noAuditLogs.totalDocs).toBe(0)
console.log('✅ Condition evaluation working with $.trigger.doc path!')
}, 30000)
})

View File

@@ -1,519 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getTestPayload, cleanDatabase } from './test-setup.js'
import { mockHttpBin, testFixtures } from './test-helpers.js'
describe('Error Scenarios and Edge Cases', () => {
beforeEach(async () => {
await cleanDatabase()
// Set up comprehensive mocks for all error scenarios
mockHttpBin.mockAllErrorScenarios()
})
afterEach(async () => {
await cleanDatabase()
mockHttpBin.cleanup()
})
it('should handle HTTP timeout errors gracefully', async () => {
const payload = getTestPayload()
// Clear existing mocks and set up a proper timeout mock
mockHttpBin.cleanup()
mockHttpBin.mockTimeout()
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Error - HTTP Timeout',
description: 'Tests HTTP request timeout handling',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create'
}
],
steps: [
{
...testFixtures.httpRequestStep('https://httpbin.org/delay/10'),
name: 'timeout-request',
method: 'GET',
timeout: 2000, // 2 second timeout
body: null
}
]
}
})
const post = await payload.create({
collection: 'posts',
data: {
content: 'Test Error Timeout Post'
}
})
// Wait for workflow execution (should timeout)
await new Promise(resolve => setTimeout(resolve, 5000))
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
expect(runs.totalDocs).toBe(1)
// Either failed due to timeout or completed (depending on network speed)
expect(['failed', 'completed']).toContain(runs.docs[0].status)
// Verify that detailed error information is preserved via new independent storage system
const context = runs.docs[0].context
const stepContext = context.steps['timeout-request']
// Check that independent execution info was recorded
expect(stepContext.executionInfo).toBeDefined()
expect(stepContext.executionInfo.completed).toBe(true)
// Check that detailed error information was preserved (new feature!)
if (runs.docs[0].status === 'failed' && stepContext.errorDetails) {
expect(stepContext.errorDetails.errorType).toBe('timeout')
expect(stepContext.errorDetails.duration).toBeGreaterThan(2000)
expect(stepContext.errorDetails.attempts).toBe(1)
expect(stepContext.errorDetails.context.url).toBe('https://httpbin.org/delay/10')
expect(stepContext.errorDetails.context.timeout).toBe(2000)
console.log('✅ Detailed timeout error information preserved:', {
errorType: stepContext.errorDetails.errorType,
duration: stepContext.errorDetails.duration,
attempts: stepContext.errorDetails.attempts
})
} else if (runs.docs[0].status === 'failed') {
console.log('✅ Timeout error handled:', runs.docs[0].error)
} else {
console.log('✅ Request completed within timeout')
}
}, 15000)
it('should handle invalid JSON responses', async () => {
const payload = getTestPayload()
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Error - Invalid JSON',
description: 'Tests invalid JSON response handling',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create'
}
],
steps: [
{
name: 'invalid-json-request',
step: 'http-request-step',
url: 'https://httpbin.org/html', // Returns HTML, not JSON
method: 'GET'
}
]
}
})
const post = await payload.create({
collection: 'posts',
data: {
content: 'Test Error Invalid JSON Post'
}
})
await new Promise(resolve => setTimeout(resolve, 5000))
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
expect(runs.totalDocs).toBe(1)
expect(runs.docs[0].status).toBe('completed') // Should complete but with HTML body
expect(runs.docs[0].context.steps['invalid-json-request'].output.body).toContain('<html>')
console.log('✅ Non-JSON response handled correctly')
}, 25000)
it('should handle circular reference in JSONPath resolution', async () => {
const payload = getTestPayload()
// This test creates a scenario where JSONPath might encounter circular references
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Error - Circular Reference',
description: 'Tests circular reference handling in JSONPath',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create'
}
],
steps: [
{
name: 'circular-test',
step: 'http-request-step',
url: 'https://httpbin.org/post',
method: 'POST',
body: {
// This creates a deep reference that could cause issues
triggerData: '$.trigger',
stepData: '$.steps',
nestedRef: '$.trigger.doc'
}
}
]
}
})
const post = await payload.create({
collection: 'posts',
data: {
content: 'Test Error Circular Reference Post'
}
})
await new Promise(resolve => setTimeout(resolve, 5000))
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
expect(runs.totalDocs).toBe(1)
// Should either succeed with safe serialization or fail gracefully
expect(['completed', 'failed']).toContain(runs.docs[0].status)
console.log('✅ Circular reference handled:', runs.docs[0].status)
}, 20000)
it('should handle malformed workflow configurations', async () => {
const payload = getTestPayload()
// This test should expect the workflow creation to fail due to validation
let creationFailed = false
let workflow: any = null
try {
// Create workflow with missing required fields for create-document
workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Error - Malformed Config',
description: 'Tests malformed workflow configuration',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create'
}
],
steps: [
{
name: 'malformed-step',
step: 'create-document',
// Missing required collectionSlug
data: {
message: 'This should fail'
}
}
]
}
})
} catch (error) {
creationFailed = true
expect(error).toBeDefined()
console.log('✅ Workflow creation failed as expected:', error instanceof Error ? error.message : error)
}
// If creation failed, that's the expected behavior
if (creationFailed) {
return
}
// If somehow the workflow was created, test execution failure
if (workflow) {
const post = await payload.create({
collection: 'posts',
data: {
content: 'Test Error Malformed Config Post'
}
})
await new Promise(resolve => setTimeout(resolve, 3000))
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
expect(runs.totalDocs).toBe(1)
expect(runs.docs[0].status).toBe('failed')
expect(runs.docs[0].error).toBeDefined()
console.log('✅ Malformed config caused execution failure:', runs.docs[0].error)
}
}, 15000)
it('should handle HTTP 4xx and 5xx errors properly', async () => {
const payload = getTestPayload()
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Error - HTTP Errors',
description: 'Tests HTTP error status handling',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create'
}
],
steps: [
{
name: 'not-found-request',
step: 'http-request-step',
url: 'https://httpbin.org/status/404',
method: 'GET'
},
{
name: 'server-error-request',
step: 'http-request-step',
url: 'https://httpbin.org/status/500',
method: 'GET',
dependencies: ['not-found-request']
}
]
}
})
const post = await payload.create({
collection: 'posts',
data: {
content: 'Test Error HTTP Status Post'
}
})
await new Promise(resolve => setTimeout(resolve, 8000))
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
expect(runs.totalDocs).toBe(1)
expect(runs.docs[0].status).toBe('completed') // Workflow should complete successfully
// Check that both steps completed with HTTP error outputs
const context = runs.docs[0].context
expect(context.steps['not-found-request'].state).toBe('succeeded') // HTTP request completed
expect(context.steps['not-found-request'].output.status).toBe(404) // But with error status
console.log('✅ HTTP error statuses handled correctly')
}, 25000)
it('should handle retry logic for transient failures', async () => {
const payload = getTestPayload()
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Error - Retry Logic',
description: 'Tests retry logic for HTTP requests',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create'
}
],
steps: [
{
name: 'retry-request',
step: 'http-request-step',
url: 'https://httpbin.org/status/503', // Service unavailable
method: 'GET',
retries: 3,
retryDelay: 1000
}
]
}
})
const post = await payload.create({
collection: 'posts',
data: {
content: 'Test Error Retry Logic Post'
}
})
await new Promise(resolve => setTimeout(resolve, 10000))
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
expect(runs.totalDocs).toBe(1)
expect(runs.docs[0].status).toBe('completed') // Workflow should complete with HTTP error output
// The step should have succeeded but with error status
const stepContext = runs.docs[0].context.steps['retry-request']
expect(stepContext.state).toBe('succeeded')
expect(stepContext.output.status).toBe(503)
console.log('✅ Retry logic executed correctly')
}, 25000)
it('should handle extremely large workflow contexts', async () => {
const payload = getTestPayload()
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Error - Large Context',
description: 'Tests handling of large workflow contexts',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create'
}
],
steps: [
{
name: 'large-response-request',
step: 'http-request-step',
url: 'https://httpbin.org/base64/SFRUUEJJTiBpcyBhd2Vzb21l', // Returns base64 decoded text
method: 'GET'
}
]
}
})
const post = await payload.create({
collection: 'posts',
data: {
content: 'Test Error Large Context Post'
}
})
await new Promise(resolve => setTimeout(resolve, 5000))
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
expect(runs.totalDocs).toBe(1)
// Should handle large contexts without memory issues
expect(['completed', 'failed']).toContain(runs.docs[0].status)
console.log('✅ Large context handled:', runs.docs[0].status)
}, 20000)
it('should handle undefined and null values in JSONPath', async () => {
const payload = getTestPayload()
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Error - Null Values',
description: 'Tests null/undefined values in JSONPath expressions',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create'
}
],
steps: [
{
name: 'null-value-request',
step: 'http-request-step',
url: 'https://httpbin.org/post',
method: 'POST',
body: {
nonexistentField: '$.trigger.doc.nonexistent',
nullField: '$.trigger.doc.null',
undefinedField: '$.trigger.doc.undefined'
}
}
]
}
})
const post = await payload.create({
collection: 'posts',
data: {
content: 'Test Error Null Values Post'
}
})
await new Promise(resolve => setTimeout(resolve, 5000))
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
expect(runs.totalDocs).toBe(1)
// Should handle null/undefined values gracefully
expect(['completed', 'failed']).toContain(runs.docs[0].status)
if (runs.docs[0].status === 'completed') {
const stepOutput = runs.docs[0].context.steps['null-value-request'].output
expect(stepOutput.status).toBe(200) // httpbin should accept the request
console.log('✅ Null values handled gracefully')
} else {
console.log('✅ Null values caused expected failure:', runs.docs[0].error)
}
}, 20000)
})

View File

@@ -1,392 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getTestPayload, cleanDatabase } from './test-setup.js'
import { mockHttpBin, testFixtures } from './test-helpers.js'
describe('Hook Execution Reliability Tests', () => {
beforeEach(async () => {
await cleanDatabase()
})
afterEach(async () => {
await cleanDatabase()
mockHttpBin.cleanup()
})
it('should reliably execute hooks when collections are created', async () => {
const payload = getTestPayload()
// Create a workflow with collection trigger
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Hook Reliability - Create',
description: 'Tests hook execution on post creation',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create'
}
],
steps: [
{
...testFixtures.createDocumentStep('auditLog'),
name: 'create-audit-log',
data: {
message: 'Post was created via workflow trigger',
post: '$.trigger.doc.id'
}
}
]
}
})
expect(workflow).toBeDefined()
expect(workflow.id).toBeDefined()
// Create a post to trigger the workflow
const post = await payload.create({
collection: 'posts',
data: {
content: 'Test Hook Reliability Post'
}
})
expect(post).toBeDefined()
// Wait for workflow execution
await new Promise(resolve => setTimeout(resolve, 5000))
// Verify workflow run was created
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
expect(runs.totalDocs).toBe(1)
// Either succeeded or failed, but should have executed
expect(['completed', 'failed']).toContain(runs.docs[0].status)
console.log('✅ Hook execution status:', runs.docs[0].status)
// Verify audit log was created only if the workflow succeeded
if (runs.docs[0].status === 'completed') {
const auditLogs = await payload.find({
collection: 'auditLog',
where: {
post: {
equals: post.id
}
},
limit: 1
})
expect(auditLogs.totalDocs).toBeGreaterThan(0)
expect(auditLogs.docs[0].message).toContain('workflow trigger')
} else {
// If workflow failed, just log the error but don't fail the test
console.log('⚠️ Workflow failed:', runs.docs[0].error)
// The important thing is that a workflow run was created
}
}, 30000)
it('should handle hook execution errors gracefully', async () => {
const payload = getTestPayload()
// Mock network error for invalid URL
mockHttpBin.mockNetworkError('invalid-url-that-will-fail')
// Create a workflow with invalid step configuration
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Hook Error Handling',
description: 'Tests error handling in hook execution',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create'
}
],
steps: [
{
name: 'invalid-http-request',
step: 'http-request-step',
url: 'https://invalid-url-that-will-fail',
method: 'GET'
}
]
}
})
// Create a post to trigger the workflow
const post = await payload.create({
collection: 'posts',
data: {
content: 'Test Hook Error Handling Post'
}
})
// Wait for workflow execution
await new Promise(resolve => setTimeout(resolve, 5000))
// Verify a failed workflow run was created
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
expect(runs.totalDocs).toBe(1)
expect(runs.docs[0].status).toBe('failed')
expect(runs.docs[0].error).toBeDefined()
// Check that the error mentions either the URL or the task failure
const errorMessage = runs.docs[0].error.toLowerCase()
const hasRelevantError = errorMessage.includes('url') ||
errorMessage.includes('invalid-url') ||
errorMessage.includes('network') ||
errorMessage.includes('failed')
expect(hasRelevantError).toBe(true)
console.log('✅ Error handling working:', runs.docs[0].error)
}, 30000)
it('should create failed workflow runs when executor is unavailable', async () => {
const payload = getTestPayload()
// This test simulates the executor being unavailable
// We'll create a workflow and then simulate a hook execution without proper executor
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Hook Executor Unavailable',
description: 'Tests handling when executor is not available',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create'
}
],
steps: [
{
name: 'simple-step',
step: 'http-request-step',
url: 'https://httpbin.org/get'
}
]
}
})
// Temporarily disable the executor by setting it to null
// This simulates the initialization issue
const global = globalThis as any
const originalExecutor = global.__workflowExecutor
global.__workflowExecutor = null
try {
// Create a post to trigger the workflow
const post = await payload.create({
collection: 'posts',
data: {
content: 'Test Hook Executor Unavailable Post'
}
})
// Wait for hook execution attempt
await new Promise(resolve => setTimeout(resolve, 3000))
// Verify a failed workflow run was created for executor unavailability
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
if (runs.totalDocs > 0) {
expect(runs.docs[0].error).toBeDefined()
console.log('✅ Executor unavailable error captured:', runs.docs[0].error)
} else {
console.log('⚠️ No workflow run created - this indicates the hook may not have executed')
}
} finally {
// Restore the original executor
global.__workflowExecutor = originalExecutor
}
}, 30000)
it('should handle workflow conditions properly', async () => {
const payload = getTestPayload()
// Create a workflow with a condition that should prevent execution
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Hook Conditional Execution',
description: 'Tests conditional workflow execution',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create',
condition: '$.trigger.doc.content == "TRIGGER_CONDITION"'
}
],
steps: [
{
name: 'conditional-audit',
step: 'create-document',
collectionSlug: 'auditLog',
data: {
post: '$.trigger.doc.id',
message: 'Conditional trigger executed'
}
}
]
}
})
// Create a post that SHOULD NOT trigger the workflow
const post1 = await payload.create({
collection: 'posts',
data: {
content: 'Test Hook Conditional - Should Not Trigger'
}
})
// Create a post that SHOULD trigger the workflow
const post2 = await payload.create({
collection: 'posts',
data: {
content: 'TRIGGER_CONDITION'
}
})
// Wait for workflow execution
await new Promise(resolve => setTimeout(resolve, 5000))
// Check workflow runs
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
}
})
// Should have exactly 1 run (only for the matching condition)
expect(runs.totalDocs).toBe(1)
// Either succeeded or failed, but should have executed
expect(['completed', 'failed']).toContain(runs.docs[0].status)
// Verify audit log was created only for the correct post
const auditLogs = await payload.find({
collection: 'auditLog',
where: {
post: {
equals: post2.id
}
}
})
expect(auditLogs.totalDocs).toBe(1)
// Verify no audit log for the first post
const noAuditLogs = await payload.find({
collection: 'auditLog',
where: {
post: {
equals: post1.id
}
}
})
expect(noAuditLogs.totalDocs).toBe(0)
console.log('✅ Conditional execution working correctly')
}, 30000)
it('should handle multiple concurrent hook executions', async () => {
const payload = getTestPayload()
// Create a workflow
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Hook Concurrent Execution',
description: 'Tests handling multiple concurrent hook executions',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create'
}
],
steps: [
{
name: 'concurrent-audit',
step: 'create-document',
collectionSlug: 'auditLog',
data: {
post: '$.trigger.doc.id',
message: 'Concurrent execution test'
}
}
]
}
})
// Create multiple posts concurrently
const concurrentCreations = Array.from({ length: 5 }, (_, i) =>
payload.create({
collection: 'posts',
data: {
content: `Test Hook Concurrent Post ${i + 1}`
}
})
)
const posts = await Promise.all(concurrentCreations)
expect(posts).toHaveLength(5)
// Wait for all workflow executions
await new Promise(resolve => setTimeout(resolve, 8000))
// Verify all workflow runs were created
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
}
})
expect(runs.totalDocs).toBe(5)
// Verify all runs completed successfully
const failedRuns = runs.docs.filter(run => run.status === 'failed')
expect(failedRuns).toHaveLength(0)
console.log('✅ Concurrent executions completed:', {
totalRuns: runs.totalDocs,
statuses: runs.docs.map(run => run.status)
})
}, 45000)
})

View File

@@ -1,16 +1,13 @@
import type {CollectionSlug, TypedJobs} from 'payload';
import type {CollectionSlug} from 'payload';
import {sqliteAdapter} from "@payloadcms/db-sqlite"
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path'
import {buildConfig} from 'payload'
import sharp from 'sharp'
import { fileURLToPath } from 'url'
import {workflowsPlugin} from "../src/plugin/index.js"
import {HttpRequestStepTask} from "../src/steps/http-request.js"
import {CreateDocumentStepTask} from "../src/steps/index.js"
import {CreateDocumentStepTask,HttpRequestStepTask} from "../src/steps/index.js"
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
import { seed } from './seed.js'

View File

@@ -1,94 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getTestPayload, cleanDatabase } from './test-setup.js'
import { mockHttpBin, testFixtures } from './test-helpers.js'
describe('Workflow Trigger Test', () => {
beforeEach(async () => {
await cleanDatabase()
// Set up HTTP mocks
const expectedRequestData = {
message: 'Post created',
postId: expect.any(String), // MongoDB ObjectId
postTitle: 'Test post content for workflow trigger'
}
mockHttpBin.mockPost(expectedRequestData)
})
afterEach(async () => {
await cleanDatabase()
mockHttpBin.cleanup()
})
it('should create a workflow run when a post is created', async () => {
const payload = getTestPayload()
// Use test fixtures for consistent data
const testWorkflow = {
...testFixtures.basicWorkflow,
name: 'Test Post Creation Workflow',
description: 'Triggers when a post is created',
steps: [
{
...testFixtures.httpRequestStep(),
name: 'log-post',
body: {
message: 'Post created',
postId: '$.trigger.doc.id',
postTitle: '$.trigger.doc.content'
}
}
]
}
// Create a workflow with collection trigger
const workflow = await payload.create({
collection: 'workflows',
data: testWorkflow
})
expect(workflow).toBeDefined()
expect(workflow.id).toBeDefined()
// Create a post to trigger the workflow
const post = await payload.create({
collection: 'posts',
data: testFixtures.testPost
})
expect(post).toBeDefined()
expect(post.id).toBeDefined()
// Wait a bit for workflow to execute
await new Promise(resolve => setTimeout(resolve, 3000))
// Check for workflow runs
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 10
})
expect(runs.totalDocs).toBeGreaterThan(0)
// Check if workflow is an object or ID
const workflowRef = runs.docs[0].workflow
const workflowId = typeof workflowRef === 'object' && workflowRef !== null
? (workflowRef as any).id
: workflowRef
expect(workflowId).toBe(workflow.id) // Should reference the workflow ID
console.log('✅ Workflow run created successfully!')
console.log(`Run status: ${runs.docs[0].status}`)
console.log(`Run ID: ${runs.docs[0].id}`)
if (runs.docs[0].status === 'failed' && runs.docs[0].error) {
console.log(`Error: ${runs.docs[0].error}`)
}
}, 30000)
})

View File

@@ -1,201 +0,0 @@
import nock from 'nock'
/**
* Mock HTTP requests to httpbin.org for testing
*/
export const mockHttpBin = {
/**
* Mock a successful POST request to httpbin.org/post
*/
mockPost: (expectedData?: any) => {
return nock('https://httpbin.org')
.post('/post')
.reply(200, {
args: {},
data: JSON.stringify(expectedData || {}),
files: {},
form: {},
headers: {
'Accept': '*/*',
'Accept-Encoding': 'br, gzip, deflate',
'Accept-Language': '*',
'Content-Type': 'application/json',
'Host': 'httpbin.org',
'Sec-Fetch-Mode': 'cors',
'User-Agent': 'PayloadCMS-Automation/1.0'
},
json: expectedData || {},
origin: '127.0.0.1',
url: 'https://httpbin.org/post'
}, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Credentials': 'true'
})
},
/**
* Mock a GET request to httpbin.org/get
*/
mockGet: () => {
return nock('https://httpbin.org')
.get('/get')
.reply(200, {
args: {},
headers: {
'Accept': '*/*',
'Host': 'httpbin.org',
'User-Agent': 'PayloadCMS-Automation/1.0'
},
origin: '127.0.0.1',
url: 'https://httpbin.org/get'
})
},
/**
* Mock HTTP timeout
*/
mockTimeout: (path: string = '/delay/10') => {
return nock('https://httpbin.org')
.get(path)
.replyWithError({
code: 'ECONNABORTED',
message: 'timeout of 2000ms exceeded'
})
},
/**
* Mock HTTP error responses
*/
mockError: (status: number, path: string = '/status/' + status) => {
return nock('https://httpbin.org')
.get(path)
.reply(status, {
error: `HTTP ${status} Error`,
message: `Mock ${status} response`
})
},
/**
* Mock invalid URL to simulate network errors
*/
mockNetworkError: (url: string = 'invalid-url-that-will-fail') => {
return nock('https://' + url)
.get('/')
.replyWithError({
code: 'ENOTFOUND',
message: `getaddrinfo ENOTFOUND ${url}`
})
},
/**
* Mock HTML response (non-JSON)
*/
mockHtml: () => {
return nock('https://httpbin.org')
.get('/html')
.reply(200, '<!DOCTYPE html><html><head><title>Test</title></head><body>Test HTML</body></html>', {
'Content-Type': 'text/html'
})
},
/**
* Mock all common endpoints for error scenarios
*/
mockAllErrorScenarios: () => {
// HTML response for invalid JSON test
nock('https://httpbin.org')
.get('/html')
.reply(200, '<!DOCTYPE html><html><head><title>Test</title></head><body>Test HTML</body></html>', {
'Content-Type': 'text/html'
})
// 404 error
nock('https://httpbin.org')
.get('/status/404')
.reply(404, {
error: 'Not Found',
message: 'The requested resource was not found'
})
// 500 error
nock('https://httpbin.org')
.get('/status/500')
.reply(500, {
error: 'Internal Server Error',
message: 'Server encountered an error'
})
// 503 error for retry tests
nock('https://httpbin.org')
.get('/status/503')
.times(3) // Allow 3 retries
.reply(503, {
error: 'Service Unavailable',
message: 'Service is temporarily unavailable'
})
// POST endpoint for circular reference and other POST tests
nock('https://httpbin.org')
.post('/post')
.times(5) // Allow multiple POST requests
.reply(200, (uri, requestBody) => ({
args: {},
data: JSON.stringify(requestBody),
json: requestBody,
url: 'https://httpbin.org/post'
}))
},
/**
* Clean up all nock mocks
*/
cleanup: () => {
nock.cleanAll()
}
}
/**
* Test fixtures for common workflow configurations
*/
export const testFixtures = {
basicWorkflow: {
name: 'Test Basic Workflow',
description: 'Basic workflow for testing',
triggers: [
{
type: 'collection-trigger' as const,
collectionSlug: 'posts',
operation: 'create' as const
}
]
},
httpRequestStep: (url: string = 'https://httpbin.org/post', expectedData?: any) => ({
name: 'http-request',
step: 'http-request-step',
url,
method: 'POST' as const,
headers: {
'Content-Type': 'application/json'
},
body: expectedData || {
message: 'Test request',
data: '$.trigger.doc'
}
}),
createDocumentStep: (collectionSlug: string = 'auditLog') => ({
name: 'create-audit',
step: 'create-document',
collectionSlug,
data: {
message: 'Test document created',
sourceId: '$.trigger.doc.id'
}
}),
testPost: {
content: 'Test post content for workflow trigger'
}
}

View File

@@ -1,125 +0,0 @@
import { MongoMemoryReplSet } from 'mongodb-memory-server'
import { getPayload } from 'payload'
import type { Payload } from 'payload'
import nock from 'nock'
import config from './payload.config.js'
// Configure nock to intercept fetch requests properly in Node.js 22
nock.disableNetConnect()
nock.enableNetConnect('127.0.0.1')
// Set global fetch to use undici for proper nock interception
import { fetch } from 'undici'
global.fetch = fetch
let mongod: MongoMemoryReplSet | null = null
let payload: Payload | null = null
// Global test setup - runs once for all tests
beforeAll(async () => {
// Start MongoDB in-memory replica set
mongod = await MongoMemoryReplSet.create({
replSet: {
count: 1,
dbName: 'payload-test',
},
})
const mongoUri = mongod.getUri()
process.env.DATABASE_URI = mongoUri
console.log('🚀 MongoDB in-memory server started:', mongoUri)
// Initialize Payload with test config
payload = await getPayload({
config: await config,
local: true
})
console.log('✅ Payload initialized for testing')
}, 60000)
// Global test teardown - runs once after all tests
afterAll(async () => {
if (payload) {
console.log('🛑 Shutting down Payload...')
// Payload doesn't have a shutdown method, but we can clear the cache
delete (global as any).payload
payload = null
}
if (mongod) {
console.log('🛑 Stopping MongoDB in-memory server...')
await mongod.stop()
mongod = null
}
}, 30000)
// Export payload instance for tests
export const getTestPayload = () => {
if (!payload) {
throw new Error('Payload not initialized. Make sure test setup has run.')
}
return payload
}
// Helper to clean all collections
export const cleanDatabase = async () => {
if (!payload) return
try {
// Clean up workflow runs first (child records)
const runs = await payload.find({
collection: 'workflow-runs',
limit: 1000
})
for (const run of runs.docs) {
await payload.delete({
collection: 'workflow-runs',
id: run.id
})
}
// Clean up workflows
const workflows = await payload.find({
collection: 'workflows',
limit: 1000
})
for (const workflow of workflows.docs) {
await payload.delete({
collection: 'workflows',
id: workflow.id
})
}
// Clean up audit logs
const auditLogs = await payload.find({
collection: 'auditLog',
limit: 1000
})
for (const log of auditLogs.docs) {
await payload.delete({
collection: 'auditLog',
id: log.id
})
}
// Clean up posts
const posts = await payload.find({
collection: 'posts',
limit: 1000
})
for (const post of posts.docs) {
await payload.delete({
collection: 'posts',
id: post.id
})
}
} catch (error) {
console.warn('Database cleanup failed:', error)
}
}

View File

@@ -1,104 +0,0 @@
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import config from './payload.config'
async function testWorkflowTrigger() {
console.log('Starting workflow trigger test...')
// Get payload instance
const payload = await getPayload({ config })
try {
// Create a test user
const user = await payload.create({
collection: 'users',
data: {
email: 'test@example.com',
password: 'password123'
}
})
console.log('Created test user:', user.id)
// Create a workflow with collection trigger
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Post Creation Workflow',
description: 'Triggers when a post is created',
triggers: [
{
type: 'collection-trigger',
collectionSlug: 'posts',
operation: 'create'
}
],
steps: [
{
name: 'log-post',
step: 'http-request-step',
input: {
url: 'https://httpbin.org/post',
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: {
message: 'Post created',
postId: '$.trigger.doc.id',
postTitle: '$.trigger.doc.title'
}
}
}
]
},
user: user.id
})
console.log('Created workflow:', workflow.id)
// Create a post to trigger the workflow
console.log('Creating post to trigger workflow...')
const post = await payload.create({
collection: 'posts',
data: {
title: 'Test Post',
content: 'This should trigger the workflow',
_status: 'published'
},
user: user.id
})
console.log('Created post:', post.id)
// Wait a bit for workflow to execute
await new Promise(resolve => setTimeout(resolve, 2000))
// Check for workflow runs
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
}
})
console.log('Workflow runs found:', runs.totalDocs)
if (runs.totalDocs > 0) {
console.log('✅ SUCCESS: Workflow was triggered!')
console.log('Run status:', runs.docs[0].status)
console.log('Run context:', JSON.stringify(runs.docs[0].context, null, 2))
} else {
console.log('❌ FAILURE: Workflow was not triggered')
}
} catch (error) {
console.error('Test failed:', error)
} finally {
await payload.shutdown()
}
}
testWorkflowTrigger().catch(console.error)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@xtr-dev/payload-workflows",
"version": "0.0.37",
"version": "0.0.40",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xtr-dev/payload-workflows",
"version": "0.0.37",
"version": "0.0.40",
"license": "MIT",
"dependencies": {
"jsonpath-plus": "^10.3.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/payload-automation",
"version": "0.0.37",
"version": "0.0.40",
"description": "PayloadCMS Automation Plugin - Comprehensive workflow automation system with visual workflow building, execution tracking, and step types",
"license": "MIT",
"type": "module",
@@ -75,6 +75,7 @@
"@payloadcms/ui": "3.45.0",
"@playwright/test": "^1.52.0",
"@swc/cli": "0.6.0",
"@types/handlebars": "^4.1.0",
"@types/nock": "^11.1.0",
"@types/node": "^22.5.4",
"@types/node-cron": "^3.0.11",
@@ -135,7 +136,7 @@
"registry": "https://registry.npmjs.org/",
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184",
"dependencies": {
"jsonpath-plus": "^10.3.0",
"handlebars": "^4.7.8",
"node-cron": "^4.2.1",
"pino": "^9.9.0"
}

86
pnpm-lock.yaml generated
View File

@@ -8,9 +8,9 @@ importers:
.:
dependencies:
jsonpath-plus:
specifier: ^10.3.0
version: 10.3.0
handlebars:
specifier: ^4.7.8
version: 4.7.8
node-cron:
specifier: ^4.2.1
version: 4.2.1
@@ -45,6 +45,9 @@ importers:
'@swc/cli':
specifier: 0.6.0
version: 0.6.0(@swc/core@1.13.4)
'@types/handlebars':
specifier: ^4.1.0
version: 4.1.0
'@types/nock':
specifier: ^11.1.0
version: 11.1.0
@@ -962,18 +965,6 @@ packages:
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@jsep-plugin/assignment@1.3.0':
resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==}
engines: {node: '>= 10.16.0'}
peerDependencies:
jsep: ^0.4.0||^1.0.0
'@jsep-plugin/regex@1.0.4':
resolution: {integrity: sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==}
engines: {node: '>= 10.16.0'}
peerDependencies:
jsep: ^0.4.0||^1.0.0
'@lexical/clipboard@0.28.0':
resolution: {integrity: sha512-LYqion+kAwFQJStA37JAEMxTL/m1WlZbotDfM/2WuONmlO0yWxiyRDI18oeCwhBD6LQQd9c3Ccxp9HFwUG1AVw==}
@@ -1600,6 +1591,10 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/handlebars@4.1.0':
resolution: {integrity: sha512-gq9YweFKNNB1uFK71eRqsd4niVkXrxHugqWFQkeLRJvGjnxsLr16bYtcsG4tOFwmYi0Bax+wCkbf1reUfdl4kA==}
deprecated: This is a stub types definition. handlebars provides its own type definitions, so you do not need this installed.
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
@@ -2886,6 +2881,11 @@ packages:
resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
handlebars@4.7.8:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
engines: {node: '>=0.4.7'}
hasBin: true
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -3172,10 +3172,6 @@ packages:
resolution: {integrity: sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw==}
engines: {node: '>=12.0.0'}
jsep@1.4.0:
resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==}
engines: {node: '>= 10.16.0'}
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -3204,11 +3200,6 @@ packages:
json-stringify-safe@5.0.1:
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
jsonpath-plus@10.3.0:
resolution: {integrity: sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==}
engines: {node: '>=18.0.0'}
hasBin: true
jsox@1.2.121:
resolution: {integrity: sha512-9Ag50tKhpTwS6r5wh3MJSAvpSof0UBr39Pto8OnzFT32Z/pAbxAsKHzyvsyMEHVslELvHyO/4/jaQELHk8wDcw==}
hasBin: true
@@ -3534,6 +3525,9 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
new-find-package-json@2.0.0:
resolution: {integrity: sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==}
engines: {node: '>=12.22.0'}
@@ -4472,6 +4466,11 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uglify-js@3.19.3:
resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
engines: {node: '>=0.8.0'}
hasBin: true
uint8array-extras@1.5.0:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
@@ -4663,6 +4662,9 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -5447,14 +5449,6 @@ snapshots:
'@jsdevtools/ono@7.1.3': {}
'@jsep-plugin/assignment@1.3.0(jsep@1.4.0)':
dependencies:
jsep: 1.4.0
'@jsep-plugin/regex@1.0.4(jsep@1.4.0)':
dependencies:
jsep: 1.4.0
'@lexical/clipboard@0.28.0':
dependencies:
'@lexical/html': 0.28.0
@@ -6316,6 +6310,10 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/handlebars@4.1.0':
dependencies:
handlebars: 4.7.8
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -7966,6 +7964,15 @@ snapshots:
graphql@16.11.0: {}
handlebars@4.7.8:
dependencies:
minimist: 1.2.8
neo-async: 2.6.2
source-map: 0.6.1
wordwrap: 1.0.0
optionalDependencies:
uglify-js: 3.19.3
has-bigints@1.1.0: {}
has-flag@4.0.0: {}
@@ -8224,8 +8231,6 @@ snapshots:
jsdoc-type-pratt-parser@4.8.0: {}
jsep@1.4.0: {}
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
@@ -8252,12 +8257,6 @@ snapshots:
json-stringify-safe@5.0.1: {}
jsonpath-plus@10.3.0:
dependencies:
'@jsep-plugin/assignment': 1.3.0(jsep@1.4.0)
'@jsep-plugin/regex': 1.0.4(jsep@1.4.0)
jsep: 1.4.0
jsox@1.2.121: {}
jsx-ast-utils@3.3.5:
@@ -8698,6 +8697,8 @@ snapshots:
natural-compare@1.4.0: {}
neo-async@2.6.2: {}
new-find-package-json@2.0.0:
dependencies:
debug: 4.4.1
@@ -9761,6 +9762,9 @@ snapshots:
typescript@5.7.3: {}
uglify-js@3.19.3:
optional: true
uint8array-extras@1.5.0: {}
unbox-primitive@1.1.0:
@@ -9973,6 +9977,8 @@ snapshots:
word-wrap@1.2.5: {}
wordwrap@1.0.0: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0

View File

@@ -84,15 +84,13 @@ export const createWorkflowCollection: <T extends string>(options: WorkflowsPlug
options: steps.map(t => t.slug)
},
{
name: 'parameters',
name: 'input',
type: 'json',
admin: {
hidden: true,
description: 'Step input configuration. Use JSONPath expressions to reference dynamic data (e.g., {"url": "$.trigger.doc.webhookUrl", "data": "$.steps.previousStep.output.result"})'
},
defaultValue: {}
},
// Virtual fields for custom triggers
...steps.flatMap(step => (step.inputSchema || []).map(s => parameter(step.slug, s as any))),
{
name: 'dependencies',
type: 'text',

View File

@@ -0,0 +1,297 @@
'use client'
import React, { useState, useCallback, useEffect } from 'react'
import type { Node } from '@xyflow/react'
import { Button } from '@payloadcms/ui'
interface StepField {
name: string
type: string
label?: string
admin?: {
description?: string
condition?: (data: any, siblingData: any) => boolean
}
options?: Array<{ label: string; value: string }>
defaultValue?: any
required?: boolean
hasMany?: boolean
fields?: StepField[] // For group fields
}
interface StepType {
slug: string
label?: string
inputSchema?: StepField[]
outputSchema?: StepField[]
}
interface StepConfigurationFormProps {
selectedNode: Node | null
availableStepTypes: StepType[]
availableSteps: string[] // For dependency selection
onNodeUpdate: (nodeId: string, data: Partial<Node['data']>) => void
onClose: () => void
}
export const StepConfigurationForm: React.FC<StepConfigurationFormProps> = ({
selectedNode,
availableStepTypes,
availableSteps,
onNodeUpdate,
onClose
}) => {
const [formData, setFormData] = useState<Record<string, any>>(
selectedNode?.data.configuration || {}
)
const [jsonText, setJsonText] = useState<string>(() =>
JSON.stringify(selectedNode?.data.configuration || {}, null, 2)
)
if (!selectedNode) return null
const stepType = availableStepTypes.find(type => type.slug === selectedNode.data.stepType)
const inputSchema = stepType?.inputSchema || []
// Update form data when selected node changes
useEffect(() => {
const config = selectedNode?.data.configuration || {}
setFormData(config)
setJsonText(JSON.stringify(config, null, 2))
}, [selectedNode])
const handleSave = useCallback(() => {
// Update the node with form data
onNodeUpdate(selectedNode.id, {
...selectedNode.data,
configuration: formData
})
onClose()
}, [selectedNode, formData, onNodeUpdate, onClose])
const renderStepConfiguration = () => {
if (!inputSchema.length) {
return (
<div style={{
padding: '20px',
textAlign: 'center',
color: 'var(--theme-text-400)',
fontStyle: 'italic'
}}>
This step type has no configuration parameters.
</div>
)
}
return (
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '4px',
fontSize: '12px',
fontWeight: '500',
color: 'var(--theme-text)'
}}>
Step Configuration
</label>
<div style={{ fontSize: '11px', color: 'var(--theme-text-400)', marginBottom: '8px' }}>
Configure this step's parameters in JSON format. Use JSONPath expressions like <code>$.trigger.doc.id</code> to reference dynamic data.
</div>
{/* Schema Reference */}
<details style={{ marginBottom: '12px' }}>
<summary style={{
fontSize: '11px',
color: 'var(--theme-text-400)',
cursor: 'pointer',
marginBottom: '8px'
}}>
📖 Available Fields (click to expand)
</summary>
<div style={{
background: 'var(--theme-elevation-50)',
border: '1px solid var(--theme-elevation-100)',
borderRadius: '4px',
padding: '12px',
fontSize: '11px',
fontFamily: 'monospace'
}}>
{inputSchema.map((field, index) => (
<div key={field.name} style={{ marginBottom: index < inputSchema.length - 1 ? '8px' : '0' }}>
<strong>{field.name}</strong> ({field.type})
{field.required && <span style={{ color: 'var(--theme-error-500)' }}> *required</span>}
{field.admin?.description && (
<div style={{ color: 'var(--theme-text-400)', marginTop: '2px' }}>
{field.admin.description}
</div>
)}
</div>
))}
</div>
</details>
<textarea
value={jsonText}
onChange={(e) => {
const text = e.target.value
setJsonText(text)
try {
const parsed = JSON.parse(text)
setFormData(parsed)
} catch {
// Keep invalid JSON, user is still typing
// Don't update formData until JSON is valid
}
}}
rows={Math.min(Math.max(inputSchema.length * 2, 6), 15)}
style={{
width: '100%',
padding: '12px',
border: '1px solid var(--theme-elevation-100)',
borderRadius: '4px',
fontSize: '13px',
fontFamily: 'monospace',
lineHeight: '1.4',
background: 'var(--theme-input-bg)',
color: 'var(--theme-text)',
resize: 'vertical'
}}
placeholder='{\n "field1": "value1",\n "field2": "$.trigger.doc.id"\n}'
/>
</div>
)
}
return (
<div style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
{/* Header */}
<div style={{
padding: '16px',
borderBottom: '1px solid var(--theme-elevation-100)',
background: 'var(--theme-elevation-50)'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h4 style={{ margin: 0, fontSize: '16px', fontWeight: '600', color: 'var(--theme-text)' }}>
Configure Step
</h4>
<Button
buttonStyle="none"
onClick={onClose}
size="small"
>
×
</Button>
</div>
<div style={{ fontSize: '12px', color: 'var(--theme-text-400)', marginTop: '4px' }}>
{stepType?.label || (selectedNode.data.stepType as string)}
</div>
</div>
{/* Form */}
<div style={{
flex: 1,
overflow: 'auto',
padding: '16px'
}}>
{/* Basic step info */}
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
Step Name *
</label>
<input
type="text"
value={(selectedNode.data.label as string) || ''}
onChange={(e) => onNodeUpdate(selectedNode.id, {
...selectedNode.data,
label: e.target.value
})}
style={{
width: '100%',
padding: '8px',
border: '1px solid var(--theme-elevation-100)',
borderRadius: '4px',
fontSize: '14px'
}}
required
/>
</div>
{/* Dependencies */}
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
Dependencies
</label>
<div style={{ fontSize: '11px', color: 'var(--theme-text-400)', marginBottom: '8px' }}>
Steps that must complete before this step can run
</div>
{availableSteps
.filter(step => step !== selectedNode.id)
.map(stepId => (
<label key={stepId} style={{
display: 'block',
fontSize: '12px',
marginBottom: '4px'
}}>
<input
type="checkbox"
checked={((selectedNode.data.dependencies as string[]) || []).includes(stepId)}
onChange={(e) => {
const currentDeps = (selectedNode.data.dependencies as string[]) || []
const newDeps = e.target.checked
? [...currentDeps, stepId]
: currentDeps.filter((dep: string) => dep !== stepId)
onNodeUpdate(selectedNode.id, {
...selectedNode.data,
dependencies: newDeps
})
}}
style={{ marginRight: '8px' }}
/>
{stepId}
</label>
))}
</div>
{/* Step-specific configuration */}
{renderStepConfiguration()}
{/* Submit button */}
<div style={{
borderTop: '1px solid var(--theme-elevation-100)',
paddingTop: '16px',
marginTop: '16px'
}}>
<Button
buttonStyle="primary"
onClick={handleSave}
>
Save Configuration
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,255 @@
'use client'
import React, { useCallback, useMemo, useState } from 'react'
import {
ReactFlow,
Node,
Edge,
addEdge,
Connection,
useNodesState,
useEdgesState,
Controls,
Background,
BackgroundVariant,
MiniMap,
Panel
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
// Import custom node types
import { StepNode } from './nodes/StepNode.js'
import { WorkflowToolbar } from './WorkflowToolbar.js'
import { StepConfigurationForm } from './StepConfigurationForm.js'
// Define node types for React Flow
const nodeTypes = {
stepNode: StepNode,
}
interface WorkflowData {
id: string
name: string
steps?: Array<{
name: string
type: string
position?: { x: number; y: number }
visual?: { color?: string; icon?: string }
dependencies?: string[]
}>
layout?: {
viewport?: { x: number; y: number; zoom: number }
}
}
interface StepType {
slug: string
label?: string
inputSchema?: any[]
outputSchema?: any[]
}
interface WorkflowBuilderProps {
workflow?: WorkflowData
availableStepTypes?: StepType[]
onSave?: (workflow: WorkflowData) => void
readonly?: boolean
}
export const WorkflowBuilder: React.FC<WorkflowBuilderProps> = ({
workflow,
availableStepTypes = [],
onSave,
readonly = false
}) => {
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
// Convert workflow steps to React Flow nodes
const initialNodes: Node[] = useMemo(() => {
if (!workflow?.steps) return []
return workflow.steps.map((step, index) => ({
id: step.name || `step-${index}`,
type: 'stepNode',
position: step.position || { x: 100 + index * 200, y: 100 },
data: {
label: step.name || 'Unnamed Step',
stepType: step.type,
color: step.visual?.color || '#3b82f6',
icon: step.visual?.icon,
dependencies: step.dependencies || []
}
}))
}, [workflow?.steps])
// Convert dependencies to React Flow edges
const initialEdges: Edge[] = useMemo(() => {
if (!workflow?.steps) return []
const edges: Edge[] = []
workflow.steps.forEach((step, index) => {
const targetId = step.name || `step-${index}`
if (step.dependencies) {
step.dependencies.forEach((depName) => {
// Find the source step
const sourceStep = workflow.steps?.find(s => s.name === depName)
if (sourceStep) {
const sourceId = sourceStep.name || `step-${workflow.steps?.indexOf(sourceStep)}`
edges.push({
id: `${sourceId}-${targetId}`,
source: sourceId,
target: targetId,
type: 'smoothstep'
})
}
})
}
})
return edges
}, [workflow?.steps])
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
// Handle new connections
const onConnect = useCallback((params: Connection) => {
if (readonly) return
setEdges((eds) => addEdge({
...params,
type: 'smoothstep'
}, eds))
}, [setEdges, readonly])
// Handle node selection
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
console.log('Node clicked:', node.id, node.data.label)
setSelectedNode(node)
}, [])
// Handle adding new step
const onAddStep = useCallback((stepType: string) => {
if (readonly) return
const newStep: Node = {
id: `step-${Date.now()}`,
type: 'stepNode',
position: { x: 100, y: 100 },
data: {
label: 'New Step',
stepType,
color: '#3b82f6',
dependencies: []
}
}
setNodes((nds) => [...nds, newStep])
}, [setNodes, readonly])
// Handle updating a node's data
const handleNodeUpdate = useCallback((nodeId: string, newData: Partial<Node['data']>) => {
setNodes((nds) =>
nds.map((node) =>
node.id === nodeId
? { ...node, data: { ...node.data, ...newData } }
: node
)
)
}, [setNodes])
// Handle saving workflow
const handleSave = useCallback(() => {
if (!workflow || !onSave) return
// Convert nodes and edges back to workflow format
const updatedSteps = nodes.map((node) => {
// Find dependencies from edges
const dependencies = edges
.filter(edge => edge.target === node.id)
.map(edge => edge.source)
return {
name: node.id,
type: node.data.stepType as string,
position: node.position,
visual: {
color: node.data.color as string,
icon: node.data.icon as string
},
dependencies: dependencies.length > 0 ? dependencies : undefined
}
})
const updatedWorkflow: WorkflowData = {
...workflow,
steps: updatedSteps
}
onSave(updatedWorkflow)
}, [workflow, nodes, edges, onSave])
return (
<div style={{
width: '100%',
height: '600px',
display: 'flex',
background: 'var(--theme-bg)',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-100)'
}}>
{/* Main canvas area */}
<div style={{
flex: selectedNode ? '1 1 70%' : '1 1 100%',
transition: 'flex 0.3s ease'
}}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
attributionPosition="top-right"
>
<Controls />
<MiniMap />
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
{!readonly && (
<Panel position="top-left">
<WorkflowToolbar
availableStepTypes={availableStepTypes}
onAddStep={onAddStep}
onSave={handleSave}
/>
</Panel>
)}
</ReactFlow>
</div>
{/* Side panel for step configuration */}
{selectedNode && !readonly && (
<div style={{
flex: '0 0 30%',
borderLeft: '1px solid var(--theme-elevation-100)',
background: 'var(--theme-elevation-0)',
display: 'flex',
flexDirection: 'column'
}}>
<StepConfigurationForm
selectedNode={selectedNode}
availableStepTypes={availableStepTypes}
availableSteps={nodes.map(node => node.id)}
onNodeUpdate={handleNodeUpdate}
onClose={() => setSelectedNode(null)}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,118 @@
'use client'
import React from 'react'
interface AvailableStepType {
slug: string
label?: string
inputSchema?: any[]
outputSchema?: any[]
}
interface WorkflowToolbarProps {
availableStepTypes: AvailableStepType[]
onAddStep: (stepType: string) => void
onSave: () => void
}
export const WorkflowToolbar: React.FC<WorkflowToolbarProps> = ({
availableStepTypes,
onAddStep,
onSave
}) => {
const getStepTypeLabel = (stepType: AvailableStepType) => {
return stepType.label || stepType.slug.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}
const getStepTypeIcon = (stepType: AvailableStepType) => {
// Simple icon mapping based on step type
switch (stepType.slug) {
case 'http-request-step':
return '🌐'
case 'create-document-step':
return '📄'
case 'read-document-step':
return '👁️'
case 'update-document-step':
return '✏️'
case 'delete-document-step':
return '🗑️'
case 'send-email-step':
return '📧'
default:
return '⚡'
}
}
return (
<div style={{
background: 'var(--theme-elevation-0)',
padding: '12px',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-150)',
minWidth: '200px'
}}>
<h4 style={{
margin: '0 0 12px 0',
fontSize: '14px',
fontWeight: '600',
color: 'var(--theme-text)'
}}>
Add Step
</h4>
<div style={{ marginBottom: '16px' }}>
{availableStepTypes.map((stepType) => (
<button
key={stepType.slug}
onClick={() => onAddStep(stepType.slug)}
style={{
display: 'block',
width: '100%',
padding: '8px 12px',
margin: '4px 0',
background: 'var(--theme-elevation-50)',
border: '1px solid var(--theme-elevation-100)',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
fontSize: '12px',
color: 'var(--theme-text)',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-elevation-100)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-elevation-50)'
}}
>
<span style={{ marginRight: '8px' }}>
{getStepTypeIcon(stepType)}
</span>
{getStepTypeLabel(stepType)}
</button>
))}
</div>
<div style={{ borderTop: '1px solid var(--theme-elevation-100)', paddingTop: '12px' }}>
<button
onClick={onSave}
style={{
width: '100%',
padding: '8px 16px',
background: 'var(--theme-success-500)',
color: 'var(--theme-base-0)',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '500'
}}
>
💾 Save Workflow
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { WorkflowBuilder } from './WorkflowBuilder.js'
export { WorkflowToolbar } from './WorkflowToolbar.js'
export { StepConfigurationForm } from './StepConfigurationForm.js'
export { StepNode } from './nodes/StepNode.js'

View File

@@ -0,0 +1,157 @@
'use client'
import React, { memo } from 'react'
import { Handle, Position, NodeProps } from '@xyflow/react'
interface StepNodeData {
label: string
stepType: string
color?: string
icon?: string
dependencies?: string[]
}
export const StepNode: React.FC<NodeProps> = memo(({ data, selected }) => {
const { label, stepType, color = '#3b82f6', icon, dependencies = [] } = data as unknown as StepNodeData
const getStepTypeIcon = (type: string) => {
// Return icon from data or default based on type
if (icon) return icon
switch (type) {
case 'http-request-step':
return '🌐'
case 'create-document-step':
return '📄'
case 'read-document-step':
return '👁️'
case 'update-document-step':
return '✏️'
case 'delete-document-step':
return '🗑️'
case 'send-email-step':
return '📧'
default:
return '⚡'
}
}
const getStepTypeLabel = (type: string) => {
return type.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}
return (
<div
style={{
background: color,
color: 'white',
borderRadius: '8px',
padding: '12px 16px',
minWidth: '150px',
border: selected ? '2px solid #1e40af' : '1px solid rgba(255, 255, 255, 0.2)',
boxShadow: selected
? '0 8px 25px rgba(0, 0, 0, 0.15)'
: '0 4px 15px rgba(0, 0, 0, 0.1)',
transition: 'all 0.2s ease',
cursor: 'pointer',
position: 'relative'
}}
title="Click to configure this step"
>
{/* Input Handle - only show if this step has dependencies */}
{dependencies.length > 0 && (
<Handle
type="target"
position={Position.Top}
style={{
background: '#fff',
border: '2px solid #3b82f6',
width: '10px',
height: '10px'
}}
/>
)}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '4px'
}}>
<span style={{ fontSize: '16px' }}>
{getStepTypeIcon(stepType)}
</span>
<div>
<div style={{
fontWeight: '600',
fontSize: '14px',
lineHeight: '1.2'
}}>
{label}
</div>
</div>
</div>
<div style={{
fontSize: '11px',
opacity: 0.9,
fontWeight: '400'
}}>
{getStepTypeLabel(stepType)}
</div>
{/* Status indicator for dependencies */}
{dependencies.length > 0 && (
<div style={{
position: 'absolute',
top: '4px',
right: '20px',
background: 'rgba(255, 255, 255, 0.2)',
borderRadius: '50%',
width: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '8px',
fontWeight: 'bold',
pointerEvents: 'none' // Allow clicks to pass through to parent
}}>
{dependencies.length}
</div>
)}
{/* Configuration indicator */}
<div style={{
position: 'absolute',
top: '4px',
right: '4px',
background: 'rgba(255, 255, 255, 0.3)',
borderRadius: '50%',
width: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
pointerEvents: 'none' // Allow clicks to pass through to parent
}}>
</div>
{/* Output Handle - always show for potential connections */}
<Handle
type="source"
position={Position.Bottom}
style={{
background: '#fff',
border: '2px solid #3b82f6',
width: '10px',
height: '10px'
}}
/>
</div>
)
})
StepNode.displayName = 'StepNode'

View File

@@ -19,7 +19,7 @@ export type PayloadWorkflow = {
[key: string]: unknown
}> | null
steps?: Array<{
step?: null | string
type?: null | string
name?: null | string
input?: unknown
dependencies?: null | string[]
@@ -29,7 +29,7 @@ export type PayloadWorkflow = {
[key: string]: unknown
}
import { JSONPath } from 'jsonpath-plus'
import Handlebars from 'handlebars'
// Helper type to extract workflow step data from the generated types
export type WorkflowStep = {
@@ -52,6 +52,63 @@ export class WorkflowExecutor {
private logger: Payload['logger']
) {}
/**
* Convert string values to appropriate types based on common patterns
*/
private convertValueType(value: unknown, key: string): unknown {
if (typeof value !== 'string') {
return value
}
// Type conversion patterns based on field names and values
const numericFields = ['timeout', 'retries', 'delay', 'port', 'limit', 'offset', 'count', 'max', 'min']
const booleanFields = ['enabled', 'required', 'active', 'success', 'failed', 'complete']
// Convert numeric fields
if (numericFields.some(field => key.toLowerCase().includes(field))) {
const numValue = Number(value)
if (!isNaN(numValue)) {
this.logger.debug({
key,
originalValue: value,
convertedValue: numValue
}, 'Auto-converted field to number')
return numValue
}
}
// Convert boolean fields
if (booleanFields.some(field => key.toLowerCase().includes(field))) {
if (value === 'true') return true
if (value === 'false') return false
}
// Try to parse as number if it looks numeric
if (/^\d+$/.test(value)) {
const numValue = parseInt(value, 10)
this.logger.debug({
key,
originalValue: value,
convertedValue: numValue
}, 'Auto-converted numeric string to number')
return numValue
}
// Try to parse as float if it looks like a decimal
if (/^\d+\.\d+$/.test(value)) {
const floatValue = parseFloat(value)
this.logger.debug({
key,
originalValue: value,
convertedValue: floatValue
}, 'Auto-converted decimal string to number')
return floatValue
}
// Return as string if no conversion applies
return value
}
/**
* Classifies error types based on error messages
*/
@@ -165,31 +222,11 @@ export class WorkflowExecutor {
const taskSlug = step.type as string
try {
// Extract input data from step - PayloadCMS flattens inputSchema fields to step level
const inputFields: Record<string, unknown> = {}
// Get input configuration from the step
const inputConfig = (step.input as Record<string, unknown>) || {}
// Get all fields except the core step fields
const coreFields = ['step', 'name', 'dependencies', 'condition', 'type', 'id', 'parameters']
for (const [key, value] of Object.entries(step)) {
if (!coreFields.includes(key)) {
// Handle flattened parameters (remove 'parameter' prefix)
if (key.startsWith('parameter')) {
const cleanKey = key.replace('parameter', '')
const properKey = cleanKey.charAt(0).toLowerCase() + cleanKey.slice(1)
inputFields[properKey] = value
} else {
inputFields[key] = value
}
}
}
// Also extract from nested parameters object if it exists
if (step.parameters && typeof step.parameters === 'object') {
Object.assign(inputFields, step.parameters)
}
// Resolve input data using JSONPath
const resolvedInput = this.resolveStepInput(inputFields, context)
// Resolve input data using Handlebars templates
const resolvedInput = this.resolveStepInput(inputConfig, context, taskSlug)
context.steps[stepName].input = resolvedInput
if (!taskSlug) {
@@ -447,32 +484,6 @@ export class WorkflowExecutor {
}
}
/**
* Parse a condition value (string literal, number, boolean, or JSONPath)
*/
private parseConditionValue(expr: string, context: ExecutionContext): any {
// Handle string literals
if ((expr.startsWith('"') && expr.endsWith('"')) || (expr.startsWith("'") && expr.endsWith("'"))) {
return expr.slice(1, -1) // Remove quotes
}
// Handle boolean literals
if (expr === 'true') {return true}
if (expr === 'false') {return false}
// Handle number literals
if (/^-?\d+(?:\.\d+)?$/.test(expr)) {
return Number(expr)
}
// Handle JSONPath expressions
if (expr.startsWith('$')) {
return this.resolveJSONPathValue(expr, context)
}
// Return as string if nothing else matches
return expr
}
/**
* Resolve step execution order based on dependencies
@@ -531,68 +542,56 @@ export class WorkflowExecutor {
return executionBatches
}
/**
* Resolve a JSONPath value from the context
*/
private resolveJSONPathValue(expr: string, context: ExecutionContext): any {
if (expr.startsWith('$')) {
const result = JSONPath({
json: context,
path: expr,
wrap: false
})
// Return first result if array, otherwise the result itself
return Array.isArray(result) && result.length > 0 ? result[0] : result
}
return expr
}
/**
* Resolve step input using JSONPath expressions
* Resolve step input using Handlebars templates with automatic type conversion
*/
private resolveStepInput(config: Record<string, unknown>, context: ExecutionContext): Record<string, unknown> {
private resolveStepInput(config: Record<string, unknown>, context: ExecutionContext, stepType?: string): Record<string, unknown> {
const resolved: Record<string, unknown> = {}
this.logger.debug({
configKeys: Object.keys(config),
contextSteps: Object.keys(context.steps),
triggerType: context.trigger?.type
}, 'Starting step input resolution')
triggerType: context.trigger?.type,
stepType
}, 'Starting step input resolution with Handlebars')
for (const [key, value] of Object.entries(config)) {
if (typeof value === 'string' && value.startsWith('$')) {
// This is a JSONPath expression
this.logger.debug({
key,
jsonPath: value,
availableSteps: Object.keys(context.steps),
hasTriggerData: !!context.trigger?.data,
hasTriggerDoc: !!context.trigger?.doc
}, 'Resolving JSONPath expression')
try {
const result = JSONPath({
json: context,
path: value,
wrap: false
})
if (typeof value === 'string') {
// Check if the string contains Handlebars templates
if (value.includes('{{') && value.includes('}}')) {
this.logger.debug({
key,
jsonPath: value,
result: JSON.stringify(result).substring(0, 200),
resultType: Array.isArray(result) ? 'array' : typeof result
}, 'JSONPath resolved successfully')
template: value,
availableSteps: Object.keys(context.steps),
hasTriggerData: !!context.trigger?.data,
hasTriggerDoc: !!context.trigger?.doc
}, 'Processing Handlebars template')
resolved[key] = result
} catch (error) {
this.logger.warn({
error: error instanceof Error ? error.message : 'Unknown error',
key,
path: value,
contextSnapshot: JSON.stringify(context).substring(0, 500)
}, 'Failed to resolve JSONPath')
resolved[key] = value // Keep original value if resolution fails
try {
const template = Handlebars.compile(value)
const result = template(context)
this.logger.debug({
key,
template: value,
result: JSON.stringify(result).substring(0, 200),
resultType: typeof result
}, 'Handlebars template resolved successfully')
resolved[key] = this.convertValueType(result, key)
} catch (error) {
this.logger.warn({
error: error instanceof Error ? error.message : 'Unknown error',
key,
template: value,
contextSnapshot: JSON.stringify(context).substring(0, 500)
}, 'Failed to resolve Handlebars template')
resolved[key] = value // Keep original value if resolution fails
}
} else {
// Regular string, apply type conversion
resolved[key] = this.convertValueType(value, key)
}
} else if (typeof value === 'object' && value !== null) {
// Recursively resolve nested objects
@@ -601,7 +600,7 @@ export class WorkflowExecutor {
nestedKeys: Object.keys(value as Record<string, unknown>)
}, 'Recursively resolving nested object')
resolved[key] = this.resolveStepInput(value as Record<string, unknown>, context)
resolved[key] = this.resolveStepInput(value as Record<string, unknown>, context, stepType)
} else {
// Keep literal values as-is
resolved[key] = value
@@ -690,7 +689,7 @@ export class WorkflowExecutor {
}
/**
* Evaluate a condition using JSONPath and comparison operators
* Evaluate a condition using Handlebars templates and comparison operators
*/
public evaluateCondition(condition: string, context: ExecutionContext): boolean {
this.logger.debug({
@@ -708,11 +707,11 @@ export class WorkflowExecutor {
if (comparisonMatch) {
const [, leftExpr, operator, rightExpr] = comparisonMatch
// Evaluate left side (should be JSONPath)
const leftValue = this.resolveJSONPathValue(leftExpr.trim(), context)
// Evaluate left side (could be Handlebars template or JSONPath)
const leftValue = this.resolveConditionValue(leftExpr.trim(), context)
// Parse right side (could be string, number, boolean, or JSONPath)
const rightValue = this.parseConditionValue(rightExpr.trim(), context)
// Evaluate right side (could be Handlebars template, JSONPath, or literal)
const rightValue = this.resolveConditionValue(rightExpr.trim(), context)
this.logger.debug({
condition,
@@ -760,19 +759,15 @@ export class WorkflowExecutor {
return result
} else {
// Treat as simple JSONPath boolean evaluation
const result = JSONPath({
json: context,
path: condition,
wrap: false
})
// Treat as template or JSONPath boolean evaluation
const result = this.resolveConditionValue(condition, context)
this.logger.debug({
condition,
result,
resultType: Array.isArray(result) ? 'array' : typeof result,
resultLength: Array.isArray(result) ? result.length : undefined
}, 'JSONPath boolean evaluation result')
}, 'Boolean evaluation result')
// Handle different result types
let finalResult: boolean
@@ -802,6 +797,43 @@ export class WorkflowExecutor {
}
}
/**
* Resolve a condition value using Handlebars templates or JSONPath
*/
private resolveConditionValue(expr: string, context: ExecutionContext): any {
// Handle string literals
if ((expr.startsWith('"') && expr.endsWith('"')) || (expr.startsWith("'") && expr.endsWith("'"))) {
return expr.slice(1, -1) // Remove quotes
}
// Handle boolean literals
if (expr === 'true') {return true}
if (expr === 'false') {return false}
// Handle number literals
if (/^-?\d+(?:\.\d+)?$/.test(expr)) {
return Number(expr)
}
// Handle Handlebars templates
if (expr.includes('{{') && expr.includes('}}')) {
try {
const template = Handlebars.compile(expr)
return template(context)
} catch (error) {
this.logger.warn({
error: error instanceof Error ? error.message : 'Unknown error',
expr
}, 'Failed to resolve Handlebars condition')
return false
}
}
// Return as string if nothing else matches
return expr
}
/**
* Execute a workflow with the given context
*/

View File

@@ -0,0 +1,113 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import { useField, useFormFields } from '@payloadcms/ui'
import { WorkflowBuilder } from '../components/WorkflowBuilder/index.js'
// Import the step types from the steps module
import * as stepTasks from '../steps/index.js'
// Extract available step types from imported tasks
const getAvailableStepTypes = () => {
const stepTypes: Array<{
slug: string
label?: string
inputSchema?: any[]
outputSchema?: any[]
}> = []
// Get all exported step tasks
const tasks = [
stepTasks.HttpRequestStepTask,
stepTasks.CreateDocumentStepTask,
stepTasks.ReadDocumentStepTask,
stepTasks.UpdateDocumentStepTask,
stepTasks.DeleteDocumentStepTask,
stepTasks.SendEmailStepTask
]
tasks.forEach(task => {
if (task && task.slug) {
stepTypes.push({
slug: task.slug,
label: undefined, // Tasks don't have labels, will use slug
inputSchema: task.inputSchema,
outputSchema: task.outputSchema
})
}
})
return stepTypes
}
interface WorkflowBuilderFieldProps {
name?: string
path?: string
}
export const WorkflowBuilderField: React.FC<WorkflowBuilderFieldProps> = ({
name,
path
}) => {
const availableStepTypes = getAvailableStepTypes()
const { value: steps, setValue: setSteps } = useField<any>({ path: 'steps' })
const { value: layout, setValue: setLayout } = useField<any>({ path: 'layout' })
const { value: workflowName } = useField<string>({ path: 'name' })
const [workflowData, setWorkflowData] = useState<any>({
id: 'temp',
name: workflowName || 'Workflow',
steps: steps || [],
layout: layout || {}
})
// Update local state when form fields change
useEffect(() => {
setWorkflowData({
id: 'temp',
name: workflowName || 'Workflow',
steps: steps || [],
layout: layout || {}
})
}, [steps, layout, workflowName])
const handleSave = useCallback((updatedWorkflow: any) => {
// Update the form fields
if (updatedWorkflow.steps) {
setSteps(updatedWorkflow.steps)
}
if (updatedWorkflow.layout) {
setLayout(updatedWorkflow.layout)
}
}, [setSteps, setLayout])
return (
<div style={{
marginTop: '20px',
marginBottom: '20px',
border: '1px solid var(--theme-elevation-100)',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
background: 'var(--theme-elevation-50)',
padding: '12px 16px',
borderBottom: '1px solid var(--theme-elevation-100)'
}}>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600', color: 'var(--theme-text)' }}>
Visual Workflow Builder
</h3>
<p style={{ margin: '4px 0 0', fontSize: '12px', color: 'var(--theme-text-400)' }}>
Drag and drop steps to build your workflow visually. Click on any step to configure its parameters.
</p>
</div>
<WorkflowBuilder
workflow={workflowData}
availableStepTypes={availableStepTypes}
onSave={handleSave}
readonly={false}
/>
</div>
)
}

View File

@@ -3,7 +3,7 @@ import type {Field} from "payload"
export const parameter = (slug: string, field: {name: string} & Field): Field => ({
...field,
name: 'parameter' + field.name.replace(/^\w/, c => c.toUpperCase()),
name: 'parameter' + field.name.replace(/^\w/, c => c.toUpperCase()) + Math.random().toString().replace(/\D/g, ''),
admin: {
...(field.admin as unknown || {}),
condition: (_, siblingData, __) => {

View File

@@ -38,6 +38,45 @@ export const createCollectionTriggerHook = (collectionSlug: string, hookType: st
}
}
// Check if any trigger has a condition and evaluate it
let shouldExecute = false
for (const trigger of workflow.triggers || []) {
if (trigger.type === 'collection-hook' &&
trigger.parameters?.collectionSlug === collectionSlug &&
trigger.parameters?.hook === hookType) {
if (trigger.condition) {
// Evaluate the condition
try {
const conditionMet = executor.evaluateCondition(trigger.condition, context)
if (conditionMet) {
shouldExecute = true
break
}
} catch (error) {
payload.logger.error({
workflowId: workflow.id,
condition: trigger.condition,
error: error instanceof Error ? error.message : 'Unknown error'
}, 'Failed to evaluate trigger condition')
}
} else {
// No condition means always execute
shouldExecute = true
break
}
}
}
if (!shouldExecute) {
payload.logger.debug({
workflowId: workflow.id,
collection: collectionSlug,
hookType
}, 'Workflow skipped due to unmet condition')
continue
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await executor.execute(workflow as any, context, req)

View File

@@ -1,14 +0,0 @@
import { describe, it, expect } from 'vitest'
describe('PayloadCMS Automation Plugin', () => {
it('should export the plugin function from server export', async () => {
const { workflowsPlugin } = await import('../exports/server.js')
expect(workflowsPlugin).toBeDefined()
expect(typeof workflowsPlugin).toBe('function')
})
it('should have the correct package name', async () => {
// Basic test to ensure the plugin can be imported
expect(true).toBe(true)
})
})

View File

@@ -1,356 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createDocumentHandler } from '../steps/create-document-handler.js'
import type { Payload } from 'payload'
describe('CreateDocumentStepHandler', () => {
let mockPayload: Payload
let mockReq: any
beforeEach(() => {
mockPayload = {
create: vi.fn()
} as any
mockReq = {
payload: mockPayload,
user: { id: 'user-123', email: 'test@example.com' }
}
vi.clearAllMocks()
})
describe('Document creation', () => {
it('should create document successfully', async () => {
const createdDoc = {
id: 'doc-123',
title: 'Test Document',
content: 'Test content'
}
;(mockPayload.create as any).mockResolvedValue(createdDoc)
const input = {
collectionSlug: 'posts',
data: {
title: 'Test Document',
content: 'Test content'
},
stepName: 'test-create-step'
}
const result = await createDocumentHandler({ input, req: mockReq })
expect(result.state).toBe('succeeded')
expect(result.output.document).toEqual(createdDoc)
expect(result.output.id).toBe('doc-123')
expect(mockPayload.create).toHaveBeenCalledWith({
collection: 'posts',
data: {
title: 'Test Document',
content: 'Test content'
},
req: mockReq
})
})
it('should create document with relationship fields', async () => {
const createdDoc = {
id: 'doc-456',
title: 'Related Document',
author: 'user-123',
category: 'cat-789'
}
;(mockPayload.create as any).mockResolvedValue(createdDoc)
const input = {
collectionSlug: 'articles',
data: {
title: 'Related Document',
author: 'user-123',
category: 'cat-789'
},
stepName: 'test-create-with-relations'
}
const result = await createDocumentHandler({ input, req: mockReq })
expect(result.state).toBe('succeeded')
expect(result.output.document).toEqual(createdDoc)
expect(mockPayload.create).toHaveBeenCalledWith({
collection: 'articles',
data: {
title: 'Related Document',
author: 'user-123',
category: 'cat-789'
},
req: mockReq
})
})
it('should create document with complex nested data', async () => {
const complexData = {
title: 'Complex Document',
metadata: {
tags: ['tag1', 'tag2'],
settings: {
featured: true,
priority: 5
}
},
blocks: [
{ type: 'text', content: 'Text block' },
{ type: 'image', src: 'image.jpg', alt: 'Test image' }
]
}
const createdDoc = { id: 'doc-complex', ...complexData }
;(mockPayload.create as any).mockResolvedValue(createdDoc)
const input = {
collectionSlug: 'pages',
data: complexData,
stepName: 'test-create-complex'
}
const result = await createDocumentHandler({ input, req: mockReq })
expect(result.state).toBe('succeeded')
expect(result.output.document).toEqual(createdDoc)
expect(mockPayload.create).toHaveBeenCalledWith({
collection: 'pages',
data: complexData,
req: mockReq
})
})
})
describe('Error handling', () => {
it('should handle PayloadCMS validation errors', async () => {
const validationError = new Error('Validation failed')
;(validationError as any).data = [
{
message: 'Title is required',
path: 'title',
value: undefined
}
]
;(mockPayload.create as any).mockRejectedValue(validationError)
const input = {
collectionSlug: 'posts',
data: {
content: 'Missing title'
},
stepName: 'test-validation-error'
}
const result = await createDocumentHandler({ input, req: mockReq })
expect(result.state).toBe('failed')
expect(result.error).toContain('Validation failed')
})
it('should handle permission errors', async () => {
const permissionError = new Error('Insufficient permissions')
;(permissionError as any).status = 403
;(mockPayload.create as any).mockRejectedValue(permissionError)
const input = {
collectionSlug: 'admin-only',
data: {
secret: 'confidential data'
},
stepName: 'test-permission-error'
}
const result = await createDocumentHandler({ input, req: mockReq })
expect(result.state).toBe('failed')
expect(result.error).toContain('Insufficient permissions')
})
it('should handle database connection errors', async () => {
const dbError = new Error('Database connection failed')
;(mockPayload.create as any).mockRejectedValue(dbError)
const input = {
collectionSlug: 'posts',
data: { title: 'Test' },
stepName: 'test-db-error'
}
const result = await createDocumentHandler({ input, req: mockReq })
expect(result.state).toBe('failed')
expect(result.error).toContain('Database connection failed')
})
it('should handle unknown collection errors', async () => {
const collectionError = new Error('Collection "unknown" not found')
;(mockPayload.create as any).mockRejectedValue(collectionError)
const input = {
collectionSlug: 'unknown-collection',
data: { title: 'Test' },
stepName: 'test-unknown-collection'
}
const result = await createDocumentHandler({ input, req: mockReq })
expect(result.state).toBe('failed')
expect(result.error).toContain('Collection "unknown" not found')
})
})
describe('Input validation', () => {
it('should validate required collection slug', async () => {
const input = {
data: { title: 'Test' },
stepName: 'test-missing-collection'
}
const result = await createDocumentStepHandler({ input, req: mockReq } as any)
expect(result.state).toBe('failed')
expect(result.error).toContain('Collection slug is required')
})
it('should validate required data field', async () => {
const input = {
collectionSlug: 'posts',
stepName: 'test-missing-data'
}
const result = await createDocumentStepHandler({ input, req: mockReq } as any)
expect(result.state).toBe('failed')
expect(result.error).toContain('Data is required')
})
it('should validate data is an object', async () => {
const input = {
collectionSlug: 'posts',
data: 'invalid-data-type',
stepName: 'test-invalid-data-type'
}
const result = await createDocumentStepHandler({ input, req: mockReq } as any)
expect(result.state).toBe('failed')
expect(result.error).toContain('Data must be an object')
})
it('should handle empty data object', async () => {
const createdDoc = { id: 'empty-doc' }
;(mockPayload.create as any).mockResolvedValue(createdDoc)
const input = {
collectionSlug: 'posts',
data: {},
stepName: 'test-empty-data'
}
const result = await createDocumentHandler({ input, req: mockReq })
expect(result.state).toBe('succeeded')
expect(result.output.document).toEqual(createdDoc)
expect(mockPayload.create).toHaveBeenCalledWith({
collection: 'posts',
data: {},
req: mockReq
})
})
})
describe('Request context', () => {
it('should pass user context from request', async () => {
const createdDoc = { id: 'user-doc', title: 'User Document' }
;(mockPayload.create as any).mockResolvedValue(createdDoc)
const input = {
collectionSlug: 'posts',
data: { title: 'User Document' },
stepName: 'test-user-context'
}
await createDocumentStepHandler({ input, req: mockReq })
const createCall = (mockPayload.create as any).mock.calls[0][0]
expect(createCall.req).toBe(mockReq)
expect(createCall.req.user).toEqual({
id: 'user-123',
email: 'test@example.com'
})
})
it('should handle requests without user context', async () => {
const reqWithoutUser = {
payload: mockPayload,
user: null
}
const createdDoc = { id: 'anonymous-doc' }
;(mockPayload.create as any).mockResolvedValue(createdDoc)
const input = {
collectionSlug: 'posts',
data: { title: 'Anonymous Document' },
stepName: 'test-anonymous'
}
const result = await createDocumentStepHandler({ input, req: reqWithoutUser })
expect(result.state).toBe('succeeded')
expect(mockPayload.create).toHaveBeenCalledWith({
collection: 'posts',
data: { title: 'Anonymous Document' },
req: reqWithoutUser
})
})
})
describe('Output structure', () => {
it('should return correct output structure on success', async () => {
const createdDoc = {
id: 'output-test-doc',
title: 'Output Test',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z'
}
;(mockPayload.create as any).mockResolvedValue(createdDoc)
const input = {
collectionSlug: 'posts',
data: { title: 'Output Test' },
stepName: 'test-output-structure'
}
const result = await createDocumentHandler({ input, req: mockReq })
expect(result).toEqual({
state: 'succeeded',
output: {
document: createdDoc,
id: 'output-test-doc'
}
})
})
it('should return correct error structure on failure', async () => {
const error = new Error('Test error')
;(mockPayload.create as any).mockRejectedValue(error)
const input = {
collectionSlug: 'posts',
data: { title: 'Error Test' },
stepName: 'test-error-structure'
}
const result = await createDocumentHandler({ input, req: mockReq })
expect(result).toEqual({
state: 'failed',
error: 'Test error'
})
})
})
})

View File

@@ -1,348 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { httpRequestStepHandler } from '../steps/http-request-handler.js'
import type { Payload } from 'payload'
// Mock fetch globally
global.fetch = vi.fn()
describe('HttpRequestStepHandler', () => {
let mockPayload: Payload
let mockReq: any
beforeEach(() => {
mockPayload = {} as Payload
mockReq = {
payload: mockPayload,
user: null
}
vi.clearAllMocks()
})
describe('GET requests', () => {
it('should handle successful GET request', async () => {
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers({ 'content-type': 'application/json' }),
text: vi.fn().mockResolvedValue('{"success": true}')
}
;(global.fetch as any).mockResolvedValue(mockResponse)
const input = {
url: 'https://api.example.com/data',
method: 'GET' as const,
stepName: 'test-get-step'
}
const result = await httpRequestStepHandler({ input, req: mockReq })
expect(result.state).toBe('succeeded')
expect(result.output.status).toBe(200)
expect(result.output.statusText).toBe('OK')
expect(result.output.body).toBe('{"success": true}')
expect(result.output.headers).toEqual({ 'content-type': 'application/json' })
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/data', {
method: 'GET',
headers: {},
signal: expect.any(AbortSignal)
})
})
it('should handle GET request with custom headers', async () => {
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers(),
text: vi.fn().mockResolvedValue('success')
}
;(global.fetch as any).mockResolvedValue(mockResponse)
const input = {
url: 'https://api.example.com/data',
method: 'GET' as const,
headers: {
'Authorization': 'Bearer token123',
'User-Agent': 'PayloadCMS-Workflow/1.0'
},
stepName: 'test-get-with-headers'
}
await httpRequestStepHandler({ input, req: mockReq })
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/data', {
method: 'GET',
headers: {
'Authorization': 'Bearer token123',
'User-Agent': 'PayloadCMS-Workflow/1.0'
},
signal: expect.any(AbortSignal)
})
})
})
describe('POST requests', () => {
it('should handle POST request with JSON body', async () => {
const mockResponse = {
ok: true,
status: 201,
statusText: 'Created',
headers: new Headers(),
text: vi.fn().mockResolvedValue('{"id": "123"}')
}
;(global.fetch as any).mockResolvedValue(mockResponse)
const input = {
url: 'https://api.example.com/posts',
method: 'POST' as const,
body: { title: 'Test Post', content: 'Test content' },
headers: { 'Content-Type': 'application/json' },
stepName: 'test-post-step'
}
const result = await httpRequestStepHandler({ input, req: mockReq })
expect(result.state).toBe('succeeded')
expect(result.output.status).toBe(201)
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Test Post', content: 'Test content' }),
signal: expect.any(AbortSignal)
})
})
it('should handle POST request with string body', async () => {
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers(),
text: vi.fn().mockResolvedValue('OK')
}
;(global.fetch as any).mockResolvedValue(mockResponse)
const input = {
url: 'https://api.example.com/webhook',
method: 'POST' as const,
body: 'plain text data',
headers: { 'Content-Type': 'text/plain' },
stepName: 'test-post-string'
}
await httpRequestStepHandler({ input, req: mockReq })
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/webhook', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: 'plain text data',
signal: expect.any(AbortSignal)
})
})
})
describe('Error handling', () => {
it('should handle network errors', async () => {
;(global.fetch as any).mockRejectedValue(new Error('Network error'))
const input = {
url: 'https://invalid-url.example.com',
method: 'GET' as const,
stepName: 'test-network-error'
}
const result = await httpRequestStepHandler({ input, req: mockReq })
expect(result.state).toBe('failed')
expect(result.error).toContain('Network error')
})
it('should handle HTTP error status codes', async () => {
const mockResponse = {
ok: false,
status: 404,
statusText: 'Not Found',
headers: new Headers(),
text: vi.fn().mockResolvedValue('Page not found')
}
;(global.fetch as any).mockResolvedValue(mockResponse)
const input = {
url: 'https://api.example.com/nonexistent',
method: 'GET' as const,
stepName: 'test-404-error'
}
const result = await httpRequestStepHandler({ input, req: mockReq })
expect(result.state).toBe('failed')
expect(result.error).toContain('HTTP 404')
expect(result.output.status).toBe(404)
expect(result.output.statusText).toBe('Not Found')
})
it('should handle timeout errors', async () => {
const abortError = new Error('The operation was aborted')
abortError.name = 'AbortError'
;(global.fetch as any).mockRejectedValue(abortError)
const input = {
url: 'https://slow-api.example.com',
method: 'GET' as const,
timeout: 1000,
stepName: 'test-timeout'
}
const result = await httpRequestStepHandler({ input, req: mockReq })
expect(result.state).toBe('failed')
expect(result.error).toContain('timeout')
})
it('should handle invalid JSON response parsing', async () => {
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers({ 'content-type': 'application/json' }),
text: vi.fn().mockResolvedValue('invalid json {')
}
;(global.fetch as any).mockResolvedValue(mockResponse)
const input = {
url: 'https://api.example.com/invalid-json',
method: 'GET' as const,
stepName: 'test-invalid-json'
}
const result = await httpRequestStepHandler({ input, req: mockReq })
// Should still succeed but with raw text body
expect(result.state).toBe('succeeded')
expect(result.output.body).toBe('invalid json {')
})
})
describe('Request validation', () => {
it('should validate required URL field', async () => {
const input = {
method: 'GET' as const,
stepName: 'test-missing-url'
}
const result = await httpRequestStepHandler({ input, req: mockReq } as any)
expect(result.state).toBe('failed')
expect(result.error).toContain('URL is required')
})
it('should validate HTTP method', async () => {
const input = {
url: 'https://api.example.com',
method: 'INVALID' as any,
stepName: 'test-invalid-method'
}
const result = await httpRequestStepHandler({ input, req: mockReq })
expect(result.state).toBe('failed')
expect(result.error).toContain('Invalid HTTP method')
})
it('should validate URL format', async () => {
const input = {
url: 'not-a-valid-url',
method: 'GET' as const,
stepName: 'test-invalid-url'
}
const result = await httpRequestStepHandler({ input, req: mockReq })
expect(result.state).toBe('failed')
expect(result.error).toContain('Invalid URL')
})
})
describe('Response processing', () => {
it('should parse JSON responses automatically', async () => {
const responseData = { id: 123, name: 'Test Item' }
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers({ 'content-type': 'application/json' }),
text: vi.fn().mockResolvedValue(JSON.stringify(responseData))
}
;(global.fetch as any).mockResolvedValue(mockResponse)
const input = {
url: 'https://api.example.com/item/123',
method: 'GET' as const,
stepName: 'test-json-parsing'
}
const result = await httpRequestStepHandler({ input, req: mockReq })
expect(result.state).toBe('succeeded')
expect(typeof result.output.body).toBe('string')
// Should contain the JSON as string for safe storage
expect(result.output.body).toBe(JSON.stringify(responseData))
})
it('should handle non-JSON responses', async () => {
const htmlContent = '<html><body>Hello World</body></html>'
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers({ 'content-type': 'text/html' }),
text: vi.fn().mockResolvedValue(htmlContent)
}
;(global.fetch as any).mockResolvedValue(mockResponse)
const input = {
url: 'https://example.com/page',
method: 'GET' as const,
stepName: 'test-html-response'
}
const result = await httpRequestStepHandler({ input, req: mockReq })
expect(result.state).toBe('succeeded')
expect(result.output.body).toBe(htmlContent)
})
it('should capture response headers', async () => {
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers({
'content-type': 'application/json',
'x-rate-limit': '100',
'x-custom-header': 'custom-value'
}),
text: vi.fn().mockResolvedValue('{}')
}
;(global.fetch as any).mockResolvedValue(mockResponse)
const input = {
url: 'https://api.example.com/data',
method: 'GET' as const,
stepName: 'test-response-headers'
}
const result = await httpRequestStepHandler({ input, req: mockReq })
expect(result.state).toBe('succeeded')
expect(result.output.headers).toEqual({
'content-type': 'application/json',
'x-rate-limit': '100',
'x-custom-header': 'custom-value'
})
})
})
})

View File

@@ -1,472 +0,0 @@
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.data.url',
message: 'Static message',
data: {
id: '$.trigger.doc.id',
title: '$.trigger.doc.title'
}
}
const context = {
trigger: {
doc: { id: 'doc-123', title: 'Doc Title' },
data: { 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')
})
})
})

View File

@@ -1,17 +0,0 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
threads: false, // Prevent port/DB conflicts
pool: 'forks',
poolOptions: {
forks: {
singleFork: true
}
},
testTimeout: 30000, // 30 second timeout for integration tests
setupFiles: ['./dev/test-setup.ts']
},
})