Add UI tests for AppLockFlowCoordinator. (#2055)

* Add UITests for the App Lock flow.

* Add Notification Signal

Fix unwanted imports in UITests.
This commit is contained in:
Doug
2023-11-10 15:38:54 +00:00
committed by GitHub
parent da831f6725
commit 37d88e622c
71 changed files with 348 additions and 147 deletions
@@ -18,5 +18,6 @@ import Foundation
protocol AppCoordinatorProtocol: CoordinatorProtocol {
var notificationManager: NotificationManagerProtocol { get }
var windowManager: WindowManager { get }
@discardableResult func handleDeepLink(_ url: URL) -> Bool
}
@@ -15,8 +15,7 @@
//
import Combine
import Foundation
import UIKit
import SwiftUI
enum AppDelegateCallback {
case registeredNotifications(deviceToken: Data)
@@ -29,10 +29,9 @@ struct Application: App {
} else if ProcessInfo.isRunningUnitTests {
appCoordinator = UnitTestsAppCoordinator()
} else {
let coordinator = AppCoordinator(appDelegate: appDelegate)
SceneDelegate.windowManager = coordinator.windowManager
appCoordinator = coordinator
appCoordinator = AppCoordinator(appDelegate: appDelegate)
}
SceneDelegate.windowManager = appCoordinator.windowManager
}
var body: some Scene {
@@ -23,7 +23,7 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate {
weak static var windowManager: WindowManager!
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene, !ProcessInfo.isRunningTests else { return }
guard let windowScene = scene as? UIWindowScene else { return }
Self.windowManager.configure(with: windowScene)
}
}
@@ -40,7 +40,9 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
actionsSubject.eraseToAnyPublisher()
}
init(appLockService: AppLockServiceProtocol, navigationCoordinator: NavigationRootCoordinator) {
init(appLockService: AppLockServiceProtocol,
navigationCoordinator: NavigationRootCoordinator,
notificationCenter: NotificationCenter = .default) {
self.appLockService = appLockService
self.navigationCoordinator = navigationCoordinator
@@ -54,13 +56,13 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
notificationCenter.publisher(for: UIApplication.didEnterBackgroundNotification)
.sink { [weak self] _ in
self?.applicationDidEnterBackground()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification)
.sink { [weak self] _ in
self?.applicationWillEnterForeground()
}
@@ -19,6 +19,7 @@ import Foundation
enum A11yIdentifiers {
static let alertInfo = AlertInfo()
static let analyticsPromptScreen = AnalyticsPromptScreen()
static let appLockScreen = AppLockScreen()
static let appLockSetupBiometricsScreen = AppLockSetupBiometricsScreen()
static let appLockSetupPINScreen = AppLockSetupPINScreen()
static let appLockSetupSettingsScreen = AppLockSetupSettingsScreen()
@@ -51,6 +52,10 @@ enum A11yIdentifiers {
let secondaryButton = "alert_info-secondary_button"
}
struct AppLockScreen {
func numpad(_ digit: Int) -> String { "app_lock-numpad_\(digit)" }
}
struct AppLockSetupBiometricsScreen {
let allow = "app_lock_setup_biometrics-allow"
}
@@ -27,12 +27,14 @@ struct AppLockScreenPINKeypad: View {
ForEach(1..<4) { column in
let digit = (3 * row) + column
Button("\(digit)") { press(digit) }
.accessibilityIdentifier(A11yIdentifiers.appLockScreen.numpad(digit))
}
}
}
GridRow {
Button("") { }.hidden()
Button("0") { press(0) }
.accessibilityIdentifier(A11yIdentifiers.appLockScreen.numpad(0))
Button(action: pressDelete) {
Image(systemSymbol: .deleteBackward)
.symbolVariant(.fill)
@@ -125,9 +125,9 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
/// Handles a UI test signal as necessary.
private func handleSignal(_ signal: UITestsSignal) async throws {
switch signal {
case .paginate:
case .timeline(.paginate):
try await simulateBackPagination()
case .incomingMessage:
case .timeline(.incomingMessage):
try await simulateIncomingItem()
default:
break
@@ -19,17 +19,22 @@ import MatrixRustSDK
import SwiftUI
import UIKit
class UITestsAppCoordinator: AppCoordinatorProtocol {
class UITestsAppCoordinator: AppCoordinatorProtocol, WindowManagerDelegate {
private let navigationRootCoordinator: NavigationRootCoordinator
private var mockScreen: MockScreen?
private var alternateWindowMockScreen: MockScreen?
let notificationManager: NotificationManagerProtocol = NotificationManagerMock()
let windowManager = WindowManager()
init() {
// disabling View animations
UIView.setAnimationsEnabled(false)
navigationRootCoordinator = NavigationRootCoordinator()
windowManager.delegate = self
ServiceLocator.shared.register(userIndicatorController: UserIndicatorControllerMock.default)
AppSettings.configureWithSuiteName("io.element.elementx.uitests")
@@ -42,14 +47,6 @@ class UITestsAppCoordinator: AppCoordinatorProtocol {
}
func start() {
// Fix the app tint colour.
UIApplication.shared.connectedScenes.forEach { scene in
guard let delegate = scene.delegate as? UIWindowSceneDelegate else {
return
}
delegate.window??.tintColor = .compound.textActionPrimary
}
guard let screenID = ProcessInfo.testScreenID else { fatalError("Unable to launch with unknown screen.") }
let mockScreen = MockScreen(id: screenID)
@@ -64,17 +61,27 @@ class UITestsAppCoordinator: AppCoordinatorProtocol {
func handleDeepLink(_ url: URL) -> Bool {
fatalError("Not implemented.")
}
func windowManagerDidConfigureWindows(_ windowManager: WindowManager) {
guard let screenID = ProcessInfo.testScreenID, screenID == .appLockFlow || screenID == .appLockFlowDisabled else { return }
let screen = MockScreen(id: screenID == .appLockFlow ? .appLockFlowAlternateWindow : .appLockFlowDisabledAlternateWindow, windowManager: windowManager)
windowManager.alternateWindow.rootViewController = UIHostingController(rootView: screen.coordinator.toPresentable().statusBarHidden())
alternateWindowMockScreen = screen
}
}
@MainActor
class MockScreen: Identifiable {
let id: UITestsScreenIdentifier
let windowManager: WindowManager?
private var retainedState = [Any]()
private var cancellables = Set<AnyCancellable>()
init(id: UITestsScreenIdentifier) {
init(id: UITestsScreenIdentifier, windowManager: WindowManager? = nil) {
self.id = id
self.windowManager = windowManager
}
lazy var coordinator: CoordinatorProtocol = {
@@ -158,24 +165,15 @@ class MockScreen: Identifiable {
let coordinator = TemplateScreenCoordinator(parameters: .init())
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .appLockScreen:
let appLockService = AppLockService(keychainController: KeychainControllerMock(), appSettings: ServiceLocator.shared.settings)
let coordinator = AppLockScreenCoordinator(parameters: .init(appLockService: appLockService))
return coordinator
case .appLockSetupFlow, .appLockSetupFlowUnlock, .appLockSetupFlowMandatory:
let navigationStackCoordinator = NavigationStackCoordinator()
// The flow expects an existing root coordinator, use the placeholder as a placeholder 😅
navigationStackCoordinator.setRootCoordinator(BlankFormCoordinator())
case .appLockFlow, .appLockFlowDisabled:
// The tested coordinator is setup below in the alternate window.
// Here we just return a blank screen to snapshot as the unlocked app.
return BlankFormCoordinator()
case .appLockFlowAlternateWindow, .appLockFlowDisabledAlternateWindow:
let navigationCoordinator = NavigationRootCoordinator()
let keychainController = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
keychainController.resetSecrets()
if id == .appLockSetupFlowUnlock {
do {
try keychainController.setPINCode("2023")
} catch {
fatalError("Failed to pre-set the PIN code")
}
}
let context = LAContextMock()
context.biometryTypeValue = UIDevice.current.isPhone ? .faceID : .touchID // (iPhone 14 & iPad 9th gen)
@@ -186,6 +184,59 @@ class MockScreen: Identifiable {
appSettings: ServiceLocator.shared.settings,
context: context)
if id == .appLockFlowAlternateWindow {
guard case .success = appLockService.setupPINCode("2023") else {
fatalError("Failed to preset the PIN code.")
}
}
let notificationCenter = UITestsNotificationCenter()
do {
try notificationCenter.startListening()
} catch {
fatalError("Failed to start listening for notifications.")
}
let coordinator = AppLockFlowCoordinator(appLockService: appLockService,
navigationCoordinator: navigationCoordinator,
notificationCenter: notificationCenter)
guard let windowManager else { fatalError("The window manager must be supplied.") }
coordinator.actions
.sink { action in
switch action {
case .lockApp:
windowManager.switchToAlternate()
case .unlockApp:
windowManager.switchToMain()
case .forceLogout:
break
}
}
.store(in: &cancellables)
return coordinator
case .appLockSetupFlow, .appLockSetupFlowUnlock, .appLockSetupFlowMandatory:
let navigationStackCoordinator = NavigationStackCoordinator()
// The flow expects an existing root coordinator, use a blank form as a placeholder.
navigationStackCoordinator.setRootCoordinator(BlankFormCoordinator())
let keychainController = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
keychainController.resetSecrets()
let context = LAContextMock()
context.biometryTypeValue = UIDevice.current.isPhone ? .faceID : .touchID // (iPhone 14 & iPad 9th gen)
context.evaluatePolicyReturnValue = true
context.evaluatedPolicyDomainStateValue = "😎".data(using: .utf8)
let appLockService = AppLockService(keychainController: keychainController,
appSettings: ServiceLocator.shared.settings,
context: context)
if id == .appLockSetupFlowUnlock, case .failure = appLockService.setupPINCode("2023") {
fatalError("Failed to pre-set the PIN code")
}
let flow: AppLockSetupFlowCoordinator.PresentationFlow = id == .appLockSetupFlowMandatory ? .onboarding : .settings
let coordinator = AppLockSetupFlowCoordinator(presentingFlow: flow,
appLockService: appLockService,
@@ -0,0 +1,57 @@
//
// Copyright 2023 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 Combine
import SwiftUI
@MainActor
/// A notification center that can be injected in the app to post notifications
/// that are sent from the UI tests runner. Usage:
/// - Create an instance of the center in the screen you want to test and call `startListening`.
/// - Create a `UITestSignalling.Client` in the `.tests` mode in your tests.
/// - Start the app from the tests and call `client.waitForApp()` to establish communication.
/// - Send the notification from the tests you would like posted in the app.
class UITestsNotificationCenter: NotificationCenter {
private var client: UITestsSignalling.Client?
private var signalCancellable: AnyCancellable?
/// Starts listening for signals to post notifications.
func startListening() throws {
let client = try UITestsSignalling.Client(mode: .app)
signalCancellable = client.signals.sink { [weak self] signal in
Task {
do {
try await self?.handleSignal(signal)
} catch {
MXLog.error(error.localizedDescription)
}
}
}
self.client = client
}
/// Handles any notification signals, and drops anything else received.
private func handleSignal(_ signal: UITestsSignal) async throws {
switch signal {
case .notification(let name):
post(name: name, object: nil)
default:
break
}
}
}
@@ -29,7 +29,10 @@ enum UITestsScreenIdentifier: String {
case analyticsSettingsScreen
case migration
case templateScreen
case appLockScreen
case appLockFlow
case appLockFlowAlternateWindow
case appLockFlowDisabled
case appLockFlowDisabledAlternateWindow
case appLockSetupFlow
case appLockSetupFlowUnlock
case appLockSetupFlowMandatory
@@ -18,15 +18,24 @@ import Combine
import KZFileWatchers
import SwiftUI
enum UITestsSignal: String {
extension Notification.Name: Codable { }
enum UITestsSignal: Codable, Equatable {
/// 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.
case incomingMessage
/// The operation has completed successfully.
case success
case timeline(Timeline)
enum Timeline: Codable {
/// Ask the app to back paginate.
case paginate
/// Ask the app to simulate an incoming message.
case incomingMessage
}
/// Posts a notification.
case notification(name: Notification.Name)
}
enum UITestsSignalError: String, LocalizedError {
@@ -60,7 +69,7 @@ enum UITestsSignalling {
}()
/// A mode that defines the behaviour of the client.
enum Mode: String { case app, tests }
enum Mode: Codable { case app, tests }
/// The mode that the client is using.
let mode: Mode
@@ -78,10 +87,10 @@ enum UITestsSignalling {
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)
try rawMessage(.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 }
guard try String(contentsOf: fileURL) == Message(mode: .tests, signal: .ready).rawValue else { throw UITestsSignalError.testsClientNotReady }
isConnected = true
// The app client then echoes back to the tests that it is now ready.
try send(.ready)
@@ -110,15 +119,42 @@ enum UITestsSignalling {
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)")
let rawMessage = rawMessage(signal)
try rawMessage.write(to: fileURL, atomically: false, encoding: .utf8)
NSLog("UITestsSignalling: Sent \(rawMessage)")
}
/// 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)"
/// The signal formatted as a complete message string, including the identifier for this sender.
private func rawMessage(_ signal: UITestsSignal) -> String {
Message(mode: mode, signal: signal).rawValue
}
/// The complete data that is serialised to disk for signalling.
/// This consists of the signal along with an identifier for the sender.
private struct Message: Codable {
let mode: Mode
let signal: UITestsSignal
init(mode: Mode, signal: UITestsSignal) {
self.mode = mode
self.signal = signal
}
var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let string = String(data: data, encoding: .utf8) else {
return "unknown"
}
return string
}
init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let value = try? JSONDecoder().decode(Self.self, from: data) else {
return nil
}
self = value
}
}
/// Handles a file refresh to receive a new signal.
@@ -134,22 +170,19 @@ enum UITestsSignalling {
/// 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 }
guard let rawMessage = 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])
guard let message = Message(rawValue: rawMessage),
message.mode != mode // Filter out messages sent by this client.
else { return }
if signal == .ready {
if message.signal == .ready {
isConnected = true
}
signals.send(signal)
signals.send(message.signal)
NSLog("UITestsSignalling: Received \(message)")
NSLog("UITestsSignalling: Received \(rawMessage)")
}
}
}
@@ -18,6 +18,7 @@ import SwiftUI
class UnitTestsAppCoordinator: AppCoordinatorProtocol {
let notificationManager: NotificationManagerProtocol = NotificationManagerMock()
let windowManager = WindowManager()
init() {
ServiceLocator.shared.register(userIndicatorController: UserIndicatorControllerMock.default)