TestFlight / Build and upload (push) Successful in 5m29s
Integrate the shared Agent room, durable outbox, cursor replay and read state on iPhone and iPad. Reuse message rendering with native messaging layout and expandable tool details. Validated with 58 Xcode package tests, six Simulator UI scenarios, read-only production connectivity and independent review.
176 lines
10 KiB
Swift
176 lines
10 KiB
Swift
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")
|
|
}
|
|
}
|