diff --git a/StreamChatSwiftUITestsAppTests/Pages/PhotoLibrary.swift b/StreamChatSwiftUITestsAppTests/Pages/PhotoLibrary.swift index 3f688c123..569d77d2a 100644 --- a/StreamChatSwiftUITestsAppTests/Pages/PhotoLibrary.swift +++ b/StreamChatSwiftUITestsAppTests/Pages/PhotoLibrary.swift @@ -17,7 +17,14 @@ enum PhotoLibrary { XCTFail("Could not write the video: \(error)", file: file, line: line) return } - requestAddAuthorization() + guard requestAddAuthorization() == .authorized else { + XCTFail( + "Photo library access is denied, reset it with `xcrun simctl privacy reset photos-add io.getstream.iOS.StreamChatSwiftUITestsAppTests.xctrunner`", + file: file, + line: line + ) + return + } let saved = XCTestExpectation(description: "The video is saved to the photo library") nonisolated(unsafe) var saveError: Error? @@ -31,16 +38,20 @@ enum PhotoLibrary { XCTAssertNil(saveError, "Could not save the video: \(String(describing: saveError))", file: file, line: line) } - private static func requestAddAuthorization() { - guard PHPhotoLibrary.authorizationStatus(for: .addOnly) != .authorized else { return } - let authorized = XCTestExpectation(description: "Photo library access is granted") - PHPhotoLibrary.requestAuthorization(for: .addOnly) { _ in authorized.fulfill() } + // CI grants access up front (`allow_photo_library_access` lane), local runs may get the system prompt. + private static func requestAddAuthorization() -> PHAuthorizationStatus { + let status = PHPhotoLibrary.authorizationStatus(for: .addOnly) + guard status == .notDetermined else { return status } + + let answered = XCTestExpectation(description: "The photo library prompt is answered") + PHPhotoLibrary.requestAuthorization(for: .addOnly) { _ in answered.fulfill() } let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") let allowButton = springboard.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Allow'")).firstMatch - if allowButton.waitForExistence(timeout: 5) { + if allowButton.waitForExistence(timeout: 15) { allowButton.tap() } - _ = XCTWaiter.wait(for: [authorized], timeout: 10) + _ = XCTWaiter.wait(for: [answered], timeout: 10) + return PHPhotoLibrary.authorizationStatus(for: .addOnly) } private static func writeVideo(to url: URL) throws { diff --git a/StreamChatSwiftUITestsAppTests/Robots/UserRobot+MessageActions.swift b/StreamChatSwiftUITestsAppTests/Robots/UserRobot+MessageActions.swift index d9376e819..56d88e076 100644 --- a/StreamChatSwiftUITestsAppTests/Robots/UserRobot+MessageActions.swift +++ b/StreamChatSwiftUITestsAppTests/Robots/UserRobot+MessageActions.swift @@ -27,7 +27,8 @@ extension UserRobot { @discardableResult func openContextMenu(forMessageWithText text: String) -> Self { let cell = MessageListPage.cell(withText: text).wait() - attributes.messageBubble(in: cell).waitForHitPoint().press(forDuration: 1) + _ = attributes.messageBubble(in: cell).waitForHitPoint() + longPressMessageBubble(in: cell) return self } diff --git a/StreamChatSwiftUITestsAppTests/Robots/UserRobot+Reactions.swift b/StreamChatSwiftUITestsAppTests/Robots/UserRobot+Reactions.swift index d9d8b9499..9b2954a79 100644 --- a/StreamChatSwiftUITestsAppTests/Robots/UserRobot+Reactions.swift +++ b/StreamChatSwiftUITestsAppTests/Robots/UserRobot+Reactions.swift @@ -23,6 +23,15 @@ extension UserRobot { attributes.reactionButton(in: messageCell).wait().safeTap() return self } + + @discardableResult + func closeReactionAuthors() -> Self { + let grabber = MessageListPage.Reactions.authorsSheetGrabber.wait() + grabber.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + .press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98))) + MessageListPage.Reactions.authors.firstMatch.waitForDisappearance() + return self + } } // MARK: Asserts @@ -47,11 +56,14 @@ extension UserRobot { @discardableResult func assertReactionAuthor( _ authorName: String, + isPresent: Bool = true, file: StaticString = #filePath, line: UInt = #line ) -> Self { - let author = MessageListPage.Reactions.author(authorName).wait() - XCTAssertTrue(author.exists, "Reaction author \(authorName) is not shown", file: file, line: line) + let author = MessageListPage.Reactions.author(authorName) + _ = isPresent ? author.wait() : author.waitForDisappearance() + let errMessage = isPresent ? "Reaction author \(authorName) is not shown" : "Reaction author \(authorName) is shown" + XCTAssertEqual(isPresent, author.exists, errMessage, file: file, line: line) return self } } @@ -65,6 +77,12 @@ extension MessageListPage.Reactions { app.buttons.matching(NSPredicate(format: "label == %@", type.emoji)).firstMatch } + static let currentUserAuthorName = "You" + + static var authors: XCUIElementQuery { app.buttons.matching(identifier: "ReactionAuthorView") } + + static var authorsSheetGrabber: XCUIElement { app.buttons["Sheet Grabber"].firstMatch } + static func author(_ name: String) -> XCUIElement { app.buttons.matching(NSPredicate(format: "identifier == 'ReactionAuthorView' AND label CONTAINS %@", name)).firstMatch } diff --git a/StreamChatSwiftUITestsAppTests/Robots/UserRobot.swift b/StreamChatSwiftUITestsAppTests/Robots/UserRobot.swift index 3b8b54749..5397fc203 100644 --- a/StreamChatSwiftUITestsAppTests/Robots/UserRobot.swift +++ b/StreamChatSwiftUITestsAppTests/Robots/UserRobot.swift @@ -65,11 +65,27 @@ extension UserRobot { @discardableResult func openContextMenu(messageCellIndex: Int = 0) -> Self { let messageCell = messageCell(withIndex: messageCellIndex) - let messageBubble = attributes.messageBubble(in: messageCell) - messageBubble.press(forDuration: 1) + longPressMessageBubble(in: messageCell) return self } + // Before iOS 18 the message's long press isn't high priority, so the top reactions take presses + // within ~15pt of them and open the reactions sheet instead of the message actions. + func longPressMessageBubble(in messageCell: XCUIElement) { + let bubble = attributes.messageBubble(in: messageCell) + let reactions = attributes.reactionButton(in: messageCell) + guard reactions.exists else { + bubble.press(forDuration: 1) + return + } + + let frame = bubble.frame + let y = min(max(frame.midY, reactions.frame.maxY + 30), frame.maxY - 4) + bubble.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: frame.width / 2, dy: y - frame.minY)) + .press(forDuration: 1) + } + @discardableResult func typeText(_ text: String, obtainKeyboardFocus: Bool = true) -> Self { if obtainKeyboardFocus { diff --git a/StreamChatSwiftUITestsAppTests/Tests/BaseTestCase/StreamTestCase.swift b/StreamChatSwiftUITestsAppTests/Tests/BaseTestCase/StreamTestCase.swift index 02135694d..92984ed43 100644 --- a/StreamChatSwiftUITestsAppTests/Tests/BaseTestCase/StreamTestCase.swift +++ b/StreamChatSwiftUITestsAppTests/Tests/BaseTestCase/StreamTestCase.swift @@ -71,13 +71,16 @@ extension StreamTestCase { add(attachment) } + // Without a handler, XCTest dismisses an alert with "Don't Allow", and the denial sticks for later tests. private func alertHandler() { - let title = "Push Notification Alert" + let title = "Permission Alert" _ = addUIInterruptionMonitor(withDescription: title) { (alert: XCUIElement) -> Bool in - let allowButton = alert.buttons["Allow"] - if allowButton.exists { - allowButton.tap() - return true + for label in ["Allow", "Allow Full Access"] { + let allowButton = alert.buttons[label] + if allowButton.exists { + allowButton.tap() + return true + } } return false } diff --git a/StreamChatSwiftUITestsAppTests/Tests/Reactions+Extended_Tests.swift b/StreamChatSwiftUITestsAppTests/Tests/Reactions+Extended_Tests.swift index 13cc5c7b8..ae9d511a5 100644 --- a/StreamChatSwiftUITestsAppTests/Tests/Reactions+Extended_Tests.swift +++ b/StreamChatSwiftUITestsAppTests/Tests/Reactions+Extended_Tests.swift @@ -30,7 +30,7 @@ extension Reactions_Tests { } } - func test_reactionAuthorsSheetIsShown_whenUserTapsOnReaction() { + func test_reactionAuthorsSheetIsShown_whenUserTapsOnReaction() throws { linkToScenario(withId: 12042) let message = "test message" @@ -42,6 +42,11 @@ extension Reactions_Tests { participantRobot.sendMessage(message) userRobot.assertMessage(message) } + AND("user adds the reaction") { + userRobot + .addReaction(type: .wow) + .assertReaction(type: .wow, isPresent: true) + } AND("participant adds the reaction") { participantRobot.addReaction(type: .love) userRobot.assertReaction(type: .love, isPresent: true) @@ -49,9 +54,27 @@ extension Reactions_Tests { WHEN("user taps on the message reaction") { userRobot.tapOnMessageReactions() } - THEN("the reaction authors sheet shows the participant") { - userRobot.assertReactionAuthor(participantRobot.name) + THEN("the reaction authors sheet shows the participant and the user") { + userRobot + .assertReactionAuthor(participantRobot.name) + .assertReactionAuthor(MessageListPage.Reactions.currentUserAuthorName) } + +// Skipped because of https://linear.app/stream/issue/IOS-2191 +// +// WHEN("participant removes the reaction") { +// userRobot.closeReactionAuthors() +// participantRobot.deleteReaction(type: .love) +// userRobot.assertReaction(type: .love, isPresent: false) +// } +// AND("user taps on the message reaction") { +// userRobot.tapOnMessageReactions() +// } +// THEN("the reaction authors sheet shows only the user") { +// userRobot +// .assertReactionAuthor(MessageListPage.Reactions.currentUserAuthorName) +// .assertReactionAuthor(participantRobot.name, isPresent: false) +// } } func test_userAddsReactionWhileOffline() throws { diff --git a/StreamChatSwiftUITestsAppTests/Tests/Reactions_Tests.swift b/StreamChatSwiftUITestsAppTests/Tests/Reactions_Tests.swift index fa6d15792..2de0a1d52 100644 --- a/StreamChatSwiftUITestsAppTests/Tests/Reactions_Tests.swift +++ b/StreamChatSwiftUITestsAppTests/Tests/Reactions_Tests.swift @@ -35,16 +35,36 @@ final class Reactions_Tests: StreamTestCase { WHEN("user sends the message: '\(message)'") { userRobot.sendMessage(message) } + AND("participant adds the reaction") { + participantRobot.addReaction(type: .love) + userRobot.assertReaction(type: .love, isPresent: true) + } AND("user adds the reaction") { userRobot .addReaction(type: .wow) - .waitForNewReaction() + .assertReaction(type: .wow, isPresent: true) } - AND("user removes the reaction") { - userRobot.deleteReaction(type: .wow) + AND("user opens the reaction authors") { + userRobot.tapOnMessageReactions() + } + THEN("the reaction authors show the user and the participant") { + userRobot + .assertReactionAuthor(MessageListPage.Reactions.currentUserAuthorName) + .assertReactionAuthor(participantRobot.name) + } + WHEN("user removes the reaction") { + userRobot + .closeReactionAuthors() + .deleteReaction(type: .wow) } THEN("the reaction is removed") { - userRobot.assertReaction(isPresent: false) + userRobot.assertReaction(type: .wow, isPresent: false) + } + AND("the reaction authors show only the participant") { + userRobot + .tapOnMessageReactions() + .assertReactionAuthor(participantRobot.name) + .assertReactionAuthor(MessageListPage.Reactions.currentUserAuthorName, isPresent: false) } } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 383fd307a..80835ec9f 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -288,6 +288,7 @@ lane :test_e2e_mock do |options| next unless is_check_required(sources: @impact[:e2e][:sources], force_check: @force_check, required_checks: @impact[:e2e][:checks]) start_mock_server + allow_photo_library_access(device: options[:device]) scan_options = { project: xcode_project, @@ -415,6 +416,28 @@ private_lane :build_e2e_coverage_map do |options| ) end +# The test runner saves a video to the photo library and the app's media picker reads it. Granting access up front +# avoids the system prompts, which can be dismissed as "Don't Allow" when they're not answered in time and then +# stay denied for every retry. +private_lane :allow_photo_library_access do |options| + next UI.important('No device is given, photo library access is not granted') unless options[:device] + + name, os_version = options[:device].match(/^(.+?)(?: \(([\d.]+)\))?$/).captures + simulators = FastlaneCore::DeviceManager.simulators('iOS') + simulator = + if os_version + simulators.find { |device| device.name == name && Gem::Version.new(device.os_version) == Gem::Version.new(os_version) } + else + # Same pick as `scan` makes for a device without a version, so both target the same simulator. + Scan::DetectValues.highest_compatible_simulator(simulators, name) + end + UI.user_error!("No simulator matches #{options[:device]}, photo library access cannot be granted") unless simulator + + sh("xcrun simctl bootstatus #{simulator.udid} -b") + sh("xcrun simctl privacy #{simulator.udid} grant photos-add io.getstream.iOS.StreamChatSwiftUITestsAppTests.xctrunner") + sh("xcrun simctl privacy #{simulator.udid} grant photos io.getstream.iOS.StreamChatSwiftUITestsApp") +end + private_lane :all_e2e_tests do xctestrun = Dir.glob(File.expand_path("../#{derived_data_path}/Build/Products/*.xctestrun")).first retrieve_xctest_names(xctestrun: xctestrun).values.flatten