feat(ipad): W1-W3 — adaptive split-view layout + finding fixes
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/
This commit is contained in:
182
ios/App/WebTermUITests/ProjectsLayoutUITests.swift
Normal file
182
ios/App/WebTermUITests/ProjectsLayoutUITests.swift
Normal file
@@ -0,0 +1,182 @@
|
||||
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: " | ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user