From edc06bf66281f915ee4a96f8d3fba798082beb9e Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Tue, 10 Jan 2023 14:10:30 +0000 Subject: [PATCH] Fix UI tests hanging. (#429) * Only use Client for UI test signalling. Make the server listen and return a client. * Refactor to Listener and Connection. Matches Network.framework naming. * Wait until the connection is ready before setting the established connection. * Docs and fix method name. * Fix CI issues. - Use Bonjour to allow multiple devices running simultaneously. - Sleep after establishing the connection to fix issues on Intel. --- ElementX.xcodeproj/project.pbxproj | 18 +- .../MockRoomTimelineController.swift | 18 +- .../Sources/UITests/UITestSignalling.swift | 191 ---------------- .../Sources/UITests/UITestsSignalling.swift | 213 ++++++++++++++++++ UITests/Sources/RoomScreenUITests.swift | 55 +++-- 5 files changed, 261 insertions(+), 234 deletions(-) delete mode 100644 ElementX/Sources/UITests/UITestSignalling.swift create mode 100644 ElementX/Sources/UITests/UITestsSignalling.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index ace645bb6..9bc44cf0f 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -175,7 +175,7 @@ 5B8B51CEC4717AF487794685 /* NotificationServiceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B490675B8E31423AF116BDA /* NotificationServiceProxy.swift */; }; 5C02841B2A86327B2C377682 /* NotificationConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C830A64609CBD152F06E0457 /* NotificationConstants.swift */; }; 5C8AFBF168A41E20835F3B86 /* LoginScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB34B0C74CD242FED9DD069 /* LoginScreenUITests.swift */; }; - 5D1C6D4E3583700DF7A64834 /* UITestSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = D619626288AAB513B7620BB6 /* UITestSignalling.swift */; }; + 5D1C6D4E3583700DF7A64834 /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = D619626288AAB513B7620BB6 /* UITestsSignalling.swift */; }; 5D2AF8C0DF872E7985F8FE54 /* TimelineDeliveryStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AC06FC11B6638F7BF1670E /* TimelineDeliveryStatusView.swift */; }; 5D430CDE11EAC3E8E6B80A66 /* RoomTimelineViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEE631F3A4AFDC6652DD9DA /* RoomTimelineViewFactory.swift */; }; 5D70FAE4D2BF4553AFFFFE41 /* NotificationItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F7FE40EF7490A7E09D7BE6 /* NotificationItemProxy.swift */; }; @@ -467,7 +467,7 @@ F6E860FF7B18B81DF43B30B8 /* EncryptedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FA7C8D4EF2B1873C180ED7 /* EncryptedRoomTimelineItem.swift */; }; F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */; }; F7567DD6635434E8C563BF85 /* AnalyticsClientProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */; }; - F98333D53D85B0E029FDCA12 /* UITestSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = D619626288AAB513B7620BB6 /* UITestSignalling.swift */; }; + F98333D53D85B0E029FDCA12 /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = D619626288AAB513B7620BB6 /* UITestsSignalling.swift */; }; F9981191DC408AED537C1749 /* MediaProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12C9E0B61A77C7F0EE7918C /* MediaProxy.swift */; }; F99FB21EFC6D99D247FE7CBE /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DE8DC9B3FBA402117DC4C49F /* Kingfisher */; }; F9F6D2883BBEBB9A3789A137 /* OnboardingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00A941F289F6AB876BA3361A /* OnboardingViewModelTests.swift */; }; @@ -811,7 +811,7 @@ 8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = ""; }; 8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = ""; }; - 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = ""; }; + 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = ""; }; 8ED2D2F6A137A95EA50413BE /* UserNotificationControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationControllerProtocol.swift; sourceTree = ""; }; 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = ""; }; 8FC26871038FB0E4AAE22605 /* apple_emojis_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = apple_emojis_data.json; sourceTree = ""; }; @@ -977,7 +977,7 @@ D3D455BC2423D911A62ACFB2 /* NSELogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSELogger.swift; sourceTree = ""; }; D4DA544B2520BFA65D6DB4BB /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; D5AC06FC11B6638F7BF1670E /* TimelineDeliveryStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineDeliveryStatusView.swift; sourceTree = ""; }; - D619626288AAB513B7620BB6 /* UITestSignalling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestSignalling.swift; sourceTree = ""; }; + D619626288AAB513B7620BB6 /* UITestsSignalling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsSignalling.swift; sourceTree = ""; }; D653265D006E708E4E51AD64 /* HomeScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenCoordinator.swift; sourceTree = ""; }; D67CBAFA48ED0B6FCE74F88F /* lt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lt; path = lt.lproj/Localizable.strings; sourceTree = ""; }; D6CA5F386C7701C129398945 /* AuthenticationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationCoordinator.swift; sourceTree = ""; }; @@ -1027,7 +1027,7 @@ EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = ""; }; ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = ""; }; ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = ""; }; - ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = ""; }; + ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = message.caf; sourceTree = ""; }; EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = ""; }; EDB6E40BAD4504D899FAAC9A /* TemplateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateViewModel.swift; sourceTree = ""; }; EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; @@ -1277,7 +1277,7 @@ children = ( 46C208DA43CE25D13E670F40 /* UITestsAppCoordinator.swift */, CC6FE34A0A47D010BBB4D4D4 /* UITestScreenIdentifier.swift */, - D619626288AAB513B7620BB6 /* UITestSignalling.swift */, + D619626288AAB513B7620BB6 /* UITestsSignalling.swift */, D751BB69BB7C38FD247517B4 /* UITestsRootCoordinator.swift */, ); path = UITests; @@ -3228,7 +3228,7 @@ 706F79A39BDB32F592B8C2C7 /* UIKitBackgroundTask.swift in Sources */, 3097A0A867D2B19CE32DAE58 /* UIKitBackgroundTaskService.swift in Sources */, D05A193AE63030F2CFCE2E9C /* UITestScreenIdentifier.swift in Sources */, - 5D1C6D4E3583700DF7A64834 /* UITestSignalling.swift in Sources */, + 5D1C6D4E3583700DF7A64834 /* UITestsSignalling.swift in Sources */, E96005321849DBD7C72A28F2 /* UITestsAppCoordinator.swift in Sources */, 086C2FA7750378EB2BFD0BEE /* UITestsRootCoordinator.swift in Sources */, 071A017E415AD378F2961B11 /* URL.swift in Sources */, @@ -3283,7 +3283,7 @@ B3357B00F1AA930E54F76609 /* Strings.swift in Sources */, C4180F418235DAD9DD173951 /* TemplateScreenUITests.swift in Sources */, 9A47B7EFE3793760EEF68FFE /* UITestScreenIdentifier.swift in Sources */, - F98333D53D85B0E029FDCA12 /* UITestSignalling.swift in Sources */, + F98333D53D85B0E029FDCA12 /* UITestsSignalling.swift in Sources */, B22D857D1E8FCA6DD74A58E3 /* UserSessionScreenTests.swift in Sources */, 588411C8FD72B2A2DFE5F7DE /* XCUIElement.swift in Sources */, ); diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift index 4fc6ec772..88ceca810 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift @@ -32,11 +32,11 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { var timelineItems: [RoomTimelineItemProtocol] = RoomTimelineItemFixtures.default - private var client: UITestsSignalling.Client? + private var connection: UITestsSignalling.Connection? init(listenForSignals: Bool = false) { if listenForSignals { - client = .init() + connection = .init() Task { try await startListening() } } } @@ -85,15 +85,15 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { /// Allows the simulation of server responses by listening for signals from UI tests. private func startListening() async throws { - try await client?.connect() + try await connection?.connect() Task { - while let client { + while let connection { do { - try await handleSignal(client.receive()) + try await handleSignal(connection.receive()) } catch { - client.disconnect() - self.client = nil + connection.disconnect() + self.connection = nil } } } @@ -119,7 +119,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { timelineItems.append(incomingItem) callbacks.send(.updatedTimelineItems) - try? await client?.send(.success) + try? await connection?.send(.success) } /// Prepends the next chunk of items to the `timelineItems` array. @@ -131,6 +131,6 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { callbacks.send(.updatedTimelineItems) callbacks.send(.finishedBackPaginating) - try? await client?.send(.success) + try? await connection?.send(.success) } } diff --git a/ElementX/Sources/UITests/UITestSignalling.swift b/ElementX/Sources/UITests/UITestSignalling.swift deleted file mode 100644 index 746f1a224..000000000 --- a/ElementX/Sources/UITests/UITestSignalling.swift +++ /dev/null @@ -1,191 +0,0 @@ -// -// Copyright 2022 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation -import Network - -enum UITestsSignal: String { - /// An internal signal used to bring up the connection. - case connect - /// Ask the app to back paginate. - case paginate - /// Ask the app to simulate an incoming message. - case incomingMessage - /// The operation has completed successfully. - case success -} - -enum UITestsSignalError: Error { - /// An unknown error occurred. - case unknown - /// Signalling could not be used as is hasn't been enabled. - case disabled - /// The connection was cancelled. - case cancelled - /// The connection hasn't been established. - case notConnected - /// Attempted to receive multiple signals at once. - case awaitingAnotherSignal - /// A network error occurred. - case nwError(NWError) - /// An unexpected signal was received. This error isn't used internally. - case unexpected -} - -enum UITestsSignalling { - /// A UDP server that can be used for signalling between the UI tests jig and the app. - /// The server should be instantiated on the UI tests side. - class Server: UITestsSignallingProtocol { - private let listener: NWListener - - var connection: NWConnection - var nextMessageContinuation: CheckedContinuation? - - /// Creates a new signalling server. - init() throws { - connection = NWConnection(host: "127.0.0.1", port: 0, using: .udp) - listener = try NWListener(using: .udp, on: 1234) - } - - /// Listens for a client and attempts to negotiate a connection when one is discovered. - func connect() async throws { - guard listener.state == .setup else { return } - - return try await withCheckedThrowingContinuation { [weak self] continuation in - listener.newConnectionHandler = { connection in - connection.start(queue: .main) - connection.stateUpdateHandler = { state in - switch state { - case .ready: - self?.listener.cancel() - self?.receiveNextMessage() - continuation.resume() - case .failed(let error): - self?.listener.cancel() - continuation.resume(with: .failure(error)) - default: - break - } - } - self?.connection = connection - } - listener.start(queue: .main) - } - } - - /// Stops the connection (or the listener if a connection hasn't been established). - func disconnect() { - listener.cancel() - connection.cancel() - if let nextMessageContinuation { - nextMessageContinuation.resume(throwing: UITestsSignalError.cancelled) - self.nextMessageContinuation = nil - } - } - } - - /// A UDP client that can be used for signalling between the app and the UI tests jig. - /// The client should be instantiated on the app side. - class Client: UITestsSignallingProtocol { - let connection: NWConnection - var nextMessageContinuation: CheckedContinuation? - - /// Creates a new signalling client. - init() { - connection = NWConnection(host: "127.0.0.1", port: 1234, using: .udp) - } - - /// Attempts to connect to a server. - func connect() async throws { - guard connection.state == .setup else { return } - - return try await withCheckedThrowingContinuation { continuation in - connection.start(queue: .main) - connection.stateUpdateHandler = { state in - switch state { - case .ready: - self.receiveNextMessage() - continuation.resume() - Task { try await self.send(.connect) } - case .failed(let error): - continuation.resume(with: .failure(error)) - default: - break - } - } - } - } - - /// Stops the connection. - func disconnect() { - connection.cancel() - if let nextMessageContinuation { - nextMessageContinuation.resume(throwing: UITestsSignalError.cancelled) - self.nextMessageContinuation = nil - } - } - } -} - -/// A shared implementation for sending/receiving signals between a client and a server. -protocol UITestsSignallingProtocol: AnyObject { - var connection: NWConnection { get } - var nextMessageContinuation: CheckedContinuation? { get set } -} - -extension UITestsSignallingProtocol { - /// Sends a message to the connected client/server. - func send(_ signal: UITestsSignal) async throws { - guard connection.state == .ready else { throw UITestsSignalError.notConnected } - let data = signal.rawValue.data(using: .utf8) - connection.send(content: data, completion: .idempotent) - } - - /// Returns the next message received by the client/server. - func receive() async throws -> UITestsSignal { - guard connection.state == .ready else { throw UITestsSignalError.notConnected } - guard nextMessageContinuation == nil else { throw UITestsSignalError.awaitingAnotherSignal } - - return try await withCheckedThrowingContinuation { continuation in - self.nextMessageContinuation = continuation - } - } - - /// Processes the next message received by the client/server - fileprivate func receiveNextMessage() { - connection.receiveMessage { [weak self] completeContent, _, isComplete, error in - guard let self else { return } - guard isComplete else { fatalError("Partial messages not supported") } - - guard let completeContent, - let message = String(data: completeContent, encoding: .utf8), - let signal = UITestsSignal(rawValue: message) - else { - let error: UITestsSignalError = error.map { .nwError($0) } ?? .unknown - self.nextMessageContinuation?.resume(with: .failure(error)) - self.nextMessageContinuation = nil - return - } - - if signal != .connect { - self.nextMessageContinuation?.resume(returning: signal) - self.nextMessageContinuation = nil - } - - self.receiveNextMessage() - } - } -} diff --git a/ElementX/Sources/UITests/UITestsSignalling.swift b/ElementX/Sources/UITests/UITestsSignalling.swift new file mode 100644 index 000000000..3e7ec2240 --- /dev/null +++ b/ElementX/Sources/UITests/UITestsSignalling.swift @@ -0,0 +1,213 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Network +import SwiftUI + +enum UITestsSignal: String { + /// An internal signal used to bring up the connection. + case connect + /// Ask the app to back paginate. + case paginate + /// Ask the app to simulate an incoming message. + case incomingMessage + /// The operation has completed successfully. + case success +} + +enum UITestsSignalError: Error { + /// An unknown error occurred. + case unknown + /// Signalling could not be used as is hasn't been enabled. + case disabled + /// The connection was cancelled. + case cancelled + /// The connection hasn't been established. + case notConnected + /// Attempted to receive multiple signals at once. + case awaitingAnotherSignal + /// A network error occurred. + case nwError(NWError) + /// An unexpected signal was received. This error isn't used internally. + case unexpected +} + +enum UITestsSignalling { + /// The Bonjour service name used for the connection. The device name + /// is included to allow UI tests to run on multiple devices simultaneously. + private static let serviceName = "UITestsSignalling \(UIDevice.current.name)" + /// The Bonjour service type used for the connection. + private static let serviceType = "_signalling._udp." + /// The Bonjour domain used for the connection. + private static let domain = "local." + /// The DispatchQueue used for networking. + private static let queue: DispatchQueue = .main + + /// A network listener that can be used in the UI tests runner to create a two-way `Connection` with the app. + class Listener { + /// The underlying network listener. + private let listener: NWListener + + /// The established connection. This is stored in case the connection is established + /// before `connection()` is awaited and so the continuation is still `nil`. + private var establishedConnection: Connection? + /// The continuation to call when a connection is established. + private var connectionContinuation: CheckedContinuation? + + /// Creates a new signalling `Listener` and starts listening. + init() throws { + let service = NWListener.Service(name: UITestsSignalling.serviceName, type: UITestsSignalling.serviceType, domain: UITestsSignalling.domain) + listener = try NWListener(service: service, using: .udp) + listener.newConnectionHandler = { [weak self] nwConnection in + let connection = Connection(nwConnection: nwConnection) + nwConnection.start(queue: UITestsSignalling.queue) + nwConnection.stateUpdateHandler = { state in + switch state { + case .ready: + connection.receiveNextMessage() + self?.establishedConnection = connection + self?.connectionContinuation?.resume(returning: connection) + case .failed(let error): + self?.connectionContinuation?.resume(with: .failure(error)) + default: + break + } + } + self?.listener.cancel() // Stop listening for connections when one is discovered. + } + listener.start(queue: UITestsSignalling.queue) + } + + /// Returns the negotiated `Connection` as and when it has been established. + func connection() async throws -> Connection { + guard listener.state == .setup else { throw UITestsSignalError.unknown } + if let establishedConnection { + return establishedConnection + } + return try await withCheckedThrowingContinuation { [weak self] continuation in + self?.connectionContinuation = continuation + } + } + + /// Stops the listening when a connection hasn't been established. + func cancel() { + listener.cancel() + if let connectionContinuation { + connectionContinuation.resume(throwing: UITestsSignalError.cancelled) + self.connectionContinuation = nil + } + } + } + + /// A two-way UDP connection that can be used for signalling between the app and the UI tests runner. + /// The connection should be created as follows: + /// - Create a `Listener` in the UI tests before launching the app. This will automatically start listening for a connection. + /// - With in the App, create a `Connection` and call `connect()` to establish a connection. + /// - Await the `connection()` on the `Listener` when you need to send the signal. + /// - The two `Connection` objects can now be used for two-way signalling. + class Connection { + /// The underlying network connection. + private let connection: NWConnection + /// A continuation to call each time a signal is received. + private var nextMessageContinuation: CheckedContinuation? + + /// Creates a new signalling `Connection`. + init() { + let endpoint = NWEndpoint.service(name: UITestsSignalling.serviceName, + type: UITestsSignalling.serviceType, + domain: UITestsSignalling.domain, + interface: nil) + connection = NWConnection(to: endpoint, using: .udp) + } + + /// Creates a new signalling `Connection` from an established `NWConnection`. + fileprivate init(nwConnection: NWConnection) { + connection = nwConnection + } + + /// Attempts to establish a connection with a `Listener`. + func connect() async throws { + guard connection.state == .setup else { return } + + return try await withCheckedThrowingContinuation { continuation in + connection.start(queue: UITestsSignalling.queue) + connection.stateUpdateHandler = { state in + switch state { + case .ready: + self.receiveNextMessage() + continuation.resume() + Task { try await self.send(.connect) } + case .failed(let error): + continuation.resume(with: .failure(error)) + default: + break + } + } + } + } + + /// Stops the connection. + func disconnect() { + connection.cancel() + if let nextMessageContinuation { + nextMessageContinuation.resume(throwing: UITestsSignalError.cancelled) + self.nextMessageContinuation = nil + } + } + + /// Sends a message to the other side of the connection. + func send(_ signal: UITestsSignal) async throws { + guard connection.state == .ready else { throw UITestsSignalError.notConnected } + let data = signal.rawValue.data(using: .utf8) + connection.send(content: data, completion: .idempotent) + } + + /// Returns the next message received from the other side of the connection. + func receive() async throws -> UITestsSignal { + guard connection.state == .ready else { throw UITestsSignalError.notConnected } + guard nextMessageContinuation == nil else { throw UITestsSignalError.awaitingAnotherSignal } + + return try await withCheckedThrowingContinuation { continuation in + self.nextMessageContinuation = continuation + } + } + + /// Processes the next message received by the connection. + fileprivate func receiveNextMessage() { + connection.receiveMessage { [weak self] completeContent, _, isComplete, error in + guard let self else { return } + guard isComplete else { fatalError("Partial messages not supported") } + + guard let completeContent, + let message = String(data: completeContent, encoding: .utf8), + let signal = UITestsSignal(rawValue: message) + else { + let error: UITestsSignalError = error.map { .nwError($0) } ?? .unknown + self.nextMessageContinuation?.resume(with: .failure(error)) + self.nextMessageContinuation = nil + return + } + + if signal != .connect { + self.nextMessageContinuation?.resume(returning: signal) + self.nextMessageContinuation = nil + } + + self.receiveNextMessage() + } + } + } +} diff --git a/UITests/Sources/RoomScreenUITests.swift b/UITests/Sources/RoomScreenUITests.swift index 75441af2b..03186ef87 100644 --- a/UITests/Sources/RoomScreenUITests.swift +++ b/UITests/Sources/RoomScreenUITests.swift @@ -48,46 +48,49 @@ class RoomScreenUITests: XCTestCase { } func testSmallTimelineWithIncomingAndPagination() async throws { - let server = try UITestsSignalling.Server() + let listener = try UITestsSignalling.Listener() let app = Application.launch() app.goToScreenWithIdentifier(.roomSmallTimelineIncomingAndSmallPagination) - try await server.connect() - defer { server.disconnect() } + let connection = try await listener.connection() + try await Task.sleep(for: .seconds(1)) // Allow the connection to settle on CI/Intel... + defer { connection.disconnect() } // When a back pagination occurs and an incoming message arrives. - try await performOperation(.incomingMessage, using: server) - try await performOperation(.paginate, using: server) + try await performOperation(.incomingMessage, using: connection) + try await performOperation(.paginate, using: connection) // Then the 4 visible messages should stay aligned to the bottom. app.assertScreenshot(.roomSmallTimelineIncomingAndSmallPagination) } func testSmallTimelineWithLargePagination() async throws { - let server = try UITestsSignalling.Server() + let listener = try UITestsSignalling.Listener() let app = Application.launch() app.goToScreenWithIdentifier(.roomSmallTimelineLargePagination) - try await server.connect() - defer { server.disconnect() } + let connection = try await listener.connection() + try await Task.sleep(for: .seconds(1)) // Allow the connection to settle on CI/Intel... + defer { connection.disconnect() } // When a large back pagination occurs. - try await performOperation(.paginate, using: server) + try await performOperation(.paginate, using: connection) // The bottom of the timeline should remain visible with more items added above. app.assertScreenshot(.roomSmallTimelineLargePagination) } func testTimelineLayoutInMiddle() async throws { - let server = try UITestsSignalling.Server() + let listener = try UITestsSignalling.Listener() let app = Application.launch() app.goToScreenWithIdentifier(.roomLayoutMiddle) - try await server.connect() - defer { server.disconnect() } + let connection = try await listener.connection() + try await Task.sleep(for: .seconds(1)) // Allow the connection to settle on CI/Intel... + defer { connection.disconnect() } // Given a timeline that is neither at the top nor the bottom. app.tables.element.swipeDown() @@ -95,13 +98,13 @@ class RoomScreenUITests: XCTestCase { app.assertScreenshot(.roomLayoutMiddle, step: 0) // Assert initial state for comparison. // When a back pagination occurs. - try await performOperation(.paginate, using: server) + try await performOperation(.paginate, using: connection) // Then the UI should remain unchanged. app.assertScreenshot(.roomLayoutMiddle, step: 0) // When an incoming message arrives - try await performOperation(.incomingMessage, using: server) + try await performOperation(.incomingMessage, using: connection) // Then the UI should still remain unchanged. app.assertScreenshot(.roomLayoutMiddle, step: 0) @@ -114,13 +117,14 @@ class RoomScreenUITests: XCTestCase { } func testTimelineLayoutAtTop() async throws { - let server = try UITestsSignalling.Server() + let listener = try UITestsSignalling.Listener() let app = Application.launch() app.goToScreenWithIdentifier(.roomLayoutTop) - try await server.connect() - defer { server.disconnect() } + let connection = try await listener.connection() + try await Task.sleep(for: .seconds(1)) // Allow the connection to settle on CI/Intel... + defer { connection.disconnect() } // Given a timeline that is scrolled to the top. while !app.staticTexts["Bacon ipsum dolor amet commodo incididunt ribeye dolore cupidatat short ribs."].isHittable { @@ -130,23 +134,24 @@ class RoomScreenUITests: XCTestCase { app.assertScreenshot(.roomLayoutTop, insets: cropped) // Assert initial state for comparison. // When a back pagination occurs. - try await performOperation(.paginate, using: server) + try await performOperation(.paginate, using: connection) // Then the bottom of the timeline should remain unchanged (with new items having been added above). app.assertScreenshot(.roomLayoutTop, insets: cropped) } func testTimelineLayoutAtBottom() async throws { - let server = try UITestsSignalling.Server() + let listener = try UITestsSignalling.Listener() let app = Application.launch() app.goToScreenWithIdentifier(.roomLayoutBottom) - try await server.connect() - defer { server.disconnect() } + let connection = try await listener.connection() + try await Task.sleep(for: .seconds(1)) // Allow the connection to settle on CI/Intel... + defer { connection.disconnect() } // When an incoming message arrives. - try await performOperation(.incomingMessage, using: server) + try await performOperation(.incomingMessage, using: connection) // Then the timeline should scroll down to reveal the message. app.assertScreenshot(.roomLayoutBottom, step: 0) @@ -160,9 +165,9 @@ class RoomScreenUITests: XCTestCase { // MARK: - Helper Methods - private func performOperation(_ operation: UITestsSignal, using server: UITestsSignalling.Server) async throws { - try await server.send(operation) - guard try await server.receive() == .success else { throw UITestsSignalError.unexpected } + private func performOperation(_ operation: UITestsSignal, using connection: UITestsSignalling.Connection) async throws { + try await connection.send(operation) + guard try await connection.receive() == .success else { throw UITestsSignalError.unexpected } try await Task.sleep(for: .milliseconds(500)) // Allow the timeline to update }