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:
Yaojia Wang
2026-07-05 19:58:30 +02:00
parent 77502ec4fe
commit 823432b1c8
18 changed files with 1594 additions and 84 deletions

View 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
///
/// iPadregular `NavigationSplitView` sidebar
/// iPhonecompact 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: " | ")
}
}