diff --git a/CLAUDE.md b/CLAUDE.md index 2006038..9b56bed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,4 +43,4 @@ Swift SDK for the A2A (Agent-to-Agent) protocol v1.0. - Run specific suite: `swift test --filter EventQueueManagerTests` ## Current Version -- Latest release: `0.4.0` (SSE reconnection, connection health monitoring, streaming session API) +- Latest release: `0.5.0` (A2ATesting library with mock client, executor, fixtures, and stream helpers) diff --git a/Package.swift b/Package.swift index 7243128..9d223b0 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,10 @@ let package = Package( name: "A2AVapor", targets: ["A2AVapor"] ), + .library( + name: "A2ATesting", + targets: ["A2ATesting"] + ), ], dependencies: [ .package(url: "https://github.com/vapor/vapor.git", from: "4.100.0"), @@ -41,6 +45,16 @@ let package = Package( dependencies: ["A2A"], path: "Tests/A2ATests" ), + .target( + name: "A2ATesting", + dependencies: ["A2A"], + path: "Sources/A2ATesting" + ), + .testTarget( + name: "A2ATestingTests", + dependencies: ["A2ATesting"], + path: "Tests/A2ATestingTests" + ), .testTarget( name: "A2AVaporTests", dependencies: [ diff --git a/ROADMAP.md b/ROADMAP.md index 2f6fdfe..4cdd906 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,6 +48,14 @@ - `sendStreamingMessageWithSession` / `subscribeToTaskWithSession` — rich streaming APIs - Existing streaming methods unchanged (non-breaking) +### v0.5.0 — Testing Utilities +- `A2ATesting` library target with mock client, executor, handler, and fixture builders +- `MockA2AClient` — configurable mock with response configuration, call recording, and inspection +- `MockAgentExecutor` — closure-based executor with `.echo()`, `.failing()`, `.completing()`, `.inputRequired()` presets +- `MockAgentHandler` — pre-wired handler wrapping `DefaultRequestHandler` for router testing +- Fixture builders: `.fixture()` static methods on `AgentCard`, `Message`, `A2ATask`, `Part`, `Artifact`, events +- Stream helpers: `collectStreamEvents()`, `[StreamResponse]` filtering (`.tasks`, `.statusUpdates`), typed accessors + ## Short Term ## Medium Term @@ -68,11 +76,6 @@ - RESTful routes: `POST /message:send`, `GET /tasks/{id}`, `POST /tasks/{id}:cancel`, etc. - Share the same handler/executor layer — just a different router -### Testing Utilities -- `A2ATesting` target with mock client and server -- Fixture builders for AgentCard, Task, Message, and other protocol types -- Test helpers for asserting streaming event sequences - ### Persistent TaskStore Implementations - `A2ASQLite` — lightweight local persistence using GRDB - `A2APostgres` — production-grade store using PostgresNIO diff --git a/Sources/A2A/A2A.docc/GettingStarted.md b/Sources/A2A/A2A.docc/GettingStarted.md index 32b6bab..356f75b 100644 --- a/Sources/A2A/A2A.docc/GettingStarted.md +++ b/Sources/A2A/A2A.docc/GettingStarted.md @@ -12,7 +12,7 @@ Add A2A to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/Victory-Apps/a2a-swift.git", from: "0.4.0") + .package(url: "https://github.com/Victory-Apps/a2a-swift.git", from: "0.5.0") ] ``` @@ -251,6 +251,91 @@ followUp.message.taskId = taskId let response2 = try await client.sendMessage(followUp) ``` +## Testing Your Agent + +The `A2ATesting` library provides mocks, fixtures, and stream helpers so you can test agents without running a server or making HTTP requests. + +Add it to your test target: + +```swift +.testTarget(name: "MyAgentTests", dependencies: [ + .product(name: "A2ATesting", package: "a2a-swift") +]) +``` + +### Fixtures + +Every A2A type has a `.fixture()` method with sensible defaults: + +```swift +import A2ATesting + +let card = AgentCard.fixture() // minimal valid card +let card = AgentCard.fixture(name: "My Agent") // override specific fields +let message = Message.fixture(text: "Hello") // user message with text +let task = A2ATask.fixture(status: .fixture(state: .completed)) +``` + +### Testing AgentExecutor Logic + +Use ``MockAgentExecutor`` presets or the ``DefaultRequestHandler`` with fixtures: + +```swift +@Test func agentCompletesTask() async throws { + let handler = DefaultRequestHandler( + executor: MyAgent(), + card: .fixture() + ) + let response = try await handler.handleSendMessage(.fixture( + message: .fixture(text: "What is 2+2?") + )) + + if case .task(let task) = response { + #expect(task.status.state == .completed) + #expect(task.artifacts?.first?.parts.first?.text == "4") + } +} +``` + +### Testing Streaming + +Use `collectStreamEvents` and stream assertion helpers: + +```swift +@Test func agentStreamsEvents() async throws { + let handler = DefaultRequestHandler(executor: MyAgent(), card: .fixture()) + let stream = try await handler.handleSendStreamingMessage(.fixture()) + let events = try await collectStreamEvents(stream) + + // Filter by type + #expect(!events.tasks.isEmpty) + #expect(events.containsStatus(.completed)) + + // Typed access at specific indices + let task = try events.task(at: 0) + #expect(task.status.state == .submitted) +} +``` + +### Mock Client + +Test code that calls an A2A agent without making HTTP requests: + +```swift +@Test func orchestratorDelegates() async throws { + let client = MockA2AClient() + await client.setAgentCard(.fixture(name: "Product Agent")) + await client.setSendMessageResponse(.task(.fixture( + status: .fixture(state: .completed) + ))) + + // Pass client to your orchestration code... + + let count = await client.sendMessageCallCount + #expect(count == 1) +} +``` + ## Next Steps - Read to understand how the SDK components fit together diff --git a/Sources/A2ATesting/A2ATesting.swift b/Sources/A2ATesting/A2ATesting.swift new file mode 100644 index 0000000..1080946 --- /dev/null +++ b/Sources/A2ATesting/A2ATesting.swift @@ -0,0 +1 @@ +@_exported import A2A diff --git a/Sources/A2ATesting/Fixtures/AgentCard+Fixture.swift b/Sources/A2ATesting/Fixtures/AgentCard+Fixture.swift new file mode 100644 index 0000000..7612bf9 --- /dev/null +++ b/Sources/A2ATesting/Fixtures/AgentCard+Fixture.swift @@ -0,0 +1,54 @@ +extension AgentCard { + /// Creates a fixture `AgentCard` with sensible defaults for testing. + public static func fixture( + name: String = "Test Agent", + description: String = "A test agent", + url: String = "http://localhost:8080", + version: String = "1.0.0", + capabilities: AgentCapabilities = AgentCapabilities(streaming: true), + defaultInputModes: [String] = ["text/plain"], + defaultOutputModes: [String] = ["text/plain"], + skills: [AgentSkill] = [.fixture()] + ) -> AgentCard { + AgentCard( + name: name, + description: description, + supportedInterfaces: [AgentInterface.fixture(url: url)], + version: version, + capabilities: capabilities, + defaultInputModes: defaultInputModes, + defaultOutputModes: defaultOutputModes, + skills: skills + ) + } +} + +extension AgentSkill { + /// Creates a fixture `AgentSkill` with sensible defaults for testing. + public static func fixture( + id: String = "test-skill", + name: String = "Test Skill", + description: String = "A test skill", + tags: [String] = ["test"] + ) -> AgentSkill { + AgentSkill( + id: id, + name: name, + description: description, + tags: tags + ) + } +} + +extension AgentInterface { + /// Creates a fixture `AgentInterface` with sensible defaults for testing. + public static func fixture( + url: String = "http://localhost:8080", + protocolVersion: String = "1.0" + ) -> AgentInterface { + AgentInterface( + url: url, + protocolVersion: protocolVersion + ) + } +} diff --git a/Sources/A2ATesting/Fixtures/Message+Fixture.swift b/Sources/A2ATesting/Fixtures/Message+Fixture.swift new file mode 100644 index 0000000..5930eab --- /dev/null +++ b/Sources/A2ATesting/Fixtures/Message+Fixture.swift @@ -0,0 +1,53 @@ +import Foundation + +extension Message { + /// Creates a fixture `Message` with sensible defaults for testing. + /// + /// If `parts` is provided, it takes precedence over `text`. + public static func fixture( + messageId: String = UUID().uuidString, + contextId: String? = nil, + taskId: String? = nil, + role: Role = .user, + text: String = "Hello", + parts: [Part]? = nil + ) -> Message { + Message( + messageId: messageId, + contextId: contextId, + taskId: taskId, + role: role, + parts: parts ?? [.text(text)] + ) + } +} + +extension SendMessageRequest { + /// Creates a fixture `SendMessageRequest` with sensible defaults for testing. + public static func fixture( + message: Message = .fixture(), + configuration: SendMessageConfiguration? = nil + ) -> SendMessageRequest { + SendMessageRequest( + message: message, + configuration: configuration + ) + } +} + +extension RequestContext { + /// Creates a fixture `RequestContext` with sensible defaults for testing. + public static func fixture( + task: A2ATask = .fixture(), + userMessage: Message = .fixture(), + request: SendMessageRequest = .fixture(), + isNewTask: Bool = true + ) -> RequestContext { + RequestContext( + task: task, + userMessage: userMessage, + request: request, + isNewTask: isNewTask + ) + } +} diff --git a/Sources/A2ATesting/Fixtures/Part+Fixture.swift b/Sources/A2ATesting/Fixtures/Part+Fixture.swift new file mode 100644 index 0000000..5eb0a96 --- /dev/null +++ b/Sources/A2ATesting/Fixtures/Part+Fixture.swift @@ -0,0 +1,27 @@ +import Foundation + +extension Part { + /// Creates a fixture `Part` with sensible defaults for testing. + public static func fixture( + text: String = "Test content" + ) -> Part { + .text(text) + } +} + +extension Artifact { + /// Creates a fixture `Artifact` with sensible defaults for testing. + public static func fixture( + artifactId: String = UUID().uuidString, + name: String? = nil, + description: String? = nil, + parts: [Part] = [.text("Test artifact content")] + ) -> Artifact { + Artifact( + artifactId: artifactId, + name: name, + description: description, + parts: parts + ) + } +} diff --git a/Sources/A2ATesting/Fixtures/Task+Fixture.swift b/Sources/A2ATesting/Fixtures/Task+Fixture.swift new file mode 100644 index 0000000..392c324 --- /dev/null +++ b/Sources/A2ATesting/Fixtures/Task+Fixture.swift @@ -0,0 +1,69 @@ +import Foundation + +extension A2ATask { + /// Creates a fixture `A2ATask` with sensible defaults for testing. + public static func fixture( + id: String = UUID().uuidString, + contextId: String? = nil, + status: TaskStatus = .fixture(), + artifacts: [Artifact]? = nil, + history: [Message]? = nil + ) -> A2ATask { + A2ATask( + id: id, + contextId: contextId, + status: status, + artifacts: artifacts, + history: history + ) + } +} + +extension TaskStatus { + /// Creates a fixture `TaskStatus` with sensible defaults for testing. + public static func fixture( + state: TaskState = .submitted, + message: Message? = nil, + timestamp: String? = nil + ) -> TaskStatus { + TaskStatus( + state: state, + message: message, + timestamp: timestamp + ) + } +} + +extension TaskStatusUpdateEvent { + /// Creates a fixture `TaskStatusUpdateEvent` with sensible defaults for testing. + public static func fixture( + taskId: String = "test-task", + contextId: String = "test-context", + status: TaskStatus = .fixture() + ) -> TaskStatusUpdateEvent { + TaskStatusUpdateEvent( + taskId: taskId, + contextId: contextId, + status: status + ) + } +} + +extension TaskArtifactUpdateEvent { + /// Creates a fixture `TaskArtifactUpdateEvent` with sensible defaults for testing. + public static func fixture( + taskId: String = "test-task", + contextId: String = "test-context", + artifact: Artifact = .fixture(), + append: Bool? = nil, + lastChunk: Bool? = nil + ) -> TaskArtifactUpdateEvent { + TaskArtifactUpdateEvent( + taskId: taskId, + contextId: contextId, + artifact: artifact, + append: append, + lastChunk: lastChunk + ) + } +} diff --git a/Sources/A2ATesting/Helpers/StreamAssertions.swift b/Sources/A2ATesting/Helpers/StreamAssertions.swift new file mode 100644 index 0000000..1f2e392 --- /dev/null +++ b/Sources/A2ATesting/Helpers/StreamAssertions.swift @@ -0,0 +1,79 @@ +/// Error thrown by stream assertion helpers when an event doesn't match the expected type. +public struct StreamAssertionError: Error, CustomStringConvertible { + public let description: String + + public init(_ description: String) { + self.description = description + } +} + +extension Array where Element == StreamResponse { + /// All task snapshots in the stream. + public var tasks: [A2ATask] { + compactMap { if case .task(let t) = $0 { return t } else { return nil } } + } + + /// All status update events in the stream. + public var statusUpdates: [TaskStatusUpdateEvent] { + compactMap { if case .statusUpdate(let u) = $0 { return u } else { return nil } } + } + + /// All artifact update events in the stream. + public var artifactUpdates: [TaskArtifactUpdateEvent] { + compactMap { if case .artifactUpdate(let a) = $0 { return a } else { return nil } } + } + + /// All message events in the stream. + public var messages: [Message] { + compactMap { if case .message(let m) = $0 { return m } else { return nil } } + } + + /// Returns the task at the given index, or throws if the element is not a `.task`. + public func task(at index: Int) throws -> A2ATask { + guard index < count else { + throw StreamAssertionError("Index \(index) out of bounds (count: \(count))") + } + guard case .task(let task) = self[index] else { + throw StreamAssertionError("Expected .task at index \(index), got \(self[index])") + } + return task + } + + /// Returns the status update at the given index, or throws if the element is not a `.statusUpdate`. + public func statusUpdate(at index: Int) throws -> TaskStatusUpdateEvent { + guard index < count else { + throw StreamAssertionError("Index \(index) out of bounds (count: \(count))") + } + guard case .statusUpdate(let update) = self[index] else { + throw StreamAssertionError("Expected .statusUpdate at index \(index), got \(self[index])") + } + return update + } + + /// Returns the artifact update at the given index, or throws if the element is not a `.artifactUpdate`. + public func artifactUpdate(at index: Int) throws -> TaskArtifactUpdateEvent { + guard index < count else { + throw StreamAssertionError("Index \(index) out of bounds (count: \(count))") + } + guard case .artifactUpdate(let update) = self[index] else { + throw StreamAssertionError("Expected .artifactUpdate at index \(index), got \(self[index])") + } + return update + } + + /// Returns the message at the given index, or throws if the element is not a `.message`. + public func message(at index: Int) throws -> Message { + guard index < count else { + throw StreamAssertionError("Index \(index) out of bounds (count: \(count))") + } + guard case .message(let msg) = self[index] else { + throw StreamAssertionError("Expected .message at index \(index), got \(self[index])") + } + return msg + } + + /// Whether any status update in the stream has the given state. + public func containsStatus(_ state: TaskState) -> Bool { + statusUpdates.contains { $0.status.state == state } + } +} diff --git a/Sources/A2ATesting/Helpers/StreamCollector.swift b/Sources/A2ATesting/Helpers/StreamCollector.swift new file mode 100644 index 0000000..23b037b --- /dev/null +++ b/Sources/A2ATesting/Helpers/StreamCollector.swift @@ -0,0 +1,16 @@ +/// Collects all events from a ``StreamResponse`` stream into an array. +/// +/// ```swift +/// let stream = try await handler.handleSendStreamingMessage(request) +/// let events = try await collectStreamEvents(stream) +/// #expect(events.count == 3) +/// ``` +public func collectStreamEvents( + _ stream: AsyncThrowingStream +) async throws -> [StreamResponse] { + var events: [StreamResponse] = [] + for try await event in stream { + events.append(event) + } + return events +} diff --git a/Sources/A2ATesting/Mocks/MockA2AClient.swift b/Sources/A2ATesting/Mocks/MockA2AClient.swift new file mode 100644 index 0000000..0d1b0b5 --- /dev/null +++ b/Sources/A2ATesting/Mocks/MockA2AClient.swift @@ -0,0 +1,246 @@ +import Foundation + +/// A mock A2A client for testing without network requests. +/// +/// Configure responses with `set*()` methods, then call the same methods +/// as ``A2AClient``. Inspect recorded calls after the test: +/// +/// ```swift +/// let client = MockA2AClient() +/// await client.setAgentCard(.fixture(name: "My Agent")) +/// +/// let card = try await client.fetchAgentCard() +/// // card.name == "My Agent" +/// +/// let count = await client.fetchAgentCardCallCount +/// // count == 1 +/// ``` +public final class MockA2AClient: Sendable { + private let state: StateActor + + public init() { + self.state = StateActor() + } + + // MARK: - Configuration + + /// Sets the agent card returned by ``fetchAgentCard()``. + public func setAgentCard(_ card: AgentCard) async { + await state.setAgentCard(card) + } + + /// Sets the response returned by ``sendMessage(_:)``. + public func setSendMessageResponse(_ response: SendMessageResponse) async { + await state.setSendMessageResponse(response) + } + + /// Sets the events yielded by ``sendStreamingMessage(_:)``. + public func setStreamingResponses(_ responses: [StreamResponse]) async { + await state.setStreamingResponses(responses) + } + + /// Sets the task returned by ``getTask(_:)``. + public func setGetTaskResponse(_ task: A2ATask) async { + await state.setGetTaskResponse(task) + } + + /// Sets the response returned by ``listTasks(_:)``. + public func setListTasksResponse(_ response: ListTasksResponse) async { + await state.setListTasksResponse(response) + } + + /// Sets the task returned by ``cancelTask(_:)``. + public func setCancelTaskResponse(_ task: A2ATask) async { + await state.setCancelTaskResponse(task) + } + + /// When set, all methods throw this error instead of returning configured responses. + public func setError(_ error: Error?) async { + await state.setError(error) + } + + // MARK: - Client API + + /// Returns the configured agent card. + public func fetchAgentCard() async throws -> AgentCard { + try await state.fetchAgentCard() + } + + /// Returns the configured send message response. + public func sendMessage(_ params: SendMessageRequest) async throws -> SendMessageResponse { + try await state.sendMessage(params) + } + + /// Returns a stream yielding the configured streaming responses. + public func sendStreamingMessage(_ params: SendMessageRequest) async throws -> AsyncThrowingStream { + try await state.sendStreamingMessage(params) + } + + /// Returns the configured task. + public func getTask(_ params: GetTaskRequest) async throws -> A2ATask { + try await state.getTask(params) + } + + /// Returns the configured list tasks response. + public func listTasks(_ params: ListTasksRequest) async throws -> ListTasksResponse { + try await state.listTasks(params) + } + + /// Returns the configured cancel task response. + public func cancelTask(_ params: CancelTaskRequest) async throws -> A2ATask { + try await state.cancelTask(params) + } + + // MARK: - Inspection + + /// Number of times ``fetchAgentCard()`` was called. + public var fetchAgentCardCallCount: Int { + get async { await state.fetchAgentCardCallCount } + } + + /// Number of times ``sendMessage(_:)`` was called. + public var sendMessageCallCount: Int { + get async { await state.sendMessageCallCount } + } + + /// The most recent request passed to ``sendMessage(_:)``. + public var lastSendMessageRequest: SendMessageRequest? { + get async { await state.lastSendMessageRequest } + } + + /// Number of times ``sendStreamingMessage(_:)`` was called. + public var sendStreamingMessageCallCount: Int { + get async { await state.sendStreamingMessageCallCount } + } + + /// Number of times ``getTask(_:)`` was called. + public var getTaskCallCount: Int { + get async { await state.getTaskCallCount } + } + + /// Number of times ``cancelTask(_:)`` was called. + public var cancelTaskCallCount: Int { + get async { await state.cancelTaskCallCount } + } + + /// Clears all recorded calls and configured responses. + public func reset() async { + await state.reset() + } +} + +// MARK: - Internal Actor + +extension MockA2AClient { + actor StateActor { + var agentCard: AgentCard? + var sendMessageResponse: SendMessageResponse? + var streamingResponses: [StreamResponse] = [] + var getTaskResponse: A2ATask? + var listTasksResponse: ListTasksResponse? + var cancelTaskResponse: A2ATask? + var error: Error? + + var fetchAgentCardCallCount = 0 + var sendMessageCalls: [SendMessageRequest] = [] + var sendStreamingMessageCalls: [SendMessageRequest] = [] + var getTaskCalls: [GetTaskRequest] = [] + var cancelTaskCalls: [CancelTaskRequest] = [] + + var sendMessageCallCount: Int { sendMessageCalls.count } + var sendStreamingMessageCallCount: Int { sendStreamingMessageCalls.count } + var getTaskCallCount: Int { getTaskCalls.count } + var cancelTaskCallCount: Int { cancelTaskCalls.count } + var lastSendMessageRequest: SendMessageRequest? { sendMessageCalls.last } + + // Configuration + func setAgentCard(_ card: AgentCard) { agentCard = card } + func setSendMessageResponse(_ response: SendMessageResponse) { sendMessageResponse = response } + func setStreamingResponses(_ responses: [StreamResponse]) { streamingResponses = responses } + func setGetTaskResponse(_ task: A2ATask) { getTaskResponse = task } + func setListTasksResponse(_ response: ListTasksResponse) { listTasksResponse = response } + func setCancelTaskResponse(_ task: A2ATask) { cancelTaskResponse = task } + func setError(_ err: Error?) { error = err } + + func reset() { + agentCard = nil + sendMessageResponse = nil + streamingResponses = [] + getTaskResponse = nil + listTasksResponse = nil + cancelTaskResponse = nil + error = nil + fetchAgentCardCallCount = 0 + sendMessageCalls = [] + sendStreamingMessageCalls = [] + getTaskCalls = [] + cancelTaskCalls = [] + } + + // Client methods + func fetchAgentCard() throws -> AgentCard { + fetchAgentCardCallCount += 1 + if let error { throw error } + guard let agentCard else { + throw MockA2AClientError.notConfigured("agentCard") + } + return agentCard + } + + func sendMessage(_ params: SendMessageRequest) throws -> SendMessageResponse { + sendMessageCalls.append(params) + if let error { throw error } + guard let sendMessageResponse else { + throw MockA2AClientError.notConfigured("sendMessageResponse") + } + return sendMessageResponse + } + + func sendStreamingMessage(_ params: SendMessageRequest) throws -> AsyncThrowingStream { + sendStreamingMessageCalls.append(params) + if let error { throw error } + let responses = streamingResponses + return AsyncThrowingStream { continuation in + for response in responses { + continuation.yield(response) + } + continuation.finish() + } + } + + func getTask(_ params: GetTaskRequest) throws -> A2ATask { + getTaskCalls.append(params) + if let error { throw error } + guard let getTaskResponse else { + throw MockA2AClientError.notConfigured("getTaskResponse") + } + return getTaskResponse + } + + func listTasks(_ params: ListTasksRequest) throws -> ListTasksResponse { + if let error { throw error } + guard let listTasksResponse else { + throw MockA2AClientError.notConfigured("listTasksResponse") + } + return listTasksResponse + } + + func cancelTask(_ params: CancelTaskRequest) throws -> A2ATask { + cancelTaskCalls.append(params) + if let error { throw error } + guard let cancelTaskResponse else { + throw MockA2AClientError.notConfigured("cancelTaskResponse") + } + return cancelTaskResponse + } + } +} + +/// Error thrown when a ``MockA2AClient`` method is called without configuring a response. +public struct MockA2AClientError: Error, CustomStringConvertible { + public let description: String + + static func notConfigured(_ property: String) -> MockA2AClientError { + MockA2AClientError(description: "MockA2AClient.\(property) not configured. Call set\(property.prefix(1).uppercased() + property.dropFirst())() before using this method.") + } +} diff --git a/Sources/A2ATesting/Mocks/MockAgentExecutor.swift b/Sources/A2ATesting/Mocks/MockAgentExecutor.swift new file mode 100644 index 0000000..cccebc6 --- /dev/null +++ b/Sources/A2ATesting/Mocks/MockAgentExecutor.swift @@ -0,0 +1,83 @@ +import Foundation + +/// A configurable ``AgentExecutor`` for testing. +/// +/// Use the closure-based initializer for custom behavior, or one of the +/// static factory methods for common patterns: +/// +/// ```swift +/// // Custom behavior +/// let executor = MockAgentExecutor { context, updater in +/// updater.startWork() +/// updater.addArtifact(parts: [.text("custom")]) +/// updater.complete() +/// } +/// +/// // Presets +/// let echo = MockAgentExecutor.echo() +/// let fail = MockAgentExecutor.failing() +/// let done = MockAgentExecutor.completing(with: "Result") +/// ``` +public struct MockAgentExecutor: AgentExecutor { + private let _execute: @Sendable (RequestContext, TaskUpdater) async throws -> Void + private let _cancel: (@Sendable (RequestContext, TaskUpdater) async throws -> Void)? + + public init( + onExecute: @escaping @Sendable (RequestContext, TaskUpdater) async throws -> Void = { _, updater in + updater.complete() + }, + onCancel: (@Sendable (RequestContext, TaskUpdater) async throws -> Void)? = nil + ) { + self._execute = onExecute + self._cancel = onCancel + } + + public func execute(context: RequestContext, updater: TaskUpdater) async throws { + try await _execute(context, updater) + } + + public func cancel(context: RequestContext, updater: TaskUpdater) async throws { + if let _cancel { + try await _cancel(context, updater) + } else { + updater.updateStatus(.canceled) + } + } +} + +// MARK: - Presets + +extension MockAgentExecutor { + /// An executor that echoes the user's message parts back as an artifact. + public static func echo() -> MockAgentExecutor { + MockAgentExecutor { context, updater in + updater.startWork(message: "Processing...") + updater.addArtifact(parts: context.userMessage.parts) + updater.complete(message: "Done") + } + } + + /// An executor that immediately throws an error. + public static func failing( + error: Error = A2AError.internalError("Something went wrong") + ) -> MockAgentExecutor { + MockAgentExecutor { _, _ in throw error } + } + + /// An executor that completes with the given text as an artifact. + public static func completing(with text: String) -> MockAgentExecutor { + MockAgentExecutor { _, updater in + updater.startWork() + updater.addArtifact(parts: [.text(text)]) + updater.complete() + } + } + + /// An executor that requests additional input from the user. + public static func inputRequired(message: String) -> MockAgentExecutor { + MockAgentExecutor { _, updater in + updater.startWork() + updater.requireInput(message: message) + } + } +} diff --git a/Sources/A2ATesting/Mocks/MockAgentHandler.swift b/Sources/A2ATesting/Mocks/MockAgentHandler.swift new file mode 100644 index 0000000..c024e5f --- /dev/null +++ b/Sources/A2ATesting/Mocks/MockAgentHandler.swift @@ -0,0 +1,55 @@ +import Foundation + +/// A pre-wired ``A2AAgentHandler`` for testing server-side components. +/// +/// Wraps ``DefaultRequestHandler`` with a configurable executor and agent card, +/// providing a ready-to-use handler for ``A2ARouter`` tests: +/// +/// ```swift +/// let handler = MockAgentHandler(executor: .echo()) +/// let router = A2ARouter(handler: handler) +/// let result = try await router.route(body: requestData) +/// ``` +public final class MockAgentHandler: A2AAgentHandler, @unchecked Sendable { + private let handler: DefaultRequestHandler + + /// The agent card this handler returns. + public let card: AgentCard + + /// The task store used by the handler, available for inspection in tests. + public let store: InMemoryTaskStore + + public init( + executor: any AgentExecutor = MockAgentExecutor(), + card: AgentCard = .fixture(), + store: InMemoryTaskStore = InMemoryTaskStore() + ) { + self.card = card + self.store = store + self.handler = DefaultRequestHandler(executor: executor, card: card, store: store) + } + + public func agentCard() async throws -> AgentCard { + try await handler.agentCard() + } + + public func handleSendMessage(_ request: SendMessageRequest) async throws -> SendMessageResponse { + try await handler.handleSendMessage(request) + } + + public func handleSendStreamingMessage(_ request: SendMessageRequest) async throws -> AsyncThrowingStream { + try await handler.handleSendStreamingMessage(request) + } + + public func handleGetTask(_ request: GetTaskRequest) async throws -> A2ATask { + try await handler.handleGetTask(request) + } + + public func handleListTasks(_ request: ListTasksRequest) async throws -> ListTasksResponse { + try await handler.handleListTasks(request) + } + + public func handleCancelTask(_ request: CancelTaskRequest) async throws -> A2ATask { + try await handler.handleCancelTask(request) + } +} diff --git a/Tests/A2ATestingTests/FixtureTests.swift b/Tests/A2ATestingTests/FixtureTests.swift new file mode 100644 index 0000000..99eb6a0 --- /dev/null +++ b/Tests/A2ATestingTests/FixtureTests.swift @@ -0,0 +1,167 @@ +import Testing +import Foundation +import A2ATesting + +@Suite("Fixtures") +struct FixtureTests { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + // MARK: - AgentCard + + @Test func agentCardDefaults() { + let card = AgentCard.fixture() + #expect(card.name == "Test Agent") + #expect(card.description == "A test agent") + #expect(card.version == "1.0.0") + #expect(card.supportedInterfaces.count == 1) + #expect(card.supportedInterfaces[0].url == "http://localhost:8080") + #expect(card.capabilities.streaming == true) + #expect(card.skills.count == 1) + #expect(card.skills[0].id == "test-skill") + } + + @Test func agentCardOverrides() { + let card = AgentCard.fixture( + name: "Custom Agent", + description: "Custom description", + url: "http://example.com", + version: "2.0.0" + ) + #expect(card.name == "Custom Agent") + #expect(card.description == "Custom description") + #expect(card.supportedInterfaces[0].url == "http://example.com") + #expect(card.version == "2.0.0") + } + + @Test func agentSkillDefaults() { + let skill = AgentSkill.fixture() + #expect(skill.id == "test-skill") + #expect(skill.name == "Test Skill") + #expect(skill.description == "A test skill") + #expect(skill.tags == ["test"]) + } + + // MARK: - Message + + @Test func messageDefaults() { + let message = Message.fixture() + #expect(message.role == .user) + #expect(message.parts.count == 1) + #expect(message.parts[0].text == "Hello") + #expect(!message.messageId.isEmpty) + } + + @Test func messageOverrides() { + let message = Message.fixture(role: .agent, text: "Response") + #expect(message.role == .agent) + #expect(message.parts[0].text == "Response") + } + + @Test func messageWithCustomParts() { + let message = Message.fixture(parts: [.text("A"), .text("B")]) + #expect(message.parts.count == 2) + #expect(message.parts[0].text == "A") + #expect(message.parts[1].text == "B") + } + + // MARK: - Task + + @Test func taskDefaults() { + let task = A2ATask.fixture() + #expect(!task.id.isEmpty) + #expect(task.status.state == .submitted) + #expect(task.artifacts == nil) + #expect(task.history == nil) + } + + @Test func taskOverrides() { + let task = A2ATask.fixture( + id: "custom-id", + status: .fixture(state: .completed) + ) + #expect(task.id == "custom-id") + #expect(task.status.state == .completed) + } + + @Test func taskStatusDefaults() { + let status = TaskStatus.fixture() + #expect(status.state == .submitted) + #expect(status.message == nil) + } + + @Test func taskStatusOverrides() { + let status = TaskStatus.fixture(state: .working, message: .fixture(text: "In progress")) + #expect(status.state == .working) + #expect(status.message?.parts[0].text == "In progress") + } + + // MARK: - Part & Artifact + + @Test func partFixture() { + let part = Part.fixture() + #expect(part.text == "Test content") + } + + @Test func partFixtureOverride() { + let part = Part.fixture(text: "Custom") + #expect(part.text == "Custom") + } + + @Test func artifactDefaults() { + let artifact = Artifact.fixture() + #expect(!artifact.artifactId.isEmpty) + #expect(artifact.parts.count == 1) + #expect(artifact.parts[0].text == "Test artifact content") + } + + // MARK: - Events + + @Test func taskStatusUpdateEventDefaults() { + let event = TaskStatusUpdateEvent.fixture() + #expect(event.taskId == "test-task") + #expect(event.contextId == "test-context") + #expect(event.status.state == .submitted) + } + + @Test func taskArtifactUpdateEventDefaults() { + let event = TaskArtifactUpdateEvent.fixture() + #expect(event.taskId == "test-task") + #expect(event.contextId == "test-context") + #expect(event.artifact.parts.count == 1) + } + + // MARK: - Request Context + + @Test func requestContextDefaults() { + let context = RequestContext.fixture() + #expect(context.isNewTask) + #expect(context.userMessage.role == .user) + #expect(context.task.status.state == .submitted) + } + + @Test func sendMessageRequestDefaults() { + let request = SendMessageRequest.fixture() + #expect(request.message.role == .user) + #expect(request.configuration == nil) + } + + // MARK: - JSON Round-trip + + @Test func fixturesAreEncodable() throws { + _ = try encoder.encode(AgentCard.fixture()) + _ = try encoder.encode(Message.fixture()) + _ = try encoder.encode(A2ATask.fixture()) + _ = try encoder.encode(Artifact.fixture()) + _ = try encoder.encode(TaskStatusUpdateEvent.fixture()) + _ = try encoder.encode(TaskArtifactUpdateEvent.fixture()) + } + + @Test func fixturesRoundTrip() throws { + let card = AgentCard.fixture() + let data = try encoder.encode(card) + let decoded = try decoder.decode(AgentCard.self, from: data) + #expect(decoded.name == card.name) + #expect(decoded.version == card.version) + } +} diff --git a/Tests/A2ATestingTests/MockClientTests.swift b/Tests/A2ATestingTests/MockClientTests.swift new file mode 100644 index 0000000..2c8d3f3 --- /dev/null +++ b/Tests/A2ATestingTests/MockClientTests.swift @@ -0,0 +1,125 @@ +import Testing +import Foundation +import A2ATesting + +@Suite("MockA2AClient") +struct MockClientTests { + + @Test func returnsConfiguredAgentCard() async throws { + let client = MockA2AClient() + let card = AgentCard.fixture(name: "Custom") + await client.setAgentCard(card) + + let result = try await client.fetchAgentCard() + #expect(result.name == "Custom") + + let count = await client.fetchAgentCardCallCount + #expect(count == 1) + } + + @Test func returnsConfiguredSendMessageResponse() async throws { + let client = MockA2AClient() + let task = A2ATask.fixture(status: .fixture(state: .completed)) + await client.setSendMessageResponse(.task(task)) + + let response = try await client.sendMessage(.fixture()) + + if case .task(let t) = response { + #expect(t.status.state == .completed) + } else { + Issue.record("Expected .task response") + } + + let count = await client.sendMessageCallCount + #expect(count == 1) + + let lastRequest = await client.lastSendMessageRequest + #expect(lastRequest != nil) + } + + @Test func returnsStreamingResponses() async throws { + let client = MockA2AClient() + let responses: [StreamResponse] = [ + .task(.fixture()), + .statusUpdate(.fixture(status: .fixture(state: .completed))) + ] + await client.setStreamingResponses(responses) + + let stream = try await client.sendStreamingMessage(.fixture()) + let events = try await collectStreamEvents(stream) + + #expect(events.count == 2) + #expect(events.tasks.count == 1) + #expect(events.statusUpdates.count == 1) + } + + @Test func returnsConfiguredGetTaskResponse() async throws { + let client = MockA2AClient() + let task = A2ATask.fixture(id: "my-task") + await client.setGetTaskResponse(task) + + let result = try await client.getTask(GetTaskRequest(id: "my-task")) + #expect(result.id == "my-task") + + let count = await client.getTaskCallCount + #expect(count == 1) + } + + @Test func throwsConfiguredError() async throws { + let client = MockA2AClient() + await client.setError(A2AError.internalError("Test error")) + + do { + _ = try await client.fetchAgentCard() + Issue.record("Expected error") + } catch { + // Expected + } + } + + @Test func throwsWhenNotConfigured() async throws { + let client = MockA2AClient() + + do { + _ = try await client.fetchAgentCard() + Issue.record("Expected error") + } catch let error as MockA2AClientError { + #expect(error.description.contains("not configured")) + } + } + + @Test func resetClearsState() async throws { + let client = MockA2AClient() + await client.setAgentCard(.fixture()) + _ = try await client.fetchAgentCard() + + let countBefore = await client.fetchAgentCardCallCount + #expect(countBefore == 1) + + await client.reset() + + let countAfter = await client.fetchAgentCardCallCount + #expect(countAfter == 0) + + do { + _ = try await client.fetchAgentCard() + Issue.record("Expected error after reset") + } catch { + // Expected — agent card was cleared + } + } + + @Test func recordsMultipleCalls() async throws { + let client = MockA2AClient() + await client.setSendMessageResponse(.task(.fixture())) + + _ = try await client.sendMessage(.fixture(message: .fixture(text: "First"))) + _ = try await client.sendMessage(.fixture(message: .fixture(text: "Second"))) + + let count = await client.sendMessageCallCount + #expect(count == 2) + + let last = await client.lastSendMessageRequest + #expect(last?.message.parts[0].text == "Second") + } +} diff --git a/Tests/A2ATestingTests/MockExecutorTests.swift b/Tests/A2ATestingTests/MockExecutorTests.swift new file mode 100644 index 0000000..acec123 --- /dev/null +++ b/Tests/A2ATestingTests/MockExecutorTests.swift @@ -0,0 +1,85 @@ +import Testing +import Foundation +import A2ATesting + +@Suite("MockAgentExecutor") +struct MockExecutorTests { + + @Test func echoPreset() async throws { + let handler = DefaultRequestHandler( + executor: MockAgentExecutor.echo(), + card: .fixture() + ) + let response = try await handler.handleSendMessage( + .fixture(message: .fixture(text: "Echo me")) + ) + + if case .task(let task) = response { + #expect(task.status.state == .completed) + #expect(task.artifacts?.count == 1) + #expect(task.artifacts?[0].parts[0].text == "Echo me") + } else { + Issue.record("Expected .task response") + } + } + + @Test func failingPreset() async throws { + let handler = DefaultRequestHandler( + executor: MockAgentExecutor.failing(), + card: .fixture() + ) + let response = try await handler.handleSendMessage(.fixture()) + + if case .task(let task) = response { + #expect(task.status.state == .failed) + } else { + Issue.record("Expected .task response") + } + } + + @Test func completingPreset() async throws { + let handler = DefaultRequestHandler( + executor: MockAgentExecutor.completing(with: "Result text"), + card: .fixture() + ) + let response = try await handler.handleSendMessage(.fixture()) + + if case .task(let task) = response { + #expect(task.status.state == .completed) + #expect(task.artifacts?[0].parts[0].text == "Result text") + } else { + Issue.record("Expected .task response") + } + } + + @Test func inputRequiredPreset() async throws { + let handler = DefaultRequestHandler( + executor: MockAgentExecutor.inputRequired(message: "What is your name?"), + card: .fixture() + ) + let response = try await handler.handleSendMessage(.fixture()) + + if case .task(let task) = response { + #expect(task.status.state == .completed || task.status.state == .inputRequired) + } else { + Issue.record("Expected .task response") + } + } + + @Test func customClosure() async throws { + let executor = MockAgentExecutor { _, updater in + updater.addArtifact(parts: [.text("custom")]) + updater.complete() + } + + let handler = DefaultRequestHandler(executor: executor, card: .fixture()) + let response = try await handler.handleSendMessage(.fixture()) + + if case .task(let task) = response { + #expect(task.status.state == .completed) + #expect(task.artifacts?[0].parts[0].text == "custom") + } else { + Issue.record("Expected .task response") + } + } +} diff --git a/Tests/A2ATestingTests/StreamHelperTests.swift b/Tests/A2ATestingTests/StreamHelperTests.swift new file mode 100644 index 0000000..3293768 --- /dev/null +++ b/Tests/A2ATestingTests/StreamHelperTests.swift @@ -0,0 +1,115 @@ +import Testing +import Foundation +import A2ATesting + +@Suite("Stream Helpers") +struct StreamHelperTests { + + @Test func collectStreamEventsGathersAll() async throws { + let (stream, continuation) = AsyncThrowingStream.makeStream() + continuation.yield(.task(.fixture())) + continuation.yield(.statusUpdate(.fixture(status: .fixture(state: .working)))) + continuation.yield(.statusUpdate(.fixture(status: .fixture(state: .completed)))) + continuation.finish() + + let events = try await collectStreamEvents(stream) + #expect(events.count == 3) + } + + @Test func tasksFilter() { + let events: [StreamResponse] = [ + .task(.fixture(id: "t1")), + .statusUpdate(.fixture()), + .task(.fixture(id: "t2")), + ] + let tasks = events.tasks + #expect(tasks.count == 2) + #expect(tasks[0].id == "t1") + #expect(tasks[1].id == "t2") + } + + @Test func statusUpdatesFilter() { + let events: [StreamResponse] = [ + .task(.fixture()), + .statusUpdate(.fixture(status: .fixture(state: .working))), + .statusUpdate(.fixture(status: .fixture(state: .completed))), + ] + let updates = events.statusUpdates + #expect(updates.count == 2) + #expect(updates[0].status.state == .working) + #expect(updates[1].status.state == .completed) + } + + @Test func artifactUpdatesFilter() { + let events: [StreamResponse] = [ + .task(.fixture()), + .artifactUpdate(.fixture()), + .statusUpdate(.fixture()), + ] + #expect(events.artifactUpdates.count == 1) + } + + @Test func messagesFilter() { + let events: [StreamResponse] = [ + .message(.fixture(text: "Hello")), + .task(.fixture()), + .message(.fixture(text: "World")), + ] + let msgs = events.messages + #expect(msgs.count == 2) + #expect(msgs[0].parts[0].text == "Hello") + } + + @Test func taskAtIndex() throws { + let events: [StreamResponse] = [ + .task(.fixture(id: "first")), + .statusUpdate(.fixture()), + ] + let task = try events.task(at: 0) + #expect(task.id == "first") + } + + @Test func taskAtIndexThrowsForWrongType() { + let events: [StreamResponse] = [ + .statusUpdate(.fixture()), + ] + #expect(throws: StreamAssertionError.self) { + _ = try events.task(at: 0) + } + } + + @Test func taskAtIndexThrowsForOutOfBounds() { + let events: [StreamResponse] = [] + #expect(throws: StreamAssertionError.self) { + _ = try events.task(at: 0) + } + } + + @Test func statusUpdateAtIndex() throws { + let events: [StreamResponse] = [ + .task(.fixture()), + .statusUpdate(.fixture(status: .fixture(state: .completed))), + ] + let update = try events.statusUpdate(at: 1) + #expect(update.status.state == .completed) + } + + @Test func containsStatusFindsMatch() { + let events: [StreamResponse] = [ + .statusUpdate(.fixture(status: .fixture(state: .working))), + .statusUpdate(.fixture(status: .fixture(state: .completed))), + ] + #expect(events.containsStatus(.completed)) + #expect(events.containsStatus(.working)) + #expect(!events.containsStatus(.failed)) + } + + @Test func emptyEventsReturnEmptyFilters() { + let events: [StreamResponse] = [] + #expect(events.tasks.isEmpty) + #expect(events.statusUpdates.isEmpty) + #expect(events.artifactUpdates.isEmpty) + #expect(events.messages.isEmpty) + #expect(!events.containsStatus(.completed)) + } +}