Files
Sybil-2/ios/Tests/SybilUITests/AgentUITests.swift
T

369 lines
24 KiB
Swift

import XCTest
import UIKit
import Photos
@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()
try clearComposer()
}
override func tearDown() async throws { app.terminate() }
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 clearComposer() throws {
guard let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" else { return }
composer.tap(); composer.press(forDuration: 1)
let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
if selectAll.waitForExistence(timeout: 2) { selectAll.tap() }
else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) }
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count))
let value = composer.value as? String ?? ""
_ = try XCTUnwrap(value.isEmpty || value == "Enter Prompt" ? true : nil, "Clear the synthetic fixture caption before the next test")
}
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])
}
private func uploadOriginal(name: String, mime: String, bytes: Data) async throws -> [String: Any] {
let boundary = "Fixture-" + UUID().uuidString
let uploadID = UUID().uuidString
var body = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"uploadId\"\r\n\r\n\(uploadID)\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(name)\"\r\nContent-Type: \(mime)\r\n\r\n".utf8)
body.append(bytes); body.append(Data("\r\n--\(boundary)--\r\n".utf8))
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/api/v1/agent/attachments")!)
request.httpMethod = "POST"; request.httpBody = body
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer fixture-token", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 201)
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
return try XCTUnwrap(json["attachment"] as? [String: Any])
}
func testAttachmentHistoryDownloadAndReload() async throws {
let original = Data("Synthetic original text. <script>plain text only</script>".utf8)
let file = try await uploadOriginal(name: "fixture-original.txt", mime: "text/plain", bytes: original)
let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 48)).image { context in
UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 64, height: 48))
}
let photo = try await uploadOriginal(name: "fixture-image.png", mime: "image/png", bytes: try XCTUnwrap(image.pngData()))
let ids = [try XCTUnwrap(file["id"] as? String), try XCTUnwrap(photo["id"] as? String)]
try await control(["action": "attachment-history", "ids": ids, "content": "Synthetic attachments caption"])
XCTAssertTrue(app.staticTexts["Synthetic attachments caption"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
XCTAssertTrue(app.staticTexts["fixture-image.png"].exists)
let path = try XCTUnwrap(file["workspacePath"] as? String)
XCTAssertTrue(app.buttons["Copy workspace path: " + path].exists)
let download = app.buttons["agent-download"].firstMatch
if !download.isHittable { app.scrollViews.firstMatch.swipeDown() }
XCTAssertTrue(download.waitForExistence(timeout: 5)); download.tap()
XCTAssertTrue(app.buttons["Save or share original"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "<script>plain text only</script>")).firstMatch.exists)
capture("agent-attachment-original-and-preview")
try await control(["action": "long-tool"])
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
app.terminate(); app.launch(); openAgent()
XCTAssertTrue(app.staticTexts["fixture-image.png"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
capture("agent-attachment-reloaded")
// The real app's authenticated content request is recorded by the loopback fixture.
let state = try await control(["action": "state"])
let log = try XCTUnwrap(state["log"] as? [[String: Any]])
XCTAssertTrue(log.contains { ($0["path"] as? String) == "/api/v1/agent/attachments/\(ids[0])/content" })
}
func testAttachmentCapabilityAndPickerCancellation() async throws {
XCTAssertTrue(app.buttons["agent-attach"].waitForExistence(timeout: 8))
composer.tap(); composer.typeText("Preserved attachment draft caption")
app.buttons["agent-attach"].tap()
app.buttons["Files"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
app.buttons["Cancel"].firstMatch.tap()
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
app.buttons["Cancel"].firstMatch.tap()
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
capture("agent-attachment-pickers-cancelled")
try await control(["action": "capability", "enabled": false])
app.terminate(); app.launch(); openAgent()
XCTAssertFalse(app.buttons["agent-attach"].exists)
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
try clearComposer()
capture("agent-text-only-capability")
}
func testPhotoSelectionToAttachmentOnlySend() async throws {
try XCTSkipIf(true, "Manual/device pre-release check: iOS 26.5 XCTest Photos authorization, callback isolation and cleanup are unreliable in this fixture. Selection-to-send is NOT validated; keep the general suite free of photo mutations.")
// Only synthetic assets are touched; no unrelated photo bytes are read.
if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .authorized {
// XCTest's synchronous UI waits must not block the authorization request.
let permission = Task.detached { await PHPhotoLibrary.requestAuthorization(for: .readWrite) }
try await Task.sleep(for: .milliseconds(300))
let allow = XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons.matching(
NSPredicate(format: "label IN %@", ["Allow Full Access", "Allow Access to All Photos", "Allow Access"])).firstMatch
if allow.waitForExistence(timeout: 5) { allow.tap(); _ = await permission.value }
else if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .notDetermined { _ = await permission.value }
}
try XCTSkipUnless(PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
"Simulator Photos authorization \(PHPhotoLibrary.authorizationStatus(for: .readWrite).rawValue) is unavailable to \(Bundle.main.bundleIdentifier ?? "test runner")")
// Recover only fixtures from an interrupted test, identified by BOTH our unique
// date and original-filename prefix, before creating this run's asset.
let options = PHFetchOptions()
options.predicate = NSPredicate(format: "creationDate == %@", ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")! as NSDate)
let previous = PHAsset.fetchAssets(with: .image, options: options)
let owned = previous.objects(at: IndexSet(integersIn: 0..<previous.count)).filter {
PHAssetResource.assetResources(for: $0).contains { $0.originalFilename.hasPrefix("sybil-attachment-ui-") }
}.map(\.localIdentifier)
if !owned.isEmpty { try await removeSyntheticPhotos(owned) }
let name = "sybil-attachment-ui-" + UUID().uuidString + ".png"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
let photo = UIGraphicsImageRenderer(size: CGSize(width: 96, height: 64)).image { context in
UIColor.systemPurple.setFill(); context.fill(CGRect(x: 0, y: 0, width: 96, height: 64))
}
try XCTUnwrap(photo.pngData()).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
nonisolated(unsafe) var assetID: String?
try await PHPhotoLibrary.shared().performChanges { @Sendable in
let creation = PHAssetCreationRequest.forAsset()
// A unique synthetic date lets us find this asset without enumerating other photos.
creation.creationDate = ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")
let options = PHAssetResourceCreationOptions(); options.originalFilename = name
creation.addResource(with: .photo, fileURL: url, options: options)
assetID = creation.placeholderForCreatedAsset?.localIdentifier
}
let ownedID = try XCTUnwrap(assetID)
let identity = XCTAttachment(string: ownedID); identity.name = "synthetic-photo-owned-identifier"; identity.lifetime = .keepAlways; add(identity)
addTeardownBlock { try await Self.eraseSyntheticPhotos([ownedID]) }
do { try await exercisePhotoSelection() }
catch {
try await removeSyntheticPhotos([ownedID])
throw error
}
try await removeSyntheticPhotos([ownedID])
}
private func removeSyntheticPhotos(_ identifiers: [String]) async throws {
try await Self.eraseSyntheticPhotos(identifiers)
let evidence = XCTAttachment(string: "Removed \(identifiers.count) synthetic asset(s) by owned identifier; remaining count 0.")
evidence.name = "synthetic-photo-cleanup"; evidence.lifetime = .keepAlways; add(evidence)
}
nonisolated private static func eraseSyntheticPhotos(_ identifiers: [String]) async throws {
try await Task.detached {
let owned = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
if owned.count > 0 { try await PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.deleteAssets(owned) } }
let remaining = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil).count
XCTAssertEqual(remaining, 0)
print("Synthetic photo cleanup: \(identifiers.count) owned identifier(s), \(remaining) remaining.")
}.value
}
private func exercisePhotoSelection() async throws {
app.activate()
XCTAssertFalse(app.buttons["agent-send"].isEnabled)
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
let cell = app.descendants(matching: .any).matching(NSPredicate(format:
"label BEGINSWITH %@ AND NOT label CONTAINS %@", "Photo, December 31, 2099", ", Photo,")).firstMatch
XCTAssertTrue(cell.waitForExistence(timeout: 8))
cell.tap()
app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Add")).firstMatch.tap()
let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "enabled == true"), object: app.buttons["agent-send"])
await fulfillment(of: [ready], timeout: 15)
XCTAssertFalse(app.buttons["agent-upload-retry"].exists)
capture("agent-photo-upload-ready")
app.buttons["agent-send"].tap()
let fallback = app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch
XCTAssertTrue(fallback.waitForExistence(timeout: 12))
XCTAssertFalse(app.buttons["agent-retry"].exists)
let state = try await control(["action": "state"])
let requests = try XCTUnwrap(state["requests"] as? [String: [String: Any]])
XCTAssertEqual(requests.count, 1)
let submission = try XCTUnwrap(requests.values.first)
XCTAssertEqual((submission["_payload"] as? [Any])?.first as? String, "")
XCTAssertEqual((submission["attachmentIds"] as? [String])?.count, 1)
capture("agent-photo-attachment-only-canonical")
app.terminate(); app.launch(); openAgent()
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch.waitForExistence(timeout: 8))
XCTAssertFalse(app.buttons["agent-retry"].exists)
capture("agent-photo-attachment-only-reloaded")
}
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()
XCTAssertFalse(app.buttons["agent-tool-fixture_search"].exists)
XCTAssertFalse(app.buttons["agent-tool-fixture_error"].exists)
XCTAssertFalse(app.staticTexts["Inspecting the fixture"].exists)
XCTAssertFalse(app.staticTexts["Fixture unavailable"].exists)
capture("agent-hidden-tools")
let state = try await control(["action": "state"])
XCTAssertEqual((state["requests"] as? [String: Any])?.count, 4)
}
func testBusyProgressAndSameIDMessageUpdate() async throws {
try await control(["action": "progress", "queued": 2, "workers": 1, "step": 7])
XCTAssertTrue(app.staticTexts["Sybil is thinking…"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["2 queued"].waitForExistence(timeout: 5))
try await control(["action": "progress", "queued": 3, "workers": 2, "step": 14])
XCTAssertTrue(app.staticTexts["3 queued"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["2 working"].exists)
// Same stable ID, changed content: the transcript boundary must update.
try await control(["action": "edit-last", "content": "**Revised response**\n\n[Updated reference](http://127.0.0.1:8799)"])
XCTAssertTrue(app.staticTexts["Revised response"].waitForExistence(timeout: 5))
XCTAssertTrue(app.links["Updated reference"].exists)
XCTAssertFalse(app.staticTexts["History message 3: local simulator fixture."].exists)
XCTAssertTrue(app.staticTexts["Sybil is thinking…"].exists)
try await control(["action": "busy", "value": false])
let stopped = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.staticTexts["Sybil is thinking…"])
await fulfillment(of: [stopped], timeout: 5)
}
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 testToolResultsStayHiddenLiveAndAfterReload() async throws {
try await control(["action": "long-tool"])
try await control(["action": "reply", "content": "Reply after a hidden tool result"])
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].isHittable)
XCTAssertFalse(app.buttons["agent-tool-fixture_long"].exists)
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
XCTAssertFalse(app.staticTexts["agent-tool-result"].exists)
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
capture("agent-hidden-tool-live")
app.terminate(); app.launch(); openAgent()
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].waitForExistence(timeout: 5))
XCTAssertFalse(app.buttons["agent-tool-fixture_long"].exists)
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
XCTAssertFalse(app.staticTexts["agent-tool-result"].exists)
capture("agent-hidden-tool-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")
}
}