fastmcp_console/testing/
snapshots.rs1use std::fs;
31use std::path::{Path, PathBuf};
32
33use crate::testing::TestConsole;
34
35pub struct SnapshotTest {
52 name: String,
53 snapshot_dir: PathBuf,
54 update_snapshots: bool,
55}
56
57impl SnapshotTest {
58 #[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 #[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 #[must_use]
100 pub fn with_update_mode(mut self, update: bool) -> Self {
101 self.update_snapshots = update;
102 self
103 }
104
105 pub fn assert_snapshot(&self, console: &TestConsole) {
124 let actual = console.output_string();
125 self.assert_snapshot_string(&actual);
126 }
127
128 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 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 #[must_use]
229 pub fn snapshot_path(&self) -> PathBuf {
230 self.snapshot_dir.join(format!("{}.txt", self.name))
231 }
232
233 #[must_use]
235 pub fn snapshot_path_raw(&self) -> PathBuf {
236 self.snapshot_dir.join(format!("{}.raw.txt", self.name))
237 }
238
239 #[must_use]
241 pub fn snapshot_exists(&self) -> bool {
242 self.snapshot_path().exists()
243 }
244
245 #[must_use]
247 pub fn raw_snapshot_exists(&self) -> bool {
248 self.snapshot_path_raw().exists()
249 }
250
251 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 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 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 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 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
338fn truncate_for_display(s: &str, max_len: usize) -> &str {
340 if s.len() <= max_len {
341 s
342 } else {
343 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#[macro_export]
370macro_rules! assert_snapshot {
371 ($name:expr, $console:expr) => {
372 $crate::testing::SnapshotTest::new($name).assert_snapshot(&$console)
373 };
374}
375
376#[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 let console = TestConsole::new();
433 console.console().print("Test content for snapshot");
434 snap.assert_snapshot(&console);
435
436 assert!(snap.snapshot_exists());
438
439 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); }
448
449 #[test]
450 fn test_snapshot_string_matching() {
451 let temp_dir = tempdir().expect("Failed to create temp dir");
452
453 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 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!"); }
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 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 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"); }
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"); }
495
496 #[test]
497 fn test_raw_snapshot() {
498 let temp_dir = tempdir().expect("Failed to create temp dir");
499
500 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 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 let snap_create = snap.with_update_mode(true);
569 snap_create.assert_snapshot_string("content");
570
571 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 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(¬_a_directory, "blocker").expect("failed to create blocker file");
595
596 let snap = SnapshotTest::new("create_dir_error")
597 .with_snapshot_dir(¬_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 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 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}