import XCTest import UIKit @MainActor final class AgentUITests: XCTestCase { let app = XCUIApplication() override func setUp() async throws { continueAfterFailure = false try await control(["action": "reset", "count": 3]) app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"] app.launch() openAgent() } private func openAgent() { if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable { XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15)) app.buttons["Show sidebar"].tap() } XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5)) app.buttons["agent-sidebar"].tap() XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 5) || app.textViews["agent-composer"].exists) } private var composer: XCUIElement { app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"] } private func send(_ text: String) { composer.tap(); composer.typeText(text); app.buttons["agent-send"].tap() } private func capture(_ name: String) { let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways add(attachment) } @discardableResult private func control(_ value: [String: Any]) async throws -> [String: Any] { var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!) request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: value) request.setValue("application/json", forHTTPHeaderField: "Content-Type") let (data, _) = try await URLSession.shared.data(for: request) return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) } func testSendQueueToolsAndRetry() async throws { capture("agent-history") send("first fixture") send("second fixture") XCTAssertTrue(app.staticTexts["second fixture"].waitForExistence(timeout: 5)) XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: second fixture")).firstMatch.waitForExistence(timeout: 12)) XCTAssertFalse(app.otherElements["agent-typing"].exists) capture("agent-replies-keyboard") app.swipeDown() send("retry fixture") XCTAssertTrue(app.buttons["agent-retry"].waitForExistence(timeout: 5)) capture("agent-retry") app.buttons["agent-retry"].tap() XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: retry fixture")).firstMatch.waitForExistence(timeout: 12)) send("echo wins fixture") XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: echo wins fixture")).firstMatch.waitForExistence(timeout: 12)) XCTAssertFalse(app.buttons["agent-retry"].exists) composer.tap(); app.swipeDown() let failures = app.buttons.matching(identifier: "agent-tool-fixture_error") failures.element(boundBy: failures.count - 1).tap() XCTAssertTrue(app.staticTexts["agent-tool-input"].waitForExistence(timeout: 3)) XCTAssertTrue(app.staticTexts["agent-tool-error"].exists) capture("agent-tool-error") let state = try await control(["action": "state"]) XCTAssertEqual((state["requests"] as? [String: Any])?.count, 4) } func testReduceMotionSend() async throws { try XCTSkipUnless(UIAccessibility.isReduceMotionEnabled, "Run with Simulator Reduce Motion enabled") send("reduce motion fixture") XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: reduce motion fixture")).firstMatch.waitForExistence(timeout: 12)) XCTAssertFalse(app.otherElements["agent-typing"].exists) capture("agent-reduce-motion") } func testExpandedToolCollapseToShortHistory() async throws { try await control(["action": "long-tool"]) let tool = app.buttons["agent-tool-fixture_long"] XCTAssertTrue(tool.waitForExistence(timeout: 5)) tool.tap() XCTAssertTrue(app.staticTexts["agent-tool-result"].waitForExistence(timeout: 3)) let scroll = app.scrollViews.firstMatch for _ in 0..<12 { if tool.isHittable { break } scroll.swipeDown() } XCTAssertTrue(tool.isHittable) tool.tap() XCTAssertEqual(tool.value as? String, "Collapsed") try await control(["action": "reply", "content": "Reply after collapsing a tall tool"]) XCTAssertTrue(app.staticTexts["Reply after collapsing a tall tool"].waitForExistence(timeout: 5)) XCTAssertTrue(app.staticTexts["Reply after collapsing a tall tool"].isHittable) XCTAssertFalse(app.buttons["agent-jump-latest"].exists) capture("agent-collapsed-tool-short-history") } func testTabletSidebarAndInactiveChat() async throws { try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad, "iPad layout check") XCUIDevice.shared.orientation = .landscapeLeft XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons["agent-sidebar"].isHittable) app.buttons["New chat"].tap() try await control(["action": "busy", "value": true]) try await control(["action": "reply", "content": "iPad inactive chat reply"]) let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread replies"), object: app.buttons["agent-sidebar"]) await fulfillment(of: [unread], timeout: 5) capture("agent-ipad-sidebar") app.buttons["agent-sidebar"].tap() XCTAssertTrue(app.staticTexts["iPad inactive chat reply"].waitForExistence(timeout: 5)) capture("agent-ipad-room-before-send") send("iPad fixture") XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: iPad fixture")).firstMatch.waitForExistence(timeout: 12)) capture("agent-ipad-room") XCUIDevice.shared.orientation = .portrait } func testScrollPreservationAndKeyboard() async throws { app.terminate() try await control(["action": "reset", "count": 160]) app.launch(); openAgent() let scroll = app.scrollViews.firstMatch XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable) scroll.swipeDown() scroll.swipeDown() XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 3)) let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first { $0.isHittable }) let before = anchor.frame.minY try await control(["action": "reply", "content": "Reply while reading earlier history"]) try await Task.sleep(for: .milliseconds(500)) XCTAssertEqual(anchor.frame.minY, before, accuracy: 2) capture("agent-reading-anchor") for _ in 0..<25 { if app.buttons["Load earlier messages"].isHittable { break } scroll.swipeDown(velocity: .fast) } XCTAssertTrue(app.buttons["Load earlier messages"].isHittable) let first = app.staticTexts["History message 61: local simulator fixture."] let beforeLoad = first.frame.minY app.buttons["Load earlier messages"].tap() XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3) capture("agent-older-history-anchor") app.buttons["agent-jump-latest"].tap() XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable) composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line") XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable) capture("agent-multiline-keyboard") } func testBackgroundUnreadReadReconciliationAndReplay() async throws { app.buttons["Show sidebar"].tap() try await control(["action": "busy", "value": true]) try await control(["action": "reply", "content": "Late asynchronous fixture reply"]) let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "1 unread replies"), object: app.buttons["agent-sidebar"]) await fulfillment(of: [unread], timeout: 5) capture("agent-unread-sidebar") let covered = try await control(["action": "state"]) XCTAssertLessThan(covered["readCursor"] as? Int ?? 0, covered["cursor"] as? Int ?? 0) try await control(["action": "read"]) let gone = NSPredicate(format: "NOT (label CONTAINS %@)", "unread replies") let cleared = XCTNSPredicateExpectation(predicate: gone, object: app.buttons["agent-sidebar"]) await fulfillment(of: [cleared], timeout: 20) app.buttons["agent-sidebar"].tap() XCTAssertTrue(app.staticTexts["Late asynchronous fixture reply"].waitForExistence(timeout: 5)) try await control(["action": "disconnect"]) try await control(["action": "reply", "content": "Reconnect fixture reply"]) XCTAssertTrue(app.staticTexts["Reconnect fixture reply"].waitForExistence(timeout: 10)) try await control(["action": "malformed"]) try await control(["action": "reply", "content": "Recovered malformed frame"]) XCTAssertTrue(app.staticTexts["Recovered malformed frame"].waitForExistence(timeout: 12)) XCUIDevice.shared.press(.home) try await control(["action": "reply", "content": "Foreground fixture reply"]) app.activate() XCTAssertTrue(app.staticTexts["Foreground fixture reply"].waitForExistence(timeout: 12)) XCTAssertEqual(app.staticTexts.matching(NSPredicate(format: "label == %@", "Foreground fixture reply")).count, 1) capture("agent-foreground-replay") } }