[PM-27049] Initial version of the Test Harness app (#2142)

This commit is contained in:
Federico Maccaroni 2025-11-21 16:36:58 -03:00 committed by GitHub
parent 820865c290
commit 2046d8a6ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1552 additions and 0 deletions

View File

@ -10,4 +10,7 @@
<FileRef
location = "group:BitwardenKit.xcodeproj">
</FileRef>
<FileRef
location = "group:TestHarness.xcodeproj">
</FileRef>
</Workspace>

View File

@ -0,0 +1,19 @@
CODE_SIGN_STYLE = Automatic
DEVELOPMENT_TEAM = LTZ2PFU5D6
ORGANIZATION_IDENTIFIER = com.bitwarden
BASE_BUNDLE_ID = $(ORGANIZATION_IDENTIFIER).testharness
SHARED_APP_GROUP_IDENTIFIER = group.${ORGANIZATION_IDENTIFIER}.testharness
APPICON_NAME = AppIcon
// The above code signing settings can be overriden by adding a Local-bwth.xcconfig
// file in the Configs directory.
//
// As an example, add the file Local-bwth.xcconfig with the following contents:
//
// DEVELOPMENT_TEAM = <Your team ID>
// ORGANIZATION_IDENTIFIER = <Your reversed domain name>
// PROVISIONING_PROFILE_SPECIFIER = <Optional provisioning profile specifier for the main app>
//
// This should allow Xcode to build the application based on these settings without
// code signing errors or having to modify the project itself.
//

View File

@ -0,0 +1,8 @@
#include "./Common-bwth.xcconfig"
#include "./Base-Debug.xcconfig"
#include? "./Local-bwth.xcconfig"
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
CODE_SIGN_ENTITLEMENTS = TestHarness/Application/Support/Entitlements/TestHarness.entitlements
PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_ID)
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PREVIEWS $(BITWARDEN_FLAGS)

View File

@ -0,0 +1,8 @@
#include "./Common-bwth.xcconfig"
#include "./Base-Release.xcconfig"
#include? "./Local-bwth.xcconfig"
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
CODE_SIGN_ENTITLEMENTS = TestHarness/Application/Support/Entitlements/TestHarness.entitlements
PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_ID)
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) $(BITWARDEN_FLAGS)

View File

@ -0,0 +1,6 @@
#include "./Common-bwth.xcconfig"
#include "./Base-Debug.xcconfig"
#include? "./Local-bwth.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_ID).testharness-shared
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) $(BITWARDEN_FLAGS)

View File

@ -0,0 +1,6 @@
#include "./Common-bwth.xcconfig"
#include "./Base-Release.xcconfig"
#include? "./Local-bwth.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_ID).testharness-shared
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) $(BITWARDEN_FLAGS)

View File

@ -0,0 +1,5 @@
import Foundation
/// Global test helpers for TestHarness tests.
///
/// This file provides shared testing utilities used across the TestHarness test suite.

40
README-bwth.md Normal file
View File

@ -0,0 +1,40 @@
# Test Harness
A playground application for testing and demonstrating various Bitwarden iOS features and flows.
## Overview
The Test Harness app provides a simple interface to trigger and test different scenarios within the Bitwarden iOS ecosystem, including:
- Password Autofill flows
- Passkey Autofill flows (coming soon)
- Passkey Creation flows (coming soon)
## Purpose
This app is designed for:
- Manual testing of specific flows
- Demonstrating feature functionality
- Debugging and development
- Integration testing scenarios
## Structure
The app follows the same architectural patterns as the main Bitwarden apps:
- Coordinator-based navigation
- Processor/State/Action/Effect pattern for views
- Service container for dependency injection
- Sourcery for mock generation
## Building
The Test Harness is part of the main iOS workspace. To build:
1. Run `./Scripts/bootstrap.sh` to generate the Xcode project
2. Open `Bitwarden.xcworkspace`
3. Select the `TestHarness` scheme
4. Build and run
## Note
This is a development/testing tool and is not intended for production use or App Store distribution.

View File

@ -13,6 +13,7 @@ repo_root=$(dirname "$script_dir")
mint run xcodegen --spec "$repo_root/project-bwk.yml"
mint run xcodegen --spec "$repo_root/project-pm.yml"
mint run xcodegen --spec "$repo_root/project-bwa.yml"
mint run xcodegen --spec "$repo_root/project-bwth.yml"
echo "✅ Bootstrapped!"
# Check Xcode version matches .xcode-version

View File

@ -0,0 +1,41 @@
import BitwardenKit
import TestHarnessShared
import UIKit
/// A protocol for an `AppDelegate` that can be used by the `SceneDelegate` to look up the
/// `AppDelegate` when the app is running (`AppDelegate`) or testing (`TestingAppDelegate`).
protocol AppDelegateType: AnyObject {
/// The processor that manages application level logic.
var appProcessor: AppProcessor? { get }
/// Whether the app is running for unit tests.
var isTesting: Bool { get }
}
/// The app's `UIApplicationDelegate` which serves as the entry point into the app.
class AppDelegate: UIResponder, UIApplicationDelegate, AppDelegateType {
// MARK: Properties
/// The processor that manages application level logic.
var appProcessor: AppProcessor?
/// Whether the app is running for unit tests.
var isTesting: Bool {
ProcessInfo.processInfo.arguments.contains("-testing")
}
// MARK: Methods
func application(
_: UIApplication,
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil,
) -> Bool {
// Exit early if testing to avoid running any app functionality.
guard !isTesting else { return true }
let services = ServiceContainer()
let appModule = DefaultAppModule(services: services)
appProcessor = AppProcessor(appModule: appModule, services: services)
return true
}
}

View File

@ -0,0 +1,100 @@
import BitwardenKit
import SwiftUI
import TestHarnessShared
import UIKit
/// The app's `UIWindowSceneDelegate` which manages the app's window and scene lifecycle.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// MARK: Properties
/// The processor that manages application level logic.
var appProcessor: AppProcessor? {
(UIApplication.shared.delegate as? AppDelegateType)?.appProcessor
}
/// Whether the app is still starting up. This ensures the splash view isn't dismissed on start
/// up until the processor has shown the initial view.
var isStartingUp = true
/// Window shown as either the splash view on startup or when the app is backgrounded to
/// prevent private information from being visible in the app switcher.
var splashWindow: UIWindow?
/// The main window for this scene.
var window: UIWindow?
// MARK: Methods
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions,
) {
guard let windowScene = scene as? UIWindowScene else { return }
guard let appProcessor else {
if (UIApplication.shared.delegate as? AppDelegateType)?.isTesting == true {
// If the app is running tests, show a testing view.
window = buildSplashWindow(windowScene: windowScene)
window?.makeKeyAndVisible()
}
return
}
let rootViewController = RootViewController()
let appWindow = UIWindow(windowScene: windowScene)
appWindow.rootViewController = rootViewController
appWindow.makeKeyAndVisible()
window = appWindow
// Splash window. This is initially visible until the app's processor has finished starting.
splashWindow = buildSplashWindow(windowScene: windowScene)
// Start the app's processor and show the splash view until the initial view is shown.
Task {
await appProcessor.start(
navigator: rootViewController,
window: appWindow,
)
hideSplash()
isStartingUp = false
}
}
func sceneWillResignActive(_ scene: UIScene) {
showSplash()
}
func sceneDidBecomeActive(_ scene: UIScene) {
guard !isStartingUp else { return }
hideSplash()
}
// MARK: Private
/// Builds the splash window for display in the specified window scene.
///
/// - Parameter windowScene: The window scene that the splash window will be shown in.
/// - Returns: A window containing the splash view.
private func buildSplashWindow(windowScene: UIWindowScene) -> UIWindow {
let window = UIWindow(windowScene: windowScene)
window.isHidden = false
window.rootViewController = UIStoryboard(
name: "LaunchScreen",
bundle: .main,
).instantiateInitialViewController()
window.windowLevel = UIWindow.Level.alert + 1
return window
}
/// Hides the splash view.
private func hideSplash() {
UIView.animate(withDuration: UI.duration(0.4)) {
self.splashWindow?.alpha = 0
}
}
/// Shows the splash view.
private func showSplash() {
splashWindow?.alpha = 1
}
}

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.${BASE_BUNDLE_ID}</string>
<string>$(SHARED_APP_GROUP_IDENTIFIER)</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.bitwarden.testharness</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Test Harness</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
</array>
<key>CFBundleName</key>
<string>Test Harness</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>MinimumOSVersion</key>
<string>15.0</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
</dict>
</dict>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile~ipad</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<dict>
<key>arm64</key>
<true/>
</dict>
<key>UIStatusBarHidden</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Resources/Assets.xcassets/AppIcons.appiconset</string>
<key>BitwardenTestHarnessSharedAppGroup</key>
<string>$(SHARED_APP_GROUP_IDENTIFIER)</string>
</dict>
</plist>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina5_9" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22504"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="812"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Test Harness" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="BLO-Tb-egS">
<rect key="frame" x="87.666666666666686" y="390.66666666666669" width="200" height="30"/>
<constraints>
<constraint firstAttribute="height" constant="30" id="ssv-dM-0pp"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="24"/>
<color key="textColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="BLO-Tb-egS" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="ZNN-2t-45e"/>
<constraint firstItem="BLO-Tb-egS" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="rFh-72-gEC"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="52.671755725190835" y="374.64788732394368"/>
</scene>
</scenes>
</document>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View File

@ -0,0 +1,24 @@
import TestHarnessShared
import UIKit
/// The app's `UIApplicationDelegate` used when running tests.
class TestingAppDelegate: UIResponder, UIApplicationDelegate, AppDelegateType {
// MARK: Properties
/// The processor that manages application level logic.
var appProcessor: AppProcessor?
/// Whether the app is running for unit tests.
var isTesting: Bool {
true
}
// MARK: Methods
func application(
_: UIApplication,
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil,
) -> Bool {
true
}
}

View File

@ -0,0 +1,8 @@
import UIKit
/// Determine the app delegate class that should be used during this launch of the app. If the `TestingAppDelegate`
/// can be found, the app was launched in a test environment, and we should use the `TestingAppDelegate` for
/// handling all app lifecycle events.
private let appDelegateClass: AnyClass = NSClassFromString("TestHarnessTests.TestingAppDelegate") ?? AppDelegate.self
UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(appDelegateClass))

View File

@ -0,0 +1,40 @@
import BitwardenKit
import Foundation
/// The services provided by the `ServiceContainer`.
typealias Services = HasErrorReportBuilder
/// The default implementation of a container that provides the services used by the application.
///
public class ServiceContainer: Services {
// MARK: Properties
/// A helper for building an error report containing the details of an error that occurred.
public let errorReportBuilder: ErrorReportBuilder
// MARK: Initialization
/// Initialize a `ServiceContainer`.
///
/// - Parameters:
/// - errorReportBuilder: A helper for building an error report containing the details of an
public init(
errorReportBuilder: ErrorReportBuilder,
) {
self.errorReportBuilder = errorReportBuilder
}
public convenience init() {
let appInfoService = DefaultAppInfoService()
let stateService = DefaultStateService()
let errorReportBuilder = DefaultErrorReportBuilder(
activeAccountStateProvider: stateService,
appInfoService: appInfoService,
)
self.init(
errorReportBuilder: errorReportBuilder,
)
}
}

View File

@ -0,0 +1,16 @@
import BitwardenKit
/// Protocol for the service that controls the general state of the app.
protocol StateService {}
// MARK: - DefaultStateService
/// A default implementation of `StateService`.
///
actor DefaultStateService: StateService, ActiveAccountStateProvider {
// MARK: Methods
func getActiveAccountId() async throws -> String {
"Test-Harness-Account-ID"
}
}

View File

@ -0,0 +1,2 @@
# Ignore SwiftGen generated files
*.swift

View File

@ -0,0 +1,18 @@
sources:
- ..
templates:
- ../../Sourcery/Templates/AutoMockable.stencil
output:
Generated
exclude:
- Generated
- Tests
- TestHelpers
- Fixtures
args:
autoMockableImports: ["BitwardenKit"]
autoMockableTestableImports: ["TestHarnessShared"]

View File

@ -0,0 +1,11 @@
import Foundation
/// Actions that can be processed by a `SimpleLoginFormProcessor`.
///
enum SimpleLoginFormAction: Equatable {
/// The password field was updated.
case passwordChanged(String)
/// The username field was updated.
case usernameChanged(String)
}

View File

@ -0,0 +1,5 @@
import Foundation
/// Effects that can be processed by a `SimpleLoginFormProcessor`.
///
enum SimpleLoginFormEffect: Equatable {}

View File

@ -0,0 +1,41 @@
import BitwardenKit
import Combine
/// The processor for the simple login form test screen.
///
class SimpleLoginFormProcessor: StateProcessor<
SimpleLoginFormState,
SimpleLoginFormAction,
SimpleLoginFormEffect,
> {
// MARK: Types
typealias Services = HasErrorReporter
// MARK: Private Properties
/// The coordinator that handles navigation.
private let coordinator: AnyCoordinator<RootRoute, Void>
// MARK: Initialization
/// Initialize a `SimpleLoginFormProcessor`.
///
/// - Parameter coordinator: The coordinator that handles navigation.
///
init(coordinator: AnyCoordinator<RootRoute, Void>) {
self.coordinator = coordinator
super.init(state: SimpleLoginFormState())
}
// MARK: Methods
override func receive(_ action: SimpleLoginFormAction) {
switch action {
case let .usernameChanged(newValue):
state.username = newValue
case let .passwordChanged(newValue):
state.password = newValue
}
}
}

View File

@ -0,0 +1,16 @@
import Foundation
/// The state for the simple login form test screen.
///
struct SimpleLoginFormState: Equatable {
// MARK: Properties
/// The title of the screen.
var title: String = Localizations.simpleLoginForm
/// The username field value.
var username: String = ""
/// The password field value.
var password: String = ""
}

View File

@ -0,0 +1,83 @@
import BitwardenKit
import SwiftUI
/// A view that displays a simple login form for testing autofill functionality.
///
struct SimpleLoginFormView: View {
// MARK: Properties
/// The store used to render the view.
@ObservedObject var store: Store<SimpleLoginFormState, SimpleLoginFormAction, SimpleLoginFormEffect>
// MARK: View
var body: some View {
content
.navigationTitle(store.state.title)
.navigationBarTitleDisplayMode(.large)
}
// MARK: Private Views
/// The main content view.
private var content: some View {
Form {
Section {
TextField(
Localizations.username,
text: store.binding(
get: \.username,
send: SimpleLoginFormAction.usernameChanged,
),
)
.textContentType(.username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField(
Localizations.password,
text: store.binding(
get: \.password,
send: SimpleLoginFormAction.passwordChanged,
),
)
.textContentType(.password)
} header: {
Text(Localizations.credentials)
} footer: {
Text(Localizations.simpleLoginFormDescription)
}
Section {
if !store.state.username.isEmpty || !store.state.password.isEmpty {
VStack(alignment: .leading, spacing: 8) {
if !store.state.username.isEmpty {
Text(Localizations.usernameValue(store.state.username))
.styleGuide(.body)
}
if !store.state.password.isEmpty {
Text(Localizations.passwordValue(String(repeating: "", count: store.state.password.count)))
.styleGuide(.body)
}
}
} else {
Text(Localizations.enterCredentialsAbove)
.foregroundColor(.secondary)
.styleGuide(.body)
}
} header: {
Text(Localizations.formValues)
}
}
}
}
// MARK: - Previews
#if DEBUG
#Preview {
NavigationView {
SimpleLoginFormView(store: Store(processor: StateProcessor(state: SimpleLoginFormState())))
}
}
#endif

View File

@ -0,0 +1,100 @@
import BitwardenKit
import SwiftUI
import UIKit
// MARK: - AppCoordinator
/// A coordinator that manages the app's top-level navigation.
///
@MainActor
class AppCoordinator: Coordinator, HasRootNavigator {
// MARK: Types
/// The types of modules used by this coordinator.
typealias Module = RootModule
// MARK: Private Properties
/// The coordinator currently being displayed.
private var childCoordinator: AnyObject?
// MARK: Properties
/// The module to use for creating child coordinators.
let module: Module
/// The navigator to use for presenting screens.
private(set) weak var rootNavigator: RootNavigator?
/// The service container used by the coordinator.
private let services: Services
// MARK: Initialization
/// Creates a new `AppCoordinator`.
///
/// - Parameters:
/// - module: The module to use for creating child coordinators.
/// - rootNavigator: The navigator to use for presenting screens.
/// - services: The service container used by the coordinator.
///
init(
module: Module,
rootNavigator: RootNavigator,
services: Services,
) {
self.module = module
self.rootNavigator = rootNavigator
self.services = services
}
// MARK: Methods
func handleEvent(_ event: AppEvent, context: AnyObject?) async {
switch event {
case .didStart:
showRoot(route: .scenarioPicker)
}
}
func navigate(to route: AppRoute, context _: AnyObject?) {
switch route {
case let .root(rootRoute):
showRoot(route: rootRoute)
}
}
func start() {
// Nothing to do here - the initial route is specified by `AppProcessor` and this
// coordinator doesn't need to navigate within the `Navigator` since it's the root.
}
// MARK: Private Methods
/// Shows the root route.
///
/// - Parameter route: The root route to show.
///
private func showRoot(route: RootRoute) {
if let coordinator = childCoordinator as? AnyCoordinator<RootRoute, Void> {
coordinator.navigate(to: route)
} else {
guard let rootNavigator else { return }
let navigationController = UINavigationController()
navigationController.navigationBar.prefersLargeTitles = true
let coordinator = module.makeRootCoordinator(
stackNavigator: navigationController,
)
coordinator.start()
coordinator.navigate(to: route)
childCoordinator = coordinator
rootNavigator.show(child: navigationController)
}
}
}
// MARK: - HasErrorAlertServices
extension AppCoordinator: HasErrorAlertServices {
var errorAlertServices: ErrorAlertServices { services }
}

View File

@ -0,0 +1,8 @@
import Foundation
/// The events handled by the `AppCoordinator`.
///
public enum AppEvent {
/// The app has started.
case didStart
}

View File

@ -0,0 +1,65 @@
import BitwardenKit
import Foundation
// MARK: - AppModule
/// A protocol for an object that contains the dependencies for creating coordinators in the app flow.
///
@MainActor
public protocol AppModule: AnyObject {
/// Creates an `AppCoordinator`.
///
/// - Parameter navigator: The navigator to use for presenting screens.
/// - Returns: An `AppCoordinator` instance.
///
func makeAppCoordinator(
navigator: RootNavigator,
) -> AnyCoordinator<AppRoute, AppEvent>
}
// MARK: - DefaultAppModule
/// A default implementation of `AppModule`.
///
@MainActor
public class DefaultAppModule: AppModule {
// MARK: Properties
/// The services used by the module.
let services: ServiceContainer
// MARK: Initialization
/// Initialize a `DefaultAppModule`.
///
/// - Parameter services: The services used by the module.
///
public init(services: ServiceContainer) {
self.services = services
}
// MARK: Methods
public func makeAppCoordinator(
navigator: RootNavigator,
) -> AnyCoordinator<AppRoute, AppEvent> {
AppCoordinator(
module: self,
rootNavigator: navigator,
services: services,
).asAnyCoordinator()
}
}
// MARK: - RootModule
extension DefaultAppModule: RootModule {
func makeRootCoordinator(
stackNavigator: StackNavigator,
) -> AnyCoordinator<RootRoute, Void> {
RootCoordinator(
services: services,
stackNavigator: stackNavigator,
).asAnyCoordinator()
}
}

View File

@ -0,0 +1,58 @@
import BitwardenKit
import Combine
import Foundation
import UIKit
/// The `AppProcessor` processes actions received at the application level and contains the logic
/// to control the top-level flow through the app.
///
@MainActor
public class AppProcessor {
// MARK: Properties
/// The root module to use to create sub-coordinators.
let appModule: AppModule
/// The root coordinator of the app.
var coordinator: AnyCoordinator<AppRoute, AppEvent>?
/// The services used by the app.
let services: ServiceContainer
// MARK: Initialization
/// Initializes an `AppProcessor`.
///
/// - Parameters:
/// - appModule: The root module to use to create sub-coordinators.
/// - services: The services used by the app.
///
public init(
appModule: AppModule,
services: ServiceContainer,
) {
self.appModule = appModule
self.services = services
UI.applyDefaultAppearances()
}
// MARK: Methods
/// Starts the application flow by navigating the user to the first flow.
///
/// - Parameters:
/// - navigator: The object that will be used to navigate between routes.
/// - window: The window to use to set the app's theme.
///
public func start(
navigator: RootNavigator,
window: UIWindow?,
) async {
let coordinator = appModule.makeAppCoordinator(navigator: navigator)
coordinator.start()
self.coordinator = coordinator
await coordinator.handleEvent(.didStart)
}
}

View File

@ -0,0 +1,64 @@
import BitwardenKit
import XCTest
@testable import TestHarnessShared
/// Tests for `AppProcessor`.
///
class AppProcessorTests: BitwardenTestCase {
// MARK: Properties
var appModule: MockAppModule!
var subject: AppProcessor!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
appModule = MockAppModule()
let services = ServiceContainer()
subject = AppProcessor(appModule: appModule, services: services)
}
override func tearDown() {
super.tearDown()
appModule = nil
subject = nil
}
// MARK: Tests
/// `start()` creates and starts the app coordinator.
func test_start() async {
let navigator = MockRootNavigator()
await subject.start(navigator: navigator, window: nil)
XCTAssertTrue(appModule.makeAppCoordinatorCalled)
XCTAssertNotNil(subject.coordinator)
}
}
/// A mock `AppModule` for testing.
///
@MainActor
class MockAppModule: AppModule {
var makeAppCoordinatorCalled = false
func makeAppCoordinator(navigator: RootNavigator) -> AnyCoordinator<AppRoute, AppEvent> {
makeAppCoordinatorCalled = true
return AppCoordinator(
module: self,
rootNavigator: navigator,
services: ServiceContainer(),
).asAnyCoordinator()
}
}
extension MockAppModule: RootModule {
func makeRootCoordinator(stackNavigator: StackNavigator) -> AnyCoordinator<RootRoute, Void> {
RootCoordinator(
services: ServiceContainer(),
stackNavigator: stackNavigator,
).asAnyCoordinator()
}
}

View File

@ -0,0 +1,8 @@
import Foundation
/// The routes for navigating through the app.
///
public enum AppRoute {
/// A route to the root screen.
case root(RootRoute)
}

View File

@ -0,0 +1,2 @@
# Ignore SwiftGen generated files
*.swift

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
</dict>
</plist>

View File

@ -0,0 +1,13 @@
"TestHarness" = "Test Harness";
"TestScenarios" = "Test Scenarios";
"SimpleLoginForm" = "Simple Login Form";
"PasskeyAutofill" = "Passkey Autofill";
"CreatePasskey" = "Create Passkey";
"Username" = "Username";
"Password" = "Password";
"Credentials" = "Credentials";
"SimpleLoginFormDescription" = "Use this simple login form to test autofill functionality.";
"EnterCredentialsAbove" = "Enter credentials above";
"FormValues" = "Form Values";
"UsernameValue" = "Username: %@";
"PasswordValue" = "Password: %@";

View File

@ -0,0 +1,25 @@
import Foundation
/// Utility and factory methods for handling test harness shared resources.
///
public class TestHarnessResources {
/// The language code at initialization.
public static var initialLanguageCode: String?
/// Override SwiftGen's lookup function in order to determine the language manually.
///
/// - Parameters:
/// - key: The localization key.
/// - table: The localization table.
/// - fallbackValue: The fallback value if the key is not found.
/// - Returns: The localized string.
///
public static func localizationFunction(key: String, table: String, fallbackValue: String) -> String {
if let languageCode = initialLanguageCode,
let path = Bundle(for: TestHarnessResources.self).path(forResource: languageCode, ofType: "lproj"),
let bundle = Bundle(path: path) {
return bundle.localizedString(forKey: key, value: fallbackValue, table: table)
}
return Bundle.main.localizedString(forKey: key, value: fallbackValue, table: table)
}
}

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View File

@ -0,0 +1,74 @@
import BitwardenKit
import SwiftUI
import UIKit
// MARK: - RootCoordinator
/// A coordinator that manages navigation in the root flow of test scenarios.
///
@MainActor
class RootCoordinator: Coordinator, HasStackNavigator {
// MARK: Private Properties
/// The services used by this coordinator.
private let services: Services
/// The stack navigator used to display screens.
private(set) weak var stackNavigator: StackNavigator?
// MARK: Initialization
/// Creates a new `RootCoordinator`.
///
/// - Parameters:
/// - services: The services used by this coordinator.
/// - stackNavigator: The stack navigator used to display screens.
///
init(
services: Services,
stackNavigator: StackNavigator,
) {
self.services = services
self.stackNavigator = stackNavigator
}
// MARK: Methods
func navigate(to route: RootRoute, context: AnyObject?) {
switch route {
case .scenarioPicker:
showScenarioPicker()
case .simpleLoginForm:
showSimpleLoginForm()
}
}
func start() {
// Nothing to do here - the initial route is set by the parent coordinator.
}
// MARK: Private Methods
/// Shows the scenario picker screen.
///
private func showScenarioPicker() {
let processor = ScenarioPickerProcessor(coordinator: asAnyCoordinator())
let view = ScenarioPickerView(store: Store(processor: processor))
stackNavigator?.replace(view)
}
/// Shows the simple login form test screen.
///
private func showSimpleLoginForm() {
let processor = SimpleLoginFormProcessor(coordinator: asAnyCoordinator())
let view = SimpleLoginFormView(store: Store(processor: processor))
let viewController = UIHostingController(rootView: view)
stackNavigator?.push(viewController)
}
}
// MARK: - HasErrorAlertServices
extension RootCoordinator: HasErrorAlertServices {
var errorAlertServices: ErrorAlertServices { services }
}

View File

@ -0,0 +1,16 @@
import BitwardenKit
import Foundation
/// A protocol for an object that contains the dependencies for creating coordinators in the root flow.
///
@MainActor
protocol RootModule: AnyObject {
/// Creates a `RootCoordinator`.
///
/// - Parameter stackNavigator: The stack navigator to use for presenting screens.
/// - Returns: A `RootCoordinator` instance.
///
func makeRootCoordinator(
stackNavigator: StackNavigator,
) -> AnyCoordinator<RootRoute, Void>
}

View File

@ -0,0 +1,11 @@
import Foundation
/// The routes for navigating within the home flow.
///
public enum RootRoute {
/// A route to the scenario picker home screen.
case scenarioPicker
/// A route to the simple login form test screen.
case simpleLoginForm
}

View File

@ -0,0 +1,10 @@
import Foundation
/// Actions that can be processed by a `ScenarioPickerProcessor`.
///
enum ScenarioPickerAction: Equatable {
/// A test scenario was tapped.
///
/// - Parameter scenario: The scenario that was tapped.
case scenarioTapped(ScenarioItem)
}

View File

@ -0,0 +1,5 @@
import Foundation
/// Effects that can be processed by a `ScenarioPickerProcessor`.
///
enum ScenarioPickerEffect: Equatable {}

View File

@ -0,0 +1,39 @@
import BitwardenKit
import Combine
/// The processor for the scenario picker screen.
///
class ScenarioPickerProcessor: StateProcessor<ScenarioPickerState, ScenarioPickerAction, ScenarioPickerEffect> {
// MARK: Types
typealias Services = HasErrorReporter
// MARK: Private Properties
/// The coordinator that handles navigation.
private let coordinator: AnyCoordinator<RootRoute, Void>
// MARK: Initialization
/// Initialize a `ScenarioPickerProcessor`.
///
/// - Parameter coordinator: The coordinator that handles navigation.
///
init(coordinator: AnyCoordinator<RootRoute, Void>) {
self.coordinator = coordinator
super.init(state: ScenarioPickerState())
}
// MARK: Methods
override func receive(_ action: ScenarioPickerAction) {
switch action {
case let .scenarioTapped(scenario):
guard let route = scenario.route else {
// Scenario not yet implemented
return
}
coordinator.navigate(to: route)
}
}
}

View File

@ -0,0 +1,32 @@
import Foundation
/// A scenario that can be selected in the test harness.
///
struct ScenarioItem: Equatable, Identifiable {
// MARK: Properties
/// The unique identifier for the scenario.
let id: String
/// The display title for the scenario.
let title: String
/// The route to navigate to when this scenario is selected.
let route: RootRoute?
}
/// The state for the scenario picker screen.
///
struct ScenarioPickerState: Equatable {
// MARK: Properties
/// The title of the screen.
var title: String = Localizations.testHarness
/// The available test scenarios.
var scenarios: [ScenarioItem] = [
ScenarioItem(id: "simpleLoginForm", title: Localizations.simpleLoginForm, route: .simpleLoginForm),
ScenarioItem(id: "passkeyAutofill", title: Localizations.passkeyAutofill, route: nil),
ScenarioItem(id: "createPasskey", title: Localizations.createPasskey, route: nil),
]
}

View File

@ -0,0 +1,56 @@
import BitwardenKit
import SwiftUI
/// A view that displays a list of test scenarios available in the test harness.
///
struct ScenarioPickerView: View {
// MARK: Properties
/// The store used to render the view.
@ObservedObject var store: Store<ScenarioPickerState, ScenarioPickerAction, ScenarioPickerEffect>
// MARK: View
var body: some View {
content
.navigationTitle(store.state.title)
.navigationBarTitleDisplayMode(.large)
}
// MARK: Private Views
/// The main content view.
private var content: some View {
List {
Section {
ForEach(store.state.scenarios) { scenario in
Button {
store.send(.scenarioTapped(scenario))
} label: {
HStack {
Text(scenario.title)
.styleGuide(.body)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
}
}
.foregroundColor(.primary)
}
} header: {
Text(Localizations.testScenarios)
}
}
.listStyle(.insetGrouped)
}
}
// MARK: - Previews
#if DEBUG
#Preview {
NavigationView {
ScenarioPickerView(store: Store(processor: StateProcessor(state: ScenarioPickerState())))
}
}
#endif

View File

@ -0,0 +1,37 @@
{
"configurations" : [
{
"id" : "00000000-0000-0000-0000-000000000001",
"name" : "Test Scheme Action",
"options" : {
}
}
],
"defaultOptions" : {
"targetForVariableExpansion" : {
"containerPath" : "container:TestHarness.xcodeproj",
"identifier" : "TestHarness",
"name" : "TestHarness"
}
},
"testTargets" : [
{
"parallelizable" : true,
"target" : {
"containerPath" : "container:TestHarness.xcodeproj",
"identifier" : "TestHarnessTests",
"name" : "TestHarnessTests"
}
},
{
"parallelizable" : true,
"target" : {
"containerPath" : "container:TestHarness.xcodeproj",
"identifier" : "TestHarnessSharedTests",
"name" : "TestHarnessSharedTests"
}
}
],
"version" : 1
}

View File

@ -0,0 +1,33 @@
{
"configurations" : [
{
"id" : "00000000-0000-0000-0000-000000000001",
"name" : "Test Scheme Action",
"options" : {
}
}
],
"defaultOptions" : {
"targetForVariableExpansion" : {
"containerPath" : "container:TestHarness.xcodeproj",
"identifier" : "TestHarness",
"name" : "TestHarness"
}
},
"testTargets" : [
{
"parallelizable" : true,
"skippedTests" : [
"BitwardenKitTests",
"NetworkingTests"
],
"target" : {
"containerPath" : "container:TestHarness.xcodeproj",
"identifier" : "TestHarnessSharedTests",
"name" : "TestHarnessSharedTests"
}
}
],
"version" : 1
}

179
project-bwth.yml Normal file
View File

@ -0,0 +1,179 @@
name: TestHarness
configs:
Debug: debug
Release: release
include:
- path: project-common.yml
options:
fileTypes:
"icon":
file: true
settings:
MARKETING_VERSION: 1.0.0
CURRENT_PROJECT_VERSION: 1
projectReferences:
BitwardenKit:
path: BitwardenKit.xcodeproj
schemes:
TestHarness:
build:
targets:
TestHarness: all
TestHarnessTests: [test]
test:
commandLineArguments:
"-testing": true
environmentVariables:
TZ: UTC
gatherCoverageData: true
coverageTargets:
- TestHarness
- TestHarnessShared
- BitwardenKit/BitwardenKit
targets:
- TestHarnessTests
- TestHarnessSharedTests
- BitwardenKit/BitwardenKitTests
testPlans:
- path: TestPlans/TestHarness-Default.xctestplan
defaultPlan: true
- path: TestPlans/TestHarness-Unit.xctestplan
TestHarnessShared:
build:
targets:
TestHarnessShared: all
TestHarnessSharedTests: [test]
test:
commandLineArguments:
"-testing": true
environmentVariables:
TZ: UTC
gatherCoverageData: true
targets:
- TestHarnessSharedTests
testPlans:
- path: TestPlans/TestHarness-Default.xctestplan
defaultPlan: true
- path: TestPlans/TestHarness-Unit.xctestplan
targets:
TestHarness:
type: application
platform: iOS
configFiles:
Debug: Configs/TestHarness-Debug.xcconfig
Release: Configs/TestHarness-Release.xcconfig
settings:
base:
INFOPLIST_FILE: TestHarness/Application/Support/Info.plist
sources:
- path: TestHarness
excludes:
- "**/*Tests.*"
- "**/TestHelpers/*"
- path: README-bwth.md
buildPhase: none
dependencies:
- target: TestHarnessShared
- target: BitwardenKit/BitwardenKit
- target: BitwardenKit/BitwardenResources
postCompileScripts:
- script: |
if [[ ! "$PATH" =~ "/opt/homebrew/bin" ]]; then
PATH="/opt/homebrew/bin:$PATH"
fi
mint run swiftlint
name: Swiftlint
basedOnDependencyAnalysis: false
- script: |
if [[ ! "$PATH" =~ "/opt/homebrew/bin" ]]; then
PATH="/opt/homebrew/bin:$PATH"
fi
mint run swiftformat --lint --lenient .
name: SwiftFormat Lint
basedOnDependencyAnalysis: false
TestHarnessTests:
type: bundle.unit-test
platform: iOS
settings:
base:
INFOPLIST_FILE: TestHarness/Application/TestHelpers/Support/Info.plist
sources:
- path: TestHarness
includes:
- "**/*Tests.*"
- "**/TestHelpers/*"
- path: GlobalTestHelpers-bwth
dependencies:
- target: TestHarness
- target: BitwardenKit/TestHelpers
randomExecutionOrder: true
TestHarnessShared:
type: framework
platform: iOS
configFiles:
Debug: Configs/TestHarnessShared-Debug.xcconfig
Release: Configs/TestHarnessShared-Release.xcconfig
settings:
base:
APPLICATION_EXTENSION_API_ONLY: true
INFOPLIST_FILE: TestHarnessShared/UI/Platform/Application/Support/Info.plist
sources:
- path: TestHarnessShared
excludes:
- "**/*Tests.*"
- "**/TestHelpers/*"
- "**/Fixtures/*"
- "**/Sourcery/Generated/*"
- "**/sourcery.yml"
- path: TestHarnessShared/Sourcery/sourcery.yml
buildPhase: none
- path: TestHarnessShared/UI/Platform/Application/Support/Generated/Localizations.swift
optional: true
dependencies:
- target: BitwardenKit/BitwardenKit
- target: BitwardenKit/BitwardenResources
preBuildScripts:
- name: Sourcery
script: |
if [[ ! "$PATH" =~ "/opt/homebrew/bin" ]]; then
PATH="/opt/homebrew/bin:$PATH"
fi
mint run sourcery --config TestHarnessShared/Sourcery/sourcery.yml
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/TestHarnessShared/Sourcery/Generated/AutoMockable.generated.swift
- name: SwiftGen
script: |
if [[ ! "$PATH" =~ "/opt/homebrew/bin" ]]; then
PATH="/opt/homebrew/bin:$PATH"
fi
mint run swiftgen config run --config "swiftgen-bwth.yml"
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/TestHarnessShared/UI/Platform/Application/Support/Generated/Localizations.swift
TestHarnessSharedTests:
type: bundle.unit-test
platform: iOS
settings:
base:
BUNDLE_LOADER: "$(TEST_HOST)"
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TestHarness.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TestHarness"
INFOPLIST_FILE: TestHarnessShared/UI/Platform/Application/TestHelpers/Support/Info.plist
sources:
- path: TestHarnessShared
includes:
- "**/*Tests.*"
- "**/TestHelpers/*"
- "**/Fixtures/*"
- path: GlobalTestHelpers-bwth
- path: TestHarnessShared/Sourcery/Generated
optional: true
- path: TestHarnessShared/Sourcery/Generated/AutoMockable.generated.swift
optional: true
dependencies:
- target: TestHarness
- target: TestHarnessShared
- target: BitwardenKit/BitwardenKitMocks
- target: BitwardenKit/TestHelpers
randomExecutionOrder: true

11
swiftgen-bwth.yml Normal file
View File

@ -0,0 +1,11 @@
output_dir: TestHarnessShared/UI/Platform/Application/Support/Generated/
strings:
inputs:
- TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj
outputs:
- templateName: structured-swift5
output: Localizations.swift
params:
enumName: Localizations
publicAccess: true
lookupFunction: TestHarnessResources.localizationFunction(key:table:fallbackValue:)