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. ".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 %@", "")).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.. 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 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") } }