mirror of
https://github.com/xtr-dev/payload-automation.git
synced 2025-12-10 17:03:22 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d0b83cfa3 | ||
| 067b96a5a7 | |||
| cfc716fc78 | |||
|
|
de7589d431 | ||
|
|
5be4f82ebb | ||
|
|
13968904c0 | ||
|
|
e3b79710ba | ||
| 718f5fe16b | |||
| 10a4ca1b35 | |||
| 4c513aa730 | |||
| f29f08972b | |||
| 9c75b28cd7 | |||
| 243bff2de3 | |||
| 705ed331fa | |||
| e0b13d3515 |
78
.github/workflows/claude-code-review.yml
vendored
Normal file
78
.github/workflows/claude-code-review.yml
vendored
Normal 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
64
.github/workflows/claude.yml
vendored
Normal 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
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -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
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -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
|
||||
@@ -1,225 +0,0 @@
|
||||
# Migration Guide: v0.0.36 to v0.0.37
|
||||
|
||||
## Overview
|
||||
|
||||
Version 0.0.37 introduces significant refactoring and cleanup changes focused on simplifying the plugin architecture and removing unused features. This version removes several deprecated components and consolidates trigger handling.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
### 1. Removed Components and Files
|
||||
|
||||
The following components and modules have been completely removed:
|
||||
|
||||
#### Components
|
||||
- `TriggerWorkflowButton` - Manual workflow triggering component
|
||||
- `WorkflowExecutionStatus` - Workflow execution status display component
|
||||
|
||||
#### Plugin Modules
|
||||
- `init-global-hooks.ts` - Global hook initialization (functionality moved to main plugin)
|
||||
- `init-step-tasks.ts` - Step task initialization (functionality integrated elsewhere)
|
||||
- `init-webhook.ts` - Webhook initialization (functionality removed)
|
||||
- `init-workflow-hooks.ts` - Workflow hook initialization (functionality moved to main plugin)
|
||||
|
||||
#### Triggers
|
||||
- `webhook-trigger.ts` - Webhook trigger support has been removed
|
||||
- `cron-trigger.ts` - Cron/scheduled trigger support has been removed
|
||||
- `cron-scheduler.ts` - Cron scheduling system has been removed
|
||||
|
||||
#### Tests
|
||||
- `webhook-triggers.spec.ts` - Webhook trigger integration tests
|
||||
|
||||
### 2. Cron/Scheduled Workflows Removal
|
||||
|
||||
Cron trigger functionality has been completely removed from the plugin. If you were using cron triggers in your workflows:
|
||||
|
||||
**Migration Path:**
|
||||
- Use external scheduling services like GitHub Actions or Vercel Cron
|
||||
- Trigger workflows via webhook endpoints from external schedulers
|
||||
- Implement custom scheduling in your application using libraries like `node-cron`
|
||||
|
||||
**Example with GitHub Actions:**
|
||||
```yaml
|
||||
name: Trigger Workflow
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * *' # Daily at 9 AM
|
||||
jobs:
|
||||
trigger:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Workflow
|
||||
run: |
|
||||
curl -X POST https://your-app.com/api/workflows/trigger/your-workflow-id
|
||||
```
|
||||
|
||||
### 3. Webhook Trigger Removal
|
||||
|
||||
Webhook triggers have been removed. If you were using webhook triggers:
|
||||
|
||||
**Migration Path:**
|
||||
- Implement custom webhook endpoints in your PayloadCMS application
|
||||
- Use collection or global hooks to trigger workflows based on document changes
|
||||
- Create manual trigger endpoints using the workflow executor directly
|
||||
|
||||
### 4. Architecture Changes
|
||||
|
||||
#### ExecutorRegistry Removal
|
||||
The `executorRegistry` singleton pattern has been removed. WorkflowExecutor instances are now created on-demand for each execution.
|
||||
|
||||
**What this means:**
|
||||
- No shared state between workflow executions
|
||||
- Each execution is completely independent
|
||||
- Better memory management and isolation
|
||||
|
||||
#### Hook Registration Consolidation
|
||||
Hook registration logic has been consolidated into the main plugin file:
|
||||
- Collection hooks are now registered directly in `plugin/index.ts`
|
||||
- Global hooks are handled through the new `plugin/global-hook.ts` module
|
||||
- Simplified hook management with better TypeScript typing
|
||||
|
||||
## Non-Breaking Changes
|
||||
|
||||
### 1. Trigger Module Refactoring
|
||||
|
||||
Triggers have been reorganized into a dedicated `triggers/` directory with improved modularity:
|
||||
|
||||
- `triggers/collection-trigger.ts` - Collection-based triggers
|
||||
- `triggers/global-trigger.ts` - Global document triggers
|
||||
- `triggers/index.ts` - Trigger exports
|
||||
- `triggers/types.ts` - Trigger type definitions
|
||||
|
||||
### 2. Field Helper Improvements
|
||||
|
||||
New `triggerField` helper function standardizes virtual field creation across all trigger modules:
|
||||
|
||||
```typescript
|
||||
// Before (manual virtual field creation)
|
||||
{
|
||||
name: '__builtin_collection',
|
||||
type: 'text',
|
||||
admin: { hidden: true },
|
||||
virtual: true,
|
||||
access: { read: () => false, update: () => false }
|
||||
}
|
||||
|
||||
// After (using helper)
|
||||
triggerField('collection', {
|
||||
type: 'text',
|
||||
// helper handles virtual field setup automatically
|
||||
})
|
||||
```
|
||||
|
||||
### 3. TypeScript Improvements
|
||||
|
||||
- Replaced 'any' types with proper TypeScript types
|
||||
- Added `CollectionAfterChangeHook` and `PayloadRequest` type usage
|
||||
- Improved type safety throughout the codebase
|
||||
|
||||
### 4. Code Organization
|
||||
|
||||
#### New File Structure
|
||||
```
|
||||
src/
|
||||
├── plugin/
|
||||
│ ├── collection-hook.ts # Collection hook logic
|
||||
│ ├── global-hook.ts # Global hook logic (new)
|
||||
│ └── index.ts # Main plugin (consolidated)
|
||||
├── triggers/ # Trigger modules (new directory)
|
||||
├── fields/
|
||||
│ └── parameter.ts # Moved from triggers/helpers.ts
|
||||
```
|
||||
|
||||
#### ESLint Configuration
|
||||
- Disabled `perfectionist/sort-object-types` and `perfectionist/sort-objects` rules
|
||||
- Allows natural object property ordering without enforced alphabetical sorting
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### 1. Update Imports
|
||||
|
||||
If you were importing removed components or modules, remove these imports:
|
||||
|
||||
```typescript
|
||||
// Remove these imports - no longer available
|
||||
import { TriggerWorkflowButton } from '@xtr-dev/payload-automation/client'
|
||||
import { WorkflowExecutionStatus } from '@xtr-dev/payload-automation/client'
|
||||
```
|
||||
|
||||
### 2. Update Workflow Configurations
|
||||
|
||||
If your workflows used cron or webhook triggers, you'll need to modify them:
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
{
|
||||
trigger: {
|
||||
type: 'cron',
|
||||
schedule: '0 9 * * *'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
{
|
||||
trigger: {
|
||||
type: 'collection', // Use collection or global triggers instead
|
||||
collection: 'your-collection',
|
||||
operation: 'create'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Replace Webhook Functionality
|
||||
|
||||
If you were using webhook triggers, implement custom webhook handling:
|
||||
|
||||
```typescript
|
||||
// In your PayloadCMS config
|
||||
export default buildConfig({
|
||||
endpoints: [
|
||||
{
|
||||
path: '/trigger-workflow/:workflowId',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
const { workflowId } = req.params
|
||||
// Implement your workflow triggering logic here
|
||||
// Use the WorkflowExecutor directly if needed
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Update Custom Components
|
||||
|
||||
If you built custom components using the removed ones as reference, update them to work with the new architecture.
|
||||
|
||||
## Benefits of This Release
|
||||
|
||||
1. **Simplified Architecture**: Consolidated plugin initialization reduces complexity
|
||||
2. **Better Memory Management**: On-demand executor creation eliminates shared state issues
|
||||
3. **Improved Type Safety**: Proper TypeScript typing throughout
|
||||
4. **Reduced Bundle Size**: Removal of unused code reduces package size
|
||||
5. **Better Maintainability**: Cleaner code organization and module structure
|
||||
6. **More Reliable**: External scheduling is more robust than in-process cron jobs
|
||||
|
||||
## Testing Your Migration
|
||||
|
||||
After migrating:
|
||||
|
||||
1. **Test Existing Workflows**: Ensure collection and global triggers still work as expected
|
||||
2. **Verify External Triggers**: Test any external webhook or scheduling implementations
|
||||
3. **Check Custom Components**: Validate any custom UI components that interact with workflows
|
||||
4. **Run Integration Tests**: Execute your test suite to catch any breaking changes
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues migrating from v0.0.36 to v0.0.37:
|
||||
|
||||
1. Check that you're not using any of the removed components or features
|
||||
2. Verify your workflow trigger types are supported (collection, global, manual)
|
||||
3. Update any custom integrations that relied on removed modules
|
||||
4. Consider the external scheduling alternatives for cron functionality
|
||||
|
||||
For additional support, please refer to the plugin documentation or open an issue in the project repository.
|
||||
174
README.md
174
README.md
@@ -1,18 +1,17 @@
|
||||
# @xtr-dev/payload-automation
|
||||
|
||||
A comprehensive workflow automation plugin for PayloadCMS 3.x that enables visual workflow building, execution tracking, and parallel processing.
|
||||
A workflow automation plugin for PayloadCMS 3.x. Run steps in workflows triggered by document changes or webhooks.
|
||||
|
||||
⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔄 **Visual Workflow Builder** - Create complex workflows with drag-and-drop interface
|
||||
- ⚡ **Parallel Execution** - Smart dependency resolution for optimal performance
|
||||
- 🎯 **Multiple Triggers** - Collection hooks, webhooks, manual execution
|
||||
- ⏰ **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
|
||||
- 🔄 Visual workflow builder in PayloadCMS admin
|
||||
- ⚡ Run workflows when documents are created/updated/deleted
|
||||
- 🎯 Trigger workflows via webhooks
|
||||
- 📊 Track workflow execution history
|
||||
- 🔧 HTTP requests, document operations, email sending
|
||||
- 🔗 Use data from previous steps in templates
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -47,91 +46,25 @@ export default buildConfig({
|
||||
})
|
||||
```
|
||||
|
||||
## Import Structure
|
||||
|
||||
The plugin uses separate exports to avoid bundling server-side code in client bundles:
|
||||
## Imports
|
||||
|
||||
```typescript
|
||||
// Server-side plugin and functions
|
||||
// Server plugin
|
||||
import { workflowsPlugin } from '@xtr-dev/payload-automation/server'
|
||||
|
||||
// Client-side components
|
||||
import { TriggerWorkflowButton } from '@xtr-dev/payload-automation/client'
|
||||
// Client components
|
||||
import { StatusCell, ErrorDisplay } from '@xtr-dev/payload-automation/client'
|
||||
|
||||
// Types only (safe for both server and client)
|
||||
// Types
|
||||
import type { WorkflowsPluginConfig } from '@xtr-dev/payload-automation'
|
||||
```
|
||||
|
||||
## Step Types
|
||||
|
||||
### HTTP Request
|
||||
Make external API calls with comprehensive error handling and retry logic.
|
||||
Call external APIs. Supports GET, POST, PUT, DELETE, PATCH. Uses Bearer tokens, API keys, or basic auth.
|
||||
|
||||
**Key Features:**
|
||||
- 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
|
||||
|
||||
**Error Handling:**
|
||||
HTTP Request steps use a **response-based success model** rather than status-code-based failures:
|
||||
|
||||
- ✅ **Successful completion**: All HTTP requests that receive a response (including 4xx/5xx status codes) are marked as "succeeded"
|
||||
- ❌ **Failed execution**: Only network errors, timeouts, DNS failures, and connection issues cause step failure
|
||||
- 📊 **Error information preserved**: HTTP error status codes (404, 500, etc.) are captured in the step output for workflow conditional logic
|
||||
|
||||
**Example workflow logic:**
|
||||
```typescript
|
||||
// Step outputs for a 404 response:
|
||||
{
|
||||
"status": 404,
|
||||
"statusText": "Not Found",
|
||||
"body": "Resource not found",
|
||||
"headers": {...},
|
||||
"duration": 1200
|
||||
}
|
||||
|
||||
// Use in workflow conditions:
|
||||
// "$.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.
|
||||
|
||||
**Enhanced Error Tracking:**
|
||||
For network failures (timeouts, DNS errors, connection failures), the plugin provides detailed error information through an independent storage system that bypasses PayloadCMS's output limitations:
|
||||
|
||||
```typescript
|
||||
// Timeout error details preserved in workflow context:
|
||||
{
|
||||
"steps": {
|
||||
"httpStep": {
|
||||
"state": "failed",
|
||||
"error": "Task handler returned a failed state",
|
||||
"errorDetails": {
|
||||
"errorType": "timeout",
|
||||
"duration": 2006,
|
||||
"attempts": 1,
|
||||
"finalError": "Request timeout after 2000ms",
|
||||
"context": {
|
||||
"url": "https://api.example.com/data",
|
||||
"method": "GET",
|
||||
"timeout": 2000
|
||||
}
|
||||
},
|
||||
"executionInfo": {
|
||||
"completed": true,
|
||||
"success": false,
|
||||
"executedAt": "2025-09-04T15:16:10.000Z",
|
||||
"duration": 2006
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Access in workflow conditions:
|
||||
// "$.steps.httpStep.errorDetails.errorType == 'timeout'"
|
||||
// "$.steps.httpStep.errorDetails.duration > 5000"
|
||||
```
|
||||
HTTP steps succeed even with 4xx/5xx status codes. Only network errors (timeouts, DNS failures) cause step failure. Check `{{steps.stepName.output.status}}` for error handling.
|
||||
|
||||
### Document Operations
|
||||
- **Create Document** - Create PayloadCMS documents
|
||||
@@ -142,13 +75,20 @@ For network failures (timeouts, DNS errors, connection failures), the plugin pro
|
||||
### Communication
|
||||
- **Send Email** - Send notifications via PayloadCMS email
|
||||
|
||||
## Data Resolution
|
||||
## Templates
|
||||
|
||||
Use JSONPath to access workflow data:
|
||||
Use `{{}}` to insert data:
|
||||
|
||||
- `$.trigger.doc.id` - Access trigger document
|
||||
- `$.steps.stepName.output` - Use previous step outputs
|
||||
- `$.context` - Access workflow context
|
||||
- `{{trigger.doc.id}}` - Data from the document that triggered the workflow
|
||||
- `{{steps.stepName.output}}` - Data from previous steps
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"url": "https://api.example.com/posts/{{steps.createPost.output.id}}",
|
||||
"condition": "{{trigger.doc.status}} == 'published'"
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -156,70 +96,26 @@ Use JSONPath to access workflow data:
|
||||
- Node.js ^18.20.2 || >=20.9.0
|
||||
- pnpm ^9 || ^10
|
||||
|
||||
## Environment Variables
|
||||
## Logging
|
||||
|
||||
Control plugin logging with these environment variables:
|
||||
Set `PAYLOAD_AUTOMATION_LOG_LEVEL` to control logs:
|
||||
- `silent`, `error`, `warn` (default), `info`, `debug`, `trace`
|
||||
|
||||
### `PAYLOAD_AUTOMATION_LOG_LEVEL`
|
||||
Controls both configuration-time and runtime logging.
|
||||
- **Values**: `silent`, `error`, `warn`, `info`, `debug`, `trace`
|
||||
- **Default**: `warn`
|
||||
- **Example**: `PAYLOAD_AUTOMATION_LOG_LEVEL=debug`
|
||||
|
||||
### `PAYLOAD_AUTOMATION_CONFIG_LOG_LEVEL` (optional)
|
||||
Override log level specifically for configuration-time logs (plugin setup).
|
||||
- **Values**: Same as above
|
||||
- **Default**: Falls back to `PAYLOAD_AUTOMATION_LOG_LEVEL` or `warn`
|
||||
- **Example**: `PAYLOAD_AUTOMATION_CONFIG_LOG_LEVEL=silent`
|
||||
|
||||
### Production Usage
|
||||
For production, keep the default (`warn`) or use `error` or `silent`:
|
||||
```bash
|
||||
PAYLOAD_AUTOMATION_LOG_LEVEL=error npm start
|
||||
```
|
||||
|
||||
### Development Usage
|
||||
For debugging, use `debug` or `info`:
|
||||
```bash
|
||||
PAYLOAD_AUTOMATION_LOG_LEVEL=debug npm run dev
|
||||
```
|
||||
|
||||
## Scheduled Workflows
|
||||
|
||||
For scheduled workflows, use **webhook triggers** with external cron services instead of built-in cron triggers:
|
||||
Use webhook triggers with external cron services:
|
||||
|
||||
### GitHub Actions (Free)
|
||||
```yaml
|
||||
# .github/workflows/daily-report.yml
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * *' # Daily at 9 AM UTC
|
||||
jobs:
|
||||
trigger-workflow:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: curl -X POST https://your-app.com/api/workflows-webhook/daily-report
|
||||
```bash
|
||||
# Call workflow webhook from cron
|
||||
curl -X POST https://your-app.com/api/workflows-webhook/daily-report
|
||||
```
|
||||
|
||||
### Vercel Cron (Serverless)
|
||||
```js
|
||||
// api/cron/daily.js
|
||||
export default async function handler(req, res) {
|
||||
await fetch('https://your-app.com/api/workflows-webhook/daily-report', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source: 'vercel-cron' })
|
||||
});
|
||||
res.status(200).json({ success: true });
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**: Better reliability, proper process isolation, easier debugging, and leverages existing infrastructure.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation coming soon. For now, explore the development environment in the repository for examples and patterns.
|
||||
Built-in cron triggers were removed in v0.0.37+.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@xtr-dev/payload-workflows",
|
||||
"version": "0.0.38",
|
||||
"version": "0.0.40",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@xtr-dev/payload-workflows",
|
||||
"version": "0.0.38",
|
||||
"version": "0.0.40",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "^10.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xtr-dev/payload-automation",
|
||||
"version": "0.0.38",
|
||||
"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
86
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
297
src/components/WorkflowBuilder/StepConfigurationForm.tsx
Normal file
297
src/components/WorkflowBuilder/StepConfigurationForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
255
src/components/WorkflowBuilder/WorkflowBuilder.tsx
Normal file
255
src/components/WorkflowBuilder/WorkflowBuilder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
src/components/WorkflowBuilder/WorkflowToolbar.tsx
Normal file
118
src/components/WorkflowBuilder/WorkflowToolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
src/components/WorkflowBuilder/index.ts
Normal file
4
src/components/WorkflowBuilder/index.ts
Normal 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'
|
||||
157
src/components/WorkflowBuilder/nodes/StepNode.tsx
Normal file
157
src/components/WorkflowBuilder/nodes/StepNode.tsx
Normal 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'
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
113
src/fields/WorkflowBuilderField.tsx
Normal file
113
src/fields/WorkflowBuilderField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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']
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user