diff --git a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index e72549f034..89f2af4a68 100644 --- a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -1,5 +1,5 @@ package io.sentry.react; - +import android.view.Choreographer; import static java.util.concurrent.TimeUnit.SECONDS; import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot; import static io.sentry.vendor.Base64.NO_PADDING; @@ -135,10 +135,16 @@ public class RNSentryModuleImpl { /** Max trace file size in bytes. */ private long maxTraceFileSize = 5 * 1024 * 1024; + + + private final RNSentryTimeToDisplay timeToDisplay; + + public RNSentryModuleImpl(ReactApplicationContext reactApplicationContext) { packageInfo = getPackageInfo(reactApplicationContext); this.reactApplicationContext = reactApplicationContext; this.emitNewFrameEvent = createEmitNewFrameEvent(); + this.timeToDisplay = new RNSentryTimeToDisplay(); } private ReactApplicationContext getReactApplicationContext() { @@ -693,6 +699,10 @@ public void disableNativeFramesTracking() { } } + public void getNewScreenTimeToDisplay(Promise promise) { + timeToDisplay.GetTimeToDisplay(promise); + } + private String getProfilingTracesDirPath() { if (cacheDirPath == null) { cacheDirPath = new File(getReactApplicationContext().getCacheDir(), "sentry/react").getAbsolutePath(); diff --git a/android/src/main/java/io/sentry/react/RNSentryPackage.java b/android/src/main/java/io/sentry/react/RNSentryPackage.java index 8cce37e297..75058c6997 100644 --- a/android/src/main/java/io/sentry/react/RNSentryPackage.java +++ b/android/src/main/java/io/sentry/react/RNSentryPackage.java @@ -20,9 +20,9 @@ public class RNSentryPackage extends TurboReactPackage { @Nullable @Override public NativeModule getModule(String name, ReactApplicationContext reactContext) { - if (name.equals(RNSentryModuleImpl.NAME)) { - return new RNSentryModule(reactContext); - } else { + if (name.equals(RNSentryModuleImpl.NAME)) { + return new RNSentryModule(reactContext); + } else { return null; } } @@ -55,5 +55,4 @@ public List createViewManagers( new RNSentryOnDrawReporterManager(reactContext) ); } - } diff --git a/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java b/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java new file mode 100644 index 0000000000..c8610c20aa --- /dev/null +++ b/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java @@ -0,0 +1,36 @@ +package io.sentry.react; + +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.Promise; +import com.facebook.react.turbomodule.core.interfaces.TurboModule; + +import android.view.Choreographer; + +import androidx.annotation.NonNull; + +import org.jetbrains.annotations.NotNull; +import io.sentry.SentryDate; +import io.sentry.SentryDateProvider; +import io.sentry.android.core.SentryAndroidDateProvider; + + +public class RNSentryTimeToDisplay { + + public void GetTimeToDisplay(Promise promise) { + Choreographer choreographer = Choreographer.getInstance(); + + // Invoke the callback after the frame is rendered + choreographer.postFrameCallback(new Choreographer.FrameCallback() { + @Override + public void doFrame(long frameTimeNanos) { + final @NotNull SentryDateProvider dateProvider = new SentryAndroidDateProvider(); + + final SentryDate endDate = dateProvider.now(); + + promise.resolve(endDate.nanoTimestamp() / 1e9); + } + }); + } +} diff --git a/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/android/src/newarch/java/io/sentry/react/RNSentryModule.java index fd5b902e54..abfa6d7b55 100644 --- a/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -173,4 +173,9 @@ public String getCurrentReplayId() { public void crashedLastRun(Promise promise) { this.impl.crashedLastRun(promise); } + + @Override + public void getNewScreenTimeToDisplay(Promise promise) { + this.impl.getNewScreenTimeToDisplay(promise); + } } diff --git a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 6b135ecb90..d69f330e9f 100644 --- a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -173,4 +173,9 @@ public String getCurrentReplayId() { public void crashedLastRun(Promise promise) { this.impl.crashedLastRun(promise); } + + @ReactMethod() + public void getNewScreenTimeToDisplay(Promise promise) { + this.impl.getNewScreenTimeToDisplay(promise); + } } diff --git a/ios/RNSentry.mm b/ios/RNSentry.mm index 1ff85705f1..05344cd5ab 100644 --- a/ios/RNSentry.mm +++ b/ios/RNSentry.mm @@ -1,5 +1,6 @@ #import #import "RNSentry.h" +#import "RNSentryTimeToDisplay.h" #if __has_include() #import @@ -62,6 +63,7 @@ + (void)storeEnvelope:(SentryEnvelope *)envelope; @implementation RNSentry { bool sentHybridSdkDidBecomeActive; bool hasListeners; + RNSentryTimeToDisplay *_timeToDisplay; } - (dispatch_queue_t)methodQueue @@ -106,6 +108,8 @@ + (BOOL)requiresMainQueueSetup { sentHybridSdkDidBecomeActive = true; } + _timeToDisplay = [[RNSentryTimeToDisplay alloc] init]; + #if SENTRY_TARGET_REPLAY_SUPPORTED [RNSentryReplay postInit]; #endif @@ -776,4 +780,10 @@ - (NSDictionary*) fetchNativeStackFramesBy: (NSArray*)instructionsAdd } #endif +RCT_EXPORT_METHOD(getNewScreenTimeToDisplay:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + [_timeToDisplay getTimeToDisplay:resolve]; +} + + @end diff --git a/ios/RNSentryTimeToDisplay.h b/ios/RNSentryTimeToDisplay.h new file mode 100644 index 0000000000..fbb468cb23 --- /dev/null +++ b/ios/RNSentryTimeToDisplay.h @@ -0,0 +1,7 @@ +#import + +@interface RNSentryTimeToDisplay : NSObject + +- (void)getTimeToDisplay:(RCTResponseSenderBlock)callback; + +@end diff --git a/ios/RNSentryTimeToDisplay.m b/ios/RNSentryTimeToDisplay.m new file mode 100644 index 0000000000..88e24eedc7 --- /dev/null +++ b/ios/RNSentryTimeToDisplay.m @@ -0,0 +1,43 @@ +#import "RNSentryTimeToDisplay.h" +#import +#import + +@implementation RNSentryTimeToDisplay +{ + CADisplayLink *displayLink; + RCTResponseSenderBlock resolveBlock; +} + +// Rename requestAnimationFrame to getTimeToDisplay +- (void)getTimeToDisplay:(RCTResponseSenderBlock)callback +{ + // Store the resolve block to use in the callback. + resolveBlock = callback; + +#if TARGET_OS_IOS + // Create and add a display link to get the callback after the screen is rendered. + displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)]; + [displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; +#else + resolveBlock(@[]); // Return nothing if not iOS. +#endif +} + +#if TARGET_OS_IOS +- (void)handleDisplayLink:(CADisplayLink *)link { + // Get the current time + NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970] * 1000.0; // Convert to milliseconds + + // Ensure the callback is valid and pass the current time back + if (resolveBlock) { + resolveBlock(@[@(currentTime)]); // Call the callback with the current time + resolveBlock = nil; // Clear the block after it's called + } + + // Invalidate the display link to stop future callbacks + [displayLink invalidate]; + displayLink = nil; +} +#endif + +@end diff --git a/src/js/NativeRNSentry.ts b/src/js/NativeRNSentry.ts index a5a7652fba..5b0880517b 100644 --- a/src/js/NativeRNSentry.ts +++ b/src/js/NativeRNSentry.ts @@ -9,6 +9,7 @@ import type { UnsafeObject } from './utils/rnlibrariesinterface'; export interface Spec extends TurboModule { addListener: (eventType: string) => void; removeListeners: (id: number) => void; + getNewScreenTimeToDisplay(): Promise; addBreadcrumb(breadcrumb: UnsafeObject): void; captureEnvelope( bytes: string, @@ -16,6 +17,7 @@ export interface Spec extends TurboModule { hardCrashed: boolean; }, ): Promise; + captureScreenshot(): Promise; clearBreadcrumbs(): void; crash(): void; diff --git a/src/js/tracing/reactnavigation.ts b/src/js/tracing/reactnavigation.ts index 4de2ee5c08..580aa90b8e 100644 --- a/src/js/tracing/reactnavigation.ts +++ b/src/js/tracing/reactnavigation.ts @@ -5,6 +5,7 @@ import { logger, timestampInSeconds } from '@sentry/utils'; import type { NewFrameEvent } from '../utils/sentryeventemitter'; import { type SentryEventEmitter, createSentryEventEmitter, NewFrameEventName } from '../utils/sentryeventemitter'; +import { type SentryEventEmitterFallback, createSentryFallbackEventEmitter } from '../utils/sentryeventemitterfallback'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { NATIVE } from '../wrapper'; import type { OnConfirmRoute, TransactionCreator } from './routingInstrumentation'; @@ -78,7 +79,7 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati private _navigationContainer: NavigationContainer | null = null; private _newScreenFrameEventEmitter: SentryEventEmitter | null = null; - + private _newFallbackEventEmitter: SentryEventEmitterFallback | null = null; private readonly _maxRecentRouteLen: number = 200; private _latestRoute?: NavigationRoute; @@ -101,7 +102,9 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati if (this._options.enableTimeToInitialDisplay) { this._newScreenFrameEventEmitter = createSentryEventEmitter(); + this._newFallbackEventEmitter = createSentryFallbackEventEmitter(); this._newScreenFrameEventEmitter.initAsync(NewFrameEventName); + this._newFallbackEventEmitter.initAsync(); NATIVE.initNativeReactNavigationNewFrameTracking().catch((reason: unknown) => { logger.error(`[ReactNavigationInstrumentation] Failed to initialize native new frame tracking: ${reason}`); }); @@ -247,8 +250,8 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati isAutoInstrumented: true, }); - !routeHasBeenSeen && - latestTtidSpan && + if (!routeHasBeenSeen && latestTtidSpan) { + this._newFallbackEventEmitter?.startListenerAsync(); this._newScreenFrameEventEmitter?.once( NewFrameEventName, ({ newFrameTimestampInSeconds }: NewFrameEvent) => { @@ -265,6 +268,7 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati setSpanDurationAsMeasurementOnTransaction(latestTransaction, 'time_to_initial_display', latestTtidSpan); }, ); + } this._navigationProcessingSpan?.updateName(`Processing navigation to ${route.name}`); this._navigationProcessingSpan?.setStatus('ok'); diff --git a/src/js/utils/sentryeventemitterfallback.ts b/src/js/utils/sentryeventemitterfallback.ts new file mode 100644 index 0000000000..d7838f86fd --- /dev/null +++ b/src/js/utils/sentryeventemitterfallback.ts @@ -0,0 +1,103 @@ +import { logger } from '@sentry/utils'; +import type { EmitterSubscription } from 'react-native'; +import { DeviceEventEmitter } from 'react-native'; + +import { NATIVE } from '../wrapper'; +import { NewFrameEventName } from './sentryeventemitter'; + +export type FallBackNewFrameEvent = { newFrameTimestampInSeconds: number; isFallback?: boolean }; +export interface SentryEventEmitterFallback { + /** + * Initializes the fallback event emitter + * This method is synchronous in JS but the event emitter starts asynchronously. + */ + initAsync: () => void; + closeAll: () => void; + startListenerAsync: () => void; +} + +function timeNowNanosecond(): number { + return Date.now() / 1000; // Convert to nanoseconds +} + +/** + * Creates emitter that allows to listen to UI Frame events when ready. + */ +export function createSentryFallbackEventEmitter(): SentryEventEmitterFallback { + let NativeEmitterCalled: boolean = false; + let subscription: EmitterSubscription | undefined = undefined; + let isListening = false; + + function defaultFallbackEventEmitter(): void { + // Schedule the callback to be executed when all UI Frames have flushed. + requestAnimationFrame(() => { + if (NativeEmitterCalled) { + NativeEmitterCalled = false; + isListening = false; + return; + } + const timestampInSeconds = timeNowNanosecond(); + waitForNativeResponseOrFallback(timestampInSeconds, 'JavaScript'); + }); + } + + function waitForNativeResponseOrFallback(fallbackSeconds: number, origin: string): void { + const maxRetries = 3; + let retries = 0; + + const retryCheck = (): void => { + if (NativeEmitterCalled) { + NativeEmitterCalled = false; + isListening = false; + return; // Native Replied the bridge with a timestamp. + } + + retries++; + if (retries < maxRetries) { + setTimeout(retryCheck, 1_000); + } else { + logger.log(`[Sentry] Native event emitter did not reply in time. Using ${origin} fallback emitter.`); + isListening = false; + DeviceEventEmitter.emit(NewFrameEventName, { + newFrameTimestampInSeconds: fallbackSeconds, + isFallback: true, + }); + } + }; + + // Start the retry process + retryCheck(); + } + + return { + initAsync() { + subscription = DeviceEventEmitter.addListener(NewFrameEventName, () => { + // Avoid noise from pages that we do not want to track. + if (isListening) { + NativeEmitterCalled = true; + } + }); + }, + + startListenerAsync() { + isListening = true; + + NATIVE.getNewScreenTimeToDisplay() + .then(resolve => { + if (resolve) { + waitForNativeResponseOrFallback(resolve, 'Native'); + } else { + defaultFallbackEventEmitter(); + } + }) + .catch((reason: Error) => { + logger.error('Failed to recceive Native fallback timestamp.', reason); + defaultFallbackEventEmitter(); + }); + }, + + closeAll() { + subscription?.remove(); + }, + }; +} diff --git a/src/js/wrapper.ts b/src/js/wrapper.ts index ed1bd7d1f0..84c90f8b20 100644 --- a/src/js/wrapper.ts +++ b/src/js/wrapper.ts @@ -114,6 +114,7 @@ interface SentryNativeWrapper { getCurrentReplayId(): string | null; crashedLastRun(): Promise; + getNewScreenTimeToDisplay(): Promise; } const EOL = utf8ToBytes('\n'); @@ -656,6 +657,14 @@ export const NATIVE: SentryNativeWrapper = { return typeof result === 'boolean' ? result : null; }, + getNewScreenTimeToDisplay(): Promise { + if (!this.enableNative || !this._isModuleLoaded(RNSentry)) { + return Promise.resolve(null); + } + + return RNSentry.getNewScreenTimeToDisplay(); + }, + /** * Gets the event from envelopeItem and applies the level filter to the selected event. * @param data An envelope item containing the event. diff --git a/test/mockWrapper.ts b/test/mockWrapper.ts index 2d22458a61..02cb6a6755 100644 --- a/test/mockWrapper.ts +++ b/test/mockWrapper.ts @@ -55,6 +55,7 @@ const NATIVE: MockInterface = { initNativeReactNavigationNewFrameTracking: jest.fn(), crashedLastRun: jest.fn(), + getNewScreenTimeToDisplay: jest.fn().mockResolvedValue(42), }; NATIVE.isNativeAvailable.mockReturnValue(true); diff --git a/test/utils/sentryeventemitterfallback.test.ts b/test/utils/sentryeventemitterfallback.test.ts new file mode 100644 index 0000000000..88a2685dde --- /dev/null +++ b/test/utils/sentryeventemitterfallback.test.ts @@ -0,0 +1,241 @@ +import { logger } from '@sentry/utils'; +import { DeviceEventEmitter } from 'react-native'; + +import { NewFrameEventName } from '../../src/js/utils/sentryeventemitter'; +import { createSentryFallbackEventEmitter } from '../../src/js/utils/sentryeventemitterfallback'; + +// Mock dependencies + +jest.mock('react-native', () => { + return { + DeviceEventEmitter: { + addListener: jest.fn(), + emit: jest.fn(), + }, + Platform: { + OS: 'ios', + }, + }; +}); + +jest.mock('../../src/js/utils/environment', () => ({ + isTurboModuleEnabled: () => false, +})); + +jest.mock('../../src/js/wrapper', () => jest.requireActual('../mockWrapper')); + +jest.mock('@sentry/utils', () => ({ + logger: { + log: jest.fn(), + error: jest.fn(), + }, +})); + +import { NATIVE } from '../../src/js/wrapper'; + +describe('SentryEventEmitterFallback', () => { + let emitter: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + // @ts-expect-error test + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb()); + emitter = createSentryFallbackEventEmitter(); + NATIVE.getNewScreenTimeToDisplay = jest.fn(() => Promise.resolve(12345)); + }); + + afterEach(() => { + // @ts-expect-error test + window.requestAnimationFrame.mockRestore(); + NATIVE.getNewScreenTimeToDisplay = jest.fn(); + }); + + it('should initialize and add a listener', () => { + emitter.initAsync(); + + expect(DeviceEventEmitter.addListener).toHaveBeenCalledWith(NewFrameEventName, expect.any(Function)); + }); + + it('should start listener and use fallback when native call returned undefined/null', async () => { + jest.useFakeTimers(); + const fallbackTime = Date.now() / 1000; + + const animation = NATIVE.getNewScreenTimeToDisplay as jest.Mock; + animation.mockReturnValue(Promise.resolve()); + + emitter.startListenerAsync(); + await animation; // wait for the Native execution to be completed. + + await expect(NATIVE.getNewScreenTimeToDisplay).toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalledWith('Failed to recceive Native fallback timestamp.', expect.any(Error)); + + // Simulate retries and timer + jest.runAllTimers(); + + // Ensure fallback event is emitted + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(NewFrameEventName, { + newFrameTimestampInSeconds: fallbackTime, + isFallback: true, + }); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining( + '[Sentry] Native event emitter did not reply in time. Using JavaScript fallback emitter.', + ), + ); + }); + + it('should start listener and use fallback when native call fails', async () => { + jest.useFakeTimers(); + const fallbackTime = Date.now() / 1000; + + const animation = NATIVE.getNewScreenTimeToDisplay as jest.Mock; + animation.mockRejectedValueOnce(new Error('Failed')); + + emitter.startListenerAsync(); + await animation; // wait for the Native execution to be completed. + + await expect(NATIVE.getNewScreenTimeToDisplay).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith('Failed to recceive Native fallback timestamp.', expect.any(Error)); + + // Simulate retries and timer + jest.runAllTimers(); + + // Ensure fallback event is emitted + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(NewFrameEventName, { + newFrameTimestampInSeconds: fallbackTime, + isFallback: true, + }); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining( + '[Sentry] Native event emitter did not reply in time. Using JavaScript fallback emitter.', + ), + ); + }); + + it('should start listener and use fallback when native call fails', async () => { + jest.useFakeTimers(); + const fallbackTime = Date.now() / 1000; + + const animation = NATIVE.getNewScreenTimeToDisplay as jest.Mock; + animation.mockRejectedValueOnce(new Error('Failed')); + + emitter.startListenerAsync(); + await animation; // wait for the Native execution to be completed. + + await expect(NATIVE.getNewScreenTimeToDisplay).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith('Failed to recceive Native fallback timestamp.', expect.any(Error)); + + // Simulate retries and timer + jest.runAllTimers(); + + // Ensure fallback event is emitted + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(NewFrameEventName, { + newFrameTimestampInSeconds: fallbackTime, + isFallback: true, + }); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining( + '[Sentry] Native event emitter did not reply in time. Using JavaScript fallback emitter.', + ), + ); + }); + + it('should start listener and use fallback when native call is not available', async () => { + jest.useFakeTimers(); + const fallbackTime = Date.now() / 1000; + + NATIVE.getNewScreenTimeToDisplay = () => Promise.resolve(null); + const animation = NATIVE.getNewScreenTimeToDisplay as jest.Mock; + + emitter.startListenerAsync(); + await animation; // wait for the Native execution to be completed. + + // Simulate retries and timer + jest.runAllTimers(); + + // Ensure fallback event is emitted + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(NewFrameEventName, { + newFrameTimestampInSeconds: fallbackTime, + isFallback: true, + }); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining( + '[Sentry] Native event emitter did not reply in time. Using JavaScript fallback emitter.', + ), + ); + }); + + it('should start listener and call native when native module is available', async () => { + const nativeTimestamp = 12345; + + (NATIVE.getNewScreenTimeToDisplay as jest.Mock).mockResolvedValueOnce(nativeTimestamp); + + emitter.startListenerAsync(); + + expect(NATIVE.getNewScreenTimeToDisplay).toHaveBeenCalled(); + }); + + it('should not emit if original event emitter was called', async () => { + jest.useFakeTimers(); + + const animation = NATIVE.getNewScreenTimeToDisplay as jest.Mock; + + const mockAddListener = jest.fn(); + DeviceEventEmitter.addListener = mockAddListener; + + // Capture the callback passed to addListener + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/ban-types + let callback: Function = () => {}; + mockAddListener.mockImplementationOnce((eventName, cb) => { + if (eventName === NewFrameEventName) { + callback = cb; + } + return { + remove: jest.fn(), + }; + }); + + emitter.initAsync(); + emitter.startListenerAsync(); + callback(); + + await animation; // wait for the Native execution to be completed. + + // Simulate retries and timer + jest.runAllTimers(); + + expect(DeviceEventEmitter.emit).not.toBeCalled(); + expect(logger.log).not.toBeCalled(); + }); + + it('should retry up to maxRetries and emit fallback if no response', async () => { + jest.useFakeTimers(); + + const animation = NATIVE.getNewScreenTimeToDisplay as jest.Mock; + emitter.startListenerAsync(); + await animation; // wait for the Native execution to be completed. + + expect(logger.log).not.toHaveBeenCalled(); + + // Simulate retries and timer + jest.runAllTimers(); + + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith( + NewFrameEventName, + expect.objectContaining({ isFallback: true }), + ); + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('Native event emitter did not reply in time')); + + jest.useRealTimers(); + }); + + it('should remove subscription when closeAll is called', () => { + const mockRemove = jest.fn(); + (DeviceEventEmitter.addListener as jest.Mock).mockReturnValue({ remove: mockRemove }); + + emitter.initAsync(); + emitter.closeAll(); + + expect(mockRemove).toHaveBeenCalled(); + }); +});