T-iPad-2: AdaptiveRootView/LayoutPolicy (sole size-class decision), SplitRootView (NavigationSplitView sidebar+detail), StackRootView (iPhone path verbatim, zero regression); privacy shade hoisted to shared ZStack top for both branches T-iPad-3: KeyBarVisibility predicate (hide when hardware keyboard present), pointer context menu (copy/new-in-cwd/kill, all via existing channels) T-iPad-4: Projects multi-column grid on iPad (idiom-gated), adaptive sheet detents T-iPad-5 findings (4/4 fixed): kill onKillSession thread-through + AppCoordinator.killCurrentSession; split route-gated to .sessions (iPad first-run pairing); continue-last banner in split sidebar; iPad XCUITest deferred (covered by SidebarSelectionTests) Verified: iPhone 16 277 + iPad Pro 11 278 tests green; packages 261 + integration 10; zero changes under ios/Packages, src/, public/
183 lines
7.7 KiB
Swift
183 lines
7.7 KiB
Swift
import XCTest
|
||
|
||
/// T-iPad-4 · Projects 大屏化的 UI 走查 —— 目的是产出可核对的 **截图**(iPad
|
||
/// 多列网格 vs iPhone 单列 List),非断言重逻辑(列数/detents 决策已由
|
||
/// `ProjectsLayoutTests` 单测覆盖)。
|
||
///
|
||
/// 与 harness 的契约同 `HappyPathUITests`:服务器 URL 经
|
||
/// `TEST_RUNNER_WEBTERM_SERVER_URL` 注入 runner。此测配对当前服务器 → 打开
|
||
/// Projects → 截图存为 `XCTAttachment(.keepAlways)`(宿主侧从 .xcresult 提取)。
|
||
///
|
||
/// iPad(regular)用横屏,让 `NavigationSplitView` 的 sidebar(含「项目」入口)
|
||
/// 常驻可见;iPhone(compact)保持竖屏走现有 stack。
|
||
@MainActor
|
||
final class ProjectsLayoutUITests: XCTestCase {
|
||
private enum Deadline {
|
||
static let launchSurface: TimeInterval = 30
|
||
static let ui: TimeInterval = 20
|
||
static let projects: TimeInterval = 25
|
||
static let poll: TimeInterval = 0.5
|
||
}
|
||
|
||
override func setUpWithError() throws {
|
||
continueAfterFailure = false
|
||
}
|
||
|
||
func testProjectsScreenScreenshot() throws {
|
||
let server = try serverBaseURL()
|
||
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||
if isPad {
|
||
XCUIDevice.shared.orientation = .landscapeLeft
|
||
}
|
||
|
||
let app = XCUIApplication()
|
||
app.launch()
|
||
|
||
try revealSidebarIfNeeded(app, isPad: isPad)
|
||
try pairCurrentServer(app, server: server)
|
||
|
||
// 「项目」入口(leading 工具栏,两分支同 identifier)。
|
||
let projectsButton = app.buttons["sessions.projectsButton"].firstMatch
|
||
XCTAssertTrue(
|
||
projectsButton.waitForExistence(timeout: Deadline.ui),
|
||
"项目 toolbar button not found (on-screen: \(visibleStaticTexts(app)))"
|
||
)
|
||
// host 未激活时会 disabled —— 等到可点。
|
||
let hittableDeadline = Date().addingTimeInterval(Deadline.ui)
|
||
while !projectsButton.isHittable, Date() < hittableDeadline {
|
||
Thread.sleep(forTimeInterval: Deadline.poll)
|
||
}
|
||
projectsButton.tap()
|
||
|
||
// Projects sheet 出现:等导航标题「项目」或任一 seeded 项目名出现。
|
||
let projectsTitle = app.navigationBars["项目"].firstMatch
|
||
XCTAssertTrue(
|
||
projectsTitle.waitForExistence(timeout: Deadline.projects),
|
||
"Projects screen (项目) did not appear (on-screen: \(visibleStaticTexts(app)))"
|
||
)
|
||
// 给 /projects 拉取 + 网格布局一点时间落定。
|
||
Thread.sleep(forTimeInterval: 2)
|
||
|
||
let shot = XCTAttachment(screenshot: app.screenshot())
|
||
shot.name = isPad ? "ipad_projects_grid" : "iphone_projects_list"
|
||
shot.lifetime = .keepAlways
|
||
add(shot)
|
||
}
|
||
|
||
// MARK: - Sidebar reveal (iPad split, collapsed in某些尺寸)
|
||
|
||
private func revealSidebarIfNeeded(_ app: XCUIApplication, isPad: Bool) throws {
|
||
guard isPad else { return }
|
||
let projectsButton = app.buttons["sessions.projectsButton"].firstMatch
|
||
if projectsButton.waitForExistence(timeout: 3) { return }
|
||
// 尝试系统 sidebar 切换钮;失败则从左边缘轻扫。
|
||
let toggle = app.navigationBars.buttons.matching(
|
||
NSPredicate(format: "identifier CONTAINS[c] %@", "sidebar")
|
||
).firstMatch
|
||
if toggle.exists {
|
||
toggle.tap()
|
||
} else {
|
||
let edge = app.coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.4))
|
||
let inward = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.4))
|
||
edge.press(forDuration: 0.1, thenDragTo: inward)
|
||
}
|
||
_ = projectsButton.waitForExistence(timeout: Deadline.ui)
|
||
}
|
||
|
||
// MARK: - Pairing (tolerant, mirrors HappyPathUITests)
|
||
|
||
private func serverBaseURL() throws -> URL {
|
||
let raw = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"]
|
||
guard let raw, let url = URL(string: raw), url.host != nil else {
|
||
XCTFail("WEBTERM_SERVER_URL missing/invalid in runner env — pass TEST_RUNNER_WEBTERM_SERVER_URL")
|
||
throw NSError(domain: "ProjectsLayoutUITests", code: 1)
|
||
}
|
||
return url
|
||
}
|
||
|
||
private func pairCurrentServer(_ app: XCUIApplication, server: URL) throws {
|
||
let urlField = app.textFields["pairing.urlField"].firstMatch
|
||
let hostMenu = app.buttons["sessions.hostMenu"].firstMatch
|
||
let emptyAddHost = app.buttons["配对新主机"].firstMatch
|
||
|
||
let deadline = Date().addingTimeInterval(Deadline.launchSurface)
|
||
while Date() < deadline {
|
||
if urlField.exists {
|
||
try fillPairingForm(app, server: server)
|
||
return
|
||
}
|
||
if hostMenu.exists || emptyAddHost.exists {
|
||
if hostMenu.exists { hostMenu.tap() }
|
||
XCTAssertTrue(
|
||
emptyAddHost.waitForExistence(timeout: Deadline.ui),
|
||
"配对新主机 entry not found"
|
||
)
|
||
emptyAddHost.tap()
|
||
try fillPairingForm(app, server: server)
|
||
try selectJustPairedHost(app, server: server, hostMenu: hostMenu)
|
||
return
|
||
}
|
||
Thread.sleep(forTimeInterval: Deadline.poll)
|
||
}
|
||
XCTFail("neither PairingScreen nor session list appeared (on-screen: \(visibleStaticTexts(app)))")
|
||
}
|
||
|
||
private func fillPairingForm(_ app: XCUIApplication, server: URL) throws {
|
||
let urlField = app.textFields["pairing.urlField"].firstMatch
|
||
XCTAssertTrue(urlField.waitForExistence(timeout: Deadline.ui), "pairing URL field not found")
|
||
urlField.tap()
|
||
urlField.typeText(server.absoluteString)
|
||
app.buttons["pairing.submitButton"].firstMatch.tap()
|
||
|
||
let confirmButton = app.buttons["pairing.confirmButton"].firstMatch
|
||
XCTAssertTrue(
|
||
confirmButton.waitForExistence(timeout: Deadline.ui),
|
||
"pairing confirm page not reached (on-screen: \(visibleStaticTexts(app)))"
|
||
)
|
||
confirmButton.tap()
|
||
|
||
let pairingNavBar = app.navigationBars["配对主机"].firstMatch
|
||
let retryButton = app.buttons["重试"].firstMatch
|
||
let resolveDeadline = Date().addingTimeInterval(Deadline.ui)
|
||
while pairingNavBar.exists {
|
||
if retryButton.exists {
|
||
XCTFail("pairing probe FAILED (on-screen: \(visibleStaticTexts(app)))")
|
||
return
|
||
}
|
||
if Date() >= resolveDeadline {
|
||
XCTFail("pairing did not complete (on-screen: \(visibleStaticTexts(app)))")
|
||
return
|
||
}
|
||
Thread.sleep(forTimeInterval: Deadline.poll)
|
||
}
|
||
}
|
||
|
||
private func selectJustPairedHost(
|
||
_ app: XCUIApplication, server: URL, hostMenu: XCUIElement
|
||
) throws {
|
||
let hostLabel = server.host ?? "127.0.0.1"
|
||
XCTAssertTrue(hostMenu.waitForExistence(timeout: Deadline.ui), "host menu not visible")
|
||
let hittableDeadline = Date().addingTimeInterval(Deadline.ui)
|
||
while !hostMenu.isHittable, Date() < hittableDeadline {
|
||
Thread.sleep(forTimeInterval: Deadline.poll)
|
||
}
|
||
hostMenu.tap()
|
||
let rows = app.buttons.matching(
|
||
NSPredicate(format: "label == %@ AND identifier != %@", hostLabel, "sessions.hostMenu")
|
||
)
|
||
let rowDeadline = Date().addingTimeInterval(Deadline.ui)
|
||
while rows.count == 0, Date() < rowDeadline {
|
||
Thread.sleep(forTimeInterval: Deadline.poll)
|
||
}
|
||
guard let lastRow = rows.allElementsBoundByIndex.last else {
|
||
XCTFail("no host menu row labelled \(hostLabel)")
|
||
return
|
||
}
|
||
lastRow.tap()
|
||
}
|
||
|
||
private func visibleStaticTexts(_ app: XCUIApplication) -> String {
|
||
app.staticTexts.allElementsBoundByIndex.prefix(12).map(\.label).joined(separator: " | ")
|
||
}
|
||
}
|