436 lines
28 KiB
Swift
436 lines
28 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 testAssistantImageOnlyLiveRetryAndHistoryWithoutInputCapability() async throws {
|
|
app.terminate()
|
|
try await control(["action": "reset", "count": 0])
|
|
try await control(["action": "capability", "enabled": false, "assistantImages": true])
|
|
try await control(["action": "push-available", "enabled": false])
|
|
app.launch(); openAgent()
|
|
XCTAssertFalse(app.buttons["agent-attach"].exists)
|
|
let image = UIGraphicsImageRenderer(size: CGSize(width: 320, height: 180)).image { context in
|
|
UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 320, height: 180))
|
|
UIColor.systemYellow.setFill(); context.cgContext.fillEllipse(in: CGRect(x: 225, y: 25, width: 50, height: 50))
|
|
UIColor.systemIndigo.setFill()
|
|
context.cgContext.move(to: CGPoint(x: 0, y: 180)); context.cgContext.addLine(to: CGPoint(x: 120, y: 55))
|
|
context.cgContext.addLine(to: CGPoint(x: 250, y: 180)); context.cgContext.fillPath()
|
|
}
|
|
try await control(["action": "reply", "content": "\n\n<img src='http://127.0.0.1:8799/blocked-html.png' alt='HTML landscape'>"])
|
|
try await control(["action": "assistant-image", "content": "", "png": try XCTUnwrap(image.pngData()).base64EncodedString(), "failFirstDownload": true])
|
|
XCTAssertTrue(app.staticTexts["fixture-landscape.png"].waitForExistence(timeout: 8))
|
|
XCTAssertTrue(app.buttons["Retry download"].waitForExistence(timeout: 8))
|
|
let imageFrame = app.otherElements["agent-image-frame"]
|
|
XCTAssertTrue(imageFrame.exists)
|
|
let placeholder = imageFrame.frame
|
|
XCTAssertGreaterThan(placeholder.width, 0)
|
|
XCTAssertEqual(placeholder.height, 180, accuracy: 1)
|
|
app.buttons["Retry download"].tap()
|
|
let preview = app.images["agent-image-preview"]
|
|
XCTAssertTrue(preview.waitForExistence(timeout: 8))
|
|
XCTAssertEqual(preview.label, "Image: fixture-landscape.png")
|
|
XCTAssertLessThanOrEqual(preview.frame.height, 180)
|
|
XCTAssertEqual(imageFrame.frame.width, placeholder.width, accuracy: 1)
|
|
XCTAssertEqual(imageFrame.frame.height, placeholder.height, accuracy: 1)
|
|
let loadedSize = imageFrame.frame.size
|
|
XCTAssertTrue(app.staticTexts["Markdown landscape"].exists)
|
|
XCTAssertTrue(app.staticTexts["HTML landscape"].exists)
|
|
XCTAssertFalse(app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Copy workspace path:")).firstMatch.exists)
|
|
capture("agent-assistant-image-live")
|
|
|
|
// Previously saved output remains visible when both capabilities disappear.
|
|
try await control(["action": "capability", "enabled": false, "assistantImages": false])
|
|
app.terminate(); app.launch(); openAgent()
|
|
XCTAssertTrue(preview.waitForExistence(timeout: 8))
|
|
XCTAssertEqual(imageFrame.frame.width, placeholder.width, accuracy: 1)
|
|
XCTAssertEqual(imageFrame.frame.height, placeholder.height, accuracy: 1)
|
|
print("Assistant image frame sizes: placeholder=\(placeholder.size), live=\(loadedSize), history=\(imageFrame.frame.size)")
|
|
XCTAssertEqual(app.staticTexts.matching(NSPredicate(format: "label == %@", "fixture-landscape.png")).count, 1)
|
|
XCTAssertFalse(app.buttons["agent-attach"].exists)
|
|
capture("agent-assistant-image-history")
|
|
let state = try await control(["action": "state"])
|
|
let log = try XCTUnwrap(state["log"] as? [[String: Any]])
|
|
XCTAssertFalse(log.contains { ($0["path"] as? String)?.hasPrefix("/blocked-") == true })
|
|
XCTAssertGreaterThanOrEqual(log.filter { ($0["path"] as? String) == "/api/v1/agent/attachments/fixture-output-image/content" }.count, 3)
|
|
XCTAssertTrue((state["requests"] as? [String: Any])?.isEmpty == true)
|
|
}
|
|
|
|
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()
|
|
XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
|
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
|
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"].exists && app.buttons["Load earlier messages"].isHittable { break }
|
|
scroll.swipeDown(velocity: .fast)
|
|
}
|
|
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
|
|
app.buttons["Load earlier messages"].tap()
|
|
// Loading older lazy history may move the viewport. It must still load
|
|
// the remaining history and preserve scroll-to-latest/keyboard behavior below.
|
|
XCTAssertFalse(app.buttons["Load earlier messages"].exists)
|
|
capture("agent-older-history-loaded")
|
|
let latest = app.staticTexts["Reply while reading earlier history"]
|
|
for _ in 0..<30 {
|
|
if latest.isHittable { break }
|
|
scroll.swipeUp(velocity: .fast)
|
|
}
|
|
scroll.swipeUp(velocity: .slow)
|
|
XCTAssertTrue(latest.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")
|
|
// Sending remains the explicit navigation path while reading history.
|
|
scroll.swipeDown(velocity: .slow)
|
|
scroll.swipeDown(velocity: .slow)
|
|
XCTAssertFalse(latest.isHittable)
|
|
app.buttons["agent-send"].tap()
|
|
let sent = app.staticTexts["A multiline draft\nwith a second line\nand a third line"]
|
|
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: sent)
|
|
await fulfillment(of: [followed], timeout: 5)
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|