import XCTest /// W5 · The ONE scripted XCUITest happy path (PLAN_IOS_CLIENT §9, fragile- /// surface rule): 配对(手输) → 会话列表 → attach → 输入 → gate approve — /// against a REAL web-terminal server on loopback. /// /// Contract with the harness: /// - The server URL arrives in the RUNNER process env as `WEBTERM_SERVER_URL` /// (xcodebuild delivers it via the `TEST_RUNNER_` prefix: /// `TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:`). Missing env = /// explicit FAILURE with instructions, never a silent skip (a green-by-skip /// CI leg would be worthless). /// - The runner process makes its OWN URLSession calls to the server for /// server-side assertions (attach visible in /live-sessions, typed marker in /// /live-sessions/:id/preview, held POST /hook/permission resolving to /// allow) — XCUITest cannot read SwiftTerm glyphs, the server is the oracle. /// /// Idempotency (documented decision): the app persists hosts in the REAL /// Keychain and a UI-test runner cannot uninstall the app, so the flow is /// TOLERANT of prior state instead of resetting it: /// - fresh install → first-run PairingScreen path; /// - host(s) already paired → pair the CURRENT server via the 配对新主机 /// sheet, then select the just-added host (last menu row with the loopback /// label — `HostStore.upsert` appends, so ours is last). /// CI simulators are always fresh, so CI exercises the first-run branch; local /// re-runs exercise the tolerant branch. /// /// Waits: every UI wait is `waitForExistence` (predicate-based) with a /// deadline; server polling is bounded loops with 1 s pauses — no fixed sleep /// exceeds 2 s. BUDGET NOTE (measured deviation from the ~120 s aspiration): /// SwiftTerm's caret blink keeps Core Animation permanently non-quiescent, so /// EVERY XCUITest event on the terminal screen first burns XCUITest's fixed /// 60 s "wait for app to idle" timeout ("App animations complete notification /// not received" in the log). Interactions there are minimized to three /// (focus tap · KeyBar ^L · gate approve) → the whole test runs ~4-5 min. /// /// `@MainActor`: the Xcode 16.3 SDK isolates XCUIApplication/XCUIElement to /// the main actor; XCTest runs test methods on the main thread, so class-wide /// isolation is the correct spelling under Swift 6 strict concurrency. The /// bounded semaphore waits below block only the RUNNER's main thread — the /// app under test is a separate process and keeps running. @MainActor final class HappyPathUITests: XCTestCase { // MARK: - Deadlines (seconds; total budget ~120 s) private enum Deadline { static let launchSurface: TimeInterval = 30 static let ui: TimeInterval = 20 static let http: TimeInterval = 10 static let attach: TimeInterval = 30 static let marker: TimeInterval = 30 static let gateBanner: TimeInterval = 25 static let heldDecision: TimeInterval = 30 static let pollPause: TimeInterval = 1 } private enum TestError: Error { case surfaceNotFound case pollTimedOut(String) case httpFailed(String) } override func setUpWithError() throws { continueAfterFailure = false } // MARK: - The one happy path func testHappyPath_pairListAttachTypeGateApprove() throws { let server = try serverBaseURL() // Precondition: server reachable (fail fast with an actionable message). let (probeStatus, _) = try httpGET(server.appendingPathComponent("live-sessions")) XCTAssertEqual( probeStatus, 200, "web-terminal server not reachable at \(server) — start it and pass " + "TEST_RUNNER_WEBTERM_SERVER_URL to xcodebuild" ) // 1 · Launch and pair (tolerant of pre-existing Keychain hosts). let app = XCUIApplication() app.launch() try pairCurrentServer(app, server: server) // 2 · Session list → + New session. let newButton = app.buttons["sessions.newButton"].firstMatch XCTAssertTrue( newButton.waitForExistence(timeout: Deadline.ui), "session list 新建会话 button not found after pairing" ) let sessionsBefore = try liveSessionIds(server) newButton.tap() // 3 · attach — server-side oracle: a NEW session with a client attached. let sessionId = try pollForAttachedSession(server, excluding: sessionsBefore) // 4 · 输入 — via the KeyBar, asserted server-side. // // WHY NOT typeText (verified empirically, run 7): XCUITest's typeText // requires an AX element with hasKeyboardFocus == 1, and SwiftTerm's // TerminalView never exposes one — the runner stalls hunting for // focus and no character ever reaches the PTY. The KeyBar is a plain // UIButton row (inputAccessoryView) needing no keyboard focus, and it // exercises the SAME input pipeline as typed text: KeyByteMap → // TerminalViewModel.send(key:) → engine → WS input frame → PTY. // Oracle: ^L (0x0C) makes bash-readline answer clear-screen // ESC[H ESC[2J — bytes that appear in the ring buffer ONLY when the // shell received our input (resize redraws are \r ESC[K, no overlap). app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() XCTAssertTrue( app.keyboards.firstMatch.waitForExistence(timeout: Deadline.ui), "software keyboard (with the KeyBar accessory) never appeared — " + "disable the simulator's hardware keyboard if testing locally" ) let ctrlLButton = app.buttons["Ctrl+L — redraw screen"].firstMatch XCTAssertTrue( ctrlLButton.waitForExistence(timeout: Deadline.ui), "KeyBar ^L button not found above the keyboard" ) ctrlLButton.tap() try pollPreview(server, sessionId: sessionId, contains: "\u{1B}[H\u{1B}[2J") // 5 · gate approve — hold a PermissionRequest exactly like a Claude // Code hook would (loopback POST, X-Webterm-Session header), approve // from the banner, and assert the HELD response resolves to allow. let held = startHeldPermissionRequest(server, sessionId: sessionId, tool: "UITestTool") let approveButton = app.buttons["gate.decision.approve"].firstMatch XCTAssertTrue( approveButton.waitForExistence(timeout: Deadline.gateBanner), "GateBanner approve button never appeared for the held permission" ) approveButton.tap() guard let result = held.waitForResult(timeout: Deadline.heldDecision) else { XCTFail("held POST /hook/permission did not resolve within \(Deadline.heldDecision)s of approving") return } XCTAssertEqual(result.status, 200, "held /hook/permission answered HTTP \(String(describing: result.status))") let behavior = Self.decisionBehavior(in: result.data) XCTAssertEqual( behavior, "allow", "held permission resolved to \(behavior ?? "") — expected allow " + "(body: \(String(data: result.data ?? Data(), encoding: .utf8) ?? ""))" ) } // MARK: - Pairing (tolerant two-branch flow) 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 the runner env. Run: " + "xcodebuild -scheme WebTermUITests " + "TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1: test " + "(with the server running on that port)." ) throw TestError.surfaceNotFound } return url } /// Waits for whichever entry surface the persisted state produced, then /// drives pairing so the CURRENT server ends up as the ACTIVE host. 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 { // Branch A — fresh install: first-run PairingScreen (CI path). try fillPairingForm(app, server: server) return } if hostMenu.exists || emptyAddHost.exists { // Branch B — prior state: add the current server via the sheet. if hostMenu.exists { hostMenu.tap() } XCTAssertTrue( emptyAddHost.waitForExistence(timeout: Deadline.ui), "配对新主机 entry not found on the session list" ) emptyAddHost.tap() try fillPairingForm(app, server: server) try selectJustPairedHost(app, server: server, hostMenu: hostMenu) return } Thread.sleep(forTimeInterval: 0.5) } XCTFail("neither PairingScreen nor session list appeared within \(Deadline.launchSurface)s") throw TestError.surfaceNotFound } /// Types the URL, submits, and confirms (loopback → §5.4 no-warning tier, /// so the confirm page has no blocking acknowledgement). 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 — input rejected? " + "(on-screen: \(visibleStaticTexts(app)))" ) // Guard against typeText mangling: the confirm page must display the // EXACT parsed origin (single-point derivation shows what will probe). XCTAssertTrue( app.staticTexts[expectedOrigin(server)].firstMatch.exists, "confirm page does not show \(expectedOrigin(server)) — the typed " + "URL was mangled (on-screen: \(visibleStaticTexts(app)))" ) confirmButton.tap() // Probe runs (GET /live-sessions + a WS round trip). Wait for the // WHOLE pairing surface to leave — its navigation bar (配对主机) // covers the confirm page, the transient 已配对 page AND the sheet's // dismissal animation (waiting only for the confirm button raced the // sheet and tapped an occluded toolbar — run-5 hit point {-1,-1}). // A probe failure shows the FailureView (重试) — fail FAST with its // on-screen copy. 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))") throw TestError.surfaceNotFound } if Date() >= resolveDeadline { XCTFail("pairing did not complete within \(Deadline.ui)s — " + "on-screen: \(visibleStaticTexts(app))") throw TestError.surfaceNotFound } Thread.sleep(forTimeInterval: 0.5) } } /// `scheme://host:port` exactly as HostEndpoint.originHeader derives it. private func expectedOrigin(_ server: URL) -> String { let scheme = server.scheme ?? "http" let host = server.host ?? "" let port = server.port.map { ":\($0)" } ?? "" return "\(scheme)://\(host)\(port)" } /// First dozen visible static texts — failure-message diagnostics. private func visibleStaticTexts(_ app: XCUIApplication) -> String { app.staticTexts.allElementsBoundByIndex .prefix(12) .map(\.label) .joined(separator: " | ") } /// Branch B only: the add-host sheet upserts (appends) the new host but /// keeps the previously active one. The just-added host is the LAST menu /// row bearing the loopback host label; tapping it selects it (or no-ops /// if it is already active — both are fine). 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 after add-host sheet dismissed" ) // The sheet's dismissal animation can leave the menu present-but-not- // hittable for a beat — tapping then dies with kAXErrorCannotComplete. let hittableDeadline = Date().addingTimeInterval(Deadline.ui) while !hostMenu.isHittable { if Date() >= hittableDeadline { XCTFail("host menu never became hittable after the add-host sheet") throw TestError.surfaceNotFound } Thread.sleep(forTimeInterval: 0.5) } 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: 0.5) } let allRows = rows.allElementsBoundByIndex guard let lastRow = allRows.last else { XCTFail("no host menu row labelled \(hostLabel) after pairing it") throw TestError.surfaceNotFound } lastRow.tap() } // MARK: - Server oracles (runner-side URLSession) private func liveSessionIds(_ server: URL) throws -> Set { let (status, data) = try httpGET(server.appendingPathComponent("live-sessions")) guard status == 200, let data, let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { throw TestError.httpFailed("GET /live-sessions → \(status.map(String.init) ?? "no response")") } return Set(list.compactMap { $0["id"] as? String }) } /// Polls /live-sessions until a session NOT in `excluding` reports /// clientCount >= 1 (the app's WS attach) — returns its id. private func pollForAttachedSession( _ server: URL, excluding: Set ) throws -> String { let deadline = Date().addingTimeInterval(Deadline.attach) while Date() < deadline { let (status, data) = try httpGET(server.appendingPathComponent("live-sessions")) if status == 200, let data, let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] { for entry in list { guard let id = entry["id"] as? String, !excluding.contains(id), let clientCount = entry["clientCount"] as? Int, clientCount >= 1 else { continue } return id } } Thread.sleep(forTimeInterval: Deadline.pollPause) } XCTFail("no newly attached session appeared in /live-sessions within \(Deadline.attach)s") throw TestError.pollTimedOut("attach") } /// Polls /live-sessions/:id/preview until the ring-buffer tail contains /// `marker` (the shell OUTPUT of the typed command). private func pollPreview( _ server: URL, sessionId: String, contains marker: String ) throws { let url = server .appendingPathComponent("live-sessions") .appendingPathComponent(sessionId) .appendingPathComponent("preview") let deadline = Date().addingTimeInterval(Deadline.marker) while Date() < deadline { let (status, data) = try httpGET(url) if status == 200, let data, let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let text = obj["data"] as? String, text.contains(marker) { return } Thread.sleep(forTimeInterval: Deadline.pollPause) } XCTFail("marker \(marker) never showed up in /live-sessions/\(sessionId)/preview within \(Deadline.marker)s") throw TestError.pollTimedOut("marker") } // MARK: - Held permission (mimics the Claude Code PermissionRequest hook) /// Fire-and-park POST /hook/permission: the server HOLDS the response until /// a client decides (or its 5-min default timeout). Returns a handle the /// test awaits AFTER tapping ✓ Approve. private func startHeldPermissionRequest( _ server: URL, sessionId: String, tool: String ) -> HeldHTTPResponse { var request = URLRequest(url: server.appendingPathComponent("hook/permission")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(sessionId, forHTTPHeaderField: "X-Webterm-Session") request.timeoutInterval = 120 request.httpBody = try? JSONSerialization.data(withJSONObject: [ "hook_event_name": "PermissionRequest", "tool_name": tool, ]) let held = HeldHTTPResponse() let task = URLSession.shared.dataTask(with: request) { data, response, error in held.complete( status: (response as? HTTPURLResponse)?.statusCode, data: data, errorDescription: error.map(String.init(describing:)) ) } task.resume() return held } /// Extracts hookSpecificOutput.decision.behavior from the resolved hold. private static func decisionBehavior(in data: Data?) -> String? { guard let data, let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let output = obj["hookSpecificOutput"] as? [String: Any], let decision = output["decision"] as? [String: Any] else { return nil } return decision["behavior"] as? String } // MARK: - Bounded synchronous HTTP (URLSession callbacks run off-thread) private func httpGET(_ url: URL) throws -> (Int?, Data?) { var request = URLRequest(url: url) request.timeoutInterval = Deadline.http let box = HeldHTTPResponse() URLSession.shared.dataTask(with: request) { data, response, error in box.complete( status: (response as? HTTPURLResponse)?.statusCode, data: data, errorDescription: error.map(String.init(describing:)) ) }.resume() guard let result = box.waitForResult(timeout: Deadline.http + 2) else { throw TestError.httpFailed("GET \(url) timed out after \(Deadline.http + 2)s") } if let errorDescription = result.errorDescription { throw TestError.httpFailed("GET \(url) failed: \(errorDescription)") } return (result.status, result.data) } } /// Lock-protected completion box for URLSession callbacks (they arrive on the /// session's delegate queue; the test thread waits with a bounded semaphore). /// `@unchecked Sendable`: every mutable field is guarded by `lock`. private final class HeldHTTPResponse: @unchecked Sendable { struct Result { let status: Int? let data: Data? let errorDescription: String? } private let lock = NSLock() private let semaphore = DispatchSemaphore(value: 0) private var result: Result? func complete(status: Int?, data: Data?, errorDescription: String?) { lock.lock() result = Result(status: status, data: data, errorDescription: errorDescription) lock.unlock() semaphore.signal() } /// Bounded wait for the response; nil = still pending after `timeout`. func waitForResult(timeout: TimeInterval) -> Result? { guard semaphore.wait(timeout: .now() + timeout) == .success else { return nil } lock.lock() defer { lock.unlock() } return result } }