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:
Doug
2023-01-05 16:35:20 +00:00
committed by GitHub
parent 530ae336ab
commit 51e300642c
63 changed files with 597 additions and 79 deletions
@@ -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,
@@ -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: