ai-workflows 2.1.1 → 2.3.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 +17 -1
- package/README.md +305 -184
- package/dist/barrier.d.ts +159 -0
- package/dist/barrier.d.ts.map +1 -0
- package/dist/barrier.js +377 -0
- package/dist/barrier.js.map +1 -0
- package/dist/cascade-context.d.ts +149 -0
- package/dist/cascade-context.d.ts.map +1 -0
- package/dist/cascade-context.js +324 -0
- package/dist/cascade-context.js.map +1 -0
- package/dist/cascade-executor.d.ts +196 -0
- package/dist/cascade-executor.d.ts.map +1 -0
- package/dist/cascade-executor.js +384 -0
- package/dist/cascade-executor.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +27 -8
- 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/dependency-graph.d.ts +157 -0
- package/dist/dependency-graph.d.ts.map +1 -0
- package/dist/dependency-graph.js +382 -0
- package/dist/dependency-graph.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/every.d.ts +31 -2
- package/dist/every.d.ts.map +1 -1
- package/dist/every.js +63 -32
- package/dist/every.js.map +1 -1
- package/dist/graph/index.d.ts +8 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +8 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/topological-sort.d.ts +121 -0
- package/dist/graph/topological-sort.d.ts.map +1 -0
- package/dist/graph/topological-sort.js +292 -0
- package/dist/graph/topological-sort.js.map +1 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -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 +35 -10
- package/dist/on.d.ts.map +1 -1
- package/dist/on.js +53 -19
- 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 +77 -0
- package/dist/timer-registry.d.ts.map +1 -0
- package/dist/timer-registry.js +154 -0
- package/dist/timer-registry.js.map +1 -0
- package/dist/types.d.ts +105 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -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 +136 -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 +21 -4
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +507 -0
- package/src/cascade-context.ts +495 -0
- package/src/cascade-executor.ts +588 -0
- package/src/context.ts +51 -17
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -0
- package/src/dependency-graph.ts +518 -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/every.ts +104 -35
- package/src/graph/index.ts +19 -0
- package/src/graph/topological-sort.ts +412 -0
- package/src/index.ts +147 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +81 -26
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +179 -0
- package/src/types.ts +146 -10
- 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 +199 -355
- package/test/barrier-join.test.ts +442 -0
- package/test/barrier-unhandled-rejections.test.ts +359 -0
- package/test/cascade-context.test.ts +390 -0
- package/test/cascade-executor.test.ts +852 -0
- 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/dependency-graph.test.ts +512 -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/graph/topological-sort.test.ts +586 -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 +353 -0
- package/test/send-race-conditions.test.ts +400 -0
- package/test/type-safety-every.test.ts +303 -0
- 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 -7
- 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,1670 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkflowBuilder DSL Tests (RED Phase)
|
|
3
|
+
*
|
|
4
|
+
* Tests for WorkflowBuilder - a fluent DSL for building durable workflows
|
|
5
|
+
* with support for sequential steps, parallel execution, conditional branching,
|
|
6
|
+
* loops, error handlers, timeouts, and retries.
|
|
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 regular vitest (not vitest-pool-workers).
|
|
12
|
+
*
|
|
13
|
+
* Bead: aip-llm1
|
|
14
|
+
*
|
|
15
|
+
* @packageDocumentation
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// These imports will FAIL because WorkflowBuilder does not exist yet.
|
|
22
|
+
// This is the RED phase of TDD.
|
|
23
|
+
// ============================================================================
|
|
24
|
+
import {
|
|
25
|
+
workflow,
|
|
26
|
+
WorkflowBuilder,
|
|
27
|
+
type WorkflowDefinition,
|
|
28
|
+
type StepDefinition,
|
|
29
|
+
type StepChain,
|
|
30
|
+
type ConditionalChain,
|
|
31
|
+
type LoopChain,
|
|
32
|
+
type BuiltWorkflow,
|
|
33
|
+
type StepContext,
|
|
34
|
+
type RetryConfig,
|
|
35
|
+
} from '../src/workflow-builder.js'
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Test Data - Sample Step Functions
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
const validateOrder = async (input: { orderId: string }) => {
|
|
42
|
+
return { valid: true, orderId: input.orderId }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const chargePayment = async (input: { orderId: string; amount: number }) => {
|
|
46
|
+
return { charged: true, transactionId: `txn_${input.orderId}` }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const fulfillOrder = async (input: { orderId: string; transactionId: string }) => {
|
|
50
|
+
return { fulfilled: true, trackingNumber: `track_${input.orderId}` }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sendNotification = async (input: { to: string; message: string }) => {
|
|
54
|
+
return { sent: true }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const processItem = async (input: { item: string; index: number }) => {
|
|
58
|
+
return { processed: true, item: input.item }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// 1. Fluent DSL: workflow('name').step().step().build()
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
describe('WorkflowBuilder Fluent DSL', () => {
|
|
66
|
+
describe('workflow() factory function', () => {
|
|
67
|
+
it('creates a workflow builder with a name', () => {
|
|
68
|
+
const builder = workflow('order-process')
|
|
69
|
+
|
|
70
|
+
expect(builder).toBeDefined()
|
|
71
|
+
expect(builder).toBeInstanceOf(WorkflowBuilder)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('supports the fluent pattern: workflow().step().step().build()', () => {
|
|
75
|
+
const built = workflow('order-process')
|
|
76
|
+
.step('step1', validateOrder)
|
|
77
|
+
.step('step2', chargePayment)
|
|
78
|
+
.build()
|
|
79
|
+
|
|
80
|
+
expect(built).toBeDefined()
|
|
81
|
+
expect(built.name).toBe('order-process')
|
|
82
|
+
expect(built.steps).toHaveLength(2)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('validates workflow name is provided', () => {
|
|
86
|
+
expect(() => workflow('')).toThrow(/workflow name/i)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('workflow name is accessible on the builder', () => {
|
|
90
|
+
const builder = workflow('my-workflow')
|
|
91
|
+
|
|
92
|
+
expect(builder.name).toBe('my-workflow')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('step() method', () => {
|
|
97
|
+
it('adds a step with name and function', () => {
|
|
98
|
+
const built = workflow('test').step('validate', validateOrder).build()
|
|
99
|
+
|
|
100
|
+
expect(built.steps).toHaveLength(1)
|
|
101
|
+
expect(built.steps[0].name).toBe('validate')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('chains multiple steps', () => {
|
|
105
|
+
const built = workflow('test')
|
|
106
|
+
.step('step1', async () => ({ a: 1 }))
|
|
107
|
+
.step('step2', async () => ({ b: 2 }))
|
|
108
|
+
.step('step3', async () => ({ c: 3 }))
|
|
109
|
+
.build()
|
|
110
|
+
|
|
111
|
+
expect(built.steps).toHaveLength(3)
|
|
112
|
+
expect(built.steps.map((s) => s.name)).toEqual(['step1', 'step2', 'step3'])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('preserves step order', () => {
|
|
116
|
+
const built = workflow('ordered')
|
|
117
|
+
.step('first', async () => 1)
|
|
118
|
+
.step('second', async () => 2)
|
|
119
|
+
.step('third', async () => 3)
|
|
120
|
+
.build()
|
|
121
|
+
|
|
122
|
+
expect(built.steps[0].name).toBe('first')
|
|
123
|
+
expect(built.steps[1].name).toBe('second')
|
|
124
|
+
expect(built.steps[2].name).toBe('third')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('rejects duplicate step names', () => {
|
|
128
|
+
expect(() =>
|
|
129
|
+
workflow('test')
|
|
130
|
+
.step('duplicate', async () => 1)
|
|
131
|
+
.step('duplicate', async () => 2)
|
|
132
|
+
.build()
|
|
133
|
+
).toThrow(/duplicate.*step.*name/i)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('build() method', () => {
|
|
138
|
+
it('returns a BuiltWorkflow object', () => {
|
|
139
|
+
const built = workflow('test').step('step1', validateOrder).build()
|
|
140
|
+
|
|
141
|
+
expect(built).toHaveProperty('name')
|
|
142
|
+
expect(built).toHaveProperty('steps')
|
|
143
|
+
expect(built).toHaveProperty('execute')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('built workflow has execute method', () => {
|
|
147
|
+
const built = workflow('test').step('step1', validateOrder).build()
|
|
148
|
+
|
|
149
|
+
expect(typeof built.execute).toBe('function')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('returns immutable workflow definition', () => {
|
|
153
|
+
const builder = workflow('test').step('step1', validateOrder)
|
|
154
|
+
|
|
155
|
+
const built1 = builder.build()
|
|
156
|
+
|
|
157
|
+
builder.step('step2', chargePayment)
|
|
158
|
+
|
|
159
|
+
const built2 = builder.build()
|
|
160
|
+
|
|
161
|
+
expect(built1.steps).toHaveLength(1)
|
|
162
|
+
expect(built2.steps).toHaveLength(2)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// ============================================================================
|
|
168
|
+
// 2. Sequential and Parallel Steps
|
|
169
|
+
// ============================================================================
|
|
170
|
+
|
|
171
|
+
describe('Sequential and Parallel Steps', () => {
|
|
172
|
+
describe('sequential execution (default)', () => {
|
|
173
|
+
it('steps execute in order by default', async () => {
|
|
174
|
+
const executionOrder: string[] = []
|
|
175
|
+
|
|
176
|
+
const built = workflow('sequential')
|
|
177
|
+
.step('first', async () => {
|
|
178
|
+
executionOrder.push('first')
|
|
179
|
+
return { order: 1 }
|
|
180
|
+
})
|
|
181
|
+
.step('second', async () => {
|
|
182
|
+
executionOrder.push('second')
|
|
183
|
+
return { order: 2 }
|
|
184
|
+
})
|
|
185
|
+
.step('third', async () => {
|
|
186
|
+
executionOrder.push('third')
|
|
187
|
+
return { order: 3 }
|
|
188
|
+
})
|
|
189
|
+
.build()
|
|
190
|
+
|
|
191
|
+
await built.execute({})
|
|
192
|
+
|
|
193
|
+
expect(executionOrder).toEqual(['first', 'second', 'third'])
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('each step receives result from previous step', async () => {
|
|
197
|
+
const built = workflow('chained')
|
|
198
|
+
.step('add5', async (input: { value: number }) => ({
|
|
199
|
+
value: input.value + 5,
|
|
200
|
+
}))
|
|
201
|
+
.step('multiply2', async (input: { value: number }) => ({
|
|
202
|
+
value: input.value * 2,
|
|
203
|
+
}))
|
|
204
|
+
.step('subtract3', async (input: { value: number }) => ({
|
|
205
|
+
value: input.value - 3,
|
|
206
|
+
}))
|
|
207
|
+
.build()
|
|
208
|
+
|
|
209
|
+
const result = await built.execute({ value: 10 })
|
|
210
|
+
|
|
211
|
+
// (10 + 5) * 2 - 3 = 27
|
|
212
|
+
expect(result.value).toBe(27)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe('parallel execution', () => {
|
|
217
|
+
it('supports parallel step groups with .parallel()', () => {
|
|
218
|
+
const built = workflow('parallel-test')
|
|
219
|
+
.parallel([
|
|
220
|
+
{ name: 'taskA', fn: async () => ({ a: true }) },
|
|
221
|
+
{ name: 'taskB', fn: async () => ({ b: true }) },
|
|
222
|
+
{ name: 'taskC', fn: async () => ({ c: true }) },
|
|
223
|
+
])
|
|
224
|
+
.build()
|
|
225
|
+
|
|
226
|
+
expect(built).toBeDefined()
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('parallel steps execute concurrently', async () => {
|
|
230
|
+
const startTimes: Record<string, number> = {}
|
|
231
|
+
|
|
232
|
+
const built = workflow('concurrent')
|
|
233
|
+
.parallel([
|
|
234
|
+
{
|
|
235
|
+
name: 'slow1',
|
|
236
|
+
fn: async () => {
|
|
237
|
+
startTimes.slow1 = Date.now()
|
|
238
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
239
|
+
return { done: 1 }
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: 'slow2',
|
|
244
|
+
fn: async () => {
|
|
245
|
+
startTimes.slow2 = Date.now()
|
|
246
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
247
|
+
return { done: 2 }
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
])
|
|
251
|
+
.build()
|
|
252
|
+
|
|
253
|
+
await built.execute({})
|
|
254
|
+
|
|
255
|
+
// Both should start at approximately the same time
|
|
256
|
+
const timeDiff = Math.abs(startTimes.slow1 - startTimes.slow2)
|
|
257
|
+
expect(timeDiff).toBeLessThan(20)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('parallel results are merged', async () => {
|
|
261
|
+
const built = workflow('merge-results')
|
|
262
|
+
.parallel([
|
|
263
|
+
{ name: 'a', fn: async () => ({ resultA: 'A' }) },
|
|
264
|
+
{ name: 'b', fn: async () => ({ resultB: 'B' }) },
|
|
265
|
+
])
|
|
266
|
+
.build()
|
|
267
|
+
|
|
268
|
+
const result = await built.execute({})
|
|
269
|
+
|
|
270
|
+
expect(result.a).toEqual({ resultA: 'A' })
|
|
271
|
+
expect(result.b).toEqual({ resultB: 'B' })
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('sequential and parallel can be mixed', async () => {
|
|
275
|
+
const executionOrder: string[] = []
|
|
276
|
+
|
|
277
|
+
const built = workflow('mixed')
|
|
278
|
+
.step('first', async () => {
|
|
279
|
+
executionOrder.push('first')
|
|
280
|
+
return {}
|
|
281
|
+
})
|
|
282
|
+
.parallel([
|
|
283
|
+
{
|
|
284
|
+
name: 'parallelA',
|
|
285
|
+
fn: async () => {
|
|
286
|
+
executionOrder.push('parallelA')
|
|
287
|
+
return {}
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
name: 'parallelB',
|
|
292
|
+
fn: async () => {
|
|
293
|
+
executionOrder.push('parallelB')
|
|
294
|
+
return {}
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
])
|
|
298
|
+
.step('last', async () => {
|
|
299
|
+
executionOrder.push('last')
|
|
300
|
+
return {}
|
|
301
|
+
})
|
|
302
|
+
.build()
|
|
303
|
+
|
|
304
|
+
await built.execute({})
|
|
305
|
+
|
|
306
|
+
expect(executionOrder[0]).toBe('first')
|
|
307
|
+
expect(executionOrder[executionOrder.length - 1]).toBe('last')
|
|
308
|
+
expect(executionOrder).toContain('parallelA')
|
|
309
|
+
expect(executionOrder).toContain('parallelB')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('parallel failure handling with .parallel().onError()', async () => {
|
|
313
|
+
const built = workflow('parallel-error')
|
|
314
|
+
.parallel([
|
|
315
|
+
{ name: 'success', fn: async () => ({ ok: true }) },
|
|
316
|
+
{
|
|
317
|
+
name: 'failure',
|
|
318
|
+
fn: async () => {
|
|
319
|
+
throw new Error('Parallel step failed')
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
])
|
|
323
|
+
.onError(async (error) => ({ recovered: true, error: error.message }))
|
|
324
|
+
.build()
|
|
325
|
+
|
|
326
|
+
const result = await built.execute({})
|
|
327
|
+
|
|
328
|
+
expect(result.recovered).toBe(true)
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
// ============================================================================
|
|
334
|
+
// 3. Conditional Branching: .when(condition).then(steps).else(steps)
|
|
335
|
+
// ============================================================================
|
|
336
|
+
|
|
337
|
+
describe('Conditional Branching', () => {
|
|
338
|
+
describe('.when(condition).then(steps)', () => {
|
|
339
|
+
it('executes then branch when condition is true', async () => {
|
|
340
|
+
const built = workflow('conditional')
|
|
341
|
+
.step('check', async () => ({ amount: 150 }))
|
|
342
|
+
.when((ctx) => ctx.result.amount > 100)
|
|
343
|
+
.then(workflow('then-branch').step('highValue', async () => ({ tier: 'premium' })))
|
|
344
|
+
.build()
|
|
345
|
+
|
|
346
|
+
const result = await built.execute({})
|
|
347
|
+
|
|
348
|
+
expect(result.tier).toBe('premium')
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('skips then branch when condition is false', async () => {
|
|
352
|
+
const executed: string[] = []
|
|
353
|
+
|
|
354
|
+
const built = workflow('conditional')
|
|
355
|
+
.step('check', async () => {
|
|
356
|
+
executed.push('check')
|
|
357
|
+
return { amount: 50 }
|
|
358
|
+
})
|
|
359
|
+
.when((ctx) => ctx.result.amount > 100)
|
|
360
|
+
.then(
|
|
361
|
+
workflow('then-branch').step('highValue', async () => {
|
|
362
|
+
executed.push('highValue')
|
|
363
|
+
return { tier: 'premium' }
|
|
364
|
+
})
|
|
365
|
+
)
|
|
366
|
+
.step('continue', async () => {
|
|
367
|
+
executed.push('continue')
|
|
368
|
+
return { continued: true }
|
|
369
|
+
})
|
|
370
|
+
.build()
|
|
371
|
+
|
|
372
|
+
await built.execute({})
|
|
373
|
+
|
|
374
|
+
expect(executed).toContain('check')
|
|
375
|
+
expect(executed).toContain('continue')
|
|
376
|
+
expect(executed).not.toContain('highValue')
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
describe('.when(condition).then(steps).else(steps)', () => {
|
|
381
|
+
it('executes else branch when condition is false', async () => {
|
|
382
|
+
const built = workflow('if-else')
|
|
383
|
+
.step('check', async () => ({ amount: 50 }))
|
|
384
|
+
.when((ctx) => ctx.result.amount > 100)
|
|
385
|
+
.then(workflow('then-branch').step('premium', async () => ({ tier: 'premium' })))
|
|
386
|
+
.else(workflow('else-branch').step('standard', async () => ({ tier: 'standard' })))
|
|
387
|
+
.build()
|
|
388
|
+
|
|
389
|
+
const result = await built.execute({})
|
|
390
|
+
|
|
391
|
+
expect(result.tier).toBe('standard')
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('executes then branch and skips else when condition is true', async () => {
|
|
395
|
+
const executed: string[] = []
|
|
396
|
+
|
|
397
|
+
const built = workflow('if-else')
|
|
398
|
+
.step('check', async () => ({ amount: 150 }))
|
|
399
|
+
.when((ctx) => ctx.result.amount > 100)
|
|
400
|
+
.then(
|
|
401
|
+
workflow('then-branch').step('premium', async () => {
|
|
402
|
+
executed.push('premium')
|
|
403
|
+
return { tier: 'premium' }
|
|
404
|
+
})
|
|
405
|
+
)
|
|
406
|
+
.else(
|
|
407
|
+
workflow('else-branch').step('standard', async () => {
|
|
408
|
+
executed.push('standard')
|
|
409
|
+
return { tier: 'standard' }
|
|
410
|
+
})
|
|
411
|
+
)
|
|
412
|
+
.build()
|
|
413
|
+
|
|
414
|
+
await built.execute({})
|
|
415
|
+
|
|
416
|
+
expect(executed).toContain('premium')
|
|
417
|
+
expect(executed).not.toContain('standard')
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('supports nested conditionals', async () => {
|
|
421
|
+
const built = workflow('nested-conditional')
|
|
422
|
+
.step('getData', async () => ({ value: 75 }))
|
|
423
|
+
.when((ctx) => ctx.result.value > 50)
|
|
424
|
+
.then(
|
|
425
|
+
workflow('outer-then')
|
|
426
|
+
.when((ctx) => ctx.result.value > 90)
|
|
427
|
+
.then(workflow('inner-then').step('high', async () => ({ level: 'high' })))
|
|
428
|
+
.else(workflow('inner-else').step('medium', async () => ({ level: 'medium' })))
|
|
429
|
+
)
|
|
430
|
+
.else(workflow('outer-else').step('low', async () => ({ level: 'low' })))
|
|
431
|
+
.build()
|
|
432
|
+
|
|
433
|
+
const result = await built.execute({})
|
|
434
|
+
|
|
435
|
+
expect(result.level).toBe('medium')
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('condition receives step context with input and results', async () => {
|
|
439
|
+
let capturedContext: StepContext | null = null
|
|
440
|
+
|
|
441
|
+
const built = workflow('context-check')
|
|
442
|
+
.step('setup', async (input: { userId: string }) => ({
|
|
443
|
+
user: input.userId,
|
|
444
|
+
premium: true,
|
|
445
|
+
}))
|
|
446
|
+
.when((ctx) => {
|
|
447
|
+
capturedContext = ctx
|
|
448
|
+
return ctx.result.premium
|
|
449
|
+
})
|
|
450
|
+
.then(workflow('premium-flow').step('premium', async () => ({ applied: true })))
|
|
451
|
+
.build()
|
|
452
|
+
|
|
453
|
+
await built.execute({ userId: 'user-123' })
|
|
454
|
+
|
|
455
|
+
expect(capturedContext).not.toBeNull()
|
|
456
|
+
expect(capturedContext!.input).toEqual({ userId: 'user-123' })
|
|
457
|
+
expect(capturedContext!.result).toEqual({ user: 'user-123', premium: true })
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('supports async conditions', async () => {
|
|
461
|
+
const built = workflow('async-condition')
|
|
462
|
+
.step('getData', async () => ({ id: 'test-123' }))
|
|
463
|
+
.when(async (ctx) => {
|
|
464
|
+
// Simulate async check (e.g., database lookup)
|
|
465
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
466
|
+
return ctx.result.id.startsWith('test')
|
|
467
|
+
})
|
|
468
|
+
.then(workflow('test-branch').step('testMode', async () => ({ testMode: true })))
|
|
469
|
+
.build()
|
|
470
|
+
|
|
471
|
+
const result = await built.execute({})
|
|
472
|
+
|
|
473
|
+
expect(result.testMode).toBe(true)
|
|
474
|
+
})
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
describe('.when() with inline steps', () => {
|
|
478
|
+
it('supports inline step functions in then()', async () => {
|
|
479
|
+
const built = workflow('inline-then')
|
|
480
|
+
.step('check', async () => ({ proceed: true }))
|
|
481
|
+
.when((ctx) => ctx.result.proceed)
|
|
482
|
+
.then(async () => ({ inlined: true }))
|
|
483
|
+
.build()
|
|
484
|
+
|
|
485
|
+
const result = await built.execute({})
|
|
486
|
+
|
|
487
|
+
expect(result.inlined).toBe(true)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('supports inline step functions in else()', async () => {
|
|
491
|
+
const built = workflow('inline-else')
|
|
492
|
+
.step('check', async () => ({ proceed: false }))
|
|
493
|
+
.when((ctx) => ctx.result.proceed)
|
|
494
|
+
.then(async () => ({ branch: 'then' }))
|
|
495
|
+
.else(async () => ({ branch: 'else' }))
|
|
496
|
+
.build()
|
|
497
|
+
|
|
498
|
+
const result = await built.execute({})
|
|
499
|
+
|
|
500
|
+
expect(result.branch).toBe('else')
|
|
501
|
+
})
|
|
502
|
+
})
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
// ============================================================================
|
|
506
|
+
// 4. Loops: .loop(condition, steps)
|
|
507
|
+
// ============================================================================
|
|
508
|
+
|
|
509
|
+
describe('Loops', () => {
|
|
510
|
+
describe('.loop(condition, steps)', () => {
|
|
511
|
+
it('repeats steps while condition is true', async () => {
|
|
512
|
+
let counter = 0
|
|
513
|
+
|
|
514
|
+
const built = workflow('while-loop')
|
|
515
|
+
.step('init', async () => ({ count: 0 }))
|
|
516
|
+
.loop(
|
|
517
|
+
(ctx) => ctx.result.count < 3,
|
|
518
|
+
workflow('loop-body').step('increment', async (input: { count: number }) => {
|
|
519
|
+
counter++
|
|
520
|
+
return { count: input.count + 1 }
|
|
521
|
+
})
|
|
522
|
+
)
|
|
523
|
+
.build()
|
|
524
|
+
|
|
525
|
+
const result = await built.execute({})
|
|
526
|
+
|
|
527
|
+
expect(counter).toBe(3)
|
|
528
|
+
expect(result.count).toBe(3)
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('does not execute loop body if condition is initially false', async () => {
|
|
532
|
+
let executed = false
|
|
533
|
+
|
|
534
|
+
const built = workflow('skip-loop')
|
|
535
|
+
.step('init', async () => ({ count: 10 }))
|
|
536
|
+
.loop(
|
|
537
|
+
(ctx) => ctx.result.count < 3,
|
|
538
|
+
workflow('loop-body').step('never', async () => {
|
|
539
|
+
executed = true
|
|
540
|
+
return {}
|
|
541
|
+
})
|
|
542
|
+
)
|
|
543
|
+
.build()
|
|
544
|
+
|
|
545
|
+
await built.execute({})
|
|
546
|
+
|
|
547
|
+
expect(executed).toBe(false)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('loop has access to accumulated results', async () => {
|
|
551
|
+
const values: number[] = []
|
|
552
|
+
|
|
553
|
+
const built = workflow('accumulator')
|
|
554
|
+
.step('init', async () => ({ items: [1, 2, 3], index: 0 }))
|
|
555
|
+
.loop(
|
|
556
|
+
(ctx) => ctx.result.index < ctx.result.items.length,
|
|
557
|
+
workflow('process-item').step(
|
|
558
|
+
'process',
|
|
559
|
+
async (input: { items: number[]; index: number }) => {
|
|
560
|
+
values.push(input.items[input.index])
|
|
561
|
+
return { ...input, index: input.index + 1 }
|
|
562
|
+
}
|
|
563
|
+
)
|
|
564
|
+
)
|
|
565
|
+
.build()
|
|
566
|
+
|
|
567
|
+
await built.execute({})
|
|
568
|
+
|
|
569
|
+
expect(values).toEqual([1, 2, 3])
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('supports async loop conditions', async () => {
|
|
573
|
+
let iterations = 0
|
|
574
|
+
|
|
575
|
+
const built = workflow('async-loop')
|
|
576
|
+
.step('init', async () => ({ remaining: 2 }))
|
|
577
|
+
.loop(
|
|
578
|
+
async (ctx) => {
|
|
579
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
580
|
+
return ctx.result.remaining > 0
|
|
581
|
+
},
|
|
582
|
+
workflow('loop-body').step('decrement', async (input: { remaining: number }) => {
|
|
583
|
+
iterations++
|
|
584
|
+
return { remaining: input.remaining - 1 }
|
|
585
|
+
})
|
|
586
|
+
)
|
|
587
|
+
.build()
|
|
588
|
+
|
|
589
|
+
await built.execute({})
|
|
590
|
+
|
|
591
|
+
expect(iterations).toBe(2)
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
it('prevents infinite loops with maxIterations option', async () => {
|
|
595
|
+
let iterations = 0
|
|
596
|
+
|
|
597
|
+
const built = workflow('infinite-guard')
|
|
598
|
+
.step('init', async () => ({ value: true }))
|
|
599
|
+
.loop(
|
|
600
|
+
() => true, // Always true - would loop forever
|
|
601
|
+
workflow('body').step('tick', async () => {
|
|
602
|
+
iterations++
|
|
603
|
+
return { value: true }
|
|
604
|
+
}),
|
|
605
|
+
{ maxIterations: 5 }
|
|
606
|
+
)
|
|
607
|
+
.build()
|
|
608
|
+
|
|
609
|
+
await built.execute({})
|
|
610
|
+
|
|
611
|
+
expect(iterations).toBe(5)
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
it('throws error when maxIterations exceeded without breakOnMax option', async () => {
|
|
615
|
+
const built = workflow('overflow')
|
|
616
|
+
.step('init', async () => ({}))
|
|
617
|
+
.loop(
|
|
618
|
+
() => true,
|
|
619
|
+
workflow('body').step('tick', async () => ({})),
|
|
620
|
+
{ maxIterations: 3, throwOnMaxIterations: true }
|
|
621
|
+
)
|
|
622
|
+
.build()
|
|
623
|
+
|
|
624
|
+
await expect(built.execute({})).rejects.toThrow(/max.*iterations.*exceeded/i)
|
|
625
|
+
})
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
describe('.forEach(items, steps)', () => {
|
|
629
|
+
it('iterates over array items', async () => {
|
|
630
|
+
const processed: string[] = []
|
|
631
|
+
|
|
632
|
+
const built = workflow('for-each')
|
|
633
|
+
.step('init', async () => ({ items: ['a', 'b', 'c'] }))
|
|
634
|
+
.forEach(
|
|
635
|
+
(ctx) => ctx.result.items,
|
|
636
|
+
workflow('process-item').step(
|
|
637
|
+
'process',
|
|
638
|
+
async (input: { item: string; index: number }) => {
|
|
639
|
+
processed.push(input.item)
|
|
640
|
+
return { processed: input.item }
|
|
641
|
+
}
|
|
642
|
+
)
|
|
643
|
+
)
|
|
644
|
+
.build()
|
|
645
|
+
|
|
646
|
+
await built.execute({})
|
|
647
|
+
|
|
648
|
+
expect(processed).toEqual(['a', 'b', 'c'])
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
it('provides item and index to loop body', async () => {
|
|
652
|
+
const captured: Array<{ item: string; index: number }> = []
|
|
653
|
+
|
|
654
|
+
const built = workflow('indexed-foreach')
|
|
655
|
+
.step('init', async () => ({ list: ['x', 'y', 'z'] }))
|
|
656
|
+
.forEach(
|
|
657
|
+
(ctx) => ctx.result.list,
|
|
658
|
+
workflow('capture').step('capture', async (input: { item: string; index: number }) => {
|
|
659
|
+
captured.push({ item: input.item, index: input.index })
|
|
660
|
+
return {}
|
|
661
|
+
})
|
|
662
|
+
)
|
|
663
|
+
.build()
|
|
664
|
+
|
|
665
|
+
await built.execute({})
|
|
666
|
+
|
|
667
|
+
expect(captured).toEqual([
|
|
668
|
+
{ item: 'x', index: 0 },
|
|
669
|
+
{ item: 'y', index: 1 },
|
|
670
|
+
{ item: 'z', index: 2 },
|
|
671
|
+
])
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
it('collects results from each iteration', async () => {
|
|
675
|
+
const built = workflow('collect-results')
|
|
676
|
+
.step('init', async () => ({ numbers: [1, 2, 3] }))
|
|
677
|
+
.forEach(
|
|
678
|
+
(ctx) => ctx.result.numbers,
|
|
679
|
+
workflow('double').step('double', async (input: { item: number }) => ({
|
|
680
|
+
doubled: input.item * 2,
|
|
681
|
+
}))
|
|
682
|
+
)
|
|
683
|
+
.build()
|
|
684
|
+
|
|
685
|
+
const result = await built.execute({})
|
|
686
|
+
|
|
687
|
+
expect(result.forEachResults).toEqual([{ doubled: 2 }, { doubled: 4 }, { doubled: 6 }])
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('supports parallel iteration with concurrency option', async () => {
|
|
691
|
+
const startTimes: number[] = []
|
|
692
|
+
|
|
693
|
+
const built = workflow('parallel-foreach')
|
|
694
|
+
.step('init', async () => ({ items: [1, 2, 3, 4] }))
|
|
695
|
+
.forEach(
|
|
696
|
+
(ctx) => ctx.result.items,
|
|
697
|
+
workflow('slow-process').step('slow', async () => {
|
|
698
|
+
startTimes.push(Date.now())
|
|
699
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
700
|
+
return {}
|
|
701
|
+
}),
|
|
702
|
+
{ concurrency: 2 }
|
|
703
|
+
)
|
|
704
|
+
.build()
|
|
705
|
+
|
|
706
|
+
await built.execute({})
|
|
707
|
+
|
|
708
|
+
// With concurrency 2, items 1&2 should start together, then 3&4
|
|
709
|
+
// So first two should have similar start times, last two should have similar start times
|
|
710
|
+
const diff12 = Math.abs(startTimes[0] - startTimes[1])
|
|
711
|
+
const diff34 = Math.abs(startTimes[2] - startTimes[3])
|
|
712
|
+
|
|
713
|
+
expect(diff12).toBeLessThan(20)
|
|
714
|
+
expect(diff34).toBeLessThan(20)
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
it('handles empty arrays gracefully', async () => {
|
|
718
|
+
let bodyExecuted = false
|
|
719
|
+
|
|
720
|
+
const built = workflow('empty-array')
|
|
721
|
+
.step('init', async () => ({ items: [] as string[] }))
|
|
722
|
+
.forEach(
|
|
723
|
+
(ctx) => ctx.result.items,
|
|
724
|
+
workflow('never').step('never', async () => {
|
|
725
|
+
bodyExecuted = true
|
|
726
|
+
return {}
|
|
727
|
+
})
|
|
728
|
+
)
|
|
729
|
+
.step('after', async () => ({ completed: true }))
|
|
730
|
+
.build()
|
|
731
|
+
|
|
732
|
+
const result = await built.execute({})
|
|
733
|
+
|
|
734
|
+
expect(bodyExecuted).toBe(false)
|
|
735
|
+
expect(result.completed).toBe(true)
|
|
736
|
+
})
|
|
737
|
+
})
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
// ============================================================================
|
|
741
|
+
// 5. Error Handlers: .onError(handler)
|
|
742
|
+
// ============================================================================
|
|
743
|
+
|
|
744
|
+
describe('Error Handlers', () => {
|
|
745
|
+
describe('.onError(handler) on steps', () => {
|
|
746
|
+
it('catches errors from a single step', async () => {
|
|
747
|
+
const built = workflow('step-error')
|
|
748
|
+
.step('failing', async () => {
|
|
749
|
+
throw new Error('Step failed!')
|
|
750
|
+
})
|
|
751
|
+
.onError(async (error) => ({
|
|
752
|
+
recovered: true,
|
|
753
|
+
errorMessage: error.message,
|
|
754
|
+
}))
|
|
755
|
+
.build()
|
|
756
|
+
|
|
757
|
+
const result = await built.execute({})
|
|
758
|
+
|
|
759
|
+
expect(result.recovered).toBe(true)
|
|
760
|
+
expect(result.errorMessage).toBe('Step failed!')
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
it('error handler receives error and context', async () => {
|
|
764
|
+
let capturedError: Error | null = null
|
|
765
|
+
let capturedContext: StepContext | null = null
|
|
766
|
+
|
|
767
|
+
const built = workflow('error-context')
|
|
768
|
+
.step('setup', async () => ({ setupValue: 42 }))
|
|
769
|
+
.step('failing', async () => {
|
|
770
|
+
throw new Error('Oops!')
|
|
771
|
+
})
|
|
772
|
+
.onError(async (error, ctx) => {
|
|
773
|
+
capturedError = error
|
|
774
|
+
capturedContext = ctx
|
|
775
|
+
return { handled: true }
|
|
776
|
+
})
|
|
777
|
+
.build()
|
|
778
|
+
|
|
779
|
+
await built.execute({ inputValue: 'test' })
|
|
780
|
+
|
|
781
|
+
expect(capturedError).not.toBeNull()
|
|
782
|
+
expect(capturedError!.message).toBe('Oops!')
|
|
783
|
+
expect(capturedContext).not.toBeNull()
|
|
784
|
+
expect(capturedContext!.input).toEqual({ inputValue: 'test' })
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
it('error handler can retry the step', async () => {
|
|
788
|
+
let attempts = 0
|
|
789
|
+
|
|
790
|
+
const built = workflow('retry-in-handler')
|
|
791
|
+
.step('flaky', async () => {
|
|
792
|
+
attempts++
|
|
793
|
+
if (attempts < 3) {
|
|
794
|
+
throw new Error(`Attempt ${attempts} failed`)
|
|
795
|
+
}
|
|
796
|
+
return { success: true }
|
|
797
|
+
})
|
|
798
|
+
.onError(async (error, ctx) => {
|
|
799
|
+
if (attempts < 3) {
|
|
800
|
+
return ctx.retry()
|
|
801
|
+
}
|
|
802
|
+
return { gaveUp: true }
|
|
803
|
+
})
|
|
804
|
+
.build()
|
|
805
|
+
|
|
806
|
+
const result = await built.execute({})
|
|
807
|
+
|
|
808
|
+
expect(attempts).toBe(3)
|
|
809
|
+
expect(result.success).toBe(true)
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it('error handler can skip to next step', async () => {
|
|
813
|
+
const executed: string[] = []
|
|
814
|
+
|
|
815
|
+
const built = workflow('skip-on-error')
|
|
816
|
+
.step('first', async () => {
|
|
817
|
+
executed.push('first')
|
|
818
|
+
throw new Error('First failed')
|
|
819
|
+
})
|
|
820
|
+
.onError(async (_, ctx) => {
|
|
821
|
+
executed.push('error-handler')
|
|
822
|
+
return ctx.skip({ skipped: true })
|
|
823
|
+
})
|
|
824
|
+
.step('second', async () => {
|
|
825
|
+
executed.push('second')
|
|
826
|
+
return { continued: true }
|
|
827
|
+
})
|
|
828
|
+
.build()
|
|
829
|
+
|
|
830
|
+
const result = await built.execute({})
|
|
831
|
+
|
|
832
|
+
expect(executed).toEqual(['first', 'error-handler', 'second'])
|
|
833
|
+
expect(result.continued).toBe(true)
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
it('unhandled errors propagate', async () => {
|
|
837
|
+
const built = workflow('unhandled')
|
|
838
|
+
.step('failing', async () => {
|
|
839
|
+
throw new Error('Unhandled error')
|
|
840
|
+
})
|
|
841
|
+
.build()
|
|
842
|
+
|
|
843
|
+
await expect(built.execute({})).rejects.toThrow('Unhandled error')
|
|
844
|
+
})
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
describe('.onError(handler) on workflow', () => {
|
|
848
|
+
it('workflow-level error handler catches any step error', async () => {
|
|
849
|
+
const built = workflow('workflow-error')
|
|
850
|
+
.step('step1', async () => ({ done: true }))
|
|
851
|
+
.step('step2', async () => {
|
|
852
|
+
throw new Error('Step 2 failed')
|
|
853
|
+
})
|
|
854
|
+
.step('step3', async () => ({ never: true }))
|
|
855
|
+
.onError(async (error, ctx) => ({
|
|
856
|
+
workflowFailed: true,
|
|
857
|
+
failedAt: ctx.currentStep,
|
|
858
|
+
error: error.message,
|
|
859
|
+
}))
|
|
860
|
+
.build()
|
|
861
|
+
|
|
862
|
+
const result = await built.execute({})
|
|
863
|
+
|
|
864
|
+
expect(result.workflowFailed).toBe(true)
|
|
865
|
+
expect(result.failedAt).toBe('step2')
|
|
866
|
+
expect(result.error).toBe('Step 2 failed')
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
it('step-level error handler takes precedence over workflow-level', async () => {
|
|
870
|
+
let workflowHandlerCalled = false
|
|
871
|
+
|
|
872
|
+
const built = workflow('precedence')
|
|
873
|
+
.step('failing', async () => {
|
|
874
|
+
throw new Error('Handled at step level')
|
|
875
|
+
})
|
|
876
|
+
.onError(async () => ({ stepHandled: true }))
|
|
877
|
+
.step('another', async () => ({ done: true }))
|
|
878
|
+
.onError(async () => {
|
|
879
|
+
workflowHandlerCalled = true
|
|
880
|
+
return { workflowHandled: true }
|
|
881
|
+
})
|
|
882
|
+
.build()
|
|
883
|
+
|
|
884
|
+
const result = await built.execute({})
|
|
885
|
+
|
|
886
|
+
expect(result.stepHandled).toBe(true)
|
|
887
|
+
expect(workflowHandlerCalled).toBe(false)
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
it('multiple error handlers can be chained (fallback pattern)', async () => {
|
|
891
|
+
const handlersCalled: string[] = []
|
|
892
|
+
|
|
893
|
+
const built = workflow('fallback')
|
|
894
|
+
.step('failing', async () => {
|
|
895
|
+
throw new Error('Original error')
|
|
896
|
+
})
|
|
897
|
+
.onError(async (error) => {
|
|
898
|
+
handlersCalled.push('handler1')
|
|
899
|
+
throw error // Re-throw to next handler
|
|
900
|
+
})
|
|
901
|
+
.onError(async (error) => {
|
|
902
|
+
handlersCalled.push('handler2')
|
|
903
|
+
return { recovered: true }
|
|
904
|
+
})
|
|
905
|
+
.build()
|
|
906
|
+
|
|
907
|
+
const result = await built.execute({})
|
|
908
|
+
|
|
909
|
+
expect(handlersCalled).toEqual(['handler1', 'handler2'])
|
|
910
|
+
expect(result.recovered).toBe(true)
|
|
911
|
+
})
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
describe('error handler with typed errors', () => {
|
|
915
|
+
it('supports custom error types', async () => {
|
|
916
|
+
class ValidationError extends Error {
|
|
917
|
+
constructor(public field: string, message: string) {
|
|
918
|
+
super(message)
|
|
919
|
+
this.name = 'ValidationError'
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const built = workflow('typed-error')
|
|
924
|
+
.step('validate', async () => {
|
|
925
|
+
throw new ValidationError('email', 'Invalid email format')
|
|
926
|
+
})
|
|
927
|
+
.onError(async (error) => {
|
|
928
|
+
if (error instanceof ValidationError) {
|
|
929
|
+
return {
|
|
930
|
+
validationFailed: true,
|
|
931
|
+
field: error.field,
|
|
932
|
+
message: error.message,
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
throw error
|
|
936
|
+
})
|
|
937
|
+
.build()
|
|
938
|
+
|
|
939
|
+
const result = await built.execute({})
|
|
940
|
+
|
|
941
|
+
expect(result.validationFailed).toBe(true)
|
|
942
|
+
expect(result.field).toBe('email')
|
|
943
|
+
})
|
|
944
|
+
})
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
// ============================================================================
|
|
948
|
+
// 6. Timeout Configuration: .timeout(ms)
|
|
949
|
+
// ============================================================================
|
|
950
|
+
|
|
951
|
+
describe('Timeout Configuration', () => {
|
|
952
|
+
describe('.timeout(ms) on steps', () => {
|
|
953
|
+
it('times out a slow step', async () => {
|
|
954
|
+
const built = workflow('timeout-step')
|
|
955
|
+
.step('slow', async () => {
|
|
956
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
957
|
+
return { completed: true }
|
|
958
|
+
})
|
|
959
|
+
.timeout(50)
|
|
960
|
+
.onError(async (error) => ({
|
|
961
|
+
timedOut: true,
|
|
962
|
+
error: error.message,
|
|
963
|
+
}))
|
|
964
|
+
.build()
|
|
965
|
+
|
|
966
|
+
const result = await built.execute({})
|
|
967
|
+
|
|
968
|
+
expect(result.timedOut).toBe(true)
|
|
969
|
+
expect(result.error).toMatch(/timeout/i)
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
it('accepts timeout as string duration', async () => {
|
|
973
|
+
const built = workflow('string-timeout')
|
|
974
|
+
.step('slow', async () => {
|
|
975
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
976
|
+
return {}
|
|
977
|
+
})
|
|
978
|
+
.timeout('50ms')
|
|
979
|
+
.onError(async () => ({ timedOut: true }))
|
|
980
|
+
.build()
|
|
981
|
+
|
|
982
|
+
const result = await built.execute({})
|
|
983
|
+
|
|
984
|
+
expect(result.timedOut).toBe(true)
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
it('completes if step finishes before timeout', async () => {
|
|
988
|
+
const built = workflow('fast-enough')
|
|
989
|
+
.step('fast', async () => {
|
|
990
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
991
|
+
return { completed: true }
|
|
992
|
+
})
|
|
993
|
+
.timeout(1000)
|
|
994
|
+
.build()
|
|
995
|
+
|
|
996
|
+
const result = await built.execute({})
|
|
997
|
+
|
|
998
|
+
expect(result.completed).toBe(true)
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
it('timeout applies per step', async () => {
|
|
1002
|
+
const results: string[] = []
|
|
1003
|
+
|
|
1004
|
+
const built = workflow('per-step-timeout')
|
|
1005
|
+
.step('fast', async () => {
|
|
1006
|
+
results.push('fast')
|
|
1007
|
+
return {}
|
|
1008
|
+
})
|
|
1009
|
+
.timeout(1000)
|
|
1010
|
+
.step('slow', async () => {
|
|
1011
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
1012
|
+
results.push('slow')
|
|
1013
|
+
return {}
|
|
1014
|
+
})
|
|
1015
|
+
.timeout(50)
|
|
1016
|
+
.onError(async () => ({ slowTimedOut: true }))
|
|
1017
|
+
.build()
|
|
1018
|
+
|
|
1019
|
+
await built.execute({})
|
|
1020
|
+
|
|
1021
|
+
expect(results).toContain('fast')
|
|
1022
|
+
expect(results).not.toContain('slow')
|
|
1023
|
+
})
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
describe('.timeout(ms) on workflow', () => {
|
|
1027
|
+
it('applies timeout to entire workflow', async () => {
|
|
1028
|
+
const built = workflow('workflow-timeout')
|
|
1029
|
+
.step('step1', async () => {
|
|
1030
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
1031
|
+
return {}
|
|
1032
|
+
})
|
|
1033
|
+
.step('step2', async () => {
|
|
1034
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
1035
|
+
return {}
|
|
1036
|
+
})
|
|
1037
|
+
.step('step3', async () => {
|
|
1038
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
1039
|
+
return {}
|
|
1040
|
+
})
|
|
1041
|
+
.timeout(50) // Total workflow timeout
|
|
1042
|
+
.onError(async () => ({ workflowTimedOut: true }))
|
|
1043
|
+
.build()
|
|
1044
|
+
|
|
1045
|
+
const result = await built.execute({})
|
|
1046
|
+
|
|
1047
|
+
expect(result.workflowTimedOut).toBe(true)
|
|
1048
|
+
})
|
|
1049
|
+
})
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
// ============================================================================
|
|
1053
|
+
// 7. Retry Configuration: .retry({ attempts, backoff })
|
|
1054
|
+
// ============================================================================
|
|
1055
|
+
|
|
1056
|
+
describe('Retry Configuration', () => {
|
|
1057
|
+
describe('.retry({ attempts }) on steps', () => {
|
|
1058
|
+
it('retries failed step specified number of times', async () => {
|
|
1059
|
+
let attempts = 0
|
|
1060
|
+
|
|
1061
|
+
const built = workflow('retry-attempts')
|
|
1062
|
+
.step('flaky', async () => {
|
|
1063
|
+
attempts++
|
|
1064
|
+
if (attempts < 3) {
|
|
1065
|
+
throw new Error(`Attempt ${attempts} failed`)
|
|
1066
|
+
}
|
|
1067
|
+
return { success: true }
|
|
1068
|
+
})
|
|
1069
|
+
.retry({ attempts: 5 })
|
|
1070
|
+
.build()
|
|
1071
|
+
|
|
1072
|
+
const result = await built.execute({})
|
|
1073
|
+
|
|
1074
|
+
expect(attempts).toBe(3)
|
|
1075
|
+
expect(result.success).toBe(true)
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
it('fails after exhausting all retries', async () => {
|
|
1079
|
+
let attempts = 0
|
|
1080
|
+
|
|
1081
|
+
const built = workflow('exhaust-retries')
|
|
1082
|
+
.step('alwaysFails', async () => {
|
|
1083
|
+
attempts++
|
|
1084
|
+
throw new Error('Always fails')
|
|
1085
|
+
})
|
|
1086
|
+
.retry({ attempts: 3 })
|
|
1087
|
+
.build()
|
|
1088
|
+
|
|
1089
|
+
await expect(built.execute({})).rejects.toThrow('Always fails')
|
|
1090
|
+
expect(attempts).toBe(3)
|
|
1091
|
+
})
|
|
1092
|
+
|
|
1093
|
+
it('does not retry on success', async () => {
|
|
1094
|
+
let attempts = 0
|
|
1095
|
+
|
|
1096
|
+
const built = workflow('no-retry-needed')
|
|
1097
|
+
.step('succeeds', async () => {
|
|
1098
|
+
attempts++
|
|
1099
|
+
return { done: true }
|
|
1100
|
+
})
|
|
1101
|
+
.retry({ attempts: 5 })
|
|
1102
|
+
.build()
|
|
1103
|
+
|
|
1104
|
+
await built.execute({})
|
|
1105
|
+
|
|
1106
|
+
expect(attempts).toBe(1)
|
|
1107
|
+
})
|
|
1108
|
+
})
|
|
1109
|
+
|
|
1110
|
+
describe('.retry({ backoff }) strategies', () => {
|
|
1111
|
+
it('supports constant backoff', async () => {
|
|
1112
|
+
const attemptTimes: number[] = []
|
|
1113
|
+
|
|
1114
|
+
const built = workflow('constant-backoff')
|
|
1115
|
+
.step('failing', async () => {
|
|
1116
|
+
attemptTimes.push(Date.now())
|
|
1117
|
+
if (attemptTimes.length < 3) {
|
|
1118
|
+
throw new Error('Retry me')
|
|
1119
|
+
}
|
|
1120
|
+
return { done: true }
|
|
1121
|
+
})
|
|
1122
|
+
.retry({
|
|
1123
|
+
attempts: 5,
|
|
1124
|
+
backoff: 'constant',
|
|
1125
|
+
delay: 50,
|
|
1126
|
+
})
|
|
1127
|
+
.build()
|
|
1128
|
+
|
|
1129
|
+
await built.execute({})
|
|
1130
|
+
|
|
1131
|
+
// Check delays are approximately constant
|
|
1132
|
+
const delay1 = attemptTimes[1] - attemptTimes[0]
|
|
1133
|
+
const delay2 = attemptTimes[2] - attemptTimes[1]
|
|
1134
|
+
|
|
1135
|
+
expect(delay1).toBeGreaterThanOrEqual(45)
|
|
1136
|
+
expect(delay1).toBeLessThan(100)
|
|
1137
|
+
expect(delay2).toBeGreaterThanOrEqual(45)
|
|
1138
|
+
expect(delay2).toBeLessThan(100)
|
|
1139
|
+
})
|
|
1140
|
+
|
|
1141
|
+
it('supports linear backoff', async () => {
|
|
1142
|
+
const attemptTimes: number[] = []
|
|
1143
|
+
|
|
1144
|
+
const built = workflow('linear-backoff')
|
|
1145
|
+
.step('failing', async () => {
|
|
1146
|
+
attemptTimes.push(Date.now())
|
|
1147
|
+
if (attemptTimes.length < 4) {
|
|
1148
|
+
throw new Error('Retry me')
|
|
1149
|
+
}
|
|
1150
|
+
return { done: true }
|
|
1151
|
+
})
|
|
1152
|
+
.retry({
|
|
1153
|
+
attempts: 5,
|
|
1154
|
+
backoff: 'linear',
|
|
1155
|
+
delay: 20, // Base delay
|
|
1156
|
+
})
|
|
1157
|
+
.build()
|
|
1158
|
+
|
|
1159
|
+
await built.execute({})
|
|
1160
|
+
|
|
1161
|
+
// Linear: delay, delay*2, delay*3, ...
|
|
1162
|
+
const delay1 = attemptTimes[1] - attemptTimes[0]
|
|
1163
|
+
const delay2 = attemptTimes[2] - attemptTimes[1]
|
|
1164
|
+
const delay3 = attemptTimes[3] - attemptTimes[2]
|
|
1165
|
+
|
|
1166
|
+
expect(delay2).toBeGreaterThan(delay1)
|
|
1167
|
+
expect(delay3).toBeGreaterThan(delay2)
|
|
1168
|
+
})
|
|
1169
|
+
|
|
1170
|
+
it('supports exponential backoff', async () => {
|
|
1171
|
+
const attemptTimes: number[] = []
|
|
1172
|
+
|
|
1173
|
+
const built = workflow('exponential-backoff')
|
|
1174
|
+
.step('failing', async () => {
|
|
1175
|
+
attemptTimes.push(Date.now())
|
|
1176
|
+
if (attemptTimes.length < 4) {
|
|
1177
|
+
throw new Error('Retry me')
|
|
1178
|
+
}
|
|
1179
|
+
return { done: true }
|
|
1180
|
+
})
|
|
1181
|
+
.retry({
|
|
1182
|
+
attempts: 5,
|
|
1183
|
+
backoff: 'exponential',
|
|
1184
|
+
delay: 10, // Base delay
|
|
1185
|
+
})
|
|
1186
|
+
.build()
|
|
1187
|
+
|
|
1188
|
+
await built.execute({})
|
|
1189
|
+
|
|
1190
|
+
// Exponential: delay, delay*2, delay*4, delay*8, ...
|
|
1191
|
+
const delay1 = attemptTimes[1] - attemptTimes[0]
|
|
1192
|
+
const delay2 = attemptTimes[2] - attemptTimes[1]
|
|
1193
|
+
const delay3 = attemptTimes[3] - attemptTimes[2]
|
|
1194
|
+
|
|
1195
|
+
// Each delay should be roughly double the previous
|
|
1196
|
+
expect(delay2).toBeGreaterThan(delay1 * 1.5)
|
|
1197
|
+
expect(delay3).toBeGreaterThan(delay2 * 1.5)
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
it('supports jitter option for backoff', async () => {
|
|
1201
|
+
const attemptTimes1: number[] = []
|
|
1202
|
+
const attemptTimes2: number[] = []
|
|
1203
|
+
|
|
1204
|
+
const createWorkflow = () =>
|
|
1205
|
+
workflow('jitter-backoff')
|
|
1206
|
+
.step('failing', async () => {
|
|
1207
|
+
throw new Error('Always fails')
|
|
1208
|
+
})
|
|
1209
|
+
.retry({
|
|
1210
|
+
attempts: 3,
|
|
1211
|
+
backoff: 'exponential',
|
|
1212
|
+
delay: 50,
|
|
1213
|
+
jitter: true,
|
|
1214
|
+
})
|
|
1215
|
+
.onError(async () => ({ failed: true }))
|
|
1216
|
+
.build()
|
|
1217
|
+
|
|
1218
|
+
// Run twice and collect timing - with jitter, times should vary
|
|
1219
|
+
const built1 = createWorkflow()
|
|
1220
|
+
const built2 = createWorkflow()
|
|
1221
|
+
|
|
1222
|
+
// Execute and track (simplified - in practice would need to capture)
|
|
1223
|
+
await built1.execute({})
|
|
1224
|
+
await built2.execute({})
|
|
1225
|
+
|
|
1226
|
+
// Jitter should introduce randomness (hard to test deterministically)
|
|
1227
|
+
// Just verify the option is accepted
|
|
1228
|
+
expect(true).toBe(true)
|
|
1229
|
+
})
|
|
1230
|
+
|
|
1231
|
+
it('supports maxDelay cap', async () => {
|
|
1232
|
+
const attemptTimes: number[] = []
|
|
1233
|
+
|
|
1234
|
+
const built = workflow('capped-backoff')
|
|
1235
|
+
.step('failing', async () => {
|
|
1236
|
+
attemptTimes.push(Date.now())
|
|
1237
|
+
if (attemptTimes.length < 5) {
|
|
1238
|
+
throw new Error('Retry me')
|
|
1239
|
+
}
|
|
1240
|
+
return { done: true }
|
|
1241
|
+
})
|
|
1242
|
+
.retry({
|
|
1243
|
+
attempts: 6,
|
|
1244
|
+
backoff: 'exponential',
|
|
1245
|
+
delay: 20,
|
|
1246
|
+
maxDelay: 50, // Cap at 50ms
|
|
1247
|
+
})
|
|
1248
|
+
.build()
|
|
1249
|
+
|
|
1250
|
+
await built.execute({})
|
|
1251
|
+
|
|
1252
|
+
// Later delays should not exceed maxDelay
|
|
1253
|
+
for (let i = 2; i < attemptTimes.length; i++) {
|
|
1254
|
+
const delay = attemptTimes[i] - attemptTimes[i - 1]
|
|
1255
|
+
expect(delay).toBeLessThanOrEqual(100) // Allow some tolerance
|
|
1256
|
+
}
|
|
1257
|
+
})
|
|
1258
|
+
})
|
|
1259
|
+
|
|
1260
|
+
describe('.retry() with conditions', () => {
|
|
1261
|
+
it('only retries on specific error types', async () => {
|
|
1262
|
+
class RetryableError extends Error {}
|
|
1263
|
+
class FatalError extends Error {}
|
|
1264
|
+
|
|
1265
|
+
let attempts = 0
|
|
1266
|
+
|
|
1267
|
+
const built = workflow('conditional-retry')
|
|
1268
|
+
.step('conditional', async () => {
|
|
1269
|
+
attempts++
|
|
1270
|
+
if (attempts === 1) {
|
|
1271
|
+
throw new RetryableError('Retry this')
|
|
1272
|
+
}
|
|
1273
|
+
if (attempts === 2) {
|
|
1274
|
+
throw new FatalError('Do not retry')
|
|
1275
|
+
}
|
|
1276
|
+
return { success: true }
|
|
1277
|
+
})
|
|
1278
|
+
.retry({
|
|
1279
|
+
attempts: 5,
|
|
1280
|
+
retryIf: (error) => error instanceof RetryableError,
|
|
1281
|
+
})
|
|
1282
|
+
.build()
|
|
1283
|
+
|
|
1284
|
+
await expect(built.execute({})).rejects.toThrow('Do not retry')
|
|
1285
|
+
expect(attempts).toBe(2)
|
|
1286
|
+
})
|
|
1287
|
+
|
|
1288
|
+
it('provides attempt number to retry condition', async () => {
|
|
1289
|
+
const attemptNumbers: number[] = []
|
|
1290
|
+
|
|
1291
|
+
const built = workflow('attempt-number')
|
|
1292
|
+
.step('failing', async () => {
|
|
1293
|
+
throw new Error('Fail')
|
|
1294
|
+
})
|
|
1295
|
+
.retry({
|
|
1296
|
+
attempts: 5,
|
|
1297
|
+
retryIf: (error, attempt) => {
|
|
1298
|
+
attemptNumbers.push(attempt)
|
|
1299
|
+
return attempt < 3
|
|
1300
|
+
},
|
|
1301
|
+
})
|
|
1302
|
+
.build()
|
|
1303
|
+
|
|
1304
|
+
await expect(built.execute({})).rejects.toThrow('Fail')
|
|
1305
|
+
|
|
1306
|
+
expect(attemptNumbers).toEqual([1, 2, 3])
|
|
1307
|
+
})
|
|
1308
|
+
})
|
|
1309
|
+
|
|
1310
|
+
describe('.retry() on workflow', () => {
|
|
1311
|
+
it('applies default retry config to all steps', () => {
|
|
1312
|
+
const built = workflow('workflow-retry')
|
|
1313
|
+
.retry({ attempts: 3, backoff: 'exponential', delay: 100 })
|
|
1314
|
+
.step('step1', async () => ({ a: 1 }))
|
|
1315
|
+
.step('step2', async () => ({ b: 2 }))
|
|
1316
|
+
.build()
|
|
1317
|
+
|
|
1318
|
+
// All steps should have the default retry config
|
|
1319
|
+
expect(built.defaultRetryConfig).toEqual({
|
|
1320
|
+
attempts: 3,
|
|
1321
|
+
backoff: 'exponential',
|
|
1322
|
+
delay: 100,
|
|
1323
|
+
})
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
it('step-level retry overrides workflow-level', async () => {
|
|
1327
|
+
let step1Attempts = 0
|
|
1328
|
+
let step2Attempts = 0
|
|
1329
|
+
|
|
1330
|
+
const built = workflow('override-retry')
|
|
1331
|
+
.retry({ attempts: 2 })
|
|
1332
|
+
.step('step1', async () => {
|
|
1333
|
+
step1Attempts++
|
|
1334
|
+
throw new Error('Step 1 fails')
|
|
1335
|
+
})
|
|
1336
|
+
.retry({ attempts: 5 }) // Override for step1
|
|
1337
|
+
.onError(async () => ({ step1Failed: true }))
|
|
1338
|
+
.step('step2', async () => {
|
|
1339
|
+
step2Attempts++
|
|
1340
|
+
throw new Error('Step 2 fails')
|
|
1341
|
+
})
|
|
1342
|
+
// Uses workflow default (2 attempts)
|
|
1343
|
+
.onError(async () => ({ step2Failed: true }))
|
|
1344
|
+
.build()
|
|
1345
|
+
|
|
1346
|
+
await built.execute({})
|
|
1347
|
+
|
|
1348
|
+
expect(step1Attempts).toBe(5)
|
|
1349
|
+
expect(step2Attempts).toBe(2)
|
|
1350
|
+
})
|
|
1351
|
+
})
|
|
1352
|
+
})
|
|
1353
|
+
|
|
1354
|
+
// ============================================================================
|
|
1355
|
+
// 8. Input/Output Typing
|
|
1356
|
+
// ============================================================================
|
|
1357
|
+
|
|
1358
|
+
describe('Input/Output Typing', () => {
|
|
1359
|
+
describe('workflow input typing', () => {
|
|
1360
|
+
it('enforces input type on execute', async () => {
|
|
1361
|
+
interface OrderInput {
|
|
1362
|
+
orderId: string
|
|
1363
|
+
amount: number
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const built = workflow<OrderInput>('typed-input')
|
|
1367
|
+
.step('validate', async (input) => {
|
|
1368
|
+
// TypeScript should infer input as OrderInput
|
|
1369
|
+
return { valid: true, orderId: input.orderId }
|
|
1370
|
+
})
|
|
1371
|
+
.build()
|
|
1372
|
+
|
|
1373
|
+
// This should type-check correctly
|
|
1374
|
+
const result = await built.execute({ orderId: 'order-123', amount: 99.99 })
|
|
1375
|
+
|
|
1376
|
+
expect(result.valid).toBe(true)
|
|
1377
|
+
})
|
|
1378
|
+
|
|
1379
|
+
it('workflow input is passed to first step', async () => {
|
|
1380
|
+
interface UserInput {
|
|
1381
|
+
userId: string
|
|
1382
|
+
email: string
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
let receivedInput: UserInput | null = null
|
|
1386
|
+
|
|
1387
|
+
const built = workflow<UserInput>('input-passthrough')
|
|
1388
|
+
.step('receive', async (input) => {
|
|
1389
|
+
receivedInput = input
|
|
1390
|
+
return { received: true }
|
|
1391
|
+
})
|
|
1392
|
+
.build()
|
|
1393
|
+
|
|
1394
|
+
await built.execute({ userId: 'user-1', email: 'test@example.com' })
|
|
1395
|
+
|
|
1396
|
+
expect(receivedInput).toEqual({ userId: 'user-1', email: 'test@example.com' })
|
|
1397
|
+
})
|
|
1398
|
+
})
|
|
1399
|
+
|
|
1400
|
+
describe('step output typing', () => {
|
|
1401
|
+
it('step output type flows to next step input', async () => {
|
|
1402
|
+
interface Step1Output {
|
|
1403
|
+
value: number
|
|
1404
|
+
label: string
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const built = workflow('typed-chain')
|
|
1408
|
+
.step(
|
|
1409
|
+
'first',
|
|
1410
|
+
async (): Promise<Step1Output> => ({
|
|
1411
|
+
value: 42,
|
|
1412
|
+
label: 'answer',
|
|
1413
|
+
})
|
|
1414
|
+
)
|
|
1415
|
+
.step('second', async (input: Step1Output) => {
|
|
1416
|
+
// TypeScript should know input has value and label
|
|
1417
|
+
return { doubled: input.value * 2 }
|
|
1418
|
+
})
|
|
1419
|
+
.build()
|
|
1420
|
+
|
|
1421
|
+
const result = await built.execute({})
|
|
1422
|
+
|
|
1423
|
+
expect(result.doubled).toBe(84)
|
|
1424
|
+
})
|
|
1425
|
+
|
|
1426
|
+
it('generic step function preserves types', async () => {
|
|
1427
|
+
const built = workflow('generic-step')
|
|
1428
|
+
.step<{ name: string }, { greeting: string }>('greet', async (input) => ({
|
|
1429
|
+
greeting: `Hello, ${input.name}!`,
|
|
1430
|
+
}))
|
|
1431
|
+
.build()
|
|
1432
|
+
|
|
1433
|
+
const result = await built.execute({ name: 'World' })
|
|
1434
|
+
|
|
1435
|
+
expect(result.greeting).toBe('Hello, World!')
|
|
1436
|
+
})
|
|
1437
|
+
})
|
|
1438
|
+
|
|
1439
|
+
describe('workflow output typing', () => {
|
|
1440
|
+
it('build() returns typed workflow with output type', async () => {
|
|
1441
|
+
interface WorkflowOutput {
|
|
1442
|
+
processed: boolean
|
|
1443
|
+
id: string
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
const built = workflow<{ id: string }, WorkflowOutput>('typed-output')
|
|
1447
|
+
.step(
|
|
1448
|
+
'process',
|
|
1449
|
+
async (input): Promise<WorkflowOutput> => ({
|
|
1450
|
+
processed: true,
|
|
1451
|
+
id: input.id,
|
|
1452
|
+
})
|
|
1453
|
+
)
|
|
1454
|
+
.build()
|
|
1455
|
+
|
|
1456
|
+
const result = await built.execute({ id: 'test-123' })
|
|
1457
|
+
|
|
1458
|
+
// result should be typed as WorkflowOutput
|
|
1459
|
+
expect(result.processed).toBe(true)
|
|
1460
|
+
expect(result.id).toBe('test-123')
|
|
1461
|
+
})
|
|
1462
|
+
|
|
1463
|
+
it('final step output is workflow output', async () => {
|
|
1464
|
+
const built = workflow<void, { final: string }>('final-output')
|
|
1465
|
+
.step('step1', async () => ({ intermediate: 'value' }))
|
|
1466
|
+
.step('step2', async () => ({ final: 'result' }))
|
|
1467
|
+
.build()
|
|
1468
|
+
|
|
1469
|
+
const result = await built.execute()
|
|
1470
|
+
|
|
1471
|
+
expect(result.final).toBe('result')
|
|
1472
|
+
})
|
|
1473
|
+
})
|
|
1474
|
+
|
|
1475
|
+
describe('type inference through complex chains', () => {
|
|
1476
|
+
it('types flow through conditionals', async () => {
|
|
1477
|
+
interface Input {
|
|
1478
|
+
value: number
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const built = workflow<Input>('conditional-types')
|
|
1482
|
+
.step('check', async (input) => ({ high: input.value > 50 }))
|
|
1483
|
+
.when((ctx) => ctx.result.high)
|
|
1484
|
+
.then(
|
|
1485
|
+
workflow('high-branch').step('handleHigh', async () => ({ tier: 'premium' as const }))
|
|
1486
|
+
)
|
|
1487
|
+
.else(workflow('low-branch').step('handleLow', async () => ({ tier: 'standard' as const })))
|
|
1488
|
+
.build()
|
|
1489
|
+
|
|
1490
|
+
const highResult = await built.execute({ value: 75 })
|
|
1491
|
+
expect(highResult.tier).toBe('premium')
|
|
1492
|
+
|
|
1493
|
+
const lowResult = await built.execute({ value: 25 })
|
|
1494
|
+
expect(lowResult.tier).toBe('standard')
|
|
1495
|
+
})
|
|
1496
|
+
|
|
1497
|
+
it('types flow through loops', async () => {
|
|
1498
|
+
interface LoopInput {
|
|
1499
|
+
items: string[]
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const built = workflow<LoopInput>('loop-types')
|
|
1503
|
+
.step('init', async (input) => ({
|
|
1504
|
+
remaining: input.items,
|
|
1505
|
+
processed: [] as string[],
|
|
1506
|
+
}))
|
|
1507
|
+
.loop(
|
|
1508
|
+
(ctx) => ctx.result.remaining.length > 0,
|
|
1509
|
+
workflow('loop-body').step('process', async (input) => ({
|
|
1510
|
+
remaining: input.remaining.slice(1),
|
|
1511
|
+
processed: [...input.processed, input.remaining[0].toUpperCase()],
|
|
1512
|
+
}))
|
|
1513
|
+
)
|
|
1514
|
+
.build()
|
|
1515
|
+
|
|
1516
|
+
const result = await built.execute({ items: ['a', 'b', 'c'] })
|
|
1517
|
+
|
|
1518
|
+
expect(result.processed).toEqual(['A', 'B', 'C'])
|
|
1519
|
+
})
|
|
1520
|
+
})
|
|
1521
|
+
})
|
|
1522
|
+
|
|
1523
|
+
// ============================================================================
|
|
1524
|
+
// 9. Complete Integration Example
|
|
1525
|
+
// ============================================================================
|
|
1526
|
+
|
|
1527
|
+
describe('Complete Integration Example', () => {
|
|
1528
|
+
it('builds and executes a complex order processing workflow', async () => {
|
|
1529
|
+
interface OrderInput {
|
|
1530
|
+
orderId: string
|
|
1531
|
+
customerId: string
|
|
1532
|
+
items: Array<{ sku: string; quantity: number; price: number }>
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
const built = workflow<OrderInput>('order-processing')
|
|
1536
|
+
// Step 1: Validate order
|
|
1537
|
+
.step('validate', async (input) => {
|
|
1538
|
+
const isValid = input.items.length > 0
|
|
1539
|
+
const total = input.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
|
1540
|
+
return { valid: isValid, total, ...input }
|
|
1541
|
+
})
|
|
1542
|
+
.retry({ attempts: 3 })
|
|
1543
|
+
|
|
1544
|
+
// Step 2: Check inventory (conditional)
|
|
1545
|
+
.when((ctx) => ctx.result.valid)
|
|
1546
|
+
.then(
|
|
1547
|
+
workflow('inventory-check')
|
|
1548
|
+
.step('checkStock', async (input) => {
|
|
1549
|
+
// Simulate inventory check
|
|
1550
|
+
return { inStock: true, ...input }
|
|
1551
|
+
})
|
|
1552
|
+
.timeout(5000)
|
|
1553
|
+
)
|
|
1554
|
+
.else(
|
|
1555
|
+
workflow('invalid-order').step('reject', async () => ({
|
|
1556
|
+
rejected: true,
|
|
1557
|
+
reason: 'Invalid order',
|
|
1558
|
+
}))
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
// Step 3: Process payment (with error handling)
|
|
1562
|
+
.step('payment', async (input) => {
|
|
1563
|
+
if (!input.inStock) {
|
|
1564
|
+
throw new Error('Cannot process payment: out of stock')
|
|
1565
|
+
}
|
|
1566
|
+
return {
|
|
1567
|
+
paid: true,
|
|
1568
|
+
transactionId: `txn_${input.orderId}`,
|
|
1569
|
+
amount: input.total,
|
|
1570
|
+
}
|
|
1571
|
+
})
|
|
1572
|
+
.retry({ attempts: 3, backoff: 'exponential', delay: 100 })
|
|
1573
|
+
.timeout(10000)
|
|
1574
|
+
.onError(async (error, ctx) => ({
|
|
1575
|
+
paymentFailed: true,
|
|
1576
|
+
error: error.message,
|
|
1577
|
+
refundRequired: false,
|
|
1578
|
+
}))
|
|
1579
|
+
|
|
1580
|
+
// Step 4: Fulfill order items in parallel
|
|
1581
|
+
.step('fulfill', async (input) => {
|
|
1582
|
+
if (!input.paid) {
|
|
1583
|
+
return { fulfilled: false }
|
|
1584
|
+
}
|
|
1585
|
+
return {
|
|
1586
|
+
fulfilled: true,
|
|
1587
|
+
trackingNumber: `track_${input.orderId}`,
|
|
1588
|
+
}
|
|
1589
|
+
})
|
|
1590
|
+
|
|
1591
|
+
// Step 5: Send notification
|
|
1592
|
+
.step('notify', async (input) => ({
|
|
1593
|
+
notified: true,
|
|
1594
|
+
message: input.fulfilled
|
|
1595
|
+
? `Order ${input.orderId} shipped!`
|
|
1596
|
+
: `Order ${input.orderId} could not be processed`,
|
|
1597
|
+
}))
|
|
1598
|
+
|
|
1599
|
+
.build()
|
|
1600
|
+
|
|
1601
|
+
// Execute the workflow
|
|
1602
|
+
const result = await built.execute({
|
|
1603
|
+
orderId: 'order-456',
|
|
1604
|
+
customerId: 'cust-789',
|
|
1605
|
+
items: [
|
|
1606
|
+
{ sku: 'WIDGET-001', quantity: 2, price: 29.99 },
|
|
1607
|
+
{ sku: 'GADGET-002', quantity: 1, price: 49.99 },
|
|
1608
|
+
],
|
|
1609
|
+
})
|
|
1610
|
+
|
|
1611
|
+
expect(result.valid).toBe(true)
|
|
1612
|
+
expect(result.inStock).toBe(true)
|
|
1613
|
+
expect(result.paid).toBe(true)
|
|
1614
|
+
expect(result.fulfilled).toBe(true)
|
|
1615
|
+
expect(result.notified).toBe(true)
|
|
1616
|
+
expect(result.trackingNumber).toBe('track_order-456')
|
|
1617
|
+
})
|
|
1618
|
+
})
|
|
1619
|
+
|
|
1620
|
+
// ============================================================================
|
|
1621
|
+
// 10. Type Definitions (for reference)
|
|
1622
|
+
// ============================================================================
|
|
1623
|
+
|
|
1624
|
+
describe('Type Definitions', () => {
|
|
1625
|
+
it('RetryConfig type matches expected shape', () => {
|
|
1626
|
+
const config: RetryConfig = {
|
|
1627
|
+
attempts: 3,
|
|
1628
|
+
backoff: 'exponential',
|
|
1629
|
+
delay: 100,
|
|
1630
|
+
maxDelay: 5000,
|
|
1631
|
+
jitter: true,
|
|
1632
|
+
retryIf: (error, attempt) => attempt < 3,
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
expect(config.attempts).toBe(3)
|
|
1636
|
+
expect(config.backoff).toBe('exponential')
|
|
1637
|
+
})
|
|
1638
|
+
|
|
1639
|
+
it('StepContext type provides expected methods', async () => {
|
|
1640
|
+
let capturedCtx: StepContext | null = null
|
|
1641
|
+
|
|
1642
|
+
const built = workflow('context-shape')
|
|
1643
|
+
.step('capture', async (_, ctx) => {
|
|
1644
|
+
capturedCtx = ctx
|
|
1645
|
+
return {}
|
|
1646
|
+
})
|
|
1647
|
+
.build()
|
|
1648
|
+
|
|
1649
|
+
await built.execute({})
|
|
1650
|
+
|
|
1651
|
+
expect(capturedCtx).not.toBeNull()
|
|
1652
|
+
expect(typeof capturedCtx!.retry).toBe('function')
|
|
1653
|
+
expect(typeof capturedCtx!.skip).toBe('function')
|
|
1654
|
+
expect(typeof capturedCtx!.abort).toBe('function')
|
|
1655
|
+
expect(capturedCtx!.input).toBeDefined()
|
|
1656
|
+
expect(capturedCtx!.result).toBeDefined()
|
|
1657
|
+
})
|
|
1658
|
+
|
|
1659
|
+
it('BuiltWorkflow type has expected properties', () => {
|
|
1660
|
+
const built = workflow('type-check')
|
|
1661
|
+
.step('test', async () => ({}))
|
|
1662
|
+
.build()
|
|
1663
|
+
|
|
1664
|
+
// BuiltWorkflow should have these properties
|
|
1665
|
+
expect(built).toHaveProperty('name')
|
|
1666
|
+
expect(built).toHaveProperty('steps')
|
|
1667
|
+
expect(built).toHaveProperty('execute')
|
|
1668
|
+
expect(typeof built.execute).toBe('function')
|
|
1669
|
+
})
|
|
1670
|
+
})
|