Perf: inject auth token at launch to skip the UI login (~26-50% faster)
Measured: ~half of every authenticated test was fixed setup, dominated by the UI login (typing email+password, keyboard/SecureField dance, ~8-12s). The test already creates the account via API and holds its real Kratos session token — so instead of typing credentials, pass the token as a launch arg and boot the app already authenticated. - App (UITestRuntime + iOSApp): reads --ui-test-session-token; after the --reset-state clear, calls DataManager.setAuthToken(token) and replicates the post-login init the UI login path runs (getCurrentUser + initializeLookups + getMyResidences + getTasks) so owner-gated/data-gated screens (residence detail delete + manage-users, pickers, lists) work on boot. Guarded by UITestRuntime.isEnabled — no effect on production. - AuthenticatedUITestCase: in fresh-account mode, create the account + seed its preconditions BEFORE launch, expose the token via additionalLaunchArguments, and drop the UI login. Legacy (usesFreshAccount=false) suites still UI-login. Measured per-test medians: Contractor 34s -> 25s; Task (uses lookups) ~34s -> 16s. TESTING.md updated. All affected suites pass; 0 leaked accounts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,18 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
|||||||
/// logout-via-profile path was the #1 source of flakes under load).
|
/// logout-via-profile path was the #1 source of flakes under load).
|
||||||
override var relaunchBetweenTests: Bool { true }
|
override var relaunchBetweenTests: Bool { true }
|
||||||
|
|
||||||
|
/// In fresh-account mode the app boots already authenticated via the
|
||||||
|
/// account's real Kratos token (the app reads `--ui-test-session-token` in
|
||||||
|
/// UITestRuntime) — skipping the slow, flaky UI login (~8-12s/test). The
|
||||||
|
/// account is created before the launch (see setUpWithError), so its token
|
||||||
|
/// is available here when BaseUITestCase assembles the launch arguments.
|
||||||
|
override var additionalLaunchArguments: [String] {
|
||||||
|
if usesFreshAccount, let token = account?.token {
|
||||||
|
return ["--ui-test-session-token", token]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
/// Credentials for the Kratos APP identity used to seed data over the API.
|
/// Credentials for the Kratos APP identity used to seed data over the API.
|
||||||
///
|
///
|
||||||
/// ⚠️ TWO DIFFERENT "admin@honeydue.com" EXIST — do NOT "fix" Test1234 to password123:
|
/// ⚠️ TWO DIFFERENT "admin@honeydue.com" EXIST — do NOT "fix" Test1234 to password123:
|
||||||
@@ -119,25 +131,25 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
|||||||
throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)")
|
throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)")
|
||||||
}
|
}
|
||||||
|
|
||||||
try super.setUpWithError()
|
|
||||||
|
|
||||||
if usesFreshAccount {
|
if usesFreshAccount {
|
||||||
// Per-test isolation: relaunchBetweenTests gives every test a fresh
|
// Per-test isolation WITHOUT a UI login: create the account and seed
|
||||||
// app launch that lands on the login screen (--reset-state), so we
|
// its UI-gated data via API BEFORE the app launches, then boot the
|
||||||
// log in as a brand-new pre-verified account, seed under its token,
|
// app already authenticated via the injected session token (see
|
||||||
// and delete it in teardown. No UI logout between tests.
|
// `additionalLaunchArguments`). relaunchBetweenTests gives every test
|
||||||
|
// a fresh launch, so each boots as its own account; the account is
|
||||||
|
// deleted in teardown (cascading all its data).
|
||||||
let acct = TestAccount.create(domain: accountDomain)
|
let acct = TestAccount.create(domain: accountDomain)
|
||||||
account = acct
|
account = acct
|
||||||
session = acct.session
|
session = acct.session
|
||||||
cleaner = TestDataCleaner(token: acct.token)
|
cleaner = TestDataCleaner(token: acct.token)
|
||||||
// Seed UI-gated baseline data BEFORE login so the app loads it on
|
|
||||||
// its post-login fetch (a fresh account is otherwise empty).
|
|
||||||
seedAccountPreconditions(acct)
|
seedAccountPreconditions(acct)
|
||||||
acct.login(into: app, timeout: loginTimeout)
|
try super.setUpWithError() // launches with --ui-test-session-token
|
||||||
waitForMainApp()
|
waitForMainApp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try super.setUpWithError()
|
||||||
|
|
||||||
// Legacy path: log in as a SPECIFIC seeded account (testCredentials),
|
// Legacy path: log in as a SPECIFIC seeded account (testCredentials),
|
||||||
// optionally opening a separate API session (apiCredentials).
|
// optionally opening a separate API session (apiCredentials).
|
||||||
let tabBar = app.tabBars.firstMatch
|
let tabBar = app.tabBars.firstMatch
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ which in `setUp`:
|
|||||||
|
|
||||||
1. mints a **unique, pre-verified Kratos identity** —
|
1. mints a **unique, pre-verified Kratos identity** —
|
||||||
`uit_<domain>_<uuid>@test.honeydue.local` (see `Core/Fixtures/TestAccount.swift`),
|
`uit_<domain>_<uuid>@test.honeydue.local` (see `Core/Fixtures/TestAccount.swift`),
|
||||||
2. logs the app in as that account,
|
2. boots the app **already authenticated** by passing the account's real Kratos
|
||||||
|
token as `--ui-test-session-token` (the app reads it in `UITestRuntime` and
|
||||||
|
calls `DataManager.setAuthToken`). This skips the slow, flaky UI login
|
||||||
|
(~8–12s/test) — the app lands straight on the main tabs. Logged-OUT suites
|
||||||
|
(login/registration/onboarding) still drive the real login UI, since that's
|
||||||
|
what they test,
|
||||||
3. exposes `session` / `cleaner` / `account` for seeding under its **own** token,
|
3. exposes `session` / `cleaner` / `account` for seeding under its **own** token,
|
||||||
|
|
||||||
and in `tearDown` calls `account.delete()`, which **cascades all of the
|
and in `tearDown` calls `account.delete()`, which **cascades all of the
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ enum UITestRuntime {
|
|||||||
static let resetStateFlag = "--reset-state"
|
static let resetStateFlag = "--reset-state"
|
||||||
static let mockAuthFlag = "--ui-test-mock-auth"
|
static let mockAuthFlag = "--ui-test-mock-auth"
|
||||||
static let completeOnboardingFlag = "--complete-onboarding"
|
static let completeOnboardingFlag = "--complete-onboarding"
|
||||||
|
static let sessionTokenFlag = "--ui-test-session-token"
|
||||||
// i18n-ignore-end
|
// i18n-ignore-end
|
||||||
|
|
||||||
static var launchArguments: [String] {
|
static var launchArguments: [String] {
|
||||||
@@ -36,6 +37,17 @@ enum UITestRuntime {
|
|||||||
isEnabled && launchArguments.contains(completeOnboardingFlag)
|
isEnabled && launchArguments.contains(completeOnboardingFlag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A real Kratos session token supplied by an authenticated UI test so the
|
||||||
|
/// app can boot already logged in (skipping the slow/flaky UI login). The
|
||||||
|
/// test obtains this token when it creates the account via API.
|
||||||
|
static var injectedSessionToken: String? {
|
||||||
|
guard isEnabled else { return nil }
|
||||||
|
let args = launchArguments
|
||||||
|
guard let i = args.firstIndex(of: sessionTokenFlag), i + 1 < args.count else { return nil }
|
||||||
|
let token = args[i + 1]
|
||||||
|
return token.isEmpty ? nil : token
|
||||||
|
}
|
||||||
|
|
||||||
static func configureForLaunch() {
|
static func configureForLaunch() {
|
||||||
guard isEnabled else { return }
|
guard isEnabled else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,26 @@ struct iOSApp: App {
|
|||||||
// Initialize TokenStorage once at app startup (legacy support)
|
// Initialize TokenStorage once at app startup (legacy support)
|
||||||
TokenStorage.shared.initialize(manager: TokenManager.Companion.shared.getInstance())
|
TokenStorage.shared.initialize(manager: TokenManager.Companion.shared.getInstance())
|
||||||
|
|
||||||
|
// UI-test auth injection: boot already authenticated with a real Kratos
|
||||||
|
// token (supplied by the test that created the account via API), skipping
|
||||||
|
// the slow, flaky UI login. Set AFTER the reset above so it isn't cleared.
|
||||||
|
// Also kick off the lookup init the UI login path would normally trigger,
|
||||||
|
// so pickers (categories, priorities, …) are populated.
|
||||||
|
if UITestRuntime.isEnabled, let token = UITestRuntime.injectedSessionToken {
|
||||||
|
DataManager.shared.setAuthToken(token: token)
|
||||||
|
// Replicate the post-login init the UI login path runs (initializeLookups
|
||||||
|
// + prefetchAllData) so data-gated screens — e.g. the residence detail
|
||||||
|
// toolbar (delete / manage-users) — have their data on boot.
|
||||||
|
Task {
|
||||||
|
// currentUser must be set or owner-gated UI (residence delete,
|
||||||
|
// manage-users) stays hidden — the UI login path fetches it too.
|
||||||
|
_ = try? await APILayer.shared.getCurrentUser(forceRefresh: true)
|
||||||
|
_ = try? await APILayer.shared.initializeLookups()
|
||||||
|
_ = try? await APILayer.shared.getMyResidences(forceRefresh: true)
|
||||||
|
_ = try? await APILayer.shared.getTasks(forceRefresh: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !UITestRuntime.isEnabled {
|
if !UITestRuntime.isEnabled {
|
||||||
// Initialize PostHog Analytics (must use Swift AnalyticsManager, not the Kotlin stub)
|
// Initialize PostHog Analytics (must use Swift AnalyticsManager, not the Kotlin stub)
|
||||||
AnalyticsManager.shared.configure()
|
AnalyticsManager.shared.configure()
|
||||||
|
|||||||
Reference in New Issue
Block a user