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            std::panic::panic_any(format!(
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 = match fs::read_to_string(&snapshot_path) {
155            Ok(expected) => expected,
156            Err(e) => {
157                std::panic::panic_any(format!(
158                    "Failed to read snapshot file '{}': {}",
159                    snapshot_path.display(),
160                    e
161                ));
162            }
163        };
164
165        if actual != expected {
166            let diff = self.generate_diff(&expected, actual);
167            std::panic::panic_any(format!(
168                "Snapshot '{}' does not match.\n\
169                 Run with UPDATE_SNAPSHOTS=1 to update.\n\
170                 Diff (expected vs actual):\n{}\n",
171                self.name, diff
172            ));
173        }
174    }
175
176    /// Asserts that the raw console output (with ANSI codes) matches the snapshot.
177    ///
178    /// This is useful for verifying that ANSI styling is applied correctly.
179    ///
180    /// # Panics
181    ///
182    /// - If the raw snapshot doesn't exist and `UPDATE_SNAPSHOTS` is not set
183    /// - If the raw output doesn't match the snapshot
184    pub fn assert_raw_snapshot(&self, console: &TestConsole) {
185        let actual = console.raw_output().join("\n");
186        let snapshot_path = self.snapshot_path_raw();
187
188        if self.update_snapshots {
189            fs::create_dir_all(&self.snapshot_dir).ok();
190            if let Err(e) = fs::write(&snapshot_path, &actual) {
191                std::panic::panic_any(format!(
192                    "Failed to write raw snapshot '{}': {}",
193                    snapshot_path.display(),
194                    e
195                ));
196            }
197            eprintln!(
198                "Updated raw snapshot: {} -> {}",
199                self.name,
200                snapshot_path.display()
201            );
202            return;
203        }
204
205        if !snapshot_path.exists() {
206            std::panic::panic_any(format!(
207                "Raw snapshot '{}' does not exist at {}.\n\
208                 Run with UPDATE_SNAPSHOTS=1 to create.",
209                self.name,
210                snapshot_path.display()
211            ));
212        }
213
214        let expected = fs::read_to_string(&snapshot_path).expect("Failed to read raw snapshot");
215
216        if actual != expected {
217            let diff = self.generate_diff(&expected, &actual);
218            std::panic::panic_any(format!(
219                "Raw snapshot '{}' does not match.\n\
220                 Run with UPDATE_SNAPSHOTS=1 to update.\n\
221                 Diff:\n{}",
222                self.name, diff
223            ));
224        }
225    }
226
227    /// Returns the path where the snapshot file would be stored.
228    #[must_use]
229    pub fn snapshot_path(&self) -> PathBuf {
230        self.snapshot_dir.join(format!("{}.txt", self.name))
231    }
232
233    /// Returns the path where the raw snapshot file would be stored.
234    #[must_use]
235    pub fn snapshot_path_raw(&self) -> PathBuf {
236        self.snapshot_dir.join(format!("{}.raw.txt", self.name))
237    }
238
239    /// Checks if a snapshot exists for this test.
240    #[must_use]
241    pub fn snapshot_exists(&self) -> bool {
242        self.snapshot_path().exists()
243    }
244
245    /// Checks if a raw snapshot exists for this test.
246    #[must_use]
247    pub fn raw_snapshot_exists(&self) -> bool {
248        self.snapshot_path_raw().exists()
249    }
250
251    /// Saves a snapshot to disk.
252    fn save_snapshot(&self, content: &str) {
253        if let Err(e) = fs::create_dir_all(&self.snapshot_dir) {
254            std::panic::panic_any(format!(
255                "Failed to create snapshot directory '{}': {}",
256                self.snapshot_dir.display(),
257                e
258            ));
259        }
260
261        let path = self.snapshot_path();
262        if let Err(e) = fs::write(&path, content) {
263            std::panic::panic_any(format!(
264                "Failed to write snapshot '{}': {}",
265                path.display(),
266                e
267            ));
268        }
269
270        eprintln!("Updated snapshot: {} -> {}", self.name, path.display());
271    }
272
273    /// Generates a simple line-by-line diff between expected and actual.
274    fn generate_diff(&self, expected: &str, actual: &str) -> String {
275        let expected_lines: Vec<&str> = expected.lines().collect();
276        let actual_lines: Vec<&str> = actual.lines().collect();
277
278        let mut diff = String::new();
279        let max_lines = expected_lines.len().max(actual_lines.len());
280
281        // Header
282        diff.push_str(&format!(
283            "Expected: {} lines, Actual: {} lines\n",
284            expected_lines.len(),
285            actual_lines.len()
286        ));
287        diff.push_str("---\n");
288
289        let mut differences = 0;
290        for i in 0..max_lines {
291            let exp = expected_lines.get(i);
292            let act = actual_lines.get(i);
293
294            match (exp, act) {
295                (Some(e), Some(a)) if e != a => {
296                    diff.push_str(&format!("L{}: - {}\n", i + 1, e));
297                    diff.push_str(&format!("L{}: + {}\n", i + 1, a));
298                    differences += 1;
299                }
300                (Some(e), None) => {
301                    diff.push_str(&format!("L{}: - {}\n", i + 1, e));
302                    differences += 1;
303                }
304                (None, Some(a)) => {
305                    diff.push_str(&format!("L{}: + {}\n", i + 1, a));
306                    differences += 1;
307                }
308                _ => {}
309            }
310
311            // Limit diff output for very large differences
312            if differences > 50 {
313                diff.push_str(&format!(
314                    "... ({} more differences truncated)\n",
315                    max_lines - i - 1
316                ));
317                break;
318            }
319        }
320
321        if differences == 0 {
322            diff.push_str("(no line differences - possible whitespace/encoding issue)\n");
323
324            // Show character-level comparison for debugging
325            if expected.len() != actual.len() {
326                diff.push_str(&format!(
327                    "Byte lengths differ: expected {} vs actual {}\n",
328                    expected.len(),
329                    actual.len()
330                ));
331            }
332        }
333
334        diff
335    }
336}
337
338/// Truncates a string for display in error messages.
339fn truncate_for_display(s: &str, max_len: usize) -> &str {
340    if s.len() <= max_len {
341        s
342    } else {
343        // Find a safe truncation point (don't split UTF-8)
344        let truncate_at = s
345            .char_indices()
346            .take_while(|(i, _)| *i < max_len - 3)
347            .last()
348            .map(|(i, c)| i + c.len_utf8())
349            .unwrap_or(max_len - 3);
350        &s[..truncate_at]
351    }
352}
353
354/// Convenience macro for snapshot tests.
355///
356/// # Example
357///
358/// ```rust,ignore
359/// use fastmcp_console::{assert_snapshot, testing::TestConsole};
360///
361/// #[test]
362/// fn test_output() {
363///     let console = TestConsole::new();
364///     console.console().print("Hello!");
365///
366///     assert_snapshot!("hello_output", console);
367/// }
368/// ```
369#[macro_export]
370macro_rules! assert_snapshot {
371    ($name:expr, $console:expr) => {
372        $crate::testing::SnapshotTest::new($name).assert_snapshot(&$console)
373    };
374}
375
376/// Convenience macro for raw snapshot tests (with ANSI codes).
377///
378/// # Example
379///
380/// ```rust,ignore
381/// use fastmcp_console::{assert_raw_snapshot, testing::TestConsole};
382///
383/// #[test]
384/// fn test_styled_output() {
385///     let console = TestConsole::new_rich();
386///     console.console().print("[bold]Hello![/]");
387///
388///     assert_raw_snapshot!("hello_styled", console);
389/// }
390/// ```
391#[macro_export]
392macro_rules! assert_raw_snapshot {
393    ($name:expr, $console:expr) => {
394        $crate::testing::SnapshotTest::new($name).assert_raw_snapshot(&$console)
395    };
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use tempfile::tempdir;
402
403    #[test]
404    fn test_snapshot_path() {
405        let snap = SnapshotTest::new("my_test");
406        let path = snap.snapshot_path();
407        assert!(path.ends_with("my_test.txt"));
408    }
409
410    #[test]
411    fn test_snapshot_path_raw() {
412        let snap = SnapshotTest::new("my_test");
413        let path = snap.snapshot_path_raw();
414        assert!(path.ends_with("my_test.raw.txt"));
415    }
416
417    #[test]
418    fn test_custom_snapshot_dir() {
419        let snap = SnapshotTest::new("test").with_snapshot_dir("/tmp/custom");
420        assert_eq!(snap.snapshot_dir, PathBuf::from("/tmp/custom"));
421    }
422
423    #[test]
424    fn test_snapshot_creation_and_matching() {
425        let temp_dir = tempdir().expect("Failed to create temp dir");
426
427        let snap = SnapshotTest::new("creation_test")
428            .with_snapshot_dir(temp_dir.path())
429            .with_update_mode(true);
430
431        // Create the snapshot
432        let console = TestConsole::new();
433        console.console().print("Test content for snapshot");
434        snap.assert_snapshot(&console);
435
436        // Verify file was created
437        assert!(snap.snapshot_exists());
438
439        // Now verify it matches on a fresh test (without update mode)
440        let snap2 = SnapshotTest::new("creation_test")
441            .with_snapshot_dir(temp_dir.path())
442            .with_update_mode(false);
443
444        let console2 = TestConsole::new();
445        console2.console().print("Test content for snapshot");
446        snap2.assert_snapshot(&console2); // Should not panic
447    }
448
449    #[test]
450    fn test_snapshot_string_matching() {
451        let temp_dir = tempdir().expect("Failed to create temp dir");
452
453        // Create snapshot
454        let snap = SnapshotTest::new("string_test")
455            .with_snapshot_dir(temp_dir.path())
456            .with_update_mode(true);
457        snap.assert_snapshot_string("Hello, world!");
458
459        // Verify match
460        let snap2 = SnapshotTest::new("string_test")
461            .with_snapshot_dir(temp_dir.path())
462            .with_update_mode(false);
463        snap2.assert_snapshot_string("Hello, world!"); // Should not panic
464    }
465
466    #[test]
467    #[should_panic(expected = "does not match")]
468    fn test_snapshot_mismatch_panics() {
469        let temp_dir = tempdir().expect("Failed to create temp dir");
470
471        // Create snapshot with one value
472        let snap = SnapshotTest::new("mismatch_test")
473            .with_snapshot_dir(temp_dir.path())
474            .with_update_mode(true);
475        snap.assert_snapshot_string("Original content");
476
477        // Try to match with different value
478        let snap2 = SnapshotTest::new("mismatch_test")
479            .with_snapshot_dir(temp_dir.path())
480            .with_update_mode(false);
481        snap2.assert_snapshot_string("Different content"); // Should panic
482    }
483
484    #[test]
485    #[should_panic(expected = "does not exist")]
486    fn test_missing_snapshot_panics() {
487        let temp_dir = tempdir().expect("Failed to create temp dir");
488
489        let snap = SnapshotTest::new("nonexistent")
490            .with_snapshot_dir(temp_dir.path())
491            .with_update_mode(false);
492
493        snap.assert_snapshot_string("Content"); // Should panic
494    }
495
496    #[test]
497    fn test_raw_snapshot() {
498        let temp_dir = tempdir().expect("Failed to create temp dir");
499
500        // Create raw snapshot
501        let snap = SnapshotTest::new("raw_test")
502            .with_snapshot_dir(temp_dir.path())
503            .with_update_mode(true);
504
505        let console = TestConsole::new_rich();
506        console.console().print("[bold]Styled text[/]");
507        snap.assert_raw_snapshot(&console);
508
509        // Verify raw snapshot exists
510        assert!(snap.raw_snapshot_exists());
511    }
512
513    #[test]
514    fn test_generate_diff() {
515        let snap = SnapshotTest::new("diff_test");
516
517        let expected = "line 1\nline 2\nline 3";
518        let actual = "line 1\nmodified line 2\nline 3";
519
520        let diff = snap.generate_diff(expected, actual);
521
522        assert!(diff.contains("- line 2"));
523        assert!(diff.contains("+ modified line 2"));
524    }
525
526    #[test]
527    fn test_generate_diff_added_lines() {
528        let snap = SnapshotTest::new("diff_test");
529
530        let expected = "line 1";
531        let actual = "line 1\nline 2";
532
533        let diff = snap.generate_diff(expected, actual);
534
535        assert!(diff.contains("+ line 2"));
536    }
537
538    #[test]
539    fn test_generate_diff_removed_lines() {
540        let snap = SnapshotTest::new("diff_test");
541
542        let expected = "line 1\nline 2";
543        let actual = "line 1";
544
545        let diff = snap.generate_diff(expected, actual);
546
547        assert!(diff.contains("- line 2"));
548    }
549
550    #[test]
551    fn test_truncate_for_display() {
552        assert_eq!(truncate_for_display("short", 10), "short");
553        assert_eq!(
554            truncate_for_display("a longer string that needs truncation", 20).len(),
555            17
556        );
557    }
558
559    #[test]
560    fn test_snapshot_exists() {
561        let temp_dir = tempdir().expect("Failed to create temp dir");
562
563        let snap = SnapshotTest::new("exists_test").with_snapshot_dir(temp_dir.path());
564
565        assert!(!snap.snapshot_exists());
566
567        // Create it
568        let snap_create = snap.with_update_mode(true);
569        snap_create.assert_snapshot_string("content");
570
571        // Now it should exist
572        let snap_check = SnapshotTest::new("exists_test").with_snapshot_dir(temp_dir.path());
573        assert!(snap_check.snapshot_exists());
574    }
575
576    #[test]
577    #[should_panic(expected = "Failed to read snapshot file")]
578    fn test_snapshot_read_error_panics() {
579        let temp_dir = tempdir().expect("Failed to create temp dir");
580        let snap = SnapshotTest::new("read_error")
581            .with_snapshot_dir(temp_dir.path())
582            .with_update_mode(false);
583
584        // Make the snapshot path exist as a directory so read_to_string fails.
585        std::fs::create_dir_all(snap.snapshot_path()).expect("failed to create snapshot directory");
586        snap.assert_snapshot_string("content");
587    }
588
589    #[test]
590    #[should_panic(expected = "Failed to create snapshot directory")]
591    fn test_snapshot_create_dir_error_panics() {
592        let temp_dir = tempdir().expect("Failed to create temp dir");
593        let not_a_directory = temp_dir.path().join("not_a_directory");
594        std::fs::write(&not_a_directory, "blocker").expect("failed to create blocker file");
595
596        let snap = SnapshotTest::new("create_dir_error")
597            .with_snapshot_dir(&not_a_directory)
598            .with_update_mode(true);
599        snap.assert_snapshot_string("content");
600    }
601
602    #[test]
603    #[should_panic(expected = "Failed to write snapshot")]
604    fn test_snapshot_write_error_panics() {
605        let temp_dir = tempdir().expect("Failed to create temp dir");
606        let snap = SnapshotTest::new("nested/write_error")
607            .with_snapshot_dir(temp_dir.path())
608            .with_update_mode(true);
609
610        // Parent subdirectory "nested" is intentionally missing.
611        snap.assert_snapshot_string("content");
612    }
613
614    #[test]
615    #[should_panic(expected = "Raw snapshot 'missing_raw' does not exist")]
616    fn test_missing_raw_snapshot_panics() {
617        let temp_dir = tempdir().expect("Failed to create temp dir");
618        let snap = SnapshotTest::new("missing_raw")
619            .with_snapshot_dir(temp_dir.path())
620            .with_update_mode(false);
621        let console = TestConsole::new_rich();
622        console.console().print("raw output");
623        snap.assert_raw_snapshot(&console);
624    }
625
626    #[test]
627    #[should_panic(expected = "Raw snapshot 'raw_mismatch' does not match")]
628    fn test_raw_snapshot_mismatch_panics() {
629        let temp_dir = tempdir().expect("Failed to create temp dir");
630
631        let create = SnapshotTest::new("raw_mismatch")
632            .with_snapshot_dir(temp_dir.path())
633            .with_update_mode(true);
634        let first = TestConsole::new_rich();
635        first.console().print("[bold]first[/]");
636        create.assert_raw_snapshot(&first);
637
638        let verify = SnapshotTest::new("raw_mismatch")
639            .with_snapshot_dir(temp_dir.path())
640            .with_update_mode(false);
641        let second = TestConsole::new_rich();
642        second.console().print("[bold]second[/]");
643        verify.assert_raw_snapshot(&second);
644    }
645
646    #[test]
647    #[should_panic(expected = "Failed to write raw snapshot")]
648    fn test_raw_snapshot_write_error_panics() {
649        let temp_dir = tempdir().expect("Failed to create temp dir");
650        let snap = SnapshotTest::new("nested/raw_write_error")
651            .with_snapshot_dir(temp_dir.path())
652            .with_update_mode(true);
653        let console = TestConsole::new_rich();
654        console.console().print("[bold]raw[/]");
655        snap.assert_raw_snapshot(&console);
656    }
657
658    #[test]
659    fn test_generate_diff_truncates_when_many_differences() {
660        let snap = SnapshotTest::new("diff_truncate_test");
661        let expected = (0..70)
662            .map(|i| format!("expected-{i}"))
663            .collect::<Vec<_>>()
664            .join("\n");
665        let actual = (0..70)
666            .map(|i| format!("actual-{i}"))
667            .collect::<Vec<_>>()
668            .join("\n");
669
670        let diff = snap.generate_diff(&expected, &actual);
671        assert!(diff.contains("more differences truncated"));
672    }
673
674    #[test]
675    fn test_generate_diff_reports_equal_lines_with_different_byte_lengths() {
676        let snap = SnapshotTest::new("diff_length_test");
677
678        // Same line content after line splitting, but different byte lengths.
679        let expected = "same-line\n";
680        let actual = "same-line";
681        let diff = snap.generate_diff(expected, actual);
682
683        assert!(diff.contains("no line differences"));
684        assert!(diff.contains("Byte lengths differ"));
685    }
686}