Skip to main content

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;