From 6c8bc5e9c407da4d41008a96154f1785b7c76103 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:57:35 +0300 Subject: [PATCH 1/3] Fix session replay init in example app --- example/src/App.tsx | 23 ++++++++++++++++++++--- example/src/ddUtils.tsx | 6 ------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index e6e4ac694..d9f247cda 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -8,12 +8,18 @@ import TraceScreen from './screens/TraceScreen'; import style from './screens/styles'; import { navigationRef } from './NavigationRoot'; import { DdRumReactNavigationTracking, NavigationTrackingOptions, ParamsTrackingPredicate, ViewNamePredicate, ViewTrackingPredicate } from '@datadog/mobile-react-navigation'; -import { DatadogProvider, TrackingConsent, DdFlags } from '@datadog/mobile-react-native' +import { DatadogProvider, TrackingConsent, DdFlags, DdLogs, DdSdkReactNative } from '@datadog/mobile-react-native' import { DatadogOpenFeatureProvider } from '@datadog/mobile-react-native-openfeature'; +import { + ImagePrivacyLevel, + SessionReplay, + TextAndInputPrivacyLevel, + TouchPrivacyLevel, +} from '@datadog/mobile-react-native-session-replay'; import { OpenFeature, OpenFeatureProvider } from '@openfeature/react-sdk'; import { Route } from "@react-navigation/native"; import { NestedNavigator } from './screens/NestedNavigator/NestedNavigator'; -import { getDatadogConfig, onDatadogInitialization } from './ddUtils'; +import { getDatadogConfig } from './ddUtils'; const Tab = createBottomTabNavigator(); @@ -69,7 +75,18 @@ const configuration = getDatadogConfig(TrackingConsent.GRANTED) // const configuration = new DatadogProviderConfiguration("fake_value", "fake_value"); const handleDatadogInitialization = async () => { - onDatadogInitialization(); + DdLogs.info('The RN Sdk was properly initialized') + DdSdkReactNative.setUserInfo({id: "1337", name: "Xavier", email: "xg@example.com", extraInfo: { type: "premium" } }) + DdSdkReactNative.addAttributes({campaign: "ad-network"}) + + // Enable Session Replay. + await SessionReplay.enable({ + replaySampleRate: 100, + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.MASK_SENSITIVE_INPUTS, + imagePrivacyLevel: ImagePrivacyLevel.MASK_NONE, + touchPrivacyLevel: TouchPrivacyLevel.SHOW, + enableHeatmaps: true, + }); // Enable Datadog Flags feature. await DdFlags.enable(); diff --git a/example/src/ddUtils.tsx b/example/src/ddUtils.tsx index 190aba093..4933cec10 100644 --- a/example/src/ddUtils.tsx +++ b/example/src/ddUtils.tsx @@ -57,12 +57,6 @@ export function getDatadogConfig(trackingConsent: TrackingConsent) { return config } - export function onDatadogInitialization() { - DdLogs.info('The RN Sdk was properly initialized') - DdSdkReactNative.setUserInfo({id: "1337", name: "Xavier", email: "xg@example.com", extraInfo: { type: "premium" } }) - DdSdkReactNative.addAttributes({campaign: "ad-network"}) -} - // Legacy SDK Setup export function initializeDatadog(trackingConsent: TrackingConsent) { From 1e70eb572fb11ad4e22f48f3430f5697fa41358a Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:35:26 +0300 Subject: [PATCH 2/3] RUMS-5681: Add heatmaps for Android --- .../reactnative/DdRumImplementation.kt | 27 +- .../reactnative/HeatmapActionHandler.kt | 99 ++++ .../reactnative/HeatmapTouchResolver.kt | 105 ++++ .../kotlin/com/datadog/reactnative/DdRum.kt | 18 +- .../kotlin/com/datadog/reactnative/DdRum.kt | 17 +- .../com/datadog/reactnative/DdRumTest.kt | 482 ++++++++++++++++ .../reactnative/HeatmapTouchResolverTest.kt | 523 ++++++++++++++++++ .../DdSessionReplayImplementation.kt | 6 +- .../DdSessionReplayImplementationTest.kt | 139 +++++ 9 files changed, 1406 insertions(+), 10 deletions(-) create mode 100644 packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapActionHandler.kt create mode 100644 packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapTouchResolver.kt create mode 100644 packages/core/android/src/test/kotlin/com/datadog/reactnative/HeatmapTouchResolverTest.kt diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdRumImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdRumImplementation.kt index 41f525761..23fca1f90 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdRumImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdRumImplementation.kt @@ -22,7 +22,10 @@ import java.util.Locale * The entry point to use Datadog's RUM feature. */ @Suppress("TooManyFunctions") -class DdRumImplementation(private val datadog: DatadogWrapper = DatadogSDKWrapper()) { +class DdRumImplementation internal constructor( + private val datadog: DatadogWrapper = DatadogSDKWrapper(), + private val heatmapActionHandler: HeatmapActionHandler = HeatmapActionHandler() +) { /** * Start tracking a RUM View. * @param key The view unique key identifier. @@ -120,11 +123,10 @@ class DdRumImplementation(private val datadog: DatadogWrapper = DatadogSDKWrappe * @param type The action type (tap, scroll, swipe, click, custom). * @param name The action name. * @param touch The native touch data for tap actions, or null for other action types. - * Currently unused on Android; reserved for heatmap support. * @param context The additional context to send. * @param timestampMs The timestamp when the action occurred (in milliseconds). If not provided, current timestamp will be used. */ - @Suppress("LongParameterList", "UnusedParameter") + @Suppress("LongParameterList") fun addAction( type: String, name: String, @@ -136,12 +138,28 @@ class DdRumImplementation(private val datadog: DatadogWrapper = DatadogSDKWrappe val attributes = context.toHashMap().toMutableMap().apply { put(RumAttributes.INTERNAL_TIMESTAMP, timestampMs.toLong()) } + + val eligibleAction = heatmapActionHandler.resolveEligibility(datadog, type, touch) + if (eligibleAction == null) { + addActionWithoutHeatmap(type, name, attributes) + promise.resolve(null) + return + } + + // Resolve before dispatching heatmap work (iOS parity). + promise.resolve(null) + + heatmapActionHandler.attachHeatmapData(eligibleAction, name, attributes) { + addActionWithoutHeatmap(type, name, attributes) + } + } + + private fun addActionWithoutHeatmap(type: String, name: String, attributes: Map) { datadog.getRumMonitor().addAction( type = type.asRumActionType(), name = name, attributes = attributes ) - promise.resolve(null) } /** @@ -460,6 +478,7 @@ class DdRumImplementation(private val datadog: DatadogWrapper = DatadogSDKWrappe } // endregion + @Suppress("UndocumentedPublicClass") companion object { private const val MISSING_RESOURCE_SIZE = -1L diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapActionHandler.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapActionHandler.kt new file mode 100644 index 000000000..25ff7479f --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapActionHandler.kt @@ -0,0 +1,99 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.reactnative + +import android.os.Handler +import android.os.Looper +import com.datadog.android.rum.RumActionType +import com.datadog.android.rum._RumInternalProxy +import com.facebook.react.bridge.ReadableMap + +/** + * Decides whether an `addAction` call is eligible for heatmap tracking and, if so, resolves and + * attaches the heatmap data via [HeatmapTouchResolver]. Split into two steps so the caller can + * resolve its own promise between them, before the heatmap work dispatches. + */ +class HeatmapActionHandler internal constructor( + private val heatmapTouchResolver: HeatmapTouchResolver = HeatmapTouchResolver(), + private val mainThreadExecutor: (() -> Unit) -> Unit = { action -> + Handler(Looper.getMainLooper()).post(action) + } +) { + + internal data class EligibleAction( + val internalProxy: _RumInternalProxy, + val viewUrl: String, + val reactTag: Int, + val positionX: Long, + val positionY: Long + ) + + internal fun resolveEligibility( + datadog: DatadogWrapper, + type: String, + touch: ReadableMap? + ): EligibleAction? { + if (!heatmapsEnabled || touch == null || !type.equals("tap", ignoreCase = true)) { + return null + } + + val internalProxy = datadog.getRumMonitor()._getInternal() + // Read now — the view can transition asynchronously right after a tap. + val viewUrl = internalProxy?.getCurrentViewUrl() + val touchFields = touch.toTouchFieldsOrNull() + + return if (internalProxy != null && viewUrl != null && touchFields != null) { + val (reactTag, positionX, positionY) = touchFields + EligibleAction(internalProxy, viewUrl, reactTag, positionX, positionY) + } else { + null + } + } + + internal fun attachHeatmapData( + eligibleAction: EligibleAction, + name: String, + attributes: Map, + fallback: () -> Unit + ) { + mainThreadExecutor { + val heatmapData = heatmapTouchResolver.resolveHeatmapActionData( + eligibleAction.reactTag, + eligibleAction.positionX, + eligibleAction.positionY, + eligibleAction.viewUrl + ) + if (heatmapData != null) { + eligibleAction.internalProxy.addActionWithHeatmap( + type = RumActionType.TAP, + name = name, + crossPlatformHeatmapActionData = heatmapData, + attributes = attributes + ) + } else { + fallback() + } + } + } + + private fun ReadableMap.toTouchFieldsOrNull(): Triple? { + if (!hasKey("reactTag") || !hasKey("x") || !hasKey("y")) return null + return runCatching { + Triple(getInt("reactTag"), getDouble("x").toLong(), getDouble("y").toLong()) + }.getOrNull() + } + + @Suppress("UndocumentedPublicClass") + companion object { + /** + * Whether heatmap data should be attached to TAP actions. Set by + * [com.datadog.reactnative.sessionreplay.DdSessionReplayImplementation.enable]. + */ + @Volatile + var heatmapsEnabled: Boolean = false + } +} diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapTouchResolver.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapTouchResolver.kt new file mode 100644 index 000000000..7193be574 --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapTouchResolver.kt @@ -0,0 +1,105 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.reactnative + +import android.content.res.Resources +import android.view.View +import android.view.ViewGroup +import com.datadog.android.heatmaps.CrossPlatformHeatmapActionData + +/** + * Resolves a React Native touch into [CrossPlatformHeatmapActionData] by walking the view + * hierarchy to the nearest clickable-and-visible ancestor — matching Session Replay's own + * `HeatmapIdentifierResolver` rule — and building its element path using the same convention, so + * the resulting hash matches the `permanentId` SR assigned to that view's wireframe. + */ +internal class HeatmapTouchResolver( + private val viewResolver: (Int) -> View? = { null } +) { + + /** Returns null if [reactTag] doesn't resolve to a valid tap target. */ + fun resolveHeatmapActionData( + reactTag: Int, + positionX: Long, + positionY: Long, + viewUrl: String + ): CrossPlatformHeatmapActionData? = runCatching { + val view = viewResolver(reactTag)?.let { clickableVisibleAncestorOf(it) } + val elementPath = view?.let { elementPathFromRootTo(it) } + + if (view != null && !elementPath.isNullOrEmpty()) { + val density = view.resources?.displayMetrics?.density ?: 1f + val targetWidth = (view.width / density).toLong().takeIf { it > 0 } + val targetHeight = (view.height / density).toLong().takeIf { it > 0 } + + CrossPlatformHeatmapActionData( + elementPath = elementPath, + viewUrl = viewUrl, + positionX = positionX, + positionY = positionY, + targetWidth = targetWidth, + targetHeight = targetHeight + ) + } else { + null + } + }.getOrNull() + + // region Private helpers + + private fun clickableVisibleAncestorOf(view: View): View? { + var current: View? = view + while (current != null) { + if (current.isClickable && current.visibility == View.VISIBLE) { + return current + } + current = current.parent as? View + } + return null + } + + private fun elementPathFromRootTo(view: View): List { + val path = mutableListOf() + var current: View? = view + while (current != null) { + val parent = current.parent as? ViewGroup + val typeIndex = if (parent != null) computeTypeIndex(current, parent) else 0 + path.add(pathComponentFor(current, typeIndex)) + current = parent + } + path.reverse() + return path + } + + private fun computeTypeIndex(view: View, parent: ViewGroup): Int { + val cls = view.javaClass + var index = 0 + for (i in 0 until parent.childCount) { + val child = parent.getChildAt(i) ?: continue + if (child === view) break + if (child.javaClass === cls) index++ + } + return index + } + + private fun pathComponentFor(view: View, typeIndex: Int): String { + val viewId = view.id + if (viewId != View.NO_ID) { + try { + @Suppress("UnsafeThirdPartyFunctionCall") + val name = view.resources?.getResourceName(viewId) + if (!name.isNullOrEmpty()) { + return "$name#$typeIndex" + } + } catch (_: Resources.NotFoundException) { + } + } + return "cls:${view.javaClass.name}#$typeIndex" + } + + // endregion +} diff --git a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdRum.kt b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdRum.kt index e7d80ff25..b9379e26d 100644 --- a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdRum.kt +++ b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdRum.kt @@ -12,6 +12,8 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.common.ViewUtil /** * The entry point to use Datadog's RUM feature. @@ -22,7 +24,20 @@ class DdRum( datadogWrapper: DatadogWrapper = DatadogSDKWrapper() ) : NativeDdRumSpec(reactContext) { - private val implementation = DdRumImplementation(datadog = datadogWrapper) + private val implementation = DdRumImplementation( + datadog = datadogWrapper, + heatmapActionHandler = HeatmapActionHandler( + heatmapTouchResolver = HeatmapTouchResolver(viewResolver = { reactTag -> + try { + val uiManagerType = ViewUtil.getUIManagerType(reactTag) + UIManagerHelper.getUIManager(reactApplicationContext, uiManagerType) + ?.resolveView(reactTag) + } catch (_: Exception) { + null + } + }) + ) + ) override fun getName(): String = DdRumImplementation.NAME @@ -105,7 +120,6 @@ class DdRum( * @param type The action type (tap, scroll, swipe, click, custom). * @param name The action name. * @param touch The native touch data for tap actions, or null for other action types. - * Currently unused on Android; reserved for heatmap support. * @param context The additional context to send. * @param timestampMs The timestamp when the action occurred (in milliseconds). * If not provided, current timestamp will be used. diff --git a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdRum.kt b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdRum.kt index dc966dce1..4259c0bc8 100644 --- a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdRum.kt +++ b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdRum.kt @@ -13,6 +13,8 @@ import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.common.UIManagerType /** * The entry point to use Datadog's RUM feature. @@ -23,7 +25,19 @@ class DdRum( datadogWrapper: DatadogWrapper = DatadogSDKWrapper() ) : ReactContextBaseJavaModule(reactContext) { - private val implementation = DdRumImplementation(datadog = datadogWrapper) + private val implementation = DdRumImplementation( + datadog = datadogWrapper, + heatmapActionHandler = HeatmapActionHandler( + heatmapTouchResolver = HeatmapTouchResolver(viewResolver = { reactTag -> + try { + UIManagerHelper.getUIManager(reactApplicationContext, UIManagerType.DEFAULT) + ?.resolveView(reactTag) + } catch (_: Exception) { + null + } + }) + ) + ) override fun getName(): String = DdRumImplementation.NAME @@ -98,7 +112,6 @@ class DdRum( * @param type The action type (tap, scroll, swipe, click, custom). * @param name The action name. * @param touch The native touch data for tap actions, or null for other action types. - * Currently unused on Android; reserved for heatmap support. * @param context The additional context to send. * @param timestampMs The timestamp when the action occurred (in milliseconds). If not provided, current timestamp will be used. */ diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdRumTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdRumTest.kt index d5315fa8b..b03c377d8 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdRumTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdRumTest.kt @@ -6,12 +6,14 @@ package com.datadog.reactnative +import com.datadog.android.heatmaps.CrossPlatformHeatmapActionData import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.RumMonitor import com.datadog.android.rum.RumResourceKind import com.datadog.android.rum.RumResourceMethod +import com.datadog.android.rum._RumInternalProxy import com.datadog.tools.unit.forge.BaseConfigurator import com.datadog.tools.unit.toReadableArray import com.datadog.tools.unit.toReadableMap @@ -36,7 +38,11 @@ import org.junit.jupiter.api.extension.Extensions 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.eq +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -49,6 +55,18 @@ import org.mockito.quality.Strictness @ForgeConfiguration(BaseConfigurator::class) internal class DdRumTest { + /** + * `RumMonitor._getInternal()` is a synthetic method (dd-sdk-android's `@NoOpImplementation`); + * Mockito can't override it, so stubbing it directly throws `AbstractMethodError`. A real + * compiled override sidesteps that. + */ + private class RumMonitorWithInternalProxy( + monitor: RumMonitor, + private val internalProxy: _RumInternalProxy? + ) : RumMonitor by monitor { + override fun _getInternal(): _RumInternalProxy? = internalProxy + } + // TODO: 14/12/2020 RUMM-925 Add the relevant unit tests after merging the feature/bridge branch lateinit var testedDdRum: DdRumImplementation @@ -62,6 +80,12 @@ internal class DdRumTest { @Mock lateinit var mockPromise: Promise + @Mock + lateinit var mockInternalProxy: _RumInternalProxy + + @Mock + lateinit var mockHeatmapTouchResolver: HeatmapTouchResolver + lateinit var fakeContext: Map @DoubleForgery(1000000000000.0, 2000000000000.0) @@ -88,6 +112,8 @@ internal class DdRumTest { @AfterEach fun `tear down`() { + // Reset global heatmaps flag so it never leaks into subsequent tests + HeatmapActionHandler.heatmapsEnabled = false } @Test @@ -555,4 +581,460 @@ internal class DdRumTest { // Then verify(mockRumMonitor).addFeatureFlagEvaluation(name, value) } + + @Test + fun `M call addActionWithHeatmap W addAction() with TAP type and resolver returns data`( + @StringForgery name: String, + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 300.0) fakeX: Double, + @DoubleForgery(0.0, 300.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val updatedContext = fakeContext.toReadableMap().toHashMap().toMutableMap().apply { + put(RumAttributes.INTERNAL_TIMESTAMP, fakeTimestamp.toLong()) + } + HeatmapActionHandler.heatmapsEnabled = true + whenever(mockDatadog.getRumMonitor()) doReturn RumMonitorWithInternalProxy( + mockRumMonitor, + mockInternalProxy + ) + whenever(mockInternalProxy.getCurrentViewUrl()) doReturn fakeViewUrl + + val fakeHeatmapData = CrossPlatformHeatmapActionData( + elementPath = listOf("cls:android.view.View#0"), + viewUrl = fakeViewUrl, + positionX = fakeX.toLong(), + positionY = fakeY.toLong(), + targetWidth = 100L, + targetHeight = 50L + ) + whenever( + mockHeatmapTouchResolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + ) doReturn fakeHeatmapData + + val testedDdRumWithHeatmap = DdRumImplementation( + datadog = mockDatadog, + heatmapActionHandler = HeatmapActionHandler( + heatmapTouchResolver = mockHeatmapTouchResolver, + mainThreadExecutor = { it() } + ) + ) + val fakeTouch = mapOf( + "reactTag" to fakeReactTag, + "x" to fakeX, + "y" to fakeY, + "pageX" to fakeX, + "pageY" to fakeY + ).toReadableMap() + + // When + testedDdRumWithHeatmap.addAction( + "TAP", + name, + fakeTouch, + fakeContext.toReadableMap(), + fakeTimestamp, + mockPromise + ) + + // Then + verify(mockInternalProxy).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(name), + eq(fakeHeatmapData), + eq(updatedContext) + ) + verify(mockPromise).resolve(null) + verify(mockRumMonitor, never()).addAction(RumActionType.TAP, name, updatedContext) + } + + @Test + fun `M fall back to addAction W addAction() with TAP type and resolver returns null`( + @StringForgery name: String, + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 300.0) fakeX: Double, + @DoubleForgery(0.0, 300.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val updatedContext = fakeContext.toReadableMap().toHashMap().toMutableMap().apply { + put(RumAttributes.INTERNAL_TIMESTAMP, fakeTimestamp.toLong()) + } + HeatmapActionHandler.heatmapsEnabled = true + whenever(mockDatadog.getRumMonitor()) doReturn RumMonitorWithInternalProxy( + mockRumMonitor, + mockInternalProxy + ) + whenever(mockInternalProxy.getCurrentViewUrl()) doReturn fakeViewUrl + whenever( + mockHeatmapTouchResolver.resolveHeatmapActionData(any(), any(), any(), any()) + ) doReturn null + + val testedDdRumWithHeatmap = DdRumImplementation( + datadog = mockDatadog, + heatmapActionHandler = HeatmapActionHandler( + heatmapTouchResolver = mockHeatmapTouchResolver, + mainThreadExecutor = { it() } + ) + ) + val fakeTouch = mapOf( + "reactTag" to fakeReactTag, + "x" to fakeX, + "y" to fakeY, + "pageX" to fakeX, + "pageY" to fakeY + ).toReadableMap() + + // When + testedDdRumWithHeatmap.addAction( + "TAP", + name, + fakeTouch, + fakeContext.toReadableMap(), + fakeTimestamp, + mockPromise + ) + + // Then + verify(mockRumMonitor).addAction(RumActionType.TAP, name, updatedContext) + verify(mockInternalProxy, never()).addActionWithHeatmap(any(), any(), any(), any()) + verify(mockPromise).resolve(null) + } + + @Test + fun `M call addAction W addAction() with TAP type and no internal proxy`( + @StringForgery name: String, + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 300.0) fakeX: Double, + @DoubleForgery(0.0, 300.0) fakeY: Double + ) { + // Given + val updatedContext = fakeContext.toReadableMap().toHashMap().toMutableMap().apply { + put(RumAttributes.INTERNAL_TIMESTAMP, fakeTimestamp.toLong()) + } + HeatmapActionHandler.heatmapsEnabled = true + whenever(mockDatadog.getRumMonitor()) doReturn RumMonitorWithInternalProxy( + mockRumMonitor, + null + ) + + val fakeTouch = mapOf( + "reactTag" to fakeReactTag, + "x" to fakeX, + "y" to fakeY, + "pageX" to fakeX, + "pageY" to fakeY + ).toReadableMap() + + // When + testedDdRum.addAction( + "TAP", + name, + fakeTouch, + fakeContext.toReadableMap(), + fakeTimestamp, + mockPromise + ) + + // Then + verify(mockRumMonitor).addAction(RumActionType.TAP, name, updatedContext) + verify(mockPromise).resolve(null) + } + + // region heatmapsEnabled gate and type guard + + @Test + fun `M call addAction W addAction() TAP with touch but heatmapsEnabled false`( + @StringForgery name: String, + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 300.0) fakeX: Double, + @DoubleForgery(0.0, 300.0) fakeY: Double + ) { + // Given — heatmapsEnabled false short-circuits before _getInternal() is reached + val updatedContext = fakeContext.toReadableMap().toHashMap().toMutableMap().apply { + put(RumAttributes.INTERNAL_TIMESTAMP, fakeTimestamp.toLong()) + } + + val fakeTouch = mapOf( + "reactTag" to fakeReactTag, + "x" to fakeX, + "y" to fakeY, + "pageX" to fakeX, + "pageY" to fakeY + ).toReadableMap() + + // When + testedDdRum.addAction( + "TAP", + name, + fakeTouch, + fakeContext.toReadableMap(), + fakeTimestamp, + mockPromise + ) + + // Then + verify(mockRumMonitor).addAction(RumActionType.TAP, name, updatedContext) + verify(mockInternalProxy, never()).addActionWithHeatmap( + any(), + any(), + any(), + any() + ) + } + + @Test + fun `M call addAction W addAction() non-TAP type with touch and heatmapsEnabled`( + @StringForgery name: String, + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 300.0) fakeX: Double, + @DoubleForgery(0.0, 300.0) fakeY: Double + ) { + // Given — non-TAP type short-circuits before _getInternal() is reached + val updatedContext = fakeContext.toReadableMap().toHashMap().toMutableMap().apply { + put(RumAttributes.INTERNAL_TIMESTAMP, fakeTimestamp.toLong()) + } + HeatmapActionHandler.heatmapsEnabled = true + + val fakeTouch = mapOf( + "reactTag" to fakeReactTag, + "x" to fakeX, + "y" to fakeY, + "pageX" to fakeX, + "pageY" to fakeY + ).toReadableMap() + + val testedDdRumWithHeatmap = DdRumImplementation( + datadog = mockDatadog, + heatmapActionHandler = HeatmapActionHandler( + heatmapTouchResolver = mockHeatmapTouchResolver, + mainThreadExecutor = { it() } + ) + ) + + // When + testedDdRumWithHeatmap.addAction( + "SCROLL", + name, + fakeTouch, + fakeContext.toReadableMap(), + fakeTimestamp, + mockPromise + ) + + // Then + verify(mockRumMonitor).addAction(RumActionType.SCROLL, name, updatedContext) + verify(mockInternalProxy, never()).addActionWithHeatmap( + any(), + any(), + any(), + any() + ) + } + + @Test + fun `M fall back to addAction W addAction() TAP with touch missing required keys`( + @StringForgery name: String, + @StringForgery fakeViewUrl: String + ) { + // Given + val updatedContext = fakeContext.toReadableMap().toHashMap().toMutableMap().apply { + put(RumAttributes.INTERNAL_TIMESTAMP, fakeTimestamp.toLong()) + } + HeatmapActionHandler.heatmapsEnabled = true + whenever(mockDatadog.getRumMonitor()) doReturn RumMonitorWithInternalProxy( + mockRumMonitor, + mockInternalProxy + ) + whenever(mockInternalProxy.getCurrentViewUrl()) doReturn fakeViewUrl + + val fakeIncompleteTouch = mapOf("reactTag" to 1).toReadableMap() + + val testedDdRumWithHeatmap = DdRumImplementation( + datadog = mockDatadog, + heatmapActionHandler = HeatmapActionHandler( + heatmapTouchResolver = mockHeatmapTouchResolver, + mainThreadExecutor = { it() } + ) + ) + + // When + testedDdRumWithHeatmap.addAction( + "TAP", + name, + fakeIncompleteTouch, + fakeContext.toReadableMap(), + fakeTimestamp, + mockPromise + ) + + // Then + verify(mockRumMonitor).addAction(RumActionType.TAP, name, updatedContext) + verify(mockInternalProxy, never()).addActionWithHeatmap(any(), any(), any(), any()) + verify(mockPromise).resolve(null) + } + + @Test + fun `M fall back to addAction W addAction() TAP with touch fields of the wrong type`( + @StringForgery name: String, + @StringForgery fakeViewUrl: String + ) { + // Given + val updatedContext = fakeContext.toReadableMap().toHashMap().toMutableMap().apply { + put(RumAttributes.INTERNAL_TIMESTAMP, fakeTimestamp.toLong()) + } + HeatmapActionHandler.heatmapsEnabled = true + whenever(mockDatadog.getRumMonitor()) doReturn RumMonitorWithInternalProxy( + mockRumMonitor, + mockInternalProxy + ) + whenever(mockInternalProxy.getCurrentViewUrl()) doReturn fakeViewUrl + + val fakeMistypedTouch = mapOf( + "reactTag" to 1, + "x" to "not-a-number", + "y" to 2.0 + ).toReadableMap() + + val testedDdRumWithHeatmap = DdRumImplementation( + datadog = mockDatadog, + heatmapActionHandler = HeatmapActionHandler( + heatmapTouchResolver = mockHeatmapTouchResolver, + mainThreadExecutor = { it() } + ) + ) + + // When + testedDdRumWithHeatmap.addAction( + "TAP", + name, + fakeMistypedTouch, + fakeContext.toReadableMap(), + fakeTimestamp, + mockPromise + ) + + // Then + verify(mockRumMonitor).addAction(RumActionType.TAP, name, updatedContext) + verify(mockInternalProxy, never()).addActionWithHeatmap(any(), any(), any(), any()) + verify(mockPromise).resolve(null) + } + + @Test + fun `M fall back to addAction W addAction() TAP with no current view url`( + @StringForgery name: String, + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 300.0) fakeX: Double, + @DoubleForgery(0.0, 300.0) fakeY: Double + ) { + // Given + val updatedContext = fakeContext.toReadableMap().toHashMap().toMutableMap().apply { + put(RumAttributes.INTERNAL_TIMESTAMP, fakeTimestamp.toLong()) + } + HeatmapActionHandler.heatmapsEnabled = true + whenever(mockDatadog.getRumMonitor()) doReturn RumMonitorWithInternalProxy( + mockRumMonitor, + mockInternalProxy + ) + whenever(mockInternalProxy.getCurrentViewUrl()) doReturn null + + val fakeTouch = mapOf( + "reactTag" to fakeReactTag, + "x" to fakeX, + "y" to fakeY, + "pageX" to fakeX, + "pageY" to fakeY + ).toReadableMap() + + val testedDdRumWithHeatmap = DdRumImplementation( + datadog = mockDatadog, + heatmapActionHandler = HeatmapActionHandler( + heatmapTouchResolver = mockHeatmapTouchResolver, + mainThreadExecutor = { it() } + ) + ) + + // When + testedDdRumWithHeatmap.addAction( + "TAP", + name, + fakeTouch, + fakeContext.toReadableMap(), + fakeTimestamp, + mockPromise + ) + + // Then + verify(mockRumMonitor).addAction(RumActionType.TAP, name, updatedContext) + verify(mockInternalProxy, never()).addActionWithHeatmap(any(), any(), any(), any()) + verify(mockPromise).resolve(null) + } + + @Test + fun `M resolve promise before dispatching heatmap work W addAction() with TAP type`( + @StringForgery name: String, + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 300.0) fakeX: Double, + @DoubleForgery(0.0, 300.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + HeatmapActionHandler.heatmapsEnabled = true + whenever(mockDatadog.getRumMonitor()) doReturn RumMonitorWithInternalProxy( + mockRumMonitor, + mockInternalProxy + ) + whenever(mockInternalProxy.getCurrentViewUrl()) doReturn fakeViewUrl + + val fakeHeatmapData = CrossPlatformHeatmapActionData( + elementPath = listOf("cls:android.view.View#0"), + viewUrl = fakeViewUrl, + positionX = fakeX.toLong(), + positionY = fakeY.toLong(), + targetWidth = 100L, + targetHeight = 50L + ) + whenever( + mockHeatmapTouchResolver.resolveHeatmapActionData(any(), any(), any(), any()) + ) doReturn fakeHeatmapData + + val testedDdRumWithHeatmap = DdRumImplementation( + datadog = mockDatadog, + heatmapActionHandler = HeatmapActionHandler( + heatmapTouchResolver = mockHeatmapTouchResolver, + mainThreadExecutor = { it() } + ) + ) + val fakeTouch = mapOf( + "reactTag" to fakeReactTag, + "x" to fakeX, + "y" to fakeY, + "pageX" to fakeX, + "pageY" to fakeY + ).toReadableMap() + + // When + testedDdRumWithHeatmap.addAction( + "TAP", + name, + fakeTouch, + fakeContext.toReadableMap(), + fakeTimestamp, + mockPromise + ) + + // Then — order holds even though mainThreadExecutor runs synchronously here + inOrder(mockPromise, mockInternalProxy) { + verify(mockPromise).resolve(null) + verify(mockInternalProxy).addActionWithHeatmap(any(), any(), any(), any()) + } + } + + // endregion } diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/HeatmapTouchResolverTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/HeatmapTouchResolverTest.kt new file mode 100644 index 000000000..2ec6f1564 --- /dev/null +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/HeatmapTouchResolverTest.kt @@ -0,0 +1,523 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.reactnative + +import android.content.res.Resources +import android.util.DisplayMetrics +import android.view.View +import android.view.ViewGroup +import com.datadog.tools.unit.forge.BaseConfigurator +import fr.xgouchet.elmyr.annotation.DoubleForgery +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(BaseConfigurator::class) +internal class HeatmapTouchResolverTest { + + // region findTapTargetView + + @Test + fun `M return data with view dimensions W resolve TAP and view is clickable and visible`( + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 300.0) fakeX: Double, + @DoubleForgery(0.0, 300.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val mockView = mock() + val mockResources = mock() + val fakeDisplayMetrics = DisplayMetrics().apply { density = 2.0f } + whenever(mockView.isClickable) doReturn true + whenever(mockView.visibility) doReturn View.VISIBLE + whenever(mockView.id) doReturn View.NO_ID + whenever(mockView.parent) doReturn null + whenever(mockView.width) doReturn 120 + whenever(mockView.height) doReturn 60 + whenever(mockView.resources) doReturn mockResources + whenever(mockResources.displayMetrics) doReturn fakeDisplayMetrics + + val resolver = HeatmapTouchResolver(viewResolver = { mockView }) + + // When + val result = resolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + + // Then + assertThat(result).isNotNull() + assertThat(result!!.targetWidth).isEqualTo(60L) // 120 / 2.0 + assertThat(result.targetHeight).isEqualTo(30L) // 60 / 2.0 + assertThat(result.positionX).isEqualTo(fakeX.toLong()) + assertThat(result.positionY).isEqualTo(fakeY.toLong()) + assertThat(result.viewUrl).isEqualTo(fakeViewUrl) + } + + @Test + fun `M return data with parent dimensions W resolve TAP view not clickable but parent is`( + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 100.0) fakeX: Double, + @DoubleForgery(0.0, 100.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val mockParent = mock() + val mockParentResources = mock() + val fakeDisplayMetrics = DisplayMetrics().apply { density = 2.0f } + whenever(mockParent.isClickable) doReturn true + whenever(mockParent.visibility) doReturn View.VISIBLE + whenever(mockParent.id) doReturn View.NO_ID + whenever(mockParent.parent) doReturn null + whenever(mockParent.width) doReturn 200 + whenever(mockParent.height) doReturn 80 + whenever(mockParent.resources) doReturn mockParentResources + whenever(mockParentResources.displayMetrics) doReturn fakeDisplayMetrics + + val mockView = mock() + whenever(mockView.isClickable) doReturn false + whenever(mockView.parent) doReturn mockParent + + val resolver = HeatmapTouchResolver(viewResolver = { mockView }) + + // When + val result = resolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + + // Then + assertThat(result).isNotNull() + assertThat(result!!.targetWidth).isEqualTo(100L) // 200 / 2.0 + assertThat(result.targetHeight).isEqualTo(40L) // 80 / 2.0 + } + + @Test + fun `M return data with parent dimensions W resolve TAP view clickable but not visible`( + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 100.0) fakeX: Double, + @DoubleForgery(0.0, 100.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val mockParent = mock() + val mockParentResources = mock() + val fakeDisplayMetrics = DisplayMetrics().apply { density = 2.0f } + whenever(mockParent.isClickable) doReturn true + whenever(mockParent.visibility) doReturn View.VISIBLE + whenever(mockParent.id) doReturn View.NO_ID + whenever(mockParent.parent) doReturn null + whenever(mockParent.width) doReturn 160 + whenever(mockParent.height) doReturn 40 + whenever(mockParent.resources) doReturn mockParentResources + whenever(mockParentResources.displayMetrics) doReturn fakeDisplayMetrics + + val mockView = mock() + whenever(mockView.isClickable) doReturn true + whenever(mockView.visibility) doReturn View.INVISIBLE + whenever(mockView.parent) doReturn mockParent + + val resolver = HeatmapTouchResolver(viewResolver = { mockView }) + + // When + val result = resolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + + // Then + assertThat(result).isNotNull() + assertThat(result!!.targetWidth).isEqualTo(80L) // 160 / 2.0 + assertThat(result.targetHeight).isEqualTo(20L) // 40 / 2.0 + } + + @Test + fun `M return null W resolve TAP and view resolved but no clickable ancestor`( + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 100.0) fakeX: Double, + @DoubleForgery(0.0, 100.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val mockView = mock() + whenever(mockView.isClickable) doReturn false + whenever(mockView.visibility) doReturn View.VISIBLE + whenever(mockView.parent) doReturn null + + val resolver = HeatmapTouchResolver(viewResolver = { mockView }) + + // When + val result = resolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + + // Then + assertThat(result).isNull() + } + + @Test + fun `M return null W resolve and viewResolver returns null`( + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 100.0) fakeX: Double, + @DoubleForgery(0.0, 100.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + val resolver = HeatmapTouchResolver(viewResolver = { null }) + + val result = resolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + + assertThat(result).isNull() + } + + @Test + fun `M return null W resolve and view traversal throws`( + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 100.0) fakeX: Double, + @DoubleForgery(0.0, 100.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val mockView = mock() + whenever(mockView.isClickable).thenThrow(RuntimeException("unexpected")) + + val resolver = HeatmapTouchResolver(viewResolver = { mockView }) + + // When + val result = resolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + + // Then + assertThat(result).isNull() + } + + @Test + fun `M use default density W resolve and view has no resources`( + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 100.0) fakeX: Double, + @DoubleForgery(0.0, 100.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val mockView = mock() + whenever(mockView.isClickable) doReturn true + whenever(mockView.visibility) doReturn View.VISIBLE + whenever(mockView.id) doReturn View.NO_ID + whenever(mockView.parent) doReturn null + whenever(mockView.width) doReturn 100 + whenever(mockView.height) doReturn 50 + whenever(mockView.resources) doReturn null + + val resolver = HeatmapTouchResolver(viewResolver = { mockView }) + + // When + val result = resolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + + // Then + assertThat(result).isNotNull() + assertThat(result!!.targetWidth).isEqualTo(100L) + assertThat(result.targetHeight).isEqualTo(50L) + } + + @Test + fun `M return null dimensions W resolve and view has zero width and height`( + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 100.0) fakeX: Double, + @DoubleForgery(0.0, 100.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val mockView = mock() + val mockResources = mock() + val fakeDisplayMetrics = DisplayMetrics().apply { density = 1.0f } + whenever(mockView.isClickable) doReturn true + whenever(mockView.visibility) doReturn View.VISIBLE + whenever(mockView.id) doReturn View.NO_ID + whenever(mockView.parent) doReturn null + whenever(mockView.width) doReturn 0 + whenever(mockView.height) doReturn 0 + whenever(mockView.resources) doReturn mockResources + whenever(mockResources.displayMetrics) doReturn fakeDisplayMetrics + + val resolver = HeatmapTouchResolver(viewResolver = { mockView }) + + // When + val result = resolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + + // Then + assertThat(result).isNotNull() + assertThat(result!!.targetWidth).isNull() + assertThat(result.targetHeight).isNull() + } + + // endregion + + // region buildElementPath / pathComponentFor / computeTypeIndex + + @Test + fun `M elementPath is root-first W view inside parent`( + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 100.0) fakeX: Double, + @DoubleForgery(0.0, 100.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val fakeParentResourceId = 1 + val fakeParentResourceName = "com.example:id/parent_view" + val fakeViewResourceId = 2 + val fakeViewResourceName = "com.example:id/child_view" + + val mockParent = mock() + val mockParentResources = mock() + val fakeDisplayMetrics = DisplayMetrics().apply { density = 1.0f } + whenever(mockParent.id) doReturn fakeParentResourceId + whenever(mockParent.parent) doReturn null + whenever(mockParent.isClickable) doReturn false + whenever(mockParent.resources) doReturn mockParentResources + whenever(mockParentResources.displayMetrics) doReturn fakeDisplayMetrics + whenever( + mockParentResources.getResourceName(fakeParentResourceId) + ) doReturn fakeParentResourceName + + val mockView = mock() + val mockResources = mock() + whenever(mockView.isClickable) doReturn true + whenever(mockView.visibility) doReturn View.VISIBLE + whenever(mockView.id) doReturn fakeViewResourceId + whenever(mockView.parent) doReturn mockParent + whenever(mockView.width) doReturn 100 + whenever(mockView.height) doReturn 50 + whenever(mockView.resources) doReturn mockResources + whenever(mockResources.displayMetrics) doReturn fakeDisplayMetrics + whenever(mockResources.getResourceName(fakeViewResourceId)) doReturn fakeViewResourceName + + val resolver = HeatmapTouchResolver(viewResolver = { mockView }) + + // When + val result = resolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + + // Then + assertThat(result).isNotNull() + assertThat(result!!.elementPath).containsExactly( + "$fakeParentResourceName#0", + "$fakeViewResourceName#0" + ) + } + + @Test + fun `M elementPath uses resource name W view has valid resource id`( + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 100.0) fakeX: Double, + @DoubleForgery(0.0, 100.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val fakeResourceId = 42 + val fakeResourceName = "com.example:id/my_button" + val mockView = mock() + val mockResources = mock() + val fakeDisplayMetrics = DisplayMetrics().apply { density = 1.0f } + whenever(mockView.isClickable) doReturn true + whenever(mockView.visibility) doReturn View.VISIBLE + whenever(mockView.id) doReturn fakeResourceId + whenever(mockView.parent) doReturn null + whenever(mockView.width) doReturn 100 + whenever(mockView.height) doReturn 50 + whenever(mockView.resources) doReturn mockResources + whenever(mockResources.displayMetrics) doReturn fakeDisplayMetrics + whenever(mockResources.getResourceName(fakeResourceId)) doReturn fakeResourceName + + val resolver = HeatmapTouchResolver(viewResolver = { mockView }) + + // When + val result = resolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + + // Then + assertThat(result).isNotNull() + assertThat(result!!.elementPath.last()).isEqualTo("$fakeResourceName#0") + } + + @Test + fun `M elementPath uses cls prefix W view has NO_ID`( + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 100.0) fakeX: Double, + @DoubleForgery(0.0, 100.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val mockView = mock() + val mockResources = mock() + val fakeDisplayMetrics = DisplayMetrics().apply { density = 1.0f } + whenever(mockView.isClickable) doReturn true + whenever(mockView.visibility) doReturn View.VISIBLE + whenever(mockView.id) doReturn View.NO_ID + whenever(mockView.parent) doReturn null + whenever(mockView.width) doReturn 100 + whenever(mockView.height) doReturn 50 + whenever(mockView.resources) doReturn mockResources + whenever(mockResources.displayMetrics) doReturn fakeDisplayMetrics + + val resolver = HeatmapTouchResolver(viewResolver = { mockView }) + + // When + val result = resolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + + // Then + assertThat(result).isNotNull() + assertThat(result!!.elementPath.last()).startsWith("cls:") + assertThat(result.elementPath.last()).endsWith("#0") + } + + @Test + fun `M elementPath uses cls prefix W getResourceName throws NotFoundException`( + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 100.0) fakeX: Double, + @DoubleForgery(0.0, 100.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val mockView = mock() + val mockResources = mock() + val fakeDisplayMetrics = DisplayMetrics().apply { density = 1.0f } + whenever(mockView.isClickable) doReturn true + whenever(mockView.visibility) doReturn View.VISIBLE + whenever(mockView.id) doReturn 999 + whenever(mockView.parent) doReturn null + whenever(mockView.width) doReturn 100 + whenever(mockView.height) doReturn 50 + whenever(mockView.resources) doReturn mockResources + whenever(mockResources.displayMetrics) doReturn fakeDisplayMetrics + whenever(mockResources.getResourceName(999)).thenThrow( + Resources.NotFoundException("not found") + ) + + val resolver = HeatmapTouchResolver(viewResolver = { mockView }) + + // When + val result = resolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + + // Then + assertThat(result).isNotNull() + assertThat(result!!.elementPath.last()).startsWith("cls:") + } + + @Test + fun `M elementPath type index counts same-class siblings W computeTypeIndex`( + @IntForgery(min = 1) fakeReactTag: Int, + @DoubleForgery(0.0, 100.0) fakeX: Double, + @DoubleForgery(0.0, 100.0) fakeY: Double, + @StringForgery fakeViewUrl: String + ) { + // Given + val mockParent = mock() + val mockParentResources = mock() + val fakeDisplayMetrics = DisplayMetrics().apply { density = 1.0f } + whenever(mockParent.id) doReturn View.NO_ID + whenever(mockParent.parent) doReturn null + whenever(mockParent.isClickable) doReturn false + whenever(mockParent.resources) doReturn mockParentResources + whenever(mockParentResources.displayMetrics) doReturn fakeDisplayMetrics + + val mockSibling1 = mock() + val mockSibling2 = mock() + val mockView = mock() + // All three are the same Mockito proxy class → computeTypeIndex counts 2 before mockView + whenever(mockParent.childCount) doReturn 3 + whenever(mockParent.getChildAt(0)) doReturn mockSibling1 + whenever(mockParent.getChildAt(1)) doReturn mockSibling2 + whenever(mockParent.getChildAt(2)) doReturn mockView + + val mockResources = mock() + whenever(mockView.isClickable) doReturn true + whenever(mockView.visibility) doReturn View.VISIBLE + whenever(mockView.id) doReturn View.NO_ID + whenever(mockView.parent) doReturn mockParent + whenever(mockView.width) doReturn 100 + whenever(mockView.height) doReturn 50 + whenever(mockView.resources) doReturn mockResources + whenever(mockResources.displayMetrics) doReturn fakeDisplayMetrics + + val resolver = HeatmapTouchResolver(viewResolver = { mockView }) + + // When + val result = resolver.resolveHeatmapActionData( + fakeReactTag, + fakeX.toLong(), + fakeY.toLong(), + fakeViewUrl + ) + + // Then + assertThat(result).isNotNull() + assertThat(result!!.elementPath.last()).endsWith("#2") + } + + // endregion +} diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt index 7f7e6e0ec..bee3b3565 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt @@ -11,6 +11,7 @@ import com.datadog.android.Datadog import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.sessionreplay.SessionReplayConfiguration import com.datadog.android.sessionreplay._SessionReplayInternalProxy +import com.datadog.reactnative.HeatmapActionHandler import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactContext @@ -32,10 +33,9 @@ class DdSessionReplayImplementation( * @param customEndpoint Custom server url for sending replay data. * @param startRecordingImmediately Whether the recording should start immediately when the feature is enabled. * @param enableHeatmaps Enables heatmap identifier computation. - * Currently unused on Android; reserved for heatmap support. */ @SuppressLint("VisibleForTests") - @Suppress("LongParameterList", "UnusedParameter") + @Suppress("LongParameterList") fun enable( replaySampleRate: Double, customEndpoint: String, @@ -53,6 +53,7 @@ class DdSessionReplayImplementation( .setImagePrivacy(privacySettings.imagePrivacyLevel) .setTouchPrivacy(privacySettings.touchPrivacyLevel) .setTextAndInputPrivacy(privacySettings.textAndInputPrivacyLevel) + .setHeatmapsEnabled(enableHeatmaps) .addExtensionSupport(ReactNativeSessionReplayExtensionSupport(textViewUtils, internalCallback)) .let { _SessionReplayInternalProxy(it).setInternalCallback(internalCallback) @@ -63,6 +64,7 @@ class DdSessionReplayImplementation( } sessionReplayProvider().enable(configuration.build(), sdkCore) + HeatmapActionHandler.heatmapsEnabled = enableHeatmaps promise.resolve(null) } diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt index b313ac1ed..cb3385866 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt @@ -12,6 +12,7 @@ import com.datadog.android.sessionreplay.SessionReplayConfiguration import com.datadog.android.sessionreplay.SessionReplayPrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.TouchPrivacy +import com.datadog.reactnative.HeatmapActionHandler import com.datadog.tools.unit.GenericAssert.Companion.assertThat import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.Promise @@ -22,7 +23,9 @@ import fr.xgouchet.elmyr.annotation.DoubleForgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension import java.io.IOException +import org.assertj.core.api.Assertions.assertThat as assertJThat import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -92,6 +95,8 @@ internal class DdSessionReplayImplementationTest { @AfterEach fun `tear down`() { + // Reset global heatmaps flag to avoid leaking state between tests + HeatmapActionHandler.heatmapsEnabled = false } @Test @@ -178,4 +183,138 @@ internal class DdSessionReplayImplementationTest { .hasFieldEqualTo("startRecordingImmediately", startRecordingImmediately) .doesNotHaveField("customEndpointUrl") } + + // region enableHeatmaps flag + + @Test + fun `M set HeatmapActionHandler heatmapsEnabled to true W enable() with enableHeatmaps true`( + @DoubleForgery(min = 0.0, max = 100.0) replaySampleRate: Double, + @BoolForgery startRecordingImmediately: Boolean + ) { + // Given + val imagePrivacy = imagePrivacyMap.keys.random() + val touchPrivacy = touchPrivacyMap.keys.random() + val textAndInputPrivacy = inputPrivacyMap.keys.random() + + // When + testedSessionReplay.enable( + replaySampleRate, + "", + SessionReplayPrivacySettings(imagePrivacy, touchPrivacy, textAndInputPrivacy), + startRecordingImmediately, + enableHeatmaps = true, + mockPromise + ) + + // Then + assertJThat(HeatmapActionHandler.heatmapsEnabled).isTrue() + } + + @Test + fun `M set HeatmapActionHandler heatmapsEnabled to false W enable() with enableHeatmaps false`( + @DoubleForgery(min = 0.0, max = 100.0) replaySampleRate: Double, + @BoolForgery startRecordingImmediately: Boolean + ) { + // Given — force the flag to true first to verify it gets set back to false + HeatmapActionHandler.heatmapsEnabled = true + val imagePrivacy = imagePrivacyMap.keys.random() + val touchPrivacy = touchPrivacyMap.keys.random() + val textAndInputPrivacy = inputPrivacyMap.keys.random() + + // When + testedSessionReplay.enable( + replaySampleRate, + "", + SessionReplayPrivacySettings(imagePrivacy, touchPrivacy, textAndInputPrivacy), + startRecordingImmediately, + enableHeatmaps = false, + mockPromise + ) + + // Then + assertJThat(HeatmapActionHandler.heatmapsEnabled).isFalse() + } + + @Test + fun `M build config with heatmapsEnabled true W enable() with enableHeatmaps true`( + @DoubleForgery(min = 0.0, max = 100.0) replaySampleRate: Double, + @BoolForgery startRecordingImmediately: Boolean + ) { + // Given + val imagePrivacy = imagePrivacyMap.keys.random() + val touchPrivacy = touchPrivacyMap.keys.random() + val textAndInputPrivacy = inputPrivacyMap.keys.random() + val sessionReplayConfigCaptor = argumentCaptor() + + // When + testedSessionReplay.enable( + replaySampleRate, + "", + SessionReplayPrivacySettings(imagePrivacy, touchPrivacy, textAndInputPrivacy), + startRecordingImmediately, + enableHeatmaps = true, + mockPromise + ) + + // Then — heatmapsEnabled is true in the built SessionReplayConfiguration + verify(mockSessionReplay).enable(sessionReplayConfigCaptor.capture(), any()) + assertThat(sessionReplayConfigCaptor.firstValue) + .hasFieldEqualTo("heatmapsEnabled", true) + } + + @Test + fun `M build config with heatmapsEnabled false W enable() with enableHeatmaps false`( + @DoubleForgery(min = 0.0, max = 100.0) replaySampleRate: Double, + @BoolForgery startRecordingImmediately: Boolean + ) { + // Given + val imagePrivacy = imagePrivacyMap.keys.random() + val touchPrivacy = touchPrivacyMap.keys.random() + val textAndInputPrivacy = inputPrivacyMap.keys.random() + val sessionReplayConfigCaptor = argumentCaptor() + + // When + testedSessionReplay.enable( + replaySampleRate, + "", + SessionReplayPrivacySettings(imagePrivacy, touchPrivacy, textAndInputPrivacy), + startRecordingImmediately, + enableHeatmaps = false, + mockPromise + ) + + // Then — heatmapsEnabled is false in the built SessionReplayConfiguration + verify(mockSessionReplay).enable(sessionReplayConfigCaptor.capture(), any()) + assertThat(sessionReplayConfigCaptor.firstValue) + .hasFieldEqualTo("heatmapsEnabled", false) + } + + @Test + fun `M not set HeatmapActionHandler heatmapsEnabled W enable() throws`( + @DoubleForgery(min = 0.0, max = 100.0) replaySampleRate: Double, + @BoolForgery startRecordingImmediately: Boolean + ) { + // Given — the underlying SR provider fails before the heatmaps flag would be set + val imagePrivacy = imagePrivacyMap.keys.random() + val touchPrivacy = touchPrivacyMap.keys.random() + val textAndInputPrivacy = inputPrivacyMap.keys.random() + whenever(mockSessionReplay.enable(any(), any())) doThrow IllegalStateException("boom") + + // When / Then + assertThrows(IllegalStateException::class.java) { + testedSessionReplay.enable( + replaySampleRate, + "", + SessionReplayPrivacySettings(imagePrivacy, touchPrivacy, textAndInputPrivacy), + startRecordingImmediately, + enableHeatmaps = true, + mockPromise + ) + } + + // Then — heatmapsEnabled was never reached, so it stays at its default + assertJThat(HeatmapActionHandler.heatmapsEnabled).isFalse() + } + + // endregion } From b81ec1d712152a79804baada34a3b7b578282fd4 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:18:03 +0300 Subject: [PATCH 3/3] RUMS-5681: Add exception logging --- .../reactnative/DdRumImplementation.kt | 2 +- .../reactnative/HeatmapActionHandler.kt | 23 +++++++++++++++++-- .../reactnative/HeatmapTouchResolver.kt | 5 +++- .../kotlin/com/datadog/reactnative/DdRum.kt | 5 +++- .../kotlin/com/datadog/reactnative/DdRum.kt | 5 +++- .../reactnative/HeatmapTouchResolverTest.kt | 12 +++++++--- 6 files changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdRumImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdRumImplementation.kt index 23fca1f90..e090f5338 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdRumImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdRumImplementation.kt @@ -139,7 +139,7 @@ class DdRumImplementation internal constructor( put(RumAttributes.INTERNAL_TIMESTAMP, timestampMs.toLong()) } - val eligibleAction = heatmapActionHandler.resolveEligibility(datadog, type, touch) + val eligibleAction = heatmapActionHandler.resolveEligibility(datadog, type, name, touch) if (eligibleAction == null) { addActionWithoutHeatmap(type, name, attributes) promise.resolve(null) diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapActionHandler.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapActionHandler.kt index 25ff7479f..5a9eef6bc 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapActionHandler.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapActionHandler.kt @@ -8,6 +8,7 @@ package com.datadog.reactnative import android.os.Handler import android.os.Looper +import com.datadog.android.api.InternalLogger import com.datadog.android.rum.RumActionType import com.datadog.android.rum._RumInternalProxy import com.facebook.react.bridge.ReadableMap @@ -35,6 +36,7 @@ class HeatmapActionHandler internal constructor( internal fun resolveEligibility( datadog: DatadogWrapper, type: String, + name: String, touch: ReadableMap? ): EligibleAction? { if (!heatmapsEnabled || touch == null || !type.equals("tap", ignoreCase = true)) { @@ -44,7 +46,7 @@ class HeatmapActionHandler internal constructor( val internalProxy = datadog.getRumMonitor()._getInternal() // Read now — the view can transition asynchronously right after a tap. val viewUrl = internalProxy?.getCurrentViewUrl() - val touchFields = touch.toTouchFieldsOrNull() + val touchFields = touch.toTouchFieldsOrNull(name) return if (internalProxy != null && viewUrl != null && touchFields != null) { val (reactTag, positionX, positionY) = touchFields @@ -80,10 +82,27 @@ class HeatmapActionHandler internal constructor( } } - private fun ReadableMap.toTouchFieldsOrNull(): Triple? { + // Missing fields are not warned on: `actionContext` is an optional addAction parameter, and + // a caller intentionally tracking a TAP action without real touch coordinates is valid usage. + private fun ReadableMap.toTouchFieldsOrNull(actionName: String): Triple? { if (!hasKey("reactTag") || !hasKey("x") || !hasKey("y")) return null return runCatching { Triple(getInt("reactTag"), getDouble("x").toLong(), getDouble("y").toLong()) + }.onFailure { + InternalLogger.UNBOUND.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { + "addAction(\"$actionName\"): heatmap tracking requires actionContext's " + + "nativeEvent.target/locationX/locationY to be numbers, as produced by a " + + "standard onPress GestureResponderEvent. The value passed for this " + + "action didn't match that shape, so heatmap tracking was skipped for it " + + "— this is expected if actionContext came from a non-standard event " + + "source (e.g. a different gesture library)." + }, + throwable = it, + onlyOnce = true + ) }.getOrNull() } diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapTouchResolver.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapTouchResolver.kt index 7193be574..94054bbe1 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapTouchResolver.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/HeatmapTouchResolver.kt @@ -18,7 +18,8 @@ import com.datadog.android.heatmaps.CrossPlatformHeatmapActionData * the resulting hash matches the `permanentId` SR assigned to that view's wireframe. */ internal class HeatmapTouchResolver( - private val viewResolver: (Int) -> View? = { null } + private val viewResolver: (Int) -> View? = { null }, + private val telemetry: DdTelemetry = DdTelemetry() ) { /** Returns null if [reactTag] doesn't resolve to a valid tap target. */ @@ -47,6 +48,8 @@ internal class HeatmapTouchResolver( } else { null } + }.onFailure { + telemetry.telemetryError("Failed to resolve heatmap action data", it) }.getOrNull() // region Private helpers diff --git a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdRum.kt b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdRum.kt index b9379e26d..72baeecdd 100644 --- a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdRum.kt +++ b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdRum.kt @@ -24,6 +24,8 @@ class DdRum( datadogWrapper: DatadogWrapper = DatadogSDKWrapper() ) : NativeDdRumSpec(reactContext) { + private val telemetry = DdTelemetry() + private val implementation = DdRumImplementation( datadog = datadogWrapper, heatmapActionHandler = HeatmapActionHandler( @@ -32,7 +34,8 @@ class DdRum( val uiManagerType = ViewUtil.getUIManagerType(reactTag) UIManagerHelper.getUIManager(reactApplicationContext, uiManagerType) ?.resolveView(reactTag) - } catch (_: Exception) { + } catch (e: Exception) { + telemetry.telemetryError("Failed to resolve view for heatmap tracking", e) null } }) diff --git a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdRum.kt b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdRum.kt index 4259c0bc8..1a2a7887b 100644 --- a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdRum.kt +++ b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdRum.kt @@ -25,6 +25,8 @@ class DdRum( datadogWrapper: DatadogWrapper = DatadogSDKWrapper() ) : ReactContextBaseJavaModule(reactContext) { + private val telemetry = DdTelemetry() + private val implementation = DdRumImplementation( datadog = datadogWrapper, heatmapActionHandler = HeatmapActionHandler( @@ -32,7 +34,8 @@ class DdRum( try { UIManagerHelper.getUIManager(reactApplicationContext, UIManagerType.DEFAULT) ?.resolveView(reactTag) - } catch (_: Exception) { + } catch (e: Exception) { + telemetry.telemetryError("Failed to resolve view for heatmap tracking", e) null } }) diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/HeatmapTouchResolverTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/HeatmapTouchResolverTest.kt index 2ec6f1564..b82ea331c 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/HeatmapTouchResolverTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/HeatmapTouchResolverTest.kt @@ -22,8 +22,11 @@ import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions 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.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -204,7 +207,7 @@ internal class HeatmapTouchResolverTest { } @Test - fun `M return null W resolve and view traversal throws`( + fun `M return null and report telemetry W resolve and view traversal throws`( @IntForgery(min = 1) fakeReactTag: Int, @DoubleForgery(0.0, 100.0) fakeX: Double, @DoubleForgery(0.0, 100.0) fakeY: Double, @@ -212,9 +215,11 @@ internal class HeatmapTouchResolverTest { ) { // Given val mockView = mock() - whenever(mockView.isClickable).thenThrow(RuntimeException("unexpected")) + val fakeException = RuntimeException("unexpected") + val mockTelemetry = mock() + whenever(mockView.isClickable).thenThrow(fakeException) - val resolver = HeatmapTouchResolver(viewResolver = { mockView }) + val resolver = HeatmapTouchResolver(viewResolver = { mockView }, telemetry = mockTelemetry) // When val result = resolver.resolveHeatmapActionData( @@ -226,6 +231,7 @@ internal class HeatmapTouchResolverTest { // Then assertThat(result).isNull() + verify(mockTelemetry).telemetryError(any(), eq(fakeException)) } @Test