Files
web-terminal/ios/App/WebTermUITests/ProjectsLayoutUITests.swift
Yaojia Wang 823432b1c8 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/
2026-07-05 19:58:30 +02:00

183 lines
7.7 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: " | ")
}
}