Skip to main content

fastmcp_console/testing/
test_console.rs

1//! TestConsole for capturing output in tests
2//!
3//! Provides a Console that captures all output for assertion instead of writing to stderr.
4
5use crate::console::FastMcpConsole;
6use std::io::Write;
7use std::sync::{Arc, Mutex};
8use strip_ansi_escapes::strip;
9
10/// A Console that captures output for testing
11///
12/// This wraps a FastMcpConsole with a buffer writer to capture all output.
13/// Use `console()` to get the inner console for rendering, then use
14/// `output()`, `contains()`, and assertion methods to verify the output.
15pub struct TestConsole {
16    inner: Arc<FastMcpConsole>,
17    buffer: Arc<Mutex<TestBuffer>>,
18    /// Whether this console reports as rich (for is_rich() method)
19    report_as_rich: bool,
20}
21
22#[derive(Debug, Default)]
23struct TestBuffer {
24    /// Lines with ANSI codes stripped
25    lines: Vec<String>,
26    /// Lines with ANSI codes preserved
27    raw_lines: Vec<String>,
28}
29
30impl TestConsole {
31    fn normalize_whitespace(text: &str) -> String {
32        text.split_whitespace().collect::<Vec<_>>().join(" ")
33    }
34
35    fn canonicalize_for_assertions(text: &str) -> String {
36        let mut out = Self::normalize_whitespace(text);
37        // Undo rich-wrapping artifacts around common punctuation.
38        for (from, to) in [
39            ("( ", "("),
40            (" )", ")"),
41            ("[ ", "["),
42            (" ]", "]"),
43            ("{ ", "{"),
44            (" }", "}"),
45            ("# ", "#"),
46            (" =", "="),
47            ("= ", "="),
48            (". ", "."),
49            (" /", "/"),
50            ("/ ", "/"),
51        ] {
52            out = out.replace(from, to);
53        }
54        out
55    }
56
57    fn compact_whitespace(text: &str) -> String {
58        text.chars().filter(|c| !c.is_whitespace()).collect()
59    }
60
61    /// Create a new test console that captures output
62    ///
63    /// Note: Internally uses rich mode to ensure output goes through the writer.
64    /// ANSI codes are stripped when reading via `output()` and `output_string()`.
65    #[must_use]
66    pub fn new() -> Self {
67        Self::new_inner(false)
68    }
69
70    /// Create a test console that renders rich output (for visual testing)
71    #[must_use]
72    pub fn new_rich() -> Self {
73        Self::new_inner(true)
74    }
75
76    /// Internal constructor
77    fn new_inner(report_as_rich: bool) -> Self {
78        let buffer = Arc::new(Mutex::new(TestBuffer::default()));
79        let writer = BufferWriter(buffer.clone());
80
81        // Always use enabled=true internally so output goes through the writer
82        Self {
83            inner: Arc::new(FastMcpConsole::with_writer(writer, true)),
84            buffer,
85            report_as_rich,
86        }
87    }
88
89    /// Get the underlying console for passing to renderers
90    #[must_use]
91    pub fn console(&self) -> &FastMcpConsole {
92        &self.inner
93    }
94
95    /// Get all captured output (ANSI codes stripped)
96    #[must_use]
97    pub fn output(&self) -> Vec<String> {
98        self.buffer
99            .lock()
100            .map(|b| b.lines.clone())
101            .unwrap_or_default()
102    }
103
104    /// Get all captured output (with ANSI codes)
105    #[must_use]
106    pub fn raw_output(&self) -> Vec<String> {
107        self.buffer
108            .lock()
109            .map(|b| b.raw_lines.clone())
110            .unwrap_or_default()
111    }
112
113    /// Get output as a single string
114    #[must_use]
115    pub fn output_string(&self) -> String {
116        Self::canonicalize_for_assertions(&self.output().join("\n"))
117    }
118
119    /// Check if output contains a string (case-insensitive)
120    #[must_use]
121    pub fn contains(&self, needle: &str) -> bool {
122        let output = self.output_string().to_lowercase();
123        let needle = Self::canonicalize_for_assertions(needle).to_lowercase();
124        if output.contains(&needle) {
125            return true;
126        }
127
128        let output_compact = Self::compact_whitespace(&output);
129        let needle_compact = Self::compact_whitespace(&needle);
130        output_compact.contains(&needle_compact)
131    }
132
133    /// Check if output contains all of the given strings
134    #[must_use]
135    pub fn contains_all(&self, needles: &[&str]) -> bool {
136        needles.iter().all(|n| self.contains(n))
137    }
138
139    /// Check if output matches a regex pattern
140    #[must_use]
141    pub fn matches(&self, pattern: &str) -> bool {
142        match regex::Regex::new(pattern) {
143            Ok(re) => {
144                let output = self.output_string();
145                re.is_match(&output) || re.is_match(&Self::compact_whitespace(&output))
146            }
147            Err(_) => false,
148        }
149    }
150
151    /// Assert that output contains a string
152    ///
153    /// # Panics
154    ///
155    /// Panics if the output does not contain the needle string.
156    pub fn assert_contains(&self, needle: &str) {
157        assert!(
158            self.contains(needle),
159            "Output did not contain '{}'. Actual output:\n{}",
160            needle,
161            self.output_string()
162        );
163    }
164
165    /// Assert that output does NOT contain a string
166    ///
167    /// # Panics
168    ///
169    /// Panics if the output contains the needle string.
170    pub fn assert_not_contains(&self, needle: &str) {
171        assert!(
172            !self.contains(needle),
173            "Output unexpectedly contained '{}'. Actual output:\n{}",
174            needle,
175            self.output_string()
176        );
177    }
178
179    /// Assert output has specific number of lines
180    ///
181    /// # Panics
182    ///
183    /// Panics if the line count doesn't match expected.
184    pub fn assert_line_count(&self, expected: usize) {
185        let actual = self.output().len();
186        assert_eq!(
187            actual,
188            expected,
189            "Expected {} lines but got {}. Actual output:\n{}",
190            expected,
191            actual,
192            self.output_string()
193        );
194    }
195
196    /// Clear the buffer
197    pub fn clear(&self) {
198        if let Ok(mut buf) = self.buffer.lock() {
199            buf.lines.clear();
200            buf.raw_lines.clear();
201        }
202    }
203
204    /// Print output for debugging (in tests)
205    pub fn debug_print(&self) {
206        eprintln!("=== TestConsole Output ===");
207        for (i, line) in self.output().iter().enumerate() {
208            eprintln!("{:3}: {}", i + 1, line);
209        }
210        eprintln!("==========================");
211    }
212
213    /// Check if the console reports as rich mode
214    ///
215    /// Note: The internal console is always in rich mode to capture output,
216    /// but this returns the mode the TestConsole was created with.
217    #[must_use]
218    pub fn is_rich(&self) -> bool {
219        self.report_as_rich
220    }
221}
222
223impl Default for TestConsole {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229impl Clone for TestConsole {
230    fn clone(&self) -> Self {
231        Self {
232            inner: self.inner.clone(),
233            buffer: self.buffer.clone(),
234            report_as_rich: self.report_as_rich,
235        }
236    }
237}
238
239impl std::fmt::Debug for TestConsole {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        f.debug_struct("TestConsole")
242            .field("is_rich", &self.is_rich())
243            .field("line_count", &self.output().len())
244            .finish()
245    }
246}
247
248/// Writer that captures to a buffer
249struct BufferWriter(Arc<Mutex<TestBuffer>>);
250
251impl std::fmt::Debug for BufferWriter {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        f.debug_struct("BufferWriter").finish_non_exhaustive()
254    }
255}
256
257impl Write for BufferWriter {
258    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
259        let s = String::from_utf8_lossy(buf);
260
261        if let Ok(mut buffer) = self.0.lock() {
262            // Store raw (with ANSI)
263            buffer.raw_lines.extend(s.lines().map(String::from));
264
265            // Store stripped (without ANSI)
266            let stripped = strip(buf);
267            let stripped_str = String::from_utf8_lossy(&stripped);
268            buffer.lines.extend(stripped_str.lines().map(String::from));
269        }
270
271        Ok(buf.len())
272    }
273
274    fn flush(&mut self) -> std::io::Result<()> {
275        Ok(())
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use std::panic::{self, AssertUnwindSafe};
283
284    fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
285        match payload.downcast::<String>() {
286            Ok(msg) => *msg,
287            Err(payload) => match payload.downcast::<&'static str>() {
288                Ok(msg) => (*msg).to_string(),
289                Err(_) => "<non-string panic payload>".to_string(),
290            },
291        }
292    }
293
294    #[test]
295    fn test_new_creates_plain_console() {
296        let tc = TestConsole::new();
297        assert!(!tc.is_rich());
298    }
299
300    #[test]
301    fn test_new_rich_creates_rich_console() {
302        let tc = TestConsole::new_rich();
303        assert!(tc.is_rich());
304    }
305
306    #[test]
307    fn test_output_capture() {
308        let tc = TestConsole::new();
309        tc.console().print("Hello, world!");
310        assert!(tc.contains("Hello"));
311        assert!(tc.contains("world"));
312    }
313
314    #[test]
315    fn test_contains_case_insensitive() {
316        let tc = TestConsole::new();
317        tc.console().print("Hello World");
318        assert!(tc.contains("hello"));
319        assert!(tc.contains("WORLD"));
320    }
321
322    #[test]
323    fn test_contains_all() {
324        let tc = TestConsole::new();
325        tc.console().print("The quick brown fox");
326        assert!(tc.contains_all(&["quick", "brown", "fox"]));
327        assert!(!tc.contains_all(&["quick", "lazy"]));
328    }
329
330    #[test]
331    fn test_assert_not_contains() {
332        let tc = TestConsole::new();
333        tc.console().print("Success");
334        tc.assert_not_contains("Error");
335    }
336
337    #[test]
338    fn test_clear() {
339        let tc = TestConsole::new();
340        tc.console().print("Some output");
341        assert!(!tc.output().is_empty());
342        tc.clear();
343        assert!(tc.output().is_empty());
344    }
345
346    #[test]
347    fn test_output_string() {
348        let tc = TestConsole::new();
349        tc.console().print("Line 1");
350        tc.console().print("Line 2");
351        let output = tc.output_string();
352        assert!(output.contains("Line 1"));
353        assert!(output.contains("Line 2"));
354    }
355
356    #[test]
357    fn test_matches_regex() {
358        let tc = TestConsole::new();
359        tc.console().print("Error code: 42");
360        assert!(tc.matches(r"code: \d+"));
361        assert!(!tc.matches(r"code: [a-z]+"));
362    }
363
364    #[test]
365    fn test_matches_invalid_regex_returns_false() {
366        let tc = TestConsole::new();
367        tc.console().print("anything");
368        assert!(!tc.matches("("));
369    }
370
371    #[test]
372    fn test_assert_contains_failure_includes_output() {
373        let tc = TestConsole::new();
374        tc.console().print("present text");
375        let panic = panic::catch_unwind(AssertUnwindSafe(|| tc.assert_contains("missing text")));
376        let message = panic_message(panic.expect_err("assert_contains should panic"));
377        assert!(message.contains("did not contain"));
378        assert!(message.contains("present text"));
379    }
380
381    #[test]
382    fn test_assert_not_contains_failure_includes_output() {
383        let tc = TestConsole::new();
384        tc.console().print("contains marker");
385        let panic = panic::catch_unwind(AssertUnwindSafe(|| {
386            tc.assert_not_contains("contains marker");
387        }));
388        let message = panic_message(panic.expect_err("assert_not_contains should panic"));
389        assert!(message.contains("unexpectedly contained"));
390        assert!(message.contains("contains marker"));
391    }
392
393    #[test]
394    fn test_assert_line_count_success_and_failure() {
395        let tc = TestConsole::new();
396        tc.console().print("line one");
397        let baseline = tc.output().len();
398        tc.assert_line_count(baseline);
399
400        let expected = baseline + 1;
401        let panic = panic::catch_unwind(AssertUnwindSafe(|| tc.assert_line_count(expected)));
402        let message = panic_message(panic.expect_err("assert_line_count should panic"));
403        assert!(message.contains(&format!("Expected {} lines but got {}", expected, baseline)));
404        assert!(message.contains("line one"));
405    }
406
407    #[test]
408    fn test_debug_print_executes() {
409        let tc = TestConsole::new();
410        tc.console().print("debug line");
411        tc.debug_print();
412    }
413
414    #[test]
415    fn test_debug_impls() {
416        let tc = TestConsole::new_rich();
417        tc.console().print("x");
418        let tc_debug = format!("{tc:?}");
419        assert!(tc_debug.contains("TestConsole"));
420        assert!(tc_debug.contains("is_rich"));
421        assert!(tc_debug.contains("line_count"));
422
423        let writer = BufferWriter(Arc::new(Mutex::new(TestBuffer::default())));
424        let writer_debug = format!("{writer:?}");
425        assert!(writer_debug.contains("BufferWriter"));
426    }
427
428    #[test]
429    fn test_clone() {
430        let tc = TestConsole::new();
431        tc.console().print("Test");
432        let tc2 = tc.clone();
433        assert!(tc2.contains("Test"));
434    }
435
436    #[test]
437    fn test_default() {
438        let tc = TestConsole::default();
439        assert!(!tc.is_rich());
440    }
441}