diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt index 98edfad9b..6a8ae6000 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt @@ -45,6 +45,15 @@ class DdSdkImplementation( fun initialize(configuration: ReadableMap, promise: Promise) { val ddSdkConfiguration = configuration.asDdSdkConfiguration() + // On new arch DdSdk is a lazy TurboModule — it's instantiated on the first JS-side + // method call (typically this initialize()), which usually lands AFTER the activity's + // first onHostResume. That means registerLifecycleEvents's listener missed the first + // resume, reactContext is still null on the session listener, and the replay inside + // nativeInitialization.initialize → onRnSdkInitialized has no emitter target. Setting + // the reactContext here — we are provably being dispatched through an active one — + // closes that race so the cached session ID is delivered on the first JS init. + DdSdkSessionStartedListener.getInstance().setReactContext(reactContext) + val nativeInitialization = DdSdkNativeInitialization(appContext, datadog, ddTelemetry) nativeInitialization.initialize(ddSdkConfiguration) diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkNativeInitialization.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkNativeInitialization.kt index d78d7e773..a6e3884e0 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkNativeInitialization.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkNativeInitialization.kt @@ -47,7 +47,7 @@ class DdSdkNativeInitialization internal constructor( private val jsonFileReader: JSONFileReader = JSONFileReader() ) { @Suppress("CyclomaticComplexMethod") - internal fun initialize(ddSdkConfiguration: DdSdkConfiguration) { + internal fun initialize(ddSdkConfiguration: DdSdkConfiguration, isCalledFromJs: Boolean = true) { val sdkConfiguration = buildSdkConfiguration(ddSdkConfiguration) val trackingConsent = buildTrackingConsent(ddSdkConfiguration.trackingConsent) var rumConfiguration: RumConfiguration? = null @@ -71,10 +71,14 @@ class DdSdkNativeInitialization internal constructor( configureRumAndTracesForLogs(ddSdkConfiguration) - if (datadog.isInitialized()) { - datadog.getRumMonitor().getCurrentSessionId { - it?.let { sessionId -> - DdSdkSessionStartedListener.getInstance().onSessionStarted(sessionId, false) + if (isCalledFromJs) { + DdSdkSessionStartedListener.getInstance().onRnSdkInitialized() + // Handles the case in which the SDK was already initialized with initFromNative. + if (datadog.isInitialized()) { + datadog.getRumMonitor().getCurrentSessionId { + it?.let { sessionId -> + DdSdkSessionStartedListener.getInstance().onSessionStarted(sessionId, false) + } } } } @@ -413,7 +417,10 @@ class DdSdkNativeInitialization internal constructor( fun initFromNative(appContext: Context) { val nativeInitialization = DdSdkNativeInitialization(appContext.applicationContext) try { - nativeInitialization.initialize(nativeInitialization.getConfigurationFromJSONFile()) + nativeInitialization.initialize( + ddSdkConfiguration = nativeInitialization.getConfigurationFromJSONFile(), + isCalledFromJs = false + ) } catch (@Suppress("TooGenericExceptionCaught") error: Exception) { Log.w( DdSdkNativeInitialization::class.java.canonicalName, diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkSessionStartedListener.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkSessionStartedListener.kt index 92a9467f3..7dae900a8 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkSessionStartedListener.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkSessionStartedListener.kt @@ -14,13 +14,27 @@ import com.facebook.react.modules.core.DeviceEventManagerModule import org.jetbrains.annotations.TestOnly -internal class DdSdkSessionStartedListener private constructor(): RumSessionListener { +internal class DdSdkSessionStartedListener private constructor() : RumSessionListener { companion object { + // JS-side callable module registered via BatchedBridge.registerCallableModule. private const val BRIDGE_MODULE_NAME = "DatadogInternalReactBridge" private const val BRIDGE_MODULE_METHOD = "__datadogOnMessageReceived" private var instance: DdSdkSessionStartedListener? = null + // Process-level state — tracks whether JS initialize() has ever run since the + // app process started. Kept outside the instance on purpose: the listener + // singleton is recreated on every onHostPause/onHostDestroy via invalidate(), + // but the JS bridge module registration survives activity lifecycle events, so + // this flag must survive them too. + private var isRnSdkInitialized: Boolean = false + + // Routes UI-thread dispatch through an injectable abstraction so JVM unit tests + // can use TestUiThreadExecutor (synchronous, no main looper required) instead of + // the real UiThreadUtil. + private var uiThreadExecutor: UiThreadExecutor = ReactUiThreadExecutor() + + // Returns the shared listener instance, creating it on first call. fun getInstance(): DdSdkSessionStartedListener { if (instance == null) { instance = DdSdkSessionStartedListener() @@ -28,26 +42,67 @@ internal class DdSdkSessionStartedListener private constructor(): RumSessionList return instance!! } + // Resets the singleton — used in tests and on host pause/destroy to release the + // React context reference. Does NOT reset isRnSdkInitialized, which tracks + // process-level JS-bridge registration state. fun invalidate() { instance = null } + + @TestOnly + fun resetIsRnSdkInitialized() { + isRnSdkInitialized = false + } + + @TestOnly + fun isRnSdkInitializedForTests(): Boolean = isRnSdkInitialized } - private var reactContext: ReactContext? = null + // Cached so it can be delivered once the bridge becomes available. private var lastSessionId: String? = null + + // Set from onHostResume; null until the React activity is first resumed. + private var reactContext: ReactContext? = null + // Overridable in tests — NativeArray cannot be instantiated without the native SO. private var convertToNativeArray: ((array: Array) -> NativeArray?)? = null - private var exceptionHandler: ((error:Exception)->Unit)? = null + // Overridable in tests to assert on bridge exceptions without crashing. + private var exceptionHandler: ((error: Exception) -> Unit)? = null + // Lazily resolved from BuildConfig; overridable in tests. private var isNewArchitecture: Boolean? = null + // Stores the session ID and attempts immediate delivery. override fun onSessionStarted(sessionId: String, isDiscarded: Boolean) { - sendSessionStartedToJS(sessionId) + this.lastSessionId = sessionId + trySendSessionStartedToJS(sessionId) } + // Stores the React context and schedules a catch-up delivery for any cached session + // ID on the UI thread. Called from: + // - DdSdk#onHostResume (both architectures) via the lifecycle listener + // - DdSdkImplementation#initialize (new-arch race fix — the TurboModule is lazy- + // instantiated, so the first onHostResume may have fired before its lifecycle + // listener was registered; setting the context here guarantees a non-null + // target for onRnSdkInitialized's replay) + // + // Delivery is dispatched to the UI thread because sendSessionIdWithBridge and + // sendSessionIdWithEventEmitter are @MainThread. The field assignment stays + // synchronous so callers that rely on reactContext being set immediately after + // this call (e.g. a same-stack onRnSdkInitialized) see the new value. fun setReactContext(reactContext: ReactContext) { this.reactContext = reactContext - if (hasValidBridge()) { - this.lastSessionId?.let { sendSessionStartedToJS(it) } - } + val cached = this.lastSessionId ?: return + uiThreadExecutor.runOnUiThread { trySendSessionStartedToJS(cached) } + } + + // Called when the RN SDK is initialized from JS for the first time while the native + // SDK was already running. At this point DatadogInternalReactBridge is guaranteed to + // be registered, so it is safe to deliver any session ID that was stored before the + // React bridge was available. Delivery is posted to the UI thread for the same + // reason as setReactContext. + fun onRnSdkInitialized() { + isRnSdkInitialized = true + val cached = this.lastSessionId ?: return + uiThreadExecutor.runOnUiThread { trySendSessionStartedToJS(cached) } } @TestOnly @@ -65,14 +120,28 @@ internal class DdSdkSessionStartedListener private constructor(): RumSessionList this.isNewArchitecture = isNewArch } + @TestOnly + fun setIsRnSdkInitialized(value: Boolean) { + isRnSdkInitialized = value + } + + @TestOnly + fun setUiThreadExecutor(executor: UiThreadExecutor) { + uiThreadExecutor = executor + } + + // Returns true only when it is safe to call callFunction on the JS thread. private fun hasValidBridge(): Boolean { val context = reactContext ?: return false val instance = context.catalystInstance ?: return false return !isNewArchitecture() && !instance.isDestroyed && - context.hasActiveReactInstance() + context.hasActiveReactInstance() && + isRnSdkInitialized } + // Lazily cached — BuildConfig is a compile-time constant but reading it through the + // nullable override lets tests inject the value without reflection. private fun isNewArchitecture(): Boolean { isNewArchitecture?.let { return it } BuildConfig.IS_NEW_ARCHITECTURE_ENABLED.let { @@ -81,8 +150,8 @@ internal class DdSdkSessionStartedListener private constructor(): RumSessionList } } - private fun sendSessionStartedToJS(sessionId: String) { - this.lastSessionId = sessionId + // Routes delivery to the appropriate path based on arch and bridge readiness. + private fun trySendSessionStartedToJS(sessionId: String) { if (hasValidBridge()) { sendSessionIdWithBridge(sessionId) } else { @@ -90,30 +159,32 @@ internal class DdSdkSessionStartedListener private constructor(): RumSessionList } } + // Old-arch delivery path via BatchedBridge. @MainThread private fun sendSessionIdWithBridge(sessionId: String) { @Suppress("TooGenericExceptionCaught") try { - val args = arrayOf(sessionId) - val nativeArray = if (convertToNativeArray != null) { - convertToNativeArray?.invoke(args) - } else { - WritableNativeArray().apply { - pushString("RUMSessionStarted") - pushString(sessionId) - } - } - + val args = arrayOf("RUMSessionStarted", sessionId) reactContext?.catalystInstance?.callFunction( BRIDGE_MODULE_NAME, BRIDGE_MODULE_METHOD, - nativeArray + buildBridgeArgs(args) ) - } catch(err: Exception) { + } catch (err: Exception) { exceptionHandler?.invoke(err) } } + private fun buildBridgeArgs(args: Array): NativeArray? { + if (convertToNativeArray != null) { + return convertToNativeArray?.invoke(args) + } + return WritableNativeArray().apply { + args.forEach { pushString(it) } + } + } + + // New-arch delivery path and fallback when the bridge is not active. @MainThread private fun sendSessionIdWithEventEmitter(sessionId: String) { val context = reactContext ?: return diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkNativeInitializationTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkNativeInitializationTest.kt index afc363646..4e9cb24c7 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkNativeInitializationTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkNativeInitializationTest.kt @@ -7,6 +7,7 @@ package com.datadog.reactnative import android.content.pm.PackageInfo +import com.datadog.android.rum.RumMonitor import com.datadog.tools.unit.GenericAssert.Companion.assertThat import com.datadog.tools.unit.forge.BaseConfigurator import com.facebook.react.bridge.ReactApplicationContext @@ -24,8 +25,12 @@ import org.mockito.Answers import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -70,6 +75,9 @@ internal class DdSdkNativeInitializationTest { mockDdTelemetry, mockJSONFileReader ) + + DdSdkSessionStartedListener.invalidate() + DdSdkSessionStartedListener.resetIsRnSdkInitialized() } // region getConfigurationFromJSONFile @@ -202,4 +210,64 @@ internal class DdSdkNativeInitializationTest { } // endregion + + // region initialize() + + @Test + fun `𝕄 mark RN SDK initialized and catch up session 𝕎 initialize() { isCalledFromJs=true, datadog already initialized }`() { // ktlint-disable-line max-line-length + // Given + val mockRumMonitor: RumMonitor = mock() + whenever(mockDatadog.isInitialized()) doReturn true + whenever(mockDatadog.getRumMonitor()) doReturn mockRumMonitor + + // When + testedNativeInitialization.initialize( + ddSdkConfiguration = minimalConfiguration(), + isCalledFromJs = true + ) + + // Then + assertThat(DdSdkSessionStartedListener.isRnSdkInitializedForTests()).isTrue() + verify(mockRumMonitor).getCurrentSessionId(any()) + } + + @Test + fun `𝕄 mark RN SDK initialized without catch up 𝕎 initialize() { isCalledFromJs=true, datadog not initialized }`() { // ktlint-disable-line max-line-length + // Given + whenever(mockDatadog.isInitialized()) doReturn false + + // When + testedNativeInitialization.initialize( + ddSdkConfiguration = minimalConfiguration(), + isCalledFromJs = true + ) + + // Then + assertThat(DdSdkSessionStartedListener.isRnSdkInitializedForTests()).isTrue() + verify(mockDatadog, never()).getRumMonitor() + } + + @Test + fun `𝕄 not mark RN SDK initialized or catch up 𝕎 initialize() { isCalledFromJs=false }`() { + // Given + whenever(mockDatadog.isInitialized()) doReturn true + + // When + testedNativeInitialization.initialize( + ddSdkConfiguration = minimalConfiguration(), + isCalledFromJs = false + ) + + // Then + assertThat(DdSdkSessionStartedListener.isRnSdkInitializedForTests()).isFalse() + verify(mockDatadog, never()).getRumMonitor() + } + + // endregion + + private fun minimalConfiguration(): DdSdkConfiguration = DdSdkConfiguration( + clientToken = "fake-client-token", + env = "fake-env", + additionalConfiguration = emptyMap() + ) } diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkSessionStartedListenerTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkSessionStartedListenerTest.kt index e5007748e..3dca7814e 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkSessionStartedListenerTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkSessionStartedListenerTest.kt @@ -8,6 +8,7 @@ package com.datadog.reactnative import com.datadog.tools.unit.GenericAssert.Companion.assertThat +import com.datadog.tools.unit.TestUiThreadExecutor import com.datadog.tools.unit.forge.BaseConfigurator import com.facebook.react.bridge.CatalystInstance import com.facebook.react.bridge.NativeArray @@ -55,6 +56,8 @@ internal class DdSdkSessionStartedListenerTest { @BeforeEach fun `set up`() { DdSdkSessionStartedListener.invalidate() + DdSdkSessionStartedListener.getInstance().setUiThreadExecutor(TestUiThreadExecutor()) + DdSdkSessionStartedListener.resetIsRnSdkInitialized() } @Test @@ -91,6 +94,7 @@ internal class DdSdkSessionStartedListenerTest { instance.setReactContext(mockReactContext) instance.setExceptionHandler(mockExceptionHandler) instance.setIsNewArchitecture(false) + instance.setIsRnSdkInitialized(true) val passedArgs = mutableListOf() instance.setConvertToNativeArray { @@ -111,6 +115,36 @@ internal class DdSdkSessionStartedListenerTest { ) } + @Test + fun `𝕄 session ID is NOT sent via bridge W { setReactContext fires before onRnSdkInitialized }`() { // ktlint-disable-line max-line-length + // GIVEN — bridge looks valid but RN SDK has not called initialize() from JS yet + whenever(mockReactContext.hasActiveReactInstance()).thenReturn(true) + whenever(mockReactContext.catalystInstance).thenReturn(mockCatalystInstance) + whenever(mockCatalystInstance.isDestroyed).thenReturn(false) + whenever(mockReactContext.fabricUIManager).thenReturn(null) + + val instance = DdSdkSessionStartedListener.getInstance() + + val mockConvertToNativeArray = mock<(array: Array) -> NativeArray?>() + instance.setConvertToNativeArray(mockConvertToNativeArray) + instance.setIsNewArchitecture(false) + + // WHEN — native session starts and context becomes available before JS initialize() + instance.onSessionStarted("TEST-SESSION-ID", false) + instance.setReactContext(mockReactContext) + + // THEN — bridge must NOT be called (DatadogInternalReactBridge not yet registered) + verifyNoInteractions(mockConvertToNativeArray) + + // WHEN — JS initialize() runs, guaranteeing the callable module is registered + instance.onRnSdkInitialized() + + // THEN — catch-up delivery is now safe and matches __datadogOnMessageReceived(eventName, data) + verify(mockConvertToNativeArray).invoke( + argWhere { it.size == 2 && it[0] == "RUMSessionStarted" && it[1] == "TEST-SESSION-ID" } + ) + } + @Test fun `𝕄 session ID event is delayed until context is available W { bridge }`() { // GIVEN @@ -134,9 +168,12 @@ internal class DdSdkSessionStartedListenerTest { verifyNoInteractions(mockConvertToNativeArray) // WHEN + instance.setIsRnSdkInitialized(true) instance.setReactContext(mockReactContext) // THEN - verify(mockConvertToNativeArray).invoke(argWhere { it.first() == "TEST-SESSION-ID" }) + verify(mockConvertToNativeArray).invoke( + argWhere { it.size == 2 && it[0] == "RUMSessionStarted" && it[1] == "TEST-SESSION-ID" } + ) } } diff --git a/packages/core/ios/Sources/DdSdkNativeInitialization.swift b/packages/core/ios/Sources/DdSdkNativeInitialization.swift index 89d6f9caf..c46ddd145 100644 --- a/packages/core/ios/Sources/DdSdkNativeInitialization.swift +++ b/packages/core/ios/Sources/DdSdkNativeInitialization.swift @@ -32,7 +32,7 @@ public class DdSdkNativeInitialization: NSObject { self.jsonFileReader = jsonFileReader } - internal func initialize(sdkConfiguration: DdSdkConfiguration) { + internal func initialize(sdkConfiguration: DdSdkConfiguration, isCalledFromJs: Bool = true) { if Datadog.isInitialized(instanceName: CoreRegistry.defaultInstanceName) { // Initializing the SDK twice results in Global.rum and Global.sharedTracer to be set to no-op instances consolePrint("Datadog SDK is already initialized, skipping initialization.", .debug) @@ -40,24 +40,31 @@ public class DdSdkNativeInitialization: NSObject { id: "datadog_react_native: RN SDK was already initialized in native", message: "RN SDK was already initialized in native" ) - - RUMMonitor.shared().currentSessionID { sessionId in - guard let id = sessionId else { return } - DdSdkSessionStartedListener.instance.rumSessionListener?(id, false) - } + } else { + self.setVerbosityLevel(configuration: sdkConfiguration) - return - } - self.setVerbosityLevel(configuration: sdkConfiguration) + let coreConfiguration = self.buildSDKConfiguration(configuration: sdkConfiguration) + DatadogSDKWrapper.shared.initialize( + coreConfiguration: coreConfiguration, + loggerConfiguration: DatadogLogs.Logger.Configuration(sdkConfiguration), + trackingConsent: sdkConfiguration.trackingConsent + ) - let coreConfiguration = self.buildSDKConfiguration(configuration: sdkConfiguration) - DatadogSDKWrapper.shared.initialize( - coreConfiguration: coreConfiguration, - loggerConfiguration: DatadogLogs.Logger.Configuration(sdkConfiguration), - trackingConsent: sdkConfiguration.trackingConsent - ) + self.enableFeatures(sdkConfiguration: sdkConfiguration) + } - self.enableFeatures(sdkConfiguration: sdkConfiguration) + if isCalledFromJs { + DdSdkSessionStartedListener.instance.onRnSdkInitialized() + // Handles the case in which the SDK was already initialized via initFromNative. + // Replay the current session ID so the listener can deliver it now that the + // JS-side DatadogInternalReactBridge module is guaranteed to be registered. + if Datadog.isInitialized(instanceName: CoreRegistry.defaultInstanceName) { + RUMMonitor.shared().currentSessionID { sessionId in + guard let id = sessionId else { return } + DdSdkSessionStartedListener.instance.rumSessionListener?(id, false) + } + } + } } internal func getConfigurationFromJSONFile() -> DdSdkConfiguration? { @@ -80,7 +87,7 @@ public class DdSdkNativeInitialization: NSObject { @objc public func initializeFromNative() { if let configuration = getConfigurationFromJSONFile() { - self.initialize(sdkConfiguration: configuration) + self.initialize(sdkConfiguration: configuration, isCalledFromJs: false) } } diff --git a/packages/core/ios/Sources/DdSdkSessionStartedListener.swift b/packages/core/ios/Sources/DdSdkSessionStartedListener.swift index 9630c4bd0..de456dc36 100644 --- a/packages/core/ios/Sources/DdSdkSessionStartedListener.swift +++ b/packages/core/ios/Sources/DdSdkSessionStartedListener.swift @@ -29,6 +29,11 @@ public class DdSdkSessionStartedListener: NSObject { private static let BRIDGE_EVENT_NAME = "RUMSessionStarted" private static var _instance: DdSdkSessionStartedListener? + // Process-level state — survives instance invalidate() because the JS-side + // DatadogInternalReactBridge registration survives bridge-lifecycle resets + // for the lifetime of the JS runtime / process. + private static var isRnSdkInitialized: Bool = false + private var rctBridge: RCTBridge? private var rctEventEmitter: RCTEventEmitter? private var lastSessionId: String? @@ -57,11 +62,22 @@ public class DdSdkSessionStartedListener: NSObject { tryToSendSessionId() } + /// Called when the RN SDK is initialized from JS. At this point + /// DatadogInternalReactBridge (the JS-side callable module registered by + /// BatchedBridge.registerCallableModule) is guaranteed to be registered, + /// so it is safe to deliver any session ID that was buffered before the + /// bridge was usable. + @objc public func onRnSdkInitialized() { + Self.isRnSdkInitialized = true + tryToSendSessionId() + } + func invalidate() { self.rctBridge = nil self.listener = nil self.hasListeners = false self.lastSessionId = nil + // isRnSdkInitialized is intentionally NOT reset — see field comment. } private func tryToSendSessionId() { @@ -71,9 +87,12 @@ public class DdSdkSessionStartedListener: NSObject { if isBridgeless() { sendToJsWithListener(sessionId: sessionId) - } else { + } else if Self.isRnSdkInitialized { sendToJsWithBridge(sessionId: sessionId) } + // else: bridge path is gated until JS DdSdk.initialize() runs, so that + // DatadogInternalReactBridge is guaranteed to be registered. The cached + // lastSessionId will be replayed when onRnSdkInitialized() fires. } private func sendToJsWithBridge(sessionId: String) { @@ -102,4 +121,12 @@ public class DdSdkSessionStartedListener: NSObject { private func isBridgeless() -> Bool { return self.rctBridge == nil } + + static func resetIsRnSdkInitializedForTests() { + isRnSdkInitialized = false + } + + static func isRnSdkInitializedForTests() -> Bool { + return isRnSdkInitialized + } } diff --git a/packages/core/ios/Tests/DdSdkNativeInitializationTests.swift b/packages/core/ios/Tests/DdSdkNativeInitializationTests.swift index c3a5f3780..cb6a6c500 100644 --- a/packages/core/ios/Tests/DdSdkNativeInitializationTests.swift +++ b/packages/core/ios/Tests/DdSdkNativeInitializationTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@testable import DatadogCore @testable import DatadogSDKReactNative @testable import DatadogInternal @@ -13,12 +14,18 @@ class DdSdkNativeInitializationTests: XCTestCase { override func setUp() { super.setUp() + DdSdkSessionStartedListener.invalidate() + DdSdkSessionStartedListener.resetIsRnSdkInitializedForTests() } override func tearDown() { + DatadogSDKWrapper.shared.onSdkInitializedListeners = [] + Datadog.internalFlushAndDeinitialize() + DdSdkSessionStartedListener.invalidate() + DdSdkSessionStartedListener.resetIsRnSdkInitializedForTests() super.tearDown() } - + func testReturnsConfigurationWithAllData() { let mockJSONFileReader = MockJSONFileReader(mockResourceFilePath: "Fixtures/complete-configuration") let nativeInitialization = DdSdkNativeInitialization( @@ -118,6 +125,42 @@ class DdSdkNativeInitializationTests: XCTestCase { XCTAssertNil(nativeInitialization.getConfigurationFromJSONFile()) XCTAssertEqual(self.consoleMessage, "Error parsing datadog-configuration.json file: 🔥 Datadog SDK usage error: JSON configuration file is missing top-level \"configuration\" key.") } + + func testInitializeWithIsCalledFromJsTrueMarksRnSdkInitialized() { + // GIVEN + let nativeInitialization = DdSdkNativeInitialization() + XCTAssertFalse(DdSdkSessionStartedListener.isRnSdkInitializedForTests()) + + // WHEN + nativeInitialization.initialize(sdkConfiguration: .mockAny(), isCalledFromJs: true) + + // THEN + XCTAssertTrue(DdSdkSessionStartedListener.isRnSdkInitializedForTests()) + } + + func testInitializeWithIsCalledFromJsFalseDoesNotMarkRnSdkInitialized() { + // GIVEN + let nativeInitialization = DdSdkNativeInitialization() + XCTAssertFalse(DdSdkSessionStartedListener.isRnSdkInitializedForTests()) + + // WHEN + nativeInitialization.initialize(sdkConfiguration: .mockAny(), isCalledFromJs: false) + + // THEN + XCTAssertFalse(DdSdkSessionStartedListener.isRnSdkInitializedForTests()) + } + + func testInitializeDefaultsToIsCalledFromJsTrue() { + // GIVEN + let nativeInitialization = DdSdkNativeInitialization() + XCTAssertFalse(DdSdkSessionStartedListener.isRnSdkInitializedForTests()) + + // WHEN — default value of isCalledFromJs is true (matches the JS-init path) + nativeInitialization.initialize(sdkConfiguration: .mockAny()) + + // THEN + XCTAssertTrue(DdSdkSessionStartedListener.isRnSdkInitializedForTests()) + } } class MockJSONFileReader: ResourceFileReader { diff --git a/packages/core/ios/Tests/DdSdkSessionStartedListenerTests.swift b/packages/core/ios/Tests/DdSdkSessionStartedListenerTests.swift index 73ed90eb0..c891dc8e4 100644 --- a/packages/core/ios/Tests/DdSdkSessionStartedListenerTests.swift +++ b/packages/core/ios/Tests/DdSdkSessionStartedListenerTests.swift @@ -13,9 +13,13 @@ class DdSdkSessionStartedListenerTests: XCTestCase { override func setUp() { super.setUp() + DdSdkSessionStartedListener.invalidate() + DdSdkSessionStartedListener.resetIsRnSdkInitializedForTests() } override func tearDown() { + DdSdkSessionStartedListener.invalidate() + DdSdkSessionStartedListener.resetIsRnSdkInitializedForTests() super.tearDown() } @@ -53,4 +57,62 @@ class DdSdkSessionStartedListenerTests: XCTestCase { // THEN XCTAssertNotNil(rumSessionListener) } + + func testIsRnSdkInitializedDefaultsToFalse() { + // THEN + XCTAssertFalse(DdSdkSessionStartedListener.isRnSdkInitializedForTests()) + } + + func testOnRnSdkInitializedFlipsFlag() { + // GIVEN + let instance = DdSdkSessionStartedListener.instance + XCTAssertFalse(DdSdkSessionStartedListener.isRnSdkInitializedForTests()) + + // WHEN + instance.onRnSdkInitialized() + + // THEN + XCTAssertTrue(DdSdkSessionStartedListener.isRnSdkInitializedForTests()) + } + + func testInvalidateDoesNotResetIsRnSdkInitialized() { + // GIVEN + let instance = DdSdkSessionStartedListener.instance + instance.onRnSdkInitialized() + XCTAssertTrue(DdSdkSessionStartedListener.isRnSdkInitializedForTests()) + + // WHEN + DdSdkSessionStartedListener.invalidate() + + // THEN + XCTAssertTrue(DdSdkSessionStartedListener.isRnSdkInitializedForTests()) + } + + func testBridgelessListenerPathIsUnaffectedByIsRnSdkInitialized() { + // GIVEN — bridgeless mode (rctBridge == nil), flag still false + let instance = DdSdkSessionStartedListener.instance + var deliveredSessionIds: [String] = [] + instance.setListenerCallback { sessionId in + deliveredSessionIds.append(sessionId) + } + instance.setHasListeners(true) + + // WHEN — native session starts before any JS init + instance.rumSessionListener?("TEST-SESSION-ID", false) + + // THEN — bridgeless path delivers regardless of the flag + XCTAssertEqual(deliveredSessionIds, ["TEST-SESSION-ID"]) + } + + func testResetIsRnSdkInitializedForTestsResetsFlag() { + // GIVEN + DdSdkSessionStartedListener.instance.onRnSdkInitialized() + XCTAssertTrue(DdSdkSessionStartedListener.isRnSdkInitializedForTests()) + + // WHEN + DdSdkSessionStartedListener.resetIsRnSdkInitializedForTests() + + // THEN + XCTAssertFalse(DdSdkSessionStartedListener.isRnSdkInitializedForTests()) + } }