ai-workflows 2.1.3 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +14 -1
- package/README.md +2 -0
- package/dist/barrier.d.ts +6 -0
- package/dist/barrier.d.ts.map +1 -1
- package/dist/barrier.js +45 -7
- package/dist/barrier.js.map +1 -1
- package/dist/cascade-context.d.ts.map +1 -1
- package/dist/cascade-context.js +25 -25
- package/dist/cascade-context.js.map +1 -1
- package/dist/cascade-executor.d.ts.map +1 -1
- package/dist/cascade-executor.js +1 -1
- package/dist/cascade-executor.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +23 -7
- package/dist/context.js.map +1 -1
- package/dist/cron-parser.d.ts +65 -0
- package/dist/cron-parser.d.ts.map +1 -0
- package/dist/cron-parser.js +294 -0
- package/dist/cron-parser.js.map +1 -0
- package/dist/cron-scheduler.d.ts +117 -0
- package/dist/cron-scheduler.d.ts.map +1 -0
- package/dist/cron-scheduler.js +176 -0
- package/dist/cron-scheduler.js.map +1 -0
- package/dist/database-context.d.ts +184 -0
- package/dist/database-context.d.ts.map +1 -0
- package/dist/database-context.js +428 -0
- package/dist/database-context.js.map +1 -0
- package/dist/digital-objects-adapter.d.ts +159 -0
- package/dist/digital-objects-adapter.d.ts.map +1 -0
- package/dist/digital-objects-adapter.js +229 -0
- package/dist/digital-objects-adapter.js.map +1 -0
- package/dist/durable-execution-cloudflare.d.ts +427 -0
- package/dist/durable-execution-cloudflare.d.ts.map +1 -0
- package/dist/durable-execution-cloudflare.js +510 -0
- package/dist/durable-execution-cloudflare.js.map +1 -0
- package/dist/durable-execution.d.ts +482 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +594 -0
- package/dist/durable-execution.js.map +1 -0
- package/dist/durable-workflow.d.ts +176 -0
- package/dist/durable-workflow.d.ts.map +1 -0
- package/dist/durable-workflow.js +552 -0
- package/dist/durable-workflow.js.map +1 -0
- package/dist/graph/topological-sort.d.ts.map +1 -1
- package/dist/graph/topological-sort.js +5 -5
- package/dist/graph/topological-sort.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +101 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +115 -0
- package/dist/logger.js.map +1 -0
- package/dist/on.d.ts.map +1 -1
- package/dist/on.js +3 -3
- package/dist/on.js.map +1 -1
- package/dist/runtime.d.ts +169 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +275 -0
- package/dist/runtime.js.map +1 -0
- package/dist/send.d.ts.map +1 -1
- package/dist/send.js +4 -3
- package/dist/send.js.map +1 -1
- package/dist/telemetry.d.ts +150 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +388 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/timer-registry.d.ts +25 -0
- package/dist/timer-registry.d.ts.map +1 -1
- package/dist/timer-registry.js +42 -8
- package/dist/timer-registry.js.map +1 -1
- package/dist/types.d.ts +17 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/dist/worker/durable-step.d.ts +481 -0
- package/dist/worker/durable-step.d.ts.map +1 -0
- package/dist/worker/durable-step.js +606 -0
- package/dist/worker/durable-step.js.map +1 -0
- package/dist/worker/index.d.ts +106 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +124 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/state-adapter.d.ts +230 -0
- package/dist/worker/state-adapter.d.ts.map +1 -0
- package/dist/worker/state-adapter.js +409 -0
- package/dist/worker/state-adapter.js.map +1 -0
- package/dist/worker/topological-executor.d.ts +282 -0
- package/dist/worker/topological-executor.d.ts.map +1 -0
- package/dist/worker/topological-executor.js +396 -0
- package/dist/worker/topological-executor.js.map +1 -0
- package/dist/worker/workflow-builder.d.ts +286 -0
- package/dist/worker/workflow-builder.d.ts.map +1 -0
- package/dist/worker/workflow-builder.js +565 -0
- package/dist/worker/workflow-builder.js.map +1 -0
- package/dist/worker.d.ts +800 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +2428 -0
- package/dist/worker.js.map +1 -0
- package/dist/workflow-builder.d.ts +287 -0
- package/dist/workflow-builder.d.ts.map +1 -0
- package/dist/workflow-builder.js +762 -0
- package/dist/workflow-builder.js.map +1 -0
- package/dist/workflow.d.ts +14 -30
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +132 -292
- package/dist/workflow.js.map +1 -1
- package/examples/01-ecommerce-order-pipeline.ts +358 -0
- package/examples/02-content-moderation-cascade.ts +454 -0
- package/examples/03-scheduled-reporting-dependencies.ts +479 -0
- package/examples/04-database-persistence.ts +518 -0
- package/examples/README.md +173 -0
- package/package.json +30 -13
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +48 -7
- package/src/cascade-context.ts +36 -29
- package/src/cascade-executor.ts +3 -2
- package/src/context.ts +41 -12
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -0
- package/src/digital-objects-adapter.ts +351 -0
- package/src/durable-execution-cloudflare.ts +855 -0
- package/src/durable-execution.ts +1042 -0
- package/src/durable-workflow.ts +717 -0
- package/src/graph/topological-sort.ts +6 -8
- package/src/index.ts +69 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +8 -9
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +44 -10
- package/src/types.ts +32 -17
- package/src/worker/durable-step.ts +976 -0
- package/src/worker/index.ts +216 -0
- package/src/worker/state-adapter.ts +589 -0
- package/src/worker/topological-executor.ts +625 -0
- package/src/worker/workflow-builder.ts +871 -0
- package/src/worker.ts +2906 -0
- package/src/workflow-builder.ts +1068 -0
- package/src/workflow.ts +188 -351
- package/test/barrier-join.test.ts +32 -24
- package/test/cascade-executor.test.ts +9 -16
- package/test/cron-parser.test.ts +314 -0
- package/test/cron-scheduler.test.ts +291 -0
- package/test/database-context.test.ts +770 -0
- package/test/db-provider-adapter.test.ts +862 -0
- package/test/durable-execution-cloudflare.test.ts +606 -0
- package/test/durable-execution-in-process.test.ts +286 -0
- package/test/durable-execution.test.ts +247 -0
- package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
- package/test/integration.test.ts +442 -0
- package/test/rpc-surface.test.ts +946 -0
- package/test/runtime.test.ts +262 -0
- package/test/schedule-timer-cleanup.test.ts +30 -21
- package/test/send-race-conditions.test.ts +30 -40
- package/test/worker/durable-cascade.test.ts +1117 -0
- package/test/worker/durable-step.test.ts +723 -0
- package/test/worker/topological-executor.test.ts +1240 -0
- package/test/worker/workflow-builder.test.ts +1067 -0
- package/test/worker.test.ts +608 -0
- package/test/workflow-builder.test.ts +1670 -0
- package/test/workflow-cron.test.ts +256 -0
- package/test/workflow-state-adapter.test.ts +923 -0
- package/test/workflow.test.ts +25 -22
- package/tsconfig.json +3 -1
- package/vitest.config.ts +38 -1
- package/vitest.workers.config.ts +44 -0
- package/wrangler.jsonc +22 -0
- package/.turbo/turbo-test.log +0 -169
- package/LICENSE +0 -21
- package/src/context.js +0 -83
- package/src/every.js +0 -267
- package/src/index.js +0 -71
- package/src/on.js +0 -79
- package/src/send.js +0 -111
- package/src/types.js +0 -4
- package/src/workflow.js +0 -455
- package/test/context.test.js +0 -116
- package/test/every.test.js +0 -282
- package/test/on.test.js +0 -80
- package/test/send.test.js +0 -89
- package/test/workflow.test.js +0 -224
- package/vitest.config.js +0 -7
|
@@ -0,0 +1,1067 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkflowBuilder DSL Tests (RED Phase)
|
|
3
|
+
*
|
|
4
|
+
* Tests for WorkflowBuilder - a declarative DSL for building durable workflows
|
|
5
|
+
* using a fluent builder pattern with step dependencies, event triggers,
|
|
6
|
+
* and scheduled execution.
|
|
7
|
+
*
|
|
8
|
+
* These tests define the expected behavior for WorkflowBuilder DSL before implementation.
|
|
9
|
+
* All tests SHOULD FAIL because WorkflowBuilder does not exist yet.
|
|
10
|
+
*
|
|
11
|
+
* Uses @cloudflare/vitest-pool-workers - NO MOCKS.
|
|
12
|
+
* Tests run against real Cloudflare Workflows bindings.
|
|
13
|
+
*
|
|
14
|
+
* Bead: aip-llm1
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
20
|
+
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// These imports will FAIL because WorkflowBuilder does not exist yet.
|
|
24
|
+
// This is the RED phase of TDD.
|
|
25
|
+
// ============================================================================
|
|
26
|
+
import {
|
|
27
|
+
WorkflowBuilder,
|
|
28
|
+
type WorkflowBuilderConfig,
|
|
29
|
+
type StepDefinition,
|
|
30
|
+
type StepChain,
|
|
31
|
+
type TriggerConfig,
|
|
32
|
+
type ScheduleConfig,
|
|
33
|
+
type BuiltWorkflow,
|
|
34
|
+
} from '../../src/worker/workflow-builder.js'
|
|
35
|
+
|
|
36
|
+
// Import DurableStep for use in step definitions
|
|
37
|
+
import { DurableStep, type StepConfig } from '../../src/worker/durable-step.js'
|
|
38
|
+
|
|
39
|
+
// Import WorkflowService for integration tests
|
|
40
|
+
import { WorkflowServiceCore } from '../../src/worker.js'
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Type Definitions for Test Environment
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
interface TestEnv {
|
|
47
|
+
WORKFLOW: Workflow
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Test Data - Sample Step Functions
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
const validateOrder = async (input: { orderId: string }) => {
|
|
55
|
+
return { valid: true, orderId: input.orderId }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const chargePayment = async (input: { orderId: string; amount: number }) => {
|
|
59
|
+
return { charged: true, transactionId: `txn_${input.orderId}` }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const fulfillOrder = async (input: { orderId: string; transactionId: string }) => {
|
|
63
|
+
return { fulfilled: true, trackingNumber: `track_${input.orderId}` }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sendNotification = async (input: { to: string; message: string }) => {
|
|
67
|
+
return { sent: true }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cleanupData = async () => {
|
|
71
|
+
return { cleaned: true }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// 1. WorkflowBuilder.create() - Creating Workflow Definitions
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
describe('WorkflowBuilder.create()', () => {
|
|
79
|
+
it('creates a new workflow builder with a name', () => {
|
|
80
|
+
const builder = WorkflowBuilder.create('order-process')
|
|
81
|
+
|
|
82
|
+
expect(builder).toBeDefined()
|
|
83
|
+
expect(builder.name).toBe('order-process')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('creates a new workflow builder with name and config', () => {
|
|
87
|
+
const builder = WorkflowBuilder.create('payment-flow', {
|
|
88
|
+
description: 'Handles payment processing',
|
|
89
|
+
version: '1.0.0',
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
expect(builder).toBeDefined()
|
|
93
|
+
expect(builder.name).toBe('payment-flow')
|
|
94
|
+
expect(builder.config?.description).toBe('Handles payment processing')
|
|
95
|
+
expect(builder.config?.version).toBe('1.0.0')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('returns a WorkflowBuilder instance', () => {
|
|
99
|
+
const builder = WorkflowBuilder.create('test-workflow')
|
|
100
|
+
|
|
101
|
+
// Should be an instance of WorkflowBuilder
|
|
102
|
+
expect(builder).toBeInstanceOf(WorkflowBuilder)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('allows method chaining', () => {
|
|
106
|
+
// The builder pattern should support fluent method chaining
|
|
107
|
+
const builder = WorkflowBuilder.create('chainable-workflow')
|
|
108
|
+
|
|
109
|
+
// step() should return something chainable
|
|
110
|
+
expect(typeof builder.step).toBe('function')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('validates workflow name is provided', () => {
|
|
114
|
+
// Empty name should throw
|
|
115
|
+
expect(() => WorkflowBuilder.create('')).toThrow()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('accepts optional timeout configuration', () => {
|
|
119
|
+
const builder = WorkflowBuilder.create('timed-workflow', {
|
|
120
|
+
timeout: '5 minutes',
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(builder.config?.timeout).toBe('5 minutes')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('accepts optional retry configuration', () => {
|
|
127
|
+
const builder = WorkflowBuilder.create('retry-workflow', {
|
|
128
|
+
retries: {
|
|
129
|
+
limit: 3,
|
|
130
|
+
delay: '1 second',
|
|
131
|
+
backoff: 'exponential',
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
expect(builder.config?.retries?.limit).toBe(3)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// ============================================================================
|
|
140
|
+
// 2. WorkflowBuilder.step() - Adding Durable Steps
|
|
141
|
+
// ============================================================================
|
|
142
|
+
|
|
143
|
+
describe('WorkflowBuilder.step()', () => {
|
|
144
|
+
let builder: ReturnType<typeof WorkflowBuilder.create>
|
|
145
|
+
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
builder = WorkflowBuilder.create('test-workflow')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('adds a step with name and function', () => {
|
|
151
|
+
const result = builder.step('validate', validateOrder)
|
|
152
|
+
|
|
153
|
+
expect(result).toBeDefined()
|
|
154
|
+
// Should return something with dependsOn for chaining
|
|
155
|
+
expect(typeof result.dependsOn).toBe('function')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('adds a step with name, config, and function', () => {
|
|
159
|
+
const result = builder.step(
|
|
160
|
+
'charge',
|
|
161
|
+
{
|
|
162
|
+
retries: { limit: 3, delay: '1 second' },
|
|
163
|
+
timeout: '30 seconds',
|
|
164
|
+
},
|
|
165
|
+
chargePayment
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
expect(result).toBeDefined()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('adds a step from a DurableStep instance', () => {
|
|
172
|
+
const durableStep = new DurableStep('fulfill', fulfillOrder)
|
|
173
|
+
|
|
174
|
+
const result = builder.step(durableStep)
|
|
175
|
+
|
|
176
|
+
expect(result).toBeDefined()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('allows multiple steps to be added', () => {
|
|
180
|
+
builder.step('step1', validateOrder)
|
|
181
|
+
builder.step('step2', chargePayment)
|
|
182
|
+
const result = builder.step('step3', fulfillOrder)
|
|
183
|
+
|
|
184
|
+
expect(result).toBeDefined()
|
|
185
|
+
// After build, should have all 3 steps
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('preserves step order', () => {
|
|
189
|
+
builder.step('first', validateOrder)
|
|
190
|
+
builder.step('second', chargePayment)
|
|
191
|
+
builder.step('third', fulfillOrder)
|
|
192
|
+
|
|
193
|
+
const workflow = builder.build()
|
|
194
|
+
|
|
195
|
+
expect(workflow.steps).toHaveLength(3)
|
|
196
|
+
expect(workflow.steps[0]?.name).toBe('first')
|
|
197
|
+
expect(workflow.steps[1]?.name).toBe('second')
|
|
198
|
+
expect(workflow.steps[2]?.name).toBe('third')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('rejects duplicate step names', () => {
|
|
202
|
+
builder.step('unique', validateOrder)
|
|
203
|
+
|
|
204
|
+
expect(() => builder.step('unique', chargePayment)).toThrow()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('returns a StepChain for further configuration', () => {
|
|
208
|
+
const stepChain = builder.step('configurable', validateOrder)
|
|
209
|
+
|
|
210
|
+
// StepChain should have dependsOn, timeout, retries methods
|
|
211
|
+
expect(typeof stepChain.dependsOn).toBe('function')
|
|
212
|
+
expect(typeof stepChain.timeout).toBe('function')
|
|
213
|
+
expect(typeof stepChain.retries).toBe('function')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('step chain allows setting timeout', () => {
|
|
217
|
+
const stepChain = builder.step('timed-step', validateOrder).timeout('30 seconds')
|
|
218
|
+
|
|
219
|
+
expect(stepChain).toBeDefined()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('step chain allows setting retries', () => {
|
|
223
|
+
const stepChain = builder
|
|
224
|
+
.step('retry-step', validateOrder)
|
|
225
|
+
.retries({ limit: 3, delay: '1 second', backoff: 'exponential' })
|
|
226
|
+
|
|
227
|
+
expect(stepChain).toBeDefined()
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// ============================================================================
|
|
232
|
+
// 3. WorkflowBuilder.step().dependsOn() - Declaring Dependencies
|
|
233
|
+
// ============================================================================
|
|
234
|
+
|
|
235
|
+
describe('WorkflowBuilder.step().dependsOn()', () => {
|
|
236
|
+
let builder: ReturnType<typeof WorkflowBuilder.create>
|
|
237
|
+
|
|
238
|
+
beforeEach(() => {
|
|
239
|
+
builder = WorkflowBuilder.create('dependency-workflow')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('declares a single dependency', () => {
|
|
243
|
+
builder.step('validate', validateOrder)
|
|
244
|
+
const result = builder.step('charge', chargePayment).dependsOn('validate')
|
|
245
|
+
|
|
246
|
+
expect(result).toBeDefined()
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('declares multiple dependencies', () => {
|
|
250
|
+
builder.step('validate', validateOrder)
|
|
251
|
+
builder.step('check-inventory', async () => ({ available: true }))
|
|
252
|
+
const result = builder.step('charge', chargePayment).dependsOn('validate', 'check-inventory')
|
|
253
|
+
|
|
254
|
+
expect(result).toBeDefined()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('declares dependencies with array syntax', () => {
|
|
258
|
+
builder.step('validate', validateOrder)
|
|
259
|
+
builder.step('check-inventory', async () => ({ available: true }))
|
|
260
|
+
const result = builder.step('charge', chargePayment).dependsOn(['validate', 'check-inventory'])
|
|
261
|
+
|
|
262
|
+
expect(result).toBeDefined()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('chains multiple dependsOn calls', () => {
|
|
266
|
+
builder.step('step1', validateOrder)
|
|
267
|
+
builder.step('step2', async () => ({ done: true }))
|
|
268
|
+
const result = builder.step('step3', chargePayment).dependsOn('step1').dependsOn('step2')
|
|
269
|
+
|
|
270
|
+
expect(result).toBeDefined()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('validates that dependencies exist', () => {
|
|
274
|
+
// Referencing non-existent step should throw on build
|
|
275
|
+
builder.step('charge', chargePayment).dependsOn('non-existent')
|
|
276
|
+
|
|
277
|
+
expect(() => builder.build()).toThrow(/dependency.*non-existent.*not found/i)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('detects circular dependencies on build', () => {
|
|
281
|
+
// This creates a cycle: A -> B -> A
|
|
282
|
+
builder.step('stepA', validateOrder).dependsOn('stepB')
|
|
283
|
+
builder.step('stepB', chargePayment).dependsOn('stepA')
|
|
284
|
+
|
|
285
|
+
expect(() => builder.build()).toThrow(/circular dependency/i)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('allows soft dependencies (can proceed on failure)', () => {
|
|
289
|
+
builder.step('validate', validateOrder)
|
|
290
|
+
const result = builder.step('charge', chargePayment).dependsOn('validate', { type: 'soft' })
|
|
291
|
+
|
|
292
|
+
expect(result).toBeDefined()
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('dependency options include wait timeout', () => {
|
|
296
|
+
builder.step('validate', validateOrder)
|
|
297
|
+
const result = builder
|
|
298
|
+
.step('charge', chargePayment)
|
|
299
|
+
.dependsOn('validate', { timeout: '5 minutes' })
|
|
300
|
+
|
|
301
|
+
expect(result).toBeDefined()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('supports step chain continuation after dependsOn', () => {
|
|
305
|
+
builder.step('validate', validateOrder)
|
|
306
|
+
|
|
307
|
+
// dependsOn should return builder for continuation
|
|
308
|
+
const result = builder
|
|
309
|
+
.step('charge', chargePayment)
|
|
310
|
+
.dependsOn('validate')
|
|
311
|
+
.step('fulfill', fulfillOrder)
|
|
312
|
+
.dependsOn('charge')
|
|
313
|
+
|
|
314
|
+
expect(result).toBeDefined()
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// ============================================================================
|
|
319
|
+
// 4. WorkflowBuilder.on() - Event-Triggered Steps
|
|
320
|
+
// ============================================================================
|
|
321
|
+
|
|
322
|
+
describe('WorkflowBuilder.on()', () => {
|
|
323
|
+
let builder: ReturnType<typeof WorkflowBuilder.create>
|
|
324
|
+
|
|
325
|
+
beforeEach(() => {
|
|
326
|
+
builder = WorkflowBuilder.create('event-workflow')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('registers an event trigger with .do()', () => {
|
|
330
|
+
builder.step('validate', validateOrder)
|
|
331
|
+
const result = builder.on('Order.placed').do('validate')
|
|
332
|
+
|
|
333
|
+
expect(result).toBeDefined()
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('registers event trigger for step with inline function', () => {
|
|
337
|
+
const result = builder.on('Order.placed').do(async (event: { orderId: string }) => {
|
|
338
|
+
return { processed: true, orderId: event.orderId }
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
expect(result).toBeDefined()
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('event names follow Noun.event format', () => {
|
|
345
|
+
builder.step('handle', validateOrder)
|
|
346
|
+
const result = builder.on('Customer.created').do('handle')
|
|
347
|
+
|
|
348
|
+
expect(result).toBeDefined()
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('validates event name format', () => {
|
|
352
|
+
builder.step('handle', validateOrder)
|
|
353
|
+
|
|
354
|
+
// Invalid event name should throw
|
|
355
|
+
expect(() => builder.on('invalid-event-name').do('handle')).toThrow()
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('multiple events can trigger the same step', () => {
|
|
359
|
+
builder.step('notify', sendNotification)
|
|
360
|
+
builder.on('Order.placed').do('notify')
|
|
361
|
+
const result = builder.on('Order.shipped').do('notify')
|
|
362
|
+
|
|
363
|
+
expect(result).toBeDefined()
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('same event can trigger multiple steps', () => {
|
|
367
|
+
builder.step('validate', validateOrder)
|
|
368
|
+
builder.step('notify', sendNotification)
|
|
369
|
+
builder.on('Order.placed').do('validate')
|
|
370
|
+
const result = builder.on('Order.placed').do('notify')
|
|
371
|
+
|
|
372
|
+
expect(result).toBeDefined()
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('returns trigger chain for configuration', () => {
|
|
376
|
+
builder.step('validate', validateOrder)
|
|
377
|
+
const chain = builder.on('Order.placed')
|
|
378
|
+
|
|
379
|
+
expect(typeof chain.do).toBe('function')
|
|
380
|
+
expect(typeof chain.filter).toBe('function')
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('supports event filtering', () => {
|
|
384
|
+
builder.step('validate', validateOrder)
|
|
385
|
+
const result = builder
|
|
386
|
+
.on('Order.placed')
|
|
387
|
+
.filter((event) => event.amount > 100)
|
|
388
|
+
.do('validate')
|
|
389
|
+
|
|
390
|
+
expect(result).toBeDefined()
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('validates that step exists when using string reference', () => {
|
|
394
|
+
// Reference to non-existent step should throw on build
|
|
395
|
+
builder.on('Order.placed').do('non-existent-step')
|
|
396
|
+
|
|
397
|
+
expect(() => builder.build()).toThrow(/step.*non-existent-step.*not found/i)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('on() creates implicit step when given inline function', () => {
|
|
401
|
+
builder.on('Order.placed').do(async (event: { orderId: string }) => {
|
|
402
|
+
return { processed: true }
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
const workflow = builder.build()
|
|
406
|
+
|
|
407
|
+
// Should have created an implicit step
|
|
408
|
+
expect(workflow.steps.length).toBeGreaterThanOrEqual(1)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('supports typed event payloads', () => {
|
|
412
|
+
interface OrderPlacedEvent {
|
|
413
|
+
orderId: string
|
|
414
|
+
amount: number
|
|
415
|
+
customerId: string
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const result = builder.on<OrderPlacedEvent>('Order.placed').do(async (event) => {
|
|
419
|
+
// TypeScript should know event has orderId, amount, customerId
|
|
420
|
+
return { processed: true, orderId: event.orderId }
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
expect(result).toBeDefined()
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
// ============================================================================
|
|
428
|
+
// 5. WorkflowBuilder.every() - Scheduled Steps
|
|
429
|
+
// ============================================================================
|
|
430
|
+
|
|
431
|
+
describe('WorkflowBuilder.every()', () => {
|
|
432
|
+
let builder: ReturnType<typeof WorkflowBuilder.create>
|
|
433
|
+
|
|
434
|
+
beforeEach(() => {
|
|
435
|
+
builder = WorkflowBuilder.create('scheduled-workflow')
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('registers a scheduled trigger with .do()', () => {
|
|
439
|
+
builder.step('cleanup', cleanupData)
|
|
440
|
+
const result = builder.every('hour').do('cleanup')
|
|
441
|
+
|
|
442
|
+
expect(result).toBeDefined()
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('supports common schedule intervals', () => {
|
|
446
|
+
builder.step('task', cleanupData)
|
|
447
|
+
|
|
448
|
+
// All these should work
|
|
449
|
+
expect(() => builder.every('minute').do('task')).not.toThrow()
|
|
450
|
+
expect(() => builder.every('hour').do('task')).not.toThrow()
|
|
451
|
+
expect(() => builder.every('day').do('task')).not.toThrow()
|
|
452
|
+
expect(() => builder.every('week').do('task')).not.toThrow()
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('supports day-of-week schedules', () => {
|
|
456
|
+
builder.step('report', cleanupData)
|
|
457
|
+
const result = builder.every('Monday').do('report')
|
|
458
|
+
|
|
459
|
+
expect(result).toBeDefined()
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('supports day-of-week with time', () => {
|
|
463
|
+
builder.step('report', cleanupData)
|
|
464
|
+
const result = builder.every('Monday').at('9am').do('report')
|
|
465
|
+
|
|
466
|
+
expect(result).toBeDefined()
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('supports interval with value', () => {
|
|
470
|
+
builder.step('check', cleanupData)
|
|
471
|
+
const result = builder.every(5).minutes().do('check')
|
|
472
|
+
|
|
473
|
+
expect(result).toBeDefined()
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('supports natural language schedules', () => {
|
|
477
|
+
builder.step('report', cleanupData)
|
|
478
|
+
const result = builder.every('first Monday of the month').do('report')
|
|
479
|
+
|
|
480
|
+
expect(result).toBeDefined()
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('supports cron expressions', () => {
|
|
484
|
+
builder.step('task', cleanupData)
|
|
485
|
+
const result = builder.every('0 9 * * 1').do('task') // Every Monday at 9am
|
|
486
|
+
|
|
487
|
+
expect(result).toBeDefined()
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('supports inline functions', () => {
|
|
491
|
+
const result = builder.every('hour').do(async () => {
|
|
492
|
+
return { completed: true }
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
expect(result).toBeDefined()
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('validates that step exists when using string reference', () => {
|
|
499
|
+
builder.every('hour').do('non-existent-step')
|
|
500
|
+
|
|
501
|
+
expect(() => builder.build()).toThrow(/step.*non-existent-step.*not found/i)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('returns schedule chain for configuration', () => {
|
|
505
|
+
builder.step('task', cleanupData)
|
|
506
|
+
const chain = builder.every('hour')
|
|
507
|
+
|
|
508
|
+
expect(typeof chain.do).toBe('function')
|
|
509
|
+
expect(typeof chain.at).toBe('function')
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it('supports timezone configuration', () => {
|
|
513
|
+
builder.step('report', cleanupData)
|
|
514
|
+
const result = builder.every('day').at('9am').timezone('America/New_York').do('report')
|
|
515
|
+
|
|
516
|
+
expect(result).toBeDefined()
|
|
517
|
+
})
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
// ============================================================================
|
|
521
|
+
// 6. WorkflowBuilder.build() - Building the Workflow
|
|
522
|
+
// ============================================================================
|
|
523
|
+
|
|
524
|
+
describe('WorkflowBuilder.build()', () => {
|
|
525
|
+
let builder: ReturnType<typeof WorkflowBuilder.create>
|
|
526
|
+
|
|
527
|
+
beforeEach(() => {
|
|
528
|
+
builder = WorkflowBuilder.create('buildable-workflow')
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('returns a BuiltWorkflow object', () => {
|
|
532
|
+
builder.step('validate', validateOrder)
|
|
533
|
+
const workflow = builder.build()
|
|
534
|
+
|
|
535
|
+
expect(workflow).toBeDefined()
|
|
536
|
+
expect(workflow.name).toBe('buildable-workflow')
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('built workflow contains all registered steps', () => {
|
|
540
|
+
builder.step('step1', validateOrder)
|
|
541
|
+
builder.step('step2', chargePayment)
|
|
542
|
+
builder.step('step3', fulfillOrder)
|
|
543
|
+
|
|
544
|
+
const workflow = builder.build()
|
|
545
|
+
|
|
546
|
+
expect(workflow.steps).toHaveLength(3)
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('built workflow contains event triggers', () => {
|
|
550
|
+
builder.step('validate', validateOrder)
|
|
551
|
+
builder.on('Order.placed').do('validate')
|
|
552
|
+
|
|
553
|
+
const workflow = builder.build()
|
|
554
|
+
|
|
555
|
+
expect(workflow.triggers).toBeDefined()
|
|
556
|
+
expect(workflow.triggers.events).toHaveLength(1)
|
|
557
|
+
expect(workflow.triggers.events[0]?.event).toBe('Order.placed')
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('built workflow contains schedule triggers', () => {
|
|
561
|
+
builder.step('cleanup', cleanupData)
|
|
562
|
+
builder.every('hour').do('cleanup')
|
|
563
|
+
|
|
564
|
+
const workflow = builder.build()
|
|
565
|
+
|
|
566
|
+
expect(workflow.triggers).toBeDefined()
|
|
567
|
+
expect(workflow.triggers.schedules).toHaveLength(1)
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('built workflow includes dependency graph', () => {
|
|
571
|
+
builder.step('validate', validateOrder)
|
|
572
|
+
builder.step('charge', chargePayment).dependsOn('validate')
|
|
573
|
+
builder.step('fulfill', fulfillOrder).dependsOn('charge')
|
|
574
|
+
|
|
575
|
+
const workflow = builder.build()
|
|
576
|
+
|
|
577
|
+
expect(workflow.dependencyGraph).toBeDefined()
|
|
578
|
+
expect(workflow.dependencyGraph.get('charge')).toContain('validate')
|
|
579
|
+
expect(workflow.dependencyGraph.get('fulfill')).toContain('charge')
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
it('built workflow provides execution order', () => {
|
|
583
|
+
builder.step('validate', validateOrder)
|
|
584
|
+
builder.step('charge', chargePayment).dependsOn('validate')
|
|
585
|
+
builder.step('fulfill', fulfillOrder).dependsOn('charge')
|
|
586
|
+
|
|
587
|
+
const workflow = builder.build()
|
|
588
|
+
|
|
589
|
+
// Should provide topologically sorted execution order
|
|
590
|
+
expect(workflow.executionOrder).toEqual(['validate', 'charge', 'fulfill'])
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('built workflow is executable', () => {
|
|
594
|
+
builder.step('validate', validateOrder)
|
|
595
|
+
const workflow = builder.build()
|
|
596
|
+
|
|
597
|
+
// Should have an execute method
|
|
598
|
+
expect(typeof workflow.execute).toBe('function')
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
it('build validates the workflow definition', () => {
|
|
602
|
+
// Empty workflow with no steps should be valid (entry point can be event)
|
|
603
|
+
builder.on('Order.placed').do(async () => ({ done: true }))
|
|
604
|
+
|
|
605
|
+
expect(() => builder.build()).not.toThrow()
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
it('build throws on invalid workflow', () => {
|
|
609
|
+
// Referencing non-existent step in dependency
|
|
610
|
+
builder.step('validate', validateOrder).dependsOn('missing')
|
|
611
|
+
|
|
612
|
+
expect(() => builder.build()).toThrow()
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it('returns immutable workflow definition', () => {
|
|
616
|
+
builder.step('validate', validateOrder)
|
|
617
|
+
const workflow = builder.build()
|
|
618
|
+
|
|
619
|
+
// Modifying the builder after build should not affect built workflow
|
|
620
|
+
builder.step('extra', chargePayment)
|
|
621
|
+
const newWorkflow = builder.build()
|
|
622
|
+
|
|
623
|
+
expect(workflow.steps).toHaveLength(1)
|
|
624
|
+
expect(newWorkflow.steps).toHaveLength(2)
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
it('build includes workflow metadata', () => {
|
|
628
|
+
const builder = WorkflowBuilder.create('metadata-workflow', {
|
|
629
|
+
description: 'Test workflow',
|
|
630
|
+
version: '1.0.0',
|
|
631
|
+
})
|
|
632
|
+
builder.step('task', validateOrder)
|
|
633
|
+
|
|
634
|
+
const workflow = builder.build()
|
|
635
|
+
|
|
636
|
+
expect(workflow.metadata?.description).toBe('Test workflow')
|
|
637
|
+
expect(workflow.metadata?.version).toBe('1.0.0')
|
|
638
|
+
})
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
// ============================================================================
|
|
642
|
+
// 7. BuiltWorkflow.execute() - Executing the Workflow
|
|
643
|
+
// ============================================================================
|
|
644
|
+
|
|
645
|
+
describe('BuiltWorkflow.execute()', () => {
|
|
646
|
+
it('executes a simple workflow', async () => {
|
|
647
|
+
const workflow = WorkflowBuilder.create('simple-workflow')
|
|
648
|
+
.step('validate', validateOrder)
|
|
649
|
+
.build()
|
|
650
|
+
|
|
651
|
+
const result = await workflow.execute({ orderId: 'order-123' })
|
|
652
|
+
|
|
653
|
+
expect(result).toBeDefined()
|
|
654
|
+
expect(result.validate.valid).toBe(true)
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
it('executes steps in dependency order', async () => {
|
|
658
|
+
const executionLog: string[] = []
|
|
659
|
+
|
|
660
|
+
const workflow = WorkflowBuilder.create('ordered-workflow')
|
|
661
|
+
.step('first', async () => {
|
|
662
|
+
executionLog.push('first')
|
|
663
|
+
return { done: true }
|
|
664
|
+
})
|
|
665
|
+
.step('second', async () => {
|
|
666
|
+
executionLog.push('second')
|
|
667
|
+
return { done: true }
|
|
668
|
+
})
|
|
669
|
+
.dependsOn('first')
|
|
670
|
+
.step('third', async () => {
|
|
671
|
+
executionLog.push('third')
|
|
672
|
+
return { done: true }
|
|
673
|
+
})
|
|
674
|
+
.dependsOn('second')
|
|
675
|
+
.build()
|
|
676
|
+
|
|
677
|
+
await workflow.execute()
|
|
678
|
+
|
|
679
|
+
expect(executionLog).toEqual(['first', 'second', 'third'])
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
it('passes step output to dependent steps', async () => {
|
|
683
|
+
const workflow = WorkflowBuilder.create('data-flow')
|
|
684
|
+
.step('validate', async (input: { orderId: string }) => {
|
|
685
|
+
return { valid: true, orderId: input.orderId }
|
|
686
|
+
})
|
|
687
|
+
.step('charge', async (input: { orderId: string }, ctx) => {
|
|
688
|
+
// Should be able to access validate's output
|
|
689
|
+
const validateResult = ctx.getStepResult('validate')
|
|
690
|
+
return { charged: true, wasValid: validateResult.valid }
|
|
691
|
+
})
|
|
692
|
+
.dependsOn('validate')
|
|
693
|
+
.build()
|
|
694
|
+
|
|
695
|
+
const result = await workflow.execute({ orderId: 'order-123' })
|
|
696
|
+
|
|
697
|
+
expect(result.charge.wasValid).toBe(true)
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
it('executes parallel steps concurrently', async () => {
|
|
701
|
+
const startTimes: Record<string, number> = {}
|
|
702
|
+
|
|
703
|
+
const workflow = WorkflowBuilder.create('parallel-workflow')
|
|
704
|
+
.step('stepA', async () => {
|
|
705
|
+
startTimes.stepA = Date.now()
|
|
706
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
707
|
+
return { a: true }
|
|
708
|
+
})
|
|
709
|
+
.step('stepB', async () => {
|
|
710
|
+
startTimes.stepB = Date.now()
|
|
711
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
712
|
+
return { b: true }
|
|
713
|
+
})
|
|
714
|
+
.step('stepC', async () => {
|
|
715
|
+
startTimes.stepC = Date.now()
|
|
716
|
+
return { c: true }
|
|
717
|
+
})
|
|
718
|
+
.dependsOn('stepA', 'stepB')
|
|
719
|
+
.build()
|
|
720
|
+
|
|
721
|
+
await workflow.execute()
|
|
722
|
+
|
|
723
|
+
// stepA and stepB should start at approximately the same time
|
|
724
|
+
const timeDiff = Math.abs(startTimes.stepA - startTimes.stepB)
|
|
725
|
+
expect(timeDiff).toBeLessThan(50) // Within 50ms of each other
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it('returns results from all steps', async () => {
|
|
729
|
+
const workflow = WorkflowBuilder.create('result-workflow')
|
|
730
|
+
.step('validate', async () => ({ valid: true }))
|
|
731
|
+
.step('charge', async () => ({ charged: true }))
|
|
732
|
+
.step('fulfill', async () => ({ fulfilled: true }))
|
|
733
|
+
.build()
|
|
734
|
+
|
|
735
|
+
const result = await workflow.execute()
|
|
736
|
+
|
|
737
|
+
expect(result.validate.valid).toBe(true)
|
|
738
|
+
expect(result.charge.charged).toBe(true)
|
|
739
|
+
expect(result.fulfill.fulfilled).toBe(true)
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
it('throws on step failure', async () => {
|
|
743
|
+
const workflow = WorkflowBuilder.create('failing-workflow')
|
|
744
|
+
.step('fail', async () => {
|
|
745
|
+
throw new Error('Step failed')
|
|
746
|
+
})
|
|
747
|
+
.build()
|
|
748
|
+
|
|
749
|
+
await expect(workflow.execute()).rejects.toThrow('Step failed')
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
it('supports step error handlers', async () => {
|
|
753
|
+
const workflow = WorkflowBuilder.create('error-handling-workflow')
|
|
754
|
+
.step('risky', async () => {
|
|
755
|
+
throw new Error('Oops')
|
|
756
|
+
})
|
|
757
|
+
.onError((error, ctx) => {
|
|
758
|
+
return { recovered: true, error: error.message }
|
|
759
|
+
})
|
|
760
|
+
.build()
|
|
761
|
+
|
|
762
|
+
const result = await workflow.execute()
|
|
763
|
+
|
|
764
|
+
expect(result.risky.recovered).toBe(true)
|
|
765
|
+
})
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
// ============================================================================
|
|
769
|
+
// 8. WorkflowService Integration
|
|
770
|
+
// ============================================================================
|
|
771
|
+
|
|
772
|
+
describe('WorkflowBuilder with WorkflowService', () => {
|
|
773
|
+
it('built workflow can be registered with WorkflowService', () => {
|
|
774
|
+
const workflow = WorkflowBuilder.create('registered-workflow')
|
|
775
|
+
.step('validate', validateOrder)
|
|
776
|
+
.on('Order.placed')
|
|
777
|
+
.do('validate')
|
|
778
|
+
.build()
|
|
779
|
+
|
|
780
|
+
const service = new WorkflowServiceCore()
|
|
781
|
+
|
|
782
|
+
// Should be able to register the built workflow
|
|
783
|
+
const result = service.registerWorkflow(workflow)
|
|
784
|
+
|
|
785
|
+
expect(result).toBeDefined()
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
it('registered workflow receives events', async () => {
|
|
789
|
+
let receivedEvent: unknown = null
|
|
790
|
+
|
|
791
|
+
const workflow = WorkflowBuilder.create('event-receiver')
|
|
792
|
+
.step('handle', async (event: { orderId: string }) => {
|
|
793
|
+
receivedEvent = event
|
|
794
|
+
return { handled: true }
|
|
795
|
+
})
|
|
796
|
+
.on('Order.placed')
|
|
797
|
+
.do('handle')
|
|
798
|
+
.build()
|
|
799
|
+
|
|
800
|
+
const service = new WorkflowServiceCore()
|
|
801
|
+
const registration = service.registerWorkflow(workflow)
|
|
802
|
+
|
|
803
|
+
// Emit an event
|
|
804
|
+
await service.emit(registration.id, 'Order.placed', { orderId: 'order-123' })
|
|
805
|
+
|
|
806
|
+
// Wait for processing
|
|
807
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
808
|
+
|
|
809
|
+
expect(receivedEvent).toEqual({ orderId: 'order-123' })
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it('registered workflow executes on schedule', async () => {
|
|
813
|
+
let executionCount = 0
|
|
814
|
+
|
|
815
|
+
const workflow = WorkflowBuilder.create('scheduled-runner')
|
|
816
|
+
.step('run', async () => {
|
|
817
|
+
executionCount++
|
|
818
|
+
return { count: executionCount }
|
|
819
|
+
})
|
|
820
|
+
.every('100ms')
|
|
821
|
+
.do('run') // Very short interval for testing
|
|
822
|
+
.build()
|
|
823
|
+
|
|
824
|
+
const service = new WorkflowServiceCore()
|
|
825
|
+
const registration = service.registerWorkflow(workflow)
|
|
826
|
+
|
|
827
|
+
await service.start(registration.id)
|
|
828
|
+
|
|
829
|
+
// Wait for a few executions
|
|
830
|
+
await new Promise((r) => setTimeout(r, 350))
|
|
831
|
+
|
|
832
|
+
await service.stop(registration.id)
|
|
833
|
+
|
|
834
|
+
expect(executionCount).toBeGreaterThanOrEqual(2)
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
it('multiple workflows can be registered', () => {
|
|
838
|
+
const workflow1 = WorkflowBuilder.create('workflow-1').step('step1', validateOrder).build()
|
|
839
|
+
|
|
840
|
+
const workflow2 = WorkflowBuilder.create('workflow-2').step('step2', chargePayment).build()
|
|
841
|
+
|
|
842
|
+
const service = new WorkflowServiceCore()
|
|
843
|
+
|
|
844
|
+
const reg1 = service.registerWorkflow(workflow1)
|
|
845
|
+
const reg2 = service.registerWorkflow(workflow2)
|
|
846
|
+
|
|
847
|
+
expect(reg1.id).not.toBe(reg2.id)
|
|
848
|
+
expect(service.list()).toContain(reg1.id)
|
|
849
|
+
expect(service.list()).toContain(reg2.id)
|
|
850
|
+
})
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
// ============================================================================
|
|
854
|
+
// 9. Complete DSL Example (from Issue)
|
|
855
|
+
// ============================================================================
|
|
856
|
+
|
|
857
|
+
describe('WorkflowBuilder DSL Complete Example', () => {
|
|
858
|
+
it('builds the order-process workflow from the issue example', () => {
|
|
859
|
+
const workflow = WorkflowBuilder.create('order-process')
|
|
860
|
+
.step('validate', validateOrder)
|
|
861
|
+
.step('charge', chargePayment)
|
|
862
|
+
.dependsOn('validate')
|
|
863
|
+
.step('fulfill', fulfillOrder)
|
|
864
|
+
.dependsOn('charge')
|
|
865
|
+
.on('Order.placed')
|
|
866
|
+
.do('validate')
|
|
867
|
+
.build()
|
|
868
|
+
|
|
869
|
+
expect(workflow).toBeDefined()
|
|
870
|
+
expect(workflow.name).toBe('order-process')
|
|
871
|
+
expect(workflow.steps).toHaveLength(3)
|
|
872
|
+
expect(workflow.triggers.events).toHaveLength(1)
|
|
873
|
+
expect(workflow.executionOrder).toEqual(['validate', 'charge', 'fulfill'])
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
it('executes the order-process workflow end-to-end', async () => {
|
|
877
|
+
const workflow = WorkflowBuilder.create('order-process')
|
|
878
|
+
.step('validate', async (input: { orderId: string }) => {
|
|
879
|
+
return { valid: true, orderId: input.orderId }
|
|
880
|
+
})
|
|
881
|
+
.step('charge', async (input: { orderId: string; amount: number }, ctx) => {
|
|
882
|
+
const validation = ctx.getStepResult('validate')
|
|
883
|
+
if (!validation.valid) throw new Error('Invalid order')
|
|
884
|
+
return { charged: true, transactionId: `txn_${input.orderId}` }
|
|
885
|
+
})
|
|
886
|
+
.dependsOn('validate')
|
|
887
|
+
.step('fulfill', async (input, ctx) => {
|
|
888
|
+
const charge = ctx.getStepResult('charge')
|
|
889
|
+
return { fulfilled: true, transactionId: charge.transactionId }
|
|
890
|
+
})
|
|
891
|
+
.dependsOn('charge')
|
|
892
|
+
.build()
|
|
893
|
+
|
|
894
|
+
const result = await workflow.execute({ orderId: 'order-456', amount: 99.99 })
|
|
895
|
+
|
|
896
|
+
expect(result.validate.valid).toBe(true)
|
|
897
|
+
expect(result.charge.charged).toBe(true)
|
|
898
|
+
expect(result.fulfill.fulfilled).toBe(true)
|
|
899
|
+
expect(result.fulfill.transactionId).toBe('txn_order-456')
|
|
900
|
+
})
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
// ============================================================================
|
|
904
|
+
// 10. Edge Cases and Error Handling
|
|
905
|
+
// ============================================================================
|
|
906
|
+
|
|
907
|
+
describe('WorkflowBuilder Edge Cases', () => {
|
|
908
|
+
it('handles empty workflow (no steps, only event triggers)', () => {
|
|
909
|
+
const workflow = WorkflowBuilder.create('event-only')
|
|
910
|
+
.on('Order.placed')
|
|
911
|
+
.do(async () => ({ done: true }))
|
|
912
|
+
.build()
|
|
913
|
+
|
|
914
|
+
expect(workflow).toBeDefined()
|
|
915
|
+
expect(workflow.steps.length).toBeGreaterThanOrEqual(1) // Implicit step from inline fn
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
it('handles workflow with only scheduled triggers', () => {
|
|
919
|
+
const workflow = WorkflowBuilder.create('schedule-only')
|
|
920
|
+
.every('hour')
|
|
921
|
+
.do(async () => ({ done: true }))
|
|
922
|
+
.build()
|
|
923
|
+
|
|
924
|
+
expect(workflow).toBeDefined()
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
it('handles complex dependency graph', () => {
|
|
928
|
+
// A
|
|
929
|
+
// / \
|
|
930
|
+
// B C
|
|
931
|
+
// \ /
|
|
932
|
+
// D
|
|
933
|
+
const workflow = WorkflowBuilder.create('diamond')
|
|
934
|
+
.step('A', async () => ({ a: true }))
|
|
935
|
+
.step('B', async () => ({ b: true }))
|
|
936
|
+
.dependsOn('A')
|
|
937
|
+
.step('C', async () => ({ c: true }))
|
|
938
|
+
.dependsOn('A')
|
|
939
|
+
.step('D', async () => ({ d: true }))
|
|
940
|
+
.dependsOn('B', 'C')
|
|
941
|
+
.build()
|
|
942
|
+
|
|
943
|
+
expect(workflow).toBeDefined()
|
|
944
|
+
expect(workflow.dependencyGraph.get('D')).toContain('B')
|
|
945
|
+
expect(workflow.dependencyGraph.get('D')).toContain('C')
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
it('handles self-referential step (should throw)', () => {
|
|
949
|
+
const builder = WorkflowBuilder.create('self-ref')
|
|
950
|
+
.step('loop', async () => ({ done: true }))
|
|
951
|
+
.dependsOn('loop')
|
|
952
|
+
|
|
953
|
+
expect(() => builder.build()).toThrow(/circular|self-referential/i)
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
it('handles very long step chains', () => {
|
|
957
|
+
const builder = WorkflowBuilder.create('long-chain')
|
|
958
|
+
|
|
959
|
+
// Create a chain of 100 steps
|
|
960
|
+
for (let i = 0; i < 100; i++) {
|
|
961
|
+
builder.step(`step${i}`, async () => ({ step: i }))
|
|
962
|
+
if (i > 0) {
|
|
963
|
+
builder.dependsOn(`step${i - 1}`)
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const workflow = builder.build()
|
|
968
|
+
|
|
969
|
+
expect(workflow.steps).toHaveLength(100)
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
it('builder is reusable (can build multiple times)', () => {
|
|
973
|
+
const builder = WorkflowBuilder.create('reusable').step('step1', validateOrder)
|
|
974
|
+
|
|
975
|
+
const workflow1 = builder.build()
|
|
976
|
+
|
|
977
|
+
builder.step('step2', chargePayment)
|
|
978
|
+
|
|
979
|
+
const workflow2 = builder.build()
|
|
980
|
+
|
|
981
|
+
expect(workflow1.steps).toHaveLength(1)
|
|
982
|
+
expect(workflow2.steps).toHaveLength(2)
|
|
983
|
+
})
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
// ============================================================================
|
|
987
|
+
// 11. Type Safety Tests
|
|
988
|
+
// ============================================================================
|
|
989
|
+
|
|
990
|
+
describe('WorkflowBuilder Type Safety', () => {
|
|
991
|
+
it('preserves input/output types through step chain', () => {
|
|
992
|
+
interface OrderInput {
|
|
993
|
+
orderId: string
|
|
994
|
+
amount: number
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
interface ValidationResult {
|
|
998
|
+
valid: boolean
|
|
999
|
+
orderId: string
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const workflow = WorkflowBuilder.create('typed-workflow')
|
|
1003
|
+
.step<OrderInput, ValidationResult>('validate', async (input) => {
|
|
1004
|
+
// TypeScript should know input has orderId and amount
|
|
1005
|
+
return { valid: true, orderId: input.orderId }
|
|
1006
|
+
})
|
|
1007
|
+
.build()
|
|
1008
|
+
|
|
1009
|
+
expect(workflow).toBeDefined()
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
it('step context provides typed access to previous results', async () => {
|
|
1013
|
+
interface Step1Result {
|
|
1014
|
+
value: number
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
interface Step2Result {
|
|
1018
|
+
doubled: number
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const workflow = WorkflowBuilder.create('typed-context')
|
|
1022
|
+
.step<void, Step1Result>('step1', async () => ({ value: 21 }))
|
|
1023
|
+
.step<void, Step2Result>('step2', async (_, ctx) => {
|
|
1024
|
+
const step1Result = ctx.getStepResult<Step1Result>('step1')
|
|
1025
|
+
return { doubled: step1Result.value * 2 }
|
|
1026
|
+
})
|
|
1027
|
+
.dependsOn('step1')
|
|
1028
|
+
.build()
|
|
1029
|
+
|
|
1030
|
+
const result = await workflow.execute()
|
|
1031
|
+
|
|
1032
|
+
expect(result.step2.doubled).toBe(42)
|
|
1033
|
+
})
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
// ============================================================================
|
|
1037
|
+
// 12. Real Cloudflare Workflows Integration
|
|
1038
|
+
// ============================================================================
|
|
1039
|
+
|
|
1040
|
+
describe('WorkflowBuilder with Real Cloudflare Workflows', () => {
|
|
1041
|
+
it('built workflow integrates with Cloudflare Workflows runtime', async () => {
|
|
1042
|
+
// This test requires the TestWorkflow to be configured in wrangler.jsonc
|
|
1043
|
+
// and the workflow to use real step.do() calls
|
|
1044
|
+
|
|
1045
|
+
const workflow = WorkflowBuilder.create('cf-integrated')
|
|
1046
|
+
.step('durable-step', async (input: { value: number }) => {
|
|
1047
|
+
// This should be wrapped in step.do() for durability
|
|
1048
|
+
return { result: input.value * 2 }
|
|
1049
|
+
})
|
|
1050
|
+
.build()
|
|
1051
|
+
|
|
1052
|
+
// The built workflow should be compatible with Cloudflare Workflows
|
|
1053
|
+
expect(workflow.isCloudflareCompatible).toBe(true)
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
it('steps are wrapped with DurableStep for durability', () => {
|
|
1057
|
+
const workflow = WorkflowBuilder.create('durable-wrapped')
|
|
1058
|
+
.step('my-step', validateOrder, {
|
|
1059
|
+
retries: { limit: 3 },
|
|
1060
|
+
})
|
|
1061
|
+
.build()
|
|
1062
|
+
|
|
1063
|
+
// Each step in the built workflow should be a DurableStep
|
|
1064
|
+
const step = workflow.steps.find((s) => s.name === 'my-step')
|
|
1065
|
+
expect(step?.durableStep).toBeInstanceOf(DurableStep)
|
|
1066
|
+
})
|
|
1067
|
+
})
|