a2a_protocol_client/testing/mod.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Tom F. <tomf@tomtomtech.net> (https://github.com/tomtom215)
3
4//! Scripted hostile peers, for testing whatever calls an agent.
5//!
6//! A coordinator that delegates has to survive the worker it calls: one that
7//! stalls mid-stream, drops the connection before the final event, sends a
8//! frame that does not parse, or refuses the caller's credentials. Those are
9//! the failures a real network produces and a well-behaved test server never
10//! does, so they go untested unless something produces them on purpose.
11//!
12//! [`ScriptedPeer`] is that something. It listens on a loopback port, speaks
13//! one binding — JSON-RPC, HTTP+JSON, WebSocket (feature `websocket`) or gRPC
14//! (feature `grpc`) — and answers every call according to one script:
15//!
16//! | Script | Streaming calls | Unary calls |
17//! |---|---|---|
18//! | [`stall_after(n)`](ScriptedPeer::stall_after) | `n` events, then silence with the connection held open | no answer, connection held open |
19//! | [`cut_off_after(n)`](ScriptedPeer::cut_off_after) | `n` events, then the connection or stream is dropped | the connection is dropped |
20//! | [`misframe_after(n)`](ScriptedPeer::misframe_after) | `n` events, then one that does not parse, then silence | a body that does not parse |
21//! | [`unauthorized()`](ScriptedPeer::unauthorized) | refused: HTTP 401, gRPC `UNAUTHENTICATED`, or a WebSocket handshake answered 401 | the same |
22//!
23//! Every event is a `TASK_STATE_WORKING` status update for task `t`, so no
24//! stream ever reaches a terminal state on its own: what the caller sees next
25//! is decided by the script alone.
26//!
27//! ```no_run
28//! use a2a_protocol_client::testing::{Binding, ScriptedPeer};
29//!
30//! # async fn example() -> std::io::Result<()> {
31//! let peer = ScriptedPeer::new().stall_after(1).on(Binding::Rest).start().await?;
32//! // Point the code under test at `peer.url()`, and assert it gives up.
33//! # let _ = peer.url();
34//! # Ok(())
35//! # }
36//! ```
37//!
38//! This repository's own client tests use it (`tests/scripted_peer_tests.rs`),
39//! which is how it is known to produce each failure on each binding.
40//! Enabled by the `testing` feature; it is for tests, and nothing in a
41//! production build should depend on it.
42
43mod http1;
44
45#[cfg(feature = "grpc")]
46mod grpc;
47#[cfg(feature = "websocket")]
48mod ws;
49
50use std::sync::Arc;
51use std::sync::atomic::{AtomicUsize, Ordering};
52
53/// The binding a [`ScriptedPeer`] speaks.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[non_exhaustive]
56pub enum Binding {
57 /// JSON-RPC 2.0 over HTTP/1.1, streaming as server-sent events.
58 JsonRpc,
59 /// HTTP+JSON (the REST binding), streaming as server-sent events.
60 Rest,
61 /// JSON-RPC 2.0 frames over a WebSocket.
62 #[cfg(feature = "websocket")]
63 WebSocket,
64 /// `lf.a2a.v1.A2AService` over HTTP/2 gRPC.
65 #[cfg(feature = "grpc")]
66 Grpc,
67}
68
69/// What a [`ScriptedPeer`] does with each call.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71#[non_exhaustive]
72pub enum Script {
73 /// Send this many events, then fall silent and hold the connection.
74 Stall {
75 /// Events sent first.
76 after: usize,
77 },
78 /// Send this many events, then drop the connection or stream.
79 CutOff {
80 /// Events sent first.
81 after: usize,
82 },
83 /// Send this many events, then one that does not parse, then fall silent.
84 MisFrame {
85 /// Events sent first.
86 after: usize,
87 },
88 /// Refuse every call as unauthenticated.
89 Unauthorized,
90}
91
92/// A hostile A2A peer on a loopback port. See the [module docs](self).
93#[derive(Debug, Clone, Copy)]
94#[must_use]
95pub struct ScriptedPeer {
96 binding: Binding,
97 script: Script,
98}
99
100impl Default for ScriptedPeer {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl ScriptedPeer {
107 /// A JSON-RPC peer that stalls after one event; the methods below change
108 /// either half.
109 pub const fn new() -> Self {
110 Self {
111 binding: Binding::JsonRpc,
112 script: Script::Stall { after: 1 },
113 }
114 }
115
116 /// The binding to speak.
117 pub const fn on(mut self, binding: Binding) -> Self {
118 self.binding = binding;
119 self
120 }
121
122 /// Send `events`, then fall silent with the connection held open.
123 pub const fn stall_after(mut self, events: usize) -> Self {
124 self.script = Script::Stall { after: events };
125 self
126 }
127
128 /// Send `events`, then drop the connection (or, on gRPC, reset the stream).
129 pub const fn cut_off_after(mut self, events: usize) -> Self {
130 self.script = Script::CutOff { after: events };
131 self
132 }
133
134 /// Send `events`, then one that does not parse, then fall silent.
135 pub const fn misframe_after(mut self, events: usize) -> Self {
136 self.script = Script::MisFrame { after: events };
137 self
138 }
139
140 /// Refuse every call as unauthenticated.
141 pub const fn unauthorized(mut self) -> Self {
142 self.script = Script::Unauthorized;
143 self
144 }
145
146 /// Binds `127.0.0.1:0` and starts answering.
147 ///
148 /// # Errors
149 ///
150 /// Returns the error from binding the listener.
151 pub async fn start(self) -> std::io::Result<RunningPeer> {
152 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
153 let addr = listener.local_addr()?;
154 let accepted = Arc::new(AtomicUsize::new(0));
155 let counter = Arc::clone(&accepted);
156 let script = self.script;
157 let binding = self.binding;
158 let task = tokio::spawn(async move {
159 while let Ok((stream, _)) = listener.accept().await {
160 counter.fetch_add(1, Ordering::SeqCst);
161 let _ = stream.set_nodelay(true);
162 tokio::spawn(serve(binding, script, stream));
163 }
164 });
165 let url = match binding {
166 #[cfg(feature = "websocket")]
167 Binding::WebSocket => format!("ws://{addr}"),
168 _ => format!("http://{addr}"),
169 };
170 Ok(RunningPeer {
171 url,
172 accepted,
173 task,
174 })
175 }
176}
177
178async fn serve(binding: Binding, script: Script, stream: tokio::net::TcpStream) {
179 match binding {
180 Binding::JsonRpc => http1::serve(http1::Flavour::JsonRpc, script, stream).await,
181 Binding::Rest => http1::serve(http1::Flavour::Rest, script, stream).await,
182 #[cfg(feature = "websocket")]
183 Binding::WebSocket => ws::serve(script, stream).await,
184 #[cfg(feature = "grpc")]
185 Binding::Grpc => grpc::serve(script, stream).await,
186 }
187}
188
189/// A started [`ScriptedPeer`]. Dropping it stops the listener; connections
190/// already accepted keep whatever state their script left them in until the
191/// runtime ends.
192#[derive(Debug)]
193pub struct RunningPeer {
194 url: String,
195 accepted: Arc<AtomicUsize>,
196 task: tokio::task::JoinHandle<()>,
197}
198
199impl RunningPeer {
200 /// The URL to call: `http://127.0.0.1:PORT`, or `ws://…` for WebSocket.
201 #[must_use]
202 pub fn url(&self) -> &str {
203 &self.url
204 }
205
206 /// How many connections the peer has accepted.
207 #[must_use]
208 pub fn connections(&self) -> usize {
209 self.accepted.load(Ordering::SeqCst)
210 }
211}
212
213impl Drop for RunningPeer {
214 fn drop(&mut self) {
215 self.task.abort();
216 }
217}
218
219/// The one event every script sends: task `t` is working.
220fn working_event() -> serde_json::Value {
221 serde_json::json!({
222 "statusUpdate": {
223 "taskId": "t",
224 "contextId": "c",
225 "status": { "state": "TASK_STATE_WORKING" }
226 }
227 })
228}
229
230/// Holds the current task forever: the "silence" every stalling script ends
231/// in. The connection it owns stays open because the task never returns.
232async fn hold_open() {
233 std::future::pending::<()>().await;
234}