diff --git a/CHANGELOG.md b/CHANGELOG.md index 061969035c..adc2984a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Unreleased +# 1.17.0 / 23-03-2023 +- [BUGFIX] Fix crash in `VitalInfoSampler`. See [#1216][] (Thanks [@cltnschlosser][]) +- [IMPROVEMENT] Fix Xcode analysis warning. See[#1220][] + # 1.16.0 / 02-03-2023 - [IMPROVEMENT] Always create an ApplicationLaunch view on session initialization. See [#1160][] - [BUGFIX] Remove the data race caused by sampling on the RUM thread. See [#1177][] (Thanks [@cltnschlosser][]) @@ -438,6 +442,8 @@ [#1160]: https://github.com/DataDog/dd-sdk-ios/pull/1160 [#1177]: https://github.com/DataDog/dd-sdk-ios/pull/1177 [#1188]: https://github.com/DataDog/dd-sdk-ios/pull/1188 +[#1216]: https://github.com/DataDog/dd-sdk-ios/pull/1216 +[#1220]: https://github.com/DataDog/dd-sdk-ios/pull/1220 [@00fa9a]: https://github.com/00FA9A [@britton-earnin]: https://github.com/Britton-Earnin [@hengyu]: https://github.com/Hengyu diff --git a/DatadogSDK.podspec b/DatadogSDK.podspec index 20f71ae33f..9309bafb77 100644 --- a/DatadogSDK.podspec +++ b/DatadogSDK.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "DatadogSDK" s.module_name = "Datadog" - s.version = "1.16.0" + s.version = "1.17.0" s.summary = "Official Datadog Swift SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogSDKAlamofireExtension.podspec b/DatadogSDKAlamofireExtension.podspec index 620cd2435b..f03adb14e0 100644 --- a/DatadogSDKAlamofireExtension.podspec +++ b/DatadogSDKAlamofireExtension.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "DatadogSDKAlamofireExtension" s.module_name = "DatadogAlamofireExtension" - s.version = "1.16.0" + s.version = "1.17.0" s.summary = "An Official Extensions of Datadog Swift SDK for Alamofire." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogSDKCrashReporting.podspec b/DatadogSDKCrashReporting.podspec index 83503f1b0d..6d9ec7f0c4 100644 --- a/DatadogSDKCrashReporting.podspec +++ b/DatadogSDKCrashReporting.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "DatadogSDKCrashReporting" s.module_name = "DatadogCrashReporting" - s.version = "1.16.0" + s.version = "1.17.0" s.summary = "Official Datadog Crash Reporting SDK for iOS." s.homepage = "https://www.datadoghq.com" @@ -22,6 +22,6 @@ Pod::Spec.new do |s| s.static_framework = true s.source_files = "Sources/DatadogCrashReporting/**/*.swift" - s.dependency 'DatadogSDK', '1.16.0' + s.dependency 'DatadogSDK', '1.17.0' s.dependency 'PLCrashReporter', '~> 1.11.0' end diff --git a/DatadogSDKObjc.podspec b/DatadogSDKObjc.podspec index e50e11db23..8751a28a7b 100644 --- a/DatadogSDKObjc.podspec +++ b/DatadogSDKObjc.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "DatadogSDKObjc" s.module_name = "DatadogObjc" - s.version = "1.16.0" + s.version = "1.17.0" s.summary = "Official Datadog Objective-C SDK for iOS." s.homepage = "https://www.datadoghq.com" @@ -21,5 +21,5 @@ Pod::Spec.new do |s| s.source = { :git => 'https://github.com/DataDog/dd-sdk-ios.git', :tag => s.version.to_s } s.source_files = "Sources/DatadogObjc/**/*.swift" - s.dependency 'DatadogSDK', '1.16.0' + s.dependency 'DatadogSDK', '1.17.0' end diff --git a/DatadogSDKSessionReplay.podspec b/DatadogSDKSessionReplay.podspec index 4bd37f0385..99c958e0f2 100644 --- a/DatadogSDKSessionReplay.podspec +++ b/DatadogSDKSessionReplay.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "DatadogSDKSessionReplay" s.module_name = "DatadogSessionReplay" - s.version = "1.16.0" + s.version = "1.17.0" s.summary = "Official Datadog Session Replay SDK for iOS. This module is currently in beta - contact Datadog to request a try." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogSessionReplay/SRSnapshotTests/SRHost/AppDelegate.swift b/DatadogSessionReplay/SRSnapshotTests/SRHost/AppDelegate.swift index 8896ace1d1..da5061988c 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRHost/AppDelegate.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRHost/AppDelegate.swift @@ -34,10 +34,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } var keyWindow: UIWindow? { - return UIApplication.shared - .connectedScenes - .compactMap { $0 as? UIWindowScene } - .first { scene in scene.windows.contains { window in window.isKeyWindow } }? - .keyWindow + if #available(iOS 15.0, *) { + return UIApplication.shared + .connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { scene in scene.windows.contains { window in window.isKeyWindow } }? + .keyWindow + } else { + let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication // swiftlint:disable:this unsafe_uiapplication_shared + return application? + .connectedScenes + .flatMap { ($0 as? UIWindowScene)?.windows ?? [] } + .first { $0.isKeyWindow } + } } } diff --git a/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/Fixtures.swift b/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/Fixtures.swift index e8071ddf5b..caebe8d305 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/Fixtures.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/Fixtures.swift @@ -13,6 +13,14 @@ internal enum Fixture: CaseIterable { case segments case pickers case switches + case textFields + case steppers + case datePickersInline + case datePickersCompact + case datePickersWheels + case timePickersCountDown + case timePickersWheels + case timePickersCompact var menuItemTitle: String { switch self { @@ -28,6 +36,22 @@ internal enum Fixture: CaseIterable { return "Pickers" case .switches: return "Switches" + case .textFields: + return "Text Fields" + case .steppers: + return "Steppers" + case .datePickersInline: + return "Date Picker (inline)" + case .datePickersCompact: + return "Date Picker (compact)" + case .datePickersWheels: + return "Date Picker (wheels)" + case .timePickersCountDown: + return "Time Picker (count down)" + case .timePickersWheels: + return "Time Picker (wheels)" + case .timePickersCompact: + return "Time Picker (compact)" } } @@ -45,6 +69,22 @@ internal enum Fixture: CaseIterable { return UIStoryboard.inputElements.instantiateViewController(withIdentifier: "Pickers") case .switches: return UIStoryboard.inputElements.instantiateViewController(withIdentifier: "Switches") + case .textFields: + return UIStoryboard.inputElements.instantiateViewController(withIdentifier: "TextFields") + case .steppers: + return UIStoryboard.inputElements.instantiateViewController(withIdentifier: "Steppers") + case .datePickersInline: + return UIStoryboard.datePickers.instantiateViewController(withIdentifier: "DatePickersInline") + case .datePickersCompact: + return UIStoryboard.datePickers.instantiateViewController(withIdentifier: "DatePickersCompact") + case .datePickersWheels: + return UIStoryboard.datePickers.instantiateViewController(withIdentifier: "DatePickersWheels") + case .timePickersCountDown: + return UIStoryboard.datePickers.instantiateViewController(withIdentifier: "TimePickersCountDown") + case .timePickersWheels: + return UIStoryboard.datePickers.instantiateViewController(withIdentifier: "TimePickersWheels") + case .timePickersCompact: + return UIStoryboard.datePickers.instantiateViewController(withIdentifier: "DatePickersCompact") // sharing the same VC with `datePickersCompact` } } } @@ -52,4 +92,5 @@ internal enum Fixture: CaseIterable { internal extension UIStoryboard { static var basic: UIStoryboard { UIStoryboard(name: "Basic", bundle: nil) } static var inputElements: UIStoryboard { UIStoryboard(name: "InputElements", bundle: nil) } + static var datePickers: UIStoryboard { UIStoryboard(name: "InputElements-DatePickers", bundle: nil) } } diff --git a/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputElements-DatePickers.storyboard b/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputElements-DatePickers.storyboard new file mode 100644 index 0000000000..152710ac75 --- /dev/null +++ b/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputElements-DatePickers.storyboard @@ -0,0 +1,235 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputElements.storyboard b/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputElements.storyboard index 41846dcbc4..45bf4b804f 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputElements.storyboard +++ b/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputElements.storyboard @@ -88,6 +88,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -351,6 +467,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputViewControllers.swift b/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputViewControllers.swift index 6866edfb00..20e4d64912 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputViewControllers.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputViewControllers.swift @@ -57,3 +57,67 @@ internal class PickersViewController: UIViewController { secondPicker.selectRow(4, inComponent: 2, animated: false) } } + +internal class DatePickersInlineViewController: UIViewController { + @IBOutlet weak var datePicker: UIDatePicker! + + func set(date: Date) { + datePicker.setDate(date, animated: false) + } +} + +internal class DatePickersCompactViewController: UIViewController { + @IBOutlet weak var datePicker: UIDatePicker! + + func set(date: Date) { + datePicker.setDate(date, animated: false) + } + + /// Forces the "compact" date picker to open full calendar view in a popover. + func openCalendarPopover() { + // Here we use private Objc APIs. It works fine on iOS 15.0+ which matches the OS version used + // for snapshot tests, but might need updates in the future. + if #available(iOS 15.0, *) { + let label = datePicker.subviews[0].subviews[0] + let tapAction = NSSelectorFromString("_didTapTextLabel") + label.perform(tapAction) + } + } + + /// Forces the "wheel" time picker to open in a popover. + func openTimePickerPopover() { + // Here we use private Objc APIs - it works fine on iOS 15.0+ which matches the OS version used + // for snapshot tests, but might need updates in the future. + if #available(iOS 15.0, *) { + class DummySender: NSObject { + @objc + func activeTouch() -> UITouch? { return nil } + } + + let label = datePicker.subviews[0].subviews[1] + let tapAction = NSSelectorFromString("didTapInputLabel:") + label.perform(tapAction, with: DummySender()) + } + } +} + +internal class DatePickersWheelsViewController: UIViewController { + @IBOutlet weak var datePicker: UIDatePicker! + + func set(date: Date) { + datePicker.setDate(date, animated: false) + } +} + +internal class TimePickersCountDownViewController: UIViewController {} + +internal class TimePickersWheelViewController: UIViewController { + @IBOutlet weak var datePicker: UIDatePicker! + + func set(date: Date) { + datePicker.setDate(date, animated: false) + } +} + +/// Sharing the same VC for compact time and date picker. +internal typealias TimePickersCompactViewController = DatePickersCompactViewController diff --git a/DatadogSessionReplay/SRSnapshotTests/SRHost/MenuViewController.swift b/DatadogSessionReplay/SRSnapshotTests/SRHost/MenuViewController.swift index a9c22dbfee..62601fcf5e 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRHost/MenuViewController.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRHost/MenuViewController.swift @@ -17,9 +17,17 @@ internal class MenuViewController: UITableViewController { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell() - var content = cell.defaultContentConfiguration() - content.text = Fixture.allCases[indexPath.item].menuItemTitle - cell.contentConfiguration = content + + if #available(iOS 14.0, *) { + var content = cell.defaultContentConfiguration() + content.text = Fixture.allCases[indexPath.item].menuItemTitle + cell.contentConfiguration = content + } else { + let label = UILabel(frame: .init(x: 10, y: 0, width: tableView.bounds.width, height: 44)) + label.text = Fixture.allCases[indexPath.item].menuItemTitle + cell.addSubview(label) + } + return cell } diff --git a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests.xcodeproj/project.pbxproj b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests.xcodeproj/project.pbxproj index 77a1ae7a58..37e4b14868 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests.xcodeproj/project.pbxproj +++ b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 616C37DA299F6913005E0472 /* InputElements.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 616C37D9299F6913005E0472 /* InputElements.storyboard */; }; + 6196D32429AF7EB2002EACAF /* InputElements-DatePickers.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6196D32329AF7EB2002EACAF /* InputElements-DatePickers.storyboard */; }; 619C49B229952108006B66A6 /* Framer in Frameworks */ = {isa = PBXBuildFile; productRef = 619C49B129952108006B66A6 /* Framer */; }; 619C49B429952E12006B66A6 /* SnapshotTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 619C49B329952E12006B66A6 /* SnapshotTestCase.swift */; }; 619C49B72995512A006B66A6 /* ImageComparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = 619C49B62995512A006B66A6 /* ImageComparison.swift */; }; @@ -39,6 +40,7 @@ /* Begin PBXFileReference section */ 616C37D9299F6913005E0472 /* InputElements.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = InputElements.storyboard; sourceTree = ""; }; + 6196D32329AF7EB2002EACAF /* InputElements-DatePickers.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = "InputElements-DatePickers.storyboard"; sourceTree = ""; }; 619C49B329952E12006B66A6 /* SnapshotTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotTestCase.swift; sourceTree = ""; }; 619C49B62995512A006B66A6 /* ImageComparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComparison.swift; sourceTree = ""; }; 619C49B8299551F5006B66A6 /* _snapshots_ */ = {isa = PBXFileReference; lastKnownFileType = folder; path = _snapshots_; sourceTree = ""; }; @@ -147,6 +149,7 @@ 61E7DFBA299A5C9D001D7A3A /* BasicViewControllers.swift */, 616C37D9299F6913005E0472 /* InputElements.storyboard */, 61A735A929A5137400001820 /* InputViewControllers.swift */, + 6196D32329AF7EB2002EACAF /* InputElements-DatePickers.storyboard */, ); path = Fixtures; sourceTree = ""; @@ -246,6 +249,7 @@ 61B3BC572993BE2F0032C78A /* LaunchScreen.storyboard in Resources */, 61E7DFB9299A5A3E001D7A3A /* Basic.storyboard in Resources */, 61B3BC522993BE2E0032C78A /* Main.storyboard in Resources */, + 6196D32429AF7EB2002EACAF /* InputElements-DatePickers.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -439,6 +443,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -466,6 +471,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -490,6 +496,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = JKFCB4CN7C; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.datadoghq.SRSnapshotTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -507,6 +514,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = JKFCB4CN7C; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.datadoghq.SRSnapshotTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/SRSnapshotTests.swift b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/SRSnapshotTests.swift index a6d2fd8ffe..bd3ada0cf6 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/SRSnapshotTests.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/SRSnapshotTests.swift @@ -6,6 +6,7 @@ import XCTest @testable import SRHost +import TestUtilities final class SRSnapshotTests: SnapshotTestCase { private let snapshotsFolderName = "_snapshots_" @@ -106,4 +107,153 @@ final class SRSnapshotTests: SnapshotTestCase { record: recordingMode ) } + + func testTextFields() throws { + show(fixture: .textFields) + + var image = try takeSnapshot(configuration: .init(privacy: .allowAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-allowAll-privacy"), + record: recordingMode + ) + + image = try takeSnapshot(configuration: .init(privacy: .maskAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-maskAll-privacy"), + record: recordingMode + ) + } + + func testSteppers() throws { + show(fixture: .steppers) + + var image = try takeSnapshot(configuration: .init(privacy: .allowAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-allowAll-privacy"), + record: recordingMode + ) + + image = try takeSnapshot(configuration: .init(privacy: .maskAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-maskAll-privacy"), + record: recordingMode + ) + } + + + func testDatePickers() throws { + let vc1 = show(fixture: .datePickersInline) as! DatePickersInlineViewController + vc1.set(date: .mockDecember15th2019At10AMUTC()) + wait(seconds: 0.25) + + var image = try takeSnapshot(configuration: .init(privacy: .allowAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-inline-allowAll-privacy"), + record: recordingMode + ) + + image = try takeSnapshot(configuration: .init(privacy: .maskAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-inline-maskAll-privacy"), + record: recordingMode + ) + + let vc2 = show(fixture: .datePickersCompact) as! DatePickersCompactViewController + vc2.set(date: .mockDecember15th2019At10AMUTC()) + vc2.openCalendarPopover() + wait(seconds: 0.25) + + image = try takeSnapshot(configuration: .init(privacy: .allowAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-compact-allowAll-privacy"), + record: recordingMode + ) + + image = try takeSnapshot(configuration: .init(privacy: .maskAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-compact-maskAll-privacy"), + record: recordingMode + ) + + let vc3 = show(fixture: .datePickersWheels) as! DatePickersWheelsViewController + vc3.set(date: .mockDecember15th2019At10AMUTC()) + wait(seconds: 0.25) + + image = try takeSnapshot(configuration: .init(privacy: .allowAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-wheels-allowAll-privacy"), + record: recordingMode + ) + + image = try takeSnapshot(configuration: .init(privacy: .maskAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-wheels-maskAll-privacy"), + record: recordingMode + ) + } + + func testTimePickers() throws { + show(fixture: .timePickersCountDown) + + var image = try takeSnapshot(configuration: .init(privacy: .allowAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-count-down-allowAll-privacy"), + record: recordingMode + ) + + image = try takeSnapshot(configuration: .init(privacy: .maskAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-count-down-maskAll-privacy"), + record: recordingMode + ) + + let vc1 = show(fixture: .timePickersWheels) as! TimePickersWheelViewController + vc1.set(date: .mockDecember15th2019At10AMUTC()) + wait(seconds: 0.25) + + image = try takeSnapshot(configuration: .init(privacy: .allowAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-wheels-allowAll-privacy"), + record: recordingMode + ) + + image = try takeSnapshot(configuration: .init(privacy: .maskAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-wheels-maskAll-privacy"), + record: recordingMode + ) + + let vc2 = show(fixture: .timePickersCompact) as! TimePickersCompactViewController + vc2.set(date: .mockDecember15th2019At10AMUTC()) + vc2.openTimePickerPopover() + wait(seconds: 0.25) + + image = try takeSnapshot(configuration: .init(privacy: .allowAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-compact-allowAll-privacy"), + record: recordingMode + ) + + image = try takeSnapshot(configuration: .init(privacy: .maskAll)) + DDAssertSnapshotTest( + newImage: image, + snapshotLocation: .folder(named: snapshotsFolderName, fileNameSuffix: "-compact-maskAll-privacy"), + record: recordingMode + ) + } } diff --git a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/ImageRendering.swift b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/ImageRendering.swift index 2f6d3cd54f..772f700694 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/ImageRendering.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/ImageRendering.swift @@ -74,8 +74,11 @@ private extension SRImageWireframe { y: CGFloat(y), width: CGFloat(width), height: CGFloat(height), - style: frameStyle(border: border, style: shapeStyle), - content: .init(text: "IMG", textColor: .black, font: .systemFont(ofSize: 8)) + style: .init(lineWidth: 1, lineColor: .black, fillColor: .red), + annotation: .init( + text: "IMG \(width) x \(height)", + style: .init(size: .small, position: .top, alignment: .trailing) + ) ) } } diff --git a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift index 329e4bf9da..533ec62033 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift @@ -63,6 +63,14 @@ internal class SnapshotTestCase: XCTestCase { return createSideBySideImage(appImage, wireframesImage) } + func wait(seconds: TimeInterval) { + let expectation = self.expectation(description: "Wait \(seconds)") + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + expectation.fulfill() + } + waitForExpectations(timeout: seconds * 2) + } + /// Puts two images side-by-side, adds titles and returns new, composite image. private func createSideBySideImage(_ image1: UIImage, _ image2: UIImage) -> UIImage { var leftRect = CGRect(origin: .zero, size: image1.size) diff --git a/DatadogSessionReplay/Sources/Processor/Flattening/NodesFlattener.swift b/DatadogSessionReplay/Sources/Processor/Flattening/NodesFlattener.swift index 88255ad45b..b6932df11c 100644 --- a/DatadogSessionReplay/Sources/Processor/Flattening/NodesFlattener.swift +++ b/DatadogSessionReplay/Sources/Processor/Flattening/NodesFlattener.swift @@ -19,23 +19,20 @@ internal struct NodesFlattener { var flattened: [Node] = [] for nextNode in snapshot.nodes { - // Skip invisible nodes: - if !(nextNode.semantics is InvisibleElement) { - // When accepting nodes, remove ones that are covered by another opaque node: - flattened = flattened.compactMap { previousNode in - let previousFrame = previousNode.semantics.wireframesBuilder?.wireframeRect ?? .zero - let nextFrame = nextNode.semantics.wireframesBuilder?.wireframeRect ?? .zero + // When accepting nodes, remove ones that are covered by another opaque node: + flattened = flattened.compactMap { previousNode in + let previousFrame = previousNode.wireframesBuilder.wireframeRect + let nextFrame = nextNode.wireframesBuilder.wireframeRect - // Drop previous node when: - let dropPreviousNode = nextFrame.contains(previousFrame) // its rect is fully covered by the next node - && nextNode.viewAttributes.hasAnyAppearance // and the next node brings something visual - && !nextNode.viewAttributes.isTranslucent // and the next node is opaque + // Drop previous node when: + let dropPreviousNode = nextFrame.contains(previousFrame) // its rect is fully covered by the next node + && nextNode.viewAttributes.hasAnyAppearance // and the next node brings something visual + && !nextNode.viewAttributes.isTranslucent // and the next node is opaque - return dropPreviousNode ? nil : previousNode - } - - flattened.append(nextNode) + return dropPreviousNode ? nil : previousNode } + + flattened.append(nextNode) } return flattened diff --git a/DatadogSessionReplay/Sources/Processor/Privacy/TextObfuscator.swift b/DatadogSessionReplay/Sources/Processor/Privacy/TextObfuscator.swift index 458dc347e1..6a7047081d 100644 --- a/DatadogSessionReplay/Sources/Processor/Privacy/TextObfuscator.swift +++ b/DatadogSessionReplay/Sources/Processor/Privacy/TextObfuscator.swift @@ -13,7 +13,7 @@ internal protocol TextObfuscating { func mask(text: String) -> String } -/// Text obfuscator which replaces all readable characters with `"x"`. +/// Text obfuscator which replaces all readable characters with space-preserving `"x"` characters. internal struct TextObfuscator: TextObfuscating { /// The character to mask text with. let maskCharacter: UnicodeScalar = "x" @@ -39,6 +39,17 @@ internal struct TextObfuscator: TextObfuscating { } } +/// Text obfuscator which replaces the whole text with fixed-width `"xxx"` mask value. +/// +/// It should be used **by default** for input elements that bring sensitive information (such as passwords). +/// It shuold be used for input elements that can't safely use space-preserving masking (such as date pickers, where selection can be still +/// inferred by counting the number of x-es in the mask). +internal struct SensitiveTextObfuscator: TextObfuscating { + private static let maskedString = "xxx" + + func mask(text: String) -> String { Self.maskedString } +} + /// Text obfuscator which only returns the original text. internal struct NOPTextObfuscator: TextObfuscating { func mask(text: String) -> String { diff --git a/DatadogSessionReplay/Sources/Processor/Processor.swift b/DatadogSessionReplay/Sources/Processor/Processor.swift index ac76c910d6..d7789077d0 100644 --- a/DatadogSessionReplay/Sources/Processor/Processor.swift +++ b/DatadogSessionReplay/Sources/Processor/Processor.swift @@ -66,7 +66,7 @@ internal class Processor: Processing { private func processSync(viewTreeSnapshot: ViewTreeSnapshot, touchSnapshot: TouchSnapshot?) { let flattenedNodes = nodesFlattener.flattenNodes(in: viewTreeSnapshot) let wireframes: [SRWireframe] = flattenedNodes - .compactMap { node in node.semantics.wireframesBuilder } + .map { node in node.wireframesBuilder } .flatMap { nodeBuilder in nodeBuilder.buildWireframes(with: wireframesBuilder) } #if DEBUG diff --git a/DatadogSessionReplay/Sources/Recorder/Utilities/ImageDataProvider.swift b/DatadogSessionReplay/Sources/Recorder/Utilities/ImageDataProvider.swift index da41fbb984..1d8c5511a6 100644 --- a/DatadogSessionReplay/Sources/Recorder/Utilities/ImageDataProvider.swift +++ b/DatadogSessionReplay/Sources/Recorder/Utilities/ImageDataProvider.swift @@ -31,38 +31,33 @@ internal class ImageDataProvider { of image: UIImage?, tintColor: UIColor? = nil ) -> String? { - guard var image = image else { - return "" - } - if #available(iOS 13.0, *), let tintColor = tintColor { - image = image.withTintColor(tintColor) - } - guard let imageData = image.pngData() else { - return "" - } - - var identifier: String - if let md5 = image.md5 { - identifier = md5 - } else { - let md5 = imageData.md5 - image.md5 = md5 - identifier = md5 - } + autoreleasepool { + guard var image = image else { + return "" + } + if #available(iOS 13.0, *), let tintColor = tintColor { + image = image.withTintColor(tintColor) + } - let dataLoadingStaus = cache[identifier] - switch dataLoadingStaus { - case .none: - if let imageData = image.pngData(), image.size <= maxDimensions && imageData.count <= maxBytesSize { - cache[identifier] = .loaded(imageData.base64EncodedString()) - } else { - cache[identifier] = .ignored + var identifier = image.srIdentifier + if let tintColorIdentifier = tintColor?.srIdentifier { + identifier += tintColorIdentifier + } + let dataLoadingStaus = cache[identifier] + switch dataLoadingStaus { + case .none: + if let imageData = image.pngData(), image.size <= maxDimensions && imageData.count <= maxBytesSize { + let base64EncodedImage = imageData.base64EncodedString() + cache[identifier, base64EncodedImage.count] = .loaded(base64EncodedImage) + } else { + cache[identifier] = .ignored + } + return contentBase64String(of: image) + case .loaded(let base64String): + return base64String + case .ignored: + return "" } - return contentBase64String(of: image) - case .loaded(let base64String): - return base64String - case .ignored: - return "" } } } @@ -73,27 +68,14 @@ fileprivate extension CGSize { } } -fileprivate var imageMd5Key: UInt8 = 1 - -fileprivate extension UIImage { - var md5: String? { - set { objc_setAssociatedObject(self, &imageMd5Key, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) } - get { objc_getAssociatedObject(self, &imageMd5Key) as? String } +extension UIImage { + var srIdentifier: String { + return "\(hash)" } } -import var CommonCrypto.CC_MD5_DIGEST_LENGTH -import func CommonCrypto.CC_MD5 -import typealias CommonCrypto.CC_LONG - -fileprivate extension Data { - var md5: String { - var result = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH)) - _ = withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in - result.withUnsafeMutableBytes { resultBytes in - CC_MD5(bytes.baseAddress, CC_LONG(count), resultBytes.baseAddress) - } - } - return Data(result).map { String(format: "%02hhx", $0) }.joined() +extension UIColor { + var srIdentifier: String { + return "\(hash)" } } diff --git a/DatadogSessionReplay/Sources/Recorder/Utilities/SystemColors.swift b/DatadogSessionReplay/Sources/Recorder/Utilities/SystemColors.swift index 891c8f4ca0..ed4c894e6f 100644 --- a/DatadogSessionReplay/Sources/Recorder/Utilities/SystemColors.swift +++ b/DatadogSessionReplay/Sources/Recorder/Utilities/SystemColors.swift @@ -34,6 +34,24 @@ internal enum SystemColors { } } + static var systemBackground: CGColor { + if #available(iOS 13.0, *) { + return UIColor.systemBackground.cgColor + } else { + // Fallback to iOS 16.2 light mode color: + return UIColor(red: 255 / 255, green: 255 / 255, blue: 255 / 255, alpha: 1).cgColor + } + } + + static var secondarySystemGroupedBackground: CGColor { + if #available(iOS 13.0, *) { + return UIColor.secondarySystemGroupedBackground.cgColor + } else { + // Fallback to iOS 16.2 light mode color: + return UIColor(red: 255 / 255, green: 255 / 255, blue: 255 / 255, alpha: 1).cgColor + } + } + static var secondarySystemFill: CGColor { if #available(iOS 13.0, *) { return UIColor.secondarySystemFill.cgColor @@ -64,4 +82,13 @@ internal enum SystemColors { static var systemGreen: CGColor { return UIColor.systemGreen.cgColor } + + static var placeholderText: CGColor { + if #available(iOS 13.0, *) { + return UIColor.placeholderText.cgColor + } else { + // Fallback to iOS 16.2 light mode color: + return UIColor(red: 197 / 255, green: 197 / 255, blue: 197 / 255, alpha: 1).cgColor + } + } } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorder.swift new file mode 100644 index 0000000000..1b08882601 --- /dev/null +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorder.swift @@ -0,0 +1,163 @@ +/* + * 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 + +internal struct UIDatePickerRecorder: NodeRecorder { + private let wheelsStyleRecorder = WheelsStyleDatePickerRecorder() + private let compactStyleRecorder = CompactStyleDatePickerRecorder() + private let inlineStyleRecorder = InlineStyleDatePickerRecorder() + + func semantics(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> NodeSemantics? { + guard let datePicker = view as? UIDatePicker else { + return nil + } + + guard attributes.isVisible else { + return InvisibleElement.constant + } + + var nodes: [Node] = [] + + if #available(iOS 13.4, *) { + switch datePicker.datePickerStyle { + case .wheels: + nodes = wheelsStyleRecorder.recordNodes(of: datePicker, with: attributes, in: context) + case .compact: + nodes = compactStyleRecorder.recordNodes(of: datePicker, with: attributes, in: context) + case .inline: + nodes = inlineStyleRecorder.recordNodes(of: datePicker, with: attributes, in: context) + case .automatic: + // According to `datePicker.datePickerStyle` documentation: + // > "This property always returns a concrete style, never `UIDatePickerStyle.automatic`." + break + @unknown default: + nodes = wheelsStyleRecorder.recordNodes(of: datePicker, with: attributes, in: context) + } + } else { + // Observation: older OS versions use the "wheels" style + nodes = wheelsStyleRecorder.recordNodes(of: datePicker, with: attributes, in: context) + } + + let isDisplayedInPopover: Bool = { + if let superview = view.superview { + // This gets effective on iOS 15.0+ which is the earliest version that displays + // date pickers in popover views: + return "\(type(of: superview))" == "_UIVisualEffectContentView" + } + return false + }() + + let builder = UIDatePickerWireframesBuilder( + wireframeRect: attributes.frame, + attributes: attributes, + backgroundWireframeID: context.ids.nodeID(for: datePicker), + isDisplayedInPopover: isDisplayedInPopover + ) + let backgroundNode = Node( + viewAttributes: attributes, + wireframesBuilder: builder + ) + return SpecificElement( + subtreeStrategy: .ignore, + nodes: [backgroundNode] + nodes + ) + } +} + +private struct WheelsStyleDatePickerRecorder { + let pickerTreeRecorder = ViewTreeRecorder( + nodeRecorders: [ + UIPickerViewRecorder() + ] + ) + + func recordNodes(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> [Node] { + var context = context + // Do not elevate text obfuscation for selected options in the context of date picker. + // Meaning: do not replace dates with fixed-width `"xxx"` mask - instead, replace each character individually. + context.selectionTextObfuscator = context.textObfuscator + return pickerTreeRecorder.recordNodes(for: view, in: context) + } +} + +private struct InlineStyleDatePickerRecorder { + let viewRecorder: UIViewRecorder + let labelRecorder: UILabelRecorder + let subtreeRecorder: ViewTreeRecorder + + init() { + self.viewRecorder = UIViewRecorder() + self.labelRecorder = UILabelRecorder() + self.subtreeRecorder = ViewTreeRecorder( + nodeRecorders: [ + viewRecorder, + labelRecorder, + UIImageViewRecorder(), + UISegmentRecorder(), // iOS 14.x uses `UISegmentedControl` for "AM | PM" + ] + ) + } + + func recordNodes(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> [Node] { + viewRecorder.semanticsOverride = { _, viewAttributes in + if context.recorder.privacy == .maskAll { + let isSquare = viewAttributes.frame.width == viewAttributes.frame.height + let isCircle = isSquare && viewAttributes.layerCornerRadius == viewAttributes.frame.width * 0.5 + if isCircle { + return IgnoredElement(subtreeStrategy: .ignore) + } + } + return nil + } + + if context.recorder.privacy == .maskAll { + labelRecorder.builderOverride = { builder in + var builder = builder + builder.textColor = SystemColors.label + return builder + } + } + + return subtreeRecorder.recordNodes(for: view, in: context) + } +} + +private struct CompactStyleDatePickerRecorder { + let subtreeRecorder = ViewTreeRecorder( + nodeRecorders: [ + UIViewRecorder(), + UILabelRecorder() + ] + ) + + func recordNodes(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> [Node] { + return subtreeRecorder.recordNodes(for: view, in: context) + } +} + +internal struct UIDatePickerWireframesBuilder: NodeWireframesBuilder { + var wireframeRect: CGRect + let attributes: ViewAttributes + let backgroundWireframeID: WireframeID + /// If date picker is displayed in popover view (possible only in iOS 15.0+). + let isDisplayedInPopover: Bool + + func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] { + return [ + builder.createShapeWireframe( + id: backgroundWireframeID, + frame: wireframeRect, + clip: nil, + borderColor: isDisplayedInPopover ? SystemColors.secondarySystemFill : nil, + borderWidth: isDisplayedInPopover ? 1 : 0, + backgroundColor: isDisplayedInPopover ? SystemColors.secondarySystemGroupedBackground : SystemColors.systemBackground, + cornerRadius: 10, + opacity: attributes.alpha + ) + ] + } +} diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorder.swift index acbd552a91..e7dee5c9e0 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorder.swift @@ -6,7 +6,16 @@ import UIKit -internal struct UIImageViewRecorder: NodeRecorder { +internal class UIImageViewRecorder: NodeRecorder { + /// An option for overriding default semantics from parent recorder. + var semanticsOverride: (UIImageView, ViewAttributes) -> NodeSemantics? = { imageView, _ in + let className = "\(type(of: imageView))" + // This gets effective on iOS 15.0+ which is the earliest version that displays some elements in popover views. + // Here we explicitly ignore the "shadow" effect applied to popover. + let isSystemShadow = className == "_UICutoutShadowView" + return isSystemShadow ? IgnoredElement(subtreeStrategy: .ignore) : nil + } + private let imageDataProvider = ImageDataProvider() func semantics( @@ -17,6 +26,9 @@ internal struct UIImageViewRecorder: NodeRecorder { guard let imageView = view as? UIImageView else { return nil } + if let semantics = semanticsOverride(imageView, attributes) { + return semantics + } guard attributes.hasAnyAppearance || imageView.image != nil else { return InvisibleElement.constant } @@ -41,7 +53,8 @@ internal struct UIImageViewRecorder: NodeRecorder { imageTintColor: imageView.tintColor, imageDataProvider: imageDataProvider ) - return SpecificElement(wireframesBuilder: builder, subtreeStrategy: .record) + let node = Node(viewAttributes: attributes, wireframesBuilder: builder) + return SpecificElement(subtreeStrategy: .record, nodes: [node]) } } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorder.swift index 51a8b4cb0c..1955a20538 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorder.swift @@ -6,13 +6,16 @@ import UIKit -internal struct UILabelRecorder: NodeRecorder { +internal class UILabelRecorder: NodeRecorder { + /// An option for customizing wireframes builder created by this recorder. + var builderOverride: (UILabelWireframesBuilder) -> UILabelWireframesBuilder = { $0 } + func semantics(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> NodeSemantics? { guard let label = view as? UILabel else { return nil } - let hasVisibleText = !(label.text?.isEmpty ?? true) + let hasVisibleText = attributes.isVisible && !(label.text?.isEmpty ?? true) guard hasVisibleText || attributes.hasAnyAppearance else { return InvisibleElement.constant @@ -34,10 +37,11 @@ internal struct UILabelRecorder: NodeRecorder { textAlignment: nil, font: label.font, fontScalingEnabled: label.adjustsFontSizeToFitWidth, - textObfuscator: context.recorder.privacy == .maskAll ? context.textObfuscator : nopTextObfuscator, + textObfuscator: context.textObfuscator, wireframeRect: textFrame ) - return SpecificElement(wireframesBuilder: builder, subtreeStrategy: .ignore) + let node = Node(viewAttributes: attributes, wireframesBuilder: builderOverride(builder)) + return SpecificElement(subtreeStrategy: .ignore, nodes: [node]) } } @@ -48,13 +52,13 @@ internal struct UILabelWireframesBuilder: NodeWireframesBuilder { /// The text inside label. let text: String /// The color of the text. - let textColor: CGColor? + var textColor: CGColor? /// The alignment of the text. var textAlignment: SRTextPosition.Alignment? /// The font used by the label. let font: UIFont? /// Flag that determines if font should be scaled - let fontScalingEnabled: Bool + var fontScalingEnabled: Bool /// Text obfuscator for masking text. let textObfuscator: TextObfuscating diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorder.swift index 8f364be4af..8ad5b186d5 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorder.swift @@ -19,7 +19,8 @@ internal struct UINavigationBarRecorder: NodeRecorder { color: inferColor(of: navigationBar) ) - return SpecificElement(wireframesBuilder: builder, subtreeStrategy: .record) + let node = Node(viewAttributes: attributes, wireframesBuilder: builder) + return SpecificElement(subtreeStrategy: .record, nodes: [node]) } private func inferOccupiedFrame(of navigationBar: UINavigationBar, in context: ViewTreeRecordingContext) -> CGRect { diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorder.swift index b4dcfd45e5..9740729e8c 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorder.swift @@ -13,24 +13,40 @@ import UIKit /// - We can't request `picker.dataSource` to receive the value - doing so will result in calling applicaiton code, which could be /// dangerous (if the code is faulty) and may significantly slow down the performance (e.g. if the underlying source requires database fetch). /// - Similarly, we don't call `picker.delegate` to avoid running application code outside `UIKit's` lifecycle. -/// - Instead, we infer the value by traversing picker's subtree state and finding texts that are displayed closest to its geometry center. +/// - Instead, we infer the value by traversing picker's subtree and finding texts that have no "3D wheel" effect applied. /// - If privacy mode is elevated, we don't replace individual characters with "x" letter - instead we change whole options to fixed-width mask value. internal struct UIPickerViewRecorder: NodeRecorder { - /// Custom text obfuscator for picker option labels. - /// - /// Unlike the default `TextObfuscator` it doesn't mask each individual character with "x" letter. Instead, it replaces - /// whole options with fixed "xxx" string. This elevates the level of privacy, because selected option can not be inferred - /// by counting number of characters. - private struct PickerOptionTextObfuscator: TextObfuscating { - private static let maskedString = "xxx" - func mask(text: String) -> String { Self.maskedString } - } - /// A sub-tree recorder for capturing shapes nested in picker's view hierarchy. + /// Records all shapes in picker's subtree. /// It is used to capture the background of selected option. - private let selectionRecorder = ViewTreeRecorder(nodeRecorders: [UIViewRecorder()]) - /// A sub-tree recorder for capturing labels nested in picker's view hierarchy. + private let selectionRecorder: ViewTreeRecorder + /// Records all labels in picker's subtree. /// It is used to capture titles for displayed options. - private let labelsRecorder = ViewTreeRecorder(nodeRecorders: [UILabelRecorder()]) + private let labelsRecorder: ViewTreeRecorder + + init() { + let viewRecorder = UIViewRecorder() + viewRecorder.semanticsOverride = { view, attributes in + if #available(iOS 13.0, *) { + if attributes.isTranslucent || !CATransform3DIsIdentity(view.transform3D) { + // If this view has any 3D effect applied, do not enter its subtree: + return IgnoredElement(subtreeStrategy: .ignore) + } + } + // Otherwise, enter the subtree of this element, but do not consider it significant (`InvisibleElement`): + return InvisibleElement(subtreeStrategy: .record) + } + + let labelRecorder = UILabelRecorder() + labelRecorder.builderOverride = { builder in + var builder = builder + builder.textAlignment = .init(horizontal: .center, vertical: .center) + builder.fontScalingEnabled = true + return builder + } + + self.labelsRecorder = ViewTreeRecorder(nodeRecorders: [viewRecorder, labelRecorder]) + self.selectionRecorder = ViewTreeRecorder(nodeRecorders: [UIViewRecorder()]) + } func semantics(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> NodeSemantics? { guard let picker = view as? UIPickerView else { @@ -44,29 +60,24 @@ internal struct UIPickerViewRecorder: NodeRecorder { // For our "approximation", we render selected option text on top of selection background. However, // in the actual `UIPickerView's` tree their order is opposite (blending is used to make the label // pass through the shape). For that reason, we record both kinds of nodes separately and then reorder - // them in returned `.replace(subtreeNodes:)` strategy: + // them in returned semantics: let backgroundNodes = recordBackgroundOfSelectedOption(in: picker, using: context) let titleNodes = recordTitlesOfSelectedOption(in: picker, pickerAttributes: attributes, using: context) guard attributes.hasAnyAppearance else { // If the root view of `UIPickerView` defines no other appearance (e.g. no custom `.background`), we can // safely ignore it, with only forwarding child nodes to final recording. - return InvisibleElement( - subtreeStrategy: .replace(subtreeNodes: backgroundNodes + titleNodes) - ) + return SpecificElement(subtreeStrategy: .ignore, nodes: backgroundNodes + titleNodes) } - // Otherwise, we build dedicated wireframes to describe additional appearance coming from picker's root `UIView`: + // Otherwise, we build dedicated wireframes to describe extra appearance coming from picker's root `UIView`: let builder = UIPickerViewWireframesBuilder( wireframeRect: attributes.frame, attributes: attributes, backgroundWireframeID: context.ids.nodeID(for: picker) ) - - return SpecificElement( - wireframesBuilder: builder, - subtreeStrategy: .replace(subtreeNodes: backgroundNodes + titleNodes) - ) + let node = Node(viewAttributes: attributes, wireframesBuilder: builder) + return SpecificElement(subtreeStrategy: .ignore, nodes: [node] + backgroundNodes + titleNodes) } /// Records `UIView` nodes that define background of selected option. @@ -74,27 +85,10 @@ internal struct UIPickerViewRecorder: NodeRecorder { return selectionRecorder.recordNodes(for: picker, in: context) } - /// Records `UILabel` nodes that hold titles of **selected** options - if picker defines N components, there will be N nodes returned. + /// Records `UILabel` nodes that hold titles of **selected** options. private func recordTitlesOfSelectedOption(in picker: UIPickerView, pickerAttributes: ViewAttributes, using context: ViewTreeRecordingContext) -> [Node] { var context = context - context.textObfuscator = PickerOptionTextObfuscator() - context.semanticsOverride = { currentSemantics, label, attributes in - // We consider option to be "selected" if it is displayed close enough to picker's geometry center - // and its `UILabel` is opaque: - let isNearCenter = abs(attributes.frame.midY - pickerAttributes.frame.midY) < 10 - let isForeground = attributes.alpha == 1 - - if isNearCenter && isForeground, var wireframeBuilder = (currentSemantics.wireframesBuilder as? UILabelWireframesBuilder) { - // For some reason, the text within `UILabel` is not centered in regular way (with `intrinsicContentSize`), hence - // we need to manually center it within produced wireframe. Here we use SR text alignment options to achieve it: - var newSemantics = currentSemantics - wireframeBuilder.textAlignment = .init(horizontal: .center, vertical: .center) - newSemantics.wireframesBuilder = wireframeBuilder - return newSemantics - } else { - return InvisibleElement.constant // this node doesn't describe selected option - ignore it - } - } + context.textObfuscator = context.selectionTextObfuscator return labelsRecorder.recordNodes(for: picker, in: context) } } @@ -106,11 +100,7 @@ internal struct UIPickerViewWireframesBuilder: NodeWireframesBuilder { func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] { return [ - builder.createShapeWireframe( - id: backgroundWireframeID, - frame: wireframeRect, - attributes: attributes - ) + builder.createShapeWireframe(id: backgroundWireframeID, frame: wireframeRect, attributes: attributes) ] } } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorder.swift index 63dd6c92ba..d0f2eac084 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorder.swift @@ -21,7 +21,7 @@ internal struct UISegmentRecorder: NodeRecorder { let builder = UISegmentWireframesBuilder( wireframeRect: attributes.frame, attributes: attributes, - textObfuscator: context.recorder.privacy == .maskAll ? context.textObfuscator : nopTextObfuscator, + textObfuscator: context.textObfuscator, backgroundWireframeID: ids[0], segmentWireframeIDs: Array(ids[1.. NodeSemantics? { + guard let stepper = view as? UIStepper else { + return nil + } + guard attributes.isVisible else { + return InvisibleElement.constant + } + + let stepperFrame = CGRect(origin: attributes.frame.origin, size: stepper.intrinsicContentSize) + let ids = context.ids.nodeIDs(5, for: stepper) + let isMasked = context.recorder.privacy == .maskAll + + let builder = UIStepperWireframesBuilder( + wireframeRect: stepperFrame, + cornerRadius: stepper.subviews.first?.layer.cornerRadius ?? 0, + backgroundWireframeID: ids[0], + dividerWireframeID: ids[1], + minusWireframeID: ids[2], + plusHorizontalWireframeID: ids[3], + plusVerticalWireframeID: ids[4], + isMinusEnabled: isMasked || (stepper.value > stepper.minimumValue), + isPlusEnabled: isMasked || (stepper.value < stepper.maximumValue) + ) + let node = Node(viewAttributes: attributes, wireframesBuilder: builder) + return SpecificElement(subtreeStrategy: .ignore, nodes: [node]) + } +} + +internal struct UIStepperWireframesBuilder: NodeWireframesBuilder { + let wireframeRect: CGRect + let cornerRadius: CGFloat + let backgroundWireframeID: WireframeID + let dividerWireframeID: WireframeID + let minusWireframeID: WireframeID + let plusHorizontalWireframeID: WireframeID + let plusVerticalWireframeID: WireframeID + let isMinusEnabled: Bool + let isPlusEnabled: Bool + + func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] { + let background = builder.createShapeWireframe( + id: backgroundWireframeID, + frame: wireframeRect, + borderColor: nil, + borderWidth: nil, + backgroundColor: SystemColors.tertiarySystemFill, + cornerRadius: cornerRadius + ) + let verticalMargin: CGFloat = 6 + let divider = builder.createShapeWireframe( + id: dividerWireframeID, + frame: CGRect( + origin: CGPoint(x: 0, y: verticalMargin), + size: CGSize(width: 1, height: wireframeRect.size.height - 2 * verticalMargin) + ).putInside(wireframeRect, horizontalAlignment: .center, verticalAlignment: .middle), + backgroundColor: SystemColors.placeholderText + ) + + let horizontalElementRect = CGRect(origin: .zero, size: CGSize(width: 14, height: 2)) + let verticalElementRect = CGRect(origin: .zero, size: CGSize(width: 2, height: 14)) + let (leftButtonFrame, rightButtonFrame) = wireframeRect.divided(atDistance: wireframeRect.size.width / 2, from: .minXEdge) + let minus = builder.createShapeWireframe( + id: minusWireframeID, + frame: horizontalElementRect.putInside(leftButtonFrame, horizontalAlignment: .center, verticalAlignment: .middle), + backgroundColor: isMinusEnabled ? SystemColors.label : SystemColors.placeholderText, + cornerRadius: horizontalElementRect.size.height + ) + let plusHorizontal = builder.createShapeWireframe( + id: plusHorizontalWireframeID, + frame: horizontalElementRect.putInside(rightButtonFrame, horizontalAlignment: .center, verticalAlignment: .middle), + backgroundColor: isPlusEnabled ? SystemColors.label : SystemColors.placeholderText, + cornerRadius: horizontalElementRect.size.height + ) + let plusVertical = builder.createShapeWireframe( + id: plusVerticalWireframeID, + frame: verticalElementRect.putInside(rightButtonFrame, horizontalAlignment: .center, verticalAlignment: .middle), + backgroundColor: isPlusEnabled ? SystemColors.label : SystemColors.placeholderText, + cornerRadius: verticalElementRect.size.width + ) + return [background, divider, minus, plusHorizontal, plusVertical] + } +} diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorder.swift index ac5349de79..992ae06277 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorder.swift @@ -29,11 +29,13 @@ internal struct UISwitchRecorder: NodeRecorder { isEnabled: `switch`.isEnabled, isDarkMode: `switch`.usesDarkMode, isOn: `switch`.isOn, + isMasked: context.recorder.privacy == .maskAll, thumbTintColor: `switch`.thumbTintColor?.cgColor, onTintColor: `switch`.onTintColor?.cgColor, offTintColor: `switch`.tintColor?.cgColor ) - return SpecificElement(wireframesBuilder: builder, subtreeStrategy: .ignore) + let node = Node(viewAttributes: attributes, wireframesBuilder: builder) + return SpecificElement(subtreeStrategy: .ignore, nodes: [node]) } } @@ -47,11 +49,41 @@ internal struct UISwitchWireframesBuilder: NodeWireframesBuilder { let isEnabled: Bool let isDarkMode: Bool let isOn: Bool + let isMasked: Bool let thumbTintColor: CGColor? let onTintColor: CGColor? let offTintColor: CGColor? func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] { + if isMasked { + return createMasked(with: builder) + } else { + return createNotMasked(with: builder) + } + } + + private func createMasked(with builder: WireframesBuilder) -> [SRWireframe] { + let track = builder.createShapeWireframe( + id: trackWireframeID, + frame: wireframeRect, + borderColor: nil, + borderWidth: nil, + backgroundColor: SystemColors.tertiarySystemFill, + cornerRadius: wireframeRect.height * 0.5, + opacity: isEnabled ? attributes.alpha : 0.5 + ) + + // Create background wireframe if the underlying `UIView` has any appearance: + if attributes.hasAnyAppearance { + let background = builder.createShapeWireframe(id: backgroundWireframeID, frame: attributes.frame, attributes: attributes) + + return [background, track] + } else { + return [track] + } + } + + private func createNotMasked(with builder: WireframesBuilder) -> [SRWireframe] { let radius = wireframeRect.height * 0.5 // Create track wireframe: diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorder.swift index 8cc5a966bf..007a69dd49 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorder.swift @@ -18,7 +18,8 @@ internal struct UITabBarRecorder: NodeRecorder { attributes: attributes, color: inferColor(of: tabBar) ) - return SpecificElement(wireframesBuilder: builder, subtreeStrategy: .record) + let node = Node(viewAttributes: attributes, wireframesBuilder: builder) + return SpecificElement(subtreeStrategy: .record, nodes: [node]) } private func inferOccupiedFrame(of tabBar: UITabBar, in context: ViewTreeRecordingContext) -> CGRect { diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorder.swift index 0f693e951d..7517505b07 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorder.swift @@ -7,6 +7,18 @@ import UIKit internal struct UITextFieldRecorder: NodeRecorder { + /// `UIViewRecorder` for recording appearance of the text field. + private let backgroundViewRecorder: UIViewRecorder + /// `UIImageViewRecorder` for recording icons that are displayed in text field. + private let iconsRecorder: UIImageViewRecorder + private let subtreeRecorder: ViewTreeRecorder + + init() { + self.backgroundViewRecorder = UIViewRecorder() + self.iconsRecorder = UIImageViewRecorder() + self.subtreeRecorder = ViewTreeRecorder(nodeRecorders: [backgroundViewRecorder, iconsRecorder]) + } + func semantics(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> NodeSemantics? { guard let textField = view as? UITextField else { return nil @@ -16,104 +28,109 @@ internal struct UITextFieldRecorder: NodeRecorder { return InvisibleElement.constant } - // TODO: RUMM-2459 - // Explore other (better) ways of infering text field appearance: - - var editorProperties: UITextFieldWireframesBuilder.EditorFieldProperties? = nil - // Lookup the actual editor field's view in `textField` hierarchy to infer its appearance. - // Perhaps this can be do better by infering it from `UITextField` object in RUMM-2459: - dfsVisitSubviews(of: textField) { subview in - if subview.bounds == textField.bounds { - editorProperties = .init( - backgroundColor: subview.backgroundColor?.cgColor, - layerBorderColor: subview.layer.borderColor, - layerBorderWidth: subview.layer.borderWidth, - layerCornerRadius: subview.layer.cornerRadius - ) - } + // For our "approximation", we render text field's text on top of other TF's appearance. + // Here we record both kind of nodes separately and order them respectively in returned semantics: + let appearanceNodes = recordAppearance(in: textField, textFieldAttributes: attributes, using: context) + if let textNode = recordText(in: textField, attributes: attributes, using: context) { + return SpecificElement(subtreeStrategy: .ignore, nodes: appearanceNodes + [textNode]) + } else { + return SpecificElement(subtreeStrategy: .ignore, nodes: appearanceNodes) } + } - let text: String = { - guard let textFieldText = textField.text, !textFieldText.isEmpty else { - return textField.placeholder ?? "" - } - return textFieldText - }() + /// Records `UIView` and `UIImageViewRecorder` nodes that define text field's appearance. + private func recordAppearance(in textField: UITextField, textFieldAttributes: ViewAttributes, using context: ViewTreeRecordingContext) -> [Node] { + backgroundViewRecorder.semanticsOverride = { _, viewAttributes in + // We consider view to define text field's appearance if it has the same + // size as text field: + let hasSameSize = textFieldAttributes.frame == viewAttributes.frame + let isBackground = hasSameSize && viewAttributes.hasAnyAppearance + return !isBackground ? IgnoredElement(subtreeStrategy: .record) : nil + } + + return subtreeRecorder.recordNodes(for: textField, in: context) + } + + /// Creates node that represents TF's text. + /// We cannot use general view-tree traversal solution to find nested labels (`UITextField's` subtree doesn't look that way). Instead, we read + /// text information and create arbitrary node with appropriate wireframes builder configuration. + private func recordText(in textField: UITextField, attributes: ViewAttributes, using context: ViewTreeRecordingContext) -> Node? { + let text: String + let isPlaceholder: Bool + + if let fieldText = textField.text, !fieldText.isEmpty { + text = fieldText + isPlaceholder = false + } else if let fieldPlaceholder = textField.placeholder { + text = fieldPlaceholder + isPlaceholder = true + } else { + return nil + } - // TODO: RUMM-2459 - // Enhance text fields rendering by calculating the actual frame of the text: let textFrame = attributes.frame + .insetBy(dx: 5, dy: 5) // 5 points padding let builder = UITextFieldWireframesBuilder( - wireframeID: context.ids.nodeID(for: textField), + wireframeRect: textFrame, attributes: attributes, + wireframeID: context.ids.nodeID(for: textField), text: text, - // TODO: RUMM-2459 - // Is it correct to assume `textField.textColor` for placeholder text? textColor: textField.textColor?.cgColor, + textAlignment: textField.textAlignment, + isPlaceholderText: isPlaceholder, font: textField.font, fontScalingEnabled: textField.adjustsFontSizeToFitWidth, - editor: editorProperties, - textObfuscator: context.recorder.privacy == .maskAll ? context.textObfuscator : nopTextObfuscator, - wireframeRect: textFrame + textObfuscator: textObfuscator(for: textField, in: context) ) - return SpecificElement(wireframesBuilder: builder, subtreeStrategy: .ignore) + return Node(viewAttributes: attributes, wireframesBuilder: builder) + } + + private func textObfuscator(for textField: UITextField, in context: ViewTreeRecordingContext) -> TextObfuscating { + if textField.isSecureTextEntry || textField.textContentType == .emailAddress || textField.textContentType == .telephoneNumber { + return context.sensitiveTextObfuscator + } + + return context.textObfuscator } } internal struct UITextFieldWireframesBuilder: NodeWireframesBuilder { - let wireframeID: WireframeID - /// Attributes of the base `UIView`. + let wireframeRect: CGRect let attributes: ViewAttributes - /// The text inside text field. + + let wireframeID: WireframeID + let text: String - /// The color of the text. let textColor: CGColor? - /// The font used by the text field. + let textAlignment: NSTextAlignment + let isPlaceholderText: Bool let font: UIFont? - /// Flag that determines if font should be scaled let fontScalingEnabled: Bool - /// Properties of the editor field (which is a nested subview in `UITextField`). - let editor: EditorFieldProperties? - /// Text obfuscator for masking text. let textObfuscator: TextObfuscating - let wireframeRect: CGRect - - struct EditorFieldProperties { - /// Editor view's `.backgorundColor`. - var backgroundColor: CGColor? = nil - /// Editor view's `layer.backgorundColor`. - var layerBorderColor: CGColor? = nil - /// Editor view's `layer.backgorundColor`. - var layerBorderWidth: CGFloat = 0 - /// Editor view's `layer.cornerRadius`. - var layerCornerRadius: CGFloat = 0 - } - func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] { + let horizontalAlignment: SRTextPosition.Alignment.Horizontal? = { + switch textAlignment { + case .left: return .left + case .center: return .center + case .right: return .right + default: return nil + } + }() + return [ builder.createTextWireframe( id: wireframeID, - frame: attributes.frame, + frame: wireframeRect, text: textObfuscator.mask(text: text), textFrame: wireframeRect, - textColor: textColor, + textAlignment: .init(horizontal: horizontalAlignment, vertical: .center), + textColor: isPlaceholderText ? SystemColors.placeholderText : textColor, font: font, fontScalingEnabled: fontScalingEnabled, - borderColor: editor?.layerBorderColor ?? attributes.layerBorderColor, - borderWidth: editor?.layerBorderWidth ?? attributes.layerBorderWidth, - backgroundColor: editor?.backgroundColor ?? attributes.backgroundColor, - cornerRadius: editor?.layerCornerRadius ?? attributes.layerCornerRadius, opacity: attributes.alpha ) ] } } - -private func dfsVisitSubviews(of view: UIView, visit: (UIView) -> Void) { - view.subviews.forEach { subview in - visit(subview) - dfsVisitSubviews(of: subview, visit: visit) - } -} diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorder.swift index a7096dfd0a..cf5f337fc8 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorder.swift @@ -20,10 +20,11 @@ internal struct UITextViewRecorder: NodeRecorder { text: textView.text, textColor: textView.textColor?.cgColor ?? UIColor.black.cgColor, font: textView.font, - textObfuscator: context.recorder.privacy == .maskAll ? context.textObfuscator : nopTextObfuscator, + textObfuscator: context.textObfuscator, contentRect: CGRect(origin: textView.contentOffset, size: textView.contentSize) ) - return SpecificElement(wireframesBuilder: builder, subtreeStrategy: .record) + let node = Node(viewAttributes: attributes, wireframesBuilder: builder) + return SpecificElement(subtreeStrategy: .record, nodes: [node]) } } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift index fc215d954c..128bf39d1f 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift @@ -6,11 +6,17 @@ import UIKit -internal struct UIViewRecorder: NodeRecorder { +internal class UIViewRecorder: NodeRecorder { + /// An option for overriding default semantics from parent recorder. + var semanticsOverride: (UIView, ViewAttributes) -> NodeSemantics? = { _, _ in nil } + func semantics(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> NodeSemantics? { guard attributes.isVisible else { return InvisibleElement.constant } + if let semantics = semanticsOverride(view, attributes) { + return semantics + } guard attributes.hasAnyAppearance else { // The view has no appearance, but it may contain subviews that bring visual elements, so @@ -23,8 +29,8 @@ internal struct UIViewRecorder: NodeRecorder { attributes: attributes, wireframeRect: attributes.frame ) - - return AmbiguousElement(wireframesBuilder: builder) + let node = Node(viewAttributes: attributes, wireframesBuilder: builder) + return AmbiguousElement(nodes: [node]) } } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift index 70a4ec5f50..43e9a91552 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift @@ -16,15 +16,13 @@ internal struct ViewTreeRecordingContext { let coordinateSpace: UICoordinateSpace /// Generates stable IDs for traversed views. let ids: NodeIDGenerator - /// Masks text in recorded nodes. + /// Text obfuscator applied to all non-sensitive texts. No-op if privacy mode is disabled. /// Can be overwriten in by `NodeRecorder` if their subtree recording requires different masking. var textObfuscator: TextObfuscating - /// Allows `NodeRecorders` to modify semantics of nodes in their subtree. - /// It gets called each time when a new semantic is found. - /// - /// The closure takes: current semantics, the `UIView` object and its `ViewAttributes`. - /// The closure implementation should return new semantics for that element. - var semanticsOverride: ((NodeSemantics, UIView, ViewAttributes) -> NodeSemantics)? = nil + /// Text obfuscator applied to user selection texts (such as labels in picker control). + var selectionTextObfuscator: TextObfuscating + /// Text obfuscator applied to all sensitive texts (such as passwords or e-mail address). + let sensitiveTextObfuscator: TextObfuscating } internal struct ViewTreeRecorder { @@ -44,22 +42,23 @@ internal struct ViewTreeRecorder { // MARK: - Private private func recordRecursively(nodes: inout [Node], view: UIView, context: ViewTreeRecordingContext) { - let node = node(for: view, in: context) - nodes.append(node) + let semantics = nodeSemantics(for: view, in: context) + + if !semantics.nodes.isEmpty { + nodes.append(contentsOf: semantics.nodes) + } - switch node.semantics.subtreeStrategy { + switch semantics.subtreeStrategy { case .record: for subview in view.subviews { recordRecursively(nodes: &nodes, view: subview, context: context) } - case .replace(let subtreeNodes): - nodes.append(contentsOf: subtreeNodes) case .ignore: break } } - private func node(for view: UIView, in context: ViewTreeRecordingContext) -> Node { + private func nodeSemantics(for view: UIView, in context: ViewTreeRecordingContext) -> NodeSemantics { let attributes = ViewAttributes( frameInRootView: view.convert(view.bounds, to: context.coordinateSpace), view: view @@ -75,10 +74,6 @@ internal struct ViewTreeRecorder { if nextSemantics.importance >= semantics.importance { semantics = nextSemantics - if let semanticsOverride = context.semanticsOverride { - semantics = semanticsOverride(semantics, view, attributes) - } - if nextSemantics.importance == .max { // We know the current semantics is best we can get, so skip querying other `nodeRecorders`: break @@ -86,6 +81,6 @@ internal struct ViewTreeRecorder { } } - return Node(viewAttributes: attributes, semantics: semantics) + return semantics } } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift index 50f1c0ccf0..384323783d 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift @@ -37,9 +37,8 @@ internal struct ViewTreeSnapshot { internal struct Node { /// Attributes of the `UIView` that this node was created for. let viewAttributes: ViewAttributes - - /// The semantics of this node. - let semantics: NodeSemantics + /// A type defining how to build SR wireframes for the UI element described by this node. + let wireframesBuilder: NodeWireframesBuilder } /// Attributes of the `UIView` that the node was created for. @@ -111,11 +110,15 @@ extension ViewAttributes { } } -/// A type denoting semantics of given UI element in Session Replay. +/// A type defining semantics of portion of view-tree hierarchy (one or more `Nodes`). /// -/// The `NodeSemantics` is attached to each node produced by `Recorder`. During tree traversal, -/// views are queried in available node recorders. Each `NodeRecorder` inspects the view object and -/// tries to infer its identity (a `NodeSemantics`). +/// It is leveraged during view-tree traversal in `Recorder`: +/// - for each view, a sequence of `NodeRecorders` is queried to find best semantics of the view and its subtree; +/// - if multiple `NodeRecorders` find few semantics, the one with higher `.importance` is used; +/// - each `NodeRecorder` can construct `semantic.nodes` according to its own routines, in particular: +/// - it can create virtual nodes that define custom wireframes; +/// - it can use other node recorders to tarverse the subtree of certain view and find `semantic.nodes` with custom rules; +/// - it can return `semantic.nodes` and ask parent recorder to traverse the rest of subtree following global rules (`subtreeStrategy: .record`). /// /// There are two `NodeSemantics` that describe the identity of UI element: /// - `AmbiguousElement` - element is of `UIView` class and we only know its base attributes (the real identity could be ambiguous); @@ -127,9 +130,6 @@ extension ViewAttributes { /// be safely ignored in `Recorder` or `Processor` (e.g. a `UILabel` with no text, no border and fully transparent color). /// - `UnknownElement` - the element is of unknown kind, which could indicate an error during view tree traversal (e.g. working on /// assumption that is not met). -/// -/// Both `AmbiguousElement` and `SpecificElement` provide an implementation of `NodeWireframesBuilder` which describes -/// how to construct SR wireframes for UI elements they refer to. No builder is provided for `InvisibleElement` and `UnknownElement`. internal protocol NodeSemantics { /// The severity of this semantic. /// @@ -139,9 +139,8 @@ internal protocol NodeSemantics { /// Defines the strategy which `Recorder` should apply to subtree of this node. var subtreeStrategy: NodeSubtreeStrategy { get } - - /// A type defining how to build SR wireframes for the UI element this semantic was recorded for. - var wireframesBuilder: NodeWireframesBuilder? { set get } + /// Nodes that share this semantics. + var nodes: [Node] { get } } extension NodeSemantics { @@ -159,11 +158,6 @@ internal enum NodeSubtreeStrategy { /// This strategy is particularly useful for semantics that do not make assumption on node's content (e.g. this strategy can be /// practical choice for `UITabBar` node to let the recorder automatically capture any labels, images or shapes that are displayed in it). case record - /// Do not traverse subtree of this node and instead replace it (the subtree) with provided nodes. - /// - /// This strategy is useful for semantics that only partially describe certain elements and perform curated traversal of their subtree (e.g. it can be - /// used for `UIPickerView` where we only traverse subtree to look for specific elements, like the text of the selected row). - case replace(subtreeNodes: [Node]) /// Do not enter the subtree of this node. /// /// This strategy should be used for semantics that fully describe certain elements (e.g. it doesn't make sense to traverse the subtree of `UISwitch`). @@ -174,8 +168,8 @@ internal enum NodeSubtreeStrategy { /// in view-tree traversal performed in `Recorder` (e.g. working on assumption that is not met). internal struct UnknownElement: NodeSemantics { static let importance: Int = .min - var wireframesBuilder: NodeWireframesBuilder? = nil let subtreeStrategy: NodeSubtreeStrategy = .record + let nodes: [Node] = [] /// Use `UnknownElement.constant` instead. private init () {} @@ -186,11 +180,12 @@ internal struct UnknownElement: NodeSemantics { /// A semantics of an UI element that is either `UIView` or one of its known subclasses. This semantics mean that the element /// has no visual appearance that can be presented in SR (e.g. a `UILabel` with no text, no border and fully transparent color). -/// Nodes with this semantics can be safely ignored in `Recorder` or in `Processor`. +/// Unlike `IgnoredElement`, this semantics can be overwritten with another one with higher importance. This means that even +/// if the root view of certain element has no appearance, other node recorders will continue checking it for strictkier semantics. internal struct InvisibleElement: NodeSemantics { static let importance: Int = 0 - var wireframesBuilder: NodeWireframesBuilder? = nil let subtreeStrategy: NodeSubtreeStrategy + let nodes: [Node] = [] /// Use `InvisibleElement.constant` instead. private init () { @@ -201,18 +196,26 @@ internal struct InvisibleElement: NodeSemantics { self.subtreeStrategy = subtreeStrategy } - /// A constant value of `InvisibleElement` semantics with `subtreeStrategy: .ignore`. + /// A constant value of `InvisibleElement` semantics. static let constant = InvisibleElement() } +/// A semantics of an UI element that should be ignored when traversing view-tree. Unlike `InvisibleElement` this semantics cannot +/// be overwritten by any other. This means that next node recorders won't be asked for further check of a strictkier semantics. +internal struct IgnoredElement: NodeSemantics { + static var importance: Int = .max + let subtreeStrategy: NodeSubtreeStrategy + let nodes: [Node] = [] +} + /// A semantics of an UI element that is of `UIView` type. This semantics mean that the element has visual appearance in SR, but /// it will only utilize its base `UIView` attributes. The full identity of the node will remain ambiguous if not overwritten with `SpecificElement`. /// /// The view-tree traversal algorithm will continue visiting the subtree of given `UIView` if it has `AmbiguousElement` semantics. internal struct AmbiguousElement: NodeSemantics { static let importance: Int = 0 - var wireframesBuilder: NodeWireframesBuilder? let subtreeStrategy: NodeSubtreeStrategy = .record + let nodes: [Node] } /// A semantics of an UI element that is one of `UIView` subclasses. This semantics mean that we know its full identity along with set of @@ -220,6 +223,6 @@ internal struct AmbiguousElement: NodeSemantics { /// "on" / "off" state of `UISwitch` control). internal struct SpecificElement: NodeSemantics { static let importance: Int = .max - var wireframesBuilder: NodeWireframesBuilder? let subtreeStrategy: NodeSubtreeStrategy + let nodes: [Node] } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift index eb7d073537..00571257a9 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift @@ -15,8 +15,10 @@ internal struct ViewTreeSnapshotBuilder { let viewTreeRecorder: ViewTreeRecorder /// Generates stable IDs for traversed views. let idsGenerator: NodeIDGenerator - /// Masks text in recorded nodes. - let textObfuscator: TextObfuscator + /// Text obfuscator applied to all non-sensitive texts. No-op if privacy mode is disabled. + let textObfuscator = TextObfuscator() + /// Text obfuscator applied to all sensitive texts. + let sensitiveTextObfuscator = SensitiveTextObfuscator() /// Builds the `ViewTreeSnapshot` for given root view. /// @@ -30,7 +32,19 @@ internal struct ViewTreeSnapshotBuilder { recorder: recorderContext, coordinateSpace: rootView, ids: idsGenerator, - textObfuscator: textObfuscator + textObfuscator: { + switch recorderContext.privacy { + case .maskAll: return textObfuscator + case .allowAll: return nopTextObfuscator + } + }(), + selectionTextObfuscator: { + switch recorderContext.privacy { + case .maskAll: return sensitiveTextObfuscator + case .allowAll: return nopTextObfuscator + } + }(), + sensitiveTextObfuscator: sensitiveTextObfuscator ) let snapshot = ViewTreeSnapshot( date: recorderContext.date.addingTimeInterval(recorderContext.rumContext.viewServerTimeOffset ?? 0), @@ -45,24 +59,27 @@ internal struct ViewTreeSnapshotBuilder { extension ViewTreeSnapshotBuilder { init() { self.init( - viewTreeRecorder: ViewTreeRecorder(nodeRecorders: defaultNodeRecorders), - idsGenerator: NodeIDGenerator(), - textObfuscator: TextObfuscator() + viewTreeRecorder: ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()), + idsGenerator: NodeIDGenerator() ) } } /// An arrays of default node recorders executed for the root view-tree hierarchy. -internal let defaultNodeRecorders: [NodeRecorder] = [ - UIViewRecorder(), - UILabelRecorder(), - UIImageViewRecorder(), - UITextFieldRecorder(), - UITextViewRecorder(), - UISwitchRecorder(), - UISliderRecorder(), - UISegmentRecorder(), - UINavigationBarRecorder(), - UITabBarRecorder(), - UIPickerViewRecorder(), -] +internal func createDefaultNodeRecorders() -> [NodeRecorder] { + return [ + UIViewRecorder(), + UILabelRecorder(), + UIImageViewRecorder(), + UITextFieldRecorder(), + UITextViewRecorder(), + UISwitchRecorder(), + UISliderRecorder(), + UISegmentRecorder(), + UIStepperRecorder(), + UINavigationBarRecorder(), + UITabBarRecorder(), + UIPickerViewRecorder(), + UIDatePickerRecorder(), + ] +} diff --git a/DatadogSessionReplay/Sources/Utilities/Cache.swift b/DatadogSessionReplay/Sources/Utilities/Cache.swift index 024b7870cf..c015c27941 100644 --- a/DatadogSessionReplay/Sources/Utilities/Cache.swift +++ b/DatadogSessionReplay/Sources/Utilities/Cache.swift @@ -14,17 +14,20 @@ internal final class Cache { init(dateProvider: @escaping () -> Date = Date.init, entryLifetime: TimeInterval = 12 * 60 * 60, - maximumEntryCount: Int = 100) { + totalBytesLimit: Int = 5_000_000, + maximumEntryCount: Int = 50 + ) { self.dateProvider = dateProvider self.entryLifetime = entryLifetime wrapped.countLimit = maximumEntryCount + wrapped.totalCostLimit = totalBytesLimit wrapped.delegate = keyTracker } - func insert(_ value: Value, forKey key: Key) { + func insert(_ value: Value, forKey key: Key, size: Int = 0) { let date = dateProvider().addingTimeInterval(entryLifetime) let entry = Entry(key: key, value: value, expirationDate: date) - wrapped.setObject(entry, forKey: WrappedKey(key)) + wrapped.setObject(entry, forKey: WrappedKey(key), cost: size) keyTracker.keys.insert(key) } @@ -94,7 +97,7 @@ private extension Cache { } extension Cache { - subscript(key: Key) -> Value? { + subscript(key: Key, size: Int = 0) -> Value? { get { return value(forKey: key) } set { guard let value = newValue else { @@ -102,7 +105,7 @@ extension Cache { return } - insert(value, forKey: key) + insert(value, forKey: key, size: size) } } } diff --git a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift index b9193a39eb..fe1931f8a1 100644 --- a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift @@ -201,11 +201,7 @@ extension NodeSubtreeStrategy: AnyMockable, RandomMockable { } public static func mockRandom() -> NodeSubtreeStrategy { - let all: [NodeSubtreeStrategy] = [ - .record, - .replace(subtreeNodes: [.mockAny()]), - .ignore, - ] + let all: [NodeSubtreeStrategy] = [.record, .ignore] return all.randomElement()! } } @@ -218,8 +214,8 @@ func mockRandomNodeSemantics() -> NodeSemantics { let all: [NodeSemantics] = [ UnknownElement.constant, InvisibleElement.constant, - AmbiguousElement(wireframesBuilder: NOPWireframesBuilderMock()), - SpecificElement(wireframesBuilder: NOPWireframesBuilderMock(), subtreeStrategy: .mockRandom()), + AmbiguousElement(nodes: .mockRandom(count: .mockRandom(min: 1, max: 5))), + SpecificElement(subtreeStrategy: .mockRandom(), nodes: .mockRandom(count: .mockRandom(min: 1, max: 5))), ] return all.randomElement()! } @@ -239,37 +235,50 @@ extension Node: AnyMockable, RandomMockable { static func mockWith( viewAttributes: ViewAttributes = .mockAny(), - semantics: NodeSemantics = InvisibleElement.constant + wireframesBuilder: NodeWireframesBuilder = NOPWireframesBuilderMock() ) -> Node { return .init( viewAttributes: viewAttributes, - semantics: semantics + wireframesBuilder: wireframesBuilder ) } public static func mockRandom() -> Node { return .init( viewAttributes: .mockRandom(), - semantics: mockRandomNodeSemantics() + wireframesBuilder: NOPWireframesBuilderMock() ) } } extension SpecificElement { static func mockAny() -> SpecificElement { - SpecificElement(wireframesBuilder: NOPWireframesBuilderMock(), subtreeStrategy: .mockRandom()) + SpecificElement(subtreeStrategy: .mockRandom(), nodes: []) } - static func mock( - wireframeRect: CGRect, - subtreeStrategy: NodeSubtreeStrategy = .mockRandom() + + static func mockWith( + subtreeStrategy: NodeSubtreeStrategy = .mockAny(), + nodes: [Node] = .mockAny() ) -> SpecificElement { SpecificElement( - wireframesBuilder: ShapeWireframesBuilderMock(wireframeRect: wireframeRect), - subtreeStrategy: subtreeStrategy + subtreeStrategy: subtreeStrategy, + nodes: nodes ) } } +internal class TextObfuscatorMock: TextObfuscating { + var result: (String) -> String = { $0 } + + func mask(text: String) -> String { + return result(text) + } +} + +internal func mockRandomTextObfuscator() -> TextObfuscating { + return [NOPTextObfuscator(), TextObfuscator(), SensitiveTextObfuscator()].randomElement()! +} + extension ViewTreeRecordingContext: AnyMockable, RandomMockable { public static func mockAny() -> ViewTreeRecordingContext { return .mockWith() @@ -280,7 +289,9 @@ extension ViewTreeRecordingContext: AnyMockable, RandomMockable { recorder: .mockRandom(), coordinateSpace: UIView.mockRandom(), ids: NodeIDGenerator(), - textObfuscator: TextObfuscator() + textObfuscator: mockRandomTextObfuscator(), + selectionTextObfuscator: mockRandomTextObfuscator(), + sensitiveTextObfuscator: mockRandomTextObfuscator() ) } @@ -288,13 +299,17 @@ extension ViewTreeRecordingContext: AnyMockable, RandomMockable { recorder: Recorder.Context = .mockAny(), coordinateSpace: UICoordinateSpace = UIView.mockAny(), ids: NodeIDGenerator = NodeIDGenerator(), - textObfuscator: TextObfuscator = TextObfuscator() + textObfuscator: TextObfuscating = NOPTextObfuscator(), + selectionTextObfuscator: TextObfuscating = NOPTextObfuscator(), + sensitiveTextObfuscator: TextObfuscating = NOPTextObfuscator() ) -> ViewTreeRecordingContext { return .init( recorder: recorder, coordinateSpace: coordinateSpace, ids: ids, - textObfuscator: textObfuscator + textObfuscator: textObfuscator, + selectionTextObfuscator: selectionTextObfuscator, + sensitiveTextObfuscator: sensitiveTextObfuscator ) } } diff --git a/DatadogSessionReplay/Tests/Processor/Flattening/NodesFlattenerTests.swift b/DatadogSessionReplay/Tests/Processor/Flattening/NodesFlattenerTests.swift index 0113811a33..f247a1f86a 100644 --- a/DatadogSessionReplay/Tests/Processor/Flattening/NodesFlattenerTests.swift +++ b/DatadogSessionReplay/Tests/Processor/Flattening/NodesFlattenerTests.swift @@ -10,51 +10,21 @@ import Datadog @testable import TestUtilities class NodesFlattenerTests: XCTestCase { - /* - R - / \ - I1 V1 - */ - func testFlattenNodes_withInvisibleNode() { - // Given - let smallFrame: CGRect = .mockRandom(minWidth: 1, maxWidth: 10, minHeight: 1, maxHeight: 10) - let bigFrame: CGRect = .mockRandom(minWidth: 11, maxWidth: 100, minHeight: 11, maxHeight: 100) - let invisibleNode = Node.mockWith( - viewAttributes: .mock(fixture: .invisible) - ) - let visibleNode = Node.mockWith( - viewAttributes: .mock(fixture: .visible()), - semantics: SpecificElement.mock(wireframeRect: smallFrame) - ) - let rootNode = Node.mockWith( - viewAttributes: .mock(fixture: .visible()), - semantics: SpecificElement.mock(wireframeRect: bigFrame) - ) - let snapshot = ViewTreeSnapshot.mockWith(nodes: [rootNode, invisibleNode, visibleNode]) - let flattener = NodesFlattener() - - // When - let flattenedNodes = flattener.flattenNodes(in: snapshot) - - // Then - DDAssertReflectionEqual(flattenedNodes, [rootNode, visibleNode]) - } - /* V | V1 */ - func testFlattenNodes_withVisibleNodeThatCoversAnotherNode() { + func testFlattenNodes_withNodeThatCoversAnotherNode() { // Given let frame = CGRect.mockRandom(minWidth: 1, minHeight: 1) let coveringNode = Node.mockWith( viewAttributes: .mock(fixture: .opaque), - semantics: SpecificElement.mock(wireframeRect: frame) + wireframesBuilder: ShapeWireframesBuilderMock(wireframeRect: frame) ) let coveredNode = Node.mockWith( viewAttributes: .mockRandom(), - semantics: SpecificElement.mock(wireframeRect: frame) + wireframesBuilder: ShapeWireframesBuilderMock(wireframeRect: frame) ) let snapshot = ViewTreeSnapshot.mockWith(nodes: [coveredNode, coveringNode]) let flattener = NodesFlattener() @@ -67,42 +37,6 @@ class NodesFlattenerTests: XCTestCase { DDAssertReflectionEqual(flattenedNodes, [coveringNode]) } - /* - R - / \ - I1 V2 - / - V1 - */ - func testFlattenNodes_withMixedVisibleAndInvisibleNodes() { - // Given - let frame1: CGRect = .mockRandom(minWidth: 1, maxWidth: 10, minHeight: 1, maxHeight: 10) - let frame2: CGRect = .mockRandom(minWidth: 11, maxWidth: 100, minHeight: 11, maxHeight: 100) - let rootFrame: CGRect = .mockRandom(minWidth: 101, maxWidth: 1_000, minHeight: 101, maxHeight: 1_000) - let visibleNode1 = Node.mockWith( - viewAttributes: .mock(fixture: .visible()), - semantics: SpecificElement.mock(wireframeRect: frame1) - ) - let invisibleNode1 = Node.mockWith(viewAttributes: .mock(fixture: .invisible)) - let visibleNode2 = Node.mockWith( - viewAttributes: .mock(fixture: .visible()), - semantics: SpecificElement.mock(wireframeRect: frame2) - ) - let rootNode = Node.mockWith( - viewAttributes: .mock(fixture: .visible()), - semantics: SpecificElement.mock(wireframeRect: rootFrame) - ) - - let snapshot = ViewTreeSnapshot.mockWith(nodes: [rootNode, invisibleNode1, visibleNode1, visibleNode2]) - let flattener = NodesFlattener() - - // When - let flattenedNodes = flattener.flattenNodes(in: snapshot) - - // Then - DDAssertReflectionEqual(flattenedNodes, [rootNode, visibleNode1, visibleNode2]) - } - /* R / \ @@ -110,23 +44,26 @@ class NodesFlattenerTests: XCTestCase { | | CN CN */ - func testFlattenNodes_withMultipleVisibleNodesThatAreCoveredByAnotherNode() { + func testFlattenNodes_withMultipleNodesThatAreCoveredByAnotherNode() { // Given // set rects let frame = CGRect.mockRandom() let coveringNode = Node.mockWith( viewAttributes: .mock(fixture: .opaque), - semantics: SpecificElement.mock(wireframeRect: frame) + wireframesBuilder: ShapeWireframesBuilderMock(wireframeRect: frame) ) let coveredNode1 = Node.mockWith( viewAttributes: .mockRandom(), - semantics: SpecificElement.mock(wireframeRect: frame) + wireframesBuilder: ShapeWireframesBuilderMock(wireframeRect: frame) ) let coveredNode2 = Node.mockWith( viewAttributes: .mockRandom(), - semantics: SpecificElement.mock(wireframeRect: frame) + wireframesBuilder: ShapeWireframesBuilderMock(wireframeRect: frame) + ) + let rootNode = Node.mockWith( + viewAttributes: .mockRandom(), + wireframesBuilder: ShapeWireframesBuilderMock(wireframeRect: frame) ) - let rootNode = Node.mockAny() let snapshot = ViewTreeSnapshot.mockWith(nodes: [rootNode, coveredNode1, coveringNode, coveredNode2, coveringNode]) let flattener = NodesFlattener() @@ -136,32 +73,4 @@ class NodesFlattenerTests: XCTestCase { // Then DDAssertReflectionEqual(flattenedNodes, [coveringNode]) } - - /* - R - / \ - V1 V2 - */ - func testFlattenNodes_withNodesWithSameFrameAndDifferentAppearances() { - // Given - let smallFrame: CGRect = .mockRandom(minWidth: 1, maxWidth: 10, minHeight: 1, maxHeight: 10) - let bigFrame: CGRect = .mockRandom(minWidth: 11, maxWidth: 100, minHeight: 11, maxHeight: 100) - let visibleNode1 = Node.mockWith( - viewAttributes: .mock(fixture: .visible()), - semantics: SpecificElement.mock(wireframeRect: smallFrame) - ) - let visibleNode2 = Node.mockWith( - viewAttributes: .mock(fixture: .visible()), - semantics: SpecificElement.mock(wireframeRect: bigFrame) - ) - let rootNode = Node.mockAny() - let snapshot = ViewTreeSnapshot.mockWith(nodes: [rootNode, visibleNode1, visibleNode2]) - let flattener = NodesFlattener() - - // When - let flattenedNodes = flattener.flattenNodes(in: snapshot) - - // Then - DDAssertReflectionEqual(flattenedNodes, [visibleNode1, visibleNode2]) - } } diff --git a/DatadogSessionReplay/Tests/Processor/Privacy/TextObfuscatorTests.swift b/DatadogSessionReplay/Tests/Processor/Privacy/TextObfuscatorTests.swift index e7dbfda175..c512c6975f 100644 --- a/DatadogSessionReplay/Tests/Processor/Privacy/TextObfuscatorTests.swift +++ b/DatadogSessionReplay/Tests/Processor/Privacy/TextObfuscatorTests.swift @@ -49,3 +49,15 @@ class TestObfuscatorTests: XCTestCase { XCTAssertEqual(obfuscator.mask(text: "foo 🇮🇹 bar"), "xxx xx xxx") } } + +class InputTextObfuscatorTests: XCTestCase { + let obfuscator = SensitiveTextObfuscator() + + func testWhenObfuscatingItAlwaysReplacesTextItWithConstantMask() { + let expectedMask = "xxx" + + XCTAssertEqual(obfuscator.mask(text: .mockRandom(among: .alphanumericsAndWhitespace)), expectedMask) + XCTAssertEqual(obfuscator.mask(text: .mockRandom(among: .allUnicodes)), expectedMask) + XCTAssertEqual(obfuscator.mask(text: .mockRandom(among: .alphanumerics)), expectedMask) + } +} diff --git a/DatadogSessionReplay/Tests/Recorder/Utilties/ImageDataProviderTests.swift b/DatadogSessionReplay/Tests/Recorder/Utilties/ImageDataProviderTests.swift index 03dfe81c90..50ff5577f7 100644 --- a/DatadogSessionReplay/Tests/Recorder/Utilties/ImageDataProviderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/Utilties/ImageDataProviderTests.swift @@ -60,6 +60,24 @@ class ImageDataProviderTests: XCTestCase { let imageData = try XCTUnwrap(sut.contentBase64String(of: image)) XCTAssertEqual(imageData.count, 0) } + + func test_imageIdentifierConsistency() { + var ids = Set() + for _ in 0..<100 { + if let imageIdentifier = UIImage(named: "dd_logo_v_rgb", in: Bundle.module, compatibleWith: nil)?.srIdentifier { + ids.insert(imageIdentifier) + } + } + XCTAssertEqual(ids.count, 1) + } + + func test_colorIdentifierConsistency() { + var ids = Set() + for _ in 0..<100 { + ids.insert( UIColor.red.srIdentifier) + } + XCTAssertEqual(ids.count, 1) + } } #if XCODE_BUILD diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorderTests.swift new file mode 100644 index 0000000000..39460a8a98 --- /dev/null +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorderTests.swift @@ -0,0 +1,53 @@ +/* + * 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 XCTest +@testable import DatadogSessionReplay + +class UIDatePickerRecorderTests: XCTestCase { + private let recorder = UIDatePickerRecorder() + private let datePicker = UIDatePicker() + private var viewAttributes: ViewAttributes = .mockAny() + + func testWhenDatePickerIsNotVisible() throws { + // When + viewAttributes = .mock(fixture: .invisible) + + // Then + let semantics = try XCTUnwrap(recorder.semantics(of: datePicker, with: viewAttributes, in: .mockAny())) + XCTAssertTrue(semantics is InvisibleElement) + } + + func testWhenDatePickerIsVisibleAndHasSomeAppearance() throws { + // When + viewAttributes = .mock(fixture: .visible(.someAppearance)) + + // Then + let semantics = try XCTUnwrap(recorder.semantics(of: datePicker, with: viewAttributes, in: .mockAny())) + XCTAssertTrue(semantics is SpecificElement) + XCTAssertEqual(semantics.subtreeStrategy, .ignore) + XCTAssertTrue(semantics.nodes.first?.wireframesBuilder is UIDatePickerWireframesBuilder) + } + + func testWhenDatePickerIsVisibleAndHasNoAppearance() throws { + // When + viewAttributes = .mock(fixture: .visible(.noAppearance)) + + // Then + let semantics = try XCTUnwrap(recorder.semantics(of: datePicker, with: viewAttributes, in: .mockAny())) + XCTAssertTrue(semantics is SpecificElement) + XCTAssertEqual(semantics.subtreeStrategy, .ignore) + XCTAssertTrue(semantics.nodes.first?.wireframesBuilder is UIDatePickerWireframesBuilder) + } + + func testWhenViewIsNotOfExpectedType() { + // When + let view = UITextField() + + // Then + XCTAssertNil(recorder.semantics(of: view, with: viewAttributes, in: .mockAny())) + } +} diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift index 8468109589..82a0d7623c 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift @@ -24,21 +24,19 @@ class UIImageViewRecorderTests: XCTestCase { // Then let semantics = try XCTUnwrap(recorder.semantics(of: imageView, with: viewAttributes, in: .mockAny())) XCTAssertTrue(semantics is InvisibleElement) - DDAssertReflectionEqual(semantics.subtreeStrategy, .ignore, "Image view's subtree should not be recorded") + XCTAssertEqual(semantics.subtreeStrategy, .ignore) } - func testWhenImageViewHasNoImageAndSomeAppearance() throws { + func testWhenImageViewHasNoImageAndHasSomeAppearance() throws { // When imageView.image = nil - viewAttributes = .mock(fixture: .visible()) + viewAttributes = .mock(fixture: .visible(.someAppearance)) // Then let semantics = try XCTUnwrap(recorder.semantics(of: imageView, with: viewAttributes, in: .mockAny())) XCTAssertTrue(semantics is SpecificElement) - DDAssertReflectionEqual(semantics.subtreeStrategy, .record, "Image view's subtree should be recorded") - - let builder = try XCTUnwrap(semantics.wireframesBuilder as? UIImageViewWireframesBuilder) - XCTAssertEqual(builder.buildWireframes(with: WireframesBuilder()).count, 1) + XCTAssertEqual(semantics.subtreeStrategy, .record, "Image view's subtree should be recorded") + XCTAssertTrue(semantics.nodes.first?.wireframesBuilder is UIImageViewWireframesBuilder) } func testWhenImageViewHasImageAndSomeAppearance() throws { @@ -49,35 +47,8 @@ class UIImageViewRecorderTests: XCTestCase { // Then let semantics = try XCTUnwrap(recorder.semantics(of: imageView, with: viewAttributes, in: .mockAny())) XCTAssertTrue(semantics is SpecificElement) - DDAssertReflectionEqual(semantics.subtreeStrategy, .record, "Image view's subtree should be recorded") - - let builder = try XCTUnwrap(semantics.wireframesBuilder as? UIImageViewWireframesBuilder) - XCTAssertEqual(builder.buildWireframes(with: WireframesBuilder()).count, 2) - } - - func testWhenImageViewHasImageOrAppearance() throws { - // When - oneOf([ - { - self.imageView.image = UIImage() - self.viewAttributes = .mock(fixture: .visible()) - }, - { - self.imageView.image = nil - self.viewAttributes = .mock(fixture: .visible()) - }, - { - self.imageView.image = UIImage() - self.viewAttributes = .mock(fixture: .visible(.noAppearance)) - }, - ]) - - // Then - let semantics = try XCTUnwrap(recorder.semantics(of: imageView, with: viewAttributes, in: .mockAny()) as? SpecificElement) - - let builder = try XCTUnwrap(semantics.wireframesBuilder as? UIImageViewWireframesBuilder) - XCTAssertEqual(builder.attributes, viewAttributes) - XCTAssertEqual(builder.wireframeRect, viewAttributes.frame) + XCTAssertEqual(semantics.subtreeStrategy, .record, "Image view's subtree should be recorded") + XCTAssertTrue(semantics.nodes.first?.wireframesBuilder is UIImageViewWireframesBuilder) } func testWhenViewIsNotOfExpectedType() { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift index dbfeb6599f..515f530ec5 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift @@ -6,7 +6,6 @@ import XCTest @testable import DatadogSessionReplay -@testable import TestUtilities // swiftlint:disable opening_brace class UILabelRecorderTests: XCTestCase { @@ -27,7 +26,7 @@ class UILabelRecorderTests: XCTestCase { // Then let semantics = try XCTUnwrap(recorder.semantics(of: label, with: viewAttributes, in: .mockAny())) XCTAssertTrue(semantics is InvisibleElement) - XCTAssertNil(semantics.wireframesBuilder) + XCTAssertEqual(semantics.subtreeStrategy, .ignore) } func testWhenLabelHasTextOrAppearance() throws { @@ -49,9 +48,9 @@ class UILabelRecorderTests: XCTestCase { // Then let semantics = try XCTUnwrap(recorder.semantics(of: label, with: viewAttributes, in: .mockAny()) as? SpecificElement) - DDAssertReflectionEqual(semantics.subtreeStrategy, .ignore, "Label's subtree should not be recorded") + XCTAssertEqual(semantics.subtreeStrategy, .ignore, "Label's subtree should not be recorded") - let builder = try XCTUnwrap(semantics.wireframesBuilder as? UILabelWireframesBuilder) + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UILabelWireframesBuilder) XCTAssertEqual(builder.attributes, viewAttributes) XCTAssertEqual(builder.text, label.text ?? "") XCTAssertEqual(builder.textColor, label.textColor?.cgColor) @@ -63,14 +62,20 @@ class UILabelRecorderTests: XCTestCase { label.text = .mockRandom() // When - let semantics1 = try XCTUnwrap(recorder.semantics(of: label, with: viewAttributes, in: .mockWith(recorder: .mockWith(privacy: .maskAll)))) - let semantics2 = try XCTUnwrap(recorder.semantics(of: label, with: viewAttributes, in: .mockWith(recorder: .mockWith(privacy: .allowAll)))) + let context: ViewTreeRecordingContext = .mockWith( + recorder: .mockWith(privacy: .mockRandom()), + textObfuscator: TextObfuscatorMock(), + selectionTextObfuscator: mockRandomTextObfuscator(), + sensitiveTextObfuscator: mockRandomTextObfuscator() + ) + let semantics = try XCTUnwrap(recorder.semantics(of: label, with: viewAttributes, in: context)) // Then - let builder1 = try XCTUnwrap(semantics1.wireframesBuilder as? UILabelWireframesBuilder) - let builder2 = try XCTUnwrap(semantics2.wireframesBuilder as? UILabelWireframesBuilder) - XCTAssertTrue(builder1.textObfuscator is TextObfuscator, "With `.maskAll` privacy the text obfuscator should be used") - XCTAssertTrue(builder2.textObfuscator is NOPTextObfuscator, "With `.allowAll` privacy the text obfuscator should not be used") + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UILabelWireframesBuilder) + XCTAssertTrue( + (builder.textObfuscator as? TextObfuscatorMock) === (context.textObfuscator as? TextObfuscatorMock), + "Labels should use default text obfuscator specific to current privacy mode" + ) } func testWhenViewIsNotOfExpectedType() { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorderTests.swift index 489ab750a3..cbedfe912f 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorderTests.swift @@ -6,7 +6,6 @@ import XCTest @testable import DatadogSessionReplay -import TestUtilities class UINavigationBarRecorderTests: XCTestCase { private let recorder = UINavigationBarRecorder() @@ -20,7 +19,7 @@ class UINavigationBarRecorderTests: XCTestCase { let semantics = try XCTUnwrap(recorder.semantics(of: navigationBar, with: viewAttributes, in: .mockAny()) as? SpecificElement) // Then - DDAssertReflectionEqual(semantics.subtreeStrategy, .record, "Image view's subtree should be recorded") + XCTAssertEqual(semantics.subtreeStrategy, .record) } func testWhenViewIsNotOfExpectedType() { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorderTests.swift index 3a1ec6970b..7703ffb398 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorderTests.swift @@ -19,35 +19,28 @@ class UIPickerViewRecorderTests: XCTestCase { // Then let semantics = try XCTUnwrap(recorder.semantics(of: picker, with: viewAttributes, in: .mockAny())) XCTAssertTrue(semantics is InvisibleElement) - XCTAssertNil(semantics.wireframesBuilder) } - func testWhenPickerIsVisibleButHasNoAppearance() throws { + func testWhenPickerIsVisibleAndHasSomeAppearance() throws { // When - viewAttributes = .mock(fixture: .visible(.noAppearance)) + viewAttributes = .mock(fixture: .visible(.someAppearance)) // Then - let semantics = try XCTUnwrap(recorder.semantics(of: picker, with: viewAttributes, in: .mockAny()) as? InvisibleElement) - XCTAssertNil(semantics.wireframesBuilder) - guard case .replace(let nodes) = semantics.subtreeStrategy else { - XCTFail("Expected `.replace()` subtreeStrategy, got \(semantics.subtreeStrategy)") - return - } - XCTAssertFalse(nodes.isEmpty) + let semantics = try XCTUnwrap(recorder.semantics(of: picker, with: viewAttributes, in: .mockAny())) + XCTAssertTrue(semantics is SpecificElement) + XCTAssertEqual(semantics.subtreeStrategy, .ignore) + XCTAssertTrue(semantics.nodes.first?.wireframesBuilder is UIPickerViewWireframesBuilder) } - func testWhenPickerIsVisibleAndHasSomeAppearance() throws { + func testWhenPickerIsVisibleAndHasNoAppearance() throws { // When - viewAttributes = .mock(fixture: .visible(.someAppearance)) + viewAttributes = .mock(fixture: .visible(.noAppearance)) // Then - let semantics = try XCTUnwrap(recorder.semantics(of: picker, with: viewAttributes, in: .mockAny()) as? SpecificElement) - XCTAssertNotNil(semantics.wireframesBuilder) - guard case .replace(let nodes) = semantics.subtreeStrategy else { - XCTFail("Expected `.replace()` subtreeStrategy, got \(semantics.subtreeStrategy)") - return - } - XCTAssertFalse(nodes.isEmpty) + let semantics = try XCTUnwrap(recorder.semantics(of: picker, with: viewAttributes, in: .mockAny())) + XCTAssertTrue(semantics is SpecificElement) + XCTAssertEqual(semantics.subtreeStrategy, .ignore) + XCTAssertFalse(semantics.nodes.first?.wireframesBuilder is UIPickerViewWireframesBuilder) } func testWhenViewIsNotOfExpectedType() { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift index bca382aa1e..ac7cb0c491 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift @@ -5,7 +5,6 @@ */ import XCTest -@testable import TestUtilities @testable import DatadogSessionReplay class UISegmentRecorderTests: XCTestCase { @@ -20,7 +19,6 @@ class UISegmentRecorderTests: XCTestCase { // Then let semantics = try XCTUnwrap(recorder.semantics(of: segment, with: viewAttributes, in: .mockAny())) XCTAssertTrue(semantics is InvisibleElement) - XCTAssertNil(semantics.wireframesBuilder) } func testWhenSegmentIsVisible() throws { @@ -31,15 +29,15 @@ class UISegmentRecorderTests: XCTestCase { } // When - viewAttributes = .mock(fixture: .visible()) + viewAttributes = .mock(fixture: .visible(.someAppearance)) // Then - let semantics = try XCTUnwrap(recorder.semantics(of: segment, with: viewAttributes, in: .mockAny()) as? SpecificElement) - DDAssertReflectionEqual(semantics.subtreeStrategy, .ignore, "Segment's subtree should not be recorded") + let semantics = try XCTUnwrap(recorder.semantics(of: segment, with: viewAttributes, in: .mockAny())) + XCTAssertTrue(semantics is SpecificElement) + XCTAssertEqual(semantics.subtreeStrategy, .ignore) - let builder = try XCTUnwrap(semantics.wireframesBuilder as? UISegmentWireframesBuilder) + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UISegmentWireframesBuilder) XCTAssertEqual(builder.attributes, viewAttributes) - XCTAssertEqual(builder.segmentTitles, ["first", "second", "third"]) XCTAssertEqual(builder.selectedSegmentIndex, 2) if #available(iOS 13.0, *) { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISliderRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISliderRecorderTests.swift index 100f499450..32c004ae96 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISliderRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISliderRecorderTests.swift @@ -5,7 +5,6 @@ */ import XCTest -@testable import TestUtilities @testable import DatadogSessionReplay class UISliderRecorderTests: XCTestCase { @@ -20,7 +19,6 @@ class UISliderRecorderTests: XCTestCase { // Then let semantics = try XCTUnwrap(recorder.semantics(of: slider, with: viewAttributes, in: .mockAny())) XCTAssertTrue(semantics is InvisibleElement) - XCTAssertNil(semantics.wireframesBuilder) } func testWhenSliderIsVisible() throws { @@ -35,9 +33,9 @@ class UISliderRecorderTests: XCTestCase { // Then let semantics = try XCTUnwrap(recorder.semantics(of: slider, with: viewAttributes, in: .mockAny()) as? SpecificElement) - DDAssertReflectionEqual(semantics.subtreeStrategy, .ignore, "Slider's subtree should not be recorded") + XCTAssertEqual(semantics.subtreeStrategy, .ignore, "Slider's subtree should not be recorded") - let builder = try XCTUnwrap(semantics.wireframesBuilder as? UISliderWireframesBuilder) + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UISliderWireframesBuilder) XCTAssertEqual(builder.attributes, viewAttributes) XCTAssertEqual(builder.isEnabled, slider.isEnabled) XCTAssertEqual(builder.thumbTintColor, slider.thumbTintColor?.cgColor) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIStepperRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIStepperRecorderTests.swift new file mode 100644 index 0000000000..009d5ede4a --- /dev/null +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIStepperRecorderTests.swift @@ -0,0 +1,47 @@ +/* + * 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 XCTest +@testable import DatadogSessionReplay + +class UIStepperRecorderTests: XCTestCase { + private let recorder = UIStepperRecorder() + private let stepper = UIStepper() + /// `ViewAttributes` simulating common attributes of switch's `UIView`. + private var viewAttributes: ViewAttributes = .mockAny() + + func testWhenStepperIsNotVisible() throws { + // When + viewAttributes = .mock(fixture: .invisible) + + // Then + let semantics = try XCTUnwrap(recorder.semantics(of: stepper, with: viewAttributes, in: .mockAny())) + XCTAssertTrue(semantics is InvisibleElement) + } + + func testWhenStepperIsVisible() throws { + // Given + stepper.tintColor = .mockRandom() + + // When + viewAttributes = .mock(fixture: .visible()) + + // Then + let semantics = try XCTUnwrap(recorder.semantics(of: stepper, with: viewAttributes, in: .mockAny())) + XCTAssertTrue(semantics is SpecificElement) + XCTAssertEqual(semantics.subtreeStrategy, .ignore, "Stepper's subtree should not be recorded") + + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UIStepperWireframesBuilder) + } + + func testWhenViewIsNotOfExpectedType() { + // When + let view = UITextField() + + // Then + XCTAssertNil(recorder.semantics(of: view, with: viewAttributes, in: .mockAny())) + } +} diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorderTests.swift index 5c407c7d4f..7067c8381f 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorderTests.swift @@ -5,7 +5,6 @@ */ import XCTest -@testable import TestUtilities @testable import DatadogSessionReplay class UISwitchRecorderTests: XCTestCase { @@ -22,7 +21,6 @@ class UISwitchRecorderTests: XCTestCase { // Then let semantics = try XCTUnwrap(recorder.semantics(of: `switch`, with: viewAttributes, in: .mockAny())) XCTAssertTrue(semantics is InvisibleElement) - XCTAssertNil(semantics.wireframesBuilder) } func testWhenSwitchIsVisible() throws { @@ -36,10 +34,11 @@ class UISwitchRecorderTests: XCTestCase { viewAttributes = .mock(fixture: .visible()) // Then - let semantics = try XCTUnwrap(recorder.semantics(of: `switch`, with: viewAttributes, in: .mockAny()) as? SpecificElement) - DDAssertReflectionEqual(semantics.subtreeStrategy, .ignore, "Switch's subtree should not be recorded") + let semantics = try XCTUnwrap(recorder.semantics(of: `switch`, with: viewAttributes, in: .mockAny())) + XCTAssertTrue(semantics is SpecificElement) + XCTAssertEqual(semantics.subtreeStrategy, .ignore, "Switch's subtree should not be recorded") - let builder = try XCTUnwrap(semantics.wireframesBuilder as? UISwitchWireframesBuilder) + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UISwitchWireframesBuilder) XCTAssertEqual(builder.attributes, viewAttributes) XCTAssertEqual(builder.isOn, `switch`.isOn) XCTAssertEqual(builder.thumbTintColor, `switch`.thumbTintColor?.cgColor) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorderTests.swift index d2da7de9ec..b411cc6520 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorderTests.swift @@ -6,21 +6,20 @@ import XCTest @testable import DatadogSessionReplay -import TestUtilities class UITabBarRecorderTests: XCTestCase { private let recorder = UITabBarRecorder() func testWhenViewIsOfExpectedType() throws { - // Given + // When let tabBar = UITabBar.mock(withFixture: .allCases.randomElement()!) let viewAttributes = ViewAttributes(frameInRootView: tabBar.frame, view: tabBar) - // When - let semantics = try XCTUnwrap(recorder.semantics(of: tabBar, with: viewAttributes, in: .mockAny()) as? SpecificElement) - // Then - DDAssertReflectionEqual(semantics.subtreeStrategy, .record, "TabBar's subtree should not be recorded") + let semantics = try XCTUnwrap(recorder.semantics(of: tabBar, with: viewAttributes, in: .mockAny())) + XCTAssertTrue(semantics is SpecificElement) + XCTAssertEqual(semantics.subtreeStrategy, .record) + XCTAssertTrue(semantics.nodes.first?.wireframesBuilder is UITabBarWireframesBuilder) } func testWhenViewIsNotOfExpectedType() { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift index 365e2dbfdd..c18746a1ee 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift @@ -6,7 +6,6 @@ import XCTest @testable import DatadogSessionReplay -@testable import TestUtilities // swiftlint:disable opening_brace class UITextFieldRecorderTests: XCTestCase { @@ -23,7 +22,6 @@ class UITextFieldRecorderTests: XCTestCase { // Then let semantics = try XCTUnwrap(recorder.semantics(of: textField, with: viewAttributes, in: .mockAny())) XCTAssertTrue(semantics is InvisibleElement) - XCTAssertNil(semantics.wireframesBuilder) } func testWhenTextFieldHasText() throws { @@ -47,34 +45,61 @@ class UITextFieldRecorderTests: XCTestCase { self.textField.placeholder = .mockRandom() }, ]) - viewAttributes = .mock(fixture: .visible()) - textField.layoutSubviews() // force layout (so TF creates its sub-tree) + viewAttributes = .mock(fixture: .visible(.someAppearance)) // Then - let semantics = try XCTUnwrap(recorder.semantics(of: textField, with: viewAttributes, in: .mockAny()) as? SpecificElement) - DDAssertReflectionEqual(semantics.subtreeStrategy, .ignore, "TextField's subtree should not be recorded") + let semantics = try XCTUnwrap(recorder.semantics(of: textField, with: viewAttributes, in: .mockAny())) + XCTAssertTrue(semantics is SpecificElement) + XCTAssertEqual(semantics.subtreeStrategy, .ignore) - let builder = try XCTUnwrap(semantics.wireframesBuilder as? UITextFieldWireframesBuilder) + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UITextFieldWireframesBuilder) XCTAssertEqual(builder.text, randomText) XCTAssertEqual(builder.textColor, textField.textColor?.cgColor) XCTAssertEqual(builder.font, textField.font) - XCTAssertNotNil(builder.editor) } func testWhenRecordingInDifferentPrivacyModes() throws { // Given - textField.text = .mockRandom() + let textField1 = UITextField(frame: .mockAny()) + let textField2 = UITextField(frame: .mockAny()) + let textField3 = UITextField(frame: .mockAny()) + textField1.text = .mockRandom() + textField2.text = .mockRandom() + textField3.text = .mockRandom() + + textField2.isSecureTextEntry = true + textField3.textContentType = [.telephoneNumber, .emailAddress].randomElement()! // When viewAttributes = .mock(fixture: .visible()) - let semantics1 = try XCTUnwrap(recorder.semantics(of: textField, with: viewAttributes, in: .mockWith(recorder: .mockWith(privacy: .maskAll)))) - let semantics2 = try XCTUnwrap(recorder.semantics(of: textField, with: viewAttributes, in: .mockWith(recorder: .mockWith(privacy: .allowAll)))) + let context: ViewTreeRecordingContext = .mockWith( + recorder: .mockWith(privacy: .mockRandom()), + textObfuscator: TextObfuscatorMock(), + selectionTextObfuscator: mockRandomTextObfuscator(), + sensitiveTextObfuscator: TextObfuscatorMock() + ) + + let semantics1 = try XCTUnwrap(recorder.semantics(of: textField1, with: viewAttributes, in: context)) + let semantics2 = try XCTUnwrap(recorder.semantics(of: textField2, with: viewAttributes, in: context)) + let semantics3 = try XCTUnwrap(recorder.semantics(of: textField3, with: viewAttributes, in: context)) // Then - let builder1 = try XCTUnwrap(semantics1.wireframesBuilder as? UITextFieldWireframesBuilder) - let builder2 = try XCTUnwrap(semantics2.wireframesBuilder as? UITextFieldWireframesBuilder) - XCTAssertTrue(builder1.textObfuscator is TextObfuscator, "With `.maskAll` privacy the text obfuscator should be used") - XCTAssertTrue(builder2.textObfuscator is NOPTextObfuscator, "With `.allowAll` privacy the text obfuscator should not be used") + let builder1 = try XCTUnwrap(semantics1.nodes.first?.wireframesBuilder as? UITextFieldWireframesBuilder) + let builder2 = try XCTUnwrap(semantics2.nodes.first?.wireframesBuilder as? UITextFieldWireframesBuilder) + let builder3 = try XCTUnwrap(semantics3.nodes.first?.wireframesBuilder as? UITextFieldWireframesBuilder) + + XCTAssertTrue( + (builder1.textObfuscator as? TextObfuscatorMock) === (context.textObfuscator as? TextObfuscatorMock), + "Non-sensitive text fields should use default text obfuscator specific to current privacy mode" + ) + XCTAssertTrue( + (builder2.textObfuscator as? TextObfuscatorMock) === (context.sensitiveTextObfuscator as? TextObfuscatorMock), + "Sensitive text fields should use sensitive text obfuscator no matter of privacy mode" + ) + XCTAssertTrue( + (builder3.textObfuscator as? TextObfuscatorMock) === (context.sensitiveTextObfuscator as? TextObfuscatorMock), + "Sensitive text fields should use sensitive text obfuscator no matter of privacy mode" + ) } func testWhenViewIsNotOfExpectedType() { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift index fcf575e8eb..781ecdffdd 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift @@ -23,7 +23,6 @@ class UITextViewRecorderTests: XCTestCase { // Then let semantics = try XCTUnwrap(recorder.semantics(of: textView, with: viewAttributes, in: .mockAny())) XCTAssertTrue(semantics is InvisibleElement) - XCTAssertNil(semantics.wireframesBuilder) } func testWhenTextViewHasText() throws { @@ -39,10 +38,11 @@ class UITextViewRecorderTests: XCTestCase { viewAttributes = .mock(fixture: .visible()) // Then - let semantics = try XCTUnwrap(recorder.semantics(of: textView, with: viewAttributes, in: .mockAny()) as? SpecificElement) - DDAssertReflectionEqual(semantics.subtreeStrategy, .record, "TextView's subtree should not be recorded") + let semantics = try XCTUnwrap(recorder.semantics(of: textView, with: viewAttributes, in: .mockAny())) + XCTAssertTrue(semantics is SpecificElement) + XCTAssertEqual(semantics.subtreeStrategy, .record) - let builder = try XCTUnwrap(semantics.wireframesBuilder as? UITextViewWireframesBuilder) + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UITextViewWireframesBuilder) XCTAssertEqual(builder.text, randomText) XCTAssertEqual(builder.textColor, textView.textColor?.cgColor) XCTAssertEqual(builder.font, textView.font) @@ -54,14 +54,20 @@ class UITextViewRecorderTests: XCTestCase { // When viewAttributes = .mock(fixture: .visible()) - let semantics1 = try XCTUnwrap(recorder.semantics(of: textView, with: viewAttributes, in: .mockWith(recorder: .mockWith(privacy: .maskAll)))) - let semantics2 = try XCTUnwrap(recorder.semantics(of: textView, with: viewAttributes, in: .mockWith(recorder: .mockWith(privacy: .allowAll)))) + let context: ViewTreeRecordingContext = .mockWith( + recorder: .mockWith(privacy: .mockRandom()), + textObfuscator: TextObfuscatorMock(), + selectionTextObfuscator: mockRandomTextObfuscator(), + sensitiveTextObfuscator: mockRandomTextObfuscator() + ) + let semantics = try XCTUnwrap(recorder.semantics(of: textView, with: viewAttributes, in: context)) // Then - let builder1 = try XCTUnwrap(semantics1.wireframesBuilder as? UITextViewWireframesBuilder) - let builder2 = try XCTUnwrap(semantics2.wireframesBuilder as? UITextViewWireframesBuilder) - XCTAssertTrue(builder1.textObfuscator is TextObfuscator, "With `.maskAll` privacy the text obfuscator should be used") - XCTAssertTrue(builder2.textObfuscator is NOPTextObfuscator, "With `.allowAll` privacy the text obfuscator should not be used") + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UITextViewWireframesBuilder) + XCTAssertTrue( + (builder.textObfuscator as? TextObfuscatorMock) === (context.textObfuscator as? TextObfuscatorMock), + "Text views should use default text obfuscator specific to current privacy mode" + ) } func testWhenViewIsNotOfExpectedType() { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift index 3d2406d61d..0c1d35d19c 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift @@ -5,7 +5,6 @@ */ import XCTest -import TestUtilities @testable import DatadogSessionReplay class UIViewRecorderTests: XCTestCase { @@ -22,19 +21,16 @@ class UIViewRecorderTests: XCTestCase { // Then let semantics = try XCTUnwrap(recorder.semantics(of: view, with: viewAttributes, in: .mockAny())) XCTAssertTrue(semantics is InvisibleElement) - XCTAssertNil(semantics.wireframesBuilder) } - func testWhenViewIsVisible() throws { + func testWhenViewIsVisibleAndHasSomeAppearance() throws { // When - viewAttributes = .mock(fixture: .visible()) + viewAttributes = .mock(fixture: .visible(.someAppearance)) // Then let semantics = try XCTUnwrap(recorder.semantics(of: view, with: viewAttributes, in: .mockAny())) XCTAssertTrue(semantics is AmbiguousElement) - - let builder = try XCTUnwrap(semantics.wireframesBuilder as? UIViewWireframesBuilder) - XCTAssertEqual(builder.attributes, viewAttributes) + XCTAssertTrue(semantics.nodes.first?.wireframesBuilder is UIViewWireframesBuilder) } func testWhenViewIsVisibleButHasNoAppearance() throws { @@ -44,7 +40,6 @@ class UIViewRecorderTests: XCTestCase { // Then let semantics = try XCTUnwrap(recorder.semantics(of: view, with: viewAttributes, in: .mockAny())) XCTAssertTrue(semantics is InvisibleElement) - XCTAssertNil(semantics.wireframesBuilder) - DDAssertReflectionEqual(semantics.subtreeStrategy, .record) + XCTAssertEqual(semantics.subtreeStrategy, .record) } } diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift index c2a861d65d..83f3d8cc81 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift @@ -11,8 +11,20 @@ import XCTest private struct MockSemantics: NodeSemantics { static var importance: Int = .mockAny() var subtreeStrategy: NodeSubtreeStrategy - var wireframesBuilder: NodeWireframesBuilder? = nil - let debugName: String + var nodes: [Node] + + init(subtreeStrategy: NodeSubtreeStrategy, nodeNames: [String]) { + self.subtreeStrategy = subtreeStrategy + self.nodes = nodeNames.map { + Node(viewAttributes: .mockAny(), wireframesBuilder: MockWireframesBuilder(nodeName: $0)) + } + } +} + +private struct MockWireframesBuilder: NodeWireframesBuilder { + let nodeName: String + var wireframeRect: CGRect = .mockAny() + func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] { [] } } class ViewTreeRecorderTests: XCTestCase { @@ -46,8 +58,8 @@ class ViewTreeRecorderTests: XCTestCase { let view = UIView(frame: .mockRandom()) let unknownElement = UnknownElement.constant - let ambiguousElement = AmbiguousElement(wireframesBuilder: NOPWireframesBuilderMock()) - let specificElement = SpecificElement(wireframesBuilder: NOPWireframesBuilderMock(), subtreeStrategy: .mockRandom()) + let ambiguousElement = AmbiguousElement(nodes: .mockAny()) + let specificElement = SpecificElement(subtreeStrategy: .mockRandom(), nodes: .mockAny()) // When let recorders: [NodeRecorderMock] = [ @@ -101,26 +113,16 @@ class ViewTreeRecorderTests: XCTestCase { c.addSubview(cb) let semanticsByView: [UIView: NodeSemantics] = [ - rootView: MockSemantics(subtreeStrategy: .record, debugName: "rootView"), - a: MockSemantics(subtreeStrategy: .record, debugName: "a"), - b: MockSemantics(subtreeStrategy: .record, debugName: "b"), - c: MockSemantics(subtreeStrategy: .ignore, debugName: "c"), // The subtree of `c` should be ignored - aa: MockSemantics( - // The subtree of `aa` (`aaa`) should be replaced with 3 virtual nodes: - subtreeStrategy: .replace( - subtreeNodes: [ - .mockWith(semantics: MockSemantics(subtreeStrategy: .record, debugName: "aav1")), - .mockWith(semantics: MockSemantics(subtreeStrategy: .record, debugName: "aav2")), - .mockWith(semantics: MockSemantics(subtreeStrategy: .record, debugName: "aav3")), - ] - ), - debugName: "aa" - ), - ab: MockSemantics(subtreeStrategy: .record, debugName: "ab"), - aba: MockSemantics(subtreeStrategy: .record, debugName: "aba"), - abb: MockSemantics(subtreeStrategy: .record, debugName: "abb"), - ca: MockSemantics(subtreeStrategy: .record, debugName: "ca"), - cb: MockSemantics(subtreeStrategy: .record, debugName: "cb"), + rootView: MockSemantics(subtreeStrategy: .record, nodeNames: ["rootView"]), + a: MockSemantics(subtreeStrategy: .record, nodeNames: ["a"]), + b: MockSemantics(subtreeStrategy: .record, nodeNames: ["b"]), + c: MockSemantics(subtreeStrategy: .ignore, nodeNames: ["c"]), // ignore subtree of `c` + aa: MockSemantics(subtreeStrategy: .ignore, nodeNames: ["aa", "aav1", "aav2", "aav3"]), // replace `aaa` (subtree of `aa`) with 3 nodes + ab: MockSemantics(subtreeStrategy: .record, nodeNames: ["ab"]), + aba: MockSemantics(subtreeStrategy: .record, nodeNames: ["aba"]), + abb: MockSemantics(subtreeStrategy: .record, nodeNames: ["abb"]), + ca: MockSemantics(subtreeStrategy: .record, nodeNames: ["ca"]), + cb: MockSemantics(subtreeStrategy: .record, nodeNames: ["cb"]), ] // When @@ -130,7 +132,7 @@ class ViewTreeRecorderTests: XCTestCase { // Then let expectedNodes = ["rootView", "a", "aa", "aav1", "aav2", "aav3", "ab", "aba", "abb", "b", "c"] - let actualNodes = nodes.compactMap { ($0.semantics as? MockSemantics)?.debugName } + let actualNodes = nodes.compactMap { ($0.wireframesBuilder as? MockWireframesBuilder)?.nodeName } XCTAssertEqual(expectedNodes, actualNodes, "Nodes must be recorded in DFS order") let expectedQueriedViews: [UIView] = [rootView, a, b, c, aa, ab, aba, abb] @@ -143,33 +145,9 @@ class ViewTreeRecorderTests: XCTestCase { // MARK: - Recording Certain Node Semantics - func testWhenChildNodeSemanticsIsFound_itCanBeOverwrittenByParent() { - // Given - let view = UIView.mockAny() - let semantics = MockSemantics(subtreeStrategy: .record, debugName: "original") - let recorder = ViewTreeRecorder( - nodeRecorders: [ - NodeRecorderMock(resultForView: { _ in semantics }) - ] - ) - - // When - var context: ViewTreeRecordingContext = .mockRandom() - context.semanticsOverride = { currentSemantis, currentView, viewAttributes in - XCTAssertEqual((currentSemantis as? MockSemantics)?.debugName, "original") - XCTAssertTrue(currentView === view) - return MockSemantics(subtreeStrategy: .record, debugName: "overwritten") - } - let nodes = recorder.recordNodes(for: view, in: context) - - // Then - XCTAssertEqual(nodes.count, 1) - XCTAssertEqual((nodes[0].semantics as? MockSemantics)?.debugName, "overwritten") - } - func testItRecordsInvisibleViews() { // Given - let recorder = ViewTreeRecorder(nodeRecorders: defaultNodeRecorders) + let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) let views: [UIView] = [ UIView.mock(withFixture: .invisible), UILabel.mock(withFixture: .invisible), @@ -178,24 +156,18 @@ class ViewTreeRecorderTests: XCTestCase { UISwitch.mock(withFixture: .invisible), ] - // When - let nodes = views.map { recorder.recordNodes(for: $0, in: .mockRandom()) } + views.forEach { view in + // When + let nodes = recorder.recordNodes(for: view, in: .mockRandom()) - // Then - zip(nodes, views).forEach { nodes, view in - XCTAssertTrue( - nodes[0].semantics is InvisibleElement, - """ - All invisible members of `UIView` should record `InvisibleElement` semantics as - they will not appear in SR anyway. Got \(type(of: nodes[0].semantics)) instead. - """ - ) + // Then + XCTAssertTrue(nodes.isEmpty, "No nodes should be recorded for \(type(of: view)) when it is not visible") } } func testItRecordsViewsWithNoAppearance() { // Given - let recorder = ViewTreeRecorder(nodeRecorders: defaultNodeRecorders) + let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) let view = UIView.mock(withFixture: .visible(.noAppearance)) let label = UILabel.mock(withFixture: .visible(.noAppearance)) @@ -205,108 +177,43 @@ class ViewTreeRecorderTests: XCTestCase { // When let viewNodes = recorder.recordNodes(for: view, in: .mockRandom()) - XCTAssertEqual(viewNodes.count, 1) - XCTAssertTrue( - viewNodes[0].semantics is InvisibleElement, - """ - Bare `UIView` with no appearance should record `InvisibleElement` semantics as we don't know - if this view is specialised with appearance coming from its superclass. - Got \(type(of: viewNodes[0].semantics)) instead. - """ - ) - DDAssertReflectionEqual( - viewNodes[0].semantics.subtreeStrategy, - .record, - """ - For bare `UIView` with no appearance it should still record its sub-tree hierarchy as it might - contain other visible elements. - """ - ) + XCTAssertTrue(viewNodes.isEmpty, "No nodes should be recorded for `UIView` when it has no appearance") let labelNodes = recorder.recordNodes(for: label, in: .mockRandom()) - XCTAssertEqual(labelNodes.count, 1) - XCTAssertTrue( - labelNodes[0].semantics is InvisibleElement, - """ - `UILabel` with no appearance should record `InvisibleElement` semantics as it - won't display anything in SR. Got \(type(of: labelNodes[0].semantics)) instead. - """ - ) + XCTAssertTrue(labelNodes.isEmpty, "No nodes should be recorded for `UILabel` when it has no appearance") let imageViewNodes = recorder.recordNodes(for: imageView, in: .mockRandom()) - XCTAssertEqual(imageViewNodes.count, 1) - XCTAssertTrue( - imageViewNodes[0].semantics is InvisibleElement, - """ - `UIImageView` with no appearance should record `InvisibleElement` semantics as it - won't display anything in SR. Got \(type(of: imageViewNodes[0].semantics)) instead. - """ - ) + XCTAssertTrue(imageViewNodes.isEmpty, "No nodes should be recorded for `UIImageView` when it has no appearance") let textFieldNodes = recorder.recordNodes(for: textField, in: .mockRandom()) - XCTAssertEqual(textFieldNodes.count, 1) - XCTAssertTrue( - textFieldNodes[0].semantics is SpecificElement, - """ - `UITextField` with no appearance should still record `SpecificElement` semantics as it - has style coming from its internal subtree. Got \(type(of: textFieldNodes[0].semantics)) instead. - """ - ) + XCTAssertTrue(textFieldNodes.isEmpty, "No nodes should be recorded for `UITextField` when it has no appearance") let switchNodes = recorder.recordNodes(for: `switch`, in: .mockRandom()) - XCTAssertEqual(switchNodes.count, 1) - XCTAssertTrue( - switchNodes[0].semantics is SpecificElement, - """ - `UISwitch` with no appearance should still record `SpecificElement` semantics as it - has style coming from its internal subtree. Got \(type(of: switchNodes[0].semantics)) instead. - """ + XCTAssertFalse( + switchNodes.isEmpty, + "`UISwitch` with no appearance should record some nodes as it has style coming from its internal subtree." ) } - func testItRecordsBaseViewWithSomeAppearance() { + func testItRecordsViewsWithSomeAppearance() { // Given - let recorder = ViewTreeRecorder(nodeRecorders: defaultNodeRecorders) - let view = UIView.mock(withFixture: .visible()) - - // When - let nodes = recorder.recordNodes(for: view, in: .mockRandom()) - - // Then - XCTAssertTrue( - nodes[0].semantics is AmbiguousElement, - """ - Bare `UIView` with no appearance should record `AmbiguousElement` semantics as we don't know - if this view is specialised with appearance coming from its superclass. - Got \(type(of: nodes[0].semantics)) instead. - """ - ) - } - - func testItRecordsSpecialisedViewsWithSomeAppearance() { - // Given - let recorder = ViewTreeRecorder(nodeRecorders: defaultNodeRecorders) + let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) let views: [UIView] = [ - UILabel.mock(withFixture: .visible()), - UIImageView.mock(withFixture: .visible()), - UITextField.mock(withFixture: .visible()), - UISwitch.mock(withFixture: .visible()), - UITabBar.mock(withFixture: .visible()), - UINavigationBar.mock(withFixture: .visible()), + UIView.mock(withFixture: .visible(.someAppearance)), + UILabel.mock(withFixture: .visible(.someAppearance)), + UIImageView.mock(withFixture: .visible(.someAppearance)), + UITextField.mock(withFixture: .visible(.someAppearance)), + UISwitch.mock(withFixture: .visible(.someAppearance)), + UITabBar.mock(withFixture: .visible(.someAppearance)), + UINavigationBar.mock(withFixture: .visible(.someAppearance)), ] - // When - let nodes = views.map { recorder.recordNodes(for: $0, in: .mockRandom()) } + views.forEach { view in + // When + let nodes = recorder.recordNodes(for: view, in: .mockRandom()) - // Then - zip(nodes, views).forEach { nodes, view in - XCTAssertTrue( - nodes[0].semantics is SpecificElement, - """ - All specialised subclasses of `UIView` should record `SpecificElement` semantics as - long as they are visible. Got \(type(of: nodes[0].semantics)) instead. - """ - ) + // Then + XCTAssertFalse(nodes.isEmpty, "Some nodes should be recorded for \(type(of: view)) when it has some appearance") } } } diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilderTests.swift index 989fe9ab02..176c544e5b 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilderTests.swift @@ -12,20 +12,17 @@ class ViewTreeSnapshotBuilderTests: XCTestCase { func testWhenQueryingNodeRecorders_itPassesAppropriateContext() throws { // Given let view = UIView(frame: .mockRandom()) - - let randomRecorderContext: Recorder.Context = .mockWith() + let randomRecorderContext: Recorder.Context = .mockRandom() let nodeRecorder = NodeRecorderMock(resultForView: { _ in nil }) let builder = ViewTreeSnapshotBuilder( viewTreeRecorder: ViewTreeRecorder(nodeRecorders: [nodeRecorder]), - idsGenerator: NodeIDGenerator(), - textObfuscator: TextObfuscator() + idsGenerator: NodeIDGenerator() ) // When let snapshot = builder.createSnapshot(of: view, with: randomRecorderContext) // Then - XCTAssertEqual(snapshot.date, randomRecorderContext.date) XCTAssertEqual(snapshot.rumContext, randomRecorderContext.rumContext) let queryContext = try XCTUnwrap(nodeRecorder.queryContexts.first) @@ -33,6 +30,32 @@ class ViewTreeSnapshotBuilderTests: XCTestCase { XCTAssertEqual(queryContext.recorder, randomRecorderContext) } + func testItConfiguresTextObfuscatorsAccordinglyToCurrentPrivacyMode() throws { + // Given + let view = UIView(frame: .mockRandom()) + let nodeRecorder = NodeRecorderMock(resultForView: { _ in nil }) + let builder = ViewTreeSnapshotBuilder( + viewTreeRecorder: ViewTreeRecorder(nodeRecorders: [nodeRecorder]), + idsGenerator: NodeIDGenerator() + ) + + // When + _ = builder.createSnapshot(of: view, with: .mockWith(privacy: .allowAll)) + _ = builder.createSnapshot(of: view, with: .mockWith(privacy: .maskAll)) + + // Then + let queriedContexts = nodeRecorder.queryContexts + XCTAssertEqual(queriedContexts.count, 2) + + XCTAssertTrue(queriedContexts[0].textObfuscator is NOPTextObfuscator) + XCTAssertTrue(queriedContexts[0].selectionTextObfuscator is NOPTextObfuscator) + XCTAssertTrue(queriedContexts[0].sensitiveTextObfuscator is SensitiveTextObfuscator) + + XCTAssertTrue(queriedContexts[1].textObfuscator is TextObfuscator) + XCTAssertTrue(queriedContexts[1].selectionTextObfuscator is SensitiveTextObfuscator) + XCTAssertTrue(queriedContexts[1].sensitiveTextObfuscator is SensitiveTextObfuscator) + } + func testItAppliesServerTimeOffsetToSnapshot() { // Given let now = Date() @@ -40,8 +63,7 @@ class ViewTreeSnapshotBuilderTests: XCTestCase { let nodeRecorder = NodeRecorderMock(resultForView: { _ in nil }) let builder = ViewTreeSnapshotBuilder( viewTreeRecorder: ViewTreeRecorder(nodeRecorders: [nodeRecorder]), - idsGenerator: NodeIDGenerator(), - textObfuscator: TextObfuscator() + idsGenerator: NodeIDGenerator() ) // When diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift index 0bec683be9..241243199b 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift @@ -140,8 +140,8 @@ class NodeSemanticsTests: XCTestCase { func testImportance() { let unknownElement = UnknownElement.constant let invisibleElement = InvisibleElement.constant - let ambiguousElement = AmbiguousElement(wireframesBuilder: nil) - let specificElement = SpecificElement(wireframesBuilder: nil, subtreeStrategy: .mockAny()) + let ambiguousElement = AmbiguousElement(nodes: []) + let specificElement = SpecificElement(subtreeStrategy: .mockAny(), nodes: []) XCTAssertGreaterThan( specificElement.importance, @@ -174,13 +174,5 @@ class NodeSemanticsTests: XCTestCase { .ignore, "Subtree should not be recorded for 'invisible' elements as nothing in it will be visible anyway" ) - DDAssertReflectionEqual( - AmbiguousElement(wireframesBuilder: nil).subtreeStrategy, - .record, - "Subtree should be recorded for 'ambiguous' elements as it may contain other elements" - ) - - let random: NodeSubtreeStrategy = .mockRandom() - DDAssertReflectionEqual(SpecificElement(wireframesBuilder: nil, subtreeStrategy: random).subtreeStrategy, random) } } diff --git a/DatadogSessionReplay/Tests/Utilities/CacheTests.swift b/DatadogSessionReplay/Tests/Utilities/CacheTests.swift index df8b96e900..17954b4344 100644 --- a/DatadogSessionReplay/Tests/Utilities/CacheTests.swift +++ b/DatadogSessionReplay/Tests/Utilities/CacheTests.swift @@ -40,6 +40,17 @@ class CacheTests: XCTestCase { XCTAssertNil(cache.value(forKey: "one")) } + func test_bytesLimit() { + let cache = Cache(dateProvider: { + return Date(timeIntervalSinceReferenceDate: 0) + }, totalBytesLimit: 1) + + cache.insert(1, forKey: "one", size: 1) + cache.insert(1, forKey: "two", size: 1) + XCTAssertNil(cache.value(forKey: "one")) + XCTAssertNotNil(cache.value(forKey: "two")) + } + func test_countLimit() { let cache = Cache(maximumEntryCount: 1) diff --git a/Makefile b/Makefile index 0731321760..cc694b764c 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ all: dependencies xcodeproj-httpservermock templates # The release version of `dd-sdk-swift-testing` to use for tests instrumentation. -DD_SDK_SWIFT_TESTING_VERSION = 2.2.0 +DD_SDK_SWIFT_TESTING_VERSION = 2.2.4 define DD_SDK_TESTING_XCCONFIG_CI FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]=$$(inherited) $$(SRCROOT)/../instrumented-tests/DatadogSDKTesting.xcframework/ios-arm64_x86_64-simulator/\n diff --git a/Sources/Datadog/CrashReporting/Integrations/CrashReportSender.swift b/Sources/Datadog/CrashReporting/Integrations/CrashReportSender.swift index b27589cbdf..7bc473c057 100644 --- a/Sources/Datadog/CrashReporting/Integrations/CrashReportSender.swift +++ b/Sources/Datadog/CrashReporting/Integrations/CrashReportSender.swift @@ -6,7 +6,7 @@ /// An object for sending crash reports. internal protocol CrashReportSender { - /// Send the crash report et context to integrations. + /// Send the crash report and context to integrations. /// /// - Parameters: /// - report: The crash report. @@ -21,14 +21,8 @@ internal struct MessageBusSender: CrashReportSender { /// The key for a crash message. /// /// Use this key when the crash should be reported - /// as a RUM event. + /// as a RUM and a Logs event. static let crash = "crash" - - /// The key for a crash log message. - /// - /// Use this key when the crash should be reported - /// as a log event. - static let crashLog = "crash-log" } /// The core for sending crash report and context. @@ -47,7 +41,7 @@ internal struct MessageBusSender: CrashReportSender { return } - sendRUM( + sendCrash( baggage: [ "report": report, "context": context @@ -55,28 +49,18 @@ internal struct MessageBusSender: CrashReportSender { ) } - private func sendRUM(baggage: FeatureBaggage) { + private func sendCrash(baggage: FeatureBaggage) { core?.send( message: .custom(key: MessageKeys.crash, baggage: baggage), - else: { self.sendLog(baggage: baggage) } - ) - } - - private func sendLog(baggage: FeatureBaggage) { - core?.send( - message: .custom(key: MessageKeys.crashLog, baggage: baggage), - else: printError - ) - } - - private func printError() { - // This case is not reachable in higher abstraction but we add sanity warning. - DD.logger.error( + else: { + DD.logger.warn( """ In order to use Crash Reporting, RUM or Logging feature must be enabled. Make sure `.enableRUM(true)` or `.enableLogging(true)` are configured when initializing Datadog SDK. """ + ) + } ) } } diff --git a/Sources/Datadog/Logging/LoggingV2Configuration.swift b/Sources/Datadog/Logging/LoggingV2Configuration.swift index c865c77067..e91f67b4ac 100644 --- a/Sources/Datadog/Logging/LoggingV2Configuration.swift +++ b/Sources/Datadog/Logging/LoggingV2Configuration.swift @@ -67,7 +67,7 @@ internal enum LoggingMessageKeys { static let log = "log" /// The key references a crash message. - static let crash = "crash-log" + static let crash = "crash" /// The key references a browser log message. static let browserLog = "browser-log" diff --git a/Sources/Datadog/RUM/DataModels/RUMDataModels.swift b/Sources/Datadog/RUM/DataModels/RUMDataModels.swift index 7a23b896b4..3aba87d398 100644 --- a/Sources/Datadog/RUM/DataModels/RUMDataModels.swift +++ b/Sources/Datadog/RUM/DataModels/RUMDataModels.swift @@ -1123,7 +1123,7 @@ public struct RUMResourceEvent: RUMDataModel { /// Version of the RUM event format public let formatVersion: Int64 = 2 - /// tracing sample rate in decimal format + /// trace sample rate in decimal format public let rulePsr: Double? /// Session-related internal properties @@ -2385,6 +2385,9 @@ public struct TelemetryConfigurationEvent: RUMDataModel { /// The upload frequency of batches (in milliseconds) public let batchUploadFrequency: Int64? + /// The version of Dart used in a Flutter application + public var dartVersion: String? + /// Session replay default privacy level public var defaultPrivacyLevel: String? @@ -2406,9 +2409,18 @@ public struct TelemetryConfigurationEvent: RUMDataModel { /// The percentage of sessions with Browser RUM & Session Replay pricing tracked (deprecated in favor of session_replay_sample_rate) public let premiumSampleRate: Int64? + /// The version of ReactNative used in a ReactNative application + public var reactNativeVersion: String? + + /// The version of React used in a ReactNative application + public var reactVersion: String? + /// The percentage of sessions with Browser RUM & Session Replay pricing tracked (deprecated in favor of session_replay_sample_rate) public let replaySampleRate: Int64? + /// A list of selected tracing propagators + public let selectedTracingPropagators: [SelectedTracingPropagators]? + /// The percentage of sessions with Browser RUM & Session Replay pricing tracked public var sessionReplaySampleRate: Int64? @@ -2442,7 +2454,7 @@ public struct TelemetryConfigurationEvent: RUMDataModel { /// Whether user frustrations are tracked public var trackFrustrations: Bool? - /// Whether user actions are tracked + /// Whether user actions are tracked (deprecated in favor of track_user_interactions) public var trackInteractions: Bool? /// Whether long tasks are tracked @@ -2466,12 +2478,18 @@ public struct TelemetryConfigurationEvent: RUMDataModel { /// Whether sessions across subdomains for the same site are tracked public let trackSessionAcrossSubdomains: Bool? + /// Whether user actions are tracked + public var trackUserInteractions: Bool? + /// Whether the RUM views creation is handled manually public var trackViewsManually: Bool? - /// Whether the allowed tracing origins list is used + /// Whether the allowed tracing origins list is used (deprecated in favor of use_allowed_tracing_urls) public let useAllowedTracingOrigins: Bool? + /// Whether the allowed tracing urls list is used + public let useAllowedTracingUrls: Bool? + /// Whether beforeSend callback function is used public let useBeforeSend: Bool? @@ -2503,6 +2521,7 @@ public struct TelemetryConfigurationEvent: RUMDataModel { case actionNameAttribute = "action_name_attribute" case batchSize = "batch_size" case batchUploadFrequency = "batch_upload_frequency" + case dartVersion = "dart_version" case defaultPrivacyLevel = "default_privacy_level" case forwardConsoleLogs = "forward_console_logs" case forwardErrorsToLogs = "forward_errors_to_logs" @@ -2510,7 +2529,10 @@ public struct TelemetryConfigurationEvent: RUMDataModel { case initializationType = "initialization_type" case mobileVitalsUpdatePeriod = "mobile_vitals_update_period" case premiumSampleRate = "premium_sample_rate" + case reactNativeVersion = "react_native_version" + case reactVersion = "react_version" case replaySampleRate = "replay_sample_rate" + case selectedTracingPropagators = "selected_tracing_propagators" case sessionReplaySampleRate = "session_replay_sample_rate" case sessionSampleRate = "session_sample_rate" case silentMultipleInit = "silent_multiple_init" @@ -2530,8 +2552,10 @@ public struct TelemetryConfigurationEvent: RUMDataModel { case trackNetworkRequests = "track_network_requests" case trackResources = "track_resources" case trackSessionAcrossSubdomains = "track_session_across_subdomains" + case trackUserInteractions = "track_user_interactions" case trackViewsManually = "track_views_manually" case useAllowedTracingOrigins = "use_allowed_tracing_origins" + case useAllowedTracingUrls = "use_allowed_tracing_urls" case useBeforeSend = "use_before_send" case useCrossSiteSessionCookie = "use_cross_site_session_cookie" case useExcludedActivityUrls = "use_excluded_activity_urls" @@ -2627,6 +2651,13 @@ public struct TelemetryConfigurationEvent: RUMDataModel { } } + public enum SelectedTracingPropagators: String, Codable { + case datadog = "datadog" + case b3 = "b3" + case b3multi = "b3multi" + case tracecontext = "tracecontext" + } + /// View tracking strategy public enum ViewTrackingStrategy: String, Codable { case activityViewTrackingStrategy = "ActivityViewTrackingStrategy" @@ -2926,4 +2957,4 @@ public enum RUMMethod: String, Codable { case patch = "PATCH" } -// Generated from https://github.com/DataDog/rum-events-format/tree/083edbb0f9fec392224820bd05c6336ce6d62c30 +// Generated from https://github.com/DataDog/rum-events-format/tree/581880e6d9e9bb51f6c81ecd87bae2923865a2a5 diff --git a/Sources/Datadog/RUM/RUMTelemetry.swift b/Sources/Datadog/RUM/RUMTelemetry.swift index 630108665a..4312be6518 100644 --- a/Sources/Datadog/RUM/RUMTelemetry.swift +++ b/Sources/Datadog/RUM/RUMTelemetry.swift @@ -235,7 +235,10 @@ private extension FeaturesConfiguration { initializationType: nil, mobileVitalsUpdatePeriod: self.rum?.vitalsFrequency?.toInt64Milliseconds, premiumSampleRate: nil, + reactNativeVersion: nil, + reactVersion: nil, replaySampleRate: nil, + selectedTracingPropagators: nil, sessionReplaySampleRate: nil, sessionSampleRate: self.rum?.sessionSampler.samplingRate.toInt64(), silentMultipleInit: nil, @@ -257,6 +260,7 @@ private extension FeaturesConfiguration { trackSessionAcrossSubdomains: nil, trackViewsManually: nil, useAllowedTracingOrigins: nil, + useAllowedTracingUrls: nil, useBeforeSend: nil, useCrossSiteSessionCookie: nil, useExcludedActivityUrls: nil, diff --git a/Sources/Datadog/RUM/RUMVitals/VitalInfoSampler.swift b/Sources/Datadog/RUM/RUMVitals/VitalInfoSampler.swift index dfee4bdbbc..aab6c053b9 100644 --- a/Sources/Datadog/RUM/RUMVitals/VitalInfoSampler.swift +++ b/Sources/Datadog/RUM/RUMVitals/VitalInfoSampler.swift @@ -63,6 +63,10 @@ internal final class VitalInfoSampler { self.refreshRateReader.register(self.refreshRatePublisher) self.maximumRefreshRate = maximumRefreshRate + // Take initial sample + RunLoop.main.perform(inModes: [.common]) { [weak self] in + self?.takeSample() + } // Schedule reoccuring samples let timer = Timer( timeInterval: frequency, @@ -70,13 +74,6 @@ internal final class VitalInfoSampler { ) { [weak self] _ in self?.takeSample() } - // Take initial sample - RunLoop.main.perform { - timer.fire() - } - // NOTE: RUMM-1280 based on my running Example app - // non-main run loops don't fire the timer. - // Although i can't catch this in unit tests RunLoop.main.add(timer, forMode: .common) self.timer = timer } diff --git a/Sources/Datadog/Versioning.swift b/Sources/Datadog/Versioning.swift index 0fdcfd15df..c1017bf803 100644 --- a/Sources/Datadog/Versioning.swift +++ b/Sources/Datadog/Versioning.swift @@ -1,3 +1,3 @@ // GENERATED FILE: Do not edit directly -internal let __sdkVersion = "1.16.0" +internal let __sdkVersion = "1.17.0" diff --git a/Sources/DatadogObjc/RUM/RUMDataModels+objc.swift b/Sources/DatadogObjc/RUM/RUMDataModels+objc.swift index 592298c84a..eabab60602 100644 --- a/Sources/DatadogObjc/RUM/RUMDataModels+objc.swift +++ b/Sources/DatadogObjc/RUM/RUMDataModels+objc.swift @@ -4772,6 +4772,11 @@ public class DDTelemetryConfigurationEventTelemetryConfiguration: NSObject { root.swiftModel.telemetry.configuration.batchUploadFrequency as NSNumber? } + @objc public var dartVersion: String? { + set { root.swiftModel.telemetry.configuration.dartVersion = newValue } + get { root.swiftModel.telemetry.configuration.dartVersion } + } + @objc public var defaultPrivacyLevel: String? { set { root.swiftModel.telemetry.configuration.defaultPrivacyLevel = newValue } get { root.swiftModel.telemetry.configuration.defaultPrivacyLevel } @@ -4803,10 +4808,24 @@ public class DDTelemetryConfigurationEventTelemetryConfiguration: NSObject { root.swiftModel.telemetry.configuration.premiumSampleRate as NSNumber? } + @objc public var reactNativeVersion: String? { + set { root.swiftModel.telemetry.configuration.reactNativeVersion = newValue } + get { root.swiftModel.telemetry.configuration.reactNativeVersion } + } + + @objc public var reactVersion: String? { + set { root.swiftModel.telemetry.configuration.reactVersion = newValue } + get { root.swiftModel.telemetry.configuration.reactVersion } + } + @objc public var replaySampleRate: NSNumber? { root.swiftModel.telemetry.configuration.replaySampleRate as NSNumber? } + @objc public var selectedTracingPropagators: [Int]? { + root.swiftModel.telemetry.configuration.selectedTracingPropagators?.map { DDTelemetryConfigurationEventTelemetryConfigurationSelectedTracingPropagators(swift: $0).rawValue } + } + @objc public var sessionReplaySampleRate: NSNumber? { set { root.swiftModel.telemetry.configuration.sessionReplaySampleRate = newValue?.int64Value } get { root.swiftModel.telemetry.configuration.sessionReplaySampleRate as NSNumber? } @@ -4896,6 +4915,11 @@ public class DDTelemetryConfigurationEventTelemetryConfiguration: NSObject { root.swiftModel.telemetry.configuration.trackSessionAcrossSubdomains as NSNumber? } + @objc public var trackUserInteractions: NSNumber? { + set { root.swiftModel.telemetry.configuration.trackUserInteractions = newValue?.boolValue } + get { root.swiftModel.telemetry.configuration.trackUserInteractions as NSNumber? } + } + @objc public var trackViewsManually: NSNumber? { set { root.swiftModel.telemetry.configuration.trackViewsManually = newValue?.boolValue } get { root.swiftModel.telemetry.configuration.trackViewsManually as NSNumber? } @@ -4905,6 +4929,10 @@ public class DDTelemetryConfigurationEventTelemetryConfiguration: NSObject { root.swiftModel.telemetry.configuration.useAllowedTracingOrigins as NSNumber? } + @objc public var useAllowedTracingUrls: NSNumber? { + root.swiftModel.telemetry.configuration.useAllowedTracingUrls as NSNumber? + } + @objc public var useBeforeSend: NSNumber? { root.swiftModel.telemetry.configuration.useBeforeSend as NSNumber? } @@ -4990,6 +5018,35 @@ public class DDTelemetryConfigurationEventTelemetryConfigurationForwardReports: } } +@objc +public enum DDTelemetryConfigurationEventTelemetryConfigurationSelectedTracingPropagators: Int { + internal init(swift: TelemetryConfigurationEvent.Telemetry.Configuration.SelectedTracingPropagators?) { + switch swift { + case nil: self = .none + case .datadog?: self = .datadog + case .b3?: self = .b3 + case .b3multi?: self = .b3multi + case .tracecontext?: self = .tracecontext + } + } + + internal var toSwift: TelemetryConfigurationEvent.Telemetry.Configuration.SelectedTracingPropagators? { + switch self { + case .none: return nil + case .datadog: return .datadog + case .b3: return .b3 + case .b3multi: return .b3multi + case .tracecontext: return .tracecontext + } + } + + case none + case datadog + case b3 + case b3multi + case tracecontext +} + @objc public enum DDTelemetryConfigurationEventTelemetryConfigurationViewTrackingStrategy: Int { internal init(swift: TelemetryConfigurationEvent.Telemetry.Configuration.ViewTrackingStrategy?) { @@ -5034,4 +5091,4 @@ public class DDTelemetryConfigurationEventView: NSObject { // swiftlint:enable force_unwrapping -// Generated from https://github.com/DataDog/rum-events-format/tree/083edbb0f9fec392224820bd05c6336ce6d62c30 +// Generated from https://github.com/DataDog/rum-events-format/tree/581880e6d9e9bb51f6c81ecd87bae2923865a2a5 diff --git a/Sources/_Datadog_Private/ObjcAppLaunchHandler.m b/Sources/_Datadog_Private/ObjcAppLaunchHandler.m index 86b236e7ed..acaafbc442 100644 --- a/Sources/_Datadog_Private/ObjcAppLaunchHandler.m +++ b/Sources/_Datadog_Private/ObjcAppLaunchHandler.m @@ -41,10 +41,10 @@ + (void)load { loadTime:CFAbsoluteTimeGetCurrent()]; NSNotificationCenter * __weak center = NSNotificationCenter.defaultCenter; - id __block token = [center addObserverForName:UIApplicationDidBecomeActiveNotification - object:nil - queue:NSOperationQueue.mainQueue - usingBlock:^(NSNotification *_){ + id __block __unused token = [center addObserverForName:UIApplicationDidBecomeActiveNotification + object:nil + queue:NSOperationQueue.mainQueue + usingBlock:^(NSNotification *_){ @synchronized(_shared) { NSTimeInterval time = CFAbsoluteTimeGetCurrent() - _shared->_processStartTime; diff --git a/Tests/DatadogTests/Datadog/CrashReporting/CrashReporterTests.swift b/Tests/DatadogTests/Datadog/CrashReporting/CrashReporterTests.swift index 6296f2b1ef..20468fb422 100644 --- a/Tests/DatadogTests/Datadog/CrashReporting/CrashReporterTests.swift +++ b/Tests/DatadogTests/Datadog/CrashReporting/CrashReporterTests.swift @@ -12,7 +12,7 @@ class CrashReporterTests: XCTestCase { // MARK: - Sending Crash Report func testWhenPendingCrashReportIsFound_itIsSentAndPurged() throws { - let expectation = self.expectation(description: "`LoggingOrRUMsender` sends the crash report") + let expectation = self.expectation(description: "`CrashReportSender` sends the crash report") let crashContext: CrashContext = .mockRandom() let crashReport: DDCrashReport = .mockRandomWith(context: crashContext) let plugin = CrashReportingPluginMock() @@ -46,6 +46,68 @@ class CrashReporterTests: XCTestCase { XCTAssertTrue(plugin.hasPurgedCrashReport == true, "It should ask to purge the crash report") } + func testWhenPendingCrashReportIsFound_itIsSentToRumFeature() throws { + let expectation = self.expectation(description: "`CrashReportSender` sends the crash report to RUM feature") + let crashContext: CrashContext = .mockRandom() + let crashReport: DDCrashReport = .mockRandomWith(context: crashContext) + let rumCrashReceiver = RUMCrashReceiverMock() + + let core = PassthroughCoreMock(messageReceiver: rumCrashReceiver) + + let plugin = CrashReportingPluginMock() + + // Given + plugin.pendingCrashReport = crashReport + plugin.injectedContextData = crashContext.data + + // When + let crashReporter = CrashReporter( + crashReportingPlugin: plugin, + crashContextProvider: CrashContextProviderMock(), + sender: MessageBusSender(core: core), + messageReceiver: NOPFeatureMessageReceiver() + ) + + //Then + plugin.didReadPendingCrashReport = { expectation.fulfill() } + crashReporter.sendCrashReportIfFound() + + waitForExpectations(timeout: 0.5, handler: nil) + + XCTAssert(!rumCrashReceiver.receivedBaggage.isEmpty, "RUM baggage must not be empty") + } + + func testWhenPendingCrashReportIsFound_itIsSentToLogsFeature() throws { + let expectation = self.expectation(description: "`CrashReportSender` sends the crash report to Logs feature") + let crashContext: CrashContext = .mockRandom() + let crashReport: DDCrashReport = .mockRandomWith(context: crashContext) + let logsCrashReceiver = LogsCrashReceiverMock() + + let core = PassthroughCoreMock(messageReceiver: logsCrashReceiver) + + let plugin = CrashReportingPluginMock() + + // Given + plugin.pendingCrashReport = crashReport + plugin.injectedContextData = crashContext.data + + // When + let crashReporter = CrashReporter( + crashReportingPlugin: plugin, + crashContextProvider: CrashContextProviderMock(), + sender: MessageBusSender(core: core), + messageReceiver: NOPFeatureMessageReceiver() + ) + + //Then + plugin.didReadPendingCrashReport = { expectation.fulfill() } + crashReporter.sendCrashReportIfFound() + + waitForExpectations(timeout: 0.5, handler: nil) + + XCTAssert(!logsCrashReceiver.receivedBaggage.isEmpty, "Logs baggage must not be empty") + } + func testWhenPendingCrashReportIsNotFound_itDoesNothing() { let expectation = self.expectation(description: "`plugin` checks the crash report") let plugin = CrashReportingPluginMock() @@ -74,7 +136,7 @@ class CrashReporterTests: XCTestCase { } func testWhenPendingCrashReportIsFoundButItHasUnavailableCrashContext_itPurgesTheCrashReportWithNoSending() { - let expectation = self.expectation(description: "`LoggingOrRUMsender` does not send the crash report") + let expectation = self.expectation(description: "`CrashReportSender` does not send the crash report") expectation.isInverted = true let plugin = CrashReportingPluginMock() @@ -233,7 +295,7 @@ class CrashReporterTests: XCTestCase { // MARK: - Usage - func testGivenNoRegisteredCrashReportReceiver_whenPendingCrashReportIsFound_itPrintsError() { + func testGivenNoRegisteredCrashReportReceiver_whenPendingCrashReportIsFound_itPrintsWarning() { let expectation = self.expectation(description: "`plugin` checks the crash report") let dd = DD.mockWith(logger: CoreLoggerMock()) @@ -260,13 +322,12 @@ class CrashReporterTests: XCTestCase { // Then waitForExpectations(timeout: 0.5, handler: nil) - XCTAssertEqual( - dd.logger.errorLog?.message, - """ + let logs = dd.logger.warnLogs + + XCTAssert(logs.contains(where: { $0.message == """ In order to use Crash Reporting, RUM or Logging feature must be enabled. Make sure `.enableRUM(true)` or `.enableLogging(true)` are configured when initializing Datadog SDK. - """ - ) + """ })) } } diff --git a/Tests/DatadogTests/Datadog/Mocks/CrashReportingFeatureMocks.swift b/Tests/DatadogTests/Datadog/Mocks/CrashReportingFeatureMocks.swift index 134b970b79..6c4fdfd0ab 100644 --- a/Tests/DatadogTests/Datadog/Mocks/CrashReportingFeatureMocks.swift +++ b/Tests/DatadogTests/Datadog/Mocks/CrashReportingFeatureMocks.swift @@ -87,6 +87,34 @@ class CrashReportSenderMock: CrashReportSender { var didSendCrashReport: (() -> Void)? } +class RUMCrashReceiverMock: FeatureMessageReceiver { + var receivedBaggage: FeatureBaggage = [:] + + func receive(message: FeatureMessage, from core: DatadogCoreProtocol) -> Bool { + switch message { + case .custom(let key, let attributes) where key == CrashReportReceiver.MessageKeys.crash: + receivedBaggage = attributes + return true + default: + return false + } + } +} + +class LogsCrashReceiverMock: FeatureMessageReceiver { + var receivedBaggage: FeatureBaggage = [:] + + func receive(message: FeatureMessage, from core: DatadogCoreProtocol) -> Bool { + switch message { + case .custom(let key, let attributes) where key == LoggingMessageKeys.crash: + receivedBaggage = attributes + return true + default: + return false + } + } +} + extension CrashContext { static func mockAny() -> CrashContext { return mockWith() diff --git a/Tests/DatadogTests/Datadog/Mocks/RUMDataModelMocks.swift b/Tests/DatadogTests/Datadog/Mocks/RUMDataModelMocks.swift index f7854f0498..9bab1f6c48 100644 --- a/Tests/DatadogTests/Datadog/Mocks/RUMDataModelMocks.swift +++ b/Tests/DatadogTests/Datadog/Mocks/RUMDataModelMocks.swift @@ -416,7 +416,10 @@ extension TelemetryConfigurationEvent: RandomMockable { initializationType: nil, mobileVitalsUpdatePeriod: .mockRandom(), premiumSampleRate: nil, + reactNativeVersion: nil, + reactVersion: nil, replaySampleRate: nil, + selectedTracingPropagators: nil, sessionReplaySampleRate: nil, sessionSampleRate: .mockRandom(), silentMultipleInit: nil, @@ -438,6 +441,7 @@ extension TelemetryConfigurationEvent: RandomMockable { trackSessionAcrossSubdomains: nil, trackViewsManually: nil, useAllowedTracingOrigins: .mockRandom(), + useAllowedTracingUrls: nil, useBeforeSend: nil, useCrossSiteSessionCookie: nil, useExcludedActivityUrls: nil, diff --git a/docs/rum_collection/_index.md b/docs/rum_collection/_index.md index 99fa978114..5f424d692d 100644 --- a/docs/rum_collection/_index.md +++ b/docs/rum_collection/_index.md @@ -51,7 +51,7 @@ Datadog Real User Monitoring (RUM) enables you to visualize and analyze the real 3. To instrument your web views, click the **Instrument your webviews** toggle. For more information, see [Web View Tracking][12]. 4. To disable automatic user data collection for either client IP or geolocation data, uncheck the boxes for those settings. For more information, see [RUM iOS Data Collected][14]. - {{< img src="real_user_monitoring/ios/new-rum-app-ios.png" alt="Create a RUM application for iOS in Datadog" style="width:100%;border:none" >}} + {{< img src="real_user_monitoring/ios/ios-create-application.png" alt="Create a RUM application for iOS in Datadog" style="width:100%;border:none" >}} To ensure the safety of your data, you must use a client token. If you used only [Datadog API keys][6] to configure the `dd-sdk-ios` library, they would be exposed client-side in the iOS application's byte code. diff --git a/docs/rum_mobile_vitals.md b/docs/rum_mobile_vitals.md index d2eb93ed15..b559dc6259 100644 --- a/docs/rum_mobile_vitals.md +++ b/docs/rum_mobile_vitals.md @@ -2,9 +2,9 @@ Real User Monitoring offers Mobile Vitals, a set of metrics inspired by [MetricKit][1], that can help compute insights about your mobile application's responsiveness, stability, and resource consumption. Mobile Vitals range from poor, moderate, to good. -Mobile Vitals appear in your application's **Overview** tab and in the side panel under **Performance** > **Event Timings and Mobile Vitals** when you click on an individual view in the [RUM Explorer][2]. Click on a graph in **Mobile Vitals** to apply a filter by version or examine filtered sessions. +Mobile Vitals appear on your your application's **Performance Overview** page when you navigate to **UX Monitoring > Performance Monitoring** and click your application. From the mobile performance dashboard for your application, click on a graph in **Mobile Vitals** to apply a filter by version or examine filtered sessions. -{{< img src="real_user_monitoring/ios/ios_mobile_vitals.png" alt="Mobile Vitals in the Performance Tab" style="width:70%;">}} +{{< img src="real_user_monitoring/ios/ios-mobile-vitals-new.png" alt="Mobile Vitals in the Performance Tab" style="width:100%;">}} Understand your application's overall health and performance with the line graphs displaying metrics across various application versions. To filter on application version or see specific sessions and views, click on a graph. @@ -17,23 +17,21 @@ You can also select a view in the RUM Explorer and observe recommended benchmark The following metrics provide insight into your mobile application's performance. | Measurement | Description | |--------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Slow renders | To ensure a smooth, [jank-free][3] user experience, your application should render frames in under 60Hz.

RUM tracks the application’s [display refresh rate][4] using `@view.refresh_rate_average` and `@view.refresh_rate_min` view attributes.

With slow rendering, you can monitor which views are taking longer than 16ms or 60Hz to render.
**Note:** Refresh rates are normalized on a range of zero to 60fps. For example, if your application runs at 100fps on a device capable of rendering 120fps, Datadog reports 50fps in **Mobile Vitals**. | -| Frozen frames | Frames that take longer than 700ms to render appear as stuck and unresponsive in your application. These are classified as [frozen frames][5].

RUM tracks `long task` events with the duration for any task taking longer then 100ms to complete.

With frozen frames, you can monitor which views appear frozen (taking longer than 700ms to render) to your end users and eliminate jank in your application. | -| Crash-free sessions by version | An [application crash][7] is reported due to an unexpected exit in the application typically caused by an unhandled exception or signal. Crash-free user sessions in your application directly correspond to your end user’s experience and overall satisfaction.

RUM tracks complete crash reports and presents trends over time with [Error Tracking][8].

With crash-free sessions, you can stay up to speed on industry benchmarks and ensure that your application is ranked highly on the Google Play Store. | -| CPU ticks per second | High CPU usage impacts the [battery life][9] on your users’ devices.

RUM tracks CPU ticks per second for each view and the CPU utilization over the course of a session. The recommended range is <40 for good and <60 for moderate.

You can see the top views with the most number of CPU ticks on average over a selected time period under **Mobile Vitals** in your application's Overview page. | -| Memory utilization | High memory usage can lead to [out-of-memory crashes][10], which causes a poor user experience.

RUM tracks the amount of physical memory used by your application in bytes for each view, over the course of a session. The recommended range is <200MB for good and <400MB for moderate.

You can see the top views with the most memory consumption on average over a selected time period under **Mobile Vitals** in your application's Overview page. | +| Slow renders | To ensure a smooth, [jank-free][2] user experience, your application should render frames in under 60Hz.

RUM tracks the application’s [display refresh rate][3] using `@view.refresh_rate_average` and `@view.refresh_rate_min` view attributes.

With slow rendering, you can monitor which views are taking longer than 16ms or 60Hz to render.
**Note:** Refresh rates are normalized on a range of zero to 60fps. For example, if your application runs at 100fps on a device capable of rendering 120fps, Datadog reports 50fps in **Mobile Vitals**. | +| Frozen frames | Frames that take longer than 700ms to render appear as stuck and unresponsive in your application. These are classified as [frozen frames][4].

RUM tracks `long task` events with the duration for any task taking longer then 100ms to complete.

With frozen frames, you can monitor which views appear frozen (taking longer than 700ms to render) to your end users and eliminate jank in your application. | +| Crash-free sessions by version | An [application crash][5] is reported due to an unexpected exit in the application typically caused by an unhandled exception or signal. Crash-free user sessions in your application directly correspond to your end user’s experience and overall satisfaction.

RUM tracks complete crash reports and presents trends over time with [Error Tracking][6].

With crash-free sessions, you can stay up to speed on industry benchmarks and ensure that your application is ranked highly on the Google Play Store. | +| CPU ticks per second | High CPU usage impacts the [battery life][7] on your users’ devices.

RUM tracks CPU ticks per second for each view and the CPU utilization over the course of a session. The recommended range is <40 for good and <60 for moderate.

You can see the top views with the most number of CPU ticks on average over a selected time period under **Mobile Vitals** in your application's Overview page. | +| Memory utilization | High memory usage can lead to [out-of-memory crashes][8], which causes a poor user experience.

RUM tracks the amount of physical memory used by your application in bytes for each view, over the course of a session. The recommended range is <200MB for good and <400MB for moderate.

You can see the top views with the most memory consumption on average over a selected time period under **Mobile Vitals** in your application's Overview page. | ## Further Reading {{< partial name="whats-next/whats-next.html" >}} [1]: https://developer.apple.com/documentation/metrickit -[2]: https://app.datadoghq.com/rum/explorer -[3]: https://developer.android.com/topic/performance/vitals/render#common-jank -[4]: https://developer.android.com/guide/topics/media/frame-rate -[5]: https://developer.android.com/topic/performance/vitals/frozen -[6]: https://developer.android.com/topic/performance/vitals/anr -[7]: https://developer.apple.com/documentation/xcode/diagnosing-issues-using-crash-reports-and-device-logs -[8]: https://docs.datadoghq.com/real_user_monitoring/ios/crash_reporting/ -[9]: https://developer.apple.com/documentation/xcode/analyzing-your-app-s-battery-use/ -[10]: https://developer.android.com/reference/java/lang/OutOfMemoryError \ No newline at end of file +[2]: https://developer.android.com/topic/performance/vitals/render#common-jank +[3]: https://developer.android.com/guide/topics/media/frame-rate +[4]: https://developer.android.com/topic/performance/vitals/frozen +[5]: https://developer.apple.com/documentation/xcode/diagnosing-issues-using-crash-reports-and-device-logs +[6]: https://docs.datadoghq.com/real_user_monitoring/ios/crash_reporting/ +[7]: https://developer.apple.com/documentation/xcode/analyzing-your-app-s-battery-use/ +[8]: https://developer.android.com/reference/java/lang/OutOfMemoryError \ No newline at end of file