- Add Apple Sign-In backend (JWKS verification, account linking, session management) - Implement push notification deep linking with NotificationDeepLinkRouter - Add jailbreak detection, runtime integrity monitoring, secure enclave service - Implement OAuth social login, token refresh, and secure logout flows - Add image caching (memory/disk), optimizer, upload queue, async semaphore - Implement notification analytics, type preferences, and category setup - Expand UI test suite with UITestBase, accessibility, auth flow, performance tests - Add CI pipeline for iOS UI tests (3 device sizes) and performance benchmarks - Restructure Xcode project to manual groups with KordantWidgets target - Add SwiftLint, Swift Collections/Algorithms/GoogleSignIn dependencies - Update project.yml for XcodeGen with new targets and configurations
226 lines
7.4 KiB
Swift
226 lines
7.4 KiB
Swift
import XCTest
|
|
|
|
/// Mock scenarios matching the main app's UITestScenario values.
|
|
/// These are passed via launch environment to configure app behavior.
|
|
enum UITestScenario: String, CaseIterable {
|
|
case emptyDashboard
|
|
case populatedDashboard
|
|
case authenticated
|
|
case unauthenticated
|
|
case darkWatchPopulated
|
|
case voicePrintPopulated
|
|
case spamShieldPopulated
|
|
case homeTitlePopulated
|
|
case removeBrokersPopulated
|
|
case settingsPopulated
|
|
case authError
|
|
case signupSuccess
|
|
case forgotPasswordSuccess
|
|
}
|
|
|
|
/// Base class for all Kordant UI tests.
|
|
/// Provides common setup, teardown, and helper methods.
|
|
class UITestBase: XCTestCase {
|
|
var app: XCUIApplication!
|
|
|
|
/// The mock scenario to use for this test class.
|
|
/// Subclasses can override to change the scenario.
|
|
class var scenario: UITestScenario { .populatedDashboard }
|
|
|
|
override func setUpWithError() throws {
|
|
continueAfterFailure = false
|
|
|
|
app = XCUIApplication()
|
|
|
|
// Configure testing mode via launch arguments and environment
|
|
app.launchArguments = ["-UITesting"]
|
|
app.launchEnvironment["UITestScenario"] = Self.scenario.rawValue
|
|
|
|
// Disable animations for faster, more reliable tests
|
|
app.launchEnvironment["UITestDisableAnimations"] = "YES"
|
|
|
|
// Reset any persisted state for clean test runs
|
|
app.launch()
|
|
|
|
// Wait for the app to settle after launch
|
|
let _ = app.wait(for: .runningForeground, timeout: 5)
|
|
}
|
|
|
|
override func tearDownWithError() throws {
|
|
// Capture screenshot on failure
|
|
if testRun?.hasSucceeded == false {
|
|
let screenshot = app.windows.firstMatch.screenshot()
|
|
let attachment = XCTAttachment(screenshot: screenshot)
|
|
attachment.name = "Failure - \(name)"
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
}
|
|
|
|
app = nil
|
|
super.tearDownWithError()
|
|
}
|
|
|
|
// MARK: - Navigation Helpers
|
|
|
|
/// Navigate to a specific tab in the main tab bar
|
|
func navigateToTab(_ tab: TabBarItem) {
|
|
app.tabBars.buttons[tab.rawValue].tap()
|
|
}
|
|
|
|
enum TabBarItem: String {
|
|
case dashboard = "Dashboard"
|
|
case services = "Services"
|
|
case alerts = "Alerts"
|
|
case settings = "Settings"
|
|
case account = "Account"
|
|
}
|
|
|
|
// MARK: - Element Queries
|
|
|
|
/// Find a button by its accessibility label or title
|
|
func button(_ label: String) -> XCUIElement {
|
|
app.buttons[label]
|
|
}
|
|
|
|
/// Find a text field by its placeholder or label
|
|
func textField(_ label: String) -> XCUIElement {
|
|
let field = app.textFields[label]
|
|
if field.exists { return field }
|
|
return app.textFields.containing(.staticText, identifier: label).element
|
|
}
|
|
|
|
/// Find a secure text field by its placeholder or label
|
|
func secureTextField(_ label: String) -> XCUIElement {
|
|
let field = app.secureTextFields[label]
|
|
if field.exists { return field }
|
|
return app.secureTextFields.containing(.staticText, identifier: label).element
|
|
}
|
|
|
|
/// Find static text by label
|
|
func text(_ label: String) -> XCUIElement {
|
|
app.staticTexts[label]
|
|
}
|
|
|
|
/// Find a navigation bar by its title
|
|
func navigationBar(_ title: String) -> XCUIElement {
|
|
app.navigationBars[title]
|
|
}
|
|
|
|
/// Find a toggle/switch by its label
|
|
func `switch`(_ label: String) -> XCUIElement {
|
|
app.switches[label]
|
|
}
|
|
|
|
// MARK: - Waiting Helpers
|
|
|
|
/// Wait for an element to appear with a timeout
|
|
@discardableResult
|
|
func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
|
|
let predicate = NSPredicate(format: "exists == true")
|
|
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
|
|
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
|
|
return result == .completed
|
|
}
|
|
|
|
/// Wait for an element to be hittable (visible and interactable)
|
|
@discardableResult
|
|
func waitForHittable(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
|
|
let predicate = NSPredicate(format: "hittable == true")
|
|
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
|
|
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
|
|
return result == .completed
|
|
}
|
|
|
|
/// Wait for an element to disappear
|
|
@discardableResult
|
|
func waitForAbsence(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
|
|
let predicate = NSPredicate(format: "exists == false")
|
|
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
|
|
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
|
|
return result == .completed
|
|
}
|
|
|
|
// MARK: - Gesture Helpers
|
|
|
|
/// Pull to refresh on a scroll view
|
|
func pullToRefresh() {
|
|
let window = app.windows.firstMatch
|
|
let start = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1))
|
|
let end = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6))
|
|
start.press(forDuration: 0, thenDragTo: end)
|
|
}
|
|
|
|
/// Scroll down in a scroll view
|
|
func scrollDown(times: Int = 1) {
|
|
for _ in 0..<times {
|
|
app.swipeUp()
|
|
}
|
|
}
|
|
|
|
/// Scroll up in a scroll view
|
|
func scrollUp(times: Int = 1) {
|
|
for _ in 0..<times {
|
|
app.swipeDown()
|
|
}
|
|
}
|
|
|
|
// MARK: - Auth Helpers
|
|
|
|
/// Type credentials into the login form
|
|
func typeLoginCredentials(email: String = "test@kordant.com", password: String = "TestPassword123") {
|
|
let emailField = app.textFields["Email"]
|
|
guard emailField.waitForExistence(timeout: 3) else { return }
|
|
|
|
emailField.tap()
|
|
emailField.typeText(email)
|
|
|
|
let passwordField = app.secureTextFields["Password"]
|
|
passwordField.tap()
|
|
passwordField.typeText(password)
|
|
}
|
|
|
|
/// Type credentials into the signup form
|
|
func typeSignupCredentials(name: String = "Test User", email: String = "test@kordant.com", password: String = "TestPassword123") {
|
|
let nameField = app.textFields.firstMatch
|
|
guard nameField.waitForExistence(timeout: 3) else { return }
|
|
|
|
nameField.tap()
|
|
nameField.typeText(name)
|
|
|
|
let emailField = app.textFields.element(boundBy: 1)
|
|
emailField.tap()
|
|
emailField.typeText(email)
|
|
|
|
let passwordField = app.secureTextFields.firstMatch
|
|
passwordField.tap()
|
|
passwordField.typeText(password)
|
|
|
|
// Confirm password field
|
|
let confirmField = app.secureTextFields.element(boundBy: 1)
|
|
if confirmField.exists {
|
|
confirmField.tap()
|
|
confirmField.typeText(password)
|
|
}
|
|
}
|
|
|
|
/// Dismiss the keyboard if present
|
|
func dismissKeyboard() {
|
|
if app.keyboards.element(boundBy: 0).exists {
|
|
app.toolbars.buttons["Done"].tap()
|
|
// Fallback: tap a non-interactive area
|
|
if app.keyboards.element(boundBy: 0).exists {
|
|
app.staticTexts.firstMatch.tap()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Take a screenshot and attach it to the test report
|
|
func captureScreen(name: String = #function) {
|
|
let screenshot = app.windows.firstMatch.screenshot()
|
|
let attachment = XCTAttachment(screenshot: screenshot)
|
|
attachment.name = name
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
}
|
|
}
|