ai-workflows 2.0.2 → 2.1.3
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 +4 -5
- package/.turbo/turbo-test.log +169 -0
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +303 -184
- package/dist/barrier.d.ts +153 -0
- package/dist/barrier.d.ts.map +1 -0
- package/dist/barrier.js +339 -0
- package/dist/barrier.js.map +1 -0
- package/dist/cascade-context.d.ts +149 -0
- package/dist/cascade-context.d.ts.map +1 -0
- package/dist/cascade-context.js +324 -0
- package/dist/cascade-context.js.map +1 -0
- package/dist/cascade-executor.d.ts +196 -0
- package/dist/cascade-executor.d.ts.map +1 -0
- package/dist/cascade-executor.js +384 -0
- package/dist/cascade-executor.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +4 -1
- package/dist/context.js.map +1 -1
- package/dist/dependency-graph.d.ts +157 -0
- package/dist/dependency-graph.d.ts.map +1 -0
- package/dist/dependency-graph.js +382 -0
- package/dist/dependency-graph.js.map +1 -0
- package/dist/every.d.ts +31 -2
- package/dist/every.d.ts.map +1 -1
- package/dist/every.js +63 -32
- package/dist/every.js.map +1 -1
- package/dist/graph/index.d.ts +8 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +8 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/topological-sort.d.ts +121 -0
- package/dist/graph/topological-sort.d.ts.map +1 -0
- package/dist/graph/topological-sort.js +292 -0
- package/dist/graph/topological-sort.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/on.d.ts +35 -10
- package/dist/on.d.ts.map +1 -1
- package/dist/on.js +52 -18
- package/dist/on.js.map +1 -1
- package/dist/send.d.ts +0 -5
- package/dist/send.d.ts.map +1 -1
- package/dist/send.js +1 -14
- package/dist/send.js.map +1 -1
- package/dist/timer-registry.d.ts +52 -0
- package/dist/timer-registry.d.ts.map +1 -0
- package/dist/timer-registry.js +120 -0
- package/dist/timer-registry.js.map +1 -0
- package/dist/types.d.ts +171 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -1
- package/dist/types.js.map +1 -1
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +22 -18
- package/dist/workflow.js.map +1 -1
- package/package.json +12 -16
- package/src/barrier.ts +466 -0
- package/src/cascade-context.ts +488 -0
- package/src/cascade-executor.ts +587 -0
- package/src/context.js +83 -0
- package/src/context.ts +12 -7
- package/src/dependency-graph.ts +518 -0
- package/src/every.js +267 -0
- package/src/every.ts +104 -35
- package/src/graph/index.ts +19 -0
- package/src/graph/topological-sort.ts +414 -0
- package/src/index.js +71 -0
- package/src/index.ts +78 -0
- package/src/on.js +79 -0
- package/src/on.ts +81 -25
- package/src/send.js +111 -0
- package/src/send.ts +1 -16
- package/src/timer-registry.ts +145 -0
- package/src/types.js +4 -0
- package/src/types.ts +218 -11
- package/src/workflow.js +455 -0
- package/src/workflow.ts +32 -23
- package/test/barrier-join.test.ts +434 -0
- package/test/barrier-unhandled-rejections.test.ts +359 -0
- package/test/cascade-context.test.ts +390 -0
- package/test/cascade-executor.test.ts +859 -0
- package/test/context.test.js +116 -0
- package/test/dependency-graph.test.ts +512 -0
- package/test/every.test.js +282 -0
- package/test/graph/topological-sort.test.ts +586 -0
- package/test/on.test.js +80 -0
- package/test/schedule-timer-cleanup.test.ts +344 -0
- package/test/send-race-conditions.test.ts +410 -0
- package/test/send.test.js +89 -0
- package/test/type-safety-every.test.ts +303 -0
- package/test/types-event-handler.test.ts +225 -0
- package/test/types-proxy-autocomplete.test.ts +345 -0
- package/test/workflow.test.js +224 -0
- package/vitest.config.js +7 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for EventBus race conditions
|
|
3
|
+
*
|
|
4
|
+
* These tests expose the race condition in send.ts:34-42 where concurrent
|
|
5
|
+
* emit() calls can cause events to be skipped or processed out of order.
|
|
6
|
+
*
|
|
7
|
+
* This is the RED phase of TDD - these tests are expected to fail or be flaky.
|
|
8
|
+
*
|
|
9
|
+
* KNOWN ISSUES EXPOSED:
|
|
10
|
+
* 1. send() returns before event is processed when processing=true
|
|
11
|
+
* 2. Events can be skipped when concurrent emit() calls overlap
|
|
12
|
+
* 3. Cascaded events ($.send inside handlers) don't await properly
|
|
13
|
+
* 4. Global EventBus can get stuck with processing=true
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
16
|
+
import { send, getEventBus } from '../src/send.js'
|
|
17
|
+
import { on, clearEventHandlers } from '../src/on.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Helper to wait for the EventBus to finish processing.
|
|
21
|
+
* This is a workaround for the race condition where send() returns early.
|
|
22
|
+
*/
|
|
23
|
+
async function waitForEventBus(maxWaitMs = 500): Promise<void> {
|
|
24
|
+
const start = Date.now()
|
|
25
|
+
// Poll until the bus is idle or timeout
|
|
26
|
+
while (Date.now() - start < maxWaitMs) {
|
|
27
|
+
// Give the event loop a chance to process
|
|
28
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('EventBus race conditions', () => {
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
clearEventHandlers()
|
|
35
|
+
// Wait for any pending processing from previous tests
|
|
36
|
+
await waitForEventBus(50)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
clearEventHandlers()
|
|
41
|
+
// Wait for any pending processing before next test
|
|
42
|
+
await waitForEventBus(50)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('concurrent emit() calls', () => {
|
|
46
|
+
/**
|
|
47
|
+
* Race condition: When multiple emit() calls happen concurrently,
|
|
48
|
+
* some events may not be delivered because:
|
|
49
|
+
* 1. emit() pushes to pending (line 24)
|
|
50
|
+
* 2. emit() checks !this.processing (line 26)
|
|
51
|
+
* 3. If another emit() pushed but process() hasn't set processing=true yet,
|
|
52
|
+
* multiple calls can enter process() simultaneously
|
|
53
|
+
*/
|
|
54
|
+
it('should deliver all events when emit() is called concurrently', async () => {
|
|
55
|
+
const receivedEvents: string[] = []
|
|
56
|
+
const eventCount = 100
|
|
57
|
+
|
|
58
|
+
// Register a handler that records all received events
|
|
59
|
+
on.Test.event(async (data: { id: number }) => {
|
|
60
|
+
receivedEvents.push(`event-${data.id}`)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Fire all events concurrently without awaiting
|
|
64
|
+
const promises = Array.from({ length: eventCount }, (_, i) =>
|
|
65
|
+
send('Test.event', { id: i })
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
// Wait for all emits to complete
|
|
69
|
+
await Promise.all(promises)
|
|
70
|
+
|
|
71
|
+
// All events should be delivered
|
|
72
|
+
expect(receivedEvents.length).toBe(eventCount)
|
|
73
|
+
|
|
74
|
+
// Verify all event IDs are present
|
|
75
|
+
const expectedIds = Array.from({ length: eventCount }, (_, i) => `event-${i}`)
|
|
76
|
+
expect(receivedEvents.sort()).toEqual(expectedIds.sort())
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Race condition: Events fired during handler execution may complete
|
|
81
|
+
* before the send() caller gets control back.
|
|
82
|
+
*
|
|
83
|
+
* When emit() is called while process() is running:
|
|
84
|
+
* 1. Event is pushed to pending
|
|
85
|
+
* 2. this.processing is true, so emit() returns immediately
|
|
86
|
+
* 3. The caller's await send() resolves before the event is processed
|
|
87
|
+
*/
|
|
88
|
+
it('should ensure send() resolves only after the event is fully processed', async () => {
|
|
89
|
+
let eventProcessed = false
|
|
90
|
+
|
|
91
|
+
on.Test.event(async () => {
|
|
92
|
+
// Simulate async work
|
|
93
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
94
|
+
eventProcessed = true
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// First emit to start processing
|
|
98
|
+
const firstSend = send('Test.event', { id: 1 })
|
|
99
|
+
|
|
100
|
+
// Second emit while first is processing
|
|
101
|
+
// Due to the race condition, this may resolve before the handler completes
|
|
102
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
103
|
+
const secondSend = send('Test.event', { id: 2 })
|
|
104
|
+
|
|
105
|
+
// When send() resolves, the event should be processed
|
|
106
|
+
await secondSend
|
|
107
|
+
|
|
108
|
+
// This assertion may fail due to race condition:
|
|
109
|
+
// secondSend resolves immediately because processing=true,
|
|
110
|
+
// but the event hasn't been processed yet
|
|
111
|
+
expect(eventProcessed).toBe(true)
|
|
112
|
+
|
|
113
|
+
await firstSend // Clean up
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Race condition: Handler that emits more events creates a cascade
|
|
118
|
+
* where new events may not be awaited properly.
|
|
119
|
+
*/
|
|
120
|
+
it('should properly await cascaded events from handlers', async () => {
|
|
121
|
+
const executionOrder: string[] = []
|
|
122
|
+
|
|
123
|
+
on.Step.one(async (_data, $) => {
|
|
124
|
+
executionOrder.push('step-1-start')
|
|
125
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
126
|
+
await $.send('Step.two', {})
|
|
127
|
+
executionOrder.push('step-1-end')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
on.Step.two(async () => {
|
|
131
|
+
executionOrder.push('step-2-start')
|
|
132
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
133
|
+
executionOrder.push('step-2-end')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
await send('Step.one', {})
|
|
137
|
+
|
|
138
|
+
// Expected order: step-1-start, step-2-start, step-2-end, step-1-end
|
|
139
|
+
// But due to race condition, step-1-end may come before step-2-end
|
|
140
|
+
expect(executionOrder).toEqual([
|
|
141
|
+
'step-1-start',
|
|
142
|
+
'step-2-start',
|
|
143
|
+
'step-2-end',
|
|
144
|
+
'step-1-end'
|
|
145
|
+
])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Race condition: Multiple concurrent emits may cause the processing
|
|
150
|
+
* flag to be set to false prematurely, causing events to be skipped.
|
|
151
|
+
*/
|
|
152
|
+
it('should not skip events when multiple process() calls overlap', async () => {
|
|
153
|
+
const processedEvents: number[] = []
|
|
154
|
+
const totalEvents = 50
|
|
155
|
+
|
|
156
|
+
on.Concurrent.event(async (data: { id: number }) => {
|
|
157
|
+
// Small delay to increase chance of race condition
|
|
158
|
+
await new Promise(resolve => setTimeout(resolve, Math.random() * 5))
|
|
159
|
+
processedEvents.push(data.id)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Fire events with slight delays to maximize race window
|
|
163
|
+
const promises: Promise<void>[] = []
|
|
164
|
+
for (let i = 0; i < totalEvents; i++) {
|
|
165
|
+
promises.push(send('Concurrent.event', { id: i }))
|
|
166
|
+
// Tiny delay to spread out the calls
|
|
167
|
+
if (i % 10 === 0) {
|
|
168
|
+
await new Promise(resolve => setTimeout(resolve, 1))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
await Promise.all(promises)
|
|
173
|
+
|
|
174
|
+
// Wait a bit more for any stragglers
|
|
175
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
176
|
+
|
|
177
|
+
// All events should be processed
|
|
178
|
+
expect(processedEvents.length).toBe(totalEvents)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Race condition: Events may be lost if emit() returns before
|
|
183
|
+
* processing completes, and the event was pushed during the
|
|
184
|
+
* while loop's shift() operation.
|
|
185
|
+
*/
|
|
186
|
+
it('should handle rapid fire events without losing any', async () => {
|
|
187
|
+
let eventCount = 0
|
|
188
|
+
const targetCount = 1000
|
|
189
|
+
|
|
190
|
+
on.Rapid.fire(async () => {
|
|
191
|
+
eventCount++
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// Fire events as fast as possible
|
|
195
|
+
const promises = Array.from({ length: targetCount }, () =>
|
|
196
|
+
send('Rapid.fire', {})
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
await Promise.all(promises)
|
|
200
|
+
|
|
201
|
+
// Allow any pending processing to complete
|
|
202
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
203
|
+
|
|
204
|
+
expect(eventCount).toBe(targetCount)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* This test specifically targets the race between lines 26-28 and 35
|
|
209
|
+
* where multiple emit() calls can both see processing=false before
|
|
210
|
+
* either sets it to true.
|
|
211
|
+
*/
|
|
212
|
+
it('should serialize event processing even with synchronous concurrent emits', async () => {
|
|
213
|
+
const processingConcurrency: number[] = []
|
|
214
|
+
let currentlyProcessing = 0
|
|
215
|
+
let maxConcurrency = 0
|
|
216
|
+
|
|
217
|
+
on.Serialize.check(async () => {
|
|
218
|
+
currentlyProcessing++
|
|
219
|
+
maxConcurrency = Math.max(maxConcurrency, currentlyProcessing)
|
|
220
|
+
processingConcurrency.push(currentlyProcessing)
|
|
221
|
+
// Simulate work
|
|
222
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
223
|
+
currentlyProcessing--
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// Fire many events simultaneously
|
|
227
|
+
const promises = Array.from({ length: 20 }, () =>
|
|
228
|
+
send('Serialize.check', {})
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
await Promise.all(promises)
|
|
232
|
+
|
|
233
|
+
// If properly serialized, we should never have more than 1 handler
|
|
234
|
+
// processing at a time (events should queue, not run in parallel)
|
|
235
|
+
// Note: The current implementation DOES run handlers in parallel via Promise.all,
|
|
236
|
+
// but different events should still be processed sequentially
|
|
237
|
+
console.log('Max concurrency observed:', maxConcurrency)
|
|
238
|
+
console.log('Concurrency at each event:', processingConcurrency)
|
|
239
|
+
|
|
240
|
+
// This may fail due to race condition allowing multiple process() calls
|
|
241
|
+
expect(maxConcurrency).toBeLessThanOrEqual(1)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Race condition: When emit() is called and processing=true,
|
|
246
|
+
* it returns immediately. If process() finishes before the pushed
|
|
247
|
+
* event is processed, the event may be orphaned.
|
|
248
|
+
*/
|
|
249
|
+
it('should not orphan events pushed while processing is finishing', async () => {
|
|
250
|
+
const processedEvents: number[] = []
|
|
251
|
+
|
|
252
|
+
on.Orphan.test(async (data: { id: number }) => {
|
|
253
|
+
// Very short delay
|
|
254
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
255
|
+
processedEvents.push(data.id)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
// Start first event
|
|
259
|
+
const first = send('Orphan.test', { id: 1 })
|
|
260
|
+
|
|
261
|
+
// Wait for processing to likely be in the deliver() await
|
|
262
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
263
|
+
|
|
264
|
+
// Push more events while processing
|
|
265
|
+
const second = send('Orphan.test', { id: 2 })
|
|
266
|
+
const third = send('Orphan.test', { id: 3 })
|
|
267
|
+
|
|
268
|
+
await Promise.all([first, second, third])
|
|
269
|
+
|
|
270
|
+
// Give extra time for any pending events
|
|
271
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
272
|
+
|
|
273
|
+
expect(processedEvents.sort()).toEqual([1, 2, 3])
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Stress test: High-frequency event emission should not lose events.
|
|
278
|
+
*/
|
|
279
|
+
it('should handle stress test with interleaved emits', async () => {
|
|
280
|
+
const results: { type: string; id: number }[] = []
|
|
281
|
+
|
|
282
|
+
on.TypeA.event((data: { id: number }) => {
|
|
283
|
+
results.push({ type: 'A', id: data.id })
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
on.TypeB.event((data: { id: number }) => {
|
|
287
|
+
results.push({ type: 'B', id: data.id })
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// Interleave different event types
|
|
291
|
+
const promises: Promise<void>[] = []
|
|
292
|
+
for (let i = 0; i < 100; i++) {
|
|
293
|
+
promises.push(send('TypeA.event', { id: i }))
|
|
294
|
+
promises.push(send('TypeB.event', { id: i }))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
await Promise.all(promises)
|
|
298
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
299
|
+
|
|
300
|
+
expect(results.filter(r => r.type === 'A').length).toBe(100)
|
|
301
|
+
expect(results.filter(r => r.type === 'B').length).toBe(100)
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
describe('event ordering', () => {
|
|
306
|
+
/**
|
|
307
|
+
* Events should be processed in the order they were emitted.
|
|
308
|
+
* The race condition may cause out-of-order processing.
|
|
309
|
+
*/
|
|
310
|
+
it('should process events in FIFO order', async () => {
|
|
311
|
+
const processedOrder: number[] = []
|
|
312
|
+
|
|
313
|
+
on.Order.test(async (data: { seq: number }) => {
|
|
314
|
+
// Small random delay to expose ordering issues
|
|
315
|
+
await new Promise(resolve => setTimeout(resolve, Math.random() * 5))
|
|
316
|
+
processedOrder.push(data.seq)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
// Emit events in order
|
|
320
|
+
for (let i = 0; i < 20; i++) {
|
|
321
|
+
await send('Order.test', { seq: i })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Events should be processed in order
|
|
325
|
+
const expected = Array.from({ length: 20 }, (_, i) => i)
|
|
326
|
+
expect(processedOrder).toEqual(expected)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* When emit() calls are not awaited between each other,
|
|
331
|
+
* order should still be maintained.
|
|
332
|
+
*/
|
|
333
|
+
it('should maintain order even with fire-and-await-all pattern', async () => {
|
|
334
|
+
const processedOrder: number[] = []
|
|
335
|
+
|
|
336
|
+
on.FireAll.test((data: { seq: number }) => {
|
|
337
|
+
processedOrder.push(data.seq)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
// Fire all without individual awaits
|
|
341
|
+
const promises = Array.from({ length: 50 }, (_, i) =>
|
|
342
|
+
send('FireAll.test', { seq: i })
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
await Promise.all(promises)
|
|
346
|
+
|
|
347
|
+
// Order should be maintained
|
|
348
|
+
const expected = Array.from({ length: 50 }, (_, i) => i)
|
|
349
|
+
expect(processedOrder).toEqual(expected)
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
describe('send() promise semantics', () => {
|
|
354
|
+
/**
|
|
355
|
+
* send() should resolve only when the event has been fully delivered
|
|
356
|
+
* to all handlers and those handlers have completed.
|
|
357
|
+
*/
|
|
358
|
+
it('should resolve after handler completion, not just after queueing', async () => {
|
|
359
|
+
let handlerCompleted = false
|
|
360
|
+
|
|
361
|
+
on.Semantics.test(async () => {
|
|
362
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
363
|
+
handlerCompleted = true
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
await send('Semantics.test', {})
|
|
367
|
+
|
|
368
|
+
// After send() resolves, handler should have completed
|
|
369
|
+
expect(handlerCompleted).toBe(true)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* When emit() is called while processing=true, the returned promise
|
|
374
|
+
* should still wait for the event to be processed.
|
|
375
|
+
*/
|
|
376
|
+
it('should wait for processing even when emit returns early due to processing flag', async () => {
|
|
377
|
+
let firstHandlerStarted = false
|
|
378
|
+
let secondHandlerCompleted = false
|
|
379
|
+
|
|
380
|
+
on.Wait.first(async () => {
|
|
381
|
+
firstHandlerStarted = true
|
|
382
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
on.Wait.second(async () => {
|
|
386
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
387
|
+
secondHandlerCompleted = true
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
// Start first event (will set processing=true)
|
|
391
|
+
const firstPromise = send('Wait.first', {})
|
|
392
|
+
|
|
393
|
+
// Wait for first handler to start
|
|
394
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
395
|
+
expect(firstHandlerStarted).toBe(true)
|
|
396
|
+
|
|
397
|
+
// Emit second event while first is processing
|
|
398
|
+
// Due to race condition, this may resolve before second handler completes
|
|
399
|
+
const secondPromise = send('Wait.second', {})
|
|
400
|
+
await secondPromise
|
|
401
|
+
|
|
402
|
+
// This assertion exposes the race condition:
|
|
403
|
+
// secondPromise resolves immediately because processing=true,
|
|
404
|
+
// not when the event is actually processed
|
|
405
|
+
expect(secondHandlerCompleted).toBe(true)
|
|
406
|
+
|
|
407
|
+
await firstPromise // Clean up
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { send, parseEvent } from '../src/send.js';
|
|
3
|
+
import { on, clearEventHandlers } from '../src/on.js';
|
|
4
|
+
describe('send - event emission', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
clearEventHandlers();
|
|
7
|
+
});
|
|
8
|
+
describe('parseEvent', () => {
|
|
9
|
+
it('should parse valid event strings', () => {
|
|
10
|
+
expect(parseEvent('Customer.created')).toEqual({
|
|
11
|
+
noun: 'Customer',
|
|
12
|
+
event: 'created',
|
|
13
|
+
});
|
|
14
|
+
expect(parseEvent('Order.completed')).toEqual({
|
|
15
|
+
noun: 'Order',
|
|
16
|
+
event: 'completed',
|
|
17
|
+
});
|
|
18
|
+
expect(parseEvent('Payment.failed')).toEqual({
|
|
19
|
+
noun: 'Payment',
|
|
20
|
+
event: 'failed',
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
it('should return null for invalid event strings', () => {
|
|
24
|
+
expect(parseEvent('invalid')).toBeNull();
|
|
25
|
+
expect(parseEvent('too.many.parts')).toBeNull();
|
|
26
|
+
expect(parseEvent('')).toBeNull();
|
|
27
|
+
expect(parseEvent('.')).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('send', () => {
|
|
31
|
+
it('should emit event to registered handler', async () => {
|
|
32
|
+
const handler = vi.fn();
|
|
33
|
+
on.Customer.created(handler);
|
|
34
|
+
await send('Customer.created', { id: '123', name: 'John' });
|
|
35
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
36
|
+
expect(handler).toHaveBeenCalledWith({ id: '123', name: 'John' }, expect.objectContaining({
|
|
37
|
+
send: expect.any(Function),
|
|
38
|
+
getState: expect.any(Function),
|
|
39
|
+
set: expect.any(Function),
|
|
40
|
+
get: expect.any(Function),
|
|
41
|
+
log: expect.any(Function),
|
|
42
|
+
}));
|
|
43
|
+
});
|
|
44
|
+
it('should emit event to multiple handlers', async () => {
|
|
45
|
+
const handler1 = vi.fn();
|
|
46
|
+
const handler2 = vi.fn();
|
|
47
|
+
on.Customer.created(handler1);
|
|
48
|
+
on.Customer.created(handler2);
|
|
49
|
+
await send('Customer.created', { id: '123' });
|
|
50
|
+
expect(handler1).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(handler2).toHaveBeenCalledTimes(1);
|
|
52
|
+
});
|
|
53
|
+
it('should not throw when no handlers are registered', async () => {
|
|
54
|
+
await expect(send('Customer.created', { id: '123' })).resolves.not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
it('should not call handlers for different events', async () => {
|
|
57
|
+
const handler = vi.fn();
|
|
58
|
+
on.Customer.updated(handler);
|
|
59
|
+
await send('Customer.created', { id: '123' });
|
|
60
|
+
expect(handler).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
it('should not call handlers for different nouns', async () => {
|
|
63
|
+
const handler = vi.fn();
|
|
64
|
+
on.Order.created(handler);
|
|
65
|
+
await send('Customer.created', { id: '123' });
|
|
66
|
+
expect(handler).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
it('should handle async handlers', async () => {
|
|
69
|
+
let completed = false;
|
|
70
|
+
on.Customer.created(async () => {
|
|
71
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
72
|
+
completed = true;
|
|
73
|
+
});
|
|
74
|
+
await send('Customer.created', { id: '123' });
|
|
75
|
+
expect(completed).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
it('should continue with other handlers if one throws', async () => {
|
|
78
|
+
const handler1 = vi.fn().mockRejectedValue(new Error('Handler 1 failed'));
|
|
79
|
+
const handler2 = vi.fn();
|
|
80
|
+
on.Customer.created(handler1);
|
|
81
|
+
on.Customer.created(handler2);
|
|
82
|
+
// Should not throw
|
|
83
|
+
await expect(send('Customer.created', { id: '123' })).resolves.not.toThrow();
|
|
84
|
+
// Both handlers should be called
|
|
85
|
+
expect(handler1).toHaveBeenCalledTimes(1);
|
|
86
|
+
expect(handler2).toHaveBeenCalledTimes(1);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|