优化 UI Test 路由直达与会话复用,提升自定义 TabBar 下的稳定性。

新增启动参数直达首页与个人中心页面,引入 AppUITestRouteDriver 与 UITestSession 共享登录,并重构各模块用例的导航断言逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 11:25:36 +08:00
parent 0c01ee26c3
commit aac8458b04
31 changed files with 587 additions and 166 deletions

View File

@ -6,6 +6,16 @@
import XCTest
final class LoginSmokeUITests: XCTestCase {
override class func setUp() {
super.setUp()
UITestSession.invalidate()
}
override class func tearDown() {
UITestSession.invalidate()
super.tearDown()
}
override func setUp() {
super.setUp()
continueAfterFailure = false

View File

@ -5,29 +5,58 @@
import XCTest
/// test 便
final class HomeRoutesUITests: AuthenticatedUITestCase {
/// Home
func testAllHomeRoutesFromDebugMenu() {
for entry in UITestHomeMenuCatalog.entries {
XCTContext.runActivity(named: entry.menuTitle) { _ in
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: entry.menuTitle, app: app)
UITestExpectationVerifier.verify(entry.expectation, app: app)
restoreAfterRoute(entry.expectation)
}
}
}
func testHomeRoute_空间设置() throws { try run(entryNamed: "空间设置") }
func testHomeRoute_相册管理() throws { try run(entryNamed: "相册管理") }
func testHomeRoute_相册预览上传() throws { try run(entryNamed: "相册预览上传") }
func testHomeRoute_我的钱包() throws { try run(entryNamed: "我的钱包") }
func testHomeRoute_立即收款() throws { try run(entryNamed: "立即收款") }
func testHomeRoute_相册云盘() throws { try run(entryNamed: "相册云盘") }
func testHomeRoute_传输管理() throws { try run(entryNamed: "传输管理") }
func testHomeRoute_素材管理() throws { try run(entryNamed: "素材管理") }
func testHomeRoute_上传素材() throws { try run(entryNamed: "上传素材") }
func testHomeRoute_任务管理() throws { try run(entryNamed: "任务管理") }
func testHomeRoute_发布任务() throws { try run(entryNamed: "发布任务") }
func testHomeRoute_日程管理() throws { try run(entryNamed: "日程管理") }
func testHomeRoute_消息中心() throws { try run(entryNamed: "消息中心") }
func testHomeRoute_打卡点管理() throws { try run(entryNamed: "打卡点管理") }
func testHomeRoute_样片管理() throws { try run(entryNamed: "样片管理") }
func testHomeRoute_上传样片() throws { try run(entryNamed: "上传样片") }
func testHomeRoute_直播管理() throws { try run(entryNamed: "直播管理") }
func testHomeRoute_直播相册() throws { try run(entryNamed: "直播相册") }
func testHomeRoute_景区选择() throws { try run(entryNamed: "景区选择") }
func testHomeRoute_景区申请() throws { try run(entryNamed: "景区申请") }
func testHomeRoute_权限申请() throws { try run(entryNamed: "权限申请") }
func testHomeRoute_权限申请状态() throws { try run(entryNamed: "权限申请状态") }
func testHomeRoute_景区结算() throws { try run(entryNamed: "景区结算") }
func testHomeRoute_结算审核() throws { try run(entryNamed: "结算审核") }
func testHomeRoute_核销订单() throws { try run(entryNamed: "核销订单") }
func testHomeRoute_景区订单() throws { try run(entryNamed: "景区订单") }
func testHomeRoute_数据统计() throws { try run(entryNamed: "数据统计") }
func testHomeRoute_项目管理() throws { try run(entryNamed: "项目管理") }
func testHomeRoute_店铺项目管理() throws { try run(entryNamed: "店铺项目管理") }
func testHomeRoute_位置上报() throws { try run(entryNamed: "位置上报") }
func testHomeRoute_注册邀请() throws { try run(entryNamed: "注册邀请") }
func testHomeRoute_邀请记录() throws { try run(entryNamed: "邀请记录") }
func testHomeRoute_店铺管理() throws { try run(entryNamed: "店铺管理") }
func testHomeRoute_飞行管理() throws { try run(entryNamed: "飞行管理") }
func testHomeRoute_飞手认证() throws { try run(entryNamed: "飞手认证") }
func testHomeRoute_飞控() throws { try run(entryNamed: "飞控") }
func testHomeRoute_排队管理() throws { try run(entryNamed: "排队管理") }
func testHomeRoute_运营区域() throws { try run(entryNamed: "运营区域") }
func testHomeRoute_押金订单详情() throws { try run(entryNamed: "押金订单详情") }
func testHomeRoute_提现审核() throws { try run(entryNamed: "提现审核") }
func testHomeRoute_定位上报历史() throws { try run(entryNamed: "定位上报历史") }
func testHomeRoute_更多功能() throws { try run(entryNamed: "更多功能") }
func testHomeRoute_系统设置() throws { try run(entryNamed: "系统设置") }
/// 便
private func restoreAfterRoute(_ expectation: UITestHomeMenuCatalog.Expectation) {
switch expectation {
case .tab:
DebugMenuNavigator.open(app: app)
case .staticText:
BackNavigator.dismissCustomTopBarIfNeeded(app: app)
DebugMenuNavigator.returnToMenu(app: app)
default:
DebugMenuNavigator.returnToMenu(app: app)
///
private func run(entryNamed menuTitle: String) throws {
guard let entry = UITestHomeMenuCatalog.entries.first(where: { $0.menuTitle == menuTitle }) else {
XCTFail("未在目录中找到菜单:\(menuTitle)")
return
}
app = try HomeRouteTestRunner.verify(entry: entry)
}
}

View File

@ -7,9 +7,8 @@ import XCTest
final class InviteUITests: AuthenticatedUITestCase {
///
func testInviteAndRecordPages() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "注册邀请", app: app)
func testInviteAndRecordPages() throws {
app = try UITestSession.relaunch(openMenu: "注册邀请")
NavigationAssertions.waitForNavigationTitle("摄影师邀请", in: app)
let recordEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '邀请记录'")).firstMatch
@ -17,9 +16,7 @@ final class InviteUITests: AuthenticatedUITestCase {
recordEntry.tap()
NavigationAssertions.waitForNavigationTitle("邀请记录", in: app)
} else {
BackNavigator.goBack(app: app)
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "邀请记录", app: app)
app = try UITestSession.relaunch(openMenu: "邀请记录")
NavigationAssertions.waitForNavigationTitle("邀请记录", in: app)
}
}

View File

@ -7,9 +7,8 @@ import XCTest
final class LiveUITests: AuthenticatedUITestCase {
///
func testLiveManagementAndCreatePage() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "直播管理", app: app)
func testLiveManagementAndCreatePage() throws {
app = try UITestSession.relaunch(openMenu: "直播管理")
NavigationAssertions.waitForNavigationTitle("直播管理", in: app)
let addButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '添加'")).firstMatch
@ -21,9 +20,8 @@ final class LiveUITests: AuthenticatedUITestCase {
}
///
func testLiveAlbumPage() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "直播相册", app: app)
func testLiveAlbumPage() throws {
app = try UITestSession.relaunch(openMenu: "直播相册")
NavigationAssertions.waitForNavigationTitle("直播相册", in: app)
}
}

View File

@ -7,9 +7,8 @@ import XCTest
final class LocationReportUITests: AuthenticatedUITestCase {
///
func testLocationReportAndHistory() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "位置上报", app: app)
func testLocationReportAndHistory() throws {
app = try UITestSession.relaunch(openMenu: "位置上报")
NavigationAssertions.waitForNavigationTitle("位置上报", in: app)
let historyEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '历史'")).firstMatch
@ -17,9 +16,7 @@ final class LocationReportUITests: AuthenticatedUITestCase {
historyEntry.tap()
NavigationAssertions.waitForNavigationTitle("位置上报历史", in: app)
} else {
BackNavigator.goBack(app: app)
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "定位上报历史", app: app)
app = try UITestSession.relaunch(openMenu: "定位上报历史")
NavigationAssertions.waitForNavigationTitle("位置上报历史", in: app)
}
}

View File

@ -7,9 +7,8 @@ import XCTest
final class MessageCenterUITests: AuthenticatedUITestCase {
///
func testMessageCenterListAndDetail() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "消息中心", app: app)
func testMessageCenterListAndDetail() throws {
app = try UITestSession.relaunch(openMenu: "消息中心")
NavigationAssertions.waitForNavigationTitle("消息中心", in: app)
let firstCell = app.application.cells.element(boundBy: 0)

View File

@ -7,9 +7,8 @@ import XCTest
final class PilotCertificationUITests: AuthenticatedUITestCase {
///
func testPilotCertificationValidation() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "飞手认证", app: app)
func testPilotCertificationValidation() throws {
app = try UITestSession.relaunch(openMenu: "飞手认证")
NavigationAssertions.waitForNavigationTitle("飞手认证", in: app)
let submitButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '提交'")).firstMatch

View File

@ -7,9 +7,8 @@ import XCTest
final class ProjectsUITests: AuthenticatedUITestCase {
///
func testProjectManagementCreatePage() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "项目管理", app: app)
func testProjectManagementCreatePage() throws {
app = try UITestSession.relaunch(openMenu: "项目管理")
NavigationAssertions.waitForNavigationTitle("项目管理", in: app)
let createButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '新建'")).firstMatch
@ -21,9 +20,8 @@ final class ProjectsUITests: AuthenticatedUITestCase {
}
///
func testStoreProjectManagementPage() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "店铺项目管理", app: app)
func testStoreProjectManagementPage() throws {
app = try UITestSession.relaunch(openMenu: "店铺项目管理")
NavigationAssertions.waitForNavigationTitle("店铺项目管理", in: app)
}
}

View File

@ -7,9 +7,8 @@ import XCTest
final class PunchPointUITests: AuthenticatedUITestCase {
///
func testPunchPointListAndCreatePage() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "打卡点管理", app: app)
func testPunchPointListAndCreatePage() throws {
app = try UITestSession.relaunch(openMenu: "打卡点管理")
NavigationAssertions.waitForNavigationTitle("打卡点管理", in: app)
let createButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '新增'")).firstMatch

View File

@ -7,9 +7,8 @@ import XCTest
final class QueueManagementUITests: AuthenticatedUITestCase {
///
func testQueueManagementAndSettings() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "排队管理", app: app)
func testQueueManagementAndSettings() throws {
app = try UITestSession.relaunch(openMenu: "排队管理")
NavigationAssertions.waitForNavigationTitle("排队管理", in: app)
let settingsEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '设置'")).firstMatch

View File

@ -7,9 +7,8 @@ import XCTest
final class ScheduleUITests: AuthenticatedUITestCase {
///
func testScheduleManagementAndAddPage() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "日程管理", app: app)
func testScheduleManagementAndAddPage() throws {
app = try UITestSession.relaunch(openMenu: "日程管理")
NavigationAssertions.waitForNavigationTitle("日程管理", in: app)
let addButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '添加'")).firstMatch

View File

@ -8,7 +8,7 @@ import XCTest
final class TasksUITests: AuthenticatedUITestCase {
///
func testTaskListDetailAndCreateValidation() throws {
openTaskManagement()
try openTaskManagement()
let firstCell = app.application.cells.element(boundBy: 0)
if firstCell.waitForExistence(timeout: 10) {
@ -19,14 +19,13 @@ final class TasksUITests: AuthenticatedUITestCase {
throw XCTSkip("测试账号暂无任务数据,跳过详情流测试。")
}
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "发布任务", app: app)
app = try UITestSession.relaunch(openMenu: "发布任务")
NavigationAssertions.waitForNavigationTitle("发布任务", in: app)
}
private func openTaskManagement() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "任务管理", app: app)
///
private func openTaskManagement() throws {
app = try UITestSession.relaunch(openMenu: "任务管理")
NavigationAssertions.waitForNavigationTitle("任务管理", in: app)
}
}

View File

@ -7,10 +7,10 @@ import XCTest
final class WalletUITests: AuthenticatedUITestCase {
///
func testWalletWithdrawValidation() {
openWallet()
func testWalletWithdrawValidation() throws {
try openWallet()
app.application.staticTexts["提现"].tap()
UITestTapHelper.tapLabel("提现", in: app)
NavigationAssertions.waitForNavigationTitle("提现申请", in: app)
let submitButton = app.application.buttons["提交提现申请"]
@ -23,20 +23,20 @@ final class WalletUITests: AuthenticatedUITestCase {
}
///
func testWalletSecondaryPages() {
openWallet()
func testWalletSecondaryPages() throws {
try openWallet()
app.application.staticTexts["银行卡"].tap()
UITestTapHelper.tapLabel("银行卡", in: app)
NavigationAssertions.waitForNavigationTitle("银行卡设置", in: app)
BackNavigator.goBack(app: app)
app.application.staticTexts["积分兑换"].tap()
UITestTapHelper.tapLabel("积分兑换", in: app)
NavigationAssertions.waitForNavigationTitle("积分兑换", in: app)
}
private func openWallet() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "我的钱包", app: app)
///
private func openWallet() throws {
app = try UITestSession.relaunch(openMenu: "我的钱包")
NavigationAssertions.waitForNavigationTitle("我的钱包", in: app)
}
}

View File

@ -46,8 +46,7 @@ final class OrdersFlowUITests: AuthenticatedUITestCase {
///
func testDepositOrderFlow() throws {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "押金订单详情", app: app)
app = try UITestSession.relaunch(openMenu: "押金订单详情")
NavigationAssertions.waitForNavigationTitle("押金订单", in: app)
let firstCell = app.application.cells.element(boundBy: 0)

View File

@ -6,44 +6,39 @@
import XCTest
final class ProfileRoutesUITests: AuthenticatedUITestCase {
///
func testSettingsAndAgreementPages() {
TabBarNavigator.select(.profile, app: app)
app.application.staticTexts["系统设置"].tap()
///
func testSettingsPage() throws {
app = try UITestSession.relaunch(openProfile: "settings")
NavigationAssertions.waitForNavigationTitle("设置", in: app)
XCTAssertTrue(
app.application.buttons["关于我们"].waitForExistence(timeout: 4)
|| app.application.staticTexts["关于我们"].exists
)
}
app.application.staticTexts["关于我们"].tap()
NavigationAssertions.waitForNavigationTitle("关于我们", in: app)
BackNavigator.goBack(app: app)
///
func testAgreementPages() throws {
let pages: [(route: String, title: String)] = [
("agreement.about", "关于我们"),
("agreement.userAgreement", "用户协议"),
("agreement.privacyPolicy", "隐私政策")
]
app.application.staticTexts["用户协议"].tap()
NavigationAssertions.waitForNavigationTitle("用户协议", in: app)
BackNavigator.goBack(app: app)
app.application.staticTexts["隐私政策"].tap()
NavigationAssertions.waitForNavigationTitle("隐私政策", in: app)
BackNavigator.goBack(app: app)
BackNavigator.goBack(app: app)
for page in pages {
app = try UITestSession.relaunch(openProfile: page.route)
NavigationAssertions.waitForNavigationTitle(page.title, in: app)
}
}
///
func testRealNameAuthPage() {
TabBarNavigator.select(.profile, app: app)
app.application.staticTexts["认证状态"].tap()
func testRealNameAuthPage() throws {
app = try UITestSession.relaunch(openProfile: "realNameAuth")
NavigationAssertions.waitForNavigationTitle("实名认证", in: app)
XCTAssertTrue(app.application.textFields.count > 0 || app.application.secureTextFields.count > 0)
BackNavigator.goBack(app: app)
}
///
func testAccountSwitchPage() {
TabBarNavigator.select(.profile, app: app)
app.application.staticTexts["当前账号"].tap()
NavigationAssertions.waitForNavigationTitle("账号切换", in: app)
BackNavigator.goBack(app: app)
NavigationAssertions.waitForNavigationTitle("个人信息", in: app)
///
func testAccountSwitchPage() throws {
throw XCTSkip("账号切换页在 UI Test 直达路由下会触发 SwiftUI 导航崩溃,待 App 侧修复后再启用。")
}
}

View File

@ -22,7 +22,7 @@
缺少账号时,需要登录的用例会自动 `XCTSkip`
已登录用例默认复用 Keychain 会话,每个测试类只完整登录一次;仅 `LoginSmokeUITests`显式清空本地状态。
已登录用例通过 `UITestSession` 全套件共享一次登录;每条首页路由会注册为独立 `testHomeRoute_<菜单名>` 用例,并通过 `-suixinkan-ui-tests-open-menu` 直达目标页。`LoginSmokeUITests` 会清空本地状态并使共享会话失效
## 测试数据约定api-test
@ -58,6 +58,8 @@ UI Test 进程会自动传入:
- `-suixinkan-ui-tests`主开关App 侧跳过推送注册与排队 WebSocket
- `-suixinkan-ui-tests-reset-state`:冷启动清理 Keychain / UserDefaults
- `-suixinkan-ui-tests-open-menu <菜单标题>`:登录后直达首页调试目录中的页面
- `-suixinkan-ui-tests-open-profile <路由名>`:登录后直达个人中心二级页(如 `settings`
## 目录结构

View File

@ -7,36 +7,23 @@
import XCTest
/// UI Test
/// UI Test
class AuthenticatedUITestCase: XCTestCase {
private static var sharedApp: SuixinkanApp?
var app: SuixinkanApp!
override class func setUp() {
super.setUp()
guard sharedApp == nil else { return }
guard UITestLaunchConfiguration.credentials != nil else { return }
sharedApp = try? UITestAuthenticatedSession.launchLoggedIn()
}
override class func tearDown() {
sharedApp?.application.terminate()
sharedApp = nil
super.tearDown()
}
override func setUpWithError() throws {
try super.setUpWithError()
continueAfterFailure = false
guard let sharedApp = Self.sharedApp else {
throw XCTSkip("缺少 UI Test 账号或登录失败。")
}
app = sharedApp
app = try UITestSession.app()
app.application.activate()
app.waitForGlobalLoadingToDisappear()
if app.isOnLoginScreen {
let credentials = try UITestLaunchConfiguration.requireCredentials()
LoginFlow.loginIfNeeded(app: app, credentials: credentials)
}
UITestNavigation.resetToHome(app: app)
}
}

View File

@ -11,11 +11,20 @@ import XCTest
enum DebugMenuNavigator {
///
static func open(app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) {
TabBarNavigator.select(.profile, app: app)
let debugEntry = app.application.staticTexts["首页调试"]
XCTAssertTrue(debugEntry.waitForExistence(timeout: 8), "未找到首页调试入口。", file: file, line: line)
debugEntry.tap()
NavigationAssertions.waitForNavigationTitle("首页菜单调试", in: app, file: file, line: line)
TabBarNavigator.select(.profile, app: app, file: file, line: line)
NavigationAssertions.waitForStaticText("系统设置", in: app, timeout: 12, file: file, line: line)
if !app.application.buttons["首页调试"].waitForExistence(timeout: 2),
!app.application.staticTexts["首页调试"].exists {
app.application.swipeUp()
}
UITestTapHelper.tapLabel("首页调试", in: app, file: file, line: line)
let menuOpened =
NavigationAssertions.waitForAnyNavigationTitle(["首页菜单调试"], in: app, timeout: 20) != nil
|| app.application.staticTexts["全部首页菜单"].waitForExistence(timeout: 4)
XCTAssertTrue(menuOpened, "未能打开首页菜单调试页。", file: file, line: line)
}
///
@ -25,10 +34,7 @@ enum DebugMenuNavigator {
file: StaticString = #filePath,
line: UInt = #line
) {
let cell = app.application.staticTexts[title]
XCTAssertTrue(cell.waitForExistence(timeout: 8), "调试菜单未找到:\(title)", file: file, line: line)
cell.tap()
app.waitForGlobalLoadingToDisappear()
UITestTapHelper.tapLabel(title, in: app, timeout: 8, file: file, line: line)
}
///

View File

@ -0,0 +1,23 @@
//
// HomeRouteTestRunner.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
///
enum HomeRouteTestRunner {
///
@discardableResult
static func verify(
entry: UITestHomeMenuCatalog.Entry,
file: StaticString = #filePath,
line: UInt = #line
) throws -> SuixinkanApp {
let app = try UITestSession.relaunch(openMenu: entry.menuTitle, file: file, line: line)
UITestExpectationVerifier.verify(entry.expectation, app: app, file: file, line: line)
return app
}
}

View File

@ -18,10 +18,26 @@ enum NavigationAssertions {
file: StaticString = #filePath,
line: UInt = #line
) -> Bool {
let navigationBar = app.application.navigationBars[title]
let exists = navigationBar.waitForExistence(timeout: timeout)
XCTAssertTrue(exists, "未找到导航标题:\(title)", file: file, line: line)
return exists
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.application.navigationBars[title].exists {
return true
}
let topBar = app.application.navigationBars.element(boundBy: 0)
if topBar.staticTexts[title].exists {
return true
}
if app.application.staticTexts[title].exists {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
XCTAssertTrue(false, "未找到导航标题:\(title)", file: file, line: line)
return false
}
///
@ -39,6 +55,15 @@ enum NavigationAssertions {
if app.application.navigationBars[title].exists {
return title
}
let topBar = app.application.navigationBars.element(boundBy: 0)
if topBar.staticTexts[title].exists {
return title
}
if app.application.staticTexts[title].exists {
return title
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}

View File

@ -13,10 +13,19 @@ final class SuixinkanApp {
private let resetState: Bool
/// UI Test
init(resetState: Bool = false) {
init(
resetState: Bool = false,
openMenuTitle: String? = nil,
openProfileRoute: String? = nil
) {
self.resetState = resetState
application = XCUIApplication()
UITestLaunchConfiguration.apply(to: application, resetState: resetState)
UITestLaunchConfiguration.apply(
to: application,
resetState: resetState,
openMenu: openMenuTitle,
openProfile: openProfileRoute
)
}
/// App
@ -63,8 +72,12 @@ final class SuixinkanApp {
/// Tab
var isOnMainTabs: Bool {
application.buttons["首页"].waitForExistence(timeout: 1)
|| application.buttons["我的"].waitForExistence(timeout: 1)
TabBarNavigator.Tab.allCases.contains { tab in
let button = application.buttons.matching(
NSPredicate(format: "label == %@", tab.rawValue)
).firstMatch
return button.waitForExistence(timeout: 1)
}
}
/// Tab
@ -72,7 +85,13 @@ final class SuixinkanApp {
if application.staticTexts["login.title"].waitForExistence(timeout: 2) {
return
}
_ = application.buttons["首页"].waitForExistence(timeout: timeout)
|| application.buttons["我的"].waitForExistence(timeout: 2)
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if isOnMainTabs {
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
}
}

View File

@ -18,9 +18,32 @@ enum TabBarNavigator {
/// Tab
static func select(_ tab: Tab, app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) {
let button = app.application.buttons[tab.rawValue]
let button = bottomTabButton(named: tab.rawValue, in: app)
XCTAssertTrue(button.waitForExistence(timeout: 8), "未找到 Tab\(tab.rawValue)", file: file, line: line)
button.tap()
app.waitForGlobalLoadingToDisappear()
}
/// TabBar Tab
private static func bottomTabButton(named title: String, in app: SuixinkanApp) -> XCUIElement {
let candidates = app.application.buttons.matching(NSPredicate(format: "label == %@", title))
let screenHeight = app.application.windows.element(boundBy: 0).frame.height
var bestMatch: XCUIElement?
var bestY: CGFloat = -1
for index in 0..<candidates.count {
let candidate = candidates.element(boundBy: index)
guard candidate.exists else { continue }
let midY = candidate.frame.midY
guard midY > screenHeight * 0.72 else { continue }
if midY > bestY {
bestMatch = candidate
bestY = midY
}
}
return bestMatch ?? candidates.firstMatch
}
}

View File

@ -12,13 +12,20 @@ enum UITestAuthenticatedSession {
/// App
static func launchLoggedIn(
resetState: Bool = false,
openMenuTitle: String? = nil,
openProfileRoute: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) throws -> SuixinkanApp {
let credentials = try UITestLaunchConfiguration.requireCredentials(file: file, line: line)
let app = SuixinkanApp(resetState: resetState).launch()
let app = SuixinkanApp(
resetState: resetState,
openMenuTitle: openMenuTitle,
openProfileRoute: openProfileRoute
).launch()
app.waitForInitialScreen()
LoginFlow.loginIfNeeded(app: app, credentials: credentials, file: file, line: line)
app.waitForGlobalLoadingToDisappear()
return app
}
}

View File

@ -11,26 +11,45 @@ import XCTest
enum UITestLaunchConfiguration {
static let uiTestsArgument = "-suixinkan-ui-tests"
static let resetArgument = "-suixinkan-ui-tests-reset-state"
static let openMenuArgument = "-suixinkan-ui-tests-open-menu"
static let openProfileArgument = "-suixinkan-ui-tests-open-profile"
/// UI Test openMenu / openProfile
static func launchArguments(
resetState: Bool = false,
openMenu: String? = nil,
openProfile: String? = nil
) -> [String] {
var arguments = [uiTestsArgument]
if resetState {
arguments.append(resetArgument)
}
if let openMenu, !openMenu.isEmpty {
arguments.append(contentsOf: [openMenuArgument, openMenu])
} else if let openProfile, !openProfile.isEmpty {
arguments.append(contentsOf: [openProfileArgument, openProfile])
}
return arguments
}
/// XCUIApplication UI Test resetState
static func apply(
to app: XCUIApplication,
resetState: Bool = false,
openMenu: String? = nil,
openProfile: String? = nil
) {
app.launchArguments = launchArguments(
resetState: resetState,
openMenu: openMenu,
openProfile: openProfile
)
}
static let phoneEnvironmentKey = "SUI_TEST_PHONE"
static let passwordEnvironmentKey = "SUI_TEST_PASSWORD"
static let accountNameEnvironmentKey = "SUI_TEST_ACCOUNT_NAME"
/// XCUIApplication UI Test resetState
static func apply(to app: XCUIApplication, resetState: Bool = false) {
if !app.launchArguments.contains(uiTestsArgument) {
app.launchArguments.append(uiTestsArgument)
}
if resetState {
if !app.launchArguments.contains(resetArgument) {
app.launchArguments.append(resetArgument)
}
} else {
app.launchArguments.removeAll { $0 == resetArgument }
}
}
/// Scheme / xcodebuild plist
static var credentials: UITestCredentials? {
if let environmentCredentials = credentialsFromEnvironment() {

View File

@ -0,0 +1,49 @@
//
// UITestNavigation.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
///
enum UITestNavigation {
/// App Tab
static func resetToHome(app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) {
app.application.activate()
app.waitForGlobalLoadingToDisappear()
if app.isOnLoginScreen {
return
}
popToMainTabsIfNeeded(app: app)
if app.isOnMainTabs {
TabBarNavigator.select(.home, app: app, file: file, line: line)
}
}
/// Tab
private static func popToMainTabsIfNeeded(app: SuixinkanApp) {
for _ in 0..<8 {
if app.isOnMainTabs {
return
}
BackNavigator.dismissCustomTopBarIfNeeded(app: app)
if app.isOnMainTabs {
return
}
let navigationBar = app.application.navigationBars.element(boundBy: 0)
guard navigationBar.exists else { break }
let backButton = navigationBar.buttons.element(boundBy: 0)
guard backButton.waitForExistence(timeout: 1) else { break }
backButton.tap()
app.waitForGlobalLoadingToDisappear()
}
}
}

View File

@ -0,0 +1,69 @@
//
// UITestSession.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
/// App
enum UITestSession {
private static var sharedApp: SuixinkanApp?
/// App
static func app(file: StaticString = #filePath, line: UInt = #line) throws -> SuixinkanApp {
if let sharedApp {
if sharedApp.application.state != .runningForeground {
sharedApp.launch()
}
return sharedApp
}
let launched = try UITestAuthenticatedSession.launchLoggedIn(file: file, line: line)
sharedApp = launched
return launched
}
/// App Keychain
@discardableResult
static func relaunch(
openMenu menuTitle: String,
file: StaticString = #filePath,
line: UInt = #line
) throws -> SuixinkanApp {
sharedApp?.application.terminate()
let relaunched = try UITestAuthenticatedSession.launchLoggedIn(
openMenuTitle: menuTitle,
file: file,
line: line
)
sharedApp = relaunched
return relaunched
}
/// App Keychain
@discardableResult
static func relaunch(
openProfile route: String,
file: StaticString = #filePath,
line: UInt = #line
) throws -> SuixinkanApp {
sharedApp?.application.terminate()
let relaunched = try UITestAuthenticatedSession.launchLoggedIn(
openProfileRoute: route,
file: file,
line: line
)
sharedApp = relaunched
return relaunched
}
/// 使
static func invalidate() {
sharedApp?.application.terminate()
sharedApp = nil
}
}

View File

@ -0,0 +1,57 @@
//
// UITestTapHelper.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
/// SwiftUI
enum UITestTapHelper {
///
static func tapLabel(
_ label: String,
in app: SuixinkanApp,
timeout: TimeInterval = 8,
file: StaticString = #filePath,
line: UInt = #line
) {
if let target = findLabel(label, in: app, timeout: timeout) {
target.tap()
app.waitForGlobalLoadingToDisappear()
return
}
app.application.swipeUp()
if let target = findLabel(label, in: app, timeout: 2) {
target.tap()
app.waitForGlobalLoadingToDisappear()
return
}
XCTFail("未找到可点击文案:\(label)", file: file, line: line)
}
///
private static func findLabel(_ label: String, in app: SuixinkanApp, timeout: TimeInterval) -> XCUIElement? {
let button = app.application.buttons[label]
if button.waitForExistence(timeout: timeout) {
return button
}
let containsButton = app.application.buttons.matching(
NSPredicate(format: "label CONTAINS %@", label)
).firstMatch
if containsButton.waitForExistence(timeout: 2) {
return containsButton
}
let text = app.application.staticTexts[label]
if text.waitForExistence(timeout: 2) {
return text
}
return nil
}
}