diff --git a/Plans/iOS-SwiftUI_Base.xctestplan b/Plans/iOS-SwiftUI_Base.xctestplan index 8a1ca655087..51ea512dc8b 100644 --- a/Plans/iOS-SwiftUI_Base.xctestplan +++ b/Plans/iOS-SwiftUI_Base.xctestplan @@ -14,7 +14,8 @@ "containerPath" : "container:iOS-SwiftUI.xcodeproj", "identifier" : "7BB6224826A56C4E00D0E75E", "name" : "iOS-SwiftUI" - } + }, + "uiTestingScreenshotsLifetime" : "keepAlways" }, "testTargets" : [ { diff --git a/Samples/iOS-SwiftUI/iOS-SwiftUI-UITests/LaunchUITests.swift b/Samples/iOS-SwiftUI/iOS-SwiftUI-UITests/LaunchUITests.swift index 822fa607e57..9620dbe0d5e 100644 --- a/Samples/iOS-SwiftUI/iOS-SwiftUI-UITests/LaunchUITests.swift +++ b/Samples/iOS-SwiftUI/iOS-SwiftUI-UITests/LaunchUITests.swift @@ -39,11 +39,94 @@ class LaunchUITests: XCTestCase { XCTAssertEqual(app.staticTexts["SPAN_ID"].label, "NO SPAN") } - func testTTID_TTFD() { + func testTTID_TTFD_withTracedViewWaitForFullDisplay_shouldBeReported() { + // -- Arrange -- let app = XCUIApplication() app.launch() + + // -- Act -- app.buttons["Show TTD"].tap() + // -- Assert -- + // By pressing the button 'Show TTD', it will display the TTD info. + // If the TTD info is not displayed, it was not reported, therefore the test should fail. XCTAssertEqual(app.staticTexts["TTDInfo"].label, "TTID and TTFD found") } + + func testTTID_TTFD_withDelayedFullDisplay_shouldReportTTDInfoOnlyAfterDelay() { + // -- Arrange -- + // Launch the app and navigate to the delayed full display view. + let app = XCUIApplication() + app.launch() + app.buttons["button.destination.full-display-deplayed"].tap() + + // Note: UI Tests can not directly access Sentry SDK data, therefore we + // need to set the status in the UI and access it from there. + let updateStatusButton = app.buttons["button.update-ttfd-ttid-status"] + guard updateStatusButton.waitForExistence(timeout: 1) else { + return XCTFail("Update status button not found") + } + // Switchs can only have two states: On or Off + // Therefore we add an additional counter to check if the status has been updated. + let statusRefreshCounter = app.staticTexts["label.status-refresh-counter"] + guard statusRefreshCounter.waitForExistence(timeout: 1) else { + return XCTFail("Status refresh counter not found") + } + XCTAssertEqual(statusRefreshCounter.label, "Status Refresh Counter: 0") + let statusSwitchTTID = app.switches["check.ttid-reported"] + guard statusSwitchTTID.waitForExistence(timeout: 1) else { + return XCTFail("TTID status Switch not found") + } + XCTAssertEqual(statusSwitchTTID.value as? String, "0") + let statusSwitchTTFD = app.switches["check.ttfd-reported"] + guard statusSwitchTTFD.waitForExistence(timeout: 1) else { + return XCTFail("TTFD status Switch not found") + } + XCTAssertEqual(statusSwitchTTID.value as? String, "0") + + // - Check Preconditions + // Expect the initial content to appear immediately. + let initialContent = app.staticTexts["content.initial"] + guard initialContent.waitForExistence(timeout: 1) else { + return XCTFail("Initial content not found") + } + + // Confirm pre-condition that full content does not exist yet. + let fullContent = app.staticTexts["content.delayed"] + if fullContent.exists { + return XCTFail("Delayed content should not exist yet") + } + + // Verify TTID has been reported, but TTFD not yet. + updateStatusButton.tap() + XCTAssertEqual(statusRefreshCounter.label, "Status Refresh Counter: 1") + XCTAssertEqual(statusSwitchTTID.value as? String, "1") + XCTAssertEqual(statusSwitchTTFD.value as? String, "0") + + // -- Act -- + // Trigger the appearance of the delayed content + let triggerButton = app.buttons["button.trigger-delayed-content"] + guard triggerButton.waitForExistence(timeout: 1) else { + return XCTFail("Trigger button not found") + } + triggerButton.tap() + + // -- Assert -- + // Verify TTFD is delayed and not reported yet. + updateStatusButton.tap() + XCTAssertEqual(statusRefreshCounter.label, "Status Refresh Counter: 2") + XCTAssertEqual(statusSwitchTTID.value as? String, "1") + XCTAssertEqual(statusSwitchTTFD.value as? String, "0") + + // Confirm that the full content eventually appears. + guard fullContent.waitForExistence(timeout: 5) else { + return XCTFail("Delayed content not found") + } + + // Verify TTFD has been reported. + updateStatusButton.tap() + XCTAssertEqual(statusRefreshCounter.label, "Status Refresh Counter: 3") + XCTAssertEqual(statusSwitchTTID.value as? String, "1") + XCTAssertEqual(statusSwitchTTFD.value as? String, "1") + } } diff --git a/Samples/iOS-SwiftUI/iOS-SwiftUI.xcodeproj/project.pbxproj b/Samples/iOS-SwiftUI/iOS-SwiftUI.xcodeproj/project.pbxproj index fd4c9d5e589..89518f15e18 100644 --- a/Samples/iOS-SwiftUI/iOS-SwiftUI.xcodeproj/project.pbxproj +++ b/Samples/iOS-SwiftUI/iOS-SwiftUI.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 7BB6225E26A56CB600D0E75E /* Sentry.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 7BB6225C26A56CB600D0E75E /* Sentry.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 84BA72B52C9369C80045B828 /* GitInjections.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BA72B32C9369C80045B828 /* GitInjections.swift */; }; 84D4FEB528ECD53500EDAAFE /* Sentry.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84D4FEB228ECD52E00EDAAFE /* Sentry.framework */; }; + D4DB99952D678355000AD63F /* DelayedFullDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4DB998D2D67834D000AD63F /* DelayedFullDisplayView.swift */; }; D8199DCD29376FD90074249E /* SentrySwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8BBD38B2901AE400011F850 /* SentrySwiftUI.framework */; }; D8199DCE29376FD90074249E /* SentrySwiftUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D8BBD38B2901AE400011F850 /* SentrySwiftUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D832FAF02982A908007A9A5F /* FormScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D832FAEF2982A908007A9A5F /* FormScreen.swift */; }; @@ -171,6 +172,7 @@ 84D4FEA628ECD51800EDAAFE /* Sentry.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Sentry.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 84D4FEA828ECD52700EDAAFE /* Sentry.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Sentry.xcodeproj; path = ../../Sentry.xcodeproj; sourceTree = ""; }; 84D4FEAA28ECD52E00EDAAFE /* Sentry.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Sentry.xcodeproj; path = "/Users/andrewmcknight/Code/organization/getsentry/repos/public/sentry-cocoa/Sentry.xcodeproj"; sourceTree = ""; }; + D4DB998D2D67834D000AD63F /* DelayedFullDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelayedFullDisplayView.swift; sourceTree = ""; }; D832FAEF2982A908007A9A5F /* FormScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormScreen.swift; sourceTree = ""; }; D85388D02980222500B63908 /* UIKitScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitScreen.swift; sourceTree = ""; }; D8A22A7729151DB7006907D9 /* bridging-headers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "bridging-headers.h"; sourceTree = ""; }; @@ -211,7 +213,7 @@ 8425DE232B52241000113FEF /* SentryProfilerTests.xctest */, 8425DE252B52241000113FEF /* libSentryTestUtils.a */, 8425DE272B52241000113FEF /* SentryTestUtilsDynamic.framework */, - D833D61B2D13216300961E7A /* libSentrySwiftUITests.a */, + D833D61B2D13216300961E7A /* SentrySwiftUITests.xctest */, ); name = Products; sourceTree = ""; @@ -249,6 +251,7 @@ 7BB6224B26A56C4E00D0E75E /* iOS-SwiftUI */ = { isa = PBXGroup; children = ( + D4DB998D2D67834D000AD63F /* DelayedFullDisplayView.swift */, 7BB6226026A56E1E00D0E75E /* iOS-SwiftUI.entitlements */, 7BB6224C26A56C4E00D0E75E /* SwiftUIApp.swift */, 7BB6224E26A56C4E00D0E75E /* ContentView.swift */, @@ -485,7 +488,7 @@ remoteRef = 84D4FEB328ECD52E00EDAAFE /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; - D833D61B2D13216300961E7A /* libSentrySwiftUITests.a */ = { + D833D61B2D13216300961E7A /* SentrySwiftUITests.xctest */ = { isa = PBXReferenceProxy; fileType = wrapper.cfbundle; path = SentrySwiftUITests.xctest; @@ -576,6 +579,7 @@ D85388D12980222500B63908 /* UIKitScreen.swift in Sources */, 7BB6224F26A56C4E00D0E75E /* ContentView.swift in Sources */, 7B5DA9D92859DC850069AD02 /* LoremIpsumView.swift in Sources */, + D4DB99952D678355000AD63F /* DelayedFullDisplayView.swift in Sources */, D832FAF02982A908007A9A5F /* FormScreen.swift in Sources */, 84BA72B52C9369C80045B828 /* GitInjections.swift in Sources */, 7BB6224D26A56C4E00D0E75E /* SwiftUIApp.swift in Sources */, diff --git a/Samples/iOS-SwiftUI/iOS-SwiftUI/ContentView.swift b/Samples/iOS-SwiftUI/iOS-SwiftUI/ContentView.swift index 9a50ff1ea3b..bbcd825e6ee 100644 --- a/Samples/iOS-SwiftUI/iOS-SwiftUI/ContentView.swift +++ b/Samples/iOS-SwiftUI/iOS-SwiftUI/ContentView.swift @@ -231,6 +231,11 @@ struct ContentView: View { NavigationLink(destination: FormScreen()) { Text("Form Screen") } + + NavigationLink(destination: DelayedFullDisplayView()) { + Text("Delayed Full Display") + .accessibilityIdentifier("button.destination.full-display-deplayed") + } } .background(Color.white) } diff --git a/Samples/iOS-SwiftUI/iOS-SwiftUI/DelayedFullDisplayView.swift b/Samples/iOS-SwiftUI/iOS-SwiftUI/DelayedFullDisplayView.swift new file mode 100644 index 00000000000..4365980f079 --- /dev/null +++ b/Samples/iOS-SwiftUI/iOS-SwiftUI/DelayedFullDisplayView.swift @@ -0,0 +1,70 @@ +import SentrySwiftUI +import SwiftUI + +struct DelayedFullDisplayView: View { + + @State private var isDelayedContentVisible = false + + @State private var statusRefreshCounter = 0 + @State private var isTTIDReported = false + @State private var isTTFDReported = false + + var body: some View { + VStack { + SentryTracedView("Content", waitForFullDisplay: true) { + Text("Initial Content") + .accessibilityIdentifier("content.initial") + Button("Show Delayed Content") { + // Cause a custom delay to simulate loading content + // The full content will then report that it is fully displayed + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + isDelayedContentVisible = true + } + } + .accessibilityIdentifier("button.trigger-delayed-content") + if isDelayedContentVisible { + Text("Delayed Content") + .accessibilityIdentifier("content.delayed") + .onAppear { + SentrySDK.reportFullyDisplayed() + } + } else { + ProgressView() + } + } + Spacer() + VStack { + Button("Refresh TTFD / TTID Status") { + guard let tracer = SentrySDK.span as? SentryTracer else { + return + } + // Check if the spans are found in the current tracer + // Afterwards increment the counter so we can definitely tell that the status was refreshed + isTTIDReported = isTTIDSpanFound(tracer: tracer) + isTTFDReported = isTTFDSpanFound(tracer: tracer) + statusRefreshCounter += 1 + } + .accessibilityIdentifier("button.update-ttfd-ttid-status") + Text("Status Refresh Counter: \(statusRefreshCounter)") + .accessibilityIdentifier("label.status-refresh-counter") + Toggle(isOn: $isTTIDReported) { + Text("TTID Reported") + } + .accessibilityIdentifier("check.ttid-reported") + Toggle(isOn: $isTTFDReported) { + Text("TTFD Reported") + } + .accessibilityIdentifier("check.ttfd-reported") + } + .font(.caption) + } + } + + func isTTIDSpanFound(tracer: SentryTracer) -> Bool { + tracer.children.contains { $0.spanDescription?.contains("initial display") == true } == true + } + + func isTTFDSpanFound(tracer: SentryTracer) -> Bool { + tracer.children.contains { $0.spanDescription?.contains("full display") == true } == true + } +} diff --git a/Sources/SentrySwiftUI/SentryTracedView.swift b/Sources/SentrySwiftUI/SentryTracedView.swift index f24da19de17..a8dedadf369 100644 --- a/Sources/SentrySwiftUI/SentryTracedView.swift +++ b/Sources/SentrySwiftUI/SentryTracedView.swift @@ -118,6 +118,18 @@ public struct SentryTracedView: View { let content: () -> Content #if canImport(SwiftUI) && canImport(UIKit) && os(iOS) || os(tvOS) + /// Creates a view that measures the performance of its `content`. + /// + /// - Parameter viewName: The name that will be used for the span, if nil we try to get the name of the content class. + /// - Parameter content: The content that you want to track the performance + public init(_ viewName: String? = nil, @ViewBuilder content: @escaping () -> Content) { + self.content = content + let name = viewName ?? SentryTracedView.extractName(content: Content.self) + let nameSource = viewName == nil ? SentryTransactionNameSource.component : SentryTransactionNameSource.custom + let initialViewModel = SentryTraceViewModel(name: name, nameSource: nameSource, waitForFullDisplay: nil) + _viewModel = State(initialValue: initialViewModel) + } + /// Creates a view that measures the performance of its `content`. /// /// - Parameter viewName: The name that will be used for the span, if nil we try to get the name of the content class. @@ -125,7 +137,9 @@ public struct SentryTracedView: View { /// in case you need to track some asyncronous task. This is ignored for any `SentryTracedView` that is child of another `SentryTracedView`. /// If nil, it will use the `enableTimeToFullDisplayTracing` option from the SDK. /// - Parameter content: The content that you want to track the performance - public init(_ viewName: String? = nil, waitForFullDisplay: Bool? = nil, @ViewBuilder content: @escaping () -> Content) { + /// + /// - Experiment: This initializer is an experimental feature and may still have bugs. + public init(_ viewName: String? = nil, waitForFullDisplay: Bool?, @ViewBuilder content: @escaping () -> Content) { self.content = content let name = viewName ?? SentryTracedView.extractName(content: Content.self) let nameSource = viewName == nil ? SentryTransactionNameSource.component : SentryTransactionNameSource.custom