agentcat_sdk/testing.rs
1//! [`Fake`]: a test double that records what a tracked server publishes, so a customer's test can
2//! assert on it. Enable the `test-util` feature of the crate to use it.
3
4use std::fmt;
5use std::sync::{Arc, Mutex, PoisonError};
6use std::time::Duration;
7
8use crate::AgentCat;
9use crate::config::Options;
10use crate::delivery::Observer;
11use crate::event::Event;
12use crate::pipeline::EventObject;
13
14/// The event type of a tool call.
15const TOOL_CALL: &str = "mcp:tools/call";
16/// The event type of a custom event.
17const CUSTOM: &str = "agentcat:custom";
18/// How long [`Fake::events`] waits for each handle to deliver.
19const FLUSH: Duration = Duration::from_secs(5);
20
21/// The events that finished the pipeline of the handles of one [`Fake`], in that order.
22#[derive(Debug, Default)]
23struct Recorder {
24 events: Mutex<Vec<Event>>,
25}
26
27impl Observer for Recorder {
28 fn finished(&self, event: &EventObject) {
29 self.events
30 .lock()
31 .unwrap_or_else(PoisonError::into_inner)
32 .push(Event::from_object(event));
33 }
34}
35
36/// A test double for `AgentCat`: it builds handles whose events never leave the process and
37/// records every event that finishes the pipeline, telemetry-only events (an empty project ID)
38/// included. The `identify`, tag, property and `redact_event` hooks run as they do in production,
39/// so a recorded event is the one that would have been published.
40///
41/// A `Fake` is an instance, not a global: two fakes in one process see only their own handles.
42/// Clones share the record. Its handles are always on (they ignore `AGENTCAT_ENABLED`), never
43/// configure the process logger and use a publisher that never sends, so a test needs no network.
44/// The log destination is process-wide and the first handle that configures it decides it, so the
45/// handles of a fake write no `~/agentcat.log` lines unless a handle built with [`AgentCat::new`]
46/// or [`AgentCat::from_env`] in the same process configured it: then their lines go there too.
47///
48/// A fake keeps a clone of each handle it built, so the last clone drops with the fake and the
49/// servers that hold one, and that drop drains as any handle does (see [dropping the last
50/// handle](AgentCat#dropping-the-last-handle)): for up to 5 s, and under `#[tokio::test]`, a
51/// current-thread runtime, an event that still needs a hook is dropped after that wait. The
52/// assertion methods flush first, so after them the drop has nothing left to wait for, unless a
53/// flush timed out: the events that flush left wait for the drop.
54///
55/// ```
56/// # async fn run() {
57/// use agentcat_sdk::Options;
58/// use agentcat_sdk::testing::Fake;
59///
60/// # struct MyServer;
61/// # impl rmcp::ServerHandler for MyServer {}
62/// let fake = Fake::new();
63/// let server = fake.agentcat("proj_123", Options::default()).track(MyServer);
64/// // ... serve `server` and make calls against it ...
65/// # let _ = server;
66/// fake.assert_nothing_tracked().await;
67/// # }
68/// ```
69///
70/// The assertion methods panic with a message that lists the recorded events' types and
71/// resource names, and says so when a handle did not finish delivering within the 5 s flush (a
72/// hook slower than that holds its event back).
73#[derive(Clone, Debug, Default)]
74pub struct Fake {
75 recorder: Arc<Recorder>,
76 handles: Arc<Mutex<Vec<AgentCat>>>,
77}
78
79impl Fake {
80 /// A fake with no handles and no events.
81 pub fn new() -> Self {
82 Self::default()
83 }
84
85 /// A handle for `project_id` (empty means telemetry only) whose events this fake records and
86 /// whose publisher never sends. Wrap a server with [`AgentCat::track`].
87 pub fn agentcat(&self, project_id: impl Into<String>, options: Options) -> AgentCat {
88 let handle = AgentCat::offline_observed(
89 project_id.into(),
90 options,
91 Arc::clone(&self.recorder) as Arc<dyn Observer>,
92 );
93 self.handles
94 .lock()
95 .unwrap_or_else(PoisonError::into_inner)
96 .push(handle.clone());
97 handle
98 }
99
100 /// Flushes every handle this fake built, for up to 5 s each, then returns the recorded events
101 /// in the order they finished. An event whose hooks take longer than that is not among them.
102 pub async fn events(&self) -> Vec<Event> {
103 self.recorded().await.events
104 }
105
106 /// [`Fake::events`], and whether every handle finished delivering within [`FLUSH`].
107 async fn recorded(&self) -> Recorded {
108 let handles: Vec<AgentCat> = self
109 .handles
110 .lock()
111 .unwrap_or_else(PoisonError::into_inner)
112 .clone();
113 let mut settled = true;
114 for handle in handles {
115 settled &= handle.flush(FLUSH).await;
116 }
117 let events = self
118 .recorder
119 .events
120 .lock()
121 .unwrap_or_else(PoisonError::into_inner)
122 .clone();
123 Recorded { events, settled }
124 }
125
126 /// Asserts that a tool call of the tool `tool_name` was tracked.
127 ///
128 /// # Panics
129 ///
130 /// When no recorded tool-call event has that resource name.
131 pub async fn assert_tracked(&self, tool_name: &str) {
132 let recorded = self.recorded().await;
133 let found = recorded
134 .events
135 .iter()
136 .any(|e| e.event_type == TOOL_CALL && e.resource_name.as_deref() == Some(tool_name));
137 assert!(
138 found,
139 "expected a tracked call of tool `{tool_name}`, {recorded}"
140 );
141 }
142
143 /// Asserts that exactly `count` tool calls were tracked (custom events do not count).
144 ///
145 /// # Panics
146 ///
147 /// When the number of recorded tool-call events differs.
148 pub async fn assert_tracked_count(&self, count: usize) {
149 let recorded = self.recorded().await;
150 let actual = recorded
151 .events
152 .iter()
153 .filter(|e| e.event_type == TOOL_CALL)
154 .count();
155 assert!(
156 actual == count,
157 "expected {count} tracked tool calls, found {actual}, {recorded}"
158 );
159 }
160
161 /// Asserts that no event of any kind was recorded.
162 ///
163 /// # Panics
164 ///
165 /// When any event was recorded.
166 pub async fn assert_nothing_tracked(&self) {
167 let recorded = self.recorded().await;
168 assert!(
169 recorded.events.is_empty(),
170 "expected nothing was tracked, {recorded}"
171 );
172 }
173
174 /// Asserts that a custom event with the resource name `resource_name` was published.
175 ///
176 /// # Panics
177 ///
178 /// When no recorded custom event has that resource name.
179 pub async fn assert_custom_event_published(&self, resource_name: &str) {
180 let recorded = self.recorded().await;
181 let found = recorded
182 .events
183 .iter()
184 .any(|e| e.event_type == CUSTOM && e.resource_name.as_deref() == Some(resource_name));
185 assert!(
186 found,
187 "expected a custom event `{resource_name}`, {recorded}"
188 );
189 }
190}
191
192/// What an assertion of a [`Fake`] saw: the recorded events and whether every handle finished
193/// delivering within [`FLUSH`].
194struct Recorded {
195 events: Vec<Event>,
196 settled: bool,
197}
198
199impl fmt::Display for Recorded {
200 /// The types and resource names of the events, such as `recorded: [mcp:tools/call echo,
201 /// agentcat:custom x]`, then a note when a flush timed out.
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 f.write_str("recorded: [")?;
204 for (i, e) in self.events.iter().enumerate() {
205 if i > 0 {
206 f.write_str(", ")?;
207 }
208 write!(
209 f,
210 "{} {}",
211 e.event_type,
212 e.resource_name.as_deref().unwrap_or("-")
213 )?;
214 }
215 f.write_str("]")?;
216 if !self.settled {
217 write!(
218 f,
219 " (a handle did not finish delivering within the {} s flush: an event whose hooks \
220 take longer is not recorded yet)",
221 FLUSH.as_secs()
222 )?;
223 }
224 Ok(())
225 }
226}
227
228#[cfg(test)]
229mod tests;