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.
Files changed (211) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +17 -1
  3. package/README.md +305 -184
  4. package/dist/barrier.d.ts +159 -0
  5. package/dist/barrier.d.ts.map +1 -0
  6. package/dist/barrier.js +377 -0
  7. package/dist/barrier.js.map +1 -0
  8. package/dist/cascade-context.d.ts +149 -0
  9. package/dist/cascade-context.d.ts.map +1 -0
  10. package/dist/cascade-context.js +324 -0
  11. package/dist/cascade-context.js.map +1 -0
  12. package/dist/cascade-executor.d.ts +196 -0
  13. package/dist/cascade-executor.d.ts.map +1 -0
  14. package/dist/cascade-executor.js +384 -0
  15. package/dist/cascade-executor.js.map +1 -0
  16. package/dist/context.d.ts.map +1 -1
  17. package/dist/context.js +27 -8
  18. package/dist/context.js.map +1 -1
  19. package/dist/cron-parser.d.ts +65 -0
  20. package/dist/cron-parser.d.ts.map +1 -0
  21. package/dist/cron-parser.js +294 -0
  22. package/dist/cron-parser.js.map +1 -0
  23. package/dist/cron-scheduler.d.ts +117 -0
  24. package/dist/cron-scheduler.d.ts.map +1 -0
  25. package/dist/cron-scheduler.js +176 -0
  26. package/dist/cron-scheduler.js.map +1 -0
  27. package/dist/database-context.d.ts +184 -0
  28. package/dist/database-context.d.ts.map +1 -0
  29. package/dist/database-context.js +428 -0
  30. package/dist/database-context.js.map +1 -0
  31. package/dist/dependency-graph.d.ts +157 -0
  32. package/dist/dependency-graph.d.ts.map +1 -0
  33. package/dist/dependency-graph.js +382 -0
  34. package/dist/dependency-graph.js.map +1 -0
  35. package/dist/digital-objects-adapter.d.ts +159 -0
  36. package/dist/digital-objects-adapter.d.ts.map +1 -0
  37. package/dist/digital-objects-adapter.js +229 -0
  38. package/dist/digital-objects-adapter.js.map +1 -0
  39. package/dist/durable-execution-cloudflare.d.ts +427 -0
  40. package/dist/durable-execution-cloudflare.d.ts.map +1 -0
  41. package/dist/durable-execution-cloudflare.js +510 -0
  42. package/dist/durable-execution-cloudflare.js.map +1 -0
  43. package/dist/durable-execution.d.ts +482 -0
  44. package/dist/durable-execution.d.ts.map +1 -0
  45. package/dist/durable-execution.js +594 -0
  46. package/dist/durable-execution.js.map +1 -0
  47. package/dist/durable-workflow.d.ts +176 -0
  48. package/dist/durable-workflow.d.ts.map +1 -0
  49. package/dist/durable-workflow.js +552 -0
  50. package/dist/durable-workflow.js.map +1 -0
  51. package/dist/every.d.ts +31 -2
  52. package/dist/every.d.ts.map +1 -1
  53. package/dist/every.js +63 -32
  54. package/dist/every.js.map +1 -1
  55. package/dist/graph/index.d.ts +8 -0
  56. package/dist/graph/index.d.ts.map +1 -0
  57. package/dist/graph/index.js +8 -0
  58. package/dist/graph/index.js.map +1 -0
  59. package/dist/graph/topological-sort.d.ts +121 -0
  60. package/dist/graph/topological-sort.d.ts.map +1 -0
  61. package/dist/graph/topological-sort.js +292 -0
  62. package/dist/graph/topological-sort.js.map +1 -0
  63. package/dist/index.d.ts +10 -1
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +25 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/logger.d.ts +101 -0
  68. package/dist/logger.d.ts.map +1 -0
  69. package/dist/logger.js +115 -0
  70. package/dist/logger.js.map +1 -0
  71. package/dist/on.d.ts +35 -10
  72. package/dist/on.d.ts.map +1 -1
  73. package/dist/on.js +53 -19
  74. package/dist/on.js.map +1 -1
  75. package/dist/runtime.d.ts +169 -0
  76. package/dist/runtime.d.ts.map +1 -0
  77. package/dist/runtime.js +275 -0
  78. package/dist/runtime.js.map +1 -0
  79. package/dist/send.d.ts.map +1 -1
  80. package/dist/send.js +4 -3
  81. package/dist/send.js.map +1 -1
  82. package/dist/telemetry.d.ts +150 -0
  83. package/dist/telemetry.d.ts.map +1 -0
  84. package/dist/telemetry.js +388 -0
  85. package/dist/telemetry.js.map +1 -0
  86. package/dist/timer-registry.d.ts +77 -0
  87. package/dist/timer-registry.d.ts.map +1 -0
  88. package/dist/timer-registry.js +154 -0
  89. package/dist/timer-registry.js.map +1 -0
  90. package/dist/types.d.ts +105 -6
  91. package/dist/types.d.ts.map +1 -1
  92. package/dist/types.js +17 -1
  93. package/dist/types.js.map +1 -1
  94. package/dist/worker/durable-step.d.ts +481 -0
  95. package/dist/worker/durable-step.d.ts.map +1 -0
  96. package/dist/worker/durable-step.js +606 -0
  97. package/dist/worker/durable-step.js.map +1 -0
  98. package/dist/worker/index.d.ts +106 -0
  99. package/dist/worker/index.d.ts.map +1 -0
  100. package/dist/worker/index.js +124 -0
  101. package/dist/worker/index.js.map +1 -0
  102. package/dist/worker/state-adapter.d.ts +230 -0
  103. package/dist/worker/state-adapter.d.ts.map +1 -0
  104. package/dist/worker/state-adapter.js +409 -0
  105. package/dist/worker/state-adapter.js.map +1 -0
  106. package/dist/worker/topological-executor.d.ts +282 -0
  107. package/dist/worker/topological-executor.d.ts.map +1 -0
  108. package/dist/worker/topological-executor.js +396 -0
  109. package/dist/worker/topological-executor.js.map +1 -0
  110. package/dist/worker/workflow-builder.d.ts +286 -0
  111. package/dist/worker/workflow-builder.d.ts.map +1 -0
  112. package/dist/worker/workflow-builder.js +565 -0
  113. package/dist/worker/workflow-builder.js.map +1 -0
  114. package/dist/worker.d.ts +800 -0
  115. package/dist/worker.d.ts.map +1 -0
  116. package/dist/worker.js +2428 -0
  117. package/dist/worker.js.map +1 -0
  118. package/dist/workflow-builder.d.ts +287 -0
  119. package/dist/workflow-builder.d.ts.map +1 -0
  120. package/dist/workflow-builder.js +762 -0
  121. package/dist/workflow-builder.js.map +1 -0
  122. package/dist/workflow.d.ts +14 -30
  123. package/dist/workflow.d.ts.map +1 -1
  124. package/dist/workflow.js +136 -292
  125. package/dist/workflow.js.map +1 -1
  126. package/examples/01-ecommerce-order-pipeline.ts +358 -0
  127. package/examples/02-content-moderation-cascade.ts +454 -0
  128. package/examples/03-scheduled-reporting-dependencies.ts +479 -0
  129. package/examples/04-database-persistence.ts +518 -0
  130. package/examples/README.md +173 -0
  131. package/package.json +21 -4
  132. package/src/__tests__/digital-objects-adapter.test.ts +274 -0
  133. package/src/__tests__/durable-workflow.test.ts +297 -0
  134. package/src/barrier.ts +507 -0
  135. package/src/cascade-context.ts +495 -0
  136. package/src/cascade-executor.ts +588 -0
  137. package/src/context.ts +51 -17
  138. package/src/cron-parser.ts +347 -0
  139. package/src/cron-scheduler.ts +239 -0
  140. package/src/database-context.ts +658 -0
  141. package/src/dependency-graph.ts +518 -0
  142. package/src/digital-objects-adapter.ts +351 -0
  143. package/src/durable-execution-cloudflare.ts +855 -0
  144. package/src/durable-execution.ts +1042 -0
  145. package/src/durable-workflow.ts +717 -0
  146. package/src/every.ts +104 -35
  147. package/src/graph/index.ts +19 -0
  148. package/src/graph/topological-sort.ts +412 -0
  149. package/src/index.ts +147 -0
  150. package/src/logger.ts +148 -0
  151. package/src/on.ts +81 -26
  152. package/src/runtime.ts +436 -0
  153. package/src/send.ts +4 -5
  154. package/src/telemetry.ts +577 -0
  155. package/src/timer-registry.ts +179 -0
  156. package/src/types.ts +146 -10
  157. package/src/worker/durable-step.ts +976 -0
  158. package/src/worker/index.ts +216 -0
  159. package/src/worker/state-adapter.ts +589 -0
  160. package/src/worker/topological-executor.ts +625 -0
  161. package/src/worker/workflow-builder.ts +871 -0
  162. package/src/worker.ts +2906 -0
  163. package/src/workflow-builder.ts +1068 -0
  164. package/src/workflow.ts +199 -355
  165. package/test/barrier-join.test.ts +442 -0
  166. package/test/barrier-unhandled-rejections.test.ts +359 -0
  167. package/test/cascade-context.test.ts +390 -0
  168. package/test/cascade-executor.test.ts +852 -0
  169. package/test/cron-parser.test.ts +314 -0
  170. package/test/cron-scheduler.test.ts +291 -0
  171. package/test/database-context.test.ts +770 -0
  172. package/test/db-provider-adapter.test.ts +862 -0
  173. package/test/dependency-graph.test.ts +512 -0
  174. package/test/durable-execution-cloudflare.test.ts +606 -0
  175. package/test/durable-execution-in-process.test.ts +286 -0
  176. package/test/durable-execution.test.ts +247 -0
  177. package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
  178. package/test/graph/topological-sort.test.ts +586 -0
  179. package/test/integration.test.ts +442 -0
  180. package/test/rpc-surface.test.ts +946 -0
  181. package/test/runtime.test.ts +262 -0
  182. package/test/schedule-timer-cleanup.test.ts +353 -0
  183. package/test/send-race-conditions.test.ts +400 -0
  184. package/test/type-safety-every.test.ts +303 -0
  185. package/test/worker/durable-cascade.test.ts +1117 -0
  186. package/test/worker/durable-step.test.ts +723 -0
  187. package/test/worker/topological-executor.test.ts +1240 -0
  188. package/test/worker/workflow-builder.test.ts +1067 -0
  189. package/test/worker.test.ts +608 -0
  190. package/test/workflow-builder.test.ts +1670 -0
  191. package/test/workflow-cron.test.ts +256 -0
  192. package/test/workflow-state-adapter.test.ts +923 -0
  193. package/test/workflow.test.ts +25 -22
  194. package/tsconfig.json +3 -1
  195. package/vitest.config.ts +38 -1
  196. package/vitest.workers.config.ts +44 -0
  197. package/wrangler.jsonc +22 -0
  198. package/.turbo/turbo-test.log +0 -7
  199. package/src/context.js +0 -83
  200. package/src/every.js +0 -267
  201. package/src/index.js +0 -71
  202. package/src/on.js +0 -79
  203. package/src/send.js +0 -111
  204. package/src/types.js +0 -4
  205. package/src/workflow.js +0 -455
  206. package/test/context.test.js +0 -116
  207. package/test/every.test.js +0 -282
  208. package/test/on.test.js +0 -80
  209. package/test/send.test.js +0 -89
  210. package/test/workflow.test.js +0 -224
  211. 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
+ })