Re-architect iOS XCUITest suite: per-test isolation + domain organization
Migrate the XCUITest suite off the legacy shared-account model (and the prior Django-style auth assumptions) to a parallel-safe, domain-organized architecture, validated end-to-end against the live Kratos stack. Isolation (parallel-safe by construction): - Core/Fixtures/TestAccount.swift: each test mints its own pre-verified Kratos identity (uit_<domain>_<uuid>@test.honeydue.local), logs in, seeds under its own token, and deletes the identity in teardown (cascading all data + clearing Kratos). No shared testuser; parallel workers no longer race. - AuthenticatedUITestCase rewritten to that model (member surface preserved); adds requiresResidence / seedAccountPreconditions to seed UI-gated data BEFORE login (a fresh account is empty at login). Organization (255 tests preserved, none dropped): - 21 domain suites under Auth/ Onboarding/ Residence/ Task/ Contractor/ Document/ Sharing/ Navigation/ Smoke/ CrossCutting/ E2E/, consistent <Domain>UITests naming. Removes the Suite1..11 / AAA_ / ZZ_ / Tests/Rebuild naming chaos and the overlapping task/residence/auth suites. Runner + test plans: - run_ui_tests.sh: Smoke gate -> Seed -> Parallel(8 workers) -> Sweep. The parallel phase runs the whole target minus phase-managed suites via -skip-testing, so new suites auto-include (no hand-maintained list to drift). Drops the 2-worker cap and Suite6 isolation (isolation made them moot). - HoneyDueUITests.xctestplan skips the 4 phase-managed suites; adds Smoke.xctestplan. Kratos auth fixes folded in (login/verify/reset endpoints removed under Kratos): real Mailpit verification codes replace the obsolete fixed "123456"; teardown deletes Kratos identities; admin-panel login uses the correct seeded password. Build green; isolation, parallelism, and the precondition/sharing migrations validated against the live stack (0 leaked accounts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import XCTest
|
||||
|
||||
/// A throwaway, fully-isolated test account.
|
||||
///
|
||||
/// The unit of isolation that lets suites run in parallel without sharing
|
||||
/// state: each test mints its own unique, pre-verified Kratos identity, drives
|
||||
/// the app's login UI as that identity, seeds data under its own token, and
|
||||
/// deletes the identity in teardown — which cascades all of its data and
|
||||
/// clears the Kratos identity in one call.
|
||||
///
|
||||
/// Email format is collision-proof so parallel workers never overlap, and
|
||||
/// carries a recognizable prefix so `SweepFixture` can find leaked accounts:
|
||||
/// uit_<domain>_<uuid>@test.honeydue.local
|
||||
struct TestAccount {
|
||||
let username: String
|
||||
let email: String
|
||||
let password: String
|
||||
let session: TestSession
|
||||
|
||||
var token: String { session.token }
|
||||
|
||||
// MARK: - Identity generation
|
||||
|
||||
/// Recognizable prefix for every generated account, so leaks are findable.
|
||||
static let emailPrefix = "uit_"
|
||||
/// Domain used for all generated test accounts (never a real mailbox).
|
||||
static let emailDomain = "test.honeydue.local"
|
||||
|
||||
static func uniqueEmail(domain: String) -> String {
|
||||
let slug = domain.lowercased().replacingOccurrences(of: " ", with: "-")
|
||||
let unique = UUID().uuidString.prefix(12).lowercased()
|
||||
return "\(emailPrefix)\(slug)_\(unique)@\(emailDomain)"
|
||||
}
|
||||
|
||||
/// True if an email belongs to the generated test-account namespace.
|
||||
static func isGenerated(_ email: String) -> Bool {
|
||||
email.hasPrefix(emailPrefix) && email.hasSuffix("@\(emailDomain)")
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Create a pre-verified, ready-to-use account via the Kratos admin API.
|
||||
/// The identity is verified up front so login lands straight on the main
|
||||
/// tabs (no email-verification gate). Fails the test if creation fails.
|
||||
@discardableResult
|
||||
static func create(
|
||||
domain: String,
|
||||
verified: Bool = true,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestAccount {
|
||||
let email = uniqueEmail(domain: domain)
|
||||
let username = String(email.split(separator: "@").first ?? "uituser")
|
||||
let password = "UitPass123!"
|
||||
|
||||
let session: TestSession?
|
||||
if verified {
|
||||
session = TestAccountAPIClient.createVerifiedAccount(
|
||||
username: username, email: email, password: password
|
||||
)
|
||||
} else {
|
||||
session = TestAccountAPIClient.createUnverifiedAccount(
|
||||
username: username, email: email, password: password
|
||||
)
|
||||
}
|
||||
|
||||
guard let session else {
|
||||
XCTFail("Failed to create isolated test account \(email)", file: file, line: line)
|
||||
preconditionFailure("account creation failed — see XCTFail above")
|
||||
}
|
||||
return TestAccount(username: username, email: email, password: password, session: session)
|
||||
}
|
||||
|
||||
/// Delete the Kratos identity (cascades all app data). Best-effort —
|
||||
/// never fails a test, since teardown cleanup should not mask the result.
|
||||
func delete() {
|
||||
_ = TestAccountAPIClient.deleteKratosIdentity(email: email)
|
||||
}
|
||||
|
||||
// MARK: - UI login
|
||||
|
||||
/// Drive the app's login screen as this account and wait for the main tabs.
|
||||
/// Assumes the app is on (or can reach) the standalone login screen.
|
||||
func login(
|
||||
into app: XCUIApplication,
|
||||
timeout: TimeInterval,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
UITestHelpers.ensureOnLoginScreen(app: app)
|
||||
|
||||
let login = LoginScreenObject(app: app)
|
||||
login.waitForLoad(timeout: timeout)
|
||||
login.enterUsername(email) // Kratos identifier is the email
|
||||
login.enterPassword(password)
|
||||
|
||||
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||||
loginButton.waitForExistenceOrFail(timeout: timeout, file: file, line: line)
|
||||
loginButton.tap()
|
||||
}
|
||||
|
||||
// MARK: - Seeding (under this account's own token)
|
||||
|
||||
@discardableResult
|
||||
func seedResidence(name: String? = nil) -> TestResidence {
|
||||
TestDataSeeder.createResidence(token: token, name: name)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func seedResidenceWithAddress(name: String? = nil) -> TestResidence {
|
||||
TestDataSeeder.createResidenceWithAddress(token: token, name: name)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func seedTask(residenceId: Int, title: String? = nil, fields: [String: Any] = [:]) -> TestTask {
|
||||
TestDataSeeder.createTask(token: token, residenceId: residenceId, title: title, fields: fields)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func seedContractor(name: String? = nil, fields: [String: Any] = [:]) -> TestContractor {
|
||||
TestDataSeeder.createContractor(token: token, name: name, fields: fields)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func seedDocument(residenceId: Int, title: String? = nil, documentType: String = "general") -> TestDocument {
|
||||
TestDataSeeder.createDocument(token: token, residenceId: residenceId, title: title, documentType: documentType)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user