- Migrate Suite4-10, SmokeTests, NavigationCriticalPathTests to AuthenticatedTestCase with seeded admin account and real backend login - Add 34 accessibility identifiers across 11 app views (task completion, profile, notifications, theme, join residence, manage users, forms) - Create FeatureCoverageTests (14 tests) covering previously untested features: profile edit, theme selection, notification prefs, task completion, manage users, join residence, task templates - Create MultiUserSharingTests (18 API tests) and MultiUserSharingUITests (8 XCUI tests) for full cross-user residence sharing lifecycle - Add cleanup infrastructure: SuiteZZ_CleanupTests auto-wipes test data after runs, cleanup_test_data.sh script for manual reset via admin API - Add share code API methods to TestAccountAPIClient (generateShareCode, joinWithCode, getShareCode, listResidenceUsers, removeUser) - Fix app bugs found by tests: - ResidencesListView join callback now uses forceRefresh:true - APILayer invalidates task cache when residence count changes - AllTasksView auto-reloads tasks when residence list changes - Fix test quality: keyboard focus waits, Save/Add button label matching, Documents tab label (Docs), remove API verification from UI tests - DataLayerTests and PasswordResetTests now verify through UI, not API calls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
306 lines
12 KiB
Swift
306 lines
12 KiB
Swift
import XCTest
|
|
|
|
/// Base class for tests requiring a logged-in session against the real local backend.
|
|
///
|
|
/// By default, creates a fresh verified account via the API, launches the app
|
|
/// (without `--ui-test-mock-auth`), and drives the UI through login.
|
|
///
|
|
/// Override `useSeededAccount` to log in with a pre-existing database account instead.
|
|
/// Override `performUILogin` to skip the UI login step (if you only need the API session).
|
|
///
|
|
/// ## Data Seeding & Cleanup
|
|
/// Use the `cleaner` property to seed data that auto-cleans in tearDown:
|
|
/// ```
|
|
/// let residence = cleaner.seedResidence(name: "My Test Home")
|
|
/// let task = cleaner.seedTask(residenceId: residence.id)
|
|
/// ```
|
|
/// Or seed without tracking via `TestDataSeeder` and track manually:
|
|
/// ```
|
|
/// let res = TestDataSeeder.createResidence(token: session.token)
|
|
/// cleaner.trackResidence(res.id)
|
|
/// ```
|
|
class AuthenticatedTestCase: BaseUITestCase {
|
|
|
|
/// The active test session, populated during setUp.
|
|
var session: TestSession!
|
|
|
|
/// Tracks and cleans up resources created during the test.
|
|
/// Initialized in setUp after the session is established.
|
|
private(set) var cleaner: TestDataCleaner!
|
|
|
|
/// Override to `true` in subclasses that should use the pre-seeded admin account.
|
|
var useSeededAccount: Bool { false }
|
|
|
|
/// Seeded account credentials. Override in subclasses that use a different seeded user.
|
|
var seededUsername: String { "admin" }
|
|
var seededPassword: String { "test1234" }
|
|
|
|
/// Override to `false` to skip driving the app through the login UI.
|
|
var performUILogin: Bool { true }
|
|
|
|
/// Skip onboarding so the app goes straight to the login screen.
|
|
override var completeOnboarding: Bool { true }
|
|
|
|
/// Don't reset state — DataManager.shared.clear() during app init triggers
|
|
/// a Kotlin/Native SIGKILL crash on the simulator. Since we use the seeded
|
|
/// admin account and loginViaUI() handles persisted sessions, this is safe.
|
|
override var includeResetStateLaunchArgument: Bool { false }
|
|
|
|
/// No mock auth - we're testing against the real backend.
|
|
override var additionalLaunchArguments: [String] { [] }
|
|
|
|
// MARK: - Setup
|
|
|
|
override func setUpWithError() throws {
|
|
// Check backend reachability before anything else
|
|
guard TestAccountAPIClient.isBackendReachable() else {
|
|
throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)")
|
|
}
|
|
|
|
// Create or login account via API
|
|
if useSeededAccount {
|
|
guard let s = TestAccountManager.loginSeededAccount(
|
|
username: seededUsername,
|
|
password: seededPassword
|
|
) else {
|
|
throw XCTSkip("Could not login seeded account '\(seededUsername)'")
|
|
}
|
|
session = s
|
|
} else {
|
|
guard let s = TestAccountManager.createVerifiedAccount() else {
|
|
throw XCTSkip("Could not create verified test account")
|
|
}
|
|
session = s
|
|
}
|
|
|
|
// Initialize the cleaner with the session token
|
|
cleaner = TestDataCleaner(token: session.token)
|
|
|
|
// Launch the app (calls BaseUITestCase.setUpWithError which launches and waits for ready)
|
|
try super.setUpWithError()
|
|
|
|
// Tap somewhere on the app to trigger any pending interruption monitors
|
|
// (BaseUITestCase already adds an addUIInterruptionMonitor in setUp)
|
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
sleep(1)
|
|
|
|
// Drive the UI through login if needed
|
|
if performUILogin {
|
|
loginViaUI()
|
|
}
|
|
}
|
|
|
|
override func tearDownWithError() throws {
|
|
// Clean up all tracked test data
|
|
cleaner?.cleanAll()
|
|
try super.tearDownWithError()
|
|
}
|
|
|
|
// MARK: - UI Login
|
|
|
|
/// Navigate to login screen → type credentials → wait for main tabs.
|
|
func loginViaUI() {
|
|
// If already on main tabs (persisted session from previous test), skip login.
|
|
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
|
let tabBar = app.tabBars.firstMatch
|
|
if mainTabs.waitForExistence(timeout: 3) || tabBar.waitForExistence(timeout: 2) {
|
|
return
|
|
}
|
|
|
|
// With --complete-onboarding the app should land on login directly.
|
|
// Use ensureOnLoginScreen as a robust fallback that handles any state.
|
|
let usernameField = app.textFields[UITestID.Auth.usernameField]
|
|
if !usernameField.waitForExistence(timeout: 10) {
|
|
UITestHelpers.ensureOnLoginScreen(app: app)
|
|
}
|
|
|
|
let login = LoginScreenObject(app: app)
|
|
login.waitForLoad(timeout: defaultTimeout)
|
|
login.enterUsername(session.username)
|
|
login.enterPassword(session.password)
|
|
|
|
// Try tapping the keyboard "Go" button first (triggers onSubmit which logs in)
|
|
let goButton = app.keyboards.buttons["Go"]
|
|
let returnButton = app.keyboards.buttons["Return"]
|
|
if goButton.waitForExistence(timeout: 3) && goButton.isHittable {
|
|
goButton.tap()
|
|
} else if returnButton.exists && returnButton.isHittable {
|
|
returnButton.tap()
|
|
} else {
|
|
// Dismiss keyboard by tapping empty area, then tap login button
|
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
|
|
sleep(1)
|
|
|
|
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
|
if loginButton.waitForExistence(timeout: defaultTimeout) {
|
|
// Wait until truly hittable (not behind keyboard)
|
|
let hittable = NSPredicate(format: "exists == true AND hittable == true")
|
|
let exp = XCTNSPredicateExpectation(predicate: hittable, object: loginButton)
|
|
_ = XCTWaiter().wait(for: [exp], timeout: 10)
|
|
loginButton.forceTap()
|
|
} else {
|
|
XCTFail("Login button not found")
|
|
}
|
|
}
|
|
|
|
// Wait for either main tabs or verification screen
|
|
let deadline = Date().addingTimeInterval(longTimeout)
|
|
var checkedForError = false
|
|
while Date() < deadline {
|
|
if mainTabs.exists || tabBar.exists {
|
|
return
|
|
}
|
|
|
|
// After a few seconds, check for login error messages
|
|
if !checkedForError {
|
|
sleep(3)
|
|
checkedForError = true
|
|
|
|
// Check if we're still on the login screen (login failed)
|
|
if usernameField.exists {
|
|
// Look for error messages
|
|
let errorTexts = app.staticTexts.allElementsBoundByIndex.filter {
|
|
let label = $0.label.lowercased()
|
|
return label.contains("error") || label.contains("invalid") ||
|
|
label.contains("failed") || label.contains("incorrect") ||
|
|
label.contains("not authenticated") || label.contains("wrong")
|
|
}
|
|
if !errorTexts.isEmpty {
|
|
let errorMsg = errorTexts.map { $0.label }.joined(separator: ", ")
|
|
XCTFail("Login failed with error: \(errorMsg)")
|
|
return
|
|
}
|
|
|
|
// No error visible but still on login — try tapping login again
|
|
let retryLoginButton = app.buttons[UITestID.Auth.loginButton]
|
|
if retryLoginButton.exists {
|
|
retryLoginButton.forceTap()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for email verification gate - if we hit it, enter the debug code
|
|
let verificationScreen = VerificationScreen(app: app)
|
|
if verificationScreen.codeField.exists {
|
|
verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
|
verificationScreen.submitCode()
|
|
// Wait for main tabs after verification
|
|
if mainTabs.waitForExistence(timeout: longTimeout) || tabBar.waitForExistence(timeout: 5) {
|
|
return
|
|
}
|
|
}
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
|
}
|
|
|
|
// Capture what's on screen for debugging
|
|
let attachment = XCTAttachment(screenshot: app.screenshot())
|
|
attachment.name = "LoginFailure"
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
|
|
let visibleTexts = app.staticTexts.allElementsBoundByIndex.prefix(15).map { $0.label }
|
|
let visibleButtons = app.buttons.allElementsBoundByIndex.prefix(10).map { $0.identifier.isEmpty ? $0.label : $0.identifier }
|
|
XCTFail("Failed to reach main app after login. Visible texts: \(visibleTexts). Buttons: \(visibleButtons)")
|
|
}
|
|
|
|
// MARK: - Tab Navigation
|
|
|
|
/// Map from identifier suffix to the actual tab bar label (handles mismatches like "Documents" → "Docs")
|
|
private static let tabLabelMap: [String: String] = [
|
|
"Documents": "Docs"
|
|
]
|
|
|
|
func navigateToTab(_ tab: String) {
|
|
// With .sidebarAdaptable tab style, there can be duplicate buttons.
|
|
// Always use the tab bar's buttons directly to avoid ambiguity.
|
|
let label = tab.replacingOccurrences(of: "TabBar.", with: "")
|
|
|
|
// Try exact match first
|
|
let tabBarButton = app.tabBars.firstMatch.buttons[label]
|
|
if tabBarButton.waitForExistence(timeout: defaultTimeout) {
|
|
tabBarButton.tap()
|
|
// Verify the tap took effect by checking the tab is selected
|
|
if !tabBarButton.waitForExistence(timeout: 2) || !tabBarButton.isSelected {
|
|
// Retry - tap the app to trigger any interruption monitors, then retry
|
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
sleep(1)
|
|
tabBarButton.tap()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Try mapped label (e.g. "Documents" → "Docs")
|
|
if let mappedLabel = Self.tabLabelMap[label] {
|
|
let mappedButton = app.tabBars.firstMatch.buttons[mappedLabel]
|
|
if mappedButton.waitForExistence(timeout: 5) {
|
|
mappedButton.tap()
|
|
if !mappedButton.isSelected {
|
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
sleep(1)
|
|
mappedButton.tap()
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Fallback: search by partial match
|
|
let byLabel = app.tabBars.firstMatch.buttons.containing(
|
|
NSPredicate(format: "label CONTAINS[c] %@", label)
|
|
).firstMatch
|
|
if byLabel.waitForExistence(timeout: 5) {
|
|
byLabel.tap()
|
|
return
|
|
}
|
|
|
|
XCTFail("Could not find tab '\(label)' in tab bar")
|
|
}
|
|
|
|
func navigateToResidences() {
|
|
navigateToTab(AccessibilityIdentifiers.Navigation.residencesTab)
|
|
}
|
|
|
|
func navigateToTasks() {
|
|
navigateToTab(AccessibilityIdentifiers.Navigation.tasksTab)
|
|
}
|
|
|
|
func navigateToContractors() {
|
|
navigateToTab(AccessibilityIdentifiers.Navigation.contractorsTab)
|
|
}
|
|
|
|
func navigateToDocuments() {
|
|
navigateToTab(AccessibilityIdentifiers.Navigation.documentsTab)
|
|
}
|
|
|
|
func navigateToProfile() {
|
|
navigateToTab(AccessibilityIdentifiers.Navigation.profileTab)
|
|
}
|
|
|
|
// MARK: - Pull to Refresh
|
|
|
|
/// Perform a pull-to-refresh gesture on the current screen's scrollable content.
|
|
/// Use after navigating to a tab when data was seeded via API after login.
|
|
func pullToRefresh() {
|
|
// SwiftUI List/Form uses UICollectionView internally
|
|
let collectionView = app.collectionViews.firstMatch
|
|
let scrollView = app.scrollViews.firstMatch
|
|
let listElement = collectionView.exists ? collectionView : scrollView
|
|
|
|
guard listElement.waitForExistence(timeout: 5) else { return }
|
|
|
|
let start = listElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
|
|
let end = listElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
|
|
start.press(forDuration: 0.3, thenDragTo: end)
|
|
sleep(3) // wait for refresh to complete
|
|
}
|
|
|
|
/// Perform pull-to-refresh repeatedly until a target element appears or max retries reached.
|
|
func pullToRefreshUntilVisible(_ element: XCUIElement, maxRetries: Int = 3) {
|
|
for _ in 0..<maxRetries {
|
|
if element.waitForExistence(timeout: 3) { return }
|
|
pullToRefresh()
|
|
}
|
|
// Final wait after last refresh
|
|
_ = element.waitForExistence(timeout: 5)
|
|
}
|
|
}
|