diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index ec6446d..3f95ac2 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ 46924A3275BFEFD2218AD748 /* SystemPhotoCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */; }; 46985C2741D425D6486849F3 /* PhotoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */; }; 481F1C57B07AF615A3629C7E /* ArchiveSyncRecoveryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */; }; + 48812531C1B7DB7752F06DEF /* TimelineUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */; }; 48BE02EB8AA84E9CD1354E1A /* MediaPlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.swift */; }; 496D99C625D771D6B91D99E7 /* ArchiveSyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */; }; 49DC659401787CF44A33B620 /* LocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */; }; @@ -270,6 +271,13 @@ remoteGlobalIDString = 0565F6C7DA26F0482928704E; remoteInfo = Luma; }; + 5C6218BA484572FDB4727ECA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AACE5AD3943208FFB6B19277 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0565F6C7DA26F0482928704E; + remoteInfo = Luma; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -292,6 +300,7 @@ 03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBubble.swift; sourceTree = ""; }; 05EEEE8A125347F7C5014A09 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 096748EF4DC92889BCAB3260 /* MessageCorrectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCorrectionTests.swift; sourceTree = ""; }; + 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineUITests.swift; sourceTree = ""; }; 0D15DA1B85B68AD2419670C8 /* ArchiveSyncWorkBudget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncWorkBudget.swift; sourceTree = ""; }; 0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMessageBatchPolicyTests.swift; sourceTree = ""; }; 0DE298382FC70ECF1513BD4A /* XMPPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XMPPService.swift; sourceTree = ""; }; @@ -371,6 +380,7 @@ 8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoAttachmentPreview.swift; sourceTree = ""; }; 8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessorVideoTests.swift; sourceTree = ""; }; 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaConnectionStatsModule.swift; sourceTree = ""; }; + 96720381802141EBA1DA9772 /* LumaUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LumaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycle.swift; sourceTree = ""; }; 99BE34DC082D19007241AA62 /* NewGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupView.swift; sourceTree = ""; }; 9A338A8EB30206647BDD922F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -561,6 +571,14 @@ path = Watch; sourceTree = ""; }; + 307BDB7CACD11611A6CCDC4E /* UITests */ = { + isa = PBXGroup; + children = ( + 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */, + ); + path = UITests; + sourceTree = ""; + }; 4D0D58FD94D94B4D409033AF /* Tests */ = { isa = PBXGroup; children = ( @@ -648,6 +666,7 @@ D1D74E43299CD30D89B80F74 /* Resources */, 2A98AB59ED6FB8802C301CF4 /* Sources */, 4D0D58FD94D94B4D409033AF /* Tests */, + 307BDB7CACD11611A6CCDC4E /* UITests */, 90CFB4B53B5F9EF91D718FC2 /* Products */, ); indentWidth = 4; @@ -685,6 +704,7 @@ DF8C531E464C9C4EDE2648C6 /* Luma.app */, EA54340DD785335237158BEF /* LumaMac.app */, 6317FAA29F22A3EA8450208F /* LumaTests.xctest */, + 96720381802141EBA1DA9772 /* LumaUITests.xctest */, 19D4E9404712E8643E31CB93 /* LumaWatch.app */, ); name = Products; @@ -770,6 +790,24 @@ productReference = EA54340DD785335237158BEF /* LumaMac.app */; productType = "com.apple.product-type.application"; }; + A086B6D25E7969CB643F1C8A /* LumaUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 06AF122279B2585E145DB210 /* Build configuration list for PBXNativeTarget "LumaUITests" */; + buildPhases = ( + DE1009798E1797BDAC72B608 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + C675A3F8639BBF6792B09F39 /* PBXTargetDependency */, + ); + name = LumaUITests; + packageProductDependencies = ( + ); + productName = LumaUITests; + productReference = 96720381802141EBA1DA9772 /* LumaUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; BAB78463D7DE07A8F7B3D27D /* LumaWatch */ = { isa = PBXNativeTarget; buildConfigurationList = 5D8E754B3FFEE24B370D350E /* Build configuration list for PBXNativeTarget "LumaWatch" */; @@ -821,6 +859,9 @@ 74A5CD40283A16247F9F36B2 = { DevelopmentTeam = K3AS449A74; }; + A086B6D25E7969CB643F1C8A = { + TestTargetID = 0565F6C7DA26F0482928704E; + }; BAB78463D7DE07A8F7B3D27D = { DevelopmentTeam = K3AS449A74; }; @@ -849,6 +890,7 @@ 74A5CD40283A16247F9F36B2 /* LumaMac */, BAB78463D7DE07A8F7B3D27D /* LumaWatch */, D4CD85C144854C8DA57B3903 /* LumaTests */, + A086B6D25E7969CB643F1C8A /* LumaUITests */, ); }; /* End PBXProject section */ @@ -1145,6 +1187,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DE1009798E1797BDAC72B608 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 48812531C1B7DB7752F06DEF /* TimelineUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -1158,6 +1208,11 @@ target = 0565F6C7DA26F0482928704E /* Luma */; targetProxy = 277A193FE4C68A777257EF0D /* PBXContainerItemProxy */; }; + C675A3F8639BBF6792B09F39 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0565F6C7DA26F0482928704E /* Luma */; + targetProxy = 5C6218BA484572FDB4727ECA /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1266,6 +1321,24 @@ }; name = Release; }; + 64B3F29554E68D692FBF07CD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.uitests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Luma; + }; + name = Release; + }; 6A07A459DB18752AD74A145A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1428,6 +1501,24 @@ }; name = Release; }; + E381B8E38C4B5D9CBB9E6416 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.uitests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Luma; + }; + name = Debug; + }; FEECD67107BB7AE74F3574C8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1464,6 +1555,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 06AF122279B2585E145DB210 /* Build configuration list for PBXNativeTarget "LumaUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E381B8E38C4B5D9CBB9E6416 /* Debug */, + 64B3F29554E68D692FBF07CD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 23A91CAAA509D787E736CA14 /* Build configuration list for PBXNativeTarget "LumaMac" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme b/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme index b7f0e83..f7ce986 100644 --- a/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme +++ b/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme @@ -64,6 +64,17 @@ ReferencedContainer = "container:Luma.xcodeproj"> + + + + diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 8e5b48c..a9550c3 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -87,6 +87,7 @@ required=( Tests/SCRAMSHA512Tests.swift Tests/AppLockPolicyTests.swift Tests/CallHistorySyncTests.swift + UITests/TimelineUITests.swift Sources/Shared/XMPP/LumaScramSha512Mechanism.swift Sources/Shared/XMPP/CallHistorySync.swift Sources/Shared/Security/AppLockVault.swift @@ -389,6 +390,10 @@ grep -q 'let items = pickedMediaItems' Sources/Shared/UI/ChatView.swift || { echo "Gallery staging must run on picker dismissal only" exit 1 } +grep -q 'connectionStatus == .connected' Sources/Shared/Models/AppModel.swift || { + echo "Older-history loads must skip silently while disconnected" + exit 1 +} grep -q 'onDismissRequest' Sources/Shared/UI/SystemPhotoCameraView.swift || { echo "The camera picker must request dismissal before background preparation" exit 1 diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index 3d660ec..638b5b8 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -301,6 +301,9 @@ final class AppModel: ObservableObject { } func bootstrap() async { +#if DEBUG + if applyUITestChatIfRequested() { return } +#endif guard !RuntimeEnvironment.isRunningTests else { return } guard let saved = preferences.load() else { return } prepareStore(for: saved.normalizedJID) @@ -459,6 +462,55 @@ final class AppModel: ObservableObject { } } +#if DEBUG + /// UI-test mode: seeds an in-memory conversation with numbered messages + /// so XCUITests can exercise timeline scrolling and the reply swipe + /// without an XMPP server. Activated with the `-luma-ui-test-chat` + /// launch argument. + private func applyUITestChatIfRequested() -> Bool { + guard ProcessInfo.processInfo.arguments.contains("-luma-ui-test-chat") else { + return false + } + let account = AccountConfiguration( + jid: "uitest@example.org", + displayName: "UI Test", + resource: "uitest" + ) + self.account = account + let conversation = Conversation( + jid: "peer@example.org", + displayName: "uitest-peer", + lastMessage: "Тестовое сообщение номер 60", + lastActivity: Date(), + kind: .direct + ) + modelContext.insert(conversation) + conversations = [conversation] + let base = Date(timeIntervalSince1970: 1_700_000_000) + var seeded: [ChatMessage] = [] + for index in 1...60 { + let outgoing = index.isMultiple(of: 2) + let message = ChatMessage( + id: "uitest-msg-\(index)", + conversationID: conversation.jid, + senderJID: outgoing ? account.normalizedJID : conversation.jid, + body: "Тестовое сообщение номер \(index)", + timestamp: base.addingTimeInterval(TimeInterval(index * 60)), + direction: outgoing ? .outgoing : .incoming, + delivery: .sent, + security: .plaintext, + kind: .text + ) + modelContext.insert(message) + seeded.append(message) + } + messages = seeded + rebuildMessageIndex() + selectedConversationID = conversation.jid + return true + } +#endif + func setApplicationActive(_ active: Bool) { appIsActive = active guard !RuntimeEnvironment.isRunningTests else { return } @@ -654,8 +706,13 @@ final class AppModel: ObservableObject { } func loadOlderHistoryForSelectedConversation() { + // While disconnected the request would only fail with + // "not connected", which used to pop the global alert right over + // the timeline and blocked scrolling. Skip silently instead: the + // trigger re-fires once the connection is back. guard !isLoadingOlderHistory, hasMoreOlderHistory, + connectionStatus == .connected, let conversation = selectedConversation else { return } // let oldestServerID = selectedMessages // .sorted { lhs, rhs in diff --git a/Sources/Shared/Models/MessageReplySwipePolicy.swift b/Sources/Shared/Models/MessageReplySwipePolicy.swift index 31de898..5ae51c2 100644 --- a/Sources/Shared/Models/MessageReplySwipePolicy.swift +++ b/Sources/Shared/Models/MessageReplySwipePolicy.swift @@ -14,10 +14,13 @@ struct MessageReplySwipePolicy { static let followDominance: CGFloat = 1.05 /// Whether this translation can lock the gesture as a horizontal swipe. - /// Only right-to-left (swipe left, negative width) is accepted; the - /// opposite direction is deliberately left to the scroll view. + /// Only right-to-left (swipe left, negative width) is accepted, and the + /// lock is only available early: once the finger has travelled mostly + /// vertically the scroll view owns the touch and the swipe must never + /// activate for the rest of that gesture. static func canLock(_ translation: CGSize) -> Bool { translation.width < 0 + && abs(translation.height) <= 24 && isHorizontal(translation, dominance: lockDominance) && abs(translation.width) >= activationDistance * 0.5 } diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index 9c7ab5d..7c6f0bd 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -628,6 +628,7 @@ struct ChatView: View { .onChange(of: messages) { _, newMessages in rebuildTimelineEntries(from: newMessages) } + .accessibilityIdentifier("chat-timeline") #if os(macOS) .onPreferenceChange(TimelineBottomYPreferenceKey.self) { bottomY in @@ -1851,6 +1852,7 @@ struct ChatView: View { .buttonStyle(.plain) .accessibilityLabel("Отменить ответ") } + .accessibilityIdentifier("reply-banner") // .padding(.horizontal, 12) // .padding(.vertical, 8) // .background( diff --git a/Sources/Shared/UI/Components/MessageBubble.swift b/Sources/Shared/UI/Components/MessageBubble.swift index 43b630a..4a92fb4 100644 --- a/Sources/Shared/UI/Components/MessageBubble.swift +++ b/Sources/Shared/UI/Components/MessageBubble.swift @@ -28,6 +28,7 @@ struct MessageBubble: View { @State private var replySwipeOffset: CGFloat = 0 @State private var replySwipeArmed = false @State private var replySwipeLocked = false + @State private var scrollOwnsGesture = false var body: some View { ZStack { @@ -38,6 +39,7 @@ struct MessageBubble: View { .frame(maxWidth: .infinity) .background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear) .contentShape(Rectangle()) + .accessibilityIdentifier("bubble-\(message.clientID)") .onTapGesture { guard isSelectionMode else { return } onToggleSelection() @@ -55,6 +57,7 @@ struct MessageBubble: View { replySwipeOffset = 0 replySwipeArmed = false replySwipeLocked = false + scrollOwnsGesture = false } .contextMenu { if !isSelectionMode { @@ -178,12 +181,20 @@ struct MessageBubble: View { private var replySwipeGesture: some Gesture { DragGesture(minimumDistance: 20) .onChanged { value in - // Once the gesture is clearly horizontal it locks in, so a - // slightly diagonal finish can neither break the reply swipe - // nor let the scroll view steal it. - if !replySwipeLocked, MessageReplySwipePolicy.canLock(value.translation) { - replySwipeLocked = true + // Arbitration: once the touch is clearly vertical the scroll + // view owns it for good, so a later horizontal arc can never + // move bubbles mid-scroll. A reply swipe locks in only while + // the finger is still moving mostly horizontally. + if !replySwipeLocked, !scrollOwnsGesture { + if MessageReplySwipePolicy.canLock(value.translation) { + replySwipeLocked = true + } else if abs(value.translation.height) > 16, + abs(value.translation.width) < abs(value.translation.height) + { + scrollOwnsGesture = true + } } + guard replySwipeLocked, !scrollOwnsGesture else { return } let offset = MessageReplySwipePolicy.offset( locked: replySwipeLocked, translation: value.translation @@ -212,12 +223,19 @@ struct MessageBubble: View { replySwipeArmed = armed } .onEnded { value in - let shouldReply = MessageReplySwipePolicy.shouldReply( - locked: replySwipeLocked, - translation: value.translation, - predictedEndTranslation: value.predictedEndTranslation - ) + // The lock is a hint, not a requirement: a fast flick may + // deliver no intermediate frame that passes canLock, yet its + // end state is still a clear leftward swipe. scrollOwnsGesture + // alone protects the timeline from reply activation. + let shouldReply = + !scrollOwnsGesture + && MessageReplySwipePolicy.shouldReply( + locked: replySwipeLocked, + translation: value.translation, + predictedEndTranslation: value.predictedEndTranslation + ) replySwipeLocked = false + scrollOwnsGesture = false if replySwipeOffset != 0 || replySwipeArmed { withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) { replySwipeOffset = 0 diff --git a/Tests/MessageReplySwipeTests.swift b/Tests/MessageReplySwipeTests.swift index a758ed8..07bf52d 100644 --- a/Tests/MessageReplySwipeTests.swift +++ b/Tests/MessageReplySwipeTests.swift @@ -107,6 +107,13 @@ final class MessageReplySwipeTests: XCTestCase { XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: 40, height: 4))) } + func testCanLockIsUnavailableOnceTheGestureTurnsVertical() { + // A scroll that arcs left after travelling down must never lock the + // swipe: the vertical travel already exceeds the lock budget. + XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: -30, height: 40))) + XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: -80, height: 60))) + } + func testLockedSwipeToleratesVerticalDrift() { // Once locked the finger may drift: a 1.17 ratio would be rejected // by the 1.6 lock dominance but must still count as a reply swipe. diff --git a/UITests/TimelineUITests.swift b/UITests/TimelineUITests.swift new file mode 100644 index 0000000..b8b43f8 --- /dev/null +++ b/UITests/TimelineUITests.swift @@ -0,0 +1,75 @@ +import XCTest + +final class TimelineUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + private func launchApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-luma-ui-test-chat"] + app.launch() + return app + } + + private func bubble(_ id: String, in app: XCUIApplication) -> XCUIElement { + app.descendants(matching: .any).matching(identifier: "bubble-\(id)").firstMatch + } + + private func openChat(_ app: XCUIApplication) { + let chatRow = app.staticTexts["uitest-peer"] + XCTAssertTrue(chatRow.waitForExistence(timeout: 15)) + chatRow.tap() + XCTAssertTrue( + app.descendants(matching: .any).matching(identifier: "chat-timeline").firstMatch + .waitForExistence(timeout: 15) + ) + XCTAssertTrue(bubble("uitest-msg-60", in: app).waitForExistence(timeout: 15)) + } + + func testTimelineVerticalScrollWorks() throws { + let app = launchApp() + openChat(app) + + for _ in 0..<7 { + app.swipeDown() + } + XCTAssertTrue(bubble("uitest-msg-1", in: app).waitForExistence(timeout: 10)) + + for _ in 0..<9 { + app.swipeUp() + } + XCTAssertTrue(bubble("uitest-msg-60", in: app).waitForExistence(timeout: 10)) + } + + func testReplySwipeShowsPlateAndScrollStillWorks() throws { + let app = launchApp() + openChat(app) + + // Message 60 can sit partially behind the composer, so drag on a + // bubble that is reliably visible and hittable in the bottom area. + let target = bubble("uitest-msg-55", in: app) + XCTAssertTrue(target.waitForExistence(timeout: 10)) + XCTAssertTrue(target.isHittable, "The swipe target must be hittable") + // A controlled right-to-left drag: the stock swipeLeft() flicks too + // fast for the gesture's horizontal lock to engage reliably. + let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)) + start.press(forDuration: 0.15, thenDragTo: start.withOffset(CGVector(dx: -200, dy: 0))) + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "after-drag" + attachment.lifetime = .keepAlways + add(attachment) + XCTAssertTrue( + app.descendants(matching: .any).matching(identifier: "reply-banner").firstMatch + .waitForExistence(timeout: 5), + "A left swipe must open the reply plate" + ) + + app.buttons["Отменить ответ"].tap() + for _ in 0..<7 { + app.swipeDown() + } + XCTAssertTrue(bubble("uitest-msg-1", in: app).waitForExistence(timeout: 10)) + } +} + diff --git a/project.yml b/project.yml index e1a1cae..7894880 100644 --- a/project.yml +++ b/project.yml @@ -188,6 +188,19 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: app.luma.chat.tests GENERATE_INFOPLIST_FILE: true + LumaUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "17.0" + sources: + - path: UITests + dependencies: + - target: Luma + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: app.luma.chat.uitests + GENERATE_INFOPLIST_FILE: true + schemes: Luma: build: @@ -200,6 +213,7 @@ schemes: config: Debug targets: - LumaTests + - LumaUITests archive: config: Release LumaMac: