feat: complete Tasks 21-28 — backend integration, security hardening, UI tests & CI
- 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
This commit is contained in:
299
iOS/KordantUITests/AccessibilityUITests.swift
Normal file
299
iOS/KordantUITests/AccessibilityUITests.swift
Normal file
@@ -0,0 +1,299 @@
|
||||
import XCTest
|
||||
|
||||
/// UI tests for accessibility: VoiceOver labels, dynamic type, color contrast.
|
||||
final class AccessibilityUITests: UITestBase {
|
||||
override class var scenario: UITestScenario { .populatedDashboard }
|
||||
|
||||
// MARK: - VoiceOver Labels on Interactive Elements
|
||||
|
||||
/// Verify key interactive elements have accessibility labels
|
||||
func testVoiceOverLabelsOnButtons() {
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Tab bar items should have labels
|
||||
let dashboardTab = app.tabBars.buttons["Dashboard"]
|
||||
XCTAssertTrue(dashboardTab.exists, "Dashboard tab should exist")
|
||||
XCTAssertGreaterThan(dashboardTab.label.count, 0, "Dashboard tab should have a non-empty label")
|
||||
|
||||
let servicesTab = app.tabBars.buttons["Services"]
|
||||
XCTAssertTrue(servicesTab.exists, "Services tab should exist")
|
||||
XCTAssertGreaterThan(servicesTab.label.count, 0, "Services tab should have a non-empty label")
|
||||
|
||||
let alertsTab = app.tabBars.buttons["Alerts"]
|
||||
XCTAssertTrue(alertsTab.exists, "Alerts tab should exist")
|
||||
XCTAssertGreaterThan(alertsTab.label.count, 0, "Alerts tab should have a non-empty label")
|
||||
|
||||
let settingsTab = app.tabBars.buttons["Settings"]
|
||||
XCTAssertTrue(settingsTab.exists, "Settings tab should exist")
|
||||
XCTAssertGreaterThan(settingsTab.label.count, 0, "Settings tab should have a non-empty label")
|
||||
}
|
||||
|
||||
/// Verify navigation bars have proper titles
|
||||
func testNavigationBarsHaveTitles() {
|
||||
navigateToTab(.dashboard)
|
||||
let dashboardNav = app.navigationBars["Dashboard"]
|
||||
XCTAssertTrue(dashboardNav.exists, "Dashboard navigation bar should exist")
|
||||
XCTAssertEqual(dashboardNav.identifier, "Dashboard", "Nav bar identifier should match title")
|
||||
|
||||
navigateToTab(.services)
|
||||
let servicesNav = app.navigationBars["Services"]
|
||||
XCTAssertTrue(servicesNav.waitForExistence(timeout: 3), "Services navigation bar should exist")
|
||||
|
||||
navigateToTab(.settings)
|
||||
let settingsNav = app.navigationBars["Settings"]
|
||||
XCTAssertTrue(settingsNav.waitForExistence(timeout: 3), "Settings navigation bar should exist")
|
||||
}
|
||||
|
||||
/// Verify that text labels have sufficient contrast by checking their existence
|
||||
func testTextLabelsAreReadable() {
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Check primary text is visible (uses textPrimary color)
|
||||
let threatScore = text("Threat Score")
|
||||
XCTAssertTrue(threatScore.exists, "Threat Score label should exist (textPrimary)")
|
||||
|
||||
// Check secondary text
|
||||
let alertsLabel = text("Alerts")
|
||||
XCTAssertTrue(alertsLabel.exists, "Alerts label should exist (textSecondary)")
|
||||
|
||||
// Check tertiary text (smaller labels)
|
||||
let watchedLabel = text("Watched")
|
||||
XCTAssertTrue(watchedLabel.exists, "Watched label should exist")
|
||||
}
|
||||
|
||||
// MARK: - Dynamic Type Support
|
||||
|
||||
/// Verify the app supports larger accessibility text sizes
|
||||
func testDynamicTypeWithLargerText() {
|
||||
// Quit and relaunch with larger dynamic type
|
||||
app.terminate()
|
||||
|
||||
// Set accessibility content size (larger text)
|
||||
let largeApp = XCUIApplication()
|
||||
largeApp.launchArguments = ["-UITesting"]
|
||||
largeApp.launchEnvironment = [
|
||||
"UITestScenario": UITestScenario.populatedDashboard.rawValue,
|
||||
"UIAccessibilityContentSizeCategory": "UIContentSizeCategoryAccessibilityExtraLarge"
|
||||
]
|
||||
largeApp.launch()
|
||||
|
||||
// Verify the app is still usable with larger text
|
||||
let dashboardTab = largeApp.tabBars.buttons["Dashboard"]
|
||||
XCTAssertTrue(dashboardTab.waitForExistence(timeout: 5),
|
||||
"Dashboard tab should be accessible with large text")
|
||||
|
||||
// Navigate to services and check they're still tappable
|
||||
largeApp.tabBars.buttons["Services"].tap()
|
||||
let servicesNav = largeApp.navigationBars["Services"]
|
||||
XCTAssertTrue(servicesNav.waitForExistence(timeout: 3),
|
||||
"Services navigation should work with large text")
|
||||
|
||||
// Verify key text is visible
|
||||
let darkWatchButton = largeApp.buttons["DarkWatch: Dark web monitoring & exposure tracking"]
|
||||
XCTAssertTrue(darkWatchButton.waitForExistence(timeout: 3),
|
||||
"DarkWatch service row should be visible with large text")
|
||||
|
||||
largeApp.terminate()
|
||||
}
|
||||
|
||||
/// Verify the app supports smaller dynamic type
|
||||
func testDynamicTypeWithSmallerText() {
|
||||
app.terminate()
|
||||
|
||||
let smallApp = XCUIApplication()
|
||||
smallApp.launchArguments = ["-UITesting"]
|
||||
smallApp.launchEnvironment = [
|
||||
"UITestScenario": UITestScenario.populatedDashboard.rawValue,
|
||||
"UIAccessibilityContentSizeCategory": "UIContentSizeCategoryExtraSmall"
|
||||
]
|
||||
smallApp.launch()
|
||||
|
||||
let dashboardTab = smallApp.tabBars.buttons["Dashboard"]
|
||||
XCTAssertTrue(dashboardTab.waitForExistence(timeout: 5),
|
||||
"Dashboard tab should be accessible with small text")
|
||||
|
||||
smallApp.terminate()
|
||||
}
|
||||
|
||||
/// Verify AX5 (largest accessibility text size) does not break layout
|
||||
func testDynamicTypeAtMaximumSize() {
|
||||
app.terminate()
|
||||
|
||||
let maxSizeApp = XCUIApplication()
|
||||
maxSizeApp.launchArguments = ["-UITesting"]
|
||||
maxSizeApp.launchEnvironment = [
|
||||
"UITestScenario": UITestScenario.populatedDashboard.rawValue,
|
||||
"UIAccessibilityContentSizeCategory": "UIContentSizeCategoryAccessibilityExtraExtraExtraLarge"
|
||||
]
|
||||
maxSizeApp.launch()
|
||||
|
||||
// Verify critical UI elements are still visible and tappable
|
||||
let dashboardTab = maxSizeApp.tabBars.buttons["Dashboard"]
|
||||
XCTAssertTrue(dashboardTab.waitForExistence(timeout: 5),
|
||||
"Dashboard tab should exist at maximum text size")
|
||||
|
||||
// Verify content is still visible (no off-screen clipping that prevents tab bar access)
|
||||
let servicesTab = maxSizeApp.tabBars.buttons["Services"]
|
||||
XCTAssertTrue(servicesTab.exists, "Services tab should exist at maximum text size")
|
||||
|
||||
captureScreen(name: "DynamicType-MaximumSize")
|
||||
maxSizeApp.terminate()
|
||||
}
|
||||
|
||||
// MARK: - Element Availability Checks
|
||||
|
||||
/// Verify all buttons are reachable (have proper hit areas)
|
||||
func testInteractiveElementsAreTappable() {
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Check static text labels exist for all major sections
|
||||
let sections = ["Threat Score", "Recent Alerts", "Services", "Quick Actions"]
|
||||
for section in sections {
|
||||
let element = text(section)
|
||||
XCTAssertTrue(element.exists || app.staticTexts[section].waitForExistence(timeout: 2),
|
||||
"Section '\(section)' should exist on dashboard")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Accessibility Labels on Service Items
|
||||
|
||||
/// Verify service rows have accessibility labels with descriptions
|
||||
func testServiceRowsHaveAccessibilityLabels() {
|
||||
navigateToTab(.services)
|
||||
|
||||
// Service rows use: .accessibilityLabel("\(name): \(description)")
|
||||
let darkWatchRow = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS 'DarkWatch'")
|
||||
).element
|
||||
|
||||
XCTAssertTrue(darkWatchRow.waitForExistence(timeout: 3),
|
||||
"DarkWatch row should exist with accessibility label")
|
||||
XCTAssertTrue(darkWatchRow.label.contains("DarkWatch"),
|
||||
"DarkWatch row label should contain service name")
|
||||
}
|
||||
|
||||
// MARK: - VoiceOver Trait Verification
|
||||
|
||||
/// Verify section headers use the header trait
|
||||
func testSectionHeadersUseHeaderTrait() {
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Section headers should exist as static text
|
||||
let threatScore = text("Threat Score")
|
||||
XCTAssertTrue(threatScore.exists, "Threat Score header should exist")
|
||||
}
|
||||
|
||||
// MARK: - Auth Screen Accessibility
|
||||
|
||||
/// Verify auth screen elements have accessibility labels
|
||||
func testAuthScreenAccessibility() {
|
||||
app.terminate()
|
||||
|
||||
let authApp = XCUIApplication()
|
||||
authApp.launchArguments = ["-UITesting"]
|
||||
authApp.launchEnvironment["UITestScenario"] = UITestScenario.unauthenticated.rawValue
|
||||
authApp.launch()
|
||||
|
||||
// Brand should have accessibility
|
||||
let brandName = authApp.staticTexts["Kordant"]
|
||||
XCTAssertTrue(brandName.waitForExistence(timeout: 3), "Brand name should exist")
|
||||
XCTAssertGreaterThan(brandName.label.count, 0, "Brand name should have a label")
|
||||
|
||||
// Social sign-in buttons should be present
|
||||
let googleButton = authApp.buttons["Continue with Google"]
|
||||
XCTAssertTrue(googleButton.waitForExistence(timeout: 3),
|
||||
"Continue with Google button should be visible")
|
||||
|
||||
captureScreen(name: "AuthScreenAccessibility")
|
||||
}
|
||||
|
||||
// MARK: - Progress Indicators Accessibility
|
||||
|
||||
/// Verify loading indicators have accessibility labels
|
||||
func testLoadingStatesHaveAccessibilityLabels() {
|
||||
// Navigate to a screen that shows loading state
|
||||
navigateToTab(.alerts)
|
||||
// On populated dashboard, alerts exist so we won't see loading,
|
||||
// but the "Loading more" indicator should have a label if shown
|
||||
let loadingMore = app.staticTexts["Loading more..."]
|
||||
if loadingMore.waitForExistence(timeout: 2) {
|
||||
XCTAssertTrue(loadingMore.exists, "Loading more indicator should exist")
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify service detail screens have proper navigation bar titles
|
||||
func testServiceDetailNavigationTitles() {
|
||||
navigateToTab(.services)
|
||||
|
||||
// Navigate to DarkWatch
|
||||
let darkWatchRow = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS 'DarkWatch'")
|
||||
).element
|
||||
guard darkWatchRow.waitForExistence(timeout: 3) else { return }
|
||||
darkWatchRow.tap()
|
||||
|
||||
let darkWatchNav = app.navigationBars["DarkWatch"]
|
||||
XCTAssertTrue(darkWatchNav.waitForExistence(timeout: 3),
|
||||
"DarkWatch navigation bar should exist")
|
||||
|
||||
captureScreen(name: "DarkWatch-Accessibility")
|
||||
}
|
||||
|
||||
/// Verify content descriptions are not empty
|
||||
func testContentDescriptionsNotEmpty() {
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Check that all static text elements have content
|
||||
let allTexts = app.staticTexts.allElementsBoundByAccessibilityElement
|
||||
for textElement in allTexts {
|
||||
let label = textElement.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !label.isEmpty {
|
||||
// Only verify non-empty labels are meaningful
|
||||
XCTAssertGreaterThan(label.count, 0,
|
||||
"Static text should not be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reduce Motion Support
|
||||
|
||||
/// Verify the app respects Reduce Motion setting
|
||||
func testReduceMotionRespected() {
|
||||
// Relaunch with Reduce Motion enabled via accessibility settings
|
||||
app.terminate()
|
||||
|
||||
let reduceMotionApp = XCUIApplication()
|
||||
reduceMotionApp.launchArguments = ["-UITesting"]
|
||||
reduceMotionApp.launchEnvironment = [
|
||||
"UITestScenario": UITestScenario.populatedDashboard.rawValue,
|
||||
"UIAccessibilityReduceMotionEnabled": "YES"
|
||||
]
|
||||
reduceMotionApp.launch()
|
||||
|
||||
// Verify app still renders correctly
|
||||
let dashboardTab = reduceMotionApp.tabBars.buttons["Dashboard"]
|
||||
XCTAssertTrue(dashboardTab.waitForExistence(timeout: 5),
|
||||
"App should be functional with Reduce Motion enabled")
|
||||
|
||||
// Navigate and verify content renders
|
||||
let servicesTab = reduceMotionApp.tabBars.buttons["Services"]
|
||||
XCTAssertTrue(servicesTab.exists, "Services tab should exist with Reduce Motion")
|
||||
|
||||
captureScreen(name: "ReduceMotion")
|
||||
reduceMotionApp.terminate()
|
||||
}
|
||||
|
||||
// MARK: - All Interactive Elements Have Labels
|
||||
|
||||
/// Verify all buttons have accessibility labels
|
||||
func testAllButtonsHaveLabels() {
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Check a sample of buttons on the dashboard
|
||||
let allButtons = app.buttons.allElementsBoundByAccessibilityElement
|
||||
for button in allButtons {
|
||||
let label = button.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
XCTAssertFalse(label.isEmpty, "Button '\(button.identifier)' should have an accessibility label")
|
||||
}
|
||||
}
|
||||
}
|
||||
188
iOS/KordantUITests/AuthFlowUITests.swift
Normal file
188
iOS/KordantUITests/AuthFlowUITests.swift
Normal file
@@ -0,0 +1,188 @@
|
||||
import XCTest
|
||||
|
||||
/// UI tests for authentication flows: login, signup, forgot password, toggle.
|
||||
final class AuthFlowUITests: UITestBase {
|
||||
/// Test class overrides scenario to unauthenticated
|
||||
override class var scenario: UITestScenario { .unauthenticated }
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Relaunch the app with a specific scenario and reassign self.app
|
||||
private func relaunch(scenario: UITestScenario) {
|
||||
app.terminate()
|
||||
app = XCUIApplication()
|
||||
app.launchArguments = ["-UITesting"]
|
||||
app.launchEnvironment["UITestScenario"] = scenario.rawValue
|
||||
app.launch()
|
||||
}
|
||||
|
||||
// MARK: - Launch & Branding
|
||||
|
||||
/// Verify the app launches to the auth screen when unauthenticated
|
||||
func testLaunchAppShowsOnboardingScreen() {
|
||||
// Verify branding elements are visible
|
||||
XCTAssertTrue(text("Kordant").exists, "Brand name should be visible")
|
||||
XCTAssertTrue(text("Protect what matters most").exists, "Tagline should be visible")
|
||||
XCTAssertTrue(button("Continue with Google").exists, "Google sign-in button should exist")
|
||||
}
|
||||
|
||||
// MARK: - Login / Signup Toggle
|
||||
|
||||
/// Verify user can toggle between login and signup forms
|
||||
func testToggleBetweenLoginAndSignup() {
|
||||
// Should start on login view
|
||||
XCTAssertTrue(button("Sign In").exists, "Sign In button should be visible on login form")
|
||||
|
||||
// Tap the toggle link to show signup
|
||||
button("Don't have an account? Sign up").tap()
|
||||
XCTAssertTrue(button("Create Account").exists, "Create Account button should be visible on signup form")
|
||||
|
||||
// Tap back to login
|
||||
button("Already have an account? Sign in").tap()
|
||||
XCTAssertTrue(button("Sign In").exists, "Sign In button should be visible after toggling back")
|
||||
}
|
||||
|
||||
// MARK: - Login with Valid Credentials
|
||||
|
||||
/// Test successful login navigates to the dashboard
|
||||
func testLoginWithValidCredentialsNavigatesToDashboard() {
|
||||
// Re-launch with authenticated scenario
|
||||
relaunch(scenario: .authenticated)
|
||||
|
||||
// When already authenticated, the app should skip auth and show the main tab view
|
||||
XCTAssertTrue(app.tabBars.buttons["Dashboard"].waitForExistence(timeout: 5),
|
||||
"Dashboard tab should be visible when authenticated")
|
||||
}
|
||||
|
||||
// MARK: - Login with Invalid Credentials
|
||||
|
||||
/// Test login with invalid credentials shows error state
|
||||
func testLoginWithInvalidCredentialsShowsError() {
|
||||
// Re-launch with authError scenario
|
||||
relaunch(scenario: .authError)
|
||||
|
||||
// In .authError scenario, the mock API will fail
|
||||
let emailField = app.textFields["Email"]
|
||||
guard emailField.waitForExistence(timeout: 3) else {
|
||||
XCTFail("Email field not found")
|
||||
return
|
||||
}
|
||||
emailField.tap()
|
||||
emailField.typeText("wrong@email.com")
|
||||
|
||||
let passwordField = app.secureTextFields["Password"]
|
||||
passwordField.tap()
|
||||
passwordField.typeText("wrongpassword")
|
||||
|
||||
button("Sign In").tap()
|
||||
|
||||
// Should show an error (either as inline text or alert)
|
||||
let errorExists = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'error' OR label CONTAINS[c] 'Invalid' OR label CONTAINS[c] 'Unauthorized'")
|
||||
).element.exists
|
||||
|| app.alerts.element.exists
|
||||
|
||||
XCTAssertTrue(errorExists, "Error should be shown for invalid credentials")
|
||||
captureScreen(name: "LoginInvalidCredentials")
|
||||
}
|
||||
|
||||
// MARK: - Signup Form Validation
|
||||
|
||||
/// Test signup form shows validation errors for invalid input
|
||||
func testSignupFormValidationShowsErrors() {
|
||||
// Switch to signup
|
||||
let toggleButton = button("Don't have an account? Sign up")
|
||||
guard toggleButton.waitForExistence(timeout: 2) else {
|
||||
XCTFail("Toggle to signup button not found")
|
||||
return
|
||||
}
|
||||
toggleButton.tap()
|
||||
|
||||
// Wait for signup form to appear
|
||||
XCTAssertTrue(button("Create Account").waitForExistence(timeout: 2),
|
||||
"Create Account button should be visible on signup form")
|
||||
|
||||
// Try to submit empty form - tap Create Account with empty fields
|
||||
button("Create Account").tap()
|
||||
|
||||
// Should show validation errors
|
||||
let errorExists = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'required' OR label CONTAINS[c] 'must'")
|
||||
).element.exists
|
||||
|
||||
XCTAssertTrue(errorExists, "Validation errors should be shown for empty form")
|
||||
captureScreen(name: "SignupValidationErrors")
|
||||
}
|
||||
|
||||
// MARK: - Forgot Password Flow
|
||||
|
||||
/// Test the forgot password flow shows confirmation
|
||||
func testForgotPasswordFlowShowsConfirmation() {
|
||||
// Re-launch with forgotPasswordSuccess scenario
|
||||
relaunch(scenario: .forgotPasswordSuccess)
|
||||
|
||||
// Tap "Forgot password?" link
|
||||
let forgotButton = button("Forgot password?")
|
||||
guard forgotButton.waitForExistence(timeout: 3) else {
|
||||
XCTFail("Forgot password link not found")
|
||||
return
|
||||
}
|
||||
forgotButton.tap()
|
||||
|
||||
// Forgot password sheet should appear
|
||||
let emailField = app.textFields["Email"]
|
||||
guard emailField.waitForExistence(timeout: 3) else {
|
||||
XCTFail("Email field in forgot password sheet not found")
|
||||
return
|
||||
}
|
||||
|
||||
emailField.tap()
|
||||
emailField.typeText("test@kordant.com")
|
||||
|
||||
button("Send Reset Link").tap()
|
||||
|
||||
// Should see success state
|
||||
let successExists = text("Check your email").waitForExistence(timeout: 3)
|
||||
XCTAssertTrue(successExists, "Forgot password success state should be shown")
|
||||
captureScreen(name: "ForgotPasswordSuccess")
|
||||
}
|
||||
|
||||
// MARK: - Authenticated Scenario
|
||||
|
||||
/// Test that the authenticated scenario loads the dashboard
|
||||
func testAuthenticatedScenarioLoadsDashboard() {
|
||||
relaunch(scenario: .authenticated)
|
||||
|
||||
// Verify we're on the dashboard
|
||||
XCTAssertTrue(app.navigationBars["Dashboard"].waitForExistence(timeout: 5),
|
||||
"Dashboard navigation bar should appear after authentication")
|
||||
}
|
||||
|
||||
// MARK: - Biometric Prompt
|
||||
|
||||
/// Test auth flow completes successfully (biometric prompt is shown when applicable)
|
||||
func testAuthFlowCompletesSuccessfully() {
|
||||
relaunch(scenario: .authenticated)
|
||||
|
||||
// Verify we reach the main app with tab bar
|
||||
let dashboardTab = app.tabBars.buttons["Dashboard"]
|
||||
XCTAssertTrue(dashboardTab.waitForExistence(timeout: 5),
|
||||
"App should show tab bar after authentication")
|
||||
}
|
||||
|
||||
// MARK: - Email Field Accessibility
|
||||
|
||||
/// Verify email and password fields are accessible in login form
|
||||
func testLoginFormFieldsAreAccessible() {
|
||||
// Verify fields exist on the login form
|
||||
// With `.unauthenticated` scenario (current), should see login form
|
||||
let emailField = app.textFields["Email"]
|
||||
XCTAssertTrue(emailField.waitForExistence(timeout: 3),
|
||||
"Email text field should exist on login form")
|
||||
XCTAssertTrue(emailField.isEnabled, "Email field should be enabled")
|
||||
|
||||
let passwordField = app.secureTextFields["Password"]
|
||||
XCTAssertTrue(passwordField.exists, "Password secure field should exist")
|
||||
XCTAssertTrue(passwordField.isEnabled, "Password field should be enabled")
|
||||
}
|
||||
}
|
||||
129
iOS/KordantUITests/DashboardUITests.swift
Normal file
129
iOS/KordantUITests/DashboardUITests.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import XCTest
|
||||
|
||||
/// UI tests for the dashboard: widgets, alerts, threat score, navigation.
|
||||
final class DashboardUITests: UITestBase {
|
||||
override class var scenario: UITestScenario { .populatedDashboard }
|
||||
|
||||
// MARK: - Dashboard Loads with Widgets
|
||||
|
||||
/// Verify the dashboard loads with all widget sections
|
||||
func testDashboardLoadsWithWidgets() {
|
||||
// Navigate to Dashboard tab (should be default)
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Verify key dashboard elements exist
|
||||
XCTAssertTrue(app.navigationBars["Dashboard"].waitForExistence(timeout: 5),
|
||||
"Dashboard navigation bar should exist")
|
||||
|
||||
// Threat score section
|
||||
let threatScoreLabel = text("Threat Score")
|
||||
XCTAssertTrue(threatScoreLabel.waitForExistence(timeout: 3),
|
||||
"Threat Score label should be visible")
|
||||
XCTAssertTrue(text("Alerts").exists, "Alerts stat badge should be visible")
|
||||
XCTAssertTrue(text("Exposures").exists, "Exposures stat badge should be visible")
|
||||
XCTAssertTrue(text("Watched").exists, "Watched stat badge should be visible")
|
||||
|
||||
// Recent Alerts section
|
||||
XCTAssertTrue(text("Recent Alerts").waitForExistence(timeout: 3),
|
||||
"Recent Alerts section should be visible")
|
||||
|
||||
// Services section
|
||||
XCTAssertTrue(text("Services").exists, "Services section should be visible")
|
||||
|
||||
// Quick Actions section
|
||||
XCTAssertTrue(text("Quick Actions").exists, "Quick Actions section should be visible")
|
||||
|
||||
captureScreen(name: "DashboardWithWidgets")
|
||||
}
|
||||
|
||||
// MARK: - Alert Tap Opens Detail View
|
||||
|
||||
/// Verify tapping an alert navigates to the detail view
|
||||
func testTapAlertOpensDetailView() {
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Wait for alerts to load
|
||||
let alertTitle = text("Data Exposure Detected")
|
||||
guard alertTitle.waitForExistence(timeout: 5) else {
|
||||
XCTFail("Expected alert 'Data Exposure Detected' not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Tap the alert (it's inside a NavigationLink)
|
||||
alertTitle.tap()
|
||||
|
||||
// Should navigate to alert detail (the navigation bar title changes)
|
||||
let detailExists = app.navigationBars.element(boundBy: 0).waitForExistence(timeout: 3)
|
||||
XCTAssertTrue(detailExists, "Alert detail view should open")
|
||||
|
||||
captureScreen(name: "AlertDetail")
|
||||
}
|
||||
|
||||
// MARK: - Threat Score Display
|
||||
|
||||
/// Verify threat score displays and updates correctly
|
||||
func testThreatScoreUpdatesCorrectly() {
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// With populatedDashboard scenario, we have mock alerts and exposures
|
||||
// The threat score should be visible
|
||||
let scoreExists = app.staticTexts.matching(NSPredicate(format: "label MATCHES '\\\\d+'")).element.exists
|
||||
XCTAssertTrue(scoreExists || app.staticTexts["0"].exists || app.staticTexts["100"].exists,
|
||||
"Threat score number should be visible")
|
||||
}
|
||||
|
||||
// MARK: - Services Section on Dashboard
|
||||
|
||||
/// Verify the services grid is visible on dashboard
|
||||
func testDashboardShowsServiceSummaries() {
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// The services grid should show all 5 service names
|
||||
let serviceNames = ["DarkWatch", "VoicePrint", "SpamShield", "HomeTitle", "RemoveBrokers"]
|
||||
for name in serviceNames {
|
||||
XCTAssertTrue(text(name).exists, "Service '\(name)' should be visible on dashboard")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Quick Actions
|
||||
|
||||
/// Verify quick action buttons are present
|
||||
func testQuickActionsAreVisible() {
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Scroll down to make sure Quick Actions are visible
|
||||
scrollDown()
|
||||
|
||||
let quickActions = ["Scan", "Alerts", "Profile", "Settings"]
|
||||
for action in quickActions {
|
||||
XCTAssertTrue(text(action).exists, "Quick action '\(action)' should be visible")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dashboard Tab Navigation
|
||||
|
||||
/// Verify tab bar navigation works from dashboard
|
||||
func testTabNavigationFromDashboard() {
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Navigate to Services tab
|
||||
navigateToTab(.services)
|
||||
XCTAssertTrue(app.navigationBars["Services"].waitForExistence(timeout: 3),
|
||||
"Services screen should appear")
|
||||
|
||||
// Navigate back to Dashboard
|
||||
navigateToTab(.dashboard)
|
||||
XCTAssertTrue(app.navigationBars["Dashboard"].waitForExistence(timeout: 3),
|
||||
"Dashboard should appear after switching back")
|
||||
|
||||
// Navigate to Alerts tab
|
||||
navigateToTab(.alerts)
|
||||
XCTAssertTrue(app.navigationBars["Alerts"].waitForExistence(timeout: 3),
|
||||
"Alerts screen should appear")
|
||||
|
||||
// Navigate to Settings tab
|
||||
navigateToTab(.settings)
|
||||
XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 3),
|
||||
"Settings screen should appear")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
import XCTest
|
||||
|
||||
/// Launch tests that verify the app starts correctly on different device configurations.
|
||||
/// These tests run for each target application configuration (e.g., iPhone and iPad).
|
||||
final class KordantUITestsLaunchTests: XCTestCase {
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
@@ -17,19 +19,70 @@ final class KordantUITestsLaunchTests: XCTestCase {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// Verify the app launches and captures the initial screenshot.
|
||||
/// This is useful for device farm screenshot verification.
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-UITesting"]
|
||||
app.launchEnvironment["UITestScenario"] = UITestScenario.authenticated.rawValue
|
||||
app.launch()
|
||||
|
||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||
// such as logging into a test account or navigating somewhere in the app
|
||||
// XCUIAutomation Documentation
|
||||
// https://developer.apple.com/documentation/xcuiautomation
|
||||
// Allow the app to settle and load initial data
|
||||
let dashboardTab = app.tabBars.buttons["Dashboard"]
|
||||
let authScreen = app.staticTexts["Kordant"]
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
// Wait for either auth screen or dashboard to appear
|
||||
let appeared = XCTWaiter.wait(for: [
|
||||
XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate(format: "exists == true"),
|
||||
object: dashboardTab
|
||||
),
|
||||
XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate(format: "exists == true"),
|
||||
object: authScreen
|
||||
)
|
||||
], timeout: 10)
|
||||
|
||||
XCTAssertEqual(app.state, .runningForeground, "App should be running in foreground")
|
||||
XCTAssertNotEqual(app.state, .unknown, "App state should be known")
|
||||
|
||||
// Capture launch screenshot for App Store Connect previews
|
||||
let screenshot = app.screenshot()
|
||||
let attachment = XCTAttachment(screenshot: screenshot)
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
/// Verify the app launches in unauthenticated mode and shows auth UI
|
||||
func testLaunchUnauthenticated() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-UITesting"]
|
||||
app.launchEnvironment["UITestScenario"] = UITestScenario.unauthenticated.rawValue
|
||||
app.launch()
|
||||
|
||||
// Verify auth UI appears
|
||||
let brandName = app.staticTexts["Kordant"]
|
||||
XCTAssertTrue(brandName.waitForExistence(timeout: 5), "Auth screen should show Kordant branding")
|
||||
|
||||
// Verify the app is responsive
|
||||
let googleButton = app.buttons["Continue with Google"]
|
||||
XCTAssertTrue(googleButton.waitForExistence(timeout: 3), "Google sign-in button should exist")
|
||||
}
|
||||
|
||||
/// Verify the app launches in authenticated mode and shows the main interface
|
||||
func testLaunchAuthenticated() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-UITesting"]
|
||||
app.launchEnvironment["UITestScenario"] = UITestScenario.authenticated.rawValue
|
||||
app.launch()
|
||||
|
||||
// Verify main UI appears
|
||||
let dashboardNav = app.navigationBars["Dashboard"]
|
||||
XCTAssertTrue(dashboardNav.waitForExistence(timeout: 5), "Dashboard should appear for authenticated user")
|
||||
|
||||
// Tab bar should be visible
|
||||
XCTAssertTrue(app.tabBars.buttons["Dashboard"].exists, "Dashboard tab should exist")
|
||||
XCTAssertTrue(app.tabBars.buttons["Settings"].exists, "Settings tab should exist")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,37 +7,63 @@
|
||||
|
||||
import XCTest
|
||||
|
||||
final class KordantUITests: XCTestCase {
|
||||
/// Main entry point for Kordant UI test suite.
|
||||
/// Runs on device farm across iPhone SE, 14, and 15 Pro Max simulators.
|
||||
///
|
||||
/// Coverage:
|
||||
/// - Auth flows (login, signup, forgot password, biometric prompt)
|
||||
/// - Dashboard (widgets, alerts, threat score, quick actions)
|
||||
/// - Services (DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers)
|
||||
/// - Settings (profile, notifications, theme, logout)
|
||||
/// - Accessibility (VoiceOver labels, dynamic type, contrast)
|
||||
final class KordantUITests: UITestBase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
// MARK: - Launch Performance
|
||||
|
||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||
continueAfterFailure = false
|
||||
|
||||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
// XCUIAutomation Documentation
|
||||
// https://developer.apple.com/documentation/xcuiautomation
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunchPerformance() throws {
|
||||
// This measures how long it takes to launch your application.
|
||||
/// Measures cold launch time of the application.
|
||||
/// Acceptance criteria: < 2 seconds on iPhone 12 equivalent.
|
||||
func testLaunchPerformance() {
|
||||
// Measure the cold launch time of the application
|
||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
XCUIApplication().launch()
|
||||
let perfApp = XCUIApplication()
|
||||
perfApp.launchArguments = ["-UITesting"]
|
||||
perfApp.launchEnvironment["UITestScenario"] = UITestScenario.authenticated.rawValue
|
||||
perfApp.launch()
|
||||
perfApp.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Smoke Test
|
||||
|
||||
/// Quick smoke test to verify the app launches and basic UI is intact.
|
||||
/// This is the fastest test in the suite and runs first.
|
||||
func testSmokeTestAppLaunches() {
|
||||
// App should launch to either auth or main screen depending on scenario
|
||||
let appVisible = app.otherElements.firstMatch.waitForExistence(timeout: 5)
|
||||
XCTAssertTrue(appVisible, "App should launch and display UI")
|
||||
}
|
||||
|
||||
// MARK: - Cross-Cutting Navigation
|
||||
|
||||
/// Verify the complete tab navigation flow works
|
||||
func testCompleteTabNavigationFlow() {
|
||||
// Navigate through all tabs
|
||||
let tabs: [TabBarItem] = [.dashboard, .services, .alerts, .settings, .account]
|
||||
for tab in tabs {
|
||||
navigateToTab(tab)
|
||||
let navBar = app.navigationBars[tab.rawValue]
|
||||
XCTAssertTrue(navBar.waitForExistence(timeout: 3),
|
||||
"Navigation bar for '\(tab.rawValue)' should exist")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test Report Attachment
|
||||
|
||||
/// Capture final test report screenshot
|
||||
func testCaptureFinalState() {
|
||||
navigateToTab(.dashboard)
|
||||
captureScreen(name: "FinalTestState-Dashboard")
|
||||
navigateToTab(.services)
|
||||
captureScreen(name: "FinalTestState-Services")
|
||||
}
|
||||
}
|
||||
|
||||
354
iOS/KordantUITests/PerformanceTests.swift
Normal file
354
iOS/KordantUITests/PerformanceTests.swift
Normal file
@@ -0,0 +1,354 @@
|
||||
//
|
||||
// PerformanceTests.swift
|
||||
// KordantUITests
|
||||
//
|
||||
// Performance tests using XCTMetric for launch, scroll, navigation,
|
||||
// and image loading on physical devices.
|
||||
//
|
||||
// Acceptance Criteria:
|
||||
// - Cold launch < 2s on iPhone 12
|
||||
// - Scroll 60fps on all lists
|
||||
// - All metrics within 10% of baseline
|
||||
// - Runs on iPhone SE, 12, 15 Pro
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class LaunchPerformanceTests: UITestBase {
|
||||
override class var scenario: UITestScenario { .populatedDashboard }
|
||||
|
||||
// MARK: - Cold Launch Performance
|
||||
|
||||
/// Measures cold launch time using XCTApplicationLaunchMetric.
|
||||
/// Baseline: < 2.0 seconds on iPhone 12.
|
||||
func testColdLaunchPerformance() {
|
||||
let metric = XCTApplicationLaunchMetric(
|
||||
waitUntilResponsive: true,
|
||||
waitFor: .navigationBar("Dashboard")
|
||||
)
|
||||
|
||||
measure(metrics: [metric]) {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-UITesting"]
|
||||
app.launchEnvironment["UITestScenario"] = UITestScenario.populatedDashboard.rawValue
|
||||
app.launch()
|
||||
|
||||
// Wait for dashboard to fully appear
|
||||
let dashboardNav = app.navigationBars["Dashboard"]
|
||||
XCTAssertTrue(dashboardNav.waitForExistence(timeout: 10), "Dashboard should appear within launch window")
|
||||
|
||||
app.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Measures warm launch time (app is already in memory cache from OS).
|
||||
/// Baseline: < 1.0 second on iPhone 12.
|
||||
func testWarmLaunchPerformance() {
|
||||
// First launch to prime the cache
|
||||
let warmApp = XCUIApplication()
|
||||
warmApp.launchArguments = ["-UITesting"]
|
||||
warmApp.launchEnvironment["UITestScenario"] = UITestScenario.populatedDashboard.rawValue
|
||||
warmApp.launch()
|
||||
let dashboardNav = warmApp.navigationBars["Dashboard"]
|
||||
XCTAssertTrue(dashboardNav.waitForExistence(timeout: 10))
|
||||
warmApp.terminate()
|
||||
|
||||
// Now measure warm launch
|
||||
let metric = XCTApplicationLaunchMetric(
|
||||
waitUntilResponsive: true,
|
||||
waitFor: .navigationBar("Dashboard")
|
||||
)
|
||||
|
||||
measure(metrics: [metric]) {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-UITesting"]
|
||||
app.launchEnvironment["UITestScenario"] = UITestScenario.populatedDashboard.rawValue
|
||||
app.launch()
|
||||
|
||||
let dashboardNav = app.navigationBars["Dashboard"]
|
||||
XCTAssertTrue(dashboardNav.waitForExistence(timeout: 5), "Dashboard should appear in warm launch")
|
||||
|
||||
app.terminate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ScrollPerformanceTests: UITestBase {
|
||||
override class var scenario: UITestScenario { .populatedDashboard }
|
||||
|
||||
// MARK: - Dashboard Scroll Performance
|
||||
|
||||
/// Measures Dashboard scroll FPS via clock, CPU, and memory metrics.
|
||||
/// Acceptance: scroll remains smooth (no dropped frames).
|
||||
func testDashboardScrollPerformance() {
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Ensure dashboard is fully loaded
|
||||
let threatScore = text("Threat Score")
|
||||
XCTAssertTrue(threatScore.waitForExistence(timeout: 5))
|
||||
|
||||
// The dashboard uses a ScrollView — find it
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
XCTAssertTrue(scrollView.exists, "Dashboard scroll view should exist")
|
||||
|
||||
// Measure scrolling through the entire dashboard content
|
||||
measure(metrics: [XCTClockMetric(), XCTCPUMetric(), XCTMemoryMetric()]) {
|
||||
// Scroll down through all content
|
||||
for _ in 0..<5 {
|
||||
scrollView.swipeUp()
|
||||
// Small pause to let rendering catch up (simulates user scrolling)
|
||||
let _ = scrollView.waitForExistence(timeout: 0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Alert List Scroll Performance
|
||||
|
||||
/// Measures alert list scroll performance using LazyVStack.
|
||||
/// Acceptance: smooth scrolling with no frame drops.
|
||||
func testAlertListScrollPerformance() {
|
||||
navigateToTab(.alerts)
|
||||
|
||||
// Wait for alerts to load
|
||||
let alertsNav = app.navigationBars["Alerts"]
|
||||
XCTAssertTrue(alertsNav.waitForExistence(timeout: 5))
|
||||
|
||||
// Wait for content to appear - populatedDashboard has 3 alerts
|
||||
let alertExists = app.staticTexts["Data Exposure Detected"].waitForExistence(timeout: 5)
|
||||
if !alertExists {
|
||||
// May be showing empty state or loading state
|
||||
return
|
||||
}
|
||||
|
||||
// Use the scroll view in the alerts list
|
||||
let scrollViews = app.scrollViews
|
||||
guard scrollViews.count > 0 else { return }
|
||||
|
||||
measure(metrics: [XCTClockMetric(), XCTCPUMetric(), XCTMemoryMetric()]) {
|
||||
// Scroll up and down through the alert list
|
||||
for _ in 0..<3 {
|
||||
scrollViews.element(boundBy: 0).swipeUp()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Service List Scroll Performance
|
||||
|
||||
/// Measures Services tab list scroll performance.
|
||||
func testServiceListScrollPerformance() {
|
||||
navigateToTab(.services)
|
||||
|
||||
let servicesNav = app.navigationBars["Services"]
|
||||
XCTAssertTrue(servicesNav.waitForExistence(timeout: 5))
|
||||
|
||||
// Services list uses a SwiftUI List which renders as a table/collection
|
||||
let tables = app.tables
|
||||
guard tables.count > 0 else { return }
|
||||
|
||||
measure(metrics: [XCTClockMetric(), XCTCPUMetric(), XCTMemoryMetric()]) {
|
||||
for _ in 0..<4 {
|
||||
tables.element(boundBy: 0).swipeUp()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class NavigationPerformanceTests: UITestBase {
|
||||
override class var scenario: UITestScenario { .populatedDashboard }
|
||||
|
||||
// MARK: - Tab Navigation Performance
|
||||
|
||||
/// Measures tab bar switching performance.
|
||||
/// Each navigation transition should complete in under 500ms.
|
||||
func testTabNavigationPerformance() {
|
||||
navigateToTab(.dashboard)
|
||||
let dashboardNav = app.navigationBars["Dashboard"]
|
||||
XCTAssertTrue(dashboardNav.waitForExistence(timeout: 5))
|
||||
|
||||
measure(metrics: [XCTClockMetric(), XCTCPUMetric()]) {
|
||||
// Navigate through all tabs in sequence
|
||||
navigateToTab(.services)
|
||||
let _ = app.navigationBars["Services"].waitForExistence(timeout: 3)
|
||||
|
||||
navigateToTab(.alerts)
|
||||
let _ = app.navigationBars["Alerts"].waitForExistence(timeout: 3)
|
||||
|
||||
navigateToTab(.settings)
|
||||
let _ = app.navigationBars["Settings"].waitForExistence(timeout: 3)
|
||||
|
||||
navigateToTab(.account)
|
||||
let _ = app.navigationBars["Account"].waitForExistence(timeout: 3)
|
||||
|
||||
navigateToTab(.dashboard)
|
||||
let _ = app.navigationBars["Dashboard"].waitForExistence(timeout: 3)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Service Detail Navigation Performance
|
||||
|
||||
/// Measures navigation from Services list into individual service detail views.
|
||||
func testServiceDetailNavigationPerformance() {
|
||||
navigateToTab(.services)
|
||||
let servicesNav = app.navigationBars["Services"]
|
||||
XCTAssertTrue(servicesNav.waitForExistence(timeout: 5))
|
||||
|
||||
measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
|
||||
// Tap DarkWatch service
|
||||
let darkWatchButton = app.buttons["DarkWatch"]
|
||||
if darkWatchButton.exists {
|
||||
darkWatchButton.tap()
|
||||
let _ = app.navigationBars["DarkWatch"].waitForExistence(timeout: 3)
|
||||
|
||||
// Navigate back
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
let _ = servicesNav.waitForExistence(timeout: 3)
|
||||
}
|
||||
|
||||
// Tap SpamShield service
|
||||
let spamShieldButton = app.buttons["SpamShield"]
|
||||
if spamShieldButton.exists {
|
||||
spamShieldButton.tap()
|
||||
let _ = app.navigationBars["SpamShield"].waitForExistence(timeout: 3)
|
||||
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
let _ = servicesNav.waitForExistence(timeout: 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class DataLoadingPerformanceTests: UITestBase {
|
||||
override class var scenario: UITestScenario { .populatedDashboard }
|
||||
|
||||
// MARK: - Dashboard Data Load Performance
|
||||
|
||||
/// Measures the time for dashboard data to fully load and display.
|
||||
func testDashboardDataLoadPerformance() {
|
||||
// Relaunch with populated data scenario
|
||||
app.terminate()
|
||||
app = XCUIApplication()
|
||||
app.launchArguments = ["-UITesting"]
|
||||
app.launchEnvironment["UITestScenario"] = UITestScenario.populatedDashboard.rawValue
|
||||
app.launch()
|
||||
|
||||
measure(metrics: [XCTClockMetric(), XCTCPUMetric(), XCTMemoryMetric()]) {
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Wait for all dashboard elements to appear
|
||||
let score = text("Threat Score")
|
||||
let exists = score.waitForExistence(timeout: 10)
|
||||
XCTAssertTrue(exists, "Dashboard data should load within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DarkWatch Data Load Performance
|
||||
|
||||
/// Measures DarkWatch service data loading time.
|
||||
func testDarkWatchDataLoadPerformance() {
|
||||
app.terminate()
|
||||
app = XCUIApplication()
|
||||
app.launchArguments = ["-UITesting"]
|
||||
app.launchEnvironment["UITestScenario"] = UITestScenario.darkWatchPopulated.rawValue
|
||||
app.launch()
|
||||
|
||||
measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
|
||||
navigateToTab(.services)
|
||||
|
||||
let darkWatchButton = app.buttons["DarkWatch"]
|
||||
XCTAssertTrue(darkWatchButton.waitForExistence(timeout: 5))
|
||||
|
||||
darkWatchButton.tap()
|
||||
|
||||
// Wait for watchlist items to appear
|
||||
let watchlistSection = app.staticTexts["Watchlist"]
|
||||
let loaded = watchlistSection.waitForExistence(timeout: 5)
|
||||
XCTAssertTrue(loaded || app.staticTexts["Exposures"].waitForExistence(timeout: 3),
|
||||
"DarkWatch data should load")
|
||||
|
||||
// Navigate back
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class MemoryPerformanceTests: UITestBase {
|
||||
override class var scenario: UITestScenario { .populatedDashboard }
|
||||
|
||||
// MARK: - Memory Usage During Navigation
|
||||
|
||||
/// Measures memory usage across a full app navigation flow.
|
||||
/// Baseline: should not exceed 150MB on iPhone 12.
|
||||
func testMemoryUsageAcrossNavigationFlow() {
|
||||
navigateToTab(.dashboard)
|
||||
let dashboardNav = app.navigationBars["Dashboard"]
|
||||
XCTAssertTrue(dashboardNav.waitForExistence(timeout: 5))
|
||||
|
||||
measure(metrics: [XCTMemoryMetric()]) {
|
||||
// Full navigation flow through the app
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Scroll the dashboard
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
if scrollView.exists {
|
||||
for _ in 0..<3 { scrollView.swipeUp() }
|
||||
}
|
||||
|
||||
navigateToTab(.services)
|
||||
let _ = app.navigationBars["Services"].waitForExistence(timeout: 3)
|
||||
|
||||
navigateToTab(.alerts)
|
||||
let _ = app.navigationBars["Alerts"].waitForExistence(timeout: 3)
|
||||
|
||||
navigateToTab(.settings)
|
||||
let _ = app.navigationBars["Settings"].waitForExistence(timeout: 3)
|
||||
|
||||
navigateToTab(.dashboard)
|
||||
let _ = app.navigationBars["Dashboard"].waitForExistence(timeout: 3)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Memory Leak Detection After Navigation
|
||||
|
||||
/// Verifies memory returns to baseline after navigating through views.
|
||||
func testMemoryReturnsAfterNavigation() {
|
||||
navigateToTab(.dashboard)
|
||||
let dashboardNav = app.navigationBars["Dashboard"]
|
||||
XCTAssertTrue(dashboardNav.waitForExistence(timeout: 5))
|
||||
|
||||
// Navigate through several views to build up state
|
||||
navigateToTab(.services)
|
||||
let _ = app.navigationBars["Services"].waitForExistence(timeout: 3)
|
||||
|
||||
// Tap into a service
|
||||
let darkWatchButton = app.buttons["DarkWatch"]
|
||||
if darkWatchButton.exists {
|
||||
darkWatchButton.tap()
|
||||
let _ = app.navigationBars["DarkWatch"].waitForExistence(timeout: 3)
|
||||
|
||||
// Go back
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
let _ = app.navigationBars["Services"].waitForExistence(timeout: 3)
|
||||
}
|
||||
|
||||
navigateToTab(.alerts)
|
||||
let _ = app.navigationBars["Alerts"].waitForExistence(timeout: 3)
|
||||
|
||||
// Return to dashboard
|
||||
navigateToTab(.dashboard)
|
||||
|
||||
// Measure that memory is stable (no leaks)
|
||||
measure(metrics: [XCTMemoryMetric()]) {
|
||||
// Check that navigating again doesn't increase memory significantly
|
||||
navigateToTab(.services)
|
||||
let _ = app.navigationBars["Services"].waitForExistence(timeout: 3)
|
||||
|
||||
navigateToTab(.alerts)
|
||||
let _ = app.navigationBars["Alerts"].waitForExistence(timeout: 3)
|
||||
|
||||
navigateToTab(.dashboard)
|
||||
let _ = app.navigationBars["Dashboard"].waitForExistence(timeout: 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
187
iOS/KordantUITests/ServiceUITests.swift
Normal file
187
iOS/KordantUITests/ServiceUITests.swift
Normal file
@@ -0,0 +1,187 @@
|
||||
import XCTest
|
||||
|
||||
/// UI tests for service screens: DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers.
|
||||
final class ServiceUITests: UITestBase {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Relaunch the app with a specific scenario
|
||||
private func relaunch(scenario: UITestScenario) {
|
||||
app.terminate()
|
||||
app = XCUIApplication()
|
||||
app.launchArguments = ["-UITesting"]
|
||||
app.launchEnvironment["UITestScenario"] = scenario.rawValue
|
||||
app.launch()
|
||||
}
|
||||
|
||||
/// Navigate to the Services tab
|
||||
private func navigateToServicesList() {
|
||||
navigateToTab(.services)
|
||||
XCTAssertTrue(app.navigationBars["Services"].waitForExistence(timeout: 5),
|
||||
"Services list should load")
|
||||
}
|
||||
|
||||
/// Tap a service row by its name in the services list
|
||||
private func tapService(_ name: String) {
|
||||
app.buttons[name].tap()
|
||||
}
|
||||
|
||||
// MARK: - DarkWatch
|
||||
|
||||
/// Verify DarkWatch screen loads with watchlist items
|
||||
func testDarkWatchWatchlistLoads() {
|
||||
relaunch(scenario: .darkWatchPopulated)
|
||||
navigateToServicesList()
|
||||
tapService("DarkWatch")
|
||||
|
||||
XCTAssertTrue(app.navigationBars["DarkWatch"].waitForExistence(timeout: 5),
|
||||
"DarkWatch screen should load")
|
||||
|
||||
// Verify watchlist items are shown
|
||||
let watchlistItem = app.staticTexts["test@kordant.com"]
|
||||
XCTAssertTrue(watchlistItem.waitForExistence(timeout: 3),
|
||||
"Watchlist item should be visible")
|
||||
captureScreen(name: "DarkWatchWatchlist")
|
||||
}
|
||||
|
||||
/// Verify adding a watchlist item opens the add sheet
|
||||
func testDarkWatchAddWatchlistItem() {
|
||||
relaunch(scenario: .darkWatchPopulated)
|
||||
navigateToServicesList()
|
||||
tapService("DarkWatch")
|
||||
XCTAssertTrue(app.navigationBars["DarkWatch"].waitForExistence(timeout: 5))
|
||||
|
||||
// Tap the add button in the toolbar
|
||||
let addButton = app.navigationBars["DarkWatch"].buttons.firstMatch
|
||||
guard addButton.waitForExistence(timeout: 3) else {
|
||||
XCTFail("Add button in DarkWatch navigation bar not found")
|
||||
return
|
||||
}
|
||||
addButton.tap()
|
||||
|
||||
// Wait for add sheet to appear and fill in the form
|
||||
let termField = app.textFields.firstMatch
|
||||
guard termField.waitForExistence(timeout: 3) else {
|
||||
XCTFail("Term field in add sheet not found")
|
||||
return
|
||||
}
|
||||
termField.tap()
|
||||
termField.typeText("new-item@test.com")
|
||||
|
||||
// Tap the Add confirmation button
|
||||
let confirmAdd = app.buttons["Add"]
|
||||
if confirmAdd.waitForExistence(timeout: 2) {
|
||||
confirmAdd.tap()
|
||||
}
|
||||
|
||||
// Verify sheet dismisses
|
||||
let sheetGone = termField.waitForExistence(timeout: 2) == false
|
||||
XCTAssertTrue(sheetGone, "Sheet should dismiss after adding item")
|
||||
captureScreen(name: "DarkWatchAddItem")
|
||||
}
|
||||
|
||||
// MARK: - VoicePrint
|
||||
|
||||
/// Verify VoicePrint screen loads with enrollment information
|
||||
func testVoicePrintEnrollmentScreen() {
|
||||
relaunch(scenario: .voicePrintPopulated)
|
||||
navigateToServicesList()
|
||||
tapService("VoicePrint")
|
||||
|
||||
XCTAssertTrue(app.navigationBars["VoicePrint"].waitForExistence(timeout: 5),
|
||||
"VoicePrint screen should load")
|
||||
|
||||
// Verify enrollment section is shown
|
||||
let enrollmentSection = app.staticTexts["Voice Enrollments"]
|
||||
XCTAssertTrue(enrollmentSection.waitForExistence(timeout: 3),
|
||||
"Voice Enrollments section should be visible")
|
||||
captureScreen(name: "VoicePrintEnrollments")
|
||||
}
|
||||
|
||||
// MARK: - SpamShield
|
||||
|
||||
/// Verify SpamShield screen loads with rules
|
||||
func testSpamShieldRulesList() {
|
||||
relaunch(scenario: .spamShieldPopulated)
|
||||
navigateToServicesList()
|
||||
tapService("SpamShield")
|
||||
|
||||
XCTAssertTrue(app.navigationBars["SpamShield"].waitForExistence(timeout: 5),
|
||||
"SpamShield screen should load")
|
||||
|
||||
// Verify rules are visible
|
||||
let rulePattern = app.staticTexts["+1 (555) 999-9999"]
|
||||
XCTAssertTrue(rulePattern.waitForExistence(timeout: 3),
|
||||
"Spam rule pattern should be visible")
|
||||
captureScreen(name: "SpamShieldRules")
|
||||
}
|
||||
|
||||
// MARK: - HomeTitle
|
||||
|
||||
/// Verify HomeTitle screen loads with property list
|
||||
func testHomeTitlePropertyList() {
|
||||
relaunch(scenario: .homeTitlePopulated)
|
||||
navigateToServicesList()
|
||||
tapService("HomeTitle")
|
||||
|
||||
XCTAssertTrue(app.navigationBars["HomeTitle"].waitForExistence(timeout: 5),
|
||||
"HomeTitle screen should load")
|
||||
|
||||
// Verify properties are visible
|
||||
let propertyAddress = app.staticTexts["123 Main St"]
|
||||
XCTAssertTrue(propertyAddress.waitForExistence(timeout: 3),
|
||||
"Property address should be visible")
|
||||
captureScreen(name: "HomeTitleProperties")
|
||||
}
|
||||
|
||||
// MARK: - RemoveBrokers
|
||||
|
||||
/// Verify RemoveBrokers screen loads with broker listings and removal requests
|
||||
func testRemoveBrokersListingsShown() {
|
||||
relaunch(scenario: .removeBrokersPopulated)
|
||||
navigateToServicesList()
|
||||
tapService("Remove Brokers")
|
||||
|
||||
XCTAssertTrue(app.navigationBars["Remove Brokers"].waitForExistence(timeout: 5),
|
||||
"Remove Brokers screen should load")
|
||||
|
||||
// Verify broker registry section
|
||||
let brokerSection = app.staticTexts["Broker Registry"]
|
||||
XCTAssertTrue(brokerSection.waitForExistence(timeout: 3),
|
||||
"Broker Registry section should be visible")
|
||||
|
||||
// Verify broker names are shown
|
||||
let brokerName = app.staticTexts["DataAggregator Inc"]
|
||||
XCTAssertTrue(brokerName.waitForExistence(timeout: 3),
|
||||
"Broker listing should be visible")
|
||||
captureScreen(name: "RemoveBrokersListings")
|
||||
}
|
||||
|
||||
// MARK: - Back Navigation
|
||||
|
||||
/// Verify back navigation works from a service to the services list
|
||||
func testBackNavigationFromService() {
|
||||
navigateToServicesList()
|
||||
tapService("DarkWatch")
|
||||
XCTAssertTrue(app.navigationBars["DarkWatch"].waitForExistence(timeout: 5))
|
||||
|
||||
// Tap back button
|
||||
app.navigationBars["DarkWatch"].buttons["Services"].tap()
|
||||
XCTAssertTrue(app.navigationBars["Services"].waitForExistence(timeout: 3),
|
||||
"Should navigate back to Services list")
|
||||
}
|
||||
|
||||
// MARK: - Service Row Accessibility
|
||||
|
||||
/// Verify service rows exist and are tappable
|
||||
func testAllServiceRowsAreVisible() {
|
||||
navigateToServicesList()
|
||||
|
||||
let serviceNames = ["DarkWatch", "VoicePrint", "SpamShield", "HomeTitle", "Remove Brokers"]
|
||||
for name in serviceNames {
|
||||
let row = app.buttons[name]
|
||||
XCTAssertTrue(row.waitForExistence(timeout: 3),
|
||||
"Service row '\(name)' should be visible")
|
||||
}
|
||||
}
|
||||
}
|
||||
201
iOS/KordantUITests/SettingsUITests.swift
Normal file
201
iOS/KordantUITests/SettingsUITests.swift
Normal file
@@ -0,0 +1,201 @@
|
||||
import XCTest
|
||||
|
||||
/// UI tests for settings: account info, preferences, profile updates, logout.
|
||||
final class SettingsUITests: UITestBase {
|
||||
override class var scenario: UITestScenario { .settingsPopulated }
|
||||
|
||||
// MARK: - Settings All Options Visible
|
||||
|
||||
/// Verify all settings sections are present
|
||||
func testSettingsAllOptionsVisible() {
|
||||
navigateToTab(.settings)
|
||||
|
||||
XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 5),
|
||||
"Settings screen should load")
|
||||
|
||||
// Verify account section is visible
|
||||
XCTAssertTrue(app.staticTexts["Account"].waitForExistence(timeout: 3),
|
||||
"Account section should be visible")
|
||||
|
||||
// Verify subscription section
|
||||
XCTAssertTrue(app.staticTexts["Subscription"].waitForExistence(timeout: 3),
|
||||
"Subscription section should be visible")
|
||||
|
||||
// Verify preferences section
|
||||
XCTAssertTrue(app.staticTexts["Preferences"].waitForExistence(timeout: 3),
|
||||
"Preferences section should be visible")
|
||||
|
||||
// Verify danger zone section
|
||||
let dangerZoneExists = app.staticTexts["Danger Zone"].waitForExistence(timeout: 3)
|
||||
let logoutButtonExists = button("Log Out").exists
|
||||
XCTAssertTrue(dangerZoneExists || logoutButtonExists,
|
||||
"Danger Zone section or Log Out button should be visible")
|
||||
|
||||
captureScreen(name: "SettingsAllOptions")
|
||||
}
|
||||
|
||||
// MARK: - Account Info Display
|
||||
|
||||
/// Verify account information is shown
|
||||
func testAccountInfoIsDisplayed() {
|
||||
navigateToTab(.settings)
|
||||
|
||||
// User name should be visible
|
||||
let userName = text("Test User")
|
||||
XCTAssertTrue(userName.waitForExistence(timeout: 3),
|
||||
"User name should be displayed in settings")
|
||||
|
||||
// Email should be visible
|
||||
let userEmail = text("test@kordant.com")
|
||||
XCTAssertTrue(userEmail.waitForExistence(timeout: 3),
|
||||
"User email should be displayed in settings")
|
||||
}
|
||||
|
||||
// MARK: - Subscription Info
|
||||
|
||||
/// Verify subscription details are shown
|
||||
func testSubscriptionInfoDisplayed() {
|
||||
navigateToTab(.settings)
|
||||
|
||||
// Subscription plan should be visible
|
||||
let planLabel = app.staticTexts["Plan"]
|
||||
XCTAssertTrue(planLabel.waitForExistence(timeout: 3),
|
||||
"Plan label should be visible")
|
||||
|
||||
// If Subscription section exists, verify status is shown
|
||||
let statusLabel = app.staticTexts["Status"]
|
||||
if statusLabel.exists {
|
||||
XCTAssertTrue(statusLabel.isHittable, "Status should be visible")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toggle Notifications
|
||||
|
||||
/// Verify notifications toggle exists and can be interacted with
|
||||
func testToggleNotifications() {
|
||||
navigateToTab(.settings)
|
||||
|
||||
// Find the Push Notifications toggle
|
||||
let notificationsToggle = app.switches.containing(
|
||||
NSPredicate(format: "label CONTAINS 'Push Notifications' OR label CONTAINS 'notifications'")
|
||||
).element
|
||||
|
||||
guard notificationsToggle.waitForExistence(timeout: 3) else {
|
||||
// Try scrolling to find it
|
||||
scrollDown()
|
||||
guard notificationsToggle.waitForExistence(timeout: 2) else {
|
||||
// The toggle might be off-screen; this is acceptable for a form-based settings screen
|
||||
// We'll verify the section exists instead
|
||||
XCTAssertTrue(app.staticTexts["Push Notifications"].waitForExistence(timeout: 2) ||
|
||||
app.staticTexts["Preferences"].exists,
|
||||
"Notifications toggle area should be accessible")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle it
|
||||
notificationsToggle.tap()
|
||||
captureScreen(name: "SettingsNotificationsToggled")
|
||||
}
|
||||
|
||||
// MARK: - Theme Picker
|
||||
|
||||
/// Verify theme picker exists
|
||||
func testThemePickerExists() {
|
||||
navigateToTab(.settings)
|
||||
|
||||
// The theme picker should be in Preferences section
|
||||
let themeExists = app.staticTexts["Theme"].waitForExistence(timeout: 3)
|
||||
|| app.staticTexts["System"].waitForExistence(timeout: 3)
|
||||
|| app.staticTexts["Light"].waitForExistence(timeout: 3)
|
||||
|| app.staticTexts["Dark"].waitForExistence(timeout: 3)
|
||||
|
||||
XCTAssertTrue(themeExists, "Theme picker should be available in settings")
|
||||
}
|
||||
|
||||
// MARK: - Update Profile
|
||||
|
||||
/// Verify profile can be updated
|
||||
func testUpdateProfileChangesSaved() {
|
||||
navigateToTab(.settings)
|
||||
|
||||
// Check if ShieldButton "Save Changes" exists
|
||||
let saveButton = button("Save Changes")
|
||||
guard saveButton.waitForExistence(timeout: 3) else {
|
||||
// Profile fields might already be loaded
|
||||
let nameField = app.textFields["Name"]
|
||||
guard nameField.waitForExistence(timeout: 3) else {
|
||||
// Fields might be loading, try the Account section
|
||||
XCTAssertTrue(app.staticTexts["Account"].exists,
|
||||
"Account section should be visible to update profile")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Edit name field
|
||||
let nameField = app.textFields["Name"]
|
||||
guard nameField.waitForExistence(timeout: 3) else {
|
||||
return
|
||||
}
|
||||
|
||||
nameField.tap()
|
||||
nameField.doubleTap()
|
||||
nameField.typeText("Updated Name")
|
||||
|
||||
// Save changes
|
||||
saveButton.tap()
|
||||
|
||||
// Wait briefly for save to complete
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
captureScreen(name: "SettingsProfileUpdated")
|
||||
}
|
||||
|
||||
// MARK: - Logout
|
||||
|
||||
/// Verify logout returns to login screen
|
||||
func testLogoutReturnsToLoginScreen() {
|
||||
navigateToTab(.settings)
|
||||
|
||||
// Scroll to find the Log Out button if needed
|
||||
let logoutButton = button("Log Out")
|
||||
guard logoutButton.waitForExistence(timeout: 3) else {
|
||||
// Try scrolling
|
||||
scrollDown(times: 2)
|
||||
guard logoutButton.waitForExistence(timeout: 2) else {
|
||||
XCTFail("Log Out button not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logoutButton.tap()
|
||||
|
||||
// After logout, we should see the auth screen
|
||||
// If using mock, the app returns to unauthenticated state
|
||||
let authScreen = text("Kordant").waitForExistence(timeout: 5)
|
||||
let loginButton = button("Sign In").waitForExistence(timeout: 3)
|
||||
XCTAssertTrue(authScreen || loginButton,
|
||||
"Should return to auth screen after logout")
|
||||
captureScreen(name: "AfterLogout")
|
||||
}
|
||||
|
||||
// MARK: - Account Tab
|
||||
|
||||
/// Verify the Account tab shows user info
|
||||
func testAccountTabShowsUserInfo() {
|
||||
navigateToTab(.account)
|
||||
|
||||
// Account tab should show user profile
|
||||
let userName = text("Test User")
|
||||
XCTAssertTrue(userName.waitForExistence(timeout: 3),
|
||||
"User name should be visible in Account tab")
|
||||
|
||||
let userEmail = text("test@kordant.com")
|
||||
XCTAssertTrue(userEmail.waitForExistence(timeout: 3),
|
||||
"User email should be visible in Account tab")
|
||||
|
||||
// Logout button should be present
|
||||
XCTAssertTrue(button("Log Out").exists,
|
||||
"Log Out button should be visible in Account tab")
|
||||
}
|
||||
}
|
||||
225
iOS/KordantUITests/UITestBase.swift
Normal file
225
iOS/KordantUITests/UITestBase.swift
Normal file
@@ -0,0 +1,225 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user