diff --git a/.swiftlint.yml b/.swiftlint.yml index e87896e4b..ec611b419 100755 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -40,6 +40,10 @@ function_body_length: warning: 50 error: 100 +nesting: + type_level: + warning: 5 + custom_rules: print_deprecation: regex: "\\b(print)\\b" diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 44b3ea86f..4b15621aa 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -72,13 +72,22 @@ "version" : "7.4.1" } }, + { + "identity" : "kzfilewatchers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzysztofzablocki/KZFileWatchers", + "state" : { + "branch" : "master", + "revision" : "d27a9557427d261adccdf4b566acc9d9c0fec6f4" + } + }, { "identity" : "matrix-analytics-events", "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-analytics-events", "state" : { - "branch" : "main", - "revision" : "4bcc7f566b165cd2d8fde07d23bda77e1d9fbb2d" + "revision" : "94598bd8ef3aa2a3d3848c656c37d5e95b6b1cff", + "version" : "0.4.0" } }, { diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift index 98d9c9c20..20ab044cc 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift @@ -32,12 +32,15 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { var timelineItems: [RoomTimelineItemProtocol] = RoomTimelineItemFixtures.default - private var connection: UITestsSignalling.Connection? + private var client: UITestsSignalling.Client? init(listenForSignals: Bool = false) { - if listenForSignals { - connection = .init() - Task { try await startListening() } + guard listenForSignals else { return } + + do { + try startListening() + } catch { + fatalError("Failure setting up signalling: \(error)") } } @@ -70,20 +73,18 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { // MARK: - UI Test signalling + /// The cancellable used for UI Tests signalling. + private var signalCancellable: AnyCancellable? + /// Allows the simulation of server responses by listening for signals from UI tests. - private func startListening() async throws { - try await connection?.connect() + private func startListening() throws { + let client = try UITestsSignalling.Client(mode: .app) - Task { - while let connection { - do { - try await handleSignal(connection.receive()) - } catch { - connection.disconnect() - self.connection = nil - } - } + signalCancellable = client.signals.sink { [weak self] signal in + Task { try await self?.handleSignal(signal) } } + + self.client = client } /// Handles a UI test signal as necessary. @@ -106,7 +107,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { timelineItems.append(incomingItem) callbacks.send(.updatedTimelineItems) - try? await connection?.send(.success) + try client?.send(.success) } /// Prepends the next chunk of items to the `timelineItems` array. @@ -119,6 +120,6 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { callbacks.send(.updatedTimelineItems) callbacks.send(.isBackPaginating(false)) - try? await connection?.send(.success) + try client?.send(.success) } } diff --git a/ElementX/Sources/UITests/UITestsSignalling.swift b/ElementX/Sources/UITests/UITestsSignalling.swift index 26cba540d..6fe0e0b09 100644 --- a/ElementX/Sources/UITests/UITestsSignalling.swift +++ b/ElementX/Sources/UITests/UITestsSignalling.swift @@ -14,12 +14,13 @@ // limitations under the License. // -import Network +import Combine +import KZFileWatchers import SwiftUI enum UITestsSignal: String { - /// An internal signal used to bring up the connection. - case connect + /// An internal signal used to indicate that one side of the connection is ready. + case ready /// Ask the app to back paginate. case paginate /// Ask the app to simulate an incoming message. @@ -28,211 +29,127 @@ enum UITestsSignal: String { 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. +enum UITestsSignalError: String, LocalizedError { + /// The app client failed to start as the tests client isn't ready. + case testsClientNotReady + /// Failed to send a signal as a connection hasn't been established. case notConnected - /// Attempted to receive multiple signals at once. - case awaitingAnotherSignal - /// Receiving the next signal timed out. - case timeout - /// A network error occurred. - case nwError(NWError) - /// An unexpected signal was received. This error isn't used internally. - case unexpected + + var errorDescription: String? { "UITestsSignalError.\(rawValue)" } } 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) (\(Locale.current.identifier))" - /// 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. + /// A two-way file-based signalling client that can be used to signal 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? - /// A task to handle the timeout when receiving a signal. - private var nextMessageTimeoutTask: Task? { - didSet { - oldValue?.cancel() - } - } + /// - Create a `Client` in `tests` mode in your UI tests before launching the app. It will start listening for signals. + /// - Within the app, create a `Client` in `app` mode. This will check that the tests are ready and echo back that the app is too. + /// - Call `waitForApp()` in the tests when you need to send the signal. This will suspend execution until the app has signalled it is ready. + /// - The two `Client` objects can now be used for two-way signalling. + class Client { + /// The file watcher responsible for receiving signals. + private let fileWatcher: FileWatcher.Local - /// 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) - } + /// The file name used for the connection. + /// + /// The device name is included to allow UI tests to run on multiple devices simultaneously. + /// When using parallel execution, each execution will spawn a simulator clone with its own unique name. + private let fileURL = { + let directory = URL(filePath: "/Users/Shared") + let deviceName = (UIDevice.current.name).replacing(" ", with: "-") + return directory.appending(component: "UITestsSignalling-\(deviceName)") + }() - /// Creates a new signalling `Connection` from an established `NWConnection`. - fileprivate init(nwConnection: NWConnection) { - connection = nwConnection - } + /// A mode that defines the behaviour of the client. + enum Mode: String { case app, tests } + /// The mode that the client is using. + let mode: Mode - /// Attempts to establish a connection with a `Listener`. - func connect() async throws { - guard connection.state == .setup else { return } + /// A publisher the will be sent every time a new signal is received. + let signals = PassthroughSubject() + + /// Whether or not the client has established a connection. + private(set) var isConnected = false + + /// Creates a new signalling `Client`. + init(mode: Mode) throws { + fileWatcher = .init(path: fileURL.path()) + self.mode = mode - 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 - } - } + switch mode { + case .tests: + // The tests client is started first and writes to the file saying it is ready. + try rawSignal(.ready).write(to: fileURL, atomically: false, encoding: .utf8) + case .app: + // The app client is started second and checks that there is a ready signal from the tests. + guard try String(contentsOf: fileURL) == "\(Mode.tests):\(UITestsSignal.ready)" else { throw UITestsSignalError.testsClientNotReady } + isConnected = true + // The app client then echoes back to the tests that it is now ready. + try send(.ready) } - } - - /// Stops the connection. - func disconnect() { - connection.cancel() - if let nextMessageContinuation { - nextMessageContinuation.resume(throwing: UITestsSignalError.cancelled) - self.nextMessageContinuation = nil - nextMessageTimeoutTask = 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 - - // Add a 30 second timeout to stop tests from hanging - self.nextMessageTimeoutTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(for: .seconds(30)) - - guard !Task.isCancelled, - let nextMessageContinuation = self.nextMessageContinuation - else { return } - - nextMessageContinuation.resume(throwing: UITestsSignalError.timeout) - self.nextMessageContinuation = nil - self.nextMessageTimeoutTask = nil - } + try fileWatcher.start { [weak self] result in + self?.handleFileRefresh(result) } } - /// 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 - self.nextMessageTimeoutTask = nil - return - } - - if signal != .connect { - self.nextMessageContinuation?.resume(returning: signal) - self.nextMessageContinuation = nil - self.nextMessageTimeoutTask = nil - } - - self.receiveNextMessage() + /// Suspends execution until the app's Client has signalled that it's ready. + func waitForApp() async { + guard mode == .tests else { fatalError("The app can't wait for itself.") } + + guard !isConnected else { return } + await _ = signals.values.first { $0 == .ready } + NSLog("UITestsSignalling: Connected to app.") + } + + /// Stops listening for signals. + func stop() throws { + try fileWatcher.stop() + } + + /// Sends a signal. + func send(_ signal: UITestsSignal) throws { + guard isConnected else { throw UITestsSignalError.notConnected } + + let rawSignal = rawSignal(signal) + try rawSignal.write(to: fileURL, atomically: false, encoding: .utf8) + NSLog("UITestsSignalling: Sent \(rawSignal)") + } + + /// The signal formatted as a string, prefixed with an identifier for the sender. + /// E.g. The tests client would produce `tests:ready` for the ready signal. + private func rawSignal(_ signal: UITestsSignal) -> String { + "\(mode.rawValue):\(signal.rawValue)" + } + + /// Handles a file refresh to receive a new signal. + fileprivate func handleFileRefresh(_ result: FileWatcher.RefreshResult) { + switch result { + case .noChanges: + guard let data = try? Data(contentsOf: fileURL) else { return } + processFileData(data) + case .updated(let data): + processFileData(data) } } + + /// Processes string data from the file and publishes its signal. + private func processFileData(_ data: Data) { + guard let message = String(data: data, encoding: .utf8) else { return } + + let components = message.components(separatedBy: ":") + + guard components.count == 2, + components[0] != mode.rawValue, // Filter out messages sent by this client. + let signal = UITestsSignal(rawValue: components[1]) + else { return } + + if signal == .ready { + isConnected = true + } + + signals.send(signal) + + NSLog("UITestsSignalling: Received \(message)") + } } } diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index 3949a1035..48f5a584a 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -120,6 +120,7 @@ targets: - package: DTCoreText - package: KeychainAccess - package: Kingfisher + - package: KZFileWatchers - package: PostHog - package: SwiftyBeaver - package: SwiftState diff --git a/UITests/Sources/RoomScreenUITests.swift b/UITests/Sources/RoomScreenUITests.swift index 3fad21d8d..2fabf0dae 100644 --- a/UITests/Sources/RoomScreenUITests.swift +++ b/UITests/Sources/RoomScreenUITests.swift @@ -19,8 +19,6 @@ import XCTest @MainActor class RoomScreenUITests: XCTestCase { - let connectionWaitDuration: Duration = .seconds(2) - func testPlainNoAvatar() { let app = Application.launch(.roomPlainNoAvatar) @@ -46,47 +44,44 @@ class RoomScreenUITests: XCTestCase { app.assertScreenshot(.roomSmallTimeline) } - func disabled_testSmallTimelineWithIncomingAndPagination() async throws { - let listener = try UITestsSignalling.Listener() + func testSmallTimelineWithIncomingAndPagination() async throws { + let client = try UITestsSignalling.Client(mode: .tests) let app = Application.launch(.roomSmallTimelineIncomingAndSmallPagination) - let connection = try await listener.connection() - try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel... - defer { connection.disconnect() } + await client.waitForApp() + defer { try? client.stop() } // When a back pagination occurs and an incoming message arrives. - try await performOperation(.incomingMessage, using: connection) - try await performOperation(.paginate, using: connection) + try await performOperation(.incomingMessage, using: client) + try await performOperation(.paginate, using: client) // Then the 4 visible messages should stay aligned to the bottom. app.assertScreenshot(.roomSmallTimelineIncomingAndSmallPagination) } - func disabled_testSmallTimelineWithLargePagination() async throws { - let listener = try UITestsSignalling.Listener() + func testSmallTimelineWithLargePagination() async throws { + let client = try UITestsSignalling.Client(mode: .tests) let app = Application.launch(.roomSmallTimelineLargePagination) - let connection = try await listener.connection() - try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel... - defer { connection.disconnect() } + await client.waitForApp() + defer { try? client.stop() } // When a large back pagination occurs. - try await performOperation(.paginate, using: connection) + try await performOperation(.paginate, using: client) // The bottom of the timeline should remain visible with more items added above. app.assertScreenshot(.roomSmallTimelineLargePagination) } - func disabled_testTimelineLayoutInMiddle() async throws { - let listener = try UITestsSignalling.Listener() + func testTimelineLayoutInMiddle() async throws { + let client = try UITestsSignalling.Client(mode: .tests) let app = Application.launch(.roomLayoutMiddle) - let connection = try await listener.connection() - try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel... - defer { connection.disconnect() } + await client.waitForApp() + defer { try? client.stop() } // Given a timeline that is neither at the top nor the bottom. app.tables.element.swipeDown() @@ -94,13 +89,13 @@ class RoomScreenUITests: XCTestCase { app.assertScreenshot(.roomLayoutMiddle, step: 0) // Assert initial state for comparison. // When a back pagination occurs. - try await performOperation(.paginate, using: connection) + try await performOperation(.paginate, using: client) // Then the UI should remain unchanged. app.assertScreenshot(.roomLayoutMiddle, step: 0) // When an incoming message arrives - try await performOperation(.incomingMessage, using: connection) + try await performOperation(.incomingMessage, using: client) // Then the UI should still remain unchanged. app.assertScreenshot(.roomLayoutMiddle, step: 0) @@ -112,14 +107,13 @@ class RoomScreenUITests: XCTestCase { app.assertScreenshot(.roomLayoutMiddle, step: 1) } - func disabled_testTimelineLayoutAtTop() async throws { - let listener = try UITestsSignalling.Listener() + func testTimelineLayoutAtTop() async throws { + let client = try UITestsSignalling.Client(mode: .tests) let app = Application.launch(.roomLayoutTop) - let connection = try await listener.connection() - try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel... - defer { connection.disconnect() } + await client.waitForApp() + defer { try? client.stop() } // Given a timeline that is scrolled to the top. while !app.staticTexts["Bacon ipsum dolor amet commodo incididunt ribeye dolore cupidatat short ribs."].isHittable { @@ -129,23 +123,22 @@ class RoomScreenUITests: XCTestCase { app.assertScreenshot(.roomLayoutTop, insets: cropped) // Assert initial state for comparison. // When a back pagination occurs. - try await performOperation(.paginate, using: connection) + try await performOperation(.paginate, using: client) // Then the bottom of the timeline should remain unchanged (with new items having been added above). app.assertScreenshot(.roomLayoutTop, insets: cropped) } - func disabled_testTimelineLayoutAtBottom() async throws { - let listener = try UITestsSignalling.Listener() + func testTimelineLayoutAtBottom() async throws { + let client = try UITestsSignalling.Client(mode: .tests) let app = Application.launch(.roomLayoutBottom) - let connection = try await listener.connection() - try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel... - defer { connection.disconnect() } + await client.waitForApp() + defer { try? client.stop() } // When an incoming message arrives. - try await performOperation(.incomingMessage, using: connection) + try await performOperation(.incomingMessage, using: client) // Then the timeline should scroll down to reveal the message. app.assertScreenshot(.roomLayoutBottom, step: 0) @@ -159,10 +152,10 @@ class RoomScreenUITests: XCTestCase { // MARK: - Helper Methods - 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: connectionWaitDuration) // Allow the timeline to update, and the connection to be ready + private func performOperation(_ operation: UITestsSignal, using client: UITestsSignalling.Client) async throws { + try client.send(operation) + await _ = client.signals.values.first { $0 == .success } + try await Task.sleep(for: .milliseconds(500)) // Allow the timeline to update } private func tapMessageComposer(in app: XCUIApplication) async throws { diff --git a/UITests/SupportingFiles/target.yml b/UITests/SupportingFiles/target.yml index fd86855fa..416cdfa10 100644 --- a/UITests/SupportingFiles/target.yml +++ b/UITests/SupportingFiles/target.yml @@ -34,6 +34,7 @@ targets: - package: DTCoreText - package: KeychainAccess - package: Kingfisher + - package: KZFileWatchers - package: PostHog - package: SwiftyBeaver - package: SwiftState diff --git a/changelog.d/534.bugfix b/changelog.d/534.bugfix index 5b6be5df9..8818ec167 100644 --- a/changelog.d/534.bugfix +++ b/changelog.d/534.bugfix @@ -1 +1 @@ -Fix UI Tests for OnboardingScreen, BugReportScreen, ServerSelectionScreen, and UserSessionFlows. \ No newline at end of file +Fix UI Tests for OnboardingScreen, BugReportScreen, ServerSelectionScreen, and UserSessionFlows. Fix UITestsSignalling by switching to file-based communication with a publisher. \ No newline at end of file diff --git a/project.yml b/project.yml index 3d814969b..63a2c63f1 100644 --- a/project.yml +++ b/project.yml @@ -46,7 +46,7 @@ packages: path: DesignKit AnalyticsEvents: url: https://github.com/matrix-org/matrix-analytics-events - branch: main + exactVersion: 0.4.0 AppAuth: url: https://github.com/openid/AppAuth-iOS majorVersion: 1.5.0 @@ -65,6 +65,9 @@ packages: Kingfisher: url: https://github.com/onevcat/Kingfisher majorVersion: 7.2.0 + KZFileWatchers: + url: https://github.com/krzysztofzablocki/KZFileWatchers + branch: master Introspect: url: https://github.com/siteline/SwiftUI-Introspect majorVersion: 0.1.4