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