Skip to main content

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}