From 3170b09ccbc41c747c0b5234b6bbef680bf69209 Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Thu, 19 Dec 2024 22:48:11 -0900 Subject: [PATCH] test(feedback): validate envelope contents for a feedback envelope in ui test --- .../UserFeedbackUITests.swift | 29 +- .../xcshareddata/xcschemes/iOS-Swift.xcscheme | 29 +- Samples/iOS-Swift/iOS-Swift/AppDelegate.swift | 16 +- .../iOS-Swift/Base.lproj/Main.storyboard | 453 ++++++++++-------- .../iOS-Swift/ExtraViewController.swift | 123 ++++- Sources/Sentry/SentryEnvelope.m | 20 + Sources/Sentry/SentryHttpTransport.m | 6 +- .../Sentry/SentryQueueableRequestManager.m | 5 + .../UserFeedback/SentryUserFeedbackForm.swift | 1 + 9 files changed, 430 insertions(+), 252 deletions(-) diff --git a/Samples/iOS-Swift/iOS-Swift-UITests/UserFeedbackUITests.swift b/Samples/iOS-Swift/iOS-Swift-UITests/UserFeedbackUITests.swift index e40f1671fe2..c41deb96915 100644 --- a/Samples/iOS-Swift/iOS-Swift-UITests/UserFeedbackUITests.swift +++ b/Samples/iOS-Swift/iOS-Swift-UITests/UserFeedbackUITests.swift @@ -111,18 +111,21 @@ class UserFeedbackUITests: BaseUITest { // MARK: Tests validating happy path / successful submission func testSubmitFullyFilledForm() throws { - launchApp(args: ["--io.sentry.feedback.all-defaults"]) + launchApp(args: ["--io.sentry.feedback.all-defaults", "--io.sentry.disable-everything", "--io.sentry.wipe-data", "--io.sentry.base64-attachment-data"]) widgetButton.tap() nameField.tap() - nameField.typeText("Andrew") + let testName = "Andrew" + nameField.typeText(testName) emailField.tap() - emailField.typeText("andrew.mcknight@sentry.io") + let testContactEmail = "andrew.mcknight@sentry.io" + emailField.typeText(testContactEmail) messageTextView.tap() - messageTextView.typeText("UITest user feedback") + let testMessage = "UITest user feedback" + messageTextView.typeText(testMessage) sendButton.tap() @@ -135,10 +138,20 @@ class UserFeedbackUITests: BaseUITest { XCTAssertEqual(try XCTUnwrap(messageTextView.value as? String), "", "The UITextView shouldn't have any initial text functioning as a placeholder; as UITextView has no placeholder property, the \"placeholder\" is a label on top of it.") - // TODO: go to Extras view - app.buttons["io.sentry.ui-test.button.get-feedback-envelope"].tap() - // TODO: pull contents out of text field - // TODO: validate contents + cancelButton.tap() + + app.buttons["Extra"].tap() + app.buttons["io.sentry.ui-test.button.get-latest-envelope"].tap() + let marshaledDataBase64 = try XCTUnwrap(app.textFields["io.sentry.ui-test.text-field.data-marshaling.latest-envelope"].value as? String) + let data = try XCTUnwrap(Data(base64Encoded: marshaledDataBase64)) + let dict = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(try XCTUnwrap(dict["event_type"] as? String), "feedback") + XCTAssertEqual(try XCTUnwrap(dict["message"] as? String), testMessage) + XCTAssertEqual(try XCTUnwrap(dict["contact_email"] as? String), testContactEmail) + XCTAssertEqual(try XCTUnwrap(dict["source"] as? String), "widget") + XCTAssertEqual(try XCTUnwrap(dict["name"] as? String), testName) + XCTAssertNotNil(dict["event_id"]) + XCTAssertEqual(try XCTUnwrap(dict["item_header_type"] as? String), "feedback") } func testSubmitWithOnlyRequiredFieldsFilled() { diff --git a/Samples/iOS-Swift/iOS-Swift.xcodeproj/xcshareddata/xcschemes/iOS-Swift.xcscheme b/Samples/iOS-Swift/iOS-Swift.xcodeproj/xcshareddata/xcschemes/iOS-Swift.xcscheme index 6e1f1501d94..cdd03802f82 100644 --- a/Samples/iOS-Swift/iOS-Swift.xcodeproj/xcshareddata/xcschemes/iOS-Swift.xcscheme +++ b/Samples/iOS-Swift/iOS-Swift.xcodeproj/xcshareddata/xcschemes/iOS-Swift.xcscheme @@ -73,6 +73,30 @@ argument = "--io.sentry.disable-everything" isEnabled = "NO"> + + + + + + + + + + + + @@ -240,11 +264,6 @@ value = "" isEnabled = "NO"> - - Bool { - args.contains("--disable-everything") || args.contains(arg) + args.contains("--io.sentry.disable-everything") || args.contains(arg) } // MARK: features that care about simulator vs device, ui tests and profiling benchmarks @@ -312,7 +312,7 @@ extension AppDelegate { /// - note: the benchmark test starts and stops a custom transaction using a UIButton, and automatic user interaction tracing stops the transaction that begins with that button press after the idle timeout elapses, stopping the profiler (only one profiler runs regardless of the number of concurrent transactions) var enableUITracing: Bool { !isBenchmarking && !checkDisabled(with: "--disable-ui-tracing") } - var enablePrewarmedAppStartTracing: Bool { !isBenchmarking } + var enablePrewarmedAppStartTracing: Bool { !isBenchmarking && !checkDisabled(with: "--disable-prewarmed-app-start-tracing") } var enablePerformanceTracing: Bool { !isBenchmarking && !checkDisabled(with: "--disable-auto-performance-tracing") } var enableTracing: Bool { !isBenchmarking && !checkDisabled(with: "--disable-tracing") } /// - note: UI tests generate false OOMs @@ -322,6 +322,10 @@ extension AppDelegate { // MARK: Other features + var enableTimeToFullDisplayTracing: Bool { !checkDisabled(with: "--disable-time-to-full-display-tracing")} + var enableAttachScreenshot: Bool { !checkDisabled(with: "--disable-attach-screenshot")} + var enableAttachViewHierarchy: Bool { !checkDisabled(with: "--disable-attach-view-hierarchy")} + var enablePerformanceV2: Bool { !checkDisabled(with: "--disable-performance-v2")} var enableSessionReplay: Bool { !checkDisabled(with: "--disable-session-replay") } var enableMetricKit: Bool { !checkDisabled(with: "--disable-metrickit-integration") } var enableSessionTracking: Bool { !checkDisabled(with: "--disable-automatic-session-tracking") } diff --git a/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard b/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard index 692fe31a42c..9d20ad7e705 100644 --- a/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard +++ b/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard @@ -899,231 +899,267 @@ - - + + - - + + - - + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + - + - + + - - - + + @@ -1131,7 +1167,10 @@ + + + diff --git a/Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift b/Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift index dc839476a51..29955471085 100644 --- a/Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift @@ -9,7 +9,10 @@ class ExtraViewController: UIViewController { @IBOutlet weak var uiTestNameLabel: UILabel! @IBOutlet weak var anrFullyBlockingButton: UIButton! @IBOutlet weak var anrFillingRunLoopButton: UIButton! - + @IBOutlet weak var envelopeDataMarshalingField: UITextField! + @IBOutlet weak var dataMarshalingStatusLabel: UILabel! + @IBOutlet weak var dataMarshalingErrorLabel: UILabel! + @IBOutlet weak var dsnView: UIView! private let dispatchQueue = DispatchQueue(label: "ExtraViewControllers", attributes: .concurrent) @@ -17,6 +20,7 @@ class ExtraViewController: UIViewController { super.viewDidLoad() if let uiTestName = ProcessInfo.processInfo.environment["--io.sentry.ui-test.test-name"] { uiTestNameLabel.text = uiTestName + uiTestNameLabel.isHidden = false } Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in @@ -186,30 +190,99 @@ class ExtraViewController: UIViewController { return pi } - // copied from ProfilingViewController.withProfile - @IBAction func getAllEnvelopes(_ sender: Any) { - let cachesDirectory = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first! + enum EnvelopeContent { + case image(Data) + case rawText(String) + case json([String: Any]) + } + + func displayError(message: String) { + dataMarshalingStatusLabel.isHidden = false + dataMarshalingStatusLabel.text = "❌" + dataMarshalingErrorLabel.isHidden = false + dataMarshalingErrorLabel.text = message + print("[iOS-Swift] \(message)") + } + + @IBAction func getLatestEnvelope(_ sender: Any) { + guard let latestEnvelopePath = latestEnvelopePath() else { return } + guard let base64String = base64EncodedStructuredUITestData(envelopePath: latestEnvelopePath) else { return } + envelopeDataMarshalingField.text = base64String + envelopeDataMarshalingField.isHidden = false + dataMarshalingStatusLabel.isHidden = false + dataMarshalingStatusLabel.text = "✅" + dataMarshalingErrorLabel.isHidden = true + } + + func latestEnvelopePath() -> String? { + guard let cachesDirectory = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first else { + displayError(message: "No user caches directory found on device.") + return nil + } let fm = FileManager.default - let dir = "\(cachesDirectory)/io.sentry/profiles" - let count = try! fm.contentsOfDirectory(atPath: dir).count - //swiftlint:disable empty_count -// guard first || count > 0 else { -// //swiftlint:enable empty_count -// profilingUITestDataMarshalingTextField.text = "" -// return -// } -// let fileName = "profile\(first ? 0 : count - 1)" -// let fullPath = "\(dir)/\(fileName)" -// -// if fm.fileExists(atPath: fullPath) { -// let url = NSURL.fileURL(withPath: fullPath) -// block(url) -// do { -// try fm.removeItem(atPath: fullPath) -// } catch { -// SentrySDK.capture(error: error) -// } -// return -// } + guard let dsnHash = try? SentryDsn(string: AppDelegate.defaultDSN).getHash() else { + displayError(message: "Couldn't compute DSN hash.") + return nil + } + let dir = "\(cachesDirectory)/io.sentry/\(dsnHash)/envelopes" + guard let contents = try? fm.contentsOfDirectory(atPath: dir) else { + displayError(message: "\(dir) has no contents.") + return nil + } + guard let latest = contents.compactMap({ path -> (String, Date)? in + guard let attr = try? fm.attributesOfItem(atPath: "\(dir)/\(path)"), let date = attr[FileAttributeKey.modificationDate] as? Date else { + return nil + } + return (path, date) + }).sorted(by: { a, b in + return a.1.compare(b.1) == .orderedAscending + }).last else { + displayError(message: "Could not find any envelopes in \(dir).") + return nil + } + return "\(dir)/\(latest.0)" + } + + func base64EncodedStructuredUITestData(envelopePath: String) -> String? { + guard let envelopeFileContents = try? String(contentsOfFile: envelopePath) else { + displayError(message: "\(envelopePath) had no contents.") + return nil + } + let parsedEnvelopeContents = envelopeFileContents.split(separator: "\n").map { line in + if let imageData = Data(base64Encoded: String(line), options: []) { + return EnvelopeContent.image(imageData) + } else if let data = line.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return EnvelopeContent.json(json) + } else { + return EnvelopeContent.rawText(String(line)) + } + } + let contentsForUITest = parsedEnvelopeContents.reduce(into: [String: Any]()) { result, item in + if case let .json(json) = item { + insertValues(from: json, into: &result) + } + } + guard let data = try? JSONSerialization.data(withJSONObject: contentsForUITest) else { + displayError(message: "Couldn't serialize marshaling dictionary.") + return nil + } + + return data.base64EncodedString() + } + + func insertValues(from json: [String: Any], into result: inout [String: Any]) { + if let eventContexts = json["contexts"] as? [String: Any] { + result["event_type"] = json["type"] + if let feedback = eventContexts["feedback"] as? [String: Any] { + result["message"] = feedback["message"] + result["contact_email"] = feedback["contact_email"] + result["source"] = feedback["source"] + result["name"] = feedback["name"] + } + } else if let itemHeaderEventId = json["event_id"] { + result["event_id"] = itemHeaderEventId + } else if let _ = json["length"], let type = json["type"] as? String, type == "feedback" { + result["item_header_type"] = json["type"] + } } } diff --git a/Sources/Sentry/SentryEnvelope.m b/Sources/Sentry/SentryEnvelope.m index a9dfec89546..9b0f6f52818 100644 --- a/Sources/Sentry/SentryEnvelope.m +++ b/Sources/Sentry/SentryEnvelope.m @@ -161,7 +161,17 @@ - (_Nullable instancetype)initWithAttachment:(SentryAttachment *)attachment return nil; } +#if DEBUG || TEST || TESTCI + if ([NSProcessInfo.processInfo.arguments + containsObject:@"--io.sentry.base64-attachment-data"]) { + data = [[attachment.data base64EncodedStringWithOptions:0] + dataUsingEncoding:NSUTF8StringEncoding]; + } else { + data = attachment.data; + } +#else data = attachment.data; +#endif // DEBUG || TEST || TESTCI } else if (nil != attachment.path) { NSError *error = nil; @@ -186,7 +196,17 @@ - (_Nullable instancetype)initWithAttachment:(SentryAttachment *)attachment return nil; } +#if DEBUG || TEST || TESTCI + if ([NSProcessInfo.processInfo.arguments + containsObject:@"--io.sentry.base64-attachment-data"]) { + data = [[[[NSFileManager defaultManager] contentsAtPath:attachment.path] + base64EncodedStringWithOptions:0] dataUsingEncoding:NSUTF8StringEncoding]; + } else { + data = [[NSFileManager defaultManager] contentsAtPath:attachment.path]; + } +#else data = [[NSFileManager defaultManager] contentsAtPath:attachment.path]; +#endif // DEBUG || TEST || TESTCI } if (data == nil) { diff --git a/Sources/Sentry/SentryHttpTransport.m b/Sources/Sentry/SentryHttpTransport.m index ae71cf89dc4..72c13ca8baf 100644 --- a/Sources/Sentry/SentryHttpTransport.m +++ b/Sources/Sentry/SentryHttpTransport.m @@ -289,10 +289,14 @@ - (void)sendAllCachedEnvelopes SENTRY_LOG_DEBUG(@"sendAllCachedEnvelopes start."); @synchronized(self) { - if (self.isSending || ![self.requestManager isReady]) { + if (self.isSending) { SENTRY_LOG_DEBUG(@"Already sending."); return; } + if (![self.requestManager isReady]) { + SENTRY_LOG_DEBUG(@"Request manager not ready."); + return; + } self.isSending = YES; } diff --git a/Sources/Sentry/SentryQueueableRequestManager.m b/Sources/Sentry/SentryQueueableRequestManager.m index 985194c2a9c..d70a95d7da4 100644 --- a/Sources/Sentry/SentryQueueableRequestManager.m +++ b/Sources/Sentry/SentryQueueableRequestManager.m @@ -34,6 +34,11 @@ - (BOOL)isReady isEqualToString:@"ui-tests"]) { return NO; } +#elif DEBUG + if ([NSProcessInfo.processInfo.arguments + containsObject:@"--io.sentry.disable-http-transport"]) { + return NO; + } #endif // TEST || TESTCI // We always have at least one operation in the queue when calling this diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackForm.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackForm.swift index 4891586afc3..26ca060cbaf 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackForm.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackForm.swift @@ -135,6 +135,7 @@ class SentryUserFeedbackForm: UIViewController { } let feedback = SentryFeedback(message: messageTextView.text, name: fullNameTextField.text, email: emailTextField.text, screenshot: screenshotImageView.image?.pngData()) + SentryLog.log(message: "Sending user feedback", andLevel: .debug) SentrySDK.capture(feedback: feedback) delegate?.finished() }