Skip to main content

fastmcp_console/testing/
snapshots.rs

1//! Snapshot testing for rich console output.
2//!
3//! This module provides utilities for snapshot testing of rich output.
4//! Snapshots store a "golden" reference of expected output and compare
5//! future runs against it. When output changes, the test fails and shows
6//! the diff.
7//!
8//! # Workflow
9//!
10//! 1. First run: Test fails with "snapshot does not exist"
11//! 2. Run with `UPDATE_SNAPSHOTS=1 cargo test` to create snapshot
12//! 3. Review generated snapshot in tests/snapshots/
13//! 4. Subsequent runs compare against stored snapshot
14//! 5. If output changes intentionally, run with `UPDATE_SNAPSHOTS=1` again
15//!
16//! # Example
17//!
18//! ```rust,ignore
19//! use fastmcp_console::testing::{TestConsole, SnapshotTest};
20//!
21//! #[test]
22//! fn test_banner_output() {
23//!     let console = TestConsole::new();
24//!     // ... render to console ...
25//!
26//!     SnapshotTest::new("banner_output").assert_snapshot(&console);
27//! }
28//! ```
29
30use std::fs;
31use std::path::{Path, PathBuf};
32
33use crate::testing::TestConsole;
34
35/// Snapshot testing for rich console output.
36///
37/// `SnapshotTest` compares console output against stored snapshots.
38/// If the output differs, the test fails with a diff. When running
39/// with `UPDATE_SNAPSHOTS=1`, snapshots are created or updated.
40///
41/// # Snapshot Storage
42///
43/// By default, snapshots are stored in `tests/snapshots/` relative to
44/// the crate's `Cargo.toml`. Use [`with_snapshot_dir`](Self::with_snapshot_dir)
45/// to customize this location.
46///
47/// # File Naming
48///
49/// - Plain text snapshots: `{name}.txt`
50/// - Raw snapshots (with ANSI): `{name}.raw.txt`
51pub struct SnapshotTest {
52    name: String,
53    snapshot_dir: PathBuf,
54    update_snapshots: bool,
55}
56
57impl SnapshotTest {
58    /// Creates a new snapshot test with the given name.
59    ///
60    /// The name is used as the filename for the snapshot (with `.txt` extension).
61    /// Snapshots are stored in `tests/snapshots/` by default.
62    ///
63    /// # Example
64    ///
65    /// ```rust,ignore
66    /// let snap = SnapshotTest::new("error_display");
67    /// // Snapshot will be at tests/snapshots/error_display.txt
68    /// ```
69    #[must_use]
70    pub fn new(name: &str) -> Self {
71        let snapshot_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
72            .join("tests")
73            .join("snapshots");
74
75        Self {
76            name: name.to_string(),
77            snapshot_dir,
78            update_snapshots: std::env::var("UPDATE_SNAPSHOTS").is_ok(),
79        }
80    }
81
82    /// Sets a custom snapshot directory.
83    ///
84    /// # Example
85    ///
86    /// ```rust,ignore
87    /// let snap = SnapshotTest::new("test")
88    ///     .with_snapshot_dir("/tmp/my_snapshots");
89    /// ```
90    #[must_use]
91    pub fn with_snapshot_dir(mut self, dir: impl AsRef<Path>) -> Self {
92        self.snapshot_dir = dir.as_ref().to_path_buf();
93        self
94    }
95
96    /// Forces update mode, regardless of environment variable.
97    ///
98    /// Use this for programmatic snapshot updates.
99    #[must_use]
100    pub fn with_update_mode(mut self, update: bool) -> Self {
101        self.update_snapshots = update;
102        self
103    }
104
105    /// Asserts that the console output matches the stored snapshot.
106    ///
107    /// Compares the stripped output (without ANSI codes) against the snapshot.
108    ///
109    /// # Panics
110    ///
111    /// - If the snapshot doesn't exist and `UPDATE_SNAPSHOTS` is not set
112    /// - If the output doesn't match the snapshot
113    ///
114    /// # Example
115    ///
116    /// ```rust,ignore
117    /// let console = TestConsole::new();
118    /// console.console().print("Hello, world!");
119    ///
120    /// SnapshotTest::new("hello")
121    ///     .assert_snapshot(&console);
122    /// ```
123    pub fn assert_snapshot(&self, console: &TestConsole) {
124        let actual = console.output_string();
125        self.assert_snapshot_string(&actual);
126    }
127
128    /// Asserts that a string matches the stored snapshot.
129    ///
130    /// # Panics
131    ///
132    /// - If the snapshot doesn't exist and `UPDATE_SNAPSHOTS` is not set
133    /// - If the string doesn't match the snapshot
134    pub fn assert_snapshot_string(&self, actual: &str) {
135        let snapshot_path = self.snapshot_path();
136
137        if self.update_snapshots {
138            self.save_snapshot(actual);
139            return;
140        }
141
142        if !snapshot_path.exists() {
143            panic!(
144                "Snapshot '{}' does not exist at {}.\n\
145                 Run with UPDATE_SNAPSHOTS=1 to create it.\n\
146                 Actual output ({} bytes):\n{}\n",
147                self.name,
148                snapshot_path.display(),
149                actual.len(),
150                truncate_for_display(actual, 1000)
151            );
152        }
153
154        let expected = fs::read_to_string(&snapshot_path).unwrap_or_else(|e| {
155            panic!(
156                "Failed to read snapshot file '{}': {}",
157                snapshot_path.display(),
158                e
159            )
160        });
161
162        if actual != expected {
163            let diff = self.generate_diff(&expected, actual);
164            panic!(
165                "Snapshot '{}' does not match.\n\
166                 Run with UPDATE_SNAPSHOTS=1 to update.\n\
167                 Diff (expected vs actual):\n{}\n",
168                self.name, diff
169            );
170        }
171    }
172
173    /// Asserts that the raw console output (with ANSI codes) matches the snapshot.
174    ///
175    /// This is useful for verifying that ANSI styling is applied correctly.
176    ///
177    /// # Panics
178    ///
179    /// - If the raw snapshot doesn't exist and `UPDATE_SNAPSHOTS` is not set
180    /// - If the raw output doesn't match the snapshot
181    pub fn assert_raw_snapshot(&self, console: &TestConsole) {
182        let actual = console.raw_output().join("\n");
183        let snapshot_path = self.snapshot_path_raw();
184
185        if self.update_snapshots {
186            fs::create_dir_all(&self.snapshot_dir).ok();
187            fs::write(&snapshot_path, &actual).unwrap_or_else(|e| {
188                panic!(
189                    "Failed to write raw snapshot '{}': {}",
190                    snapshot_path.display(),
191                    e
192                )
193            });
194            eprintln!(
195                "Updated raw snapshot: {} -> {}",
196                self.name,
197                snapshot_path.display()
198            );
199            return;
200        }
201
202        if !snapshot_path.exists() {
203            panic!(
204                "Raw snapshot '{}' does not exist at {}.\n\
205                 Run with UPDATE_SNAPSHOTS=1 to create.",
206                self.name,
207                snapshot_path.display()
208            );
209        }
210
211        let expected = fs::read_to_string(&snapshot_path).expect("Failed to read raw snapshot");
212
213        if actual != expected {
214            let diff = self.generate_diff(&expected, &actual);
215            panic!(
216                "Raw snapshot '{}' does not match.\n\
217                 Run with UPDATE_SNAPSHOTS=1 to update.\n\
218                 Diff:\n{}",
219                self.name, diff
220            );
221        }
222    }
223
224    /// Returns the path where the snapshot file would be stored.
225    #[must_use]
226    pub fn snapshot_path(&self) -> PathBuf {
227        self.snapshot_dir.join(format!("{}.txt", self.name))
228    }
229
230    /// Returns the path where the raw snapshot file would be stored.
231    #[must_use]
232    pub fn snapshot_path_raw(&self) -> PathBuf {
233        self.snapshot_dir.join(format!("{}.raw.txt", self.name))
234    }
235
236    /// Checks if a snapshot exists for this test.
237    #[must_use]
238    pub fn snapshot_exists(&self) -> bool {
239        self.snapshot_path().exists()
240    }
241
242    /// Checks if a raw snapshot exists for this test.
243    #[must_use]
244    pub fn raw_snapshot_exists(&self) -> bool {
245        self.snapshot_path_raw().exists()
246    }
247
248    /// Saves a snapshot to disk.
249    fn save_snapshot(&self, content: &str) {
250        fs::create_dir_all(&self.snapshot_dir).unwrap_or_else(|e| {
251            panic!(
252                "Failed to create snapshot directory '{}': {}",
253                self.snapshot_dir.display(),
254                e
255            )
256        });
257
258        let path = self.snapshot_path();
259        fs::write(&path, content)
260            .unwrap_or_else(|e| panic!("Failed to write snapshot '{}': {}", path.display(), e));
261
262        eprintln!("Updated snapshot: {} -> {}", self.name, path.display());
263    }
264
265    /// Generates a simple line-by-line diff between expected and actual.
266    fn generate_diff(&self, expected: &str, actual: &str) -> String {
267        let expected_lines: Vec<&str> = expected.lines().collect();
268        let actual_lines: Vec<&str> = actual.lines().collect();
269
270        let mut diff = String::new();
271        let max_lines = expected_lines.len().max(actual_lines.len());
272
273        // Header
274        diff.push_str(&format!(
275            "Expected: {} lines, Actual: {} lines\n",
276            expected_lines.len(),
277            actual_lines.len()
278        ));
279        diff.push_str("---\n");
280
281        let mut differences = 0;
282        for i in 0..max_lines {
283            let exp = expected_lines.get(i);
284            let act = actual_lines.get(i);
285
286            match (exp, act) {
287                (Some(e), Some(a)) if e != a => {
288                    diff.push_str(&format!("L{}: - {}\n", i + 1, e));
289                    diff.push_str(&format!("L{}: + {}\n", i + 1, a));
290                    differences += 1;
291                }
292                (Some(e), None) => {
293                    diff.push_str(&format!("L{}: - {}\n", i + 1, e));
294                    differences += 1;
295                }
296                (None, Some(a)) => {
297                    diff.push_str(&format!("L{}: + {}\n", i + 1, a));
298                    differences += 1;
299                }
300                _ => {}
301            }
302
303            // Limit diff output for very large differences
304            if differences > 50 {
305                diff.push_str(&format!(
306                    "... ({} more differences truncated)\n",
307                    max_lines - i - 1
308                ));
309                break;
310            }
311        }
312
313        if differences == 0 {
314            diff.push_str("(no line differences - possible whitespace/encoding issue)\n");
315
316            // Show character-level comparison for debugging
317            if expected.len() != actual.len() {
318                diff.push_str(&format!(
319                    "Byte lengths differ: expected {} vs actual {}\n",
320                    expected.len(),
321                    actual.len()
322                ));
323            }
324        }
325
326        diff
327    }
328}
329
330/// Truncates a string for display in error messages.
331fn truncate_for_display(s: &str, max_len: usize) -> &str {
332    if s.len() <= max_len {
333        s
334    } else {
335        // Find a safe truncation point (don't split UTF-8)
336        let truncate_at = s
337            .char_indices()
338            .take_while(|(i, _)| *i < max_len - 3)
339            .last()
340            .map(|(i, c)| i + c.len_utf8())
341            .unwrap_or(max_len - 3);
342        &s[..truncate_at]
343    }
344}
345
346/// Convenience macro for snapshot tests.
347///
348/// # Example
349///
350/// ```rust,ignore
351/// use fastmcp_console::{assert_snapshot, testing::TestConsole};
352///
353/// #[test]
354/// fn test_output() {
355///     let console = TestConsole::new();
356///     console.console().print("Hello!");
357///
358///     assert_snapshot!("hello_output", console);
359/// }
360/// ```
361#[macro_export]
362macro_rules! assert_snapshot {
363    ($name:expr, $console:expr) => {
364        $crate::testing::SnapshotTest::new($name).assert_snapshot(&$console)
365    };
366}
367
368/// Convenience macro for raw snapshot tests (with ANSI codes).
369///
370/// # Example
371///
372/// ```rust,ignore
373/// use fastmcp_console::{assert_raw_snapshot, testing::TestConsole};
374///
375/// #[test]
376/// fn test_styled_output() {
377///     let console = TestConsole::new_rich();
378///     console.console().print("[bold]Hello![/]");
379///
380///     assert_raw_snapshot!("hello_styled", console);
381/// }
382/// ```
383#[macro_export]
384macro_rules! assert_raw_snapshot {
385    ($name:expr, $console:expr) => {
386        $crate::testing::SnapshotTest::new($name).assert_raw_snapshot(&$console)
387    };
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use tempfile::tempdir;
394
395    #[test]
396    fn test_snapshot_path() {
397        let snap = SnapshotTest::new("my_test");
398        let path = snap.snapshot_path();
399        assert!(path.ends_with("my_test.txt"));
400    }
401
402    #[test]
403    fn test_snapshot_path_raw() {
404        let snap = SnapshotTest::new("my_test");
405        let path = snap.snapshot_path_raw();
406        assert!(path.ends_with("my_test.raw.txt"));
407    }
408
409    #[test]
410    fn test_custom_snapshot_dir() {
411        let snap = SnapshotTest::new("test").with_snapshot_dir("/tmp/custom");
412        assert_eq!(snap.snapshot_dir, PathBuf::from("/tmp/custom"));
413    }
414
415    #[test]
416    fn test_snapshot_creation_and_matching() {
417        let temp_dir = tempdir().expect("Failed to create temp dir");
418
419        let snap = SnapshotTest::new("creation_test")
420            .with_snapshot_dir(temp_dir.path())
421            .with_update_mode(true);
422
423        // Create the snapshot
424        let console = TestConsole::new();
425        console.console().print("Test content for snapshot");
426        snap.assert_snapshot(&console);
427
428        // Verify file was created
429        assert!(snap.snapshot_exists());
430
431        // Now verify it matches on a fresh test (without update mode)
432        let snap2 = SnapshotTest::new("creation_test")
433            .with_snapshot_dir(temp_dir.path())
434            .with_update_mode(false);
435
436        let console2 = TestConsole::new();
437        console2.console().print("Test content for snapshot");
438        snap2.assert_snapshot(&console2); // Should not panic
439    }
440
441    #[test]
442    fn test_snapshot_string_matching() {
443        let temp_dir = tempdir().expect("Failed to create temp dir");
444
445        // Create snapshot
446        let snap = SnapshotTest::new("string_test")
447            .with_snapshot_dir(temp_dir.path())
448            .with_update_mode(true);
449        snap.assert_snapshot_string("Hello, world!");
450
451        // Verify match
452        let snap2 = SnapshotTest::new("string_test")
453            .with_snapshot_dir(temp_dir.path())
454            .with_update_mode(false);
455        snap2.assert_snapshot_string("Hello, world!"); // Should not panic
456    }
457
458    #[test]
459    #[should_panic(expected = "does not match")]
460    fn test_snapshot_mismatch_panics() {
461        let temp_dir = tempdir().expect("Failed to create temp dir");
462
463        // Create snapshot with one value
464        let snap = SnapshotTest::new("mismatch_test")
465            .with_snapshot_dir(temp_dir.path())
466            .with_update_mode(true);
467        snap.assert_snapshot_string("Original content");
468
469        // Try to match with different value
470        let snap2 = SnapshotTest::new("mismatch_test")
471            .with_snapshot_dir(temp_dir.path())
472            .with_update_mode(false);
473        snap2.assert_snapshot_string("Different content"); // Should panic
474    }
475
476    #[test]
477    #[should_panic(expected = "does not exist")]
478    fn test_missing_snapshot_panics() {
479        let temp_dir = tempdir().expect("Failed to create temp dir");
480
481        let snap = SnapshotTest::new("nonexistent")
482            .with_snapshot_dir(temp_dir.path())
483            .with_update_mode(false);
484
485        snap.assert_snapshot_string("Content"); // Should panic
486    }
487
488    #[test]
489    fn test_raw_snapshot() {
490        let temp_dir = tempdir().expect("Failed to create temp dir");
491
492        // Create raw snapshot
493        let snap = SnapshotTest::new("raw_test")
494            .with_snapshot_dir(temp_dir.path())
495            .with_update_mode(true);
496
497        let console = TestConsole::new_rich();
498        console.console().print("[bold]Styled text[/]");
499        snap.assert_raw_snapshot(&console);
500
501        // Verify raw snapshot exists
502        assert!(snap.raw_snapshot_exists());
503    }
504
505    #[test]
506    fn test_generate_diff() {
507        let snap = SnapshotTest::new("diff_test");
508
509        let expected = "line 1\nline 2\nline 3";
510        let actual = "line 1\nmodified line 2\nline 3";
511
512        let diff = snap.generate_diff(expected, actual);
513
514        assert!(diff.contains("- line 2"));
515        assert!(diff.contains("+ modified line 2"));
516    }
517
518    #[test]
519    fn test_generate_diff_added_lines() {
520        let snap = SnapshotTest::new("diff_test");
521
522        let expected = "line 1";
523        let actual = "line 1\nline 2";
524
525        let diff = snap.generate_diff(expected, actual);
526
527        assert!(diff.contains("+ line 2"));
528    }
529
530    #[test]
531    fn test_generate_diff_removed_lines() {
532        let snap = SnapshotTest::new("diff_test");
533
534        let expected = "line 1\nline 2";
535        let actual = "line 1";
536
537        let diff = snap.generate_diff(expected, actual);
538
539        assert!(diff.contains("- line 2"));
540    }
541
542    #[test]
543    fn test_truncate_for_display() {
544        assert_eq!(truncate_for_display("short", 10), "short");
545        assert_eq!(
546            truncate_for_display("a longer string that needs truncation", 20).len(),
547            17
548        );
549    }
550
551    #[test]
552    fn test_snapshot_exists() {
553        let temp_dir = tempdir().expect("Failed to create temp dir");
554
555        let snap = SnapshotTest::new("exists_test").with_snapshot_dir(temp_dir.path());
556
557        assert!(!snap.snapshot_exists());
558
559        // Create it
560        let snap_create = snap.with_update_mode(true);
561        snap_create.assert_snapshot_string("content");
562
563        // Now it should exist
564        let snap_check = SnapshotTest::new("exists_test").with_snapshot_dir(temp_dir.path());
565        assert!(snap_check.snapshot_exists());
566    }
567}