Skip to content

Commit 4bb6b1c

Browse files
committed
feat(test-utils): Make event subscriber waiter robust and concurrent
1 parent b116f75 commit 4bb6b1c

1 file changed

Lines changed: 110 additions & 36 deletions

File tree

Lines changed: 110 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,156 @@
1-
import { IEventBusModuleService } from "@medusajs/framework/types"
21
import { EventEmitter } from "events"
32

4-
// Allows you to wait for all subscribers to execute for a given event. Only works with the local event bus.
5-
export const waitSubscribersExecution = (
6-
eventName: string,
7-
eventBus: IEventBusModuleService,
8-
{
9-
timeout = 5000,
10-
}: {
11-
timeout?: number
12-
} = {}
13-
) => {
14-
const eventEmitter: EventEmitter = (eventBus as any).eventEmitter_
15-
const subscriberPromises: Promise<any>[] = []
16-
const originalListeners = eventEmitter.listeners(eventName)
17-
let timeoutId: NodeJS.Timeout | null = null
3+
type EventBus = {
4+
eventEmitter_: EventEmitter
5+
}
6+
7+
type WaitSubscribersExecutionOptions = {
8+
timeout?: number
9+
}
10+
11+
// Map to hold pending promises for each event.
12+
const waits = new Map<string | symbol, Promise<any>>()
1813

19-
// Create a promise that rejects after the timeout
20-
const timeoutPromise = new Promise((_, reject) => {
14+
/**
15+
* Creates a promise that rejects after a specified timeout.
16+
* @param timeout - The timeout in milliseconds.
17+
* @param eventName - The name of the event being waited on.
18+
* @returns A tuple containing the timeout promise and a function to clear the timeout.
19+
*/
20+
const createTimeoutPromise = (
21+
timeout: number,
22+
eventName: string | symbol
23+
): [Promise<never>, () => void] => {
24+
let timeoutId: NodeJS.Timeout | null = null
25+
const promise = new Promise<never>((_, reject) => {
2126
timeoutId = setTimeout(() => {
2227
reject(
2328
new Error(
24-
`Timeout of ${timeout}ms exceeded while waiting for event "${eventName}"`
29+
`Timeout of ${timeout}ms exceeded while waiting for event "${String(
30+
eventName
31+
)}"`
2532
)
2633
)
2734
}, timeout)
2835
timeoutId.unref()
2936
})
37+
return [promise, () => timeoutId && clearTimeout(timeoutId)]
38+
}
39+
40+
// Core logic to wait for subscribers.
41+
const doWaitSubscribersExecution = (
42+
eventName: string | symbol,
43+
eventBus: EventBus,
44+
{ timeout = 15000 }: WaitSubscribersExecutionOptions = {}
45+
): Promise<any> => {
46+
const eventEmitter = eventBus.eventEmitter_
47+
const subscriberPromises: Promise<any>[] = []
48+
const [timeoutPromise, clearTimeout] = createTimeoutPromise(
49+
timeout,
50+
eventName
51+
)
3052

31-
// If there are no existing listeners, resolve once the event happens. Otherwise, wrap the existing subscribers in a promise and resolve once they are done.
3253
if (!eventEmitter.listeners(eventName).length) {
33-
let ok
54+
let ok: (value?: any) => void
3455
const promise = new Promise((resolve) => {
3556
ok = resolve
3657
})
37-
3858
subscriberPromises.push(promise)
39-
eventEmitter.on(eventName, ok)
59+
60+
const newListener = async () => {
61+
eventEmitter.removeListener(eventName, newListener)
62+
ok()
63+
}
64+
65+
Object.defineProperty(newListener, "__isSubscribersExecutionWrapper", {
66+
value: true,
67+
configurable: true,
68+
enumerable: false,
69+
})
70+
71+
eventEmitter.on(eventName, newListener)
4072
} else {
4173
eventEmitter.listeners(eventName).forEach((listener: any) => {
74+
if (listener.__isSubscribersExecutionWrapper) {
75+
return
76+
}
77+
4278
eventEmitter.removeListener(eventName, listener)
4379

44-
let ok, nok
80+
let ok: (value?: any) => void, nok: (reason?: any) => void
4581
const promise = new Promise((resolve, reject) => {
4682
ok = resolve
4783
nok = reject
4884
})
4985
subscriberPromises.push(promise)
5086

51-
const newListener = async (...args2) => {
87+
const newListener = async (...args2: any[]) => {
88+
// As soon as the subscriber is executed, we restore the original listener
89+
eventEmitter.removeListener(eventName, newListener)
90+
let listenerToAdd = listener
91+
while (listenerToAdd.originalListener) {
92+
listenerToAdd = listenerToAdd.originalListener
93+
}
94+
eventEmitter.on(eventName, listenerToAdd)
95+
5296
try {
5397
const res = await listener.apply(eventBus, args2)
54-
5598
ok(res)
56-
57-
return res
5899
} catch (error) {
59100
nok(error)
60101
}
61102
}
62103

104+
Object.defineProperty(newListener, "__isSubscribersExecutionWrapper", {
105+
value: true,
106+
configurable: true,
107+
enumerable: false,
108+
})
109+
Object.defineProperty(newListener, "originalListener", {
110+
value: listener,
111+
configurable: true,
112+
enumerable: false,
113+
})
63114
eventEmitter.on(eventName, newListener)
64115
})
65116
}
66117

67118
const subscribersPromise = Promise.all(subscriberPromises).finally(() => {
68119
// Clear the timeout since events have been fired and handled
69-
if (timeoutId !== null) {
70-
clearTimeout(timeoutId)
71-
}
72-
73-
// Restore original event listeners
74-
eventEmitter.removeAllListeners(eventName)
75-
originalListeners.forEach((listener) => {
76-
eventEmitter.on(eventName, listener as (...args: any) => void)
77-
})
120+
clearTimeout()
78121
})
79122

80123
// Race between the subscribers and the timeout
81124
return Promise.race([subscribersPromise, timeoutPromise])
82125
}
126+
127+
/**
128+
* Allows you to wait for all subscribers to execute for a given event.
129+
* It ensures that concurrent waits for the same event are queued and executed sequentially.
130+
*
131+
* @param eventName - The name of the event to wait for.
132+
* @param eventBus - The event bus instance.
133+
* @param options - Options including timeout.
134+
*/
135+
export const waitSubscribersExecution = (
136+
eventName: string | symbol,
137+
eventBus: EventBus,
138+
options?: WaitSubscribersExecutionOptions
139+
): Promise<any> => {
140+
const chain = waits.get(eventName) || Promise.resolve()
141+
142+
const runner = () => {
143+
return doWaitSubscribersExecution(eventName, eventBus, options)
144+
}
145+
146+
const newPromise = chain
147+
.then(runner)
148+
.catch(runner) // Still execute the runner on error to prevent cascading tests failing because the previous wait failed
149+
.finally(() => {
150+
waits.delete(eventName)
151+
})
152+
153+
waits.set(eventName, newPromise)
154+
155+
return newPromise
156+
}

0 commit comments

Comments
 (0)