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,1039 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-End Test Suite for ai-workflows
|
|
3
|
+
*
|
|
4
|
+
* Tests complete workflow scenarios that exercise the full system:
|
|
5
|
+
* 1. Customer signup -> email workflow -> notification
|
|
6
|
+
* 2. Order processing with multiple steps and dependencies
|
|
7
|
+
* 3. Scheduled task execution over time with state persistence
|
|
8
|
+
* 4. Multi-tier cascade with timeout and retry
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
Workflow,
|
|
16
|
+
createTestContext,
|
|
17
|
+
clearEventHandlers,
|
|
18
|
+
clearScheduleHandlers,
|
|
19
|
+
createCascadeContext,
|
|
20
|
+
recordStep,
|
|
21
|
+
DependencyGraph,
|
|
22
|
+
topologicalSort,
|
|
23
|
+
getExecutionLevels,
|
|
24
|
+
waitForAll,
|
|
25
|
+
waitForAny,
|
|
26
|
+
withConcurrencyLimit,
|
|
27
|
+
createBarrier,
|
|
28
|
+
CascadeExecutor,
|
|
29
|
+
AllTiersFailedError,
|
|
30
|
+
CascadeTimeoutError,
|
|
31
|
+
workflow,
|
|
32
|
+
type WorkflowInstance,
|
|
33
|
+
type CascadeContext,
|
|
34
|
+
} from '../../src/index.js'
|
|
35
|
+
|
|
36
|
+
describe('E2E: Customer Signup Workflow', () => {
|
|
37
|
+
let workflowInstance: WorkflowInstance
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
clearEventHandlers()
|
|
41
|
+
clearScheduleHandlers()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
if (workflowInstance) {
|
|
46
|
+
await workflowInstance.destroy()
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should process customer signup with email and notification chain', async () => {
|
|
51
|
+
const executionLog: string[] = []
|
|
52
|
+
const sentEmails: Array<{ to: string; template: string }> = []
|
|
53
|
+
const sentNotifications: Array<{ channel: string; message: string }> = []
|
|
54
|
+
|
|
55
|
+
workflowInstance = Workflow(($) => {
|
|
56
|
+
// Step 1: Customer signup triggers welcome email
|
|
57
|
+
$.on.Customer.created(async (customer: { name: string; email: string }, $) => {
|
|
58
|
+
executionLog.push(`Customer created: ${customer.name}`)
|
|
59
|
+
$.set('customerId', customer.email)
|
|
60
|
+
$.set('customerName', customer.name)
|
|
61
|
+
|
|
62
|
+
// Send welcome email
|
|
63
|
+
$.send('Email.welcome', {
|
|
64
|
+
to: customer.email,
|
|
65
|
+
template: 'welcome',
|
|
66
|
+
data: { name: customer.name },
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// Step 2: Welcome email triggers confirmation tracking
|
|
71
|
+
$.on.Email.welcome(
|
|
72
|
+
async (email: { to: string; template: string; data: { name: string } }) => {
|
|
73
|
+
executionLog.push(`Sending welcome email to: ${email.to}`)
|
|
74
|
+
sentEmails.push({ to: email.to, template: email.template })
|
|
75
|
+
|
|
76
|
+
// After email sent, trigger notification
|
|
77
|
+
$.send('Notification.send', {
|
|
78
|
+
channel: 'slack',
|
|
79
|
+
message: `New customer signup: ${email.data.name}`,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
// Step 3: Send notification to team
|
|
85
|
+
$.on.Notification.send(async (notification: { channel: string; message: string }) => {
|
|
86
|
+
executionLog.push(`Sending ${notification.channel} notification`)
|
|
87
|
+
sentNotifications.push(notification)
|
|
88
|
+
|
|
89
|
+
// Track in workflow state
|
|
90
|
+
$.set('notificationSent', true)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
await workflowInstance.start()
|
|
95
|
+
|
|
96
|
+
// Trigger the workflow
|
|
97
|
+
await workflowInstance.send('Customer.created', {
|
|
98
|
+
name: 'John Doe',
|
|
99
|
+
email: 'john@example.com',
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// Allow async event chain to complete
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
104
|
+
|
|
105
|
+
// Verify execution chain
|
|
106
|
+
expect(executionLog).toContain('Customer created: John Doe')
|
|
107
|
+
expect(executionLog).toContain('Sending welcome email to: john@example.com')
|
|
108
|
+
expect(executionLog).toContain('Sending slack notification')
|
|
109
|
+
|
|
110
|
+
// Verify emails sent
|
|
111
|
+
expect(sentEmails).toHaveLength(1)
|
|
112
|
+
expect(sentEmails[0]).toEqual({
|
|
113
|
+
to: 'john@example.com',
|
|
114
|
+
template: 'welcome',
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Verify notifications sent
|
|
118
|
+
expect(sentNotifications).toHaveLength(1)
|
|
119
|
+
expect(sentNotifications[0]).toMatchObject({
|
|
120
|
+
channel: 'slack',
|
|
121
|
+
message: 'New customer signup: John Doe',
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Verify workflow state
|
|
125
|
+
expect(workflowInstance.$.get('customerId')).toBe('john@example.com')
|
|
126
|
+
expect(workflowInstance.$.get('customerName')).toBe('John Doe')
|
|
127
|
+
expect(workflowInstance.$.get('notificationSent')).toBe(true)
|
|
128
|
+
|
|
129
|
+
// Verify history captured
|
|
130
|
+
const state = workflowInstance.$.getState()
|
|
131
|
+
expect(state.history.length).toBeGreaterThan(0)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should handle multiple customer signups concurrently', async () => {
|
|
135
|
+
const signups: string[] = []
|
|
136
|
+
|
|
137
|
+
workflowInstance = Workflow(($) => {
|
|
138
|
+
$.on.Customer.created(async (customer: { id: string; name: string }) => {
|
|
139
|
+
// Simulate some async work
|
|
140
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
141
|
+
signups.push(customer.id)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
await workflowInstance.start()
|
|
146
|
+
|
|
147
|
+
// Send multiple events concurrently
|
|
148
|
+
await Promise.all([
|
|
149
|
+
workflowInstance.send('Customer.created', { id: '1', name: 'Customer 1' }),
|
|
150
|
+
workflowInstance.send('Customer.created', { id: '2', name: 'Customer 2' }),
|
|
151
|
+
workflowInstance.send('Customer.created', { id: '3', name: 'Customer 3' }),
|
|
152
|
+
])
|
|
153
|
+
|
|
154
|
+
// Wait for all handlers to complete
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
156
|
+
|
|
157
|
+
expect(signups).toHaveLength(3)
|
|
158
|
+
expect(signups).toContain('1')
|
|
159
|
+
expect(signups).toContain('2')
|
|
160
|
+
expect(signups).toContain('3')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should maintain state isolation between workflow instances', async () => {
|
|
164
|
+
const workflow1 = Workflow(($) => {
|
|
165
|
+
$.on.Test.event(async (data: { value: number }) => {
|
|
166
|
+
$.set('value', data.value)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const workflow2 = Workflow(($) => {
|
|
171
|
+
$.on.Test.event(async (data: { value: number }) => {
|
|
172
|
+
$.set('value', data.value * 2)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
await workflow1.send('Test.event', { value: 10 })
|
|
177
|
+
await workflow2.send('Test.event', { value: 10 })
|
|
178
|
+
|
|
179
|
+
// Wait for handlers
|
|
180
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
181
|
+
|
|
182
|
+
expect(workflow1.$.get('value')).toBe(10)
|
|
183
|
+
expect(workflow2.$.get('value')).toBe(20)
|
|
184
|
+
|
|
185
|
+
await workflow1.destroy()
|
|
186
|
+
await workflow2.destroy()
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('E2E: Order Processing Workflow', () => {
|
|
191
|
+
let workflowInstance: WorkflowInstance
|
|
192
|
+
|
|
193
|
+
beforeEach(() => {
|
|
194
|
+
clearEventHandlers()
|
|
195
|
+
clearScheduleHandlers()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
afterEach(async () => {
|
|
199
|
+
if (workflowInstance) {
|
|
200
|
+
await workflowInstance.destroy()
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('should process order through validation, payment, and fulfillment steps', async () => {
|
|
205
|
+
const stepResults: Record<string, unknown> = {}
|
|
206
|
+
|
|
207
|
+
workflowInstance = Workflow(($) => {
|
|
208
|
+
// Step 1: Order received - validate inventory
|
|
209
|
+
$.on.Order.created(async (order: { id: string; items: string[]; total: number }) => {
|
|
210
|
+
stepResults['orderReceived'] = order.id
|
|
211
|
+
$.set('orderId', order.id)
|
|
212
|
+
$.set('orderTotal', order.total)
|
|
213
|
+
|
|
214
|
+
// Check inventory (simulated)
|
|
215
|
+
const inventoryAvailable = order.items.length > 0
|
|
216
|
+
stepResults['inventoryChecked'] = inventoryAvailable
|
|
217
|
+
|
|
218
|
+
if (inventoryAvailable) {
|
|
219
|
+
$.send('Order.validated', { orderId: order.id, status: 'validated' })
|
|
220
|
+
} else {
|
|
221
|
+
$.send('Order.failed', { orderId: order.id, reason: 'out_of_stock' })
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// Step 2: Order validated - process payment
|
|
226
|
+
$.on.Order.validated(async (data: { orderId: string }) => {
|
|
227
|
+
const total = $.get<number>('orderTotal') || 0
|
|
228
|
+
stepResults['paymentProcessing'] = true
|
|
229
|
+
|
|
230
|
+
// Simulate payment processing
|
|
231
|
+
const paymentSuccess = total > 0
|
|
232
|
+
stepResults['paymentResult'] = paymentSuccess
|
|
233
|
+
|
|
234
|
+
if (paymentSuccess) {
|
|
235
|
+
$.send('Payment.completed', {
|
|
236
|
+
orderId: data.orderId,
|
|
237
|
+
amount: total,
|
|
238
|
+
transactionId: `txn-${Date.now()}`,
|
|
239
|
+
})
|
|
240
|
+
} else {
|
|
241
|
+
$.send('Payment.failed', { orderId: data.orderId, reason: 'invalid_amount' })
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// Step 3: Payment completed - start fulfillment
|
|
246
|
+
$.on.Payment.completed(async (data: { orderId: string; transactionId: string }) => {
|
|
247
|
+
stepResults['paymentCompleted'] = data.transactionId
|
|
248
|
+
$.set('transactionId', data.transactionId)
|
|
249
|
+
|
|
250
|
+
$.send('Fulfillment.started', {
|
|
251
|
+
orderId: data.orderId,
|
|
252
|
+
status: 'processing',
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// Step 4: Fulfillment processing
|
|
257
|
+
$.on.Fulfillment.started(async (data: { orderId: string }) => {
|
|
258
|
+
stepResults['fulfillmentStarted'] = true
|
|
259
|
+
|
|
260
|
+
// Simulate fulfillment work
|
|
261
|
+
$.send('Fulfillment.completed', {
|
|
262
|
+
orderId: data.orderId,
|
|
263
|
+
trackingNumber: `TRACK-${Date.now()}`,
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// Step 5: Order complete
|
|
268
|
+
$.on.Fulfillment.completed(async (data: { orderId: string; trackingNumber: string }) => {
|
|
269
|
+
stepResults['orderCompleted'] = true
|
|
270
|
+
stepResults['trackingNumber'] = data.trackingNumber
|
|
271
|
+
$.set('orderStatus', 'completed')
|
|
272
|
+
$.set('trackingNumber', data.trackingNumber)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// Error handling
|
|
276
|
+
$.on.Order.failed(async (data: { orderId: string; reason: string }) => {
|
|
277
|
+
stepResults['orderFailed'] = data.reason
|
|
278
|
+
$.set('orderStatus', 'failed')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
$.on.Payment.failed(async (data: { orderId: string; reason: string }) => {
|
|
282
|
+
stepResults['paymentFailed'] = data.reason
|
|
283
|
+
$.set('orderStatus', 'payment_failed')
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
await workflowInstance.start()
|
|
288
|
+
|
|
289
|
+
// Trigger order processing
|
|
290
|
+
await workflowInstance.send('Order.created', {
|
|
291
|
+
id: 'order-123',
|
|
292
|
+
items: ['item-1', 'item-2'],
|
|
293
|
+
total: 99.99,
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
// Wait for full chain to complete
|
|
297
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
298
|
+
|
|
299
|
+
// Verify all steps executed
|
|
300
|
+
expect(stepResults['orderReceived']).toBe('order-123')
|
|
301
|
+
expect(stepResults['inventoryChecked']).toBe(true)
|
|
302
|
+
expect(stepResults['paymentProcessing']).toBe(true)
|
|
303
|
+
expect(stepResults['paymentResult']).toBe(true)
|
|
304
|
+
expect(stepResults['paymentCompleted']).toBeDefined()
|
|
305
|
+
expect(stepResults['fulfillmentStarted']).toBe(true)
|
|
306
|
+
expect(stepResults['orderCompleted']).toBe(true)
|
|
307
|
+
expect(stepResults['trackingNumber']).toBeDefined()
|
|
308
|
+
|
|
309
|
+
// Verify final state
|
|
310
|
+
expect(workflowInstance.$.get('orderStatus')).toBe('completed')
|
|
311
|
+
expect(workflowInstance.$.get('trackingNumber')).toBeDefined()
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('should handle order failure at payment step', async () => {
|
|
315
|
+
const stepResults: Record<string, unknown> = {}
|
|
316
|
+
|
|
317
|
+
workflowInstance = Workflow(($) => {
|
|
318
|
+
$.on.Order.created(async (order: { id: string; total: number }) => {
|
|
319
|
+
stepResults['orderReceived'] = true
|
|
320
|
+
$.set('orderId', order.id)
|
|
321
|
+
$.set('orderTotal', order.total)
|
|
322
|
+
$.send('Order.validated', { orderId: order.id })
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
$.on.Order.validated(async (data: { orderId: string }) => {
|
|
326
|
+
const total = $.get<number>('orderTotal') || 0
|
|
327
|
+
// Simulate payment failure for zero or negative amounts
|
|
328
|
+
if (total <= 0) {
|
|
329
|
+
$.send('Payment.failed', { orderId: data.orderId, reason: 'invalid_amount' })
|
|
330
|
+
} else {
|
|
331
|
+
$.send('Payment.completed', { orderId: data.orderId, amount: total })
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
$.on.Payment.failed(async (data: { reason: string }) => {
|
|
336
|
+
stepResults['paymentFailed'] = data.reason
|
|
337
|
+
$.set('orderStatus', 'payment_failed')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
$.on.Payment.completed(async () => {
|
|
341
|
+
stepResults['paymentCompleted'] = true
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
await workflowInstance.start()
|
|
346
|
+
|
|
347
|
+
// Send order with invalid amount
|
|
348
|
+
await workflowInstance.send('Order.created', {
|
|
349
|
+
id: 'order-456',
|
|
350
|
+
total: 0,
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
354
|
+
|
|
355
|
+
expect(stepResults['orderReceived']).toBe(true)
|
|
356
|
+
expect(stepResults['paymentFailed']).toBe('invalid_amount')
|
|
357
|
+
expect(stepResults['paymentCompleted']).toBeUndefined()
|
|
358
|
+
expect(workflowInstance.$.get('orderStatus')).toBe('payment_failed')
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('should execute steps with dependencies using dependency graph', async () => {
|
|
362
|
+
// Create a dependency graph for order processing steps
|
|
363
|
+
const graph = new DependencyGraph()
|
|
364
|
+
|
|
365
|
+
// Define steps
|
|
366
|
+
graph.addNode('validate')
|
|
367
|
+
graph.addNode('checkInventory')
|
|
368
|
+
graph.addNode('processPayment')
|
|
369
|
+
graph.addNode('reserveInventory')
|
|
370
|
+
graph.addNode('shipOrder')
|
|
371
|
+
graph.addNode('sendConfirmation')
|
|
372
|
+
|
|
373
|
+
// Define dependencies
|
|
374
|
+
graph.addEdge('validate', 'checkInventory')
|
|
375
|
+
graph.addEdge('validate', 'processPayment')
|
|
376
|
+
graph.addEdge('checkInventory', 'reserveInventory')
|
|
377
|
+
graph.addEdge('processPayment', 'shipOrder')
|
|
378
|
+
graph.addEdge('reserveInventory', 'shipOrder')
|
|
379
|
+
graph.addEdge('shipOrder', 'sendConfirmation')
|
|
380
|
+
|
|
381
|
+
// Get execution groups (steps that can run in parallel)
|
|
382
|
+
const groups = graph.getParallelGroups()
|
|
383
|
+
|
|
384
|
+
expect(groups.length).toBeGreaterThanOrEqual(3)
|
|
385
|
+
|
|
386
|
+
// Level 0: validate (no dependencies)
|
|
387
|
+
expect(groups[0].nodes).toContain('validate')
|
|
388
|
+
expect(groups[0].level).toBe(0)
|
|
389
|
+
|
|
390
|
+
// Level 1: checkInventory, processPayment (depend on validate)
|
|
391
|
+
expect(groups[1].nodes).toContain('checkInventory')
|
|
392
|
+
expect(groups[1].nodes).toContain('processPayment')
|
|
393
|
+
|
|
394
|
+
// Verify topological order
|
|
395
|
+
const nodes = [
|
|
396
|
+
{ id: 'validate', dependencies: [] },
|
|
397
|
+
{ id: 'checkInventory', dependencies: ['validate'] },
|
|
398
|
+
{ id: 'processPayment', dependencies: ['validate'] },
|
|
399
|
+
{ id: 'reserveInventory', dependencies: ['checkInventory'] },
|
|
400
|
+
{ id: 'shipOrder', dependencies: ['processPayment', 'reserveInventory'] },
|
|
401
|
+
{ id: 'sendConfirmation', dependencies: ['shipOrder'] },
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
const result = topologicalSort(nodes)
|
|
405
|
+
expect(result.hasCycle).toBe(false)
|
|
406
|
+
|
|
407
|
+
// Validate ordering
|
|
408
|
+
const order = result.order
|
|
409
|
+
expect(order.indexOf('validate')).toBeLessThan(order.indexOf('checkInventory'))
|
|
410
|
+
expect(order.indexOf('validate')).toBeLessThan(order.indexOf('processPayment'))
|
|
411
|
+
expect(order.indexOf('checkInventory')).toBeLessThan(order.indexOf('reserveInventory'))
|
|
412
|
+
expect(order.indexOf('reserveInventory')).toBeLessThan(order.indexOf('shipOrder'))
|
|
413
|
+
expect(order.indexOf('processPayment')).toBeLessThan(order.indexOf('shipOrder'))
|
|
414
|
+
expect(order.indexOf('shipOrder')).toBeLessThan(order.indexOf('sendConfirmation'))
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
describe('E2E: Scheduled Task Execution', () => {
|
|
419
|
+
beforeEach(() => {
|
|
420
|
+
clearEventHandlers()
|
|
421
|
+
clearScheduleHandlers()
|
|
422
|
+
vi.useFakeTimers()
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
afterEach(() => {
|
|
426
|
+
vi.useRealTimers()
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('should execute scheduled tasks and persist state across executions', async () => {
|
|
430
|
+
const executionTimes: number[] = []
|
|
431
|
+
let executionCount = 0
|
|
432
|
+
|
|
433
|
+
const workflowInstance = Workflow(($) => {
|
|
434
|
+
// Initialize counter
|
|
435
|
+
$.set('taskExecutions', 0)
|
|
436
|
+
|
|
437
|
+
// Schedule task every second
|
|
438
|
+
$.every.seconds(1)(async ($) => {
|
|
439
|
+
executionCount++
|
|
440
|
+
const current = $.get<number>('taskExecutions') || 0
|
|
441
|
+
$.set('taskExecutions', current + 1)
|
|
442
|
+
executionTimes.push(Date.now())
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
await workflowInstance.start()
|
|
447
|
+
|
|
448
|
+
// Advance time and verify executions
|
|
449
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
450
|
+
expect(executionCount).toBe(1)
|
|
451
|
+
expect(workflowInstance.$.get('taskExecutions')).toBe(1)
|
|
452
|
+
|
|
453
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
454
|
+
expect(executionCount).toBe(2)
|
|
455
|
+
expect(workflowInstance.$.get('taskExecutions')).toBe(2)
|
|
456
|
+
|
|
457
|
+
await vi.advanceTimersByTimeAsync(3000)
|
|
458
|
+
expect(executionCount).toBe(5)
|
|
459
|
+
expect(workflowInstance.$.get('taskExecutions')).toBe(5)
|
|
460
|
+
|
|
461
|
+
// Stop the workflow
|
|
462
|
+
await workflowInstance.stop()
|
|
463
|
+
|
|
464
|
+
// Verify no more executions after stop
|
|
465
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
466
|
+
expect(executionCount).toBe(5)
|
|
467
|
+
|
|
468
|
+
await workflowInstance.destroy()
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('should execute multiple schedules with different intervals', async () => {
|
|
472
|
+
const fastTaskCount = { value: 0 }
|
|
473
|
+
const slowTaskCount = { value: 0 }
|
|
474
|
+
|
|
475
|
+
const workflowInstance = Workflow(($) => {
|
|
476
|
+
$.every.seconds(1)(async () => {
|
|
477
|
+
fastTaskCount.value++
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
$.every.seconds(5)(async () => {
|
|
481
|
+
slowTaskCount.value++
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
await workflowInstance.start()
|
|
486
|
+
|
|
487
|
+
// Advance 5 seconds
|
|
488
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
489
|
+
|
|
490
|
+
// Fast task should run 5 times, slow task 1 time
|
|
491
|
+
expect(fastTaskCount.value).toBe(5)
|
|
492
|
+
expect(slowTaskCount.value).toBe(1)
|
|
493
|
+
|
|
494
|
+
// Advance another 5 seconds
|
|
495
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
496
|
+
|
|
497
|
+
expect(fastTaskCount.value).toBe(10)
|
|
498
|
+
expect(slowTaskCount.value).toBe(2)
|
|
499
|
+
|
|
500
|
+
await workflowInstance.destroy()
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('should track execution history in state', async () => {
|
|
504
|
+
const workflowInstance = Workflow(($) => {
|
|
505
|
+
$.every.seconds(1)(async ($) => {
|
|
506
|
+
$.log('Scheduled task executed')
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
await workflowInstance.start()
|
|
511
|
+
|
|
512
|
+
// Execute a few times
|
|
513
|
+
await vi.advanceTimersByTimeAsync(3000)
|
|
514
|
+
|
|
515
|
+
// Check history
|
|
516
|
+
const state = workflowInstance.$.getState()
|
|
517
|
+
const scheduleEntries = state.history.filter((h) => h.type === 'schedule')
|
|
518
|
+
expect(scheduleEntries.length).toBe(3)
|
|
519
|
+
|
|
520
|
+
await workflowInstance.destroy()
|
|
521
|
+
})
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
describe('E2E: Multi-Tier Cascade with Timeout and Retry', () => {
|
|
525
|
+
it('should cascade through tiers on failure', async () => {
|
|
526
|
+
const executionLog: string[] = []
|
|
527
|
+
|
|
528
|
+
const executor = new CascadeExecutor({
|
|
529
|
+
tiers: {
|
|
530
|
+
code: {
|
|
531
|
+
name: 'code-handler',
|
|
532
|
+
execute: async (input) => {
|
|
533
|
+
executionLog.push('code-tier-attempt')
|
|
534
|
+
throw new Error('Code tier failed')
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
generative: {
|
|
538
|
+
name: 'generative-handler',
|
|
539
|
+
execute: async (input) => {
|
|
540
|
+
executionLog.push('generative-tier-attempt')
|
|
541
|
+
return `Generated result for: ${input}`
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
timeouts: {
|
|
546
|
+
code: 100,
|
|
547
|
+
generative: 5000,
|
|
548
|
+
},
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
const result = await executor.execute('test-input')
|
|
552
|
+
|
|
553
|
+
expect(result.tier).toBe('generative')
|
|
554
|
+
expect(result.value).toBe('Generated result for: test-input')
|
|
555
|
+
expect(executionLog).toContain('code-tier-attempt')
|
|
556
|
+
expect(executionLog).toContain('generative-tier-attempt')
|
|
557
|
+
expect(result.history).toHaveLength(2)
|
|
558
|
+
expect(result.history[0].tier).toBe('code')
|
|
559
|
+
expect(result.history[0].success).toBe(false)
|
|
560
|
+
expect(result.history[1].tier).toBe('generative')
|
|
561
|
+
expect(result.history[1].success).toBe(true)
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it('should cascade through all four tiers when needed', async () => {
|
|
565
|
+
const tierAttempts: string[] = []
|
|
566
|
+
|
|
567
|
+
const executor = new CascadeExecutor({
|
|
568
|
+
tiers: {
|
|
569
|
+
code: {
|
|
570
|
+
name: 'code-handler',
|
|
571
|
+
execute: async () => {
|
|
572
|
+
tierAttempts.push('code')
|
|
573
|
+
throw new Error('Code failed')
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
generative: {
|
|
577
|
+
name: 'generative-handler',
|
|
578
|
+
execute: async () => {
|
|
579
|
+
tierAttempts.push('generative')
|
|
580
|
+
throw new Error('Generative failed')
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
agentic: {
|
|
584
|
+
name: 'agentic-handler',
|
|
585
|
+
execute: async () => {
|
|
586
|
+
tierAttempts.push('agentic')
|
|
587
|
+
throw new Error('Agentic failed')
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
human: {
|
|
591
|
+
name: 'human-handler',
|
|
592
|
+
execute: async (input) => {
|
|
593
|
+
tierAttempts.push('human')
|
|
594
|
+
return `Human resolved: ${input}`
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
timeouts: {
|
|
599
|
+
code: 100,
|
|
600
|
+
generative: 100,
|
|
601
|
+
agentic: 100,
|
|
602
|
+
human: 1000,
|
|
603
|
+
},
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
const result = await executor.execute('complex-input')
|
|
607
|
+
|
|
608
|
+
expect(tierAttempts).toEqual(['code', 'generative', 'agentic', 'human'])
|
|
609
|
+
expect(result.tier).toBe('human')
|
|
610
|
+
expect(result.value).toBe('Human resolved: complex-input')
|
|
611
|
+
expect(result.history).toHaveLength(4)
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
it('should throw AllTiersFailedError when all tiers fail', async () => {
|
|
615
|
+
const executor = new CascadeExecutor({
|
|
616
|
+
tiers: {
|
|
617
|
+
code: {
|
|
618
|
+
name: 'code-handler',
|
|
619
|
+
execute: async () => {
|
|
620
|
+
throw new Error('Code failed')
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
generative: {
|
|
624
|
+
name: 'generative-handler',
|
|
625
|
+
execute: async () => {
|
|
626
|
+
throw new Error('Generative failed')
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
timeouts: {
|
|
631
|
+
code: 100,
|
|
632
|
+
generative: 100,
|
|
633
|
+
},
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
await expect(executor.execute('failing-input')).rejects.toThrow(AllTiersFailedError)
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
it('should respect tier timeouts', async () => {
|
|
640
|
+
const executor = new CascadeExecutor({
|
|
641
|
+
tiers: {
|
|
642
|
+
code: {
|
|
643
|
+
name: 'code-handler',
|
|
644
|
+
execute: async () => {
|
|
645
|
+
// Simulate slow operation
|
|
646
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
647
|
+
return 'slow-result'
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
generative: {
|
|
651
|
+
name: 'generative-handler',
|
|
652
|
+
execute: async () => {
|
|
653
|
+
return 'fast-fallback'
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
timeouts: {
|
|
658
|
+
code: 100, // Will timeout
|
|
659
|
+
generative: 5000,
|
|
660
|
+
},
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
const result = await executor.execute('test')
|
|
664
|
+
|
|
665
|
+
// Code tier should timeout and cascade to generative
|
|
666
|
+
expect(result.tier).toBe('generative')
|
|
667
|
+
expect(result.value).toBe('fast-fallback')
|
|
668
|
+
expect(result.history[0].timedOut).toBe(true)
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('should respect total cascade timeout', async () => {
|
|
672
|
+
const executor = new CascadeExecutor({
|
|
673
|
+
tiers: {
|
|
674
|
+
code: {
|
|
675
|
+
name: 'code-handler',
|
|
676
|
+
execute: async () => {
|
|
677
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
678
|
+
throw new Error('Code failed')
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
generative: {
|
|
682
|
+
name: 'generative-handler',
|
|
683
|
+
execute: async () => {
|
|
684
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
685
|
+
return 'result'
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
totalTimeout: 150, // Total timeout less than combined tier execution
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
await expect(executor.execute('test')).rejects.toThrow(CascadeTimeoutError)
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
it('should track cascade context and steps', async () => {
|
|
696
|
+
const ctx = createCascadeContext({ name: 'order-processing' })
|
|
697
|
+
|
|
698
|
+
// Simulate cascade steps
|
|
699
|
+
const step1 = recordStep(ctx, 'validate-order', { actor: 'system' })
|
|
700
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
701
|
+
step1.complete()
|
|
702
|
+
|
|
703
|
+
const step2 = recordStep(ctx, 'process-payment', { actor: 'payment-service' })
|
|
704
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
705
|
+
step2.complete()
|
|
706
|
+
|
|
707
|
+
const step3 = recordStep(ctx, 'send-confirmation', { actor: 'notification-service' })
|
|
708
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
709
|
+
step3.complete()
|
|
710
|
+
|
|
711
|
+
expect(ctx.steps).toHaveLength(3)
|
|
712
|
+
expect(ctx.steps.every((s) => s.status === 'completed')).toBe(true)
|
|
713
|
+
expect(ctx.path).toEqual(['validate-order', 'process-payment', 'send-confirmation'])
|
|
714
|
+
|
|
715
|
+
// Verify serialization
|
|
716
|
+
const serialized = ctx.serialize()
|
|
717
|
+
expect(serialized.correlationId).toBe(ctx.correlationId)
|
|
718
|
+
expect(serialized.steps).toHaveLength(3)
|
|
719
|
+
|
|
720
|
+
// Verify formatting
|
|
721
|
+
const formatted = ctx.format()
|
|
722
|
+
expect(formatted).toContain('order-processing')
|
|
723
|
+
expect(formatted).toContain('[OK] validate-order')
|
|
724
|
+
expect(formatted).toContain('[OK] process-payment')
|
|
725
|
+
expect(formatted).toContain('[OK] send-confirmation')
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it('should retry failed tiers based on retry config', async () => {
|
|
729
|
+
let codeAttempts = 0
|
|
730
|
+
|
|
731
|
+
const executor = new CascadeExecutor({
|
|
732
|
+
tiers: {
|
|
733
|
+
code: {
|
|
734
|
+
name: 'code-handler',
|
|
735
|
+
execute: async () => {
|
|
736
|
+
codeAttempts++
|
|
737
|
+
if (codeAttempts < 3) {
|
|
738
|
+
throw new Error(`Attempt ${codeAttempts} failed`)
|
|
739
|
+
}
|
|
740
|
+
return 'success-after-retries'
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
},
|
|
744
|
+
retryConfig: {
|
|
745
|
+
code: {
|
|
746
|
+
maxRetries: 3,
|
|
747
|
+
baseDelay: 10,
|
|
748
|
+
multiplier: 1,
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
const result = await executor.execute('retry-test')
|
|
754
|
+
|
|
755
|
+
expect(codeAttempts).toBe(3)
|
|
756
|
+
expect(result.tier).toBe('code')
|
|
757
|
+
expect(result.value).toBe('success-after-retries')
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
it('should emit 5W+H events during cascade execution', async () => {
|
|
761
|
+
const events: Array<{ who: string; what: string; where: string }> = []
|
|
762
|
+
|
|
763
|
+
const executor = new CascadeExecutor({
|
|
764
|
+
tiers: {
|
|
765
|
+
code: {
|
|
766
|
+
name: 'code-handler',
|
|
767
|
+
execute: async () => 'code-result',
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
actor: 'test-system',
|
|
771
|
+
cascadeName: 'test-cascade',
|
|
772
|
+
onEvent: (event) => {
|
|
773
|
+
events.push({
|
|
774
|
+
who: event.who,
|
|
775
|
+
what: event.what,
|
|
776
|
+
where: event.where,
|
|
777
|
+
})
|
|
778
|
+
},
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
await executor.execute('input')
|
|
782
|
+
|
|
783
|
+
expect(events.some((e) => e.what === 'cascade-start')).toBe(true)
|
|
784
|
+
expect(events.some((e) => e.what === 'tier-code-execute')).toBe(true)
|
|
785
|
+
expect(events.some((e) => e.what === 'cascade-complete')).toBe(true)
|
|
786
|
+
expect(events.every((e) => e.who === 'test-system')).toBe(true)
|
|
787
|
+
expect(events.every((e) => e.where === 'test-cascade')).toBe(true)
|
|
788
|
+
})
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
describe('E2E: Barrier and Coordination Patterns', () => {
|
|
792
|
+
it('should coordinate parallel steps using barrier', async () => {
|
|
793
|
+
const barrier = createBarrier<string>(3)
|
|
794
|
+
const completionOrder: string[] = []
|
|
795
|
+
|
|
796
|
+
// Simulate three parallel tasks completing at different times
|
|
797
|
+
const task1 = async () => {
|
|
798
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
799
|
+
barrier.arrive('task1')
|
|
800
|
+
completionOrder.push('task1')
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const task2 = async () => {
|
|
804
|
+
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
805
|
+
barrier.arrive('task2')
|
|
806
|
+
completionOrder.push('task2')
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const task3 = async () => {
|
|
810
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
811
|
+
barrier.arrive('task3')
|
|
812
|
+
completionOrder.push('task3')
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Start all tasks
|
|
816
|
+
const taskPromises = [task1(), task2(), task3()]
|
|
817
|
+
|
|
818
|
+
// Wait for barrier
|
|
819
|
+
const results = await barrier.wait()
|
|
820
|
+
|
|
821
|
+
expect(results).toHaveLength(3)
|
|
822
|
+
expect(results).toContain('task1')
|
|
823
|
+
expect(results).toContain('task2')
|
|
824
|
+
expect(results).toContain('task3')
|
|
825
|
+
|
|
826
|
+
// Tasks complete in order of their delays
|
|
827
|
+
expect(completionOrder).toEqual(['task3', 'task2', 'task1'])
|
|
828
|
+
|
|
829
|
+
await Promise.all(taskPromises)
|
|
830
|
+
barrier.dispose()
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
it('should wait for all promises with timeout support', async () => {
|
|
834
|
+
const slowPromise = new Promise<string>((resolve) => setTimeout(() => resolve('slow'), 100))
|
|
835
|
+
const fastPromise = new Promise<string>((resolve) => setTimeout(() => resolve('fast'), 10))
|
|
836
|
+
|
|
837
|
+
const results = await waitForAll([slowPromise, fastPromise])
|
|
838
|
+
|
|
839
|
+
expect(results).toEqual(['slow', 'fast'])
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
it('should wait for N of M promises to complete', async () => {
|
|
843
|
+
const promises = [
|
|
844
|
+
new Promise<number>((resolve) => setTimeout(() => resolve(1), 10)),
|
|
845
|
+
new Promise<number>((resolve) => setTimeout(() => resolve(2), 50)),
|
|
846
|
+
new Promise<number>((resolve) => setTimeout(() => resolve(3), 100)),
|
|
847
|
+
]
|
|
848
|
+
|
|
849
|
+
// Wait for first 2 to complete
|
|
850
|
+
const result = await waitForAny(2, promises)
|
|
851
|
+
|
|
852
|
+
expect(result.completed).toHaveLength(2)
|
|
853
|
+
expect(result.completed).toContain(1)
|
|
854
|
+
expect(result.completed).toContain(2)
|
|
855
|
+
expect(result.pending).toHaveLength(1)
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
it('should limit concurrent task execution', async () => {
|
|
859
|
+
let concurrent = 0
|
|
860
|
+
let maxConcurrent = 0
|
|
861
|
+
const completionOrder: number[] = []
|
|
862
|
+
|
|
863
|
+
const tasks = Array.from({ length: 10 }, (_, i) => async () => {
|
|
864
|
+
concurrent++
|
|
865
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent)
|
|
866
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
867
|
+
concurrent--
|
|
868
|
+
completionOrder.push(i)
|
|
869
|
+
return i
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
const results = await withConcurrencyLimit(tasks, 3)
|
|
873
|
+
|
|
874
|
+
// All tasks completed
|
|
875
|
+
expect(results).toHaveLength(10)
|
|
876
|
+
|
|
877
|
+
// Max concurrent never exceeded 3
|
|
878
|
+
expect(maxConcurrent).toBeLessThanOrEqual(3)
|
|
879
|
+
|
|
880
|
+
// All tasks returned their index
|
|
881
|
+
results.forEach((result, i) => {
|
|
882
|
+
expect(result).toBe(i)
|
|
883
|
+
})
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
it('should handle failures with concurrency limit and collectErrors', async () => {
|
|
887
|
+
const tasks = [
|
|
888
|
+
async () => 'success-1',
|
|
889
|
+
async () => {
|
|
890
|
+
throw new Error('task-2-failed')
|
|
891
|
+
},
|
|
892
|
+
async () => 'success-3',
|
|
893
|
+
async () => {
|
|
894
|
+
throw new Error('task-4-failed')
|
|
895
|
+
},
|
|
896
|
+
async () => 'success-5',
|
|
897
|
+
]
|
|
898
|
+
|
|
899
|
+
const results = await withConcurrencyLimit(tasks, 2, { collectErrors: true })
|
|
900
|
+
|
|
901
|
+
expect(results).toHaveLength(5)
|
|
902
|
+
expect(results[0]).toBe('success-1')
|
|
903
|
+
expect(results[1]).toBeInstanceOf(Error)
|
|
904
|
+
expect((results[1] as Error).message).toBe('task-2-failed')
|
|
905
|
+
expect(results[2]).toBe('success-3')
|
|
906
|
+
expect(results[3]).toBeInstanceOf(Error)
|
|
907
|
+
expect(results[4]).toBe('success-5')
|
|
908
|
+
})
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
describe('E2E: Workflow Builder DSL', () => {
|
|
912
|
+
it('should build and execute a multi-step workflow', () => {
|
|
913
|
+
const executionLog: string[] = []
|
|
914
|
+
|
|
915
|
+
const orderWorkflow = workflow('order-processing')
|
|
916
|
+
.step('validate', async (ctx) => {
|
|
917
|
+
executionLog.push('validate')
|
|
918
|
+
return { validated: true, orderId: 'ord-123' }
|
|
919
|
+
})
|
|
920
|
+
.step('payment', async (ctx) => {
|
|
921
|
+
executionLog.push('payment')
|
|
922
|
+
return { paid: true, transactionId: 'txn-456' }
|
|
923
|
+
})
|
|
924
|
+
.step('fulfillment', async (ctx) => {
|
|
925
|
+
executionLog.push('fulfillment')
|
|
926
|
+
return { shipped: true, trackingNumber: 'TRK-789' }
|
|
927
|
+
})
|
|
928
|
+
.build()
|
|
929
|
+
|
|
930
|
+
expect(orderWorkflow.name).toBe('order-processing')
|
|
931
|
+
expect(orderWorkflow.steps).toHaveLength(3)
|
|
932
|
+
expect(orderWorkflow.steps[0].name).toBe('validate')
|
|
933
|
+
expect(orderWorkflow.steps[1].name).toBe('payment')
|
|
934
|
+
expect(orderWorkflow.steps[2].name).toBe('fulfillment')
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
it('should support conditional branching in workflow', () => {
|
|
938
|
+
const highValueFlow = workflow('high-value')
|
|
939
|
+
.step('manualReview', async () => ({ reviewed: true }))
|
|
940
|
+
.build()
|
|
941
|
+
|
|
942
|
+
const standardFlow = workflow('standard')
|
|
943
|
+
.step('autoApprove', async () => ({ approved: true }))
|
|
944
|
+
.build()
|
|
945
|
+
|
|
946
|
+
const orderWorkflow = workflow('order-routing')
|
|
947
|
+
.step('evaluate', async () => ({ orderValue: 1000 }))
|
|
948
|
+
.when((ctx) => (ctx.result?.evaluate?.orderValue ?? 0) > 500)
|
|
949
|
+
.then(highValueFlow)
|
|
950
|
+
.else(standardFlow)
|
|
951
|
+
.build()
|
|
952
|
+
|
|
953
|
+
expect(orderWorkflow.steps).toHaveLength(2)
|
|
954
|
+
expect(orderWorkflow.steps[0].name).toBe('evaluate')
|
|
955
|
+
expect(orderWorkflow.steps[1].type).toBe('conditional')
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
it('should support workflow composition', () => {
|
|
959
|
+
const emailWorkflow = workflow('send-email')
|
|
960
|
+
.step('template', async () => ({ html: '<html>...</html>' }))
|
|
961
|
+
.step('send', async () => ({ sent: true }))
|
|
962
|
+
.build()
|
|
963
|
+
|
|
964
|
+
const notifyWorkflow = workflow('notifications')
|
|
965
|
+
.step('email', async () => ({ emailQueued: true }))
|
|
966
|
+
.step('sms', async () => ({ smsQueued: true }))
|
|
967
|
+
.build()
|
|
968
|
+
|
|
969
|
+
const fullWorkflow = workflow('complete-process')
|
|
970
|
+
.step('process', async () => ({ processId: 'proc-1' }))
|
|
971
|
+
.step('emailStep', async () => ({ email: 'done' }))
|
|
972
|
+
.step('notifyStep', async () => ({ notify: 'done' }))
|
|
973
|
+
.build()
|
|
974
|
+
|
|
975
|
+
expect(fullWorkflow.steps).toHaveLength(3)
|
|
976
|
+
})
|
|
977
|
+
})
|
|
978
|
+
|
|
979
|
+
describe('E2E: Test Context Utilities', () => {
|
|
980
|
+
it('should track events in test context', () => {
|
|
981
|
+
const $ = createTestContext()
|
|
982
|
+
|
|
983
|
+
$.send('Customer.created', { id: '1', name: 'Test' })
|
|
984
|
+
$.send('Order.placed', { orderId: 'ord-1' })
|
|
985
|
+
$.send('Email.sent', { to: 'test@example.com' })
|
|
986
|
+
|
|
987
|
+
expect($.emittedEvents).toHaveLength(3)
|
|
988
|
+
expect($.emittedEvents[0].event).toBe('Customer.created')
|
|
989
|
+
expect($.emittedEvents[1].event).toBe('Order.placed')
|
|
990
|
+
expect($.emittedEvents[2].event).toBe('Email.sent')
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
it('should manage state in test context', () => {
|
|
994
|
+
const $ = createTestContext()
|
|
995
|
+
|
|
996
|
+
$.set('userId', '123')
|
|
997
|
+
$.set('sessionActive', true)
|
|
998
|
+
$.set('preferences', { theme: 'dark' })
|
|
999
|
+
|
|
1000
|
+
expect($.get('userId')).toBe('123')
|
|
1001
|
+
expect($.get('sessionActive')).toBe(true)
|
|
1002
|
+
expect($.get<{ theme: string }>('preferences')).toEqual({ theme: 'dark' })
|
|
1003
|
+
|
|
1004
|
+
const state = $.getState()
|
|
1005
|
+
expect(state.context['userId']).toBe('123')
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
it('should support handler testing patterns', async () => {
|
|
1009
|
+
// Simulate testing a handler in isolation
|
|
1010
|
+
const $ = createTestContext()
|
|
1011
|
+
|
|
1012
|
+
// Mock handler function
|
|
1013
|
+
const handleCustomerCreated = async (
|
|
1014
|
+
customer: { name: string; email: string },
|
|
1015
|
+
$: ReturnType<typeof createTestContext>
|
|
1016
|
+
) => {
|
|
1017
|
+
$.set('lastCustomer', customer.name)
|
|
1018
|
+
$.send('Email.welcome', { to: customer.email })
|
|
1019
|
+
$.send('Analytics.track', { event: 'signup', user: customer.name })
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Test the handler
|
|
1023
|
+
await handleCustomerCreated({ name: 'Test User', email: 'test@example.com' }, $)
|
|
1024
|
+
|
|
1025
|
+
// Verify state changes
|
|
1026
|
+
expect($.get('lastCustomer')).toBe('Test User')
|
|
1027
|
+
|
|
1028
|
+
// Verify events sent
|
|
1029
|
+
expect($.emittedEvents).toHaveLength(2)
|
|
1030
|
+
expect($.emittedEvents[0]).toMatchObject({
|
|
1031
|
+
event: 'Email.welcome',
|
|
1032
|
+
data: expect.objectContaining({ to: 'test@example.com' }),
|
|
1033
|
+
})
|
|
1034
|
+
expect($.emittedEvents[1]).toMatchObject({
|
|
1035
|
+
event: 'Analytics.track',
|
|
1036
|
+
data: expect.objectContaining({ event: 'signup' }),
|
|
1037
|
+
})
|
|
1038
|
+
})
|
|
1039
|
+
})
|