diff --git a/.cspell.json b/.cspell.json index 873de07..ad5d018 100644 --- a/.cspell.json +++ b/.cspell.json @@ -1,6 +1,6 @@ { "version": "0.1", "import": ["@generalprotocols/cspell-dictionary/cspell.json"], - "words": ["nonfungible", "lockscript", "cashassembly", "checksigverify", "inputindex", "utxovalue"], + "words": ["nonfungible", "lockscript", "cashassembly", "checksigverify", "inputindex", "utxovalue", "bivariance"], "ignorePaths": ["source/template/xo-template.schema.json"] } diff --git a/package-lock.json b/package-lock.json index 581f7e2..1be29c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7034,9 +7034,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -7715,9 +7715,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/source/errors.ts b/source/errors.ts new file mode 100644 index 0000000..35f2a33 --- /dev/null +++ b/source/errors.ts @@ -0,0 +1,9 @@ +/** + * Error thrown when a waitFor timeout is reached + */ +export class WaitForTimeoutError extends Error { + constructor(type: string) { + super(`Timeout waiting for event "${type}"`); + this.name = 'WaitForTimeoutError'; + } +} diff --git a/source/event-emitter.ts b/source/event-emitter.ts new file mode 100644 index 0000000..df43e83 --- /dev/null +++ b/source/event-emitter.ts @@ -0,0 +1,278 @@ +import type { DeeplyReadonly } from './types.ts'; +import type { deepFreeze } from './misc.ts'; + +import { WaitForTimeoutError } from './errors.ts'; + +export type EventMap = Record; + +type Listener = (detail: T) => void; + +/** + * Internally permits listeners for individual event payloads to be stored + * in a collection typed with the union of all event payloads. + */ +type StoredListener = { + bivarianceHack(detail: T): void; +}['bivarianceHack']; + +/** + * A listener entry. + * @template T - The event payload type. + */ +interface ListenerEntry { + listener: StoredListener; + wrappedListener: StoredListener; + cancel: () => void; +} + +/** + * Callback returned by {@link on} and {@link once} for removing a listener. + */ +export type OffCallback = () => void; + +/** + * A simple event emitter implementation. + * @template T - The event map type. + */ +export class EventEmitter { + /** + * The listeners map. + * @private + */ + #listeners: Map>> = new Map(); + + /** + * Add a listener for an event. + * @param type - The event type. + * @param listener - The listener function. + * @param debounceMilliseconds - The debounce time in milliseconds. + * @returns An off callback that can be called to stop listening for events. + */ + on(type: K, listener: Listener, debounceMilliseconds: number = 0): OffCallback { + const { cancel, listener: cancellableListener } = this.cancellable(listener); + + // Create a wrapped listener so that the debounce can be applied. + const wrappedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener; + + // If the listeners map does not have the event type, create a new set. + if (!this.#listeners.has(type)) { + this.#listeners.set(type, new Set()); + } + + // Create a listener entry. + const listenerEntry: ListenerEntry = { + listener, + wrappedListener, + cancel, + }; + + // Add the listener entry to the listeners map. + this.#listeners.get(type)?.add(listenerEntry); + + // Return an "off" callback that can be called to stop listening for events. + return () => this.off(type, listener); + } + + /** + * Add a one-time listener for an event. + * @param type - The event type. + * @param listener - The listener function. + * @param debounceMilliseconds - The debounce time in milliseconds. + * @returns An off callback that can be called to stop listening for events. + */ + once(type: K, listener: Listener, debounceMilliseconds: number = 0): OffCallback { + const wrappedListener: Listener = (detail: T[K]) => { + this.off(type, listener); + listener(detail); + }; + + // Create a cancellable listener. + const { cancel, listener: cancellableListener } = this.cancellable(wrappedListener); + + // Create a debounced listener. + const debouncedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener; + + // If the listeners map does not have the event type, create a new set. + if (!this.#listeners.has(type)) { + this.#listeners.set(type, new Set()); + } + + // Create a listener entry. + const listenerEntry: ListenerEntry = { + listener, + wrappedListener: debouncedListener, + cancel, + }; + + // Add the listener entry to the listeners map. + this.#listeners.get(type)?.add(listenerEntry); + + // Return an "off" callback that can be called to stop listening for events. + return () => this.off(type, listener); + } + + /** + * Remove a listener for an event. + * @param type - The event type. + * @param listener - The listener function. + */ + off(type: K, listener?: Listener): void { + // Get the listeners for the event type. + const listeners = this.#listeners.get(type); + if (!listeners) return; + + // Find the listener entries (If a listener was provided, only 1 entry will be returned. Otherwise, all entries will be returned). + const listenerEntries = Array.from(listeners).filter((entry) => !listener || entry.listener === listener || entry.wrappedListener === listener); + + // Remove the listener entries from the listeners set. + listenerEntries.forEach((entry) => { + // Set the wrapped listener to a no-op function to prevent it from being called by debounced events after it's been removed. + entry.cancel(); + + // Remove the listener entry from the listeners set. + listeners.delete(entry); + }); + + // If no listener was provided and no listeners are left for the event type, remove the listeners set from the listeners map. + if (!listener || this.#listeners.get(type)?.size === 0) { + this.#listeners.delete(type); + } + } + + /** + * Emit an event. + * + * @remarks The caller is responsible for ensuring the payload suits the intended mutability requirements. + * By default, the payload will be mutable, so listeners may mutate the payload, effecting both + * the original object and the other listeners. + * To prevent this, the caller can use the {@link Object.freeze} or {@link deepFreeze} function to freeze the payload. + * This will need to be defined in the EventMap using the built-in {@link Readonly} type or the provided {@link DeeplyReadonly} type. + * + * @param type - The event type. + * @param payload - The event payload. + * @returns True if there are listeners for the event, false otherwise. + */ + emit(type: K, payload: T[K]): boolean { + // Get the listeners for the event type. + const listeners = this.#listeners.get(type); + if (!listeners) return false; + + // Emit the event to all listeners. + listeners.forEach((entry) => { + try { + entry.wrappedListener(payload); + } catch (error) { + console.error(error); + } + }); + + // Return true if there are listeners for the event, false otherwise. + return listeners.size > 0; + } + + /** + * Remove all listeners. + */ + removeAllListeners(): void { + for (const [ type, listeners ] of this.#listeners.entries()) { + listeners.forEach((entry) => { + this.off(type, entry.listener); + }); + } + } + + /** + * Wait for an event to be emitted that matches the provided predicate function's criteria. + * @param type - The event type. + * @param predicate - Predicate function to filter for whether the event payload matches the criteria. + * @param timeoutMs - The timeout in milliseconds. + * @returns The event payload. + */ + async waitFor(type: K, predicate: (payload: T[K]) => boolean, timeoutMs?: number): Promise { + // Create a promise to wait for the event to be emitted. + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | undefined; + + // Create a cleanup function to remove the listener and clear the timeout if it is still pending. + const cleanup = (listener: Listener): void => { + // Remove the listener from the listeners map. + this.off(type, listener); + + // Clear the timeout if it is still pending. + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + }; + + // Create a listener function. + const listener = (payload: T[K]): void => { + try { + // If the event payload does not match the predicate condition, return. + if (!predicate(payload)) { + return; + } + + cleanup(listener); + resolve(payload); + } catch (error) { + cleanup(listener); + reject(error); + } + }; + + // Set up timeout if specified + if (timeoutMs !== undefined) { + timeoutId = setTimeout(() => { + this.off(type, listener); + reject(new WaitForTimeoutError(String(type))); + }, timeoutMs); + } + + // Add the listener to the listeners map. + this.on(type, listener); + }); + } + + /** + * Debounce a function. + * + * @remarks If {@link off} is called on a listener while it is debounced, the timeout is not cleared with clearTimeout. + * Instead, the function is no-oped. + * + * @param func - The function to debounce. + * @param wait - The wait time in milliseconds. + * @returns The debounced function. + */ + private debounce(func: Listener, wait: number): Listener { + // Create a timeout variable. + let timeout: ReturnType; + + return (detail: T[K]) => { + // If a debounce timer is already pending, clear it before scheduling the next one. + if (timeout !== undefined) { + clearTimeout(timeout); + } + + timeout = setTimeout(() => { + func(detail); + }, wait); + }; + } + + /** + * Make a function cancellable. + * @param func - The function to make cancelable. + * @returns The cancellable function with a cancel method. + */ + private cancellable(func: Listener): { cancel: () => void; listener: Listener } { + let cancelled = false; + + return { + cancel: (): boolean => (cancelled = true), + listener: (detail: T[K]): void => { + if (cancelled) return; + func(detail); + }, + }; + } +} diff --git a/source/index.ts b/source/index.ts index ca888d0..8cc874b 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1,3 +1,4 @@ +export * from './event-emitter.ts'; export * from './extended-json.ts'; export * from './script.ts'; export * from './sse-session/index.ts'; diff --git a/source/misc.ts b/source/misc.ts new file mode 100644 index 0000000..87aff34 --- /dev/null +++ b/source/misc.ts @@ -0,0 +1,22 @@ +import type { DeeplyReadonly } from './types'; + +/** + * Recursively freezes an object by iterating over all properties and freezing them. + * @param obj - The object to freeze. + * @returns The frozen object. + */ +export const deepFreeze = (value: T): DeeplyReadonly => { + if (value !== null && (typeof value === 'object' || typeof value === 'function')) { + for (const key of Reflect.ownKeys(value)) { + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + + if (descriptor && 'value' in descriptor) { + deepFreeze(descriptor.value); + } + } + + Object.freeze(value); + } + + return value; +}; diff --git a/source/types.ts b/source/types.ts new file mode 100644 index 0000000..e1408bd --- /dev/null +++ b/source/types.ts @@ -0,0 +1,8 @@ +/** + * A deeply readonly type. + * @template T - The type to make deeply readonly. + * @returns The deeply readonly type. + */ +export type DeeplyReadonly = { + readonly [K in keyof T]: T[K] extends (...args: never[]) => unknown ? T[K] : DeeplyReadonly; +}; diff --git a/test/event-emitter.test.ts b/test/event-emitter.test.ts new file mode 100644 index 0000000..0f09bd8 --- /dev/null +++ b/test/event-emitter.test.ts @@ -0,0 +1,819 @@ +import { expect, test, vi } from 'vitest'; +import { EventEmitter } from '../source/event-emitter.ts'; + +import type { DeeplyReadonly } from '../source/types.ts'; +import { deepFreeze } from '../source/misc.ts'; + +/** Simple event map used across these tests. */ +type TestEvents = { + message: string; + count: number; + readonly: Readonly<{ value: number }>; + deeplyReadonly: DeeplyReadonly<{ nested: { value: number } }>; +}; + +/** + * Tests that EventEmitter invokes listeners when an event is emitted. + */ +const testEventEmitterCallsListeners = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + // Register the listener and emit an event. + emitter.on('message', listener); + emitter.emit('message', 'hello'); + + // Expect the listener to have been called with the emitted payload. + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith('hello'); +}; + +/** + * Tests that EventEmitter invokes all listeners registered for the same event. + */ +const testEventEmitterCallsMultipleListeners = (): void => { + const emitter = new EventEmitter(); + const firstListener = vi.fn(); + const secondListener = vi.fn(); + + // Register two listeners for the same event type. + emitter.on('count', firstListener); + emitter.on('count', secondListener); + emitter.emit('count', 42); + + // Expect both listeners to receive the same payload. + expect(firstListener).toHaveBeenCalledOnce(); + expect(firstListener).toHaveBeenCalledWith(42); + expect(secondListener).toHaveBeenCalledOnce(); + expect(secondListener).toHaveBeenCalledWith(42); +}; + +/** + * Tests that EventEmitter only invokes listeners registered for the emitted event type. + */ +const testEventEmitterCallsOnlyMatchingListeners = (): void => { + const emitter = new EventEmitter(); + const messageListener = vi.fn(); + const countListener = vi.fn(); + + // Register listeners on different event types. + emitter.on('message', messageListener); + emitter.on('count', countListener); + + // Emit only the message event. + emitter.emit('message', 'hello'); + + // Expect only the matching listener to have been called. + expect(messageListener).toHaveBeenCalledOnce(); + expect(countListener).not.toHaveBeenCalled(); +}; + +/** + * Tests that EventEmitter.emit returns false when no listeners are registered. + */ +const testEventEmitterEmitReturnsFalseWithNoListeners = (): void => { + const emitter = new EventEmitter(); + + const hasListeners = emitter.emit('message', 'hello'); + + // Expect emit to report that nobody was listening. + expect(hasListeners).toBe(false); +}; + +/** + * Tests that EventEmitter.emit returns true when listeners are registered. + */ +const testEventEmitterEmitReturnsTrueWithListeners = (): void => { + const emitter = new EventEmitter(); + + emitter.on('message', vi.fn()); + + const hasListeners = emitter.emit('message', 'hello'); + + // Expect emit to report that at least one listener was invoked. + expect(hasListeners).toBe(true); +}; + +/** + * Tests that EventEmitter.emit continues after a listener throws an error. + */ +const testEventEmitterEmitContinuesAfterListenerThrows = (): void => { + const emitter = new EventEmitter(); + const secondListener = vi.fn(); + + emitter.on('message', (): void => { + throw new Error('listener failure'); + }); + emitter.on('message', secondListener); + + emitter.emit('message', 'hello'); + + expect(secondListener).toHaveBeenCalledOnce(); + expect(secondListener).toHaveBeenCalledWith('hello'); +}; + +/** + * Tests that it supports Object.freeze() frozen payloads. + */ +const testEventEmitterSupportsObjectFreezeFrozenPayloads = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + const payload = Object.freeze({ value: 42 }); + + emitter.on('readonly', listener, 100); + emitter.emit('readonly', payload); + + // @ts-expect-error - We are trying to mutate a frozen payload. + expect(() => (payload.value = 43)).toThrow(); + + // Advance the timer to ensure the debounce timer goes off + await vi.advanceTimersByTimeAsync(100); + + // Expect the listener to have been called with the frozen payload. + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(payload); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that it supports deepFreeze() frozen payloads. + */ +const testEventEmitterSupportsDeepFreezeFrozenPayloads = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + const payload = deepFreeze({ nested: { value: 42 } }); + + emitter.on('deeplyReadonly', listener, 100); + emitter.emit('deeplyReadonly', payload); + + // @ts-expect-error - We are trying to mutate a frozen payload. + expect(() => (payload.nested.value = 43)).toThrow(); + + // Advance the timer to ensure the debounce timer goes off + await vi.advanceTimersByTimeAsync(100); + + // Expect the listener to have been called with the frozen payload. + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(payload); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that the off callback returned by on() removes the listener. + */ +const testEventEmitterOffCallbackRemovesListener = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + // on() returns an off callback that removes the listener. + const off = emitter.on('message', listener); + emitter.emit('message', 'first'); + + // Unsubscribe before emitting again. + off(); + emitter.emit('message', 'second'); + + // Expect the listener to have only received the first event. + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith('first'); +}; + +/** + * Tests that off() removes a listener when given the same function reference. + */ +const testEventEmitterOffRemovesListenerByReference = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.on('message', listener); + emitter.off('message', listener); + emitter.emit('message', 'hello'); + + // Expect the listener to have been removed before the emit. + expect(listener).not.toHaveBeenCalled(); +}; + +/** + * Tests that off() removes all listeners for an event type when no listener is provided. + */ +const testEventEmitterOffRemovesAllListenersForEventType = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.on('message', listener); + emitter.off('message'); + + expect(listener).not.toHaveBeenCalled(); + expect(emitter.emit('message', 'hello')).toBe(false); + expect(emitter.emit('count', 42)).toBe(false); +}; + +/** + * Tests that off() does nothing when given an unknown listener reference. + */ +const testEventEmitterOffIgnoresUnknownListener = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.on('message', listener); + + // Try to remove a different function reference. + emitter.off('message', vi.fn()); + emitter.emit('message', 'hello'); + + // Expect the original listener to still receive the event. + expect(listener).toHaveBeenCalledOnce(); +}; + +/** + * Tests that off() does nothing when called for an event type with no listeners. + */ +const testEventEmitterOffIgnoresUnregisteredEventType = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + // Call off without ever registering this listener. + emitter.off('message', listener); + + expect(listener).not.toHaveBeenCalled(); +}; + +/** + * Tests that once() listeners are invoked only one time. + */ +const testEventEmitterOnceListenerFiresOnce = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.once('message', listener); + emitter.emit('message', 'first'); + emitter.emit('message', 'second'); + + // Expect the listener to auto-unsubscribe after the first emit. + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith('first'); +}; + +/** + * Tests that once() can be added when regular listeners already exist for the event type. + */ +const testEventEmitterOnceWorksWithExistingListeners = (): void => { + const emitter = new EventEmitter(); + const existingListener = vi.fn(); + const onceListener = vi.fn(); + + // Register a regular listener first so the event type already exists in the map. + emitter.on('message', existingListener); + emitter.once('message', onceListener); + emitter.emit('message', 'hello'); + + expect(existingListener).toHaveBeenCalledOnce(); + expect(onceListener).toHaveBeenCalledOnce(); +}; + +/** + * Tests that the off callback returned by once() removes the listener before it fires. + */ +const testEventEmitterOnceOffCallbackRemovesListener = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + const off = emitter.once('message', listener); + + // Unsubscribe before the event is ever emitted. + off(); + emitter.emit('message', 'hello'); + + expect(listener).not.toHaveBeenCalled(); +}; + +/** + * Tests that debounced listeners do not receive the debounced event if the listener is removed. + */ +const testEventEmitterOffCancelsPendingDebouncedCallback = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + const off = emitter.on('message', listener, 100); + emitter.emit('message', 'first'); + + off(); + emitter.emit('message', 'second'); + await vi.advanceTimersByTimeAsync(100); + + expect(listener).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that removeAllListeners() clears every registered listener. + */ +const testEventEmitterRemoveAllListeners = (): void => { + const emitter = new EventEmitter(); + const messageListener = vi.fn(); + const countListener = vi.fn(); + + emitter.on('message', messageListener); + emitter.on('count', countListener); + emitter.removeAllListeners(); + + // Emit on both event types after clearing all listeners. + emitter.emit('message', 'hello'); + emitter.emit('count', 1); + + expect(messageListener).not.toHaveBeenCalled(); + expect(countListener).not.toHaveBeenCalled(); +}; + +/** + * Tests that removeAllListeners() cancels a pending debounced callback. + */ +const testEventEmitterRemoveAllListenersCancelsPendingDebouncedCallback = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + // Arm a debounce timer, then clear every listener before it expires. + emitter.on('message', listener, 100); + emitter.emit('message', 'should not arrive'); + emitter.removeAllListeners(); + + await vi.advanceTimersByTimeAsync(100); + + // Cleared listeners must not receive delayed debounced delivery. + expect(listener).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that timeouts are eventually cleared when removeAllListeners is called. + */ +const testEventEmitterTimeoutsAreClearedAfterTheyRunWhenRemoveAllListenersIsCalled = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.on('message', listener, 100); + emitter.emit('message', 'pending'); + + // We expect there to be a timer since we emitted a debounced event + expect(vi.getTimerCount()).toBe(1); + + // Remove all listeners + emitter.removeAllListeners(); + + // Advance the timer to ensure the debounce timer is cleared. + await vi.advanceTimersByTimeAsync(100); + + // Make sure the listener was not called + expect(listener).not.toHaveBeenCalled(); + + // Make sure the timer was cleared + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that timeouts are eventually cleared when off is called. + */ +const testEventEmitterTimeoutsAreClearedWhenOffIsCalled = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + const off = emitter.on('message', listener, 100); + emitter.emit('message', 'pending'); + + // We expect there to be a timer since we emitted a debounced event + expect(vi.getTimerCount()).toBe(1); + + // Cancel the listener + off(); + + // Advance the timer to ensure the debounce timer is cleared. + await vi.advanceTimersByTimeAsync(100); + + // Make sure the listener was not called + expect(listener).not.toHaveBeenCalled(); + + // Make sure the timer was cleared + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that waitFor() resolves when a matching event is emitted. + */ +const testEventEmitterWaitForResolvesOnMatch = async (): Promise => { + const emitter = new EventEmitter(); + + // Wait until an event matches the predicate. + const waitPromise = emitter.waitFor('count', (payload) => payload === 42); + + // Emit a non-matching event first, then the matching one. + emitter.emit('count', 41); + emitter.emit('count', 42); + + await expect(waitPromise).resolves.toBe(42); +}; + +/** + * Tests that waitFor() ignores non-matching events while other listeners still receive them. + */ +const testEventEmitterWaitForIgnoresNonMatchingEvents = async (): Promise => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + const waitPromise = emitter.waitFor('message', (payload) => payload === 'done'); + + // A regular listener should still receive every emit while waitFor filters. + emitter.on('message', listener); + emitter.emit('message', 'pending'); + emitter.emit('message', 'done'); + + await expect(waitPromise).resolves.toBe('done'); + expect(listener).toHaveBeenCalledTimes(2); +}; + +/** + * Tests that waitFor() rejects when the timeout expires. + */ +const testEventEmitterWaitForRejectsOnTimeout = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + + const waitPromise = emitter.waitFor('message', () => true, 100); + + // Attach the rejection handler before advancing timers so the rejection is handled. + const assertion = expect(waitPromise).rejects.toThrow('Timeout waiting for event "message"'); + + await vi.advanceTimersByTimeAsync(100); + + await assertion; + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that waitFor() clears its timeout when it resolves before expiry. + */ +const testEventEmitterWaitForClearsTimeoutOnResolve = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + + // Register waitFor with a timeout, then resolve it before the timer fires. + const waitPromise = emitter.waitFor('message', (payload) => payload === 'done', 100); + + emitter.emit('message', 'done'); + + await expect(waitPromise).resolves.toBe('done'); + + // If clearTimeout was not called, advancing past the timeout would reject the promise. + await vi.advanceTimersByTimeAsync(100); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that waitFor() removes its listener after resolving. + */ +const testEventEmitterWaitForRemovesListenerAfterResolve = async (): Promise => { + const emitter = new EventEmitter(); + + const waitPromise = emitter.waitFor('message', () => true); + + emitter.emit('message', 'first'); + await expect(waitPromise).resolves.toBe('first'); + + // Register a second waitFor so we can verify the first listener was cleaned up. + const secondWaitPromise = emitter.waitFor('message', (payload) => payload === 'second'); + + // Emit a payload that only the second waitFor should accept. + emitter.emit('message', 'ignored'); + + // Track whether the second waitFor resolves too early. + let resolvedEarly = false; + /* eslint-disable-next-line */ + secondWaitPromise.then(() => { + resolvedEarly = true; + }); + + // Yield so any premature resolution would have a chance to run. + await Promise.resolve(); + expect(resolvedEarly).toBe(false); + + emitter.emit('message', 'second'); + await expect(secondWaitPromise).resolves.toBe('second'); +}; + +/** + * Tests that the first debounced emit does not call clearTimeout. + */ +const testEventEmitterDebouncedFirstEmitDoesNotClearTimeout = (): void => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); + const listener = vi.fn(); + + emitter.on('message', listener, 100); + emitter.emit('message', 'first'); + + // The first emit starts the debounce timer; there is nothing to clear yet. + expect(clearTimeoutSpy).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that debounced on() listeners receive only the last payload after the debounce window. + */ +const testEventEmitterDebouncedOnListener = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.on('message', listener, 100); + + // Emit several events in quick succession. + emitter.emit('message', 'first'); + emitter.emit('message', 'second'); + emitter.emit('message', 'third'); + + // Expect the listener to not have fired yet. + expect(listener).not.toHaveBeenCalled(); + + // Advance past the debounce window. + await vi.advanceTimersByTimeAsync(100); + + // Expect only the last payload to have been delivered. + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith('third'); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that repeated debounced emits reset the debounce timer. + */ +const testEventEmitterDebouncedTimerResetsOnRepeatedEmits = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.on('count', listener, 100); + emitter.emit('count', 1); + + // Advance halfway through the debounce window and emit again. + await vi.advanceTimersByTimeAsync(50); + emitter.emit('count', 2); + await vi.advanceTimersByTimeAsync(50); + + // The timer was reset, so the listener should not have fired yet. + expect(listener).not.toHaveBeenCalled(); + + // Advance the remaining time for the reset timer to expire. + await vi.advanceTimersByTimeAsync(50); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(2); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that a debounce time of zero behaves like a normal listener. + */ +const testEventEmitterZeroDebounceDoesNotDebounce = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + // A debounce time of zero should behave like a normal listener. + emitter.on('message', listener, 0); + emitter.emit('message', 'first'); + emitter.emit('message', 'second'); + + expect(listener).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that debounced once() listeners fire once with the last payload. + */ +const testEventEmitterDebouncedOnceListener = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.once('message', listener, 100); + emitter.emit('message', 'first'); + emitter.emit('message', 'second'); + + await vi.advanceTimersByTimeAsync(100); + + // Expect the debounced once listener to fire once with the last payload. + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith('second'); + + // Emit again after the debounce window; the once listener should stay removed. + emitter.emit('message', 'third'); + await vi.advanceTimersByTimeAsync(100); + + expect(listener).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that the `waitFor` method rejects if the predicate function throws + */ +const testEventEmitterWaitForRejectsOnPredicateError = async (): Promise => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + const waitPromise = emitter.waitFor('message', () => { + throw new Error('predicate error'); + }); + + emitter.emit('message', 'hello'); + + await expect(waitPromise).rejects.toThrow('predicate error'); + expect(listener).not.toHaveBeenCalled(); +}; + +/** + * Tests that waitFor() removes its listener after the predicate function throws an error. + */ +const testEventEmitterWaitForRemovesListenerAfterPredicateError = async (): Promise => { + const emitter = new EventEmitter(); + + // Create a predicate function that throws an error. + const predicate = vi.fn().mockImplementation(() => { + throw new Error('predicate error'); + }); + + // Wait for the predicate function to throw an error. + const waitPromise = emitter.waitFor('message', predicate); + + // Emit an event, expecting the predicate function to throw an error. + emitter.emit('message', 'hello'); + await expect(waitPromise).rejects.toThrow('predicate error'); + + // Expect the predicate function to have been called once. + expect(predicate).toHaveBeenCalledTimes(1); + + // A later emit must not invoke the failed waitFor predicate again. + emitter.emit('message', 'again'); + + // Expect the predicate function to still have been called once. + expect(predicate).toHaveBeenCalledTimes(1); +}; + +/** + * Tests that waitFor() clears its timeout when the predicate function throws an error. + */ +const testEventEmitterWaitForClearsTimeoutAfterPredicateError = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); + + const waitPromise = emitter.waitFor( + 'message', + (): boolean => { + throw new Error('predicate error'); + }, + 100, + ); + + emitter.emit('message', 'hello'); + await expect(waitPromise).rejects.toThrow('predicate error'); + + // The timeout scheduled for waitFor must be cleared on predicate failure. + expect(clearTimeoutSpy).toHaveBeenCalled(); + + // Advancing past the original timeout must not produce a second rejection path. + await vi.advanceTimersByTimeAsync(100); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +const runTests = async (): Promise => { + test('EventEmitter: calls listeners when an event is emitted', testEventEmitterCallsListeners); + test('EventEmitter: calls multiple listeners for the same event', testEventEmitterCallsMultipleListeners); + test('EventEmitter: only calls listeners for the emitted event type', testEventEmitterCallsOnlyMatchingListeners); + test('EventEmitter: returns false when emitting with no listeners', testEventEmitterEmitReturnsFalseWithNoListeners); + test('EventEmitter: returns true when emitting with listeners', testEventEmitterEmitReturnsTrueWithListeners); + test('EventEmitter: continues after a listener throws an error', testEventEmitterEmitContinuesAfterListenerThrows); + test('EventEmitter: supports Object.freeze() frozen payloads', testEventEmitterSupportsObjectFreezeFrozenPayloads); + test('EventEmitter: supports deepFreeze() frozen payloads', testEventEmitterSupportsDeepFreezeFrozenPayloads); + test('EventEmitter: stops calling a listener after its off callback is invoked', testEventEmitterOffCallbackRemovesListener); + test('EventEmitter: removes a listener when off is called with the same reference', testEventEmitterOffRemovesListenerByReference); + test( + 'EventEmitter: removes all listeners for an event type when off is called with no listener', + testEventEmitterOffRemovesAllListenersForEventType, + ); + test('EventEmitter: ignores off when the listener reference is unknown', testEventEmitterOffIgnoresUnknownListener); + test('EventEmitter: ignores off for an event type with no listeners', testEventEmitterOffIgnoresUnregisteredEventType); + test('EventEmitter: calls a once listener only one time', testEventEmitterOnceListenerFiresOnce); + test('EventEmitter: registers once when listeners already exist', testEventEmitterOnceWorksWithExistingListeners); + test('EventEmitter: stops a once listener after its off callback is invoked', testEventEmitterOnceOffCallbackRemovesListener); + test( + 'EventEmitter: debounced listeners do not receive the debounced event if the listener is removed', + testEventEmitterOffCancelsPendingDebouncedCallback, + ); + test('EventEmitter: removes all listeners when removeAllListeners is called', testEventEmitterRemoveAllListeners); + test( + 'EventEmitter: cancels a pending debounced callback when removeAllListeners is called', + testEventEmitterRemoveAllListenersCancelsPendingDebouncedCallback, + ); + test( + 'EventEmitter: timeouts are cleared after they run when removeAllListeners is called', + testEventEmitterTimeoutsAreClearedAfterTheyRunWhenRemoveAllListenersIsCalled, + ); + test('EventEmitter: timeouts are cleared when off is called', testEventEmitterTimeoutsAreClearedWhenOffIsCalled); + test('EventEmitter: resolves waitFor when a matching event is emitted', testEventEmitterWaitForResolvesOnMatch); + test('EventEmitter: ignores non-matching events while waiting with waitFor', testEventEmitterWaitForIgnoresNonMatchingEvents); + test('EventEmitter: rejects waitFor when the timeout is reached', testEventEmitterWaitForRejectsOnTimeout); + test('EventEmitter: clears the timeout when waitFor resolves before expiry', testEventEmitterWaitForClearsTimeoutOnResolve); + test('EventEmitter: removes the waitFor listener after it resolves', testEventEmitterWaitForRemovesListenerAfterResolve); + test('EventEmitter: does not clear a timeout on the first debounced emit', testEventEmitterDebouncedFirstEmitDoesNotClearTimeout); + test('EventEmitter: debounces on listeners', testEventEmitterDebouncedOnListener); + test('EventEmitter: resets the debounce timer on repeated emits', testEventEmitterDebouncedTimerResetsOnRepeatedEmits); + test('EventEmitter: does not debounce when debounceMilliseconds is zero', testEventEmitterZeroDebounceDoesNotDebounce); + test('EventEmitter: debounces once listeners and invokes them only once', testEventEmitterDebouncedOnceListener); + test('EventEmitter: rejects waitFor when the predicate function throws', testEventEmitterWaitForRejectsOnPredicateError); + test( + 'EventEmitter: removes the waitFor listener after it rejects due to predicate error', + testEventEmitterWaitForRemovesListenerAfterPredicateError, + ); + test('EventEmitter: clears the timeout when waitFor rejects due to predicate error', testEventEmitterWaitForClearsTimeoutAfterPredicateError); +}; + +await runTests();