mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
Timeline UI Tests (#380)
* Add signalling between UI tests and the app. * Fix navigationStack on roomLayout screens and update screenshots Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
@@ -79,4 +79,6 @@ struct RoomScreenViewStateBindings {
|
||||
enum RoomScreenErrorType: Hashable {
|
||||
/// A specific error message shown in an alert.
|
||||
case alert(String)
|
||||
/// A specific error message shown in a toast.
|
||||
case toast(String)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, Roo
|
||||
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
|
||||
private enum Constants {
|
||||
static let backPaginationPageSize: UInt = 50
|
||||
static let toastErrorID = "RoomScreenToastError"
|
||||
}
|
||||
|
||||
private let timelineController: RoomTimelineControllerProtocol
|
||||
@@ -123,7 +124,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
private func paginateBackwards() async {
|
||||
switch await timelineController.paginateBackwards(Constants.backPaginationPageSize) {
|
||||
case .failure:
|
||||
displayError(.alert(ElementL10n.roomTimelineBackpaginationFailure))
|
||||
displayError(.toast(ElementL10n.roomTimelineBackpaginationFailure))
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -179,6 +180,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
state.bindings.alertInfo = AlertInfo(id: type,
|
||||
title: ElementL10n.dialogTitleError,
|
||||
message: message)
|
||||
case .toast(let message):
|
||||
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: Constants.toastErrorID,
|
||||
type: .toast,
|
||||
title: message,
|
||||
iconName: "xmark"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
styledContentWithReactions
|
||||
}
|
||||
.padding(.horizontal, 16.0)
|
||||
.padding(timelineItem.isOutgoing ? .leading : .trailing, 40) // Extra padding to differentiate alignment.
|
||||
}
|
||||
|
||||
if timelineItem.isOutgoing {
|
||||
|
||||
@@ -64,7 +64,7 @@ enum RoomTimelineItemFixtures {
|
||||
senderId: "",
|
||||
senderDisplayName: "Helena"),
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "And John's speech was amazing!And John's speech was amazing!And John's speech was amazing!",
|
||||
text: "And John's speech was amazing!",
|
||||
timestamp: "5 PM",
|
||||
inGroupState: .beginning,
|
||||
isOutgoing: true,
|
||||
|
||||
+60
-33
@@ -20,13 +20,8 @@ import Foundation
|
||||
class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
||||
/// An array of timeline item arrays that will be inserted in order for each back pagination request.
|
||||
var backPaginationResponses: [[RoomTimelineItemProtocol]] = []
|
||||
/// The time delay added to each back pagination request.
|
||||
var backPaginationDelay: Duration = .milliseconds(500)
|
||||
|
||||
/// An array of timeline items that will be appended in order when ``simulateIncomingItems()`` is called.
|
||||
var incomingItems: [RoomTimelineItemProtocol] = []
|
||||
/// The time delay between each incoming item.
|
||||
var incomingDelay: Duration = .milliseconds(750)
|
||||
|
||||
let roomId = "MockRoomIdentifier"
|
||||
|
||||
@@ -34,38 +29,17 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
||||
|
||||
var timelineItems: [RoomTimelineItemProtocol] = RoomTimelineItemFixtures.default
|
||||
|
||||
func simulateIncomingItems() {
|
||||
guard !incomingItems.isEmpty else { return }
|
||||
|
||||
let incomingItem = incomingItems.removeFirst()
|
||||
|
||||
Task {
|
||||
try await Task.sleep(for: incomingDelay)
|
||||
timelineItems.append(incomingItem)
|
||||
callbacks.send(.updatedTimelineItems)
|
||||
|
||||
if !self.incomingItems.isEmpty {
|
||||
simulateIncomingItems()
|
||||
}
|
||||
private var client: UITestsSignalling.Client?
|
||||
|
||||
init(listenForSignals: Bool = false) {
|
||||
if listenForSignals {
|
||||
client = .init()
|
||||
Task { try await startListening() }
|
||||
}
|
||||
}
|
||||
|
||||
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineControllerError> {
|
||||
callbacks.send(.startedBackPaginating)
|
||||
|
||||
guard !backPaginationResponses.isEmpty else {
|
||||
callbacks.send(.finishedBackPaginating)
|
||||
return .failure(.generic)
|
||||
}
|
||||
|
||||
let newItems = backPaginationResponses.removeFirst()
|
||||
|
||||
try? await Task.sleep(for: backPaginationDelay)
|
||||
timelineItems.insert(contentsOf: newItems, at: 0)
|
||||
callbacks.send(.updatedTimelineItems)
|
||||
callbacks.send(.finishedBackPaginating)
|
||||
|
||||
return .success(())
|
||||
.failure(.generic)
|
||||
}
|
||||
|
||||
func processItemAppearance(_ itemId: String) async { }
|
||||
@@ -89,4 +63,57 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
||||
}
|
||||
|
||||
func retryDecryption(forSessionId sessionId: String) async { }
|
||||
|
||||
// MARK: - UI Test signalling
|
||||
|
||||
/// Allows the simulation of server responses by listening for signals from UI tests.
|
||||
private func startListening() async throws {
|
||||
try await client?.connect()
|
||||
|
||||
Task {
|
||||
while let client {
|
||||
do {
|
||||
try await handleSignal(client.receive())
|
||||
} catch {
|
||||
client.disconnect()
|
||||
self.client = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a UI test signal as necessary.
|
||||
private func handleSignal(_ signal: UITestsSignal) async throws {
|
||||
switch signal {
|
||||
case .paginate:
|
||||
try await simulateBackPagination()
|
||||
case .incomingMessage:
|
||||
try await simulateIncomingItem()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends the next incoming item to the `timelineItems` array.
|
||||
private func simulateIncomingItem() async throws {
|
||||
guard !incomingItems.isEmpty else { return }
|
||||
|
||||
let incomingItem = incomingItems.removeFirst()
|
||||
timelineItems.append(incomingItem)
|
||||
callbacks.send(.updatedTimelineItems)
|
||||
|
||||
try? await client?.send(.success)
|
||||
}
|
||||
|
||||
/// Prepends the next chunk of items to the `timelineItems` array.
|
||||
private func simulateBackPagination() async throws {
|
||||
guard !backPaginationResponses.isEmpty else { return }
|
||||
|
||||
let newItems = backPaginationResponses.removeFirst()
|
||||
timelineItems.insert(contentsOf: newItems, at: 0)
|
||||
callbacks.send(.updatedTimelineItems)
|
||||
callbacks.send(.finishedBackPaginating)
|
||||
|
||||
try? await client?.send(.success)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ enum UITestScreenIdentifier: String {
|
||||
case roomSmallTimeline
|
||||
case roomSmallTimelineIncomingAndSmallPagination
|
||||
case roomSmallTimelineLargePagination
|
||||
case roomLayoutTop
|
||||
case roomLayoutMiddle
|
||||
case roomLayoutBottom
|
||||
case sessionVerification
|
||||
case userSessionScreen
|
||||
case roomDetailsScreen
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// 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<UITestsSignal, Error>?
|
||||
|
||||
/// 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<UITestsSignal, Error>?
|
||||
|
||||
/// 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<UITestsSignal, Error>? { 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,11 +178,10 @@ class MockScreen: Identifiable {
|
||||
case .roomSmallTimelineIncomingAndSmallPagination:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let timelineController = MockRoomTimelineController()
|
||||
let timelineController = MockRoomTimelineController(listenForSignals: true)
|
||||
timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk
|
||||
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.singleMessageChunk]
|
||||
timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage]
|
||||
timelineController.simulateIncomingItems()
|
||||
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
|
||||
roomProxy: MockRoomProxy(displayName: "Small timeline", avatarURL: "mock_url"),
|
||||
timelineController: timelineController,
|
||||
@@ -195,7 +194,7 @@ class MockScreen: Identifiable {
|
||||
case .roomSmallTimelineLargePagination:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let timelineController = MockRoomTimelineController()
|
||||
let timelineController = MockRoomTimelineController(listenForSignals: true)
|
||||
timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk
|
||||
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk]
|
||||
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
|
||||
@@ -205,6 +204,52 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .roomLayoutTop:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let timelineController = MockRoomTimelineController(listenForSignals: true)
|
||||
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
|
||||
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk]
|
||||
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
|
||||
roomProxy: MockRoomProxy(displayName: "Large timeline", avatarURL: "mock_url"),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .roomLayoutMiddle:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let timelineController = MockRoomTimelineController(listenForSignals: true)
|
||||
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
|
||||
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk]
|
||||
timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage]
|
||||
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
|
||||
roomProxy: MockRoomProxy(displayName: "Large timeline", avatarURL: "mock_url"),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .roomLayoutBottom:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let timelineController = MockRoomTimelineController(listenForSignals: true)
|
||||
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
|
||||
timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage]
|
||||
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
|
||||
roomProxy: MockRoomProxy(displayName: "Large timeline", avatarURL: "mock_url"),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .sessionVerification:
|
||||
|
||||
Reference in New Issue
Block a user