From a60da8b770d0312308305f404ee4bbdad406fc8d Mon Sep 17 00:00:00 2001 From: Maciek Grzybowski Date: Tue, 28 Feb 2023 11:31:08 +0100 Subject: [PATCH 1/2] REPLAY-1339 Add snapshot test for `UITextField` --- .../SRHost/Fixtures/Fixtures.swift | 5 + .../SRHost/Fixtures/InputElements.storyboard | 116 ++++++++++++++++++ .../SRSnapshotTests/SRSnapshotTests.swift | 18 +++ 3 files changed, 139 insertions(+) diff --git a/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/Fixtures.swift b/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/Fixtures.swift index e8071ddf5b..e3a8b4a65f 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/Fixtures.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/Fixtures.swift @@ -13,6 +13,7 @@ internal enum Fixture: CaseIterable { case segments case pickers case switches + case textFields var menuItemTitle: String { switch self { @@ -28,6 +29,8 @@ internal enum Fixture: CaseIterable { return "Pickers" case .switches: return "Switches" + case .textFields: + return "Text Fields" } } @@ -45,6 +48,8 @@ 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") } } } diff --git a/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputElements.storyboard b/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputElements.storyboard index 41846dcbc4..741543cead 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputElements.storyboard +++ b/DatadogSessionReplay/SRSnapshotTests/SRHost/Fixtures/InputElements.storyboard @@ -88,6 +88,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/SRSnapshotTests.swift b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/SRSnapshotTests.swift index a6d2fd8ffe..9d73e4bdbc 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/SRSnapshotTests.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/SRSnapshotTests.swift @@ -106,4 +106,22 @@ 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 + ) + } } From e2559712c19cf79200bfef7502fc6a43200f9bca Mon Sep 17 00:00:00 2001 From: Maciek Grzybowski Date: Fri, 3 Mar 2023 12:02:11 +0100 Subject: [PATCH 2/2] REPLAY-1339 Support `UITextField` elements and improve their text masking --- .../Utils/ImageRendering.swift | 7 +- .../Processor/Flattening/NodesFlattener.swift | 25 +-- .../Processor/Privacy/TextObfuscator.swift | 13 +- .../Sources/Processor/Processor.swift | 2 +- .../Recorder/Utilities/SystemColors.swift | 9 + .../NodeRecorders/UIImageViewRecorder.swift | 3 +- .../NodeRecorders/UILabelRecorder.swift | 13 +- .../UINavigationBarRecorder.swift | 3 +- .../NodeRecorders/UIPickerViewRecorder.swift | 68 +++--- .../NodeRecorders/UISegmentRecorder.swift | 3 +- .../NodeRecorders/UISliderRecorder.swift | 3 +- .../NodeRecorders/UISwitchRecorder.swift | 3 +- .../NodeRecorders/UITabBarRecorder.swift | 3 +- .../NodeRecorders/UITextFieldRecorder.swift | 147 +++++++------ .../NodeRecorders/UITextViewRecorder.swift | 3 +- .../NodeRecorders/UIViewRecorder.swift | 12 +- .../ViewTreeSnapshot/ViewTreeRecorder.swift | 25 +-- .../ViewTreeSnapshot/ViewTreeSnapshot.swift | 38 ++-- .../Tests/Mocks/RecorderMocks.swift | 29 ++- .../Flattening/NodesFlattenerTests.swift | 113 +--------- .../Privacy/TextObfuscatorTests.swift | 12 ++ .../UIImageViewRecorderTests.swift | 43 +--- .../NodeRecorders/UILabelRecorderTests.swift | 11 +- .../UINavigationBarRecorderTests.swift | 3 +- .../UIPickerViewRecorderTests.swift | 31 ++- .../UISegmentRecorderTests.swift | 12 +- .../NodeRecorders/UISliderRecorderTests.swift | 6 +- .../NodeRecorders/UISwitchRecorderTests.swift | 9 +- .../NodeRecorders/UITabBarRecorderTests.swift | 11 +- .../UITextFieldRecorderTests.swift | 37 ++-- .../UITextViewRecorderTests.swift | 12 +- .../NodeRecorders/UIViewRecorderTests.swift | 13 +- .../ViewTreeRecorderTests.swift | 197 +++++------------- .../ViewTreeSnapshotTests.swift | 12 +- 34 files changed, 373 insertions(+), 558 deletions(-) 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/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..8b94f8b51e 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 InputTextObfuscator: 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/SystemColors.swift b/DatadogSessionReplay/Sources/Recorder/Utilities/SystemColors.swift index 891c8f4ca0..721d397d50 100644 --- a/DatadogSessionReplay/Sources/Recorder/Utilities/SystemColors.swift +++ b/DatadogSessionReplay/Sources/Recorder/Utilities/SystemColors.swift @@ -64,4 +64,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/UIImageViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorder.swift index acbd552a91..5e9fa9b6f6 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorder.swift @@ -41,7 +41,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..7b01202e3f 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorder.swift @@ -6,11 +6,19 @@ import UIKit -internal struct UILabelRecorder: NodeRecorder { +internal class UILabelRecorder: NodeRecorder { + /// An option for ignoring certain views by this recorder. + var dropPredicate: (UILabel, ViewAttributes) -> Bool = { _, _ in false } + /// 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 } + if dropPredicate(label, attributes) { + return nil + } let hasVisibleText = !(label.text?.isEmpty ?? true) @@ -37,7 +45,8 @@ internal struct UILabelRecorder: NodeRecorder { textObfuscator: context.recorder.privacy == .maskAll ? context.textObfuscator : nopTextObfuscator, wireframeRect: textFrame ) - return SpecificElement(wireframesBuilder: builder, subtreeStrategy: .ignore) + let node = Node(viewAttributes: attributes, wireframesBuilder: builderOverride(builder)) + return SpecificElement(subtreeStrategy: .ignore, nodes: [node]) } } 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..32f5ba6956 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorder.swift @@ -16,21 +16,20 @@ import UIKit /// - Instead, we infer the value by traversing picker's subtree state and finding texts that are displayed closest to its geometry center. /// - 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 individual labels in picker's subtree. + private let labelRecorder: UILabelRecorder + /// 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() { + self.labelRecorder = UILabelRecorder() + self.labelsRecorder = ViewTreeRecorder(nodeRecorders: [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 +43,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. @@ -76,25 +70,23 @@ internal struct UIPickerViewRecorder: NodeRecorder { /// Records `UILabel` nodes that hold titles of **selected** options - if picker defines N components, there will be N nodes returned. private func recordTitlesOfSelectedOption(in picker: UIPickerView, pickerAttributes: ViewAttributes, using context: ViewTreeRecordingContext) -> [Node] { - var context = context - context.textObfuscator = PickerOptionTextObfuscator() - context.semanticsOverride = { currentSemantics, label, attributes in + labelRecorder.dropPredicate = { _, labelAttributes 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 + let isNearCenter = abs(labelAttributes.frame.midY - pickerAttributes.frame.midY) < 10 + let isForeground = labelAttributes.alpha == 1 + let isSelectedOption = isNearCenter && isForeground + return !isSelectedOption // drop other options than selected one + } - 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 - } + labelRecorder.builderOverride = { builder in + var builder = builder + builder.textAlignment = .init(horizontal: .center, vertical: .center) + return builder } + + var context = context + context.textObfuscator = InputTextObfuscator() return labelsRecorder.recordNodes(for: picker, in: context) } } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorder.swift index 63dd6c92ba..a7c0a299da 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorder.swift @@ -34,7 +34,8 @@ internal struct UISegmentRecorder: NodeRecorder { } }() ) - return SpecificElement(wireframesBuilder: builder, subtreeStrategy: .ignore) + let node = Node(viewAttributes: attributes, wireframesBuilder: builder) + return SpecificElement(subtreeStrategy: .ignore, nodes: [node]) } } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISliderRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISliderRecorder.swift index abad348c50..35d1ee6b86 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISliderRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISliderRecorder.swift @@ -31,7 +31,8 @@ internal struct UISliderRecorder: NodeRecorder { maxTrackTintColor: slider.maximumTrackTintColor?.cgColor, thumbTintColor: slider.thumbTintColor?.cgColor ) - return SpecificElement(wireframesBuilder: builder, subtreeStrategy: .ignore) + let node = Node(viewAttributes: attributes, wireframesBuilder: builder) + return SpecificElement(subtreeStrategy: .ignore, nodes: [node]) } } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorder.swift index ac5349de79..5d3b089151 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorder.swift @@ -33,7 +33,8 @@ internal struct UISwitchRecorder: NodeRecorder { 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]) } } 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..bb5c5641a4 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.dropPredicate = { _, 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 + } + + 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 InputTextObfuscator() + } + + return context.recorder.privacy == .maskAll ? context.textObfuscator : nopTextObfuscator // default one } } 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..06fa38e179 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorder.swift @@ -23,7 +23,8 @@ internal struct UITextViewRecorder: NodeRecorder { textObfuscator: context.recorder.privacy == .maskAll ? context.textObfuscator : nopTextObfuscator, 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..dea1c2cda5 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 ignoring certain views by this recorder. + var dropPredicate: (UIView, ViewAttributes) -> Bool = { _, _ in false } + func semantics(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> NodeSemantics? { guard attributes.isVisible else { return InvisibleElement.constant } + if dropPredicate(view, attributes) { + return nil + } 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..c9f2101fa3 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift @@ -19,12 +19,6 @@ internal struct ViewTreeRecordingContext { /// Masks text in recorded nodes. /// 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 } internal struct ViewTreeRecorder { @@ -44,22 +38,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 +70,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 +77,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..7a6741552a 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 () {} @@ -189,8 +183,8 @@ internal struct UnknownElement: NodeSemantics { /// Nodes with this semantics can be safely ignored in `Recorder` or in `Processor`. 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 () { @@ -211,8 +205,8 @@ internal struct InvisibleElement: NodeSemantics { /// 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 +214,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/Tests/Mocks/RecorderMocks.swift b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift index b9193a39eb..d8faefbe99 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,33 +235,34 @@ 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 ) } } 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..1c02bca561 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 = InputTextObfuscator() + + 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/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..30fc3c36c0 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) @@ -67,8 +66,8 @@ class UILabelRecorderTests: XCTestCase { let semantics2 = try XCTUnwrap(recorder.semantics(of: label, with: viewAttributes, in: .mockWith(recorder: .mockWith(privacy: .allowAll)))) // Then - let builder1 = try XCTUnwrap(semantics1.wireframesBuilder as? UILabelWireframesBuilder) - let builder2 = try XCTUnwrap(semantics2.wireframesBuilder as? UILabelWireframesBuilder) + let builder1 = try XCTUnwrap(semantics1.nodes.first?.wireframesBuilder as? UILabelWireframesBuilder) + let builder2 = try XCTUnwrap(semantics2.nodes.first?.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") } 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/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..6ac980dcb2 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,47 @@ 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 semantics1 = try XCTUnwrap(recorder.semantics(of: textField1, with: viewAttributes, in: .mockWith(recorder: .mockWith(privacy: .maskAll)))) + let semantics2 = try XCTUnwrap(recorder.semantics(of: textField1, with: viewAttributes, in: .mockWith(recorder: .mockWith(privacy: .allowAll)))) + let semantics3 = try XCTUnwrap(recorder.semantics(of: textField2, with: viewAttributes, in: .mockWith(recorder: .mockWith(privacy: .mockRandom())))) + let semantics4 = try XCTUnwrap(recorder.semantics(of: textField3, with: viewAttributes, in: .mockWith(recorder: .mockWith(privacy: .mockRandom())))) // Then - let builder1 = try XCTUnwrap(semantics1.wireframesBuilder as? UITextFieldWireframesBuilder) - let builder2 = try XCTUnwrap(semantics2.wireframesBuilder as? UITextFieldWireframesBuilder) + 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) + let builder4 = try XCTUnwrap(semantics4.nodes.first?.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") + XCTAssertTrue(builder3.textObfuscator is InputTextObfuscator, "When `TextField` accepts secure text entry, it should use `InputTextObfuscator`") + XCTAssertTrue(builder4.textObfuscator is InputTextObfuscator, "When `TextField` accepts email or tlephone no. entry, it should use `InputTextObfuscator`") } func testWhenViewIsNotOfExpectedType() { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift index fcf575e8eb..160c00e1d7 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) @@ -58,8 +58,8 @@ class UITextViewRecorderTests: XCTestCase { let semantics2 = try XCTUnwrap(recorder.semantics(of: textView, with: viewAttributes, in: .mockWith(recorder: .mockWith(privacy: .allowAll)))) // Then - let builder1 = try XCTUnwrap(semantics1.wireframesBuilder as? UITextViewWireframesBuilder) - let builder2 = try XCTUnwrap(semantics2.wireframesBuilder as? UITextViewWireframesBuilder) + let builder1 = try XCTUnwrap(semantics1.nodes.first?.wireframesBuilder as? UITextViewWireframesBuilder) + let builder2 = try XCTUnwrap(semantics2.nodes.first?.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") } 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..1cf142f58c 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,30 +145,6 @@ 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) @@ -178,18 +156,12 @@ 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") } } @@ -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. - """ - ) - } - - func testItRecordsBaseViewWithSomeAppearance() { - // 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. - """ + XCTAssertFalse( + switchNodes.isEmpty, + "`UISwitch` with no appearance should record some nodes as it has style coming from its internal subtree." ) } - func testItRecordsSpecialisedViewsWithSomeAppearance() { + func testItRecordsViewsWithSomeAppearance() { // Given let recorder = ViewTreeRecorder(nodeRecorders: defaultNodeRecorders) 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/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) } }