mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
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:
@@ -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)
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user