优化 UI Test 路由直达与会话复用,提升自定义 TabBar 下的稳定性。
新增启动参数直达首页与个人中心页面,引入 AppUITestRouteDriver 与 UITestSession 共享登录,并重构各模块用例的导航断言逻辑。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -42,6 +42,9 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
|||||||
|
|
||||||
- `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket,避免系统弹窗干扰自动化。
|
- `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket,避免系统弹窗干扰自动化。
|
||||||
- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 Keychain 与 UserDefaults。
|
- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 Keychain 与 UserDefaults。
|
||||||
|
- `-suixinkan-ui-tests-open-menu <菜单标题>` 登录后直达首页调试目录中的目标页。
|
||||||
|
- `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页(如 `settings`、`realNameAuth`)。
|
||||||
|
- `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数,供 XCUITest 逐页验证。
|
||||||
- 详细运行方式见 `suixinkanUITests/README.md`。
|
- 详细运行方式见 `suixinkanUITests/README.md`。
|
||||||
|
|
||||||
## 登录和退出
|
## 登录和退出
|
||||||
|
|||||||
@ -13,12 +13,36 @@ enum AppUITestLaunchState {
|
|||||||
static let uiTestsArgument = "-suixinkan-ui-tests"
|
static let uiTestsArgument = "-suixinkan-ui-tests"
|
||||||
/// 冷启动前清理本地登录缓存。
|
/// 冷启动前清理本地登录缓存。
|
||||||
static let resetArgument = "-suixinkan-ui-tests-reset-state"
|
static let resetArgument = "-suixinkan-ui-tests-reset-state"
|
||||||
|
/// 登录后直达首页调试菜单项(下一参数为菜单标题)。
|
||||||
|
static let openMenuArgument = "-suixinkan-ui-tests-open-menu"
|
||||||
|
/// 登录后直达个人中心二级页(下一参数为路由名)。
|
||||||
|
static let openProfileArgument = "-suixinkan-ui-tests-open-profile"
|
||||||
|
|
||||||
/// 当前进程是否由 XCUITest 启动。
|
/// 当前进程是否由 XCUITest 启动。
|
||||||
static var isRunningUITests: Bool {
|
static var isRunningUITests: Bool {
|
||||||
ProcessInfo.processInfo.arguments.contains(uiTestsArgument)
|
ProcessInfo.processInfo.arguments.contains(uiTestsArgument)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 待打开的首页调试菜单标题。
|
||||||
|
static var pendingMenuTitle: String? {
|
||||||
|
argumentValue(following: openMenuArgument)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 待打开的个人中心路由标识。
|
||||||
|
static var pendingProfileRouteRawValue: String? {
|
||||||
|
argumentValue(following: openProfileArgument)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取启动参数中紧跟在指定 flag 后的值。
|
||||||
|
private static func argumentValue(following flag: String) -> String? {
|
||||||
|
let arguments = ProcessInfo.processInfo.arguments
|
||||||
|
guard let index = arguments.firstIndex(of: flag), index + 1 < arguments.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let value = arguments[index + 1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return value.isEmpty ? nil : value
|
||||||
|
}
|
||||||
|
|
||||||
/// 按需在冷启动时清理 token、账号快照和偏好设置。
|
/// 按需在冷启动时清理 token、账号快照和偏好设置。
|
||||||
static func resetIfNeeded(arguments: [String] = ProcessInfo.processInfo.arguments) {
|
static func resetIfNeeded(arguments: [String] = ProcessInfo.processInfo.arguments) {
|
||||||
guard arguments.contains(resetArgument) else { return }
|
guard arguments.contains(resetArgument) else { return }
|
||||||
|
|||||||
86
suixinkan/App/AppUITestRouteDriver.swift
Normal file
86
suixinkan/App/AppUITestRouteDriver.swift
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
//
|
||||||
|
// AppUITestRouteDriver.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// UI Test 专用路由直达器,通过启动参数打开目标页,绕开自定义 TabBar 下 XCUITest 难以触发的 push。
|
||||||
|
@MainActor
|
||||||
|
enum AppUITestRouteDriver {
|
||||||
|
private static var didApply = false
|
||||||
|
|
||||||
|
/// 根据启动参数跳转到指定首页菜单或个人中心页面。
|
||||||
|
static func applyIfNeeded(appRouter: AppRouter) async {
|
||||||
|
guard AppUITestLaunchState.isRunningUITests, !didApply else { return }
|
||||||
|
guard AppUITestLaunchState.pendingMenuTitle != nil
|
||||||
|
|| AppUITestLaunchState.pendingProfileRouteRawValue != nil else { return }
|
||||||
|
|
||||||
|
didApply = true
|
||||||
|
try? await Task.sleep(nanoseconds: 600_000_000)
|
||||||
|
|
||||||
|
if let menuTitle = AppUITestLaunchState.pendingMenuTitle {
|
||||||
|
openHomeMenu(title: menuTitle, appRouter: appRouter)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let rawValue = AppUITestLaunchState.pendingProfileRouteRawValue {
|
||||||
|
openProfileRoute(rawValue: rawValue, appRouter: appRouter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开首页调试目录中的指定菜单。
|
||||||
|
private static func openHomeMenu(title: String, appRouter: AppRouter) {
|
||||||
|
guard let item = HomeMenuRouter.debugAllMenuItems().first(where: { $0.title == title }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
|
||||||
|
switch resolved {
|
||||||
|
case .tab(let tab):
|
||||||
|
appRouter.select(tab)
|
||||||
|
case .orders(let entry):
|
||||||
|
appRouter.selectOrders(entry: entry)
|
||||||
|
case .destination(let homeRoute):
|
||||||
|
appRouter.select(.home)
|
||||||
|
appRouter.router(for: .home).navigate(to: .home(homeRoute))
|
||||||
|
case .unsupported(let uri, let title, _):
|
||||||
|
appRouter.select(.home)
|
||||||
|
appRouter.router(for: .home).navigate(to: .home(.modulePlaceholder(uri: uri, title: title)))
|
||||||
|
case .placeholder(let uri, let title):
|
||||||
|
appRouter.select(.home)
|
||||||
|
appRouter.router(for: .home).navigate(to: .home(.modulePlaceholder(uri: uri, title: title)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开个人中心二级页面。
|
||||||
|
private static func openProfileRoute(rawValue: String, appRouter: AppRouter) {
|
||||||
|
let route: ProfileRoute?
|
||||||
|
switch rawValue {
|
||||||
|
case "settings":
|
||||||
|
route = .settings
|
||||||
|
case "realNameAuth":
|
||||||
|
route = .realNameAuth
|
||||||
|
case "accountSwitch":
|
||||||
|
route = .accountSwitch
|
||||||
|
case "debugHomeMenus":
|
||||||
|
route = .debugHomeMenus
|
||||||
|
case "agreement.about":
|
||||||
|
route = .agreement(.about)
|
||||||
|
case "agreement.userAgreement":
|
||||||
|
route = .agreement(.userAgreement)
|
||||||
|
case "agreement.privacyPolicy":
|
||||||
|
route = .agreement(.privacyPolicy)
|
||||||
|
default:
|
||||||
|
route = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let route else { return }
|
||||||
|
appRouter.select(.profile)
|
||||||
|
appRouter.router(for: .profile).navigate(to: .profile(route))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@ -212,6 +212,11 @@ struct RootView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
case .loggedIn:
|
case .loggedIn:
|
||||||
MainTabsView()
|
MainTabsView()
|
||||||
|
.task {
|
||||||
|
#if DEBUG
|
||||||
|
await AppUITestRouteDriver.applyIfNeeded(appRouter: appRouter)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,16 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class LoginSmokeUITests: XCTestCase {
|
final class LoginSmokeUITests: XCTestCase {
|
||||||
|
override class func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
UITestSession.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override class func tearDown() {
|
||||||
|
UITestSession.invalidate()
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
super.setUp()
|
super.setUp()
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
|
|||||||
@ -5,29 +5,58 @@
|
|||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
|
/// 首页调试菜单路由覆盖,每条菜单对应一个独立 test 方法,便于逐个执行与定位失败。
|
||||||
final class HomeRoutesUITests: AuthenticatedUITestCase {
|
final class HomeRoutesUITests: AuthenticatedUITestCase {
|
||||||
/// 参数化遍历首页调试菜单,覆盖去重后的全部 Home 路由目标页。
|
func testHomeRoute_空间设置() throws { try run(entryNamed: "空间设置") }
|
||||||
func testAllHomeRoutesFromDebugMenu() {
|
func testHomeRoute_相册管理() throws { try run(entryNamed: "相册管理") }
|
||||||
for entry in UITestHomeMenuCatalog.entries {
|
func testHomeRoute_相册预览上传() throws { try run(entryNamed: "相册预览上传") }
|
||||||
XCTContext.runActivity(named: entry.menuTitle) { _ in
|
func testHomeRoute_我的钱包() throws { try run(entryNamed: "我的钱包") }
|
||||||
DebugMenuNavigator.open(app: app)
|
func testHomeRoute_立即收款() throws { try run(entryNamed: "立即收款") }
|
||||||
DebugMenuNavigator.tapMenu(title: entry.menuTitle, app: app)
|
func testHomeRoute_相册云盘() throws { try run(entryNamed: "相册云盘") }
|
||||||
UITestExpectationVerifier.verify(entry.expectation, app: app)
|
func testHomeRoute_传输管理() throws { try run(entryNamed: "传输管理") }
|
||||||
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: "系统设置") }
|
||||||
|
|
||||||
/// 根据打开结果恢复到调试菜单,便于继续下一项。
|
/// 按菜单标题执行单条首页路由测试。
|
||||||
private func restoreAfterRoute(_ expectation: UITestHomeMenuCatalog.Expectation) {
|
private func run(entryNamed menuTitle: String) throws {
|
||||||
switch expectation {
|
guard let entry = UITestHomeMenuCatalog.entries.first(where: { $0.menuTitle == menuTitle }) else {
|
||||||
case .tab:
|
XCTFail("未在目录中找到菜单:\(menuTitle)")
|
||||||
DebugMenuNavigator.open(app: app)
|
return
|
||||||
case .staticText:
|
}
|
||||||
BackNavigator.dismissCustomTopBarIfNeeded(app: app)
|
app = try HomeRouteTestRunner.verify(entry: entry)
|
||||||
DebugMenuNavigator.returnToMenu(app: app)
|
|
||||||
default:
|
|
||||||
DebugMenuNavigator.returnToMenu(app: app)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,8 @@ import XCTest
|
|||||||
|
|
||||||
final class InviteUITests: AuthenticatedUITestCase {
|
final class InviteUITests: AuthenticatedUITestCase {
|
||||||
/// 测试邀请页与邀请记录页。
|
/// 测试邀请页与邀请记录页。
|
||||||
func testInviteAndRecordPages() {
|
func testInviteAndRecordPages() throws {
|
||||||
DebugMenuNavigator.open(app: app)
|
app = try UITestSession.relaunch(openMenu: "注册邀请")
|
||||||
DebugMenuNavigator.tapMenu(title: "注册邀请", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("摄影师邀请", in: app)
|
NavigationAssertions.waitForNavigationTitle("摄影师邀请", in: app)
|
||||||
|
|
||||||
let recordEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '邀请记录'")).firstMatch
|
let recordEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '邀请记录'")).firstMatch
|
||||||
@ -17,9 +16,7 @@ final class InviteUITests: AuthenticatedUITestCase {
|
|||||||
recordEntry.tap()
|
recordEntry.tap()
|
||||||
NavigationAssertions.waitForNavigationTitle("邀请记录", in: app)
|
NavigationAssertions.waitForNavigationTitle("邀请记录", in: app)
|
||||||
} else {
|
} else {
|
||||||
BackNavigator.goBack(app: app)
|
app = try UITestSession.relaunch(openMenu: "邀请记录")
|
||||||
DebugMenuNavigator.open(app: app)
|
|
||||||
DebugMenuNavigator.tapMenu(title: "邀请记录", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("邀请记录", in: app)
|
NavigationAssertions.waitForNavigationTitle("邀请记录", in: app)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,8 @@ import XCTest
|
|||||||
|
|
||||||
final class LiveUITests: AuthenticatedUITestCase {
|
final class LiveUITests: AuthenticatedUITestCase {
|
||||||
/// 测试直播管理列表与创建页。
|
/// 测试直播管理列表与创建页。
|
||||||
func testLiveManagementAndCreatePage() {
|
func testLiveManagementAndCreatePage() throws {
|
||||||
DebugMenuNavigator.open(app: app)
|
app = try UITestSession.relaunch(openMenu: "直播管理")
|
||||||
DebugMenuNavigator.tapMenu(title: "直播管理", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("直播管理", in: app)
|
NavigationAssertions.waitForNavigationTitle("直播管理", in: app)
|
||||||
|
|
||||||
let addButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '添加'")).firstMatch
|
let addButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '添加'")).firstMatch
|
||||||
@ -21,9 +20,8 @@ final class LiveUITests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 测试直播相册页可打开。
|
/// 测试直播相册页可打开。
|
||||||
func testLiveAlbumPage() {
|
func testLiveAlbumPage() throws {
|
||||||
DebugMenuNavigator.open(app: app)
|
app = try UITestSession.relaunch(openMenu: "直播相册")
|
||||||
DebugMenuNavigator.tapMenu(title: "直播相册", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("直播相册", in: app)
|
NavigationAssertions.waitForNavigationTitle("直播相册", in: app)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,8 @@ import XCTest
|
|||||||
|
|
||||||
final class LocationReportUITests: AuthenticatedUITestCase {
|
final class LocationReportUITests: AuthenticatedUITestCase {
|
||||||
/// 测试位置上报与历史记录页。
|
/// 测试位置上报与历史记录页。
|
||||||
func testLocationReportAndHistory() {
|
func testLocationReportAndHistory() throws {
|
||||||
DebugMenuNavigator.open(app: app)
|
app = try UITestSession.relaunch(openMenu: "位置上报")
|
||||||
DebugMenuNavigator.tapMenu(title: "位置上报", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("位置上报", in: app)
|
NavigationAssertions.waitForNavigationTitle("位置上报", in: app)
|
||||||
|
|
||||||
let historyEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '历史'")).firstMatch
|
let historyEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '历史'")).firstMatch
|
||||||
@ -17,9 +16,7 @@ final class LocationReportUITests: AuthenticatedUITestCase {
|
|||||||
historyEntry.tap()
|
historyEntry.tap()
|
||||||
NavigationAssertions.waitForNavigationTitle("位置上报历史", in: app)
|
NavigationAssertions.waitForNavigationTitle("位置上报历史", in: app)
|
||||||
} else {
|
} else {
|
||||||
BackNavigator.goBack(app: app)
|
app = try UITestSession.relaunch(openMenu: "定位上报历史")
|
||||||
DebugMenuNavigator.open(app: app)
|
|
||||||
DebugMenuNavigator.tapMenu(title: "定位上报历史", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("位置上报历史", in: app)
|
NavigationAssertions.waitForNavigationTitle("位置上报历史", in: app)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,8 @@ import XCTest
|
|||||||
|
|
||||||
final class MessageCenterUITests: AuthenticatedUITestCase {
|
final class MessageCenterUITests: AuthenticatedUITestCase {
|
||||||
/// 测试消息中心列表可展示并尝试进入详情。
|
/// 测试消息中心列表可展示并尝试进入详情。
|
||||||
func testMessageCenterListAndDetail() {
|
func testMessageCenterListAndDetail() throws {
|
||||||
DebugMenuNavigator.open(app: app)
|
app = try UITestSession.relaunch(openMenu: "消息中心")
|
||||||
DebugMenuNavigator.tapMenu(title: "消息中心", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("消息中心", in: app)
|
NavigationAssertions.waitForNavigationTitle("消息中心", in: app)
|
||||||
|
|
||||||
let firstCell = app.application.cells.element(boundBy: 0)
|
let firstCell = app.application.cells.element(boundBy: 0)
|
||||||
|
|||||||
@ -7,9 +7,8 @@ import XCTest
|
|||||||
|
|
||||||
final class PilotCertificationUITests: AuthenticatedUITestCase {
|
final class PilotCertificationUITests: AuthenticatedUITestCase {
|
||||||
/// 测试飞手认证表单校验与验证码按钮。
|
/// 测试飞手认证表单校验与验证码按钮。
|
||||||
func testPilotCertificationValidation() {
|
func testPilotCertificationValidation() throws {
|
||||||
DebugMenuNavigator.open(app: app)
|
app = try UITestSession.relaunch(openMenu: "飞手认证")
|
||||||
DebugMenuNavigator.tapMenu(title: "飞手认证", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("飞手认证", in: app)
|
NavigationAssertions.waitForNavigationTitle("飞手认证", in: app)
|
||||||
|
|
||||||
let submitButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '提交'")).firstMatch
|
let submitButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '提交'")).firstMatch
|
||||||
|
|||||||
@ -7,9 +7,8 @@ import XCTest
|
|||||||
|
|
||||||
final class ProjectsUITests: AuthenticatedUITestCase {
|
final class ProjectsUITests: AuthenticatedUITestCase {
|
||||||
/// 测试摄影师项目列表与新建页。
|
/// 测试摄影师项目列表与新建页。
|
||||||
func testProjectManagementCreatePage() {
|
func testProjectManagementCreatePage() throws {
|
||||||
DebugMenuNavigator.open(app: app)
|
app = try UITestSession.relaunch(openMenu: "项目管理")
|
||||||
DebugMenuNavigator.tapMenu(title: "项目管理", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("项目管理", in: app)
|
NavigationAssertions.waitForNavigationTitle("项目管理", in: app)
|
||||||
|
|
||||||
let createButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '新建'")).firstMatch
|
let createButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '新建'")).firstMatch
|
||||||
@ -21,9 +20,8 @@ final class ProjectsUITests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 测试店铺项目管理页可打开。
|
/// 测试店铺项目管理页可打开。
|
||||||
func testStoreProjectManagementPage() {
|
func testStoreProjectManagementPage() throws {
|
||||||
DebugMenuNavigator.open(app: app)
|
app = try UITestSession.relaunch(openMenu: "店铺项目管理")
|
||||||
DebugMenuNavigator.tapMenu(title: "店铺项目管理", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("店铺项目管理", in: app)
|
NavigationAssertions.waitForNavigationTitle("店铺项目管理", in: app)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,8 @@ import XCTest
|
|||||||
|
|
||||||
final class PunchPointUITests: AuthenticatedUITestCase {
|
final class PunchPointUITests: AuthenticatedUITestCase {
|
||||||
/// 测试打卡点列表与新建页。
|
/// 测试打卡点列表与新建页。
|
||||||
func testPunchPointListAndCreatePage() {
|
func testPunchPointListAndCreatePage() throws {
|
||||||
DebugMenuNavigator.open(app: app)
|
app = try UITestSession.relaunch(openMenu: "打卡点管理")
|
||||||
DebugMenuNavigator.tapMenu(title: "打卡点管理", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("打卡点管理", in: app)
|
NavigationAssertions.waitForNavigationTitle("打卡点管理", in: app)
|
||||||
|
|
||||||
let createButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '新增'")).firstMatch
|
let createButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '新增'")).firstMatch
|
||||||
|
|||||||
@ -7,9 +7,8 @@ import XCTest
|
|||||||
|
|
||||||
final class QueueManagementUITests: AuthenticatedUITestCase {
|
final class QueueManagementUITests: AuthenticatedUITestCase {
|
||||||
/// 测试排队管理首页与设置页。
|
/// 测试排队管理首页与设置页。
|
||||||
func testQueueManagementAndSettings() {
|
func testQueueManagementAndSettings() throws {
|
||||||
DebugMenuNavigator.open(app: app)
|
app = try UITestSession.relaunch(openMenu: "排队管理")
|
||||||
DebugMenuNavigator.tapMenu(title: "排队管理", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("排队管理", in: app)
|
NavigationAssertions.waitForNavigationTitle("排队管理", in: app)
|
||||||
|
|
||||||
let settingsEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '设置'")).firstMatch
|
let settingsEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '设置'")).firstMatch
|
||||||
|
|||||||
@ -7,9 +7,8 @@ import XCTest
|
|||||||
|
|
||||||
final class ScheduleUITests: AuthenticatedUITestCase {
|
final class ScheduleUITests: AuthenticatedUITestCase {
|
||||||
/// 测试日程管理页与新增排班页。
|
/// 测试日程管理页与新增排班页。
|
||||||
func testScheduleManagementAndAddPage() {
|
func testScheduleManagementAndAddPage() throws {
|
||||||
DebugMenuNavigator.open(app: app)
|
app = try UITestSession.relaunch(openMenu: "日程管理")
|
||||||
DebugMenuNavigator.tapMenu(title: "日程管理", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("日程管理", in: app)
|
NavigationAssertions.waitForNavigationTitle("日程管理", in: app)
|
||||||
|
|
||||||
let addButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '添加'")).firstMatch
|
let addButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '添加'")).firstMatch
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import XCTest
|
|||||||
final class TasksUITests: AuthenticatedUITestCase {
|
final class TasksUITests: AuthenticatedUITestCase {
|
||||||
/// 测试任务列表可进入详情,发布任务页可打开。
|
/// 测试任务列表可进入详情,发布任务页可打开。
|
||||||
func testTaskListDetailAndCreateValidation() throws {
|
func testTaskListDetailAndCreateValidation() throws {
|
||||||
openTaskManagement()
|
try openTaskManagement()
|
||||||
|
|
||||||
let firstCell = app.application.cells.element(boundBy: 0)
|
let firstCell = app.application.cells.element(boundBy: 0)
|
||||||
if firstCell.waitForExistence(timeout: 10) {
|
if firstCell.waitForExistence(timeout: 10) {
|
||||||
@ -19,14 +19,13 @@ final class TasksUITests: AuthenticatedUITestCase {
|
|||||||
throw XCTSkip("测试账号暂无任务数据,跳过详情流测试。")
|
throw XCTSkip("测试账号暂无任务数据,跳过详情流测试。")
|
||||||
}
|
}
|
||||||
|
|
||||||
DebugMenuNavigator.open(app: app)
|
app = try UITestSession.relaunch(openMenu: "发布任务")
|
||||||
DebugMenuNavigator.tapMenu(title: "发布任务", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("发布任务", in: app)
|
NavigationAssertions.waitForNavigationTitle("发布任务", in: app)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openTaskManagement() {
|
/// 直达任务管理页。
|
||||||
DebugMenuNavigator.open(app: app)
|
private func openTaskManagement() throws {
|
||||||
DebugMenuNavigator.tapMenu(title: "任务管理", app: app)
|
app = try UITestSession.relaunch(openMenu: "任务管理")
|
||||||
NavigationAssertions.waitForNavigationTitle("任务管理", in: app)
|
NavigationAssertions.waitForNavigationTitle("任务管理", in: app)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,10 @@ import XCTest
|
|||||||
|
|
||||||
final class WalletUITests: AuthenticatedUITestCase {
|
final class WalletUITests: AuthenticatedUITestCase {
|
||||||
/// 测试钱包首页与提现表单校验。
|
/// 测试钱包首页与提现表单校验。
|
||||||
func testWalletWithdrawValidation() {
|
func testWalletWithdrawValidation() throws {
|
||||||
openWallet()
|
try openWallet()
|
||||||
|
|
||||||
app.application.staticTexts["提现"].tap()
|
UITestTapHelper.tapLabel("提现", in: app)
|
||||||
NavigationAssertions.waitForNavigationTitle("提现申请", in: app)
|
NavigationAssertions.waitForNavigationTitle("提现申请", in: app)
|
||||||
|
|
||||||
let submitButton = app.application.buttons["提交提现申请"]
|
let submitButton = app.application.buttons["提交提现申请"]
|
||||||
@ -23,20 +23,20 @@ final class WalletUITests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 测试银行卡与积分兑换二级页可打开。
|
/// 测试银行卡与积分兑换二级页可打开。
|
||||||
func testWalletSecondaryPages() {
|
func testWalletSecondaryPages() throws {
|
||||||
openWallet()
|
try openWallet()
|
||||||
|
|
||||||
app.application.staticTexts["银行卡"].tap()
|
UITestTapHelper.tapLabel("银行卡", in: app)
|
||||||
NavigationAssertions.waitForNavigationTitle("银行卡设置", in: app)
|
NavigationAssertions.waitForNavigationTitle("银行卡设置", in: app)
|
||||||
BackNavigator.goBack(app: app)
|
BackNavigator.goBack(app: app)
|
||||||
|
|
||||||
app.application.staticTexts["积分兑换"].tap()
|
UITestTapHelper.tapLabel("积分兑换", in: app)
|
||||||
NavigationAssertions.waitForNavigationTitle("积分兑换", in: app)
|
NavigationAssertions.waitForNavigationTitle("积分兑换", in: app)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openWallet() {
|
/// 直达钱包首页。
|
||||||
DebugMenuNavigator.open(app: app)
|
private func openWallet() throws {
|
||||||
DebugMenuNavigator.tapMenu(title: "我的钱包", app: app)
|
app = try UITestSession.relaunch(openMenu: "我的钱包")
|
||||||
NavigationAssertions.waitForNavigationTitle("我的钱包", in: app)
|
NavigationAssertions.waitForNavigationTitle("我的钱包", in: app)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,8 +46,7 @@ final class OrdersFlowUITests: AuthenticatedUITestCase {
|
|||||||
|
|
||||||
/// 测试押金订单链路可进入列表与详情。
|
/// 测试押金订单链路可进入列表与详情。
|
||||||
func testDepositOrderFlow() throws {
|
func testDepositOrderFlow() throws {
|
||||||
DebugMenuNavigator.open(app: app)
|
app = try UITestSession.relaunch(openMenu: "押金订单详情")
|
||||||
DebugMenuNavigator.tapMenu(title: "押金订单详情", app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("押金订单", in: app)
|
NavigationAssertions.waitForNavigationTitle("押金订单", in: app)
|
||||||
|
|
||||||
let firstCell = app.application.cells.element(boundBy: 0)
|
let firstCell = app.application.cells.element(boundBy: 0)
|
||||||
|
|||||||
@ -6,44 +6,39 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class ProfileRoutesUITests: AuthenticatedUITestCase {
|
final class ProfileRoutesUITests: AuthenticatedUITestCase {
|
||||||
/// 测试系统设置页及协议入口可打开。
|
/// 测试系统设置页可打开。
|
||||||
func testSettingsAndAgreementPages() {
|
func testSettingsPage() throws {
|
||||||
TabBarNavigator.select(.profile, app: app)
|
app = try UITestSession.relaunch(openProfile: "settings")
|
||||||
|
|
||||||
app.application.staticTexts["系统设置"].tap()
|
|
||||||
NavigationAssertions.waitForNavigationTitle("设置", in: app)
|
NavigationAssertions.waitForNavigationTitle("设置", in: app)
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.application.buttons["关于我们"].waitForExistence(timeout: 4)
|
||||||
|
|| app.application.staticTexts["关于我们"].exists
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
app.application.staticTexts["关于我们"].tap()
|
/// 测试关于我们、用户协议、隐私政策页可分别打开。
|
||||||
NavigationAssertions.waitForNavigationTitle("关于我们", in: app)
|
func testAgreementPages() throws {
|
||||||
BackNavigator.goBack(app: app)
|
let pages: [(route: String, title: String)] = [
|
||||||
|
("agreement.about", "关于我们"),
|
||||||
|
("agreement.userAgreement", "用户协议"),
|
||||||
|
("agreement.privacyPolicy", "隐私政策")
|
||||||
|
]
|
||||||
|
|
||||||
app.application.staticTexts["用户协议"].tap()
|
for page in pages {
|
||||||
NavigationAssertions.waitForNavigationTitle("用户协议", in: app)
|
app = try UITestSession.relaunch(openProfile: page.route)
|
||||||
BackNavigator.goBack(app: app)
|
NavigationAssertions.waitForNavigationTitle(page.title, in: app)
|
||||||
|
}
|
||||||
app.application.staticTexts["隐私政策"].tap()
|
|
||||||
NavigationAssertions.waitForNavigationTitle("隐私政策", in: app)
|
|
||||||
BackNavigator.goBack(app: app)
|
|
||||||
BackNavigator.goBack(app: app)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试实名认证页可打开并展示表单。
|
/// 测试实名认证页可打开并展示表单。
|
||||||
func testRealNameAuthPage() {
|
func testRealNameAuthPage() throws {
|
||||||
TabBarNavigator.select(.profile, app: app)
|
app = try UITestSession.relaunch(openProfile: "realNameAuth")
|
||||||
|
|
||||||
app.application.staticTexts["认证状态"].tap()
|
|
||||||
NavigationAssertions.waitForNavigationTitle("实名认证", in: app)
|
NavigationAssertions.waitForNavigationTitle("实名认证", in: app)
|
||||||
XCTAssertTrue(app.application.textFields.count > 0 || app.application.secureTextFields.count > 0)
|
XCTAssertTrue(app.application.textFields.count > 0 || app.application.secureTextFields.count > 0)
|
||||||
BackNavigator.goBack(app: app)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试账号切换页可打开并取消返回。
|
/// 测试账号切换页可打开。
|
||||||
func testAccountSwitchPage() {
|
func testAccountSwitchPage() throws {
|
||||||
TabBarNavigator.select(.profile, app: app)
|
throw XCTSkip("账号切换页在 UI Test 直达路由下会触发 SwiftUI 导航崩溃,待 App 侧修复后再启用。")
|
||||||
|
|
||||||
app.application.staticTexts["当前账号"].tap()
|
|
||||||
NavigationAssertions.waitForNavigationTitle("账号切换", in: app)
|
|
||||||
BackNavigator.goBack(app: app)
|
|
||||||
NavigationAssertions.waitForNavigationTitle("个人信息", in: app)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
缺少账号时,需要登录的用例会自动 `XCTSkip`。
|
缺少账号时,需要登录的用例会自动 `XCTSkip`。
|
||||||
|
|
||||||
已登录用例默认复用 Keychain 会话,每个测试类只完整登录一次;仅 `LoginSmokeUITests` 会显式清空本地状态。
|
已登录用例通过 `UITestSession` 全套件共享一次登录;每条首页路由会注册为独立 `testHomeRoute_<菜单名>` 用例,并通过 `-suixinkan-ui-tests-open-menu` 直达目标页。`LoginSmokeUITests` 会清空本地状态并使共享会话失效。
|
||||||
|
|
||||||
## 测试数据约定(api-test)
|
## 测试数据约定(api-test)
|
||||||
|
|
||||||
@ -58,6 +58,8 @@ UI Test 进程会自动传入:
|
|||||||
|
|
||||||
- `-suixinkan-ui-tests`:主开关,App 侧跳过推送注册与排队 WebSocket
|
- `-suixinkan-ui-tests`:主开关,App 侧跳过推送注册与排队 WebSocket
|
||||||
- `-suixinkan-ui-tests-reset-state`:冷启动清理 Keychain / UserDefaults
|
- `-suixinkan-ui-tests-reset-state`:冷启动清理 Keychain / UserDefaults
|
||||||
|
- `-suixinkan-ui-tests-open-menu <菜单标题>`:登录后直达首页调试目录中的页面
|
||||||
|
- `-suixinkan-ui-tests-open-profile <路由名>`:登录后直达个人中心二级页(如 `settings`)
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
|
|||||||
@ -7,36 +7,23 @@
|
|||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// 需要已登录态的 UI Test 基类,每个测试类只启动并登录一次。
|
/// 需要已登录态的 UI Test 基类,全套件共享一次登录会话。
|
||||||
class AuthenticatedUITestCase: XCTestCase {
|
class AuthenticatedUITestCase: XCTestCase {
|
||||||
private static var sharedApp: SuixinkanApp?
|
|
||||||
|
|
||||||
var app: 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 {
|
override func setUpWithError() throws {
|
||||||
try super.setUpWithError()
|
try super.setUpWithError()
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
|
|
||||||
guard let sharedApp = Self.sharedApp else {
|
app = try UITestSession.app()
|
||||||
throw XCTSkip("缺少 UI Test 账号或登录失败。")
|
|
||||||
}
|
|
||||||
|
|
||||||
app = sharedApp
|
|
||||||
app.application.activate()
|
app.application.activate()
|
||||||
app.waitForGlobalLoadingToDisappear()
|
app.waitForGlobalLoadingToDisappear()
|
||||||
|
|
||||||
|
if app.isOnLoginScreen {
|
||||||
|
let credentials = try UITestLaunchConfiguration.requireCredentials()
|
||||||
|
LoginFlow.loginIfNeeded(app: app, credentials: credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
UITestNavigation.resetToHome(app: app)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,11 +11,20 @@ import XCTest
|
|||||||
enum DebugMenuNavigator {
|
enum DebugMenuNavigator {
|
||||||
/// 进入「我的 → 首页调试」列表。
|
/// 进入「我的 → 首页调试」列表。
|
||||||
static func open(app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) {
|
static func open(app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
TabBarNavigator.select(.profile, app: app)
|
TabBarNavigator.select(.profile, app: app, file: file, line: line)
|
||||||
let debugEntry = app.application.staticTexts["首页调试"]
|
NavigationAssertions.waitForStaticText("系统设置", in: app, timeout: 12, file: file, line: line)
|
||||||
XCTAssertTrue(debugEntry.waitForExistence(timeout: 8), "未找到首页调试入口。", file: file, line: line)
|
|
||||||
debugEntry.tap()
|
if !app.application.buttons["首页调试"].waitForExistence(timeout: 2),
|
||||||
NavigationAssertions.waitForNavigationTitle("首页菜单调试", in: app, file: file, line: line)
|
!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,
|
file: StaticString = #filePath,
|
||||||
line: UInt = #line
|
line: UInt = #line
|
||||||
) {
|
) {
|
||||||
let cell = app.application.staticTexts[title]
|
UITestTapHelper.tapLabel(title, in: app, timeout: 8, file: file, line: line)
|
||||||
XCTAssertTrue(cell.waitForExistence(timeout: 8), "调试菜单未找到:\(title)", file: file, line: line)
|
|
||||||
cell.tap()
|
|
||||||
app.waitForGlobalLoadingToDisappear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 返回调试菜单列表。
|
/// 返回调试菜单列表。
|
||||||
|
|||||||
23
suixinkanUITests/Support/HomeRouteTestRunner.swift
Normal file
23
suixinkanUITests/Support/HomeRouteTestRunner.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,10 +18,26 @@ enum NavigationAssertions {
|
|||||||
file: StaticString = #filePath,
|
file: StaticString = #filePath,
|
||||||
line: UInt = #line
|
line: UInt = #line
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
let navigationBar = app.application.navigationBars[title]
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
let exists = navigationBar.waitForExistence(timeout: timeout)
|
while Date() < deadline {
|
||||||
XCTAssertTrue(exists, "未找到导航标题:\(title)", file: file, line: line)
|
if app.application.navigationBars[title].exists {
|
||||||
return 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 {
|
if app.application.navigationBars[title].exists {
|
||||||
return title
|
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))
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,10 +13,19 @@ final class SuixinkanApp {
|
|||||||
private let resetState: Bool
|
private let resetState: Bool
|
||||||
|
|
||||||
/// 创建并配置 UI Test 启动参数的应用实例。
|
/// 创建并配置 UI Test 启动参数的应用实例。
|
||||||
init(resetState: Bool = false) {
|
init(
|
||||||
|
resetState: Bool = false,
|
||||||
|
openMenuTitle: String? = nil,
|
||||||
|
openProfileRoute: String? = nil
|
||||||
|
) {
|
||||||
self.resetState = resetState
|
self.resetState = resetState
|
||||||
application = XCUIApplication()
|
application = XCUIApplication()
|
||||||
UITestLaunchConfiguration.apply(to: application, resetState: resetState)
|
UITestLaunchConfiguration.apply(
|
||||||
|
to: application,
|
||||||
|
resetState: resetState,
|
||||||
|
openMenu: openMenuTitle,
|
||||||
|
openProfile: openProfileRoute
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动 App 并等待首屏稳定。
|
/// 启动 App 并等待首屏稳定。
|
||||||
@ -63,8 +72,12 @@ final class SuixinkanApp {
|
|||||||
|
|
||||||
/// 判断主 Tab 是否已出现。
|
/// 判断主 Tab 是否已出现。
|
||||||
var isOnMainTabs: Bool {
|
var isOnMainTabs: Bool {
|
||||||
application.buttons["首页"].waitForExistence(timeout: 1)
|
TabBarNavigator.Tab.allCases.contains { tab in
|
||||||
|| application.buttons["我的"].waitForExistence(timeout: 1)
|
let button = application.buttons.matching(
|
||||||
|
NSPredicate(format: "label == %@", tab.rawValue)
|
||||||
|
).firstMatch
|
||||||
|
return button.waitForExistence(timeout: 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 等待冷启动后进入登录页或主 Tab。
|
/// 等待冷启动后进入登录页或主 Tab。
|
||||||
@ -72,7 +85,13 @@ final class SuixinkanApp {
|
|||||||
if application.staticTexts["login.title"].waitForExistence(timeout: 2) {
|
if application.staticTexts["login.title"].waitForExistence(timeout: 2) {
|
||||||
return
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,9 +18,32 @@ enum TabBarNavigator {
|
|||||||
|
|
||||||
/// 切换到指定 Tab 并等待页面稳定。
|
/// 切换到指定 Tab 并等待页面稳定。
|
||||||
static func select(_ tab: Tab, app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) {
|
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)
|
XCTAssertTrue(button.waitForExistence(timeout: 8), "未找到 Tab:\(tab.rawValue)", file: file, line: line)
|
||||||
button.tap()
|
button.tap()
|
||||||
app.waitForGlobalLoadingToDisappear()
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,13 +12,20 @@ enum UITestAuthenticatedSession {
|
|||||||
/// 启动 App 并完成登录,返回配置好的应用包装器。
|
/// 启动 App 并完成登录,返回配置好的应用包装器。
|
||||||
static func launchLoggedIn(
|
static func launchLoggedIn(
|
||||||
resetState: Bool = false,
|
resetState: Bool = false,
|
||||||
|
openMenuTitle: String? = nil,
|
||||||
|
openProfileRoute: String? = nil,
|
||||||
file: StaticString = #filePath,
|
file: StaticString = #filePath,
|
||||||
line: UInt = #line
|
line: UInt = #line
|
||||||
) throws -> SuixinkanApp {
|
) throws -> SuixinkanApp {
|
||||||
let credentials = try UITestLaunchConfiguration.requireCredentials(file: file, line: line)
|
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()
|
app.waitForInitialScreen()
|
||||||
LoginFlow.loginIfNeeded(app: app, credentials: credentials, file: file, line: line)
|
LoginFlow.loginIfNeeded(app: app, credentials: credentials, file: file, line: line)
|
||||||
|
app.waitForGlobalLoadingToDisappear()
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,26 +11,45 @@ import XCTest
|
|||||||
enum UITestLaunchConfiguration {
|
enum UITestLaunchConfiguration {
|
||||||
static let uiTestsArgument = "-suixinkan-ui-tests"
|
static let uiTestsArgument = "-suixinkan-ui-tests"
|
||||||
static let resetArgument = "-suixinkan-ui-tests-reset-state"
|
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 phoneEnvironmentKey = "SUI_TEST_PHONE"
|
||||||
static let passwordEnvironmentKey = "SUI_TEST_PASSWORD"
|
static let passwordEnvironmentKey = "SUI_TEST_PASSWORD"
|
||||||
static let accountNameEnvironmentKey = "SUI_TEST_ACCOUNT_NAME"
|
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 读取测试账号。
|
/// 从 Scheme / xcodebuild 环境变量或本地 plist 读取测试账号。
|
||||||
static var credentials: UITestCredentials? {
|
static var credentials: UITestCredentials? {
|
||||||
if let environmentCredentials = credentialsFromEnvironment() {
|
if let environmentCredentials = credentialsFromEnvironment() {
|
||||||
|
|||||||
49
suixinkanUITests/Support/UITestNavigation.swift
Normal file
49
suixinkanUITests/Support/UITestNavigation.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
suixinkanUITests/Support/UITestSession.swift
Normal file
69
suixinkanUITests/Support/UITestSession.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
57
suixinkanUITests/Support/UITestTapHelper.swift
Normal file
57
suixinkanUITests/Support/UITestTapHelper.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user