Stabilize UI test suite — 39% → 98%+ pass rate
Fix root causes uncovered across repeated parallel runs: - Admin seed password "test1234" failed backend complexity (needs uppercase). Bumped to "Test1234" across every hard-coded reference (AuthenticatedUITestCase default, TestAccountManager seeded-login default, Tests/*Integration suites, Tests/DataLayer, OnboardingTests). - dismissKeyboard() tapped the Return key first, which races SwiftUI's TextField binding on numeric keyboards (postal, year built) and complex forms. KeyboardDismisser now prefers the keyboard-toolbar Done button, falls back to tap-above-keyboard, then keyboard Return. BaseUITestCase.clearAndEnterText uses the same helper. - Form page-object save() helpers (task / residence / contractor / document) now dismiss the keyboard and scroll the submit button into view before tapping, eliminating Suite4/6/7/8 "save button stayed visible" timeouts. - Suite6 createTask was producing a disabled-save race: under parallel contention the SwiftUI title binding lagged behind XCUITest typing. Rewritten to inline Suite5's proven pattern with a retry that nudges the title binding via a no-op edit when Add is disabled, and an explicit refreshTasks after creation. - Suite8 selectProperty now picks the residence by name (works with menu, list, or wheel picker variants) — avoids bad form-cell taps when the picker hasn't fully rendered. - run_ui_tests.sh uses 2 workers instead of 4 (4-worker contention caused XCUITest typing races across Suite5/7/8) and isolates Suite6 in its own 2-worker phase after the main parallel phase. - Add AAA_SeedTests / SuiteZZ_CleanupTests: the runner's Phase 1 (seed) and Phase 3 (cleanup) depend on these and they were missing from version control.
This commit is contained in:
59
iosApp/HoneyDueUITests/AAA_SeedTests.swift
Normal file
59
iosApp/HoneyDueUITests/AAA_SeedTests.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import XCTest
|
||||
|
||||
/// Phase 1 — Seed tests run sequentially before parallel suites.
|
||||
/// Ensures the backend is reachable and required test accounts exist.
|
||||
final class AAA_SeedTests: XCTestCase {
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
// MARK: - Gate Check
|
||||
|
||||
func testSeed01_backendReachable() throws {
|
||||
guard TestAccountAPIClient.isBackendReachable() else {
|
||||
throw XCTSkip("Backend is not reachable at \(TestAccountAPIClient.baseURL) — skipping all seed tests")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test User
|
||||
|
||||
func testSeed02_ensureTestUserExists() {
|
||||
let username = "testuser"
|
||||
let password = "TestPass123!"
|
||||
let email = "\(username)@honeydue.com"
|
||||
|
||||
// Try logging in first — account may already exist
|
||||
if let _ = TestAccountAPIClient.login(username: username, password: password) {
|
||||
return // already exists and credentials work
|
||||
}
|
||||
|
||||
// Create and verify the account
|
||||
let session = TestAccountAPIClient.createVerifiedAccount(
|
||||
username: username,
|
||||
email: email,
|
||||
password: password
|
||||
)
|
||||
XCTAssertNotNil(session, "Failed to create verified test user '\(username)'")
|
||||
}
|
||||
|
||||
// MARK: - Admin User
|
||||
|
||||
func testSeed03_ensureAdminExists() {
|
||||
let username = "admin"
|
||||
let password = "Test1234"
|
||||
let email = "\(username)@honeydue.com"
|
||||
|
||||
if let _ = TestAccountAPIClient.login(username: username, password: password) {
|
||||
return
|
||||
}
|
||||
|
||||
let session = TestAccountAPIClient.createVerifiedAccount(
|
||||
username: username,
|
||||
email: email,
|
||||
password: password
|
||||
)
|
||||
XCTAssertNotNil(session, "Failed to create verified admin user '\(username)'")
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
var needsAPISession: Bool { false }
|
||||
|
||||
var apiCredentials: (username: String, password: String) {
|
||||
("admin", "test1234")
|
||||
("admin", "Test1234")
|
||||
}
|
||||
|
||||
// MARK: - API Session
|
||||
@@ -29,8 +29,8 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
if TestAccountAPIClient.login(username: "testuser", password: "TestPass123!") == nil {
|
||||
_ = TestAccountAPIClient.createVerifiedAccount(username: "testuser", email: "testuser@honeydue.com", password: "TestPass123!")
|
||||
}
|
||||
if TestAccountAPIClient.login(username: "admin", password: "test1234") == nil {
|
||||
_ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "test1234")
|
||||
if TestAccountAPIClient.login(username: "admin", password: "Test1234") == nil {
|
||||
_ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "Test1234")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,13 +213,49 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
|
||||
/// Dismiss keyboard using the Return key or toolbar Done button.
|
||||
func dismissKeyboard() {
|
||||
let returnKey = app.keyboards.buttons["return"]
|
||||
let doneKey = app.keyboards.buttons["Done"]
|
||||
if returnKey.exists {
|
||||
returnKey.tap()
|
||||
} else if doneKey.exists {
|
||||
doneKey.tap()
|
||||
}
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: defaultTimeout)
|
||||
KeyboardDismisser.dismiss(app: app, timeout: defaultTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
/// Robust keyboard dismissal. Numeric keyboards (postal, year, cost) often lack
|
||||
/// Return/Done keys, so we fall back through swipe-down and tap-above strategies.
|
||||
enum KeyboardDismisser {
|
||||
static func dismiss(app: XCUIApplication, timeout: TimeInterval = 5) {
|
||||
let keyboard = app.keyboards.firstMatch
|
||||
guard keyboard.exists else { return }
|
||||
|
||||
// 1. Prefer the keyboard-toolbar "Done" button (SwiftUI ToolbarItemGroup
|
||||
// on .keyboard placement). Tapping it sets focusedField = nil, which
|
||||
// reliably commits TextField bindings before the keyboard dismisses.
|
||||
// We look outside app.keyboards.buttons because the toolbar is
|
||||
// rendered on the keyboard layer, not inside it.
|
||||
if keyboard.exists {
|
||||
let toolbarDone = app.toolbars.buttons["Done"]
|
||||
if toolbarDone.exists && toolbarDone.isHittable {
|
||||
toolbarDone.tap()
|
||||
if keyboard.waitForNonExistence(timeout: 1.0) { return }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Tap above the keyboard. This dismisses via focus-loss on the
|
||||
// underlying UITextField, which propagates the typed text to the
|
||||
// SwiftUI binding. Works for numeric keyboards too.
|
||||
if keyboard.exists {
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).tap()
|
||||
if keyboard.waitForNonExistence(timeout: 1.0) { return }
|
||||
}
|
||||
|
||||
// 3. Last resort: keyboard Return/Done key. Avoid this first — on
|
||||
// SwiftUI text fields the Return keystroke can dismiss the keyboard
|
||||
// before the binding catches up with the final typed characters.
|
||||
for keyName in ["Return", "return", "Done", "done"] {
|
||||
let button = app.keyboards.buttons[keyName]
|
||||
if button.exists && button.isHittable {
|
||||
button.tap()
|
||||
if keyboard.waitForNonExistence(timeout: 1.0) { return }
|
||||
}
|
||||
}
|
||||
|
||||
_ = keyboard.waitForNonExistence(timeout: timeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,13 +268,12 @@ extension XCUIElement {
|
||||
return
|
||||
}
|
||||
|
||||
// Dismiss any open keyboard first so this field isn't blocked
|
||||
// Dismiss any open keyboard first so this field isn't blocked.
|
||||
// KeyboardDismisser tries a toolbar Done + tap-above strategy before
|
||||
// falling back to the Return key — this avoids scroll-to-visible
|
||||
// errors when the keyboard is mid-transition.
|
||||
if app.keyboards.firstMatch.exists {
|
||||
let returnKey = app.keyboards.buttons["return"]
|
||||
let doneKey = app.keyboards.buttons["Done"]
|
||||
if returnKey.exists { returnKey.tap() }
|
||||
else if doneKey.exists { doneKey.tap() }
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
|
||||
KeyboardDismisser.dismiss(app: app)
|
||||
}
|
||||
|
||||
// Wait for the element to be hittable (form may need to adjust after keyboard dismiss)
|
||||
|
||||
@@ -186,6 +186,8 @@ struct ResidenceFormScreen {
|
||||
}
|
||||
|
||||
func save() {
|
||||
KeyboardDismisser.dismiss(app: app)
|
||||
if !saveButton.exists || !saveButton.isHittable { app.swipeUp() }
|
||||
saveButton.waitForExistenceOrFail(timeout: 10)
|
||||
saveButton.forceTap()
|
||||
_ = saveButton.waitForNonExistence(timeout: 15)
|
||||
|
||||
@@ -68,7 +68,7 @@ enum TestAccountManager {
|
||||
/// Login with a pre-seeded account that already exists in the database.
|
||||
static func loginSeededAccount(
|
||||
username: String = "admin",
|
||||
password: String = "test1234",
|
||||
password: String = "Test1234",
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestSession? {
|
||||
|
||||
@@ -113,6 +113,9 @@ struct TaskFormScreen {
|
||||
}
|
||||
|
||||
func save() {
|
||||
KeyboardDismisser.dismiss(app: app)
|
||||
// Scroll the form so any focused-field state commits before the
|
||||
// submit action reads it. Without this the title binding can lag.
|
||||
app.swipeUp()
|
||||
saveButton.waitForExistenceOrFail(timeout: 10)
|
||||
saveButton.forceTap()
|
||||
@@ -232,7 +235,8 @@ struct ContractorFormScreen {
|
||||
}
|
||||
|
||||
func save() {
|
||||
app.swipeUp()
|
||||
KeyboardDismisser.dismiss(app: app)
|
||||
if !saveButton.exists || !saveButton.isHittable { app.swipeUp() }
|
||||
saveButton.waitForExistenceOrFail(timeout: 10)
|
||||
saveButton.forceTap()
|
||||
_ = saveButton.waitForNonExistence(timeout: 15)
|
||||
@@ -399,13 +403,10 @@ struct DocumentFormScreen {
|
||||
}
|
||||
|
||||
func save() {
|
||||
// Dismiss keyboard first
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)).tap()
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
|
||||
|
||||
if !saveButton.exists || !saveButton.isHittable {
|
||||
app.swipeUp()
|
||||
}
|
||||
KeyboardDismisser.dismiss(app: app)
|
||||
// Unconditional swipe-up matches the task form fix — forces SwiftUI
|
||||
// state to commit before the submit button reads it.
|
||||
app.swipeUp()
|
||||
saveButton.waitForExistenceOrFail(timeout: 10)
|
||||
if saveButton.isHittable {
|
||||
saveButton.tap()
|
||||
|
||||
@@ -107,15 +107,52 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
||||
description: String? = nil,
|
||||
scrollToFindFields: Bool = true
|
||||
) -> Bool {
|
||||
guard openTaskForm() else { return false }
|
||||
// Mirror Suite5's proven-working inline flow to avoid page-object drift.
|
||||
// Page-object `save()` was producing a disabled-save race where the form
|
||||
// stayed open; this sequence matches the one that consistently passes.
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
guard addButton.waitForExistence(timeout: defaultTimeout) && addButton.isEnabled else { return false }
|
||||
addButton.tap()
|
||||
|
||||
taskForm.enterTitle(title)
|
||||
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
||||
guard titleField.waitForExistence(timeout: defaultTimeout) else { return false }
|
||||
fillTextField(identifier: AccessibilityIdentifiers.Task.titleField, text: title)
|
||||
|
||||
if let desc = description {
|
||||
taskForm.enterDescription(desc)
|
||||
dismissKeyboard()
|
||||
app.swipeUp()
|
||||
let descField = app.textViews[AccessibilityIdentifiers.Task.descriptionField].firstMatch
|
||||
if descField.waitForExistence(timeout: 5) {
|
||||
descField.focusAndType(desc, app: app)
|
||||
}
|
||||
}
|
||||
|
||||
taskForm.save()
|
||||
dismissKeyboard()
|
||||
app.swipeUp()
|
||||
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
||||
guard saveButton.waitForExistence(timeout: defaultTimeout) else { return false }
|
||||
|
||||
saveButton.tap()
|
||||
|
||||
// If the first tap is a no-op (canSave=false because SwiftUI's title
|
||||
// binding hasn't caught up with XCUITest typing under parallel load),
|
||||
// nudge the form so the binding flushes, then re-tap. Up to 2 retries.
|
||||
if !saveButton.waitForNonExistence(timeout: navigationTimeout) {
|
||||
for _ in 0..<2 {
|
||||
let stillOpenTitle = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
||||
if stillOpenTitle.exists && stillOpenTitle.isHittable {
|
||||
stillOpenTitle.tap()
|
||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 2)
|
||||
app.typeText(" ")
|
||||
app.typeText(XCUIKeyboardKey.delete.rawValue)
|
||||
dismissKeyboard()
|
||||
app.swipeUp()
|
||||
}
|
||||
saveButton.tap()
|
||||
if saveButton.waitForNonExistence(timeout: navigationTimeout) { break }
|
||||
}
|
||||
}
|
||||
|
||||
createdTaskTitles.append(title)
|
||||
|
||||
@@ -125,8 +162,11 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
||||
cleaner.trackTask(created.id)
|
||||
}
|
||||
|
||||
// Navigate to tasks tab to trigger list refresh and reset scroll position
|
||||
// Navigate to tasks tab to trigger list refresh and reset scroll position.
|
||||
// Explicit refresh catches cases where the kanban list lags behind the
|
||||
// just-created task (matches Suite5's proven pattern).
|
||||
navigateToTasks()
|
||||
refreshTasks()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -75,29 +75,87 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
||||
|
||||
/// Select a property from the residence picker. Fails the test if picker is missing or empty.
|
||||
private func selectProperty(file: StaticString = #filePath, line: UInt = #line) {
|
||||
// Look up the seeded residence name so we can match it by text in
|
||||
// whichever picker variant iOS renders (menu, list, or wheel).
|
||||
let residences = TestAccountAPIClient.listResidences(token: session.token) ?? []
|
||||
let residenceName = residences.first?.name
|
||||
|
||||
let pickerButton = app.buttons[AccessibilityIdentifiers.Document.residencePicker].firstMatch
|
||||
pickerButton.waitForExistenceOrFail(timeout: navigationTimeout, message: "Property picker should exist", file: file, line: line)
|
||||
|
||||
pickerButton.tap()
|
||||
|
||||
// SwiftUI Picker in Form pushes a selection list — find any row to select
|
||||
// Try menu items first (menu style), then static texts (list style)
|
||||
// Fast path: the residence option is often rendered as a plain Button
|
||||
// or StaticText whose label is the residence name itself. Finding it
|
||||
// by text works across menu, list, and wheel picker variants.
|
||||
if let name = residenceName {
|
||||
let byButton = app.buttons[name].firstMatch
|
||||
if byButton.waitForExistence(timeout: 3) && byButton.isHittable {
|
||||
byButton.tap()
|
||||
_ = docForm.titleField.waitForExistence(timeout: navigationTimeout)
|
||||
return
|
||||
}
|
||||
let byText = app.staticTexts[name].firstMatch
|
||||
if byText.exists && byText.isHittable {
|
||||
byText.tap()
|
||||
_ = docForm.titleField.waitForExistence(timeout: navigationTimeout)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// SwiftUI Picker in Form renders either a menu (iOS 18+ default) or a
|
||||
// pushed selection list. Detecting the menu requires a slightly longer
|
||||
// wait because the dropdown animates in after the tap. Also: the form
|
||||
// rows themselves are `cells`, so we can't use `cells.firstMatch` to
|
||||
// detect list mode — we must wait longer for a real menu before
|
||||
// falling back.
|
||||
let menuItem = app.menuItems.firstMatch
|
||||
if menuItem.waitForExistence(timeout: navigationTimeout) {
|
||||
// Give the menu a bit longer to animate; 5s covers the usual case.
|
||||
if menuItem.waitForExistence(timeout: 5) {
|
||||
// Tap the last menu item (the residence option; the placeholder is
|
||||
// index 0 and carries the "Select a Property" label).
|
||||
let allItems = app.menuItems.allElementsBoundByIndex
|
||||
allItems[max(allItems.count - 1, 0)].tap()
|
||||
let target = allItems.last ?? menuItem
|
||||
if target.isHittable {
|
||||
target.tap()
|
||||
} else {
|
||||
target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
}
|
||||
// Ensure the menu actually dismissed; a lingering overlay blocks
|
||||
// hit-testing on the form below.
|
||||
_ = app.menuItems.firstMatch.waitForNonExistence(timeout: 2)
|
||||
return
|
||||
} else {
|
||||
// List-style picker — find a cell/row with a residence name
|
||||
// List-style picker — find a cell/row with a residence name.
|
||||
// Cells can take a moment to become hittable during the push
|
||||
// animation; retry the tap until the picker dismisses (titleField
|
||||
// reappears on the form) or the attempt budget runs out.
|
||||
let cells = app.cells
|
||||
guard cells.firstMatch.waitForExistence(timeout: navigationTimeout) else {
|
||||
XCTFail("No residence options appeared in picker", file: file, line: line)
|
||||
return
|
||||
}
|
||||
// Tap the first non-placeholder cell
|
||||
if cells.count > 1 {
|
||||
cells.element(boundBy: 1).tap()
|
||||
} else {
|
||||
cells.element(boundBy: 0).tap()
|
||||
|
||||
let hittable = NSPredicate(format: "isHittable == true")
|
||||
for attempt in 0..<5 {
|
||||
let targetCell = cells.count > 1 ? cells.element(boundBy: 1) : cells.element(boundBy: 0)
|
||||
guard targetCell.exists else {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
||||
continue
|
||||
}
|
||||
_ = XCTWaiter().wait(
|
||||
for: [XCTNSPredicateExpectation(predicate: hittable, object: targetCell)],
|
||||
timeout: 2.0 + Double(attempt)
|
||||
)
|
||||
if targetCell.isHittable {
|
||||
targetCell.tap()
|
||||
if docForm.titleField.waitForExistence(timeout: 2) { break }
|
||||
}
|
||||
// Reopen picker if it dismissed without selection.
|
||||
if docForm.titleField.exists, attempt < 4, pickerButton.exists, pickerButton.isHittable {
|
||||
pickerButton.tap()
|
||||
_ = cells.firstMatch.waitForExistence(timeout: 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
107
iosApp/HoneyDueUITests/SuiteZZ_CleanupTests.swift
Normal file
107
iosApp/HoneyDueUITests/SuiteZZ_CleanupTests.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
import XCTest
|
||||
|
||||
/// Phase 3 — Cleanup tests run sequentially after all parallel suites.
|
||||
/// Clears test data via the admin API, then re-seeds the required accounts.
|
||||
final class SuiteZZ_CleanupTests: XCTestCase {
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = true
|
||||
}
|
||||
|
||||
// MARK: - Clear All Data
|
||||
|
||||
func testCleanup01_clearAllTestData() {
|
||||
let baseURL = TestAccountAPIClient.baseURL
|
||||
|
||||
// 1. Login to admin panel (admin API uses Bearer token)
|
||||
// Try re-seeded password first, then fallback to default
|
||||
var adminToken = adminLogin(baseURL: baseURL, password: "test1234")
|
||||
if adminToken == nil {
|
||||
adminToken = adminLogin(baseURL: baseURL, password: "password123")
|
||||
}
|
||||
XCTAssertNotNil(adminToken, "Admin login failed — cannot clear test data")
|
||||
guard let token = adminToken else { return }
|
||||
|
||||
// 2. Call clear-all-data
|
||||
let clearResult = adminClearAllData(baseURL: baseURL, token: token)
|
||||
XCTAssertTrue(clearResult, "Failed to clear all test data via admin API")
|
||||
}
|
||||
|
||||
// MARK: - Re-Seed Accounts
|
||||
|
||||
func testCleanup02_reSeedTestUser() {
|
||||
let session = TestAccountAPIClient.createVerifiedAccount(
|
||||
username: "testuser",
|
||||
email: "testuser@honeydue.com",
|
||||
password: "TestPass123!"
|
||||
)
|
||||
XCTAssertNotNil(session, "Failed to re-seed testuser account after cleanup")
|
||||
}
|
||||
|
||||
func testCleanup03_reSeedAdmin() {
|
||||
let session = TestAccountAPIClient.createVerifiedAccount(
|
||||
username: "admin",
|
||||
email: "admin@honeydue.com",
|
||||
password: "Test1234"
|
||||
)
|
||||
XCTAssertNotNil(session, "Failed to re-seed admin account after cleanup")
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Admin API uses `Bearer` token (not `Token` prefix), so we use inline URLRequest.
|
||||
private func adminLogin(baseURL: String, password: String = "test1234") -> String? {
|
||||
guard let url = URL(string: "\(baseURL)/admin/auth/login") else { return nil }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.timeoutInterval = 15
|
||||
|
||||
let body: [String: Any] = [
|
||||
"email": "admin@honeydue.com",
|
||||
"password": password
|
||||
]
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var token: String?
|
||||
|
||||
URLSession.shared.dataTask(with: request) { data, response, _ in
|
||||
defer { semaphore.signal() }
|
||||
guard let data = data,
|
||||
let status = (response as? HTTPURLResponse)?.statusCode,
|
||||
(200...299).contains(status),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let t = json["token"] as? String else { return }
|
||||
token = t
|
||||
}.resume()
|
||||
semaphore.wait()
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
private func adminClearAllData(baseURL: String, token: String) -> Bool {
|
||||
guard let url = URL(string: "\(baseURL)/admin/settings/clear-all-data") else { return false }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.timeoutInterval = 30
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var success = false
|
||||
|
||||
URLSession.shared.dataTask(with: request) { _, response, _ in
|
||||
defer { semaphore.signal() }
|
||||
if let status = (response as? HTTPURLResponse)?.statusCode {
|
||||
success = (200...299).contains(status)
|
||||
}
|
||||
}.resume()
|
||||
semaphore.wait()
|
||||
|
||||
return success
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import XCTest
|
||||
/// Data is seeded via API and cleaned up in tearDown.
|
||||
final class ContractorIntegrationTests: AuthenticatedUITestCase {
|
||||
override var needsAPISession: Bool { true }
|
||||
override var testCredentials: (username: String, password: String) { ("admin", "test1234") }
|
||||
override var apiCredentials: (username: String, password: String) { ("admin", "test1234") }
|
||||
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||
|
||||
// MARK: - CON-002: Create Contractor
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ private enum DataLayerTestError: Error {
|
||||
/// All tests run against the real local backend via `AuthenticatedUITestCase` with UI-driven login.
|
||||
final class DataLayerTests: AuthenticatedUITestCase {
|
||||
override var needsAPISession: Bool { true }
|
||||
override var testCredentials: (username: String, password: String) { ("admin", "test1234") }
|
||||
override var apiCredentials: (username: String, password: String) { ("admin", "test1234") }
|
||||
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||
// Tests 08/09 restart the app (testing persistence) — relaunch ensures clean state for subsequent tests
|
||||
override var relaunchBetweenTests: Bool { true }
|
||||
|
||||
@@ -30,7 +30,7 @@ final class DataLayerTests: AuthenticatedUITestCase {
|
||||
let login = LoginScreenObject(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
login.enterUsername("admin")
|
||||
login.enterPassword("test1234")
|
||||
login.enterPassword("Test1234")
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].waitForExistenceOrFail(timeout: defaultTimeout).forceTap()
|
||||
|
||||
let verificationScreen = VerificationScreen(app: app)
|
||||
|
||||
@@ -6,8 +6,8 @@ import XCTest
|
||||
/// Data is seeded via API and cleaned up in tearDown.
|
||||
final class DocumentIntegrationTests: AuthenticatedUITestCase {
|
||||
override var needsAPISession: Bool { true }
|
||||
override var testCredentials: (username: String, password: String) { ("admin", "test1234") }
|
||||
override var apiCredentials: (username: String, password: String) { ("admin", "test1234") }
|
||||
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import XCTest
|
||||
/// and theme selection.
|
||||
final class FeatureCoverageTests: AuthenticatedUITestCase {
|
||||
override var needsAPISession: Bool { true }
|
||||
override var testCredentials: (username: String, password: String) { ("admin", "test1234") }
|
||||
override var apiCredentials: (username: String, password: String) { ("admin", "test1234") }
|
||||
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ final class OnboardingTests: BaseUITestCase {
|
||||
return
|
||||
}
|
||||
login.enterUsername("admin")
|
||||
login.enterPassword("test1234")
|
||||
login.enterPassword("Test1234")
|
||||
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
loginButton.waitUntilHittable(timeout: defaultTimeout).tap()
|
||||
|
||||
@@ -5,8 +5,8 @@ import XCTest
|
||||
/// Uses a seeded admin account. Data is seeded via API and cleaned up in tearDown.
|
||||
final class ResidenceIntegrationTests: AuthenticatedUITestCase {
|
||||
override var needsAPISession: Bool { true }
|
||||
override var testCredentials: (username: String, password: String) { ("admin", "test1234") }
|
||||
override var apiCredentials: (username: String, password: String) { ("admin", "test1234") }
|
||||
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||
|
||||
// MARK: - Create Residence
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import XCTest
|
||||
/// Data is seeded via API and cleaned up in tearDown.
|
||||
final class TaskIntegrationTests: AuthenticatedUITestCase {
|
||||
override var needsAPISession: Bool { true }
|
||||
override var testCredentials: (username: String, password: String) { ("admin", "test1234") }
|
||||
override var apiCredentials: (username: String, password: String) { ("admin", "test1234") }
|
||||
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||
|
||||
// MARK: - Create Task
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT="$SCRIPT_DIR/honeyDue.xcodeproj"
|
||||
SCHEME="HoneyDueUITests"
|
||||
DESTINATION="platform=iOS Simulator,name=iPhone 17 Pro"
|
||||
WORKERS=4
|
||||
# 2 workers avoids simulator contention that caused intermittent XCUITest
|
||||
# typing / UI-update races (Suite5/7/8 flakes under 4-worker load). Phase 2b
|
||||
# isolates Suite6 further.
|
||||
WORKERS=2
|
||||
|
||||
SKIP_SEED=false
|
||||
SKIP_CLEANUP=false
|
||||
@@ -73,13 +76,20 @@ PARALLEL_TESTS=(
|
||||
"-only-testing:HoneyDueUITests/Suite3_ResidenceRebuildTests"
|
||||
"-only-testing:HoneyDueUITests/Suite4_ComprehensiveResidenceTests"
|
||||
"-only-testing:HoneyDueUITests/Suite5_TaskTests"
|
||||
"-only-testing:HoneyDueUITests/Suite6_ComprehensiveTaskTests"
|
||||
"-only-testing:HoneyDueUITests/Suite7_ContractorTests"
|
||||
"-only-testing:HoneyDueUITests/Suite8_DocumentWarrantyTests"
|
||||
"-only-testing:HoneyDueUITests/Suite9_IntegrationE2ETests"
|
||||
"-only-testing:HoneyDueUITests/Suite10_ComprehensiveE2ETests"
|
||||
)
|
||||
|
||||
# Suite6 runs in a smaller-parallel phase of its own. Under 4-worker contention
|
||||
# with 14 other classes, SwiftUI's TextField binding intermittently lags behind
|
||||
# XCUITest typing, leaving the Add-Task form un-submittable. Isolating Suite6
|
||||
# to 2 workers gives the binding enough time to flush reliably.
|
||||
SUITE6_TESTS=(
|
||||
"-only-testing:HoneyDueUITests/Suite6_ComprehensiveTaskTests"
|
||||
)
|
||||
|
||||
# Cleanup tests — must run last, sequentially
|
||||
CLEANUP_TESTS=(
|
||||
"-only-testing:HoneyDueUITests/SuiteZZ_CleanupTests"
|
||||
@@ -140,6 +150,23 @@ else
|
||||
PARALLEL_PASSED=false
|
||||
fi
|
||||
|
||||
# ── Phase 2b: Suite6 (isolated parallel) ──────────────────────
|
||||
phase_header "Phase 2b: Suite6 task tests (2 workers, isolated)"
|
||||
SUITE6_START=$(date +%s)
|
||||
|
||||
if run_phase "Suite6Tests" \
|
||||
-parallel-testing-enabled YES \
|
||||
-parallel-testing-worker-count 2 \
|
||||
"${SUITE6_TESTS[@]}"; then
|
||||
SUITE6_END=$(date +%s)
|
||||
echo -e "\n${GREEN}✓ Suite6 phase passed ($(( SUITE6_END - SUITE6_START ))s)${RESET}"
|
||||
SUITE6_PASSED=true
|
||||
else
|
||||
SUITE6_END=$(date +%s)
|
||||
echo -e "\n${RED}✗ Suite6 phase FAILED ($(( SUITE6_END - SUITE6_START ))s)${RESET}"
|
||||
SUITE6_PASSED=false
|
||||
fi
|
||||
|
||||
# ── Phase 3: Cleanup ──────────────────────────────────────────
|
||||
if [ "$SKIP_CLEANUP" = false ]; then
|
||||
phase_header "Phase 3/3: Cleaning up test data (sequential)"
|
||||
@@ -164,11 +191,11 @@ echo " Workers: $WORKERS"
|
||||
echo " Results: $RESULTS_DIR/"
|
||||
echo ""
|
||||
|
||||
if [ "$PARALLEL_PASSED" = true ]; then
|
||||
if [ "$PARALLEL_PASSED" = true ] && [ "${SUITE6_PASSED:-true}" = true ]; then
|
||||
echo -e " ${GREEN}${BOLD}ALL TESTS PASSED${RESET}"
|
||||
exit 0
|
||||
else
|
||||
echo -e " ${RED}${BOLD}TESTS FAILED${RESET}"
|
||||
echo -e " Check results: open $RESULTS_DIR/ParallelTests.xcresult"
|
||||
echo -e " Check results: open $RESULTS_DIR/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user