ios: add Agent attachments and shared transcript fade
TestFlight / Build and upload (push) Successful in 1m48s
TestFlight / Build and upload (push) Successful in 1m48s
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import XCTest
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
@MainActor
|
||||
final class AgentUITests: XCTestCase {
|
||||
@@ -10,7 +11,9 @@ final class AgentUITests: XCTestCase {
|
||||
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))
|
||||
@@ -23,6 +26,16 @@ final class AgentUITests: XCTestCase {
|
||||
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()
|
||||
}
|
||||
@@ -37,6 +50,171 @@ final class AgentUITests: XCTestCase {
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user