diff --git a/example-new-architecture/ios/Podfile.lock b/example-new-architecture/ios/Podfile.lock index 692567406..19bb79785 100644 --- a/example-new-architecture/ios/Podfile.lock +++ b/example-new-architecture/ios/Podfile.lock @@ -1,23 +1,23 @@ PODS: - boost (1.76.0) - CocoaAsyncSocket (7.6.5) - - DatadogCore (2.4.0): - - DatadogInternal (= 2.4.0) - - DatadogCrashReporting (2.4.0): - - DatadogInternal (= 2.4.0) + - DatadogCore (2.5.0): + - DatadogInternal (= 2.5.0) + - DatadogCrashReporting (2.5.0): + - DatadogInternal (= 2.5.0) - PLCrashReporter (~> 1.11.1) - - DatadogInternal (2.4.0) - - DatadogLogs (2.4.0): - - DatadogInternal (= 2.4.0) - - DatadogRUM (2.4.0): - - DatadogInternal (= 2.4.0) + - DatadogInternal (2.5.0) + - DatadogLogs (2.5.0): + - DatadogInternal (= 2.5.0) + - DatadogRUM (2.5.0): + - DatadogInternal (= 2.5.0) - DatadogSDKReactNative (2.0.1): - - DatadogCore (~> 2.4.0) - - DatadogCrashReporting (~> 2.4.0) - - DatadogLogs (~> 2.4.0) - - DatadogRUM (~> 2.4.0) - - DatadogTrace (~> 2.4.0) - - DatadogWebViewTracking (~> 2.4.0) + - DatadogCore (~> 2.5.0) + - DatadogCrashReporting (~> 2.5.0) + - DatadogLogs (~> 2.5.0) + - DatadogRUM (~> 2.5.0) + - DatadogTrace (~> 2.5.0) + - DatadogWebViewTracking (~> 2.5.0) - RCT-Folly (= 2021.07.22.00) - RCTRequired - RCTTypeSafety @@ -26,10 +26,10 @@ PODS: - React-RCTFabric - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - DatadogTrace (2.4.0): - - DatadogInternal (= 2.4.0) - - DatadogWebViewTracking (2.4.0): - - DatadogInternal (= 2.4.0) + - DatadogTrace (2.5.0): + - DatadogInternal (= 2.5.0) + - DatadogWebViewTracking (2.5.0): + - DatadogInternal (= 2.5.0) - DoubleConversion (1.1.6) - FBLazyVector (0.71.10) - FBReactNativeSpec (0.71.10): @@ -959,14 +959,14 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 57d2868c099736d80fcd648bf211b4431e51a558 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 - DatadogCore: 67ce2f8cd2f58cc90bdee5486d847b49e4b31b0b - DatadogCrashReporting: 0d4bd014714946be77a1ed2d34897036892f2b7b - DatadogInternal: 5789bca7a0284b20655ba2a79738ac7d0cd56e70 - DatadogLogs: 3b8c8778c32b780f916c2894b9d2c53bbf590803 - DatadogRUM: 4207d091be536b888719969a7ca078e2c830819a - DatadogSDKReactNative: 9c95bed4eef14236ad675d88d6d3605a41f5d7be - DatadogTrace: ec75b1da1dcf9f5b574481a3773b296e0e1fda38 - DatadogWebViewTracking: e7a5841f001f488fc0240d7cba1a984ab6c86e9a + DatadogCore: a152fbcc24ea1a6b937c9844b1c1d5b86f0a375e + DatadogCrashReporting: 53b458152130de5505901e025e0dd031ce057f31 + DatadogInternal: 96448807156495aa41a9f177b8c849a404618948 + DatadogLogs: 2e67adf2e2cccd84b880b42f52e56cd0b8c7ef82 + DatadogRUM: d807827ad24ae6c738867e853df38c6cb2bb555b + DatadogSDKReactNative: 0b659c7de3043d16465ae3fd6e2d418a5b62261c + DatadogTrace: 703d7572acc1dcda474ab33b7db3d8d67984192c + DatadogWebViewTracking: 87c0c1c9de4da7bb4f9efb87595da6c3d333aba9 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 FBLazyVector: ddb55c55295ea51ed98aa7e2e08add2f826309d5 FBReactNativeSpec: 33a87f65f1a467d5f63d11d0cc106a10d3b0639d diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index b97b71251..30197e7e9 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,43 +1,44 @@ PODS: - boost (1.76.0) - - DatadogCore (2.4.0): - - DatadogInternal (= 2.4.0) - - DatadogCrashReporting (2.4.0): - - DatadogInternal (= 2.4.0) + - DatadogCore (2.5.0): + - DatadogInternal (= 2.5.0) + - DatadogCrashReporting (2.5.0): + - DatadogInternal (= 2.5.0) - PLCrashReporter (~> 1.11.1) - - DatadogInternal (2.4.0) - - DatadogLogs (2.4.0): - - DatadogInternal (= 2.4.0) - - DatadogRUM (2.4.0): - - DatadogInternal (= 2.4.0) + - DatadogInternal (2.5.0) + - DatadogLogs (2.5.0): + - DatadogInternal (= 2.5.0) + - DatadogRUM (2.5.0): + - DatadogInternal (= 2.5.0) - DatadogSDKReactNative (2.0.1): - - DatadogCore (~> 2.4.0) - - DatadogCrashReporting (~> 2.4.0) - - DatadogLogs (~> 2.4.0) - - DatadogRUM (~> 2.4.0) - - DatadogTrace (~> 2.4.0) - - DatadogWebViewTracking (~> 2.4.0) + - DatadogCore (~> 2.5.0) + - DatadogCrashReporting (~> 2.5.0) + - DatadogLogs (~> 2.5.0) + - DatadogRUM (~> 2.5.0) + - DatadogTrace (~> 2.5.0) + - DatadogWebViewTracking (~> 2.5.0) - React-Core - DatadogSDKReactNative/Tests (2.0.1): - - DatadogCore (~> 2.4.0) - - DatadogCrashReporting (~> 2.4.0) - - DatadogLogs (~> 2.4.0) - - DatadogRUM (~> 2.4.0) - - DatadogTrace (~> 2.4.0) - - DatadogWebViewTracking (~> 2.4.0) + - DatadogCore (~> 2.5.0) + - DatadogCrashReporting (~> 2.5.0) + - DatadogLogs (~> 2.5.0) + - DatadogRUM (~> 2.5.0) + - DatadogTrace (~> 2.5.0) + - DatadogWebViewTracking (~> 2.5.0) - React-Core - DatadogSDKReactNativeSessionReplay (2.0.1): - - DatadogSessionReplay (~> 2.4.0) + - DatadogSessionReplay (~> 2.5.0) - React-Core - DatadogSDKReactNativeSessionReplay/Tests (2.0.1): - - DatadogSessionReplay (~> 2.4.0) + - DatadogSessionReplay (~> 2.5.0) - React-Core - - DatadogSessionReplay (2.4.0): - - DatadogInternal (= 2.4.0) - - DatadogTrace (2.4.0): - - DatadogInternal (= 2.4.0) - - DatadogWebViewTracking (2.4.0): - - DatadogInternal (= 2.4.0) + - React-RCTText + - DatadogSessionReplay (2.5.0): + - DatadogInternal (= 2.5.0) + - DatadogTrace (2.5.0): + - DatadogInternal (= 2.5.0) + - DatadogWebViewTracking (2.5.0): + - DatadogInternal (= 2.5.0) - DoubleConversion (1.1.6) - FBLazyVector (0.71.10) - FBReactNativeSpec (0.71.10): @@ -569,16 +570,16 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 57d2868c099736d80fcd648bf211b4431e51a558 - DatadogCore: 67ce2f8cd2f58cc90bdee5486d847b49e4b31b0b - DatadogCrashReporting: 0d4bd014714946be77a1ed2d34897036892f2b7b - DatadogInternal: 5789bca7a0284b20655ba2a79738ac7d0cd56e70 - DatadogLogs: 3b8c8778c32b780f916c2894b9d2c53bbf590803 - DatadogRUM: 4207d091be536b888719969a7ca078e2c830819a - DatadogSDKReactNative: b4d2c0926219c7fa0c7fdb3b9faf963b0c37bc4f - DatadogSDKReactNativeSessionReplay: c320633e2dc1f8d6a8656ff60ca9a3d9cf305035 - DatadogSessionReplay: 8d17ac983669b62e3dd9159cf4a04e98fcf9abeb - DatadogTrace: ec75b1da1dcf9f5b574481a3773b296e0e1fda38 - DatadogWebViewTracking: e7a5841f001f488fc0240d7cba1a984ab6c86e9a + DatadogCore: a152fbcc24ea1a6b937c9844b1c1d5b86f0a375e + DatadogCrashReporting: 53b458152130de5505901e025e0dd031ce057f31 + DatadogInternal: 96448807156495aa41a9f177b8c849a404618948 + DatadogLogs: 2e67adf2e2cccd84b880b42f52e56cd0b8c7ef82 + DatadogRUM: d807827ad24ae6c738867e853df38c6cb2bb555b + DatadogSDKReactNative: 3bdadcd1ad69e2fb9fd53b823dddaa8d503edf6f + DatadogSDKReactNativeSessionReplay: 08001fa73bc35a9acb783cd4c4428cc3d22fdeb9 + DatadogSessionReplay: e264895cd8093c22408f518e0c736f6c3ca6882c + DatadogTrace: 703d7572acc1dcda474ab33b7db3d8d67984192c + DatadogWebViewTracking: 87c0c1c9de4da7bb4f9efb87595da6c3d333aba9 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 FBLazyVector: ddb55c55295ea51ed98aa7e2e08add2f826309d5 FBReactNativeSpec: 90fc1a90b4b7a171e0a7c20ea426c1bf6ce4399c diff --git a/example/ios/PodfileForTests b/example/ios/PodfileForSRTests similarity index 74% rename from example/ios/PodfileForTests rename to example/ios/PodfileForSRTests index eaa582754..64a08d0c0 100644 --- a/example/ios/PodfileForTests +++ b/example/ios/PodfileForSRTests @@ -21,8 +21,23 @@ if linkage != nil use_frameworks! :linkage => linkage.to_sym end +use_modular_headers! + target 'ddSdkReactnativeExample' do + native_ios_sdk_path = ENV['DD_NATIVE_IOS_SDK_PATH'] + if (native_ios_sdk_path) then + pod 'DatadogCore', :path => "#{native_ios_sdk_path}/DatadogCore.podspec" + pod 'DatadogLogs', :path => "#{native_ios_sdk_path}/DatadogLogs.podspec" + pod 'DatadogTrace', :path => "#{native_ios_sdk_path}/DatadogTrace.podspec" + pod 'DatadogInternal', :path => "#{native_ios_sdk_path}/DatadogInternal.podspec" + pod 'DatadogRUM', :path => "#{native_ios_sdk_path}/DatadogRUM.podspec" + pod 'DatadogCrashReporting', :path => "#{native_ios_sdk_path}/DatadogCrashReporting.podspec" + pod 'DatadogWebViewTracking', :path => "#{native_ios_sdk_path}/DatadogWebViewTracking.podspec" + pod 'DatadogSessionReplay', :path => "#{native_ios_sdk_path}/DatadogSessionReplay.podspec" + end + pod 'DatadogSDKReactNative', :path => '../../packages/core/DatadogSDKReactNative.podspec', :testspecs => ['Tests'] + pod 'DatadogSDKReactNativeSessionReplay', :path => '../../packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec', :testspecs => ['Tests'] config = use_native_modules! diff --git a/packages/core/DatadogSDKReactNative.podspec b/packages/core/DatadogSDKReactNative.podspec index 840b9946e..c71a9f671 100644 --- a/packages/core/DatadogSDKReactNative.podspec +++ b/packages/core/DatadogSDKReactNative.podspec @@ -19,12 +19,12 @@ Pod::Spec.new do |s| s.dependency "React-Core" # /!\ Remember to keep the versions in sync with DatadogSDKReactNativeSessionReplay.podspec - s.dependency 'DatadogCore', '~> 2.4.0' - s.dependency 'DatadogLogs', '~> 2.4.0' - s.dependency 'DatadogTrace', '~> 2.4.0' - s.dependency 'DatadogRUM', '~> 2.4.0' - s.dependency 'DatadogCrashReporting', '~> 2.4.0' - s.dependency 'DatadogWebViewTracking', '~> 2.4.0' + s.dependency 'DatadogCore', '~> 2.5.0' + s.dependency 'DatadogLogs', '~> 2.5.0' + s.dependency 'DatadogTrace', '~> 2.5.0' + s.dependency 'DatadogRUM', '~> 2.5.0' + s.dependency 'DatadogCrashReporting', '~> 2.5.0' + s.dependency 'DatadogWebViewTracking', '~> 2.5.0' s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'ios/Tests/*.swift' diff --git a/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec b/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec index 7312d109e..1d2cd1802 100644 --- a/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec +++ b/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec @@ -19,9 +19,10 @@ Pod::Spec.new do |s| s.dependency "React-Core" # /!\ Remember to keep the version in sync with DatadogSDKReactNative.podspec - s.dependency 'DatadogSessionReplay', '~> 2.4.0' + s.dependency 'DatadogSessionReplay', '~> 2.5.0' s.test_spec 'Tests' do |test_spec| + test_spec.dependency "React-RCTText" test_spec.source_files = 'ios/Tests/*.swift' end diff --git a/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm b/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm index 9f3a79b34..a416d5413 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm @@ -14,6 +14,7 @@ @implementation DdSessionReplay +@synthesize bridge = _bridge; RCT_EXPORT_MODULE() RCT_REMAP_METHOD(enable, withEnableReplaySampleRate:(double)replaySampleRate @@ -36,7 +37,7 @@ @implementation DdSessionReplay - (DdSessionReplayImplementation*)ddSessionReplayImplementation { if (_ddSessionReplayImplementation == nil) { - _ddSessionReplayImplementation = [[DdSessionReplayImplementation alloc] init]; + _ddSessionReplayImplementation = [[DdSessionReplayImplementation alloc] initWithBridge:_bridge]; } return _ddSessionReplayImplementation; } diff --git a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift index 61515b9b7..8f0c92411 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift @@ -5,30 +5,40 @@ */ import Foundation -import DatadogSessionReplay +@_spi(Internal) import DatadogSessionReplay import DatadogInternal +import React @objc public class DdSessionReplayImplementation: NSObject { private lazy var sessionReplay: SessionReplayProtocol = sessionReplayProvider() private let sessionReplayProvider: () -> SessionReplayProtocol + private let uiManager: RCTUIManager - internal init(_ sessionReplayProvider: @escaping () -> SessionReplayProtocol) { + internal init(sessionReplayProvider: @escaping () -> SessionReplayProtocol, uiManager: RCTUIManager) { self.sessionReplayProvider = sessionReplayProvider + self.uiManager = uiManager } @objc - public override convenience init() { - self.init({ NativeSessionReplay() }) + public convenience init(bridge: RCTBridge) { + self.init( + sessionReplayProvider: { NativeSessionReplay() }, + uiManager: bridge.uiManager + ) } @objc public func enable(replaySampleRate: Double, defaultPrivacyLevel: String, resolve:RCTPromiseResolveBlock, reject:RCTPromiseRejectBlock) -> Void { + var sessionReplayConfiguration = SessionReplay.Configuration( + replaySampleRate: Float(replaySampleRate), + defaultPrivacyLevel: buildPrivacyLevel(privacyLevel: defaultPrivacyLevel as NSString) + ) + + sessionReplayConfiguration.setAdditionalNodeRecorders([RCTTextViewRecorder(uiManager: self.uiManager)]) + sessionReplay.enable( - with: SessionReplay.Configuration( - replaySampleRate: Float(replaySampleRate), - defaultPrivacyLevel: buildPrivacyLevel(privacyLevel: defaultPrivacyLevel as NSString) - ) + with: sessionReplayConfiguration ) resolve(nil) } diff --git a/packages/react-native-session-replay/ios/Sources/RCTTextViewRecorder.swift b/packages/react-native-session-replay/ios/Sources/RCTTextViewRecorder.swift new file mode 100644 index 000000000..5a6db8466 --- /dev/null +++ b/packages/react-native-session-replay/ios/Sources/RCTTextViewRecorder.swift @@ -0,0 +1,138 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import UIKit +@_spi(Internal) +import DatadogSessionReplay +import React + +internal class RCTTextViewRecorder: SessionReplayNodeRecorder { + internal var textObfuscator: (SessionReplayViewTreeRecordingContext) -> SessionReplayTextObfuscating = { context in + return context.recorder.privacy.staticTextObfuscator + } + + internal var identifier = UUID() + + internal let uiManager: RCTUIManager + + internal init(uiManager: RCTUIManager) { + self.uiManager = uiManager + } + + internal func extractTextFromSubViews( + subviews: [RCTShadowView]? + ) -> String? { + if let subviews = subviews { + return subviews.compactMap { subview in + if let sub = subview as? RCTRawTextShadowView { + return sub.text + } + if let sub = subview as? RCTVirtualTextShadowView { + // We recursively get all subviews for nested Text components + return extractTextFromSubViews(subviews: sub.reactSubviews()) + } + return nil + }.joined() + } + return nil + } + + public func semantics( + of view: UIView, + with attributes: SessionReplayViewAttributes, + in context: SessionReplayViewTreeRecordingContext + ) -> SessionReplayNodeSemantics? { + guard let textView = view as? RCTTextView else { + return nil + } + + var shadowView: RCTTextShadowView? = nil + let tag = textView.reactTag + + RCTGetUIManagerQueue().sync { + shadowView = uiManager.shadowView(forReactTag: tag) as? RCTTextShadowView + } + + if let shadow = shadowView { + // TODO: RUM-2173 check performance is ok + let text = extractTextFromSubViews( + subviews: shadow.reactSubviews() + ) + + let builder = RCTTextViewWireframesBuilder( + wireframeID: context.ids.nodeID(view: textView, nodeRecorder: self), + attributes: attributes, + text: text, + textAlignment: shadow.textAttributes.alignment, + textColor: shadow.textAttributes.foregroundColor?.cgColor, + textObfuscator: textObfuscator(context), + font: shadow.textAttributes.effectiveFont(), // Custom fonts are currently not supported for iOS + contentRect: shadow.contentFrame + ) + let node = SessionReplayNode(viewAttributes: attributes, wireframesBuilder: builder) + return SessionReplaySpecificElement(subtreeStrategy: .ignore, nodes: [node]) + } + return SessionReplayInvisibleElement.constant + } +} + +internal struct RCTTextViewWireframesBuilder: SessionReplayNodeWireframesBuilder { + let wireframeID: WireframeID + let attributes: SessionReplayViewAttributes + let text: String? + var textAlignment: NSTextAlignment + let textColor: CGColor? + let textObfuscator: SessionReplayTextObfuscating + let font: UIFont? + let contentRect: CGRect + + public var wireframeRect: CGRect { + attributes.frame + } + + let DEFAULT_FONT_COLOR = UIColor.black.cgColor + + private var clip: SRContentClip { + let top = abs(contentRect.origin.y) + let left = abs(contentRect.origin.x) + let bottom = max(contentRect.height - attributes.frame.height - top, 0) + let right = max(contentRect.width - attributes.frame.width - left, 0) + return SRContentClip.create( + bottom: Int64(withNoOverflow: bottom), + left: Int64(withNoOverflow: left), + right: Int64(withNoOverflow: right), + top: Int64(withNoOverflow: top) + ) + } + + private var relativeIntersectedRect: CGRect { + return CGRect( + x: attributes.frame.origin.x - contentRect.origin.x, + y: attributes.frame.origin.y - contentRect.origin.y , + width: max(contentRect.width, attributes.frame.width), + height: max(contentRect.height, attributes.frame.height) + ) + } + + public func buildWireframes(with builder: SessionReplayWireframesBuilder) -> [SRWireframe] { + return [ + builder.createTextWireframe( + id: wireframeID, + frame: relativeIntersectedRect, + text: textObfuscator.mask(text: text ?? ""), + textAlignment: .init(systemTextAlignment: textAlignment, vertical: .center), + clip: clip, + textColor: textColor ?? DEFAULT_FONT_COLOR, + font: font, + borderColor: attributes.layerBorderColor, + borderWidth: attributes.layerBorderWidth, + backgroundColor: attributes.backgroundColor, + cornerRadius: attributes.layerCornerRadius, + opacity: attributes.alpha + ) + ] + } +} diff --git a/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift b/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift index 0af63ffaf..7dbc93d15 100644 --- a/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift +++ b/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift @@ -7,6 +7,7 @@ import XCTest @testable import DatadogSDKReactNativeSessionReplay import DatadogSessionReplay +import React internal class DdSessionReplayTests: XCTestCase { private func mockResolve(args: Any?) {} @@ -14,7 +15,8 @@ internal class DdSessionReplayTests: XCTestCase { func testEnablesSessionReplayWithZeroReplaySampleRate() { let sessionReplayMock = MockSessionReplay() - DdSessionReplayImplementation({ sessionReplayMock }) + let uiManagerMock = MockUIManager() + DdSessionReplayImplementation(sessionReplayProvider:{ sessionReplayMock }, uiManager: uiManagerMock) .enable(replaySampleRate: 0, defaultPrivacyLevel: "MASK", resolve: mockResolve, reject: mockReject) XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable(replaySampleRate: 0.0, privacyLevel: .mask)) @@ -22,7 +24,8 @@ internal class DdSessionReplayTests: XCTestCase { func testEnablesSessionReplayWithMaskPrivacyLevel() { let sessionReplayMock = MockSessionReplay() - DdSessionReplayImplementation({ sessionReplayMock }) + let uiManagerMock = MockUIManager() + DdSessionReplayImplementation(sessionReplayProvider:{ sessionReplayMock }, uiManager: uiManagerMock) .enable(replaySampleRate: 100, defaultPrivacyLevel: "MASK", resolve: mockResolve, reject: mockReject) XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable(replaySampleRate: 100.0, privacyLevel: .mask)) @@ -30,7 +33,8 @@ internal class DdSessionReplayTests: XCTestCase { func testEnablesSessionReplayWithMaskUserInputPrivacyLevel() { let sessionReplayMock = MockSessionReplay() - DdSessionReplayImplementation({ sessionReplayMock }) + let uiManagerMock = MockUIManager() + DdSessionReplayImplementation(sessionReplayProvider:{ sessionReplayMock }, uiManager: uiManagerMock) .enable(replaySampleRate: 100, defaultPrivacyLevel: "MASK_USER_INPUT", resolve: mockResolve, reject: mockReject) XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable(replaySampleRate: 100.0, privacyLevel: .maskUserInput)) @@ -38,7 +42,8 @@ internal class DdSessionReplayTests: XCTestCase { func testEnablesSessionReplayWithAllowPrivacyLevel() { let sessionReplayMock = MockSessionReplay() - DdSessionReplayImplementation({ sessionReplayMock }) + let uiManagerMock = MockUIManager() + DdSessionReplayImplementation(sessionReplayProvider:{ sessionReplayMock }, uiManager: uiManagerMock) .enable(replaySampleRate: 100, defaultPrivacyLevel: "ALLOW", resolve: mockResolve, reject: mockReject) XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable(replaySampleRate: 100.0, privacyLevel: .allow)) @@ -46,7 +51,8 @@ internal class DdSessionReplayTests: XCTestCase { func testEnablesSessionReplayWithBadPrivacyLevel() { let sessionReplayMock = MockSessionReplay() - DdSessionReplayImplementation({ sessionReplayMock }) + let uiManagerMock = MockUIManager() + DdSessionReplayImplementation(sessionReplayProvider:{ sessionReplayMock }, uiManager: uiManagerMock) .enable(replaySampleRate: 100, defaultPrivacyLevel: "BAD_VALUE", resolve: mockResolve, reject: mockReject) XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable(replaySampleRate: 100.0, privacyLevel: .mask)) @@ -69,3 +75,5 @@ private class MockSessionReplay: SessionReplayProtocol { ) } } + +private class MockUIManager: RCTUIManager {} diff --git a/packages/react-native-session-replay/ios/Tests/RCTTextViewRecorderTests.swift b/packages/react-native-session-replay/ios/Tests/RCTTextViewRecorderTests.swift new file mode 100644 index 000000000..852fde475 --- /dev/null +++ b/packages/react-native-session-replay/ios/Tests/RCTTextViewRecorderTests.swift @@ -0,0 +1,182 @@ +/* + * 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 2019-2020 Datadog, Inc. + */ + +import XCTest +@testable import DatadogSDKReactNativeSessionReplay +@_spi(Internal) +@testable import DatadogSessionReplay +import React + +internal class RCTTextViewRecorderTests: XCTestCase { + let mockAttributes = SessionReplayViewAttributes( + frame: CGRect(x: 0, y: 0, width: 100, height: 100), + backgroundColor: UIColor.white.cgColor, + layerBorderColor: UIColor.blue.cgColor, + layerBorderWidth: CGFloat(1.0), + layerCornerRadius: CGFloat(1.0), + alpha: CGFloat(1.0), + isHidden: false, + intrinsicContentSize: CGSize(width: 100.0, height: 100.0) + ) + + let mockAllowContext = SessionReplayViewTreeRecordingContext( + recorder: .init(privacy: SessionReplayPrivacyLevel.allow, applicationID: "app_id", sessionID: "session_id", viewID: "view_id", viewServerTimeOffset: nil), + coordinateSpace: UIView(), + ids: .init(), + imageDataProvider: ImageDataProvider() + ) + + var mockShadowView: RCTTextShadowView { + // The shadow view must be initialized with a bridge so that we can insert React Subviews into it. + let shadowView: RCTTextShadowView = .init(bridge: MockRCTBridge(delegate: .none)); + + let rawTextShadowView = RCTRawTextShadowView() + rawTextShadowView.text = "This is the test text." + shadowView.insertReactSubview(rawTextShadowView, at: 0) + + return shadowView + } + + var mockShadowViewNestedText: RCTTextShadowView { + // The shadow view must be initialized with a bridge so that we can insert React Subviews into it. + let shadowView: RCTTextShadowView = .init(bridge: MockRCTBridge(delegate: .none)); + + let rawTextShadowView = RCTRawTextShadowView() + rawTextShadowView.text = "This is the " + shadowView.insertReactSubview(rawTextShadowView, at: 0) + + let virtualTextShadowView = RCTVirtualTextShadowView() + let nestedRawTextShadowView = RCTRawTextShadowView() + nestedRawTextShadowView.text = "nested test text." + virtualTextShadowView.insertReactSubview(nestedRawTextShadowView, at: 0) + shadowView.insertReactSubview(virtualTextShadowView, at: 1) + + return shadowView + } + + func testReturnsNilIfViewIsNotRCTTextView() { + let viewMock = UIView() + let uiManagerMock = MockUIManager() + let viewRecorder = RCTTextViewRecorder(uiManager: uiManagerMock) + + let result = viewRecorder.semantics(of: viewMock, with: mockAttributes, in: mockAllowContext) + + XCTAssertNil(result) + } + + func testReturnsInvisibleElementIfShadowViewIsNotFound() throws { + let reactTag = NSNumber(value: 44) + let uiManagerMock = MockUIManager() + let viewMock = RCTTextView() + viewMock.reactTag = reactTag + let viewRecorder = RCTTextViewRecorder(uiManager: uiManagerMock) + + let result = viewRecorder.semantics(of: viewMock, with: mockAttributes, in: mockAllowContext) + + let element = try XCTUnwrap(result as? SessionReplayInvisibleElement) + XCTAssertEqual(element, SessionReplayInvisibleElement.constant) + } + + func testReturnsBuilderWithCorrectInformation() throws { + let reactTag = NSNumber(value: 44) + let uiManagerMock = MockUIManager(reactTag: reactTag, shadowView: mockShadowView) + let viewMock = RCTTextView() + viewMock.reactTag = reactTag + let viewRecorder = RCTTextViewRecorder(uiManager: uiManagerMock) + + let result = viewRecorder.semantics(of: viewMock, with: mockAttributes, in: mockAllowContext) + + let element = try XCTUnwrap(result as? SessionReplaySpecificElement) + XCTAssertEqual(element.subtreeStrategy, .ignore) + XCTAssertEqual(element.nodes.count, 1) + let wireframe = try XCTUnwrap(element.nodes[0].wireframesBuilder.buildWireframes(with: .init())[0].getAsTextWireframe()) + XCTAssertEqual(wireframe.text, "This is the test text.") + } + + func testReturnsBuilderWithCorrectInformationWhenNestedTextComponents() throws { + let reactTag = NSNumber(value: 44) + let uiManagerMock = MockUIManager(reactTag: reactTag, shadowView: mockShadowViewNestedText) + let viewMock = RCTTextView() + viewMock.reactTag = reactTag + let viewRecorder = RCTTextViewRecorder(uiManager: uiManagerMock) + + let result = viewRecorder.semantics(of: viewMock, with: mockAttributes, in: mockAllowContext) + + let element = try XCTUnwrap(result as? SessionReplaySpecificElement) + XCTAssertEqual(element.subtreeStrategy, .ignore) + XCTAssertEqual(element.nodes.count, 1) + let wireframe = try XCTUnwrap(element.nodes[0].wireframesBuilder.buildWireframes(with: .init())[0].getAsTextWireframe()) + XCTAssertEqual(wireframe.text, "This is the nested test text.") + } + + func testReturnsBuilderWithCorrectInformationWhenTextIsObfuscated() throws { + let mockMaskContext = SessionReplayViewTreeRecordingContext( + recorder: .init(privacy: SessionReplayPrivacyLevel.mask, applicationID: "app_id", sessionID: "session_id", viewID: "view_id", viewServerTimeOffset: nil), + coordinateSpace: UIView(), + ids: .init(), + imageDataProvider: ImageDataProvider() + ) + let reactTag = NSNumber(value: 44) + let uiManagerMock = MockUIManager(reactTag: reactTag, shadowView: mockShadowView) + let viewMock = RCTTextView() + viewMock.reactTag = reactTag + let viewRecorder = RCTTextViewRecorder(uiManager: uiManagerMock) + + let result = viewRecorder.semantics(of: viewMock, with: mockAttributes, in: mockMaskContext) + + let element = try XCTUnwrap(result as? SessionReplaySpecificElement) + XCTAssertEqual(element.subtreeStrategy, .ignore) + XCTAssertEqual(element.nodes.count, 1) + let wireframe = try XCTUnwrap(element.nodes[0].wireframesBuilder.buildWireframes(with: .init())[0].getAsTextWireframe()) + XCTAssertEqual(wireframe.text, "xxxx xx xxx xxxx xxxxx") + } +} + +private class MockRCTTextView: RCTTextView {} + +private class MockUIManager: RCTUIManager { + /// Tag to be used in the test corresponding to a shadow view + var shadowViewTag: NSNumber? = nil + var shadowView: RCTTextShadowView? = nil + + convenience init(reactTag: NSNumber, shadowView: RCTTextShadowView?) { + self.init() + self.shadowViewTag = reactTag + self.shadowView = shadowView + } + + internal override func shadowView(forReactTag: NSNumber) -> RCTShadowView? { + if (forReactTag == shadowViewTag) { + return shadowView + } + return nil + } + +} + +extension SessionReplayInvisibleElement: Equatable { + public static func ==(lhs: SessionReplayInvisibleElement, rhs: SessionReplayInvisibleElement) -> Bool { + // If two elements are indeed InvisibleElement they're InvisibleElement.constant + return true + } +} + +extension SRWireframe { + public func getAsTextWireframe() -> SRTextWireframe? { + if case .textWireframe(let value) = self { + return value + } + return nil + } +} + +private class MockRCTBridge: RCTBridge { + /// We need to override this function that would otherwise try to setup + /// a real bridge and fail as we don't have a bundled JS. + override func setUp() { + // do nothing + } +}