Close all 25 codex audit findings across KMP, iOS, and Android
Remediate all P0-S priority findings from cross-platform architecture audit: - Harden token storage with EncryptedSharedPreferences (Android) and Keychain (iOS) - Add SSL pinning and certificate validation to API clients - Fix subscription cache race conditions and add thread-safe access - Add input validation for document uploads and file type restrictions - Refactor DocumentApi to use proper multipart upload flow - Add rate limiting awareness and retry logic to API layer - Harden subscription tier enforcement in SubscriptionHelper - Add biometric prompt for sensitive actions (Login, Onboarding) - Fix notification permission handling and device registration - Add UI test infrastructure (page objects, fixtures, smoke tests) - Add CI workflow for mobile builds Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
73
iosApp/CaseraUITests/PageObjects/BaseScreen.swift
Normal file
73
iosApp/CaseraUITests/PageObjects/BaseScreen.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import XCTest
|
||||
|
||||
/// Base class for all page objects providing common waiting and assertion utilities.
|
||||
///
|
||||
/// Replaces ad-hoc `sleep()` calls with condition-based waits for reliable,
|
||||
/// non-flaky UI tests. All screen page objects should inherit from this class.
|
||||
class BaseScreen {
|
||||
let app: XCUIApplication
|
||||
let timeout: TimeInterval
|
||||
|
||||
init(app: XCUIApplication, timeout: TimeInterval = 10) {
|
||||
self.app = app
|
||||
self.timeout = timeout
|
||||
}
|
||||
|
||||
// MARK: - Wait Helpers (replaces fixed sleeps)
|
||||
|
||||
/// Waits for an element to exist within the timeout period.
|
||||
/// Fails the test with a descriptive message if the element does not appear.
|
||||
@discardableResult
|
||||
func waitForElement(_ element: XCUIElement, timeout: TimeInterval? = nil) -> XCUIElement {
|
||||
let t = timeout ?? self.timeout
|
||||
XCTAssertTrue(element.waitForExistence(timeout: t), "Element \(element) did not appear within \(t)s")
|
||||
return element
|
||||
}
|
||||
|
||||
/// Waits for an element to disappear within the timeout period.
|
||||
/// Fails the test if the element is still present after the timeout.
|
||||
func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval? = nil) {
|
||||
let t = timeout ?? self.timeout
|
||||
let predicate = NSPredicate(format: "exists == false")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
|
||||
let result = XCTWaiter().wait(for: [expectation], timeout: t)
|
||||
XCTAssertEqual(result, .completed, "Element \(element) did not disappear within \(t)s")
|
||||
}
|
||||
|
||||
/// Waits for an element to become hittable (visible and interactable).
|
||||
/// Returns the element for chaining.
|
||||
@discardableResult
|
||||
func waitForHittable(_ element: XCUIElement, timeout: TimeInterval? = nil) -> XCUIElement {
|
||||
let t = timeout ?? self.timeout
|
||||
let predicate = NSPredicate(format: "isHittable == true")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
|
||||
_ = XCTWaiter().wait(for: [expectation], timeout: t)
|
||||
return element
|
||||
}
|
||||
|
||||
// MARK: - State Assertions
|
||||
|
||||
/// Asserts that an element with the given accessibility identifier exists.
|
||||
func assertExists(_ identifier: String, file: StaticString = #file, line: UInt = #line) {
|
||||
let element = app.descendants(matching: .any)[identifier]
|
||||
XCTAssertTrue(element.waitForExistence(timeout: timeout), "Element '\(identifier)' not found", file: file, line: line)
|
||||
}
|
||||
|
||||
/// Asserts that an element with the given accessibility identifier does not exist.
|
||||
func assertNotExists(_ identifier: String, file: StaticString = #file, line: UInt = #line) {
|
||||
let element = app.descendants(matching: .any)[identifier]
|
||||
XCTAssertFalse(element.exists, "Element '\(identifier)' should not exist", file: file, line: line)
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
/// Taps the first button in the navigation bar (typically the back button).
|
||||
func tapBackButton() {
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
}
|
||||
|
||||
/// Subclasses must override this property to indicate whether the screen is currently displayed.
|
||||
var isDisplayed: Bool {
|
||||
fatalError("Subclasses must override isDisplayed")
|
||||
}
|
||||
}
|
||||
86
iosApp/CaseraUITests/PageObjects/LoginScreen.swift
Normal file
86
iosApp/CaseraUITests/PageObjects/LoginScreen.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import XCTest
|
||||
|
||||
/// Page object for the login screen.
|
||||
///
|
||||
/// Uses accessibility identifiers from `AccessibilityIdentifiers.Authentication`
|
||||
/// to locate elements. Provides typed actions for login flow interactions.
|
||||
class LoginScreen: BaseScreen {
|
||||
|
||||
// MARK: - Elements
|
||||
|
||||
var emailField: XCUIElement {
|
||||
app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
}
|
||||
|
||||
var passwordField: XCUIElement {
|
||||
// Password field may be a SecureTextField or regular TextField depending on visibility toggle
|
||||
let secure = app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||||
if secure.exists { return secure }
|
||||
return app.textFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||||
}
|
||||
|
||||
var loginButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||||
}
|
||||
|
||||
var appleSignInButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.appleSignInButton]
|
||||
}
|
||||
|
||||
var signUpButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.signUpButton]
|
||||
}
|
||||
|
||||
var forgotPasswordButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.forgotPasswordButton]
|
||||
}
|
||||
|
||||
var passwordVisibilityToggle: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.passwordVisibilityToggle]
|
||||
}
|
||||
|
||||
var welcomeText: XCUIElement {
|
||||
app.staticTexts["Welcome Back"]
|
||||
}
|
||||
|
||||
override var isDisplayed: Bool {
|
||||
emailField.waitForExistence(timeout: timeout)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
/// Logs in with the provided credentials and returns a MainTabScreen.
|
||||
/// Waits for the email field to appear before typing.
|
||||
@discardableResult
|
||||
func login(email: String, password: String) -> MainTabScreen {
|
||||
waitForElement(emailField).tap()
|
||||
emailField.typeText(email)
|
||||
|
||||
let pwField = passwordField
|
||||
pwField.tap()
|
||||
pwField.typeText(password)
|
||||
|
||||
loginButton.tap()
|
||||
return MainTabScreen(app: app)
|
||||
}
|
||||
|
||||
/// Taps the sign up / register link and returns a RegisterScreen.
|
||||
@discardableResult
|
||||
func tapSignUp() -> RegisterScreen {
|
||||
waitForElement(signUpButton).tap()
|
||||
return RegisterScreen(app: app)
|
||||
}
|
||||
|
||||
/// Taps the forgot password link.
|
||||
func tapForgotPassword() {
|
||||
waitForElement(forgotPasswordButton).tap()
|
||||
}
|
||||
|
||||
/// Toggles password visibility and returns whether the password is now visible.
|
||||
@discardableResult
|
||||
func togglePasswordVisibility() -> Bool {
|
||||
waitForElement(passwordVisibilityToggle).tap()
|
||||
// If a regular text field with the password identifier exists, password is visible
|
||||
return app.textFields[AccessibilityIdentifiers.Authentication.passwordField].exists
|
||||
}
|
||||
}
|
||||
88
iosApp/CaseraUITests/PageObjects/MainTabScreen.swift
Normal file
88
iosApp/CaseraUITests/PageObjects/MainTabScreen.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import XCTest
|
||||
|
||||
/// Page object for the main tab view that appears after login.
|
||||
///
|
||||
/// Provides navigation to each tab (Residences, Tasks, Contractors, Documents, Profile)
|
||||
/// and a logout flow. Uses predicate-based element lookup to match the existing test patterns.
|
||||
class MainTabScreen: BaseScreen {
|
||||
|
||||
// MARK: - Tab Elements
|
||||
|
||||
var residencesTab: XCUIElement {
|
||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
}
|
||||
|
||||
var tasksTab: XCUIElement {
|
||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
}
|
||||
|
||||
var contractorsTab: XCUIElement {
|
||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
||||
}
|
||||
|
||||
var documentsTab: XCUIElement {
|
||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Documents'")).firstMatch
|
||||
}
|
||||
|
||||
var profileTab: XCUIElement {
|
||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch
|
||||
}
|
||||
|
||||
override var isDisplayed: Bool {
|
||||
residencesTab.waitForExistence(timeout: timeout)
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
@discardableResult
|
||||
func goToResidences() -> Self {
|
||||
waitForElement(residencesTab).tap()
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func goToTasks() -> Self {
|
||||
waitForElement(tasksTab).tap()
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func goToContractors() -> Self {
|
||||
waitForElement(contractorsTab).tap()
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func goToDocuments() -> Self {
|
||||
waitForElement(documentsTab).tap()
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func goToProfile() -> Self {
|
||||
waitForElement(profileTab).tap()
|
||||
return self
|
||||
}
|
||||
|
||||
// MARK: - Logout
|
||||
|
||||
/// Logs out by navigating to the Profile tab and tapping the logout button.
|
||||
/// Handles the confirmation alert automatically.
|
||||
func logout() {
|
||||
goToProfile()
|
||||
|
||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton]
|
||||
if logoutButton.waitForExistence(timeout: 5) {
|
||||
waitForHittable(logoutButton).tap()
|
||||
|
||||
// Handle confirmation alert
|
||||
let alert = app.alerts.firstMatch
|
||||
if alert.waitForExistence(timeout: 3) {
|
||||
let confirmLogout = alert.buttons["Log Out"]
|
||||
if confirmLogout.exists {
|
||||
confirmLogout.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
iosApp/CaseraUITests/PageObjects/RegisterScreen.swift
Normal file
86
iosApp/CaseraUITests/PageObjects/RegisterScreen.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import XCTest
|
||||
|
||||
/// Page object for the registration screen.
|
||||
///
|
||||
/// Uses accessibility identifiers from `AccessibilityIdentifiers.Authentication`
|
||||
/// to locate registration form elements and perform sign-up actions.
|
||||
class RegisterScreen: BaseScreen {
|
||||
|
||||
// MARK: - Elements
|
||||
|
||||
var usernameField: XCUIElement {
|
||||
app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||||
}
|
||||
|
||||
var emailField: XCUIElement {
|
||||
app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
|
||||
}
|
||||
|
||||
var passwordField: XCUIElement {
|
||||
app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
|
||||
}
|
||||
|
||||
var confirmPasswordField: XCUIElement {
|
||||
app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
|
||||
}
|
||||
|
||||
var registerButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
|
||||
}
|
||||
|
||||
var cancelButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.registerCancelButton]
|
||||
}
|
||||
|
||||
/// Fallback element lookup for the register/create account button using predicate
|
||||
var registerButtonByLabel: XCUIElement {
|
||||
app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Register' OR label CONTAINS[c] 'Create Account'")).firstMatch
|
||||
}
|
||||
|
||||
override var isDisplayed: Bool {
|
||||
// Registration screen is visible if any of the register-specific fields exist
|
||||
let usernameExists = usernameField.waitForExistence(timeout: timeout)
|
||||
let emailExists = emailField.exists
|
||||
return usernameExists || emailExists
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
/// Fills in the registration form and submits it.
|
||||
/// Returns a MainTabScreen assuming successful registration leads to the main app.
|
||||
@discardableResult
|
||||
func register(username: String, email: String, password: String) -> MainTabScreen {
|
||||
waitForElement(usernameField).tap()
|
||||
usernameField.typeText(username)
|
||||
|
||||
emailField.tap()
|
||||
emailField.typeText(email)
|
||||
|
||||
passwordField.tap()
|
||||
passwordField.typeText(password)
|
||||
|
||||
confirmPasswordField.tap()
|
||||
confirmPasswordField.typeText(password)
|
||||
|
||||
// Try accessibility identifier first, fall back to label search
|
||||
if registerButton.exists {
|
||||
registerButton.tap()
|
||||
} else {
|
||||
registerButtonByLabel.tap()
|
||||
}
|
||||
|
||||
return MainTabScreen(app: app)
|
||||
}
|
||||
|
||||
/// Taps cancel to return to the login screen.
|
||||
@discardableResult
|
||||
func tapCancel() -> LoginScreen {
|
||||
if cancelButton.exists {
|
||||
cancelButton.tap()
|
||||
} else {
|
||||
// Fall back to navigation back button
|
||||
tapBackButton()
|
||||
}
|
||||
return LoginScreen(app: app)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user