import XCTest import SwiftUI @testable import Sybil private let originalBytes = Data([0, 255, 10, 13, 128, 42]) private let attachmentLimits = AgentAttachmentCapabilities(version: 1, maxCount: 8, maxTotalBytes: 33_554_432, maxImageBytes: 6_291_456, maxFileBytes: 8_388_608, imageMimeTypes: ["image/png", "image/jpeg"], maxImagePixels: 50_000_000, maxImageDimension: 16_384) /// In-memory upload-only fixture. Every other path fails; no socket/provider IO. private actor AttachmentTestClient: AgentAPIClienting { var fail = false var delay = Duration.milliseconds(1200) func failNextUpload(delay: Duration) { fail = true; self.delay = delay } func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment { try await Task.sleep(for: delay) if fail { fail = false throw AgentAttachmentIssue(message: "Free storage or retry later.", code: "attachment_quota_exceeded", statusCode: 507) } return AgentAttachment(id: "synthetic-" + uploadID, kind: .file, filename: file.filename, mimeType: file.mimeType, sizeBytes: file.data.count, sha256: file.sha256) } func agentDescriptor() throws -> AgentDescriptor { throw AgentAttachmentIssue.unavailable } func agentMessages(after: String?, before: String?) throws -> AgentPage { throw AgentAttachmentIssue.unavailable } func agentReadState() throws -> AgentReadState { throw AgentAttachmentIssue.unavailable } func agentMarkRead(cursor: String) throws -> AgentReadState { throw AgentAttachmentIssue.unavailable } func agentSubmit(content: String, requestID: String) throws -> AgentReceipt { throw AgentAttachmentIssue.unavailable } func agentSocket(after: String) throws -> any AgentSocket { throw AgentAttachmentIssue.unavailable } } @MainActor private func attachmentEventually(_ predicate: () -> Bool) async -> Bool { for _ in 0..<200 { if predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) } return false } // Rendering the production composer against the in-memory upload fixture // covers transient states without changing the shipping picker or contacting a provider. @MainActor final class AgentComposerPresentationTests: XCTestCase { func testLoadingUploadingFailureRetryCapacityAndCapabilitySnapshots() async throws { XCTAssertTrue(UserDefaults.standard.string(forKey: "sybil.ios.apiBaseURL") == "http://127.0.0.1:8799/api") XCTAssertTrue(ProcessInfo.processInfo.environment["SYBIL_PUSH_FIXTURE"] == "1") let room = SybilAgentRoom(), client = AttachmentTestClient() let draft = room.attachmentDraft draft.configure(client: client, storage: nil) var limits = attachmentLimits limits.maxCount = 1 draft.updateCapability(limits) await client.failNextUpload(delay: .milliseconds(1200)) await capture(room, "composer-enabled-empty") let id = try XCTUnwrap(draft.add(filename: "synthetic-original.bin") { try await Task.sleep(for: .milliseconds(600)) return .init(filename: "synthetic-original.bin", mimeType: "application/octet-stream", data: originalBytes) }) XCTAssertFalse(draft.canSend) XCTAssertEqual(draft.items.first?.state, .loading) await capture(room, "composer-loading-at-capacity-disabled") let uploading = await attachmentEventually { draft.items.first?.state == .uploading } XCTAssertTrue(uploading) await capture(room, "composer-uploading-at-capacity-disabled") let failed = await attachmentEventually { draft.items.first?.state == .failed } XCTAssertTrue(failed); XCTAssertFalse(draft.canSend) await capture(room, "composer-error-retry-disabled-send") draft.retry(id) let ready = await attachmentEventually { draft.items.first?.state == .ready } XCTAssertTrue(ready); XCTAssertTrue(draft.canSend) await capture(room, "composer-ready-enabled-send") await capture(room, "composer-ready-accessibility3", size: .accessibility3) draft.updateCapability(nil) XCTAssertFalse(draft.canSend) await capture(room, "composer-unavailable-retains-attachment") draft.remove(id) draft.caption = "Text still works" XCTAssertTrue(draft.canSend) await capture(room, "composer-text-only-capability") draft.suspend() } private func capture(_ room: SybilAgentRoom, _ name: String, size: DynamicTypeSize = .large) async { // ImageRenderer omits UIKit-backed fields/scroll views; host the real hierarchy. let host = UIHostingController(rootView: AgentComposerPreview(room: room) .padding(12).background(SybilTheme.background) .environment(\.colorScheme, .dark).environment(\.dynamicTypeSize, size)) let fitting = host.sizeThatFits(in: CGSize(width: 414, height: 600)) let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 414, height: max(120, fitting.height))) window.rootViewController = host window.makeKeyAndVisible() defer { window.isHidden = true; window.rootViewController = nil } host.view.frame = window.bounds host.view.setNeedsLayout(); host.view.layoutIfNeeded() try? await Task.sleep(for: .milliseconds(100)) let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in XCTAssertTrue(host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true)) } let attachment = XCTAttachment(image: image) attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) } } private struct AgentComposerPreview: View { var room: SybilAgentRoom @FocusState private var focused: Bool var body: some View { AgentAttachmentComposer(room: room) { SybilPromptField(text: .constant(room.composer), focused: $focused, submit: {}) SybilPromptSendButton(canSend: room.attachmentDraft.canSend, submit: {}) } } }