diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 1dc35e9389..a0ce72472f 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -481,8 +481,6 @@ 61F2728B25C9561A00D54BF8 /* PLCrashReporterIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F2728A25C9561A00D54BF8 /* PLCrashReporterIntegration.swift */; }; 61F272B125C95ED800D54BF8 /* Mocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F2729A25C95EB200D54BF8 /* Mocks.swift */; }; 61F74AF426F20E4600E5F5ED /* DebugCrashReportingWithRUMViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F74AF326F20E4600E5F5ED /* DebugCrashReportingWithRUMViewController.swift */; }; - 61F930CB2BA213AC005F0EE2 /* AppHang.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930CA2BA213AC005F0EE2 /* AppHang.swift */; }; - 61F930CC2BA213AC005F0EE2 /* AppHang.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930CA2BA213AC005F0EE2 /* AppHang.swift */; }; 61F930BE2BA1ACAC005F0EE2 /* Storage+TLV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930BD2BA1ACAC005F0EE2 /* Storage+TLV.swift */; }; 61F930BF2BA1ACAC005F0EE2 /* Storage+TLV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930BD2BA1ACAC005F0EE2 /* Storage+TLV.swift */; }; 61F930C22BA1C41A005F0EE2 /* TLVBlockReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930C12BA1C41A005F0EE2 /* TLVBlockReader.swift */; }; @@ -491,6 +489,8 @@ 61F930C62BA1C4EB005F0EE2 /* TLVBlockReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930C42BA1C4EB005F0EE2 /* TLVBlockReaderTests.swift */; }; 61F930C82BA1C51C005F0EE2 /* Storage+TLVTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930C72BA1C51C005F0EE2 /* Storage+TLVTests.swift */; }; 61F930C92BA1C51C005F0EE2 /* Storage+TLVTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930C72BA1C51C005F0EE2 /* Storage+TLVTests.swift */; }; + 61F930CB2BA213AC005F0EE2 /* AppHang.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930CA2BA213AC005F0EE2 /* AppHang.swift */; }; + 61F930CC2BA213AC005F0EE2 /* AppHang.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930CA2BA213AC005F0EE2 /* AppHang.swift */; }; 61F9CABA2513A7F5000A5E61 /* RUMSessionMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F9CA982513977A000A5E61 /* RUMSessionMatcher.swift */; }; 61FC5F3525CC1898006BB4DE /* CrashContextProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FC5F3425CC1898006BB4DE /* CrashContextProviderTests.swift */; }; 61FDBA1326971953001D9D43 /* CrashReportMinifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FDBA1226971953001D9D43 /* CrashReportMinifier.swift */; }; @@ -736,6 +736,7 @@ D23354FD2A42E32000AFCAE2 /* InternalExtended.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23354FB2A42E32000AFCAE2 /* InternalExtended.swift */; }; D234613128B7713000055D4C /* FeatureContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D234613028B7712F00055D4C /* FeatureContextTests.swift */; }; D234613228B7713000055D4C /* FeatureContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D234613028B7712F00055D4C /* FeatureContextTests.swift */; }; + D23BFFEE2BBECA1C00ED6DD6 /* WebViewSlotCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23BFFED2BBECA1C00ED6DD6 /* WebViewSlotCacheTests.swift */; }; D23F8E5229DDCD28001CFAE8 /* UIViewControllerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F3CDA2251118FB00C816E5 /* UIViewControllerHandler.swift */; }; D23F8E5329DDCD28001CFAE8 /* RUMCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63A24BF1A4B008053F2 /* RUMCommand.swift */; }; D23F8E5429DDCD28001CFAE8 /* ValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611529A425E3DD51004F740E /* ValuePublisher.swift */; }; @@ -947,6 +948,7 @@ D270CDE12B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D270CDDF2B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift */; }; D2777D9D29F6A75800FFBB40 /* TelemetryReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2777D9C29F6A75800FFBB40 /* TelemetryReceiverTests.swift */; }; D2777D9E29F6A75800FFBB40 /* TelemetryReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2777D9C29F6A75800FFBB40 /* TelemetryReceiverTests.swift */; }; + D27B8A402BB171BA0031D37E /* WebViewSlotCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27B8A3F2BB171BA0031D37E /* WebViewSlotCache.swift */; }; D27D81C12A5D415200281CC2 /* CrashReporter.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 614ED36B260352DC00C8C519 /* CrashReporter.xcframework */; }; D27D81C22A5D415200281CC2 /* DatadogCrashReporting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61B7885425C180CB002675B5 /* DatadogCrashReporting.framework */; }; D27D81C32A5D415200281CC2 /* DatadogInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D23039A5298D513C001A1FA3 /* DatadogInternal.framework */; }; @@ -2409,11 +2411,11 @@ 61F3CDA62512144600C816E5 /* UIKitRUMViewsPredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitRUMViewsPredicate.swift; sourceTree = ""; }; 61F3CDAA25121FB500C816E5 /* UIViewControllerSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewControllerSwizzlerTests.swift; sourceTree = ""; }; 61F74AF326F20E4600E5F5ED /* DebugCrashReportingWithRUMViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugCrashReportingWithRUMViewController.swift; sourceTree = ""; }; - 61F930CA2BA213AC005F0EE2 /* AppHang.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHang.swift; sourceTree = ""; }; 61F930BD2BA1ACAC005F0EE2 /* Storage+TLV.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+TLV.swift"; sourceTree = ""; }; 61F930C12BA1C41A005F0EE2 /* TLVBlockReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLVBlockReader.swift; sourceTree = ""; }; 61F930C42BA1C4EB005F0EE2 /* TLVBlockReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLVBlockReaderTests.swift; sourceTree = ""; }; 61F930C72BA1C51C005F0EE2 /* Storage+TLVTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+TLVTests.swift"; sourceTree = ""; }; + 61F930CA2BA213AC005F0EE2 /* AppHang.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHang.swift; sourceTree = ""; }; 61F9CA982513977A000A5E61 /* RUMSessionMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMSessionMatcher.swift; sourceTree = ""; }; 61FB222C244A21ED00902D19 /* LoggingFeatureMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingFeatureMocks.swift; sourceTree = ""; }; 61FB222F244E1BE900902D19 /* DatadogLogsFeatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatadogLogsFeatureTests.swift; sourceTree = ""; }; @@ -2586,6 +2588,7 @@ D23354FB2A42E32000AFCAE2 /* InternalExtended.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalExtended.swift; sourceTree = ""; }; D234613028B7712F00055D4C /* FeatureContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureContextTests.swift; sourceTree = ""; }; D236BE2729520FED00676E67 /* CrashReportReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReportReceiver.swift; sourceTree = ""; }; + D23BFFED2BBECA1C00ED6DD6 /* WebViewSlotCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSlotCacheTests.swift; sourceTree = ""; }; D23F8E9929DDCD28001CFAE8 /* DatadogRUM.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DatadogRUM.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D23F8ECD29DDCD38001CFAE8 /* DatadogRUMTests tvOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "DatadogRUMTests tvOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; D240684D27CE6C9E00C04F44 /* Example tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Example tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2650,6 +2653,7 @@ D270CDDC2B46E3DB0002EACD /* URLSessionDataDelegateSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDataDelegateSwizzler.swift; sourceTree = ""; }; D270CDDF2B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDataDelegateSwizzlerTests.swift; sourceTree = ""; }; D2777D9C29F6A75800FFBB40 /* TelemetryReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryReceiverTests.swift; sourceTree = ""; }; + D27B8A3F2BB171BA0031D37E /* WebViewSlotCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSlotCache.swift; sourceTree = ""; }; D286626D2A43487500852CE3 /* Datadog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Datadog.swift; sourceTree = ""; }; D28F836729C9E71C00EF8EA2 /* DDSpanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDSpanTests.swift; sourceTree = ""; }; D28F836A29C9E7A300EF8EA2 /* TracingURLSessionHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingURLSessionHandlerTests.swift; sourceTree = ""; }; @@ -3269,10 +3273,11 @@ 61054E242A6EE10A00AAA894 /* ViewTreeSnapshot.swift */, 61054E252A6EE10A00AAA894 /* ViewTreeSnapshotBuilder.swift */, 61054E262A6EE10A00AAA894 /* ViewTreeRecorder.swift */, - 61054E272A6EE10A00AAA894 /* NodeRecorders */, 61054E372A6EE10A00AAA894 /* ViewAttributes+Copy.swift */, 61054E382A6EE10A00AAA894 /* ViewTreeRecordingContext.swift */, 61054E392A6EE10A00AAA894 /* NodeIDGenerator.swift */, + D27B8A3F2BB171BA0031D37E /* WebViewSlotCache.swift */, + 61054E272A6EE10A00AAA894 /* NodeRecorders */, ); path = ViewTreeSnapshot; sourceTree = ""; @@ -3349,7 +3354,7 @@ A7B932F42B1F694000AE6477 /* ResourcesProcessor.swift */, 61054E492A6EE10A00AAA894 /* Privacy */, 61054E4C2A6EE10A00AAA894 /* Diffing */, - 61054E4F2A6EE10A00AAA894 /* SRDataModelsBuilder */, + 61054E4F2A6EE10A00AAA894 /* Builders */, 61054E522A6EE10A00AAA894 /* Flattening */, ); path = Processor; @@ -3372,13 +3377,13 @@ path = Diffing; sourceTree = ""; }; - 61054E4F2A6EE10A00AAA894 /* SRDataModelsBuilder */ = { + 61054E4F2A6EE10A00AAA894 /* Builders */ = { isa = PBXGroup; children = ( 61054E502A6EE10A00AAA894 /* RecordsBuilder.swift */, 61054E512A6EE10A00AAA894 /* WireframesBuilder.swift */, ); - path = SRDataModelsBuilder; + path = Builders; sourceTree = ""; }; 61054E522A6EE10A00AAA894 /* Flattening */ = { @@ -3449,7 +3454,7 @@ children = ( 61054F4F2A6EE1BA00AAA894 /* Privacy */, 61054F512A6EE1BA00AAA894 /* Diffing */, - 61054F542A6EE1BA00AAA894 /* SRDataModelsBuilder */, + 61054F542A6EE1BA00AAA894 /* Builders */, 61054F562A6EE1BA00AAA894 /* SnapshotProcessorTests.swift */, A7D9528B2B28C18D004C79B1 /* ResourceProcessorTests.swift */, 61054F572A6EE1BA00AAA894 /* Flattening */, @@ -3474,12 +3479,12 @@ path = Diffing; sourceTree = ""; }; - 61054F542A6EE1BA00AAA894 /* SRDataModelsBuilder */ = { + 61054F542A6EE1BA00AAA894 /* Builders */ = { isa = PBXGroup; children = ( 61054F552A6EE1BA00AAA894 /* RecordsBuilderTests.swift */, ); - path = SRDataModelsBuilder; + path = Builders; sourceTree = ""; }; 61054F572A6EE1BA00AAA894 /* Flattening */ = { @@ -3543,6 +3548,7 @@ 61054F662A6EE1BA00AAA894 /* ViewTreeRecordingContextTests.swift */, 61054F672A6EE1BA00AAA894 /* ViewTreeSnapshotBuilderTests.swift */, 61054F682A6EE1BA00AAA894 /* NodeIDGeneratorTests.swift */, + D23BFFED2BBECA1C00ED6DD6 /* WebViewSlotCacheTests.swift */, 61054F692A6EE1BA00AAA894 /* NodeRecorders */, 61054F792A6EE1BA00AAA894 /* ViewTreeRecorderTests.swift */, 61054F7A2A6EE1BA00AAA894 /* ViewTreeSnapshotTests.swift */, @@ -7757,6 +7763,7 @@ 61054E962A6EE10A00AAA894 /* Diff+SRWireframes.swift in Sources */, D22C5BD02A98A6660024CC1F /* Baggages.swift in Sources */, 61054E902A6EE10A00AAA894 /* SegmentJSON.swift in Sources */, + D27B8A402BB171BA0031D37E /* WebViewSlotCache.swift in Sources */, 61054E672A6EE10A00AAA894 /* Recorder.swift in Sources */, 61054E6B2A6EE10A00AAA894 /* CFType+Safety.swift in Sources */, 61054E852A6EE10A00AAA894 /* UISegmentRecorder.swift in Sources */, @@ -7878,6 +7885,7 @@ 61054F9B2A6EE1BA00AAA894 /* QueueTests.swift in Sources */, 61054F992A6EE1BA00AAA894 /* ColorsTests.swift in Sources */, 61054FBF2A6EE1BA00AAA894 /* UIPickerViewRecorderTests.swift in Sources */, + D23BFFEE2BBECA1C00ED6DD6 /* WebViewSlotCacheTests.swift in Sources */, 61054FAE2A6EE1BA00AAA894 /* TouchIdentifierGeneratorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift b/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift index a3df8539e3..1738ccd2fe 100644 --- a/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift +++ b/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift @@ -43,6 +43,7 @@ class WebRecordIntegrationTests: XCTestCase { func testWebRecordIntegration() throws { // Given + let webView = WKWebView() let randomApplicationID: String = .mockRandom() let randomUUID: UUID = .mockRandom() let randomBrowserViewID: UUID = .mockRandom() @@ -65,7 +66,7 @@ class WebRecordIntegrationTests: XCTestCase { // When RUMMonitor.shared(in: core).startView(key: "web-view") - controller.send(body: body) + controller.send(body: body, from: webView) controller.flush() // Then @@ -74,7 +75,7 @@ class WebRecordIntegrationTests: XCTestCase { let segment = try XCTUnwrap(segments.first) let expectedUUID = randomUUID.uuidString.lowercased() - let expectedSlotID = "\(controller.hash as Int)" // explicitly get the NSObject.hash + let expectedSlotID = String(webView.hash) XCTAssertEqual(segment.applicationID, randomApplicationID) XCTAssertEqual(segment.sessionID, expectedUUID) diff --git a/DatadogCore/Tests/Datadog/Mocks/SystemFrameworks/WebKitMocks.swift b/DatadogCore/Tests/Datadog/Mocks/SystemFrameworks/WebKitMocks.swift index d3bdc275dc..0382373eff 100644 --- a/DatadogCore/Tests/Datadog/Mocks/SystemFrameworks/WebKitMocks.swift +++ b/DatadogCore/Tests/Datadog/Mocks/SystemFrameworks/WebKitMocks.swift @@ -22,9 +22,9 @@ final class WKUserContentControllerMock: WKUserContentController { handlers[name] = nil } - func send(body: Any) { + func send(body: Any, from webView: WKWebView? = nil) { let handler = handlers[DDScriptMessageHandler.name] - let message = WKScriptMessageMock(body: body, name: DDScriptMessageHandler.name) + let message = WKScriptMessageMock(body: body, name: DDScriptMessageHandler.name, webView: webView) handler?.userContentController(self, didReceive: message) } @@ -41,14 +41,17 @@ final class WKUserContentControllerMock: WKUserContentController { private final class WKScriptMessageMock: WKScriptMessage { private let _body: Any private let _name: String + private weak var _webView: WKWebView? - init(body: Any, name: String) { + init(body: Any, name: String, webView: WKWebView? = nil) { _body = body _name = name + _webView = webView } override var body: Any { _body } override var name: String { _name } + override weak var webView: WKWebView? { _webView } } #endif diff --git a/DatadogRUM/Tests/Integrations/WebViewEventReceiverTests.swift b/DatadogRUM/Tests/Integrations/WebViewEventReceiverTests.swift index 11365e5b16..a6c225b848 100644 --- a/DatadogRUM/Tests/Integrations/WebViewEventReceiverTests.swift +++ b/DatadogRUM/Tests/Integrations/WebViewEventReceiverTests.swift @@ -165,10 +165,7 @@ class WebViewEventReceiverTests: XCTestCase { func testGivenRUMContextAvailable_whenReceivingWebEvent_itGetsEnrichedWithOtherMobileContextAndWritten() throws { let core = PassthroughCoreMock( - context: .mockWith( - source: "react-native", - serverTimeOffset: .mockRandom(min: -10, max: 10).rounded() - ) + context: .mockWith(source: "react-native") ) // Given diff --git a/DatadogSessionReplay/Sources/Models/SRDataModels.swift b/DatadogSessionReplay/Sources/Models/SRDataModels.swift index 61a2806adc..fbc7613f95 100644 --- a/DatadogSessionReplay/Sources/Models/SRDataModels.swift +++ b/DatadogSessionReplay/Sources/Models/SRDataModels.swift @@ -452,6 +452,9 @@ public struct SRWebviewWireframe: Codable, Hashable { /// Defines the unique ID of the wireframe. This is persistent throughout the view lifetime. public let id: Int64 + /// Whether this web-view is visible or not. + public let isVisible: Bool? + /// The style of this wireframe. public let shapeStyle: SRShapeStyle? @@ -475,6 +478,7 @@ public struct SRWebviewWireframe: Codable, Hashable { case clip = "clip" case height = "height" case id = "id" + case isVisible = "isVisible" case shapeStyle = "shapeStyle" case slotId = "slotId" case type = "type" @@ -970,6 +974,9 @@ public struct SRIncrementalSnapshotRecord: Codable { /// Defines the unique ID of the wireframe. This is persistent throughout the view lifetime. public let id: Int64 + /// Whether this web-view is visible or not. + public let isVisible: Bool? + /// The style of this wireframe. public let shapeStyle: SRShapeStyle? @@ -993,6 +1000,7 @@ public struct SRIncrementalSnapshotRecord: Codable { case clip = "clip" case height = "height" case id = "id" + case isVisible = "isVisible" case shapeStyle = "shapeStyle" case slotId = "slotId" case type = "type" @@ -1322,4 +1330,4 @@ public enum SRRecord: Codable { } } #endif -// Generated from https://github.com/DataDog/rum-events-format/tree/c3747b3facf75e51cbad4c32f77ec3894f5a7249 +// Generated from https://github.com/DataDog/rum-events-format/tree/be033e3251da4a20891a774f9843c489a693c80d diff --git a/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/RecordsBuilder.swift b/DatadogSessionReplay/Sources/Processor/Builders/RecordsBuilder.swift similarity index 100% rename from DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/RecordsBuilder.swift rename to DatadogSessionReplay/Sources/Processor/Builders/RecordsBuilder.swift diff --git a/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/WireframesBuilder.swift b/DatadogSessionReplay/Sources/Processor/Builders/WireframesBuilder.swift similarity index 90% rename from DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/WireframesBuilder.swift rename to DatadogSessionReplay/Sources/Processor/Builders/WireframesBuilder.swift index 993167f904..6a447be89c 100644 --- a/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/WireframesBuilder.swift +++ b/DatadogSessionReplay/Sources/Processor/Builders/WireframesBuilder.swift @@ -20,6 +20,34 @@ public typealias WireframeID = NodeID /// Note: `WireframesBuilder` is used by `Processor` on a single background thread. @_spi(Internal) public class SessionReplayWireframesBuilder { + /// The cache of webview slots in memory during snapshot. + private(set) var webviews: [Int: WebViewSlot] + + /// Creates a builder for builder wireframes in snapshot processing. + /// + /// The builder takes optional webview slots in cache that can be updated + /// while traversing the node. The cache will be used to create wireframes + /// that are not visible be still need to be kept by the player. + /// + /// - Parameter webviews: The webview slot cache. + init(webviews: [Int: WebViewSlot] = [:]) { + self.webviews = webviews + } + + /// Removes a webview slot. + /// + /// Any node builder should remove the slot that is visible so it can be + /// placed at the right index in the wireframe list. Any remaining slot in + /// cache are considered hidden. + /// + /// - Parameter id: The id of the slot to remove. + func removeWebView(withSlotID id: Int) { + webviews[id] = nil + } +} + +@_spi(Internal) +extension SessionReplayWireframesBuilder { /// A set of fallback values to use if the actual value cannot be read or converted. /// /// The idea is to always provide value, which would make certain element visible in the player. @@ -186,13 +214,15 @@ public class SessionReplayWireframesBuilder { borderWidth: CGFloat? = nil, backgroundColor: CGColor? = nil, cornerRadius: CGFloat? = nil, - opacity: CGFloat? = nil + opacity: CGFloat? = nil, + isVisible: Bool? = nil ) -> SRWireframe { let wireframe = SRWebviewWireframe( border: createShapeBorder(borderColor: borderColor, borderWidth: borderWidth), clip: clip, height: Int64(withNoOverflow: frame.height), id: id, + isVisible: isVisible, shapeStyle: createShapeStyle(backgroundColor: backgroundColor, cornerRadius: cornerRadius, opacity: opacity), slotId: slotId, width: Int64(withNoOverflow: frame.size.width), diff --git a/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift b/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift index 229aadfc20..c5e197ba1a 100644 --- a/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift +++ b/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift @@ -196,6 +196,7 @@ extension SRWebviewWireframe: MutableWireframe { clip: use(clip, ifDifferentThan: other.clip), height: use(height, ifDifferentThan: other.height), id: id, + isVisible: use(isVisible, ifDifferentThan: other.isVisible), shapeStyle: use(shapeStyle, ifDifferentThan: other.shapeStyle), slotId: slotId, width: use(width, ifDifferentThan: other.width), diff --git a/DatadogSessionReplay/Sources/Processor/SnapshotProcessor.swift b/DatadogSessionReplay/Sources/Processor/SnapshotProcessor.swift index d2d136173c..6862b8524b 100644 --- a/DatadogSessionReplay/Sources/Processor/SnapshotProcessor.swift +++ b/DatadogSessionReplay/Sources/Processor/SnapshotProcessor.swift @@ -33,8 +33,6 @@ internal protocol SnapshotProcessing { internal class SnapshotProcessor: SnapshotProcessing { /// Flattens VTS received from `Recorder` by removing invisible nodes. private let nodesFlattener = NodesFlattener() - /// Builds SR wireframes to describe UI elements. - private let wireframesBuilder = WireframesBuilder() /// Builds SR records to transport SR wireframes. private let recordsBuilder: RecordsBuilder @@ -78,10 +76,18 @@ internal class SnapshotProcessor: SnapshotProcessing { } private func processSync(viewTreeSnapshot: ViewTreeSnapshot, touchSnapshot: TouchSnapshot?) { - let flattenedNodes = nodesFlattener.flattenNodes(in: viewTreeSnapshot) - let wireframes: [SRWireframe] = flattenedNodes - .map { node in node.wireframesBuilder } - .flatMap { nodeBuilder in nodeBuilder.buildWireframes(with: wireframesBuilder) } + let builder = WireframesBuilder(webviews: viewTreeSnapshot.webviews) + let nodes = nodesFlattener.flattenNodes(in: viewTreeSnapshot) + + // build wireframe from nodes + var wireframes: [SRWireframe] = nodes.flatMap { node in + node.wireframesBuilder.buildWireframes(with: builder) + } + + // build hidden webview wireframe and place them at the beginning + wireframes = builder.webviews.values.flatMap { webview in + webview.hiddenWireframes(with: builder) + } + wireframes interceptWireframes?(wireframes) diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorder.swift index 0f4e48aac2..1f52067a08 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorder.swift @@ -12,49 +12,83 @@ internal class WKWebViewRecorder: NodeRecorder { let identifier = UUID() func semantics(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> NodeSemantics? { - guard let webView = view as? WKWebView else { + guard let webview = view as? WKWebView else { return nil } - guard attributes.isVisible else { - return InvisibleElement.constant - } - - let builder = WKWebViewWireframesBuilder( - wireframeID: context.ids.nodeID(view: view, nodeRecorder: self), - slotID: webView.configuration.userContentController.hash, - attributes: attributes - ) + let slot = WKWebViewSlot(webview: webview) + // Add or update the webview slot in cache + context.webviewCache.update(slot) + let builder = WKWebViewWireframesBuilder(slot: slot, attributes: attributes) let node = Node(viewAttributes: attributes, wireframesBuilder: builder) return SpecificElement(subtreeStrategy: .ignore, nodes: [node]) } } +/// The slot recorded for a `WKWebView`. +internal struct WKWebViewSlot: WebViewSlot { + /// Weak reference to the web view represented by this slot. + /// + /// If the webview become `nil`, the slot will diseappear at the + /// next recording cycle during `reset` + weak var webview: WKWebView? + + /// The slot id. + let id: Int + + init(webview: WKWebView) { + self.webview = webview + self.id = webview.hash + } + + func purge() -> WebViewSlot? { + webview.map(WKWebViewSlot.init(webview:)) + } + + func hiddenWireframes(with builder: SessionReplayWireframesBuilder) -> [SRWireframe] { + return [ + builder.createWebViewWireframe( + id: Int64(id), + frame: .zero, + slotId: String(id), + isVisible: false + ) + ] + } +} + internal struct WKWebViewWireframesBuilder: NodeWireframesBuilder { - let wireframeID: WireframeID - /// The slot identifier of the webview controller. - let slotID: Int - /// Attributes of the `UIView`. + /// The webview slot. + let slot: WebViewSlot + let attributes: ViewAttributes - var wireframeRect: CGRect { - attributes.frame - } + var wireframeRect: CGRect { attributes.frame } func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] { + guard attributes.isVisible else { + // ignore hidden webview, the wireframes will be built + // by the slot itself + return [] + } + + /// Remove the slot from the builder because it has an associated node + builder.removeWebView(withSlotID: slot.id) return [ builder.createWebViewWireframe( - id: wireframeID, - frame: wireframeRect, - slotId: String(slotID), + id: Int64(slot.id), + frame: attributes.frame, + slotId: String(slot.id), borderColor: attributes.layerBorderColor, borderWidth: attributes.layerBorderWidth, backgroundColor: attributes.backgroundColor, cornerRadius: attributes.layerCornerRadius, - opacity: attributes.alpha + opacity: attributes.alpha, + isVisible: true ) ] } } + #endif diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContext.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContext.swift index 46b5b1c7e5..715af0ca7c 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContext.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContext.swift @@ -22,6 +22,8 @@ public struct SessionReplayViewTreeRecordingContext { public let ids: NodeIDGenerator /// Variable view controller related context var viewControllerContext: ViewControllerContext = .init() + /// Webviews caching. + let webviewCache: WebViewSlotCache } // This alias enables us to have a more unique name exposed through public-internal access level diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift index abf5c5fd30..2b763fb394 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift @@ -27,6 +27,8 @@ internal struct ViewTreeSnapshot { let nodes: [Node] /// An array of resource references recorded for this snapshot - sequenced in DFS order. let resources: [Resource] + /// An array of webview nodes recorded for this and past snapshots. + let webviews: [Int: WebViewSlot] } /// An individual node in `ViewTreeSnapshot`. A `SessionReplayNode` describes a single view - similar: an array of nodes describes @@ -67,6 +69,30 @@ public protocol SessionReplayResource { /// This alias enables us to have a more unique name exposed through public-internal access level internal typealias Resource = SessionReplayResource +/// An individual webview slots in `ViewTreeSnapshot`. A `WebViewSlot` describes a single webview +/// identified by a slot id. +internal protocol WebViewSlot { + /// The slot id. + var id: Int { get } + + /// Purge the webview slot. + /// + /// The slot will be purged before recording a snapshot, the implementation + /// must return `nil` if the webview was deallocated. + /// + /// - Returns: The remaining slot or `nil` if deallocated. + func purge() -> WebViewSlot? + + /// Creates hidden wireframes for this slot. + /// + /// This build method will be called as a fallback if the slot was not recorded + /// as a ``SessionReplayNode``. + /// + /// - Parameter builder: the generic builder for constructing SR data models. + /// - Returns: one wireframe that describe a webview slot in SR. + func hiddenWireframes(with builder: WireframesBuilder) -> [SRWireframe] +} + /// Attributes of the `UIView` that the node was created for. /// /// It is used by the `Recorder` to capture view attributes on the main thread. diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift index f1d292115c..fbac433e76 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift @@ -16,6 +16,8 @@ internal struct ViewTreeSnapshotBuilder { let viewTreeRecorder: ViewTreeRecorder /// Generates stable IDs for traversed views. let idsGenerator: NodeIDGenerator + /// The webview slots caching. + let webviewCache = WebViewSlotCache() /// Builds the `ViewTreeSnapshot` for given root view. /// @@ -25,10 +27,15 @@ internal struct ViewTreeSnapshotBuilder { /// are computed relatively to the `rootView` (e.g. the `x` and `y` position of all descendant nodes is given /// as its position in the root, no matter of nesting level). func createSnapshot(of rootView: UIView, with recorderContext: Recorder.Context) -> ViewTreeSnapshot { + // Purge the webviews cache before taking snapshot. + // It will remove deallocated webview slots + webviewCache.purge() + let context = ViewTreeRecordingContext( recorder: recorderContext, coordinateSpace: rootView, - ids: idsGenerator + ids: idsGenerator, + webviewCache: webviewCache ) let recording = viewTreeRecorder.record(rootView, in: context) let snapshot = ViewTreeSnapshot( @@ -36,7 +43,8 @@ internal struct ViewTreeSnapshotBuilder { context: recorderContext, viewportSize: rootView.bounds.size, nodes: recording.nodes, - resources: recording.resources + resources: recording.resources, + webviews: webviewCache.slots ) return snapshot } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCache.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCache.swift new file mode 100644 index 0000000000..ff804a62b7 --- /dev/null +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCache.swift @@ -0,0 +1,35 @@ +/* + * 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 Foundation + +/// Keeps slots in cache during webviews lifecycle. +/// +/// Uppon recording, the cache should be reset to clear deallocated web-views. +/// +/// **Note**: This class is not thread-safe and must be called on the main thread during recording only. +internal final class WebViewSlotCache { + /// The current dictionary of slots. + private(set) var slots: [Int: WebViewSlot] = [:] + + /// Inserts the given slot into the cache. + /// + /// If a slot id is already contained in the cache, the new slot replaces + /// the existing one. + /// + /// - Parameter slot: A slot to insert into the cache + /// - Complexity O(1) + func update(_ slot: WebViewSlot) { + slots[slot.id] = slot + } + + /// Purges the cache by removing deallocated webviews. + /// + /// - Complexity O(n) + func purge() { + slots = slots.compactMapValues { $0.purge() } + } +} diff --git a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift index 8196086683..317a57f214 100644 --- a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift @@ -34,7 +34,8 @@ extension ViewTreeSnapshot: AnyMockable, RandomMockable { context: .mockRandom(), viewportSize: .mockRandom(), nodes: .mockRandom(count: .random(in: (5..<50))), - resources: .mockRandom(count: .random(in: (5..<50))) + resources: .mockRandom(count: .random(in: (5..<50))), + webviews: .mockRandom() ) } @@ -43,14 +44,16 @@ extension ViewTreeSnapshot: AnyMockable, RandomMockable { context: Recorder.Context = .mockAny(), viewportSize: CGSize = .mockAny(), nodes: [Node] = .mockAny(), - resources: [Resource] = .mockAny() + resources: [Resource] = .mockAny(), + webviews: [Int: WebViewSlot] = .mockAny() ) -> ViewTreeSnapshot { return ViewTreeSnapshot( date: date, context: context, viewportSize: viewportSize, nodes: nodes, - resources: resources + resources: resources, + webviews: webviews ) } } @@ -297,7 +300,7 @@ extension UIImageResource: RandomMockable { } } -extension Collection where Element == Resource { +extension Sequence where Element == Resource { static func mockAny() -> [Resource] { return [MockResource].mockAny() } @@ -307,6 +310,49 @@ extension Collection where Element == Resource { } } +struct WebViewSlotMock: WebViewSlot, AnyMockable, RandomMockable { + let id: Int + let shouldPurge: Bool + let hiddenWireframe: SRWireframe + + init(id: Int, shouldPurge: Bool = false, hiddenWireframe: SRWireframe = .mockAny()) { + self.id = id + self.shouldPurge = shouldPurge + self.hiddenWireframe = hiddenWireframe + } + + func purge() -> WebViewSlot? { shouldPurge ? nil : self } + + func hiddenWireframes(with builder: DatadogSessionReplay.SessionReplayWireframesBuilder) -> [SRWireframe] { + [hiddenWireframe] + } + + static func mockAny() -> WebViewSlotMock { + .mockWith() + } + + static func mockWith(id: Int = .mockAny()) -> WebViewSlotMock { + WebViewSlotMock( + id: id, + hiddenWireframe: .mockRandomWith(id: Int64(id)) + ) + } + + static func mockRandom() -> WebViewSlotMock { + WebViewSlotMock(id: .mockRandom(), hiddenWireframe: .mockRandom()) + } +} + +extension Dictionary where Key == Int, Value == WebViewSlot { + static func mockAny() -> [Int: WebViewSlot] { + return [Int: WebViewSlotMock].mockAny() + } + + static func mockRandom() -> [Int: WebViewSlot] { + return [Int: WebViewSlotMock].mockRandom() + } +} + extension SpecificElement { static func mockAny() -> SpecificElement { SpecificElement(subtreeStrategy: .mockRandom(), nodes: []) @@ -344,19 +390,22 @@ extension ViewTreeRecordingContext: AnyMockable, RandomMockable { return .init( recorder: .mockRandom(), coordinateSpace: UIView.mockRandom(), - ids: NodeIDGenerator() + ids: NodeIDGenerator(), + webviewCache: WebViewSlotCache() ) } static func mockWith( recorder: Recorder.Context = .mockAny(), coordinateSpace: UICoordinateSpace = UIView.mockAny(), - ids: NodeIDGenerator = NodeIDGenerator() + ids: NodeIDGenerator = NodeIDGenerator(), + webviewCache: WebViewSlotCache = WebViewSlotCache() ) -> ViewTreeRecordingContext { return .init( recorder: recorder, coordinateSpace: coordinateSpace, - ids: ids + ids: ids, + webviewCache: webviewCache ) } } diff --git a/DatadogSessionReplay/Tests/Processor/SRDataModelsBuilder/RecordsBuilderTests.swift b/DatadogSessionReplay/Tests/Processor/Builders/RecordsBuilderTests.swift similarity index 100% rename from DatadogSessionReplay/Tests/Processor/SRDataModelsBuilder/RecordsBuilderTests.swift rename to DatadogSessionReplay/Tests/Processor/Builders/RecordsBuilderTests.swift diff --git a/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift b/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift index 9e30dcb07b..3e0fc36a40 100644 --- a/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift +++ b/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift @@ -194,6 +194,95 @@ class SnapshotProcessorTests: XCTestCase { XCTAssertEqual(core.recordsCountByViewID?.values.map { $0 }, [4, 4]) } + func testWhenProcessingViewTreeSnapshot_itIncludeWebViewSlotFromNode() throws { + let time = Date() + let rum: RUMContext = .mockWith(serverTimeOffset: 0) + + // Given + let core = PassthroughCoreMock() + let srContextPublisher = SRContextPublisher(core: core) + let webviewCache = WebViewSlotCache() + let processor = SnapshotProcessor( + queue: NoQueue(), + recordWriter: recordWriter, + srContextPublisher: srContextPublisher, + telemetry: TelemetryMock() + ) + + let hiddenSlot = WebViewSlotMock.mockWith(id: .mockRandom()) + let visibleSlot = WebViewSlotMock.mockWith(id: .mockRandom()) + let builder = WKWebViewWireframesBuilder(slot: visibleSlot, attributes: .mockAny()) + let node = Node(viewAttributes: .mockAny(), wireframesBuilder: builder) + + // When + webviewCache.update(hiddenSlot) + webviewCache.update(visibleSlot) + + let snapshot = ViewTreeSnapshot( + date: time, + context: .init(privacy: .allow, rumContext: rum, date: time), + viewportSize: .mockRandom(minWidth: 1_000, minHeight: 1_000), + nodes: [node], + resources: [], + webviews: webviewCache.slots + ) + + processor.process(viewTreeSnapshot: snapshot, touchSnapshot: nil) + + // Then + XCTAssertEqual(recordWriter.records.count, 1) + + let enrichedRecord = try XCTUnwrap(recordWriter.records.first) + XCTAssertEqual(enrichedRecord.applicationID, rum.applicationID) + XCTAssertEqual(enrichedRecord.sessionID, rum.sessionID) + XCTAssertEqual(enrichedRecord.viewID, rum.viewID) + + XCTAssertEqual(enrichedRecord.records.count, 3) + XCTAssertTrue(enrichedRecord.records[2].isFullSnapshotRecord) + let fullSnapshotRecord = try XCTUnwrap(enrichedRecord.records[2].fullSnapshot) + XCTAssertEqual(fullSnapshotRecord.data.wireframes.count, 2) + XCTAssertEqual(fullSnapshotRecord.data.wireframes.first?.id, Int64(hiddenSlot.id), "The hidden webview wireframe should be first") + XCTAssertEqual(fullSnapshotRecord.data.wireframes.last?.id, Int64(visibleSlot.id), "The visible webview wireframe should be last") + } + + func testWhenProcessingViewTreeSnapshot_itIncludeWebViewSlotFromCache() throws { + let time = Date() + let rum: RUMContext = .mockWith(serverTimeOffset: 0) + + // Given + let core = PassthroughCoreMock() + let srContextPublisher = SRContextPublisher(core: core) + let processor = SnapshotProcessor( + queue: NoQueue(), + recordWriter: recordWriter, + srContextPublisher: srContextPublisher, + telemetry: TelemetryMock() + ) + + let hiddenWireframe: SRWireframe = .mockRandom() + let webviewSlot = WebViewSlotMock(id: .mockAny(), hiddenWireframe: hiddenWireframe) + let viewTree = generateSimpleViewTree() + + // When + snapshotBuilder.webviewCache.update(webviewSlot) + let snapshot = generateViewTreeSnapshot(for: viewTree, date: time, rumContext: rum) + processor.process(viewTreeSnapshot: snapshot, touchSnapshot: nil) + + // Then + XCTAssertEqual(recordWriter.records.count, 1) + + let enrichedRecord = try XCTUnwrap(recordWriter.records.first) + XCTAssertEqual(enrichedRecord.applicationID, rum.applicationID) + XCTAssertEqual(enrichedRecord.sessionID, rum.sessionID) + XCTAssertEqual(enrichedRecord.viewID, rum.viewID) + + XCTAssertEqual(enrichedRecord.records.count, 3) + XCTAssertTrue(enrichedRecord.records[2].isFullSnapshotRecord) + let fullSnapshotRecord = try XCTUnwrap(enrichedRecord.records[2].fullSnapshot) + XCTAssertEqual(fullSnapshotRecord.data.wireframes.count, 3) + XCTAssertEqual(fullSnapshotRecord.data.wireframes.first?.id, hiddenWireframe.id, "The hidden webview wireframe should be first") + } + // MARK: - Processing `TouchSnapshots` func testWhenProcessingTouchSnapshot_itWritesRecordsThatContinueCurrentSegment() throws { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorderTests.swift index f03be46989..bbc05e9354 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorderTests.swift @@ -31,10 +31,14 @@ class WKWebViewRecorderTests: XCTestCase { let viewAttributes: ViewAttributes = .mock(fixture: .invisible) // When - let semantics = try XCTUnwrap(recorder.semantics(of: webView, with: viewAttributes, in: .mockAny())) + let semantics = try XCTUnwrap(recorder.semantics(of: webView, with: viewAttributes, in: .mockAny()) as? SpecificElement) // Then - XCTAssertTrue(semantics is InvisibleElement) + XCTAssertEqual(semantics.subtreeStrategy, .ignore, "WebView's subtree should not be recorded") + + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? WKWebViewWireframesBuilder) + let wireframes = builder.buildWireframes(with: WireframesBuilder()) + XCTAssert(wireframes.isEmpty) } func testWhenWebViewIsVisible() throws { @@ -48,23 +52,44 @@ class WKWebViewRecorderTests: XCTestCase { XCTAssertEqual(semantics.subtreeStrategy, .ignore, "WebView's subtree should not be recorded") let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? WKWebViewWireframesBuilder) - XCTAssertEqual(builder.attributes, viewAttributes) + let wireframes = builder.buildWireframes(with: WireframesBuilder()) + XCTAssertFalse(wireframes.isEmpty) + } + + func testHiddenWebViewSlot() throws { + // Given + let slot = WKWebViewSlot(webview: webView) + + // When + let wireframes = slot.hiddenWireframes(with: WireframesBuilder()) + + // Then + XCTAssertEqual(wireframes.count, 1) + + guard case let .webviewWireframe(wireframe) = wireframes.first else { + return XCTFail("First wireframe needs to be webviewWireframe case") + } + + XCTAssertEqual(wireframe.id, Int64(webView.hash)) + XCTAssertEqual(wireframe.slotId, String(webView.hash)) + XCTAssertNil(wireframe.clip) + XCTAssertEqual(wireframe.x, 0) + XCTAssertEqual(wireframe.y, 0) + XCTAssertEqual(wireframe.width, 0) + XCTAssertEqual(wireframe.height, 0) + XCTAssertFalse(wireframe.isVisible ?? true) } - func testWebViewWireframeBuilder() throws { + func testVisibleWebViewSlot() throws { // Given - let id: WireframeID = .mockRandom() - let slotId: Int = .mockRandom() let attributes: ViewAttributes = .mock(fixture: .visible()) + let slot = WKWebViewSlot(webview: webView) - let builder = WKWebViewWireframesBuilder( - wireframeID: id, - slotID: slotId, - attributes: attributes - ) + let builder = WireframesBuilder(webviews: [slot.id: slot]) // When - let wireframes = builder.buildWireframes(with: WireframesBuilder()) + let wireframes = WKWebViewWireframesBuilder(slot: slot, attributes: attributes) + .buildWireframes(with: builder) // Then XCTAssertEqual(wireframes.count, 1) @@ -73,12 +98,30 @@ class WKWebViewRecorderTests: XCTestCase { return XCTFail("First wireframe needs to be webviewWireframe case") } - XCTAssertEqual(wireframe.id, id) - XCTAssertEqual(wireframe.slotId, String(slotId)) + XCTAssertEqual(wireframe.id, Int64(webView.hash)) + XCTAssertEqual(wireframe.slotId, String(webView.hash)) XCTAssertNil(wireframe.clip) XCTAssertEqual(wireframe.x, Int64(withNoOverflow: attributes.frame.minX)) XCTAssertEqual(wireframe.y, Int64(withNoOverflow: attributes.frame.minY)) XCTAssertEqual(wireframe.width, Int64(withNoOverflow: attributes.frame.width)) XCTAssertEqual(wireframe.height, Int64(withNoOverflow: attributes.frame.height)) + XCTAssertTrue(wireframe.isVisible ?? false) + XCTAssertNil(builder.webviews[slot.id], "webview slot should be removed from builder") } + +// This test should pass but it doesn't because `WKWebView` apparently leaks. +// +// func testDeallocatedWebViewSlot() throws { +// var slot: WebViewSlot? = WKWebViewSlot(webview: webView) +// slot = slot?.reset() +// XCTAssertNotNil(slot) +// +// autoreleasepool { +// let webView = WKWebView() +// slot = WKWebViewSlot(webview: webView) +// } +// +// slot = slot?.reset() +// XCTAssertNil(slot) +// } } diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCacheTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCacheTests.swift new file mode 100644 index 0000000000..3c00275a0e --- /dev/null +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCacheTests.swift @@ -0,0 +1,48 @@ +/* + * 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 +import TestUtilities + +@_spi(Internal) +@testable import DatadogSessionReplay + +class WebViewSlotCacheTests: XCTestCase { + func testAddingWebViewSlotToCache() { + let cache = WebViewSlotCache() + cache.update(WebViewSlotMock(id: 1)) + XCTAssertEqual(cache.slots.count, 1) + cache.update(WebViewSlotMock(id: 2)) + XCTAssertEqual(cache.slots.count, 2) + cache.update(WebViewSlotMock(id: 3)) + XCTAssertEqual(cache.slots.count, 3) + cache.update(WebViewSlotMock(id: 3)) + XCTAssertEqual(cache.slots.count, 3, "cache slot should override existing slot") + } + + func testPurgeViewSlotCache() { + let cache = WebViewSlotCache() + + cache.update(WebViewSlotMock(id: 1)) + cache.update(WebViewSlotMock(id: 2)) + cache.update(WebViewSlotMock(id: 3)) + XCTAssertEqual(cache.slots.count, 3) + cache.purge() + XCTAssertEqual(cache.slots.count, 3) + + cache.update(WebViewSlotMock(id: 1)) + cache.update(WebViewSlotMock(id: 2, shouldPurge: true)) + cache.update(WebViewSlotMock(id: 3, shouldPurge: true)) + XCTAssertEqual(cache.slots.count, 3) + cache.purge() + XCTAssertEqual(cache.slots.count, 1) + XCTAssertNotNil(cache.slots[1]) + + cache.update(WebViewSlotMock(id: 1, shouldPurge: true)) + cache.purge() + XCTAssertEqual(cache.slots.count, 0) + } +} diff --git a/DatadogWebViewTracking/Sources/DDScriptMessageHandler.swift b/DatadogWebViewTracking/Sources/DDScriptMessageHandler.swift index aaba9df396..cebbedee73 100644 --- a/DatadogWebViewTracking/Sources/DDScriptMessageHandler.swift +++ b/DatadogWebViewTracking/Sources/DDScriptMessageHandler.swift @@ -28,11 +28,11 @@ internal class DDScriptMessageHandler: NSObject, WKScriptMessageHandler { _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { - let hash = userContentController.hash + let hash = message.webView.map { String($0.hash) } // message.body must be called within UI thread let body = message.body queue.async { - self.emitter.send(body: body, slotId: String(hash)) + self.emitter.send(body: body, slotId: hash) } } } diff --git a/DatadogWebViewTracking/Tests/WebViewTrackingTests.swift b/DatadogWebViewTracking/Tests/WebViewTrackingTests.swift index b44a5b4fc0..1a1f740620 100644 --- a/DatadogWebViewTracking/Tests/WebViewTrackingTests.swift +++ b/DatadogWebViewTracking/Tests/WebViewTrackingTests.swift @@ -32,13 +32,16 @@ final class MockMessageHandler: NSObject, WKScriptMessageHandler { } final class MockScriptMessage: WKScriptMessage { - let mockBody: Any + private let _body: Any + private weak var _webView: WKWebView? - init(body: Any) { - self.mockBody = body + init(body: Any, webView: WKWebView? = nil) { + _body = body + _webView = webView } - override var body: Any { return mockBody } + override var body: Any { _body } + override weak var webView: WKWebView? { _webView } } class WebViewTrackingTests: XCTestCase { @@ -281,6 +284,7 @@ class WebViewTrackingTests: XCTestCase { func testSendingWebRecordEvent() throws { let recordMessageExpectation = expectation(description: "Record message received") + let webView = WKWebView() let controller = DDUserContentController() let core = PassthroughCoreMock( @@ -290,7 +294,7 @@ class WebViewTrackingTests: XCTestCase { XCTAssertEqual(view.id, "64308fd4-83f9-48cb-b3e1-1e91f6721230") let matcher = JSONObjectMatcher(object: event) XCTAssertEqual(try? matcher.value("date"), 1_635_932_927_012) - XCTAssertEqual(try? matcher.value("slotId"), "\(controller.hash)") + XCTAssertEqual(try? matcher.value("slotId"), String(webView.hash)) recordMessageExpectation.fulfill() case .context: break @@ -317,7 +321,7 @@ class WebViewTrackingTests: XCTestCase { }, "view": { "id": "64308fd4-83f9-48cb-b3e1-1e91f6721230" } } - """) + """, webView: webView) messageHandler?.userContentController(controller, didReceive: webLogMessage) waitForExpectations(timeout: 1) diff --git a/TestUtilities/Helpers/DDAssert.swift b/TestUtilities/Helpers/DDAssert.swift index 213ef9b875..d70b97f6f1 100644 --- a/TestUtilities/Helpers/DDAssert.swift +++ b/TestUtilities/Helpers/DDAssert.swift @@ -72,8 +72,8 @@ private func _DDAssertReflectionEqual(_ expression1: @autoclosure () throws -> A switch (mirror1.displayStyle, mirror2.displayStyle) { case (.dictionary?, .dictionary?): // two dictionaries - let dictionary1 = value1 as! [String: Any] - let dictionary2 = value2 as! [String: Any] + let dictionary1 = value1 as! [AnyHashable: Any] + let dictionary2 = value2 as! [AnyHashable: Any] guard dictionary1.keys.count == dictionary2.keys.count else { throw DDAssertError.expectedFailure("dictionaries have different number of keys", keyPath: keyPath) @@ -84,7 +84,7 @@ private func _DDAssertReflectionEqual(_ expression1: @autoclosure () throws -> A throw DDAssertError.expectedFailure("dictionaries have different key names", keyPath: keyPath) } - try _DDAssertReflectionEqual(value1, value2, keyPath: keyPath + [key1]) + try _DDAssertReflectionEqual(value1, value2, keyPath: keyPath + [key1.description]) } return // dictionaries are equal