1#![forbid(unsafe_code)]
2
3pub mod asciicast;
46pub mod determinism;
47pub mod flicker_detection;
48pub mod golden;
49pub mod resize_storm;
50pub mod terminal_model;
51pub mod time_travel;
52pub mod time_travel_inspector;
53pub mod trace_replay;
54
55#[cfg(feature = "pty-capture")]
56pub mod pty_capture;
57
58use std::fmt::Write as FmtWrite;
59use std::path::{Path, PathBuf};
60
61use ftui_core::terminal_capabilities::{TerminalCapabilities, TerminalProfile};
62use ftui_render::buffer::Buffer;
63use ftui_render::cell::{PackedRgba, StyleFlags};
64
65pub use ftui_core::geometry::Rect;
67pub use ftui_render::buffer;
68pub use ftui_render::cell;
69pub use time_travel_inspector::TimeTravelInspector;
70
71pub fn buffer_to_text(buf: &Buffer) -> String {
84 let capacity = (buf.width() as usize + 1) * buf.height() as usize;
85 let mut out = String::with_capacity(capacity);
86
87 for y in 0..buf.height() {
88 if y > 0 {
89 out.push('\n');
90 }
91 for x in 0..buf.width() {
92 let cell = buf.get(x, y).unwrap();
93 if cell.is_continuation() {
94 continue;
95 }
96 if cell.is_empty() {
97 out.push(' ');
98 } else if let Some(c) = cell.content.as_char() {
99 out.push(c);
100 } else {
101 out.push('?');
103 }
104 }
105 }
106 out
107}
108
109pub fn buffer_to_ansi(buf: &Buffer) -> String {
114 let capacity = (buf.width() as usize + 32) * buf.height() as usize;
115 let mut out = String::with_capacity(capacity);
116
117 for y in 0..buf.height() {
118 if y > 0 {
119 out.push('\n');
120 }
121
122 let mut prev_fg = PackedRgba::WHITE; let mut prev_bg = PackedRgba::TRANSPARENT; let mut prev_flags = StyleFlags::empty();
125 let mut style_active = false;
126
127 for x in 0..buf.width() {
128 let cell = buf.get(x, y).unwrap();
129 if cell.is_continuation() {
130 continue;
131 }
132
133 let fg = cell.fg;
134 let bg = cell.bg;
135 let flags = cell.attrs.flags();
136
137 let style_changed = fg != prev_fg || bg != prev_bg || flags != prev_flags;
138
139 if style_changed {
140 let has_style =
141 fg != PackedRgba::WHITE || bg != PackedRgba::TRANSPARENT || !flags.is_empty();
142
143 if has_style {
144 if style_active {
146 out.push_str("\x1b[0m");
147 }
148
149 let mut params: Vec<String> = Vec::new();
150 if !flags.is_empty() {
151 if flags.contains(StyleFlags::BOLD) {
152 params.push("1".into());
153 }
154 if flags.contains(StyleFlags::DIM) {
155 params.push("2".into());
156 }
157 if flags.contains(StyleFlags::ITALIC) {
158 params.push("3".into());
159 }
160 if flags.contains(StyleFlags::UNDERLINE) {
161 params.push("4".into());
162 }
163 if flags.contains(StyleFlags::BLINK) {
164 params.push("5".into());
165 }
166 if flags.contains(StyleFlags::REVERSE) {
167 params.push("7".into());
168 }
169 if flags.contains(StyleFlags::HIDDEN) {
170 params.push("8".into());
171 }
172 if flags.contains(StyleFlags::STRIKETHROUGH) {
173 params.push("9".into());
174 }
175 }
176 if fg.a() > 0 && fg != PackedRgba::WHITE {
177 params.push(format!("38;2;{};{};{}", fg.r(), fg.g(), fg.b()));
178 }
179 if bg.a() > 0 && bg != PackedRgba::TRANSPARENT {
180 params.push(format!("48;2;{};{};{}", bg.r(), bg.g(), bg.b()));
181 }
182
183 if !params.is_empty() {
184 write!(out, "\x1b[{}m", params.join(";")).unwrap();
185 style_active = true;
186 }
187 } else if style_active {
188 out.push_str("\x1b[0m");
189 style_active = false;
190 }
191
192 prev_fg = fg;
193 prev_bg = bg;
194 prev_flags = flags;
195 }
196
197 if cell.is_empty() {
198 out.push(' ');
199 } else if let Some(c) = cell.content.as_char() {
200 out.push(c);
201 } else {
202 out.push('?');
203 }
204 }
205
206 if style_active {
207 out.push_str("\x1b[0m");
208 }
209 }
210 out
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum MatchMode {
220 Exact,
222 TrimTrailing,
224 Fuzzy,
226}
227
228fn normalize(text: &str, mode: MatchMode) -> String {
230 match mode {
231 MatchMode::Exact => text.to_string(),
232 MatchMode::TrimTrailing => text
233 .lines()
234 .map(|l| l.trim_end())
235 .collect::<Vec<_>>()
236 .join("\n"),
237 MatchMode::Fuzzy => text
238 .lines()
239 .map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
240 .collect::<Vec<_>>()
241 .join("\n"),
242 }
243}
244
245pub fn diff_text(expected: &str, actual: &str) -> String {
258 let expected_lines: Vec<&str> = expected.lines().collect();
259 let actual_lines: Vec<&str> = actual.lines().collect();
260
261 let max_lines = expected_lines.len().max(actual_lines.len());
262 let mut out = String::new();
263 let mut has_diff = false;
264
265 for i in 0..max_lines {
266 let exp = expected_lines.get(i).copied();
267 let act = actual_lines.get(i).copied();
268
269 match (exp, act) {
270 (Some(e), Some(a)) if e == a => {
271 writeln!(out, " {e}").unwrap();
272 }
273 (Some(e), Some(a)) => {
274 writeln!(out, "-{e}").unwrap();
275 writeln!(out, "+{a}").unwrap();
276 has_diff = true;
277 }
278 (Some(e), None) => {
279 writeln!(out, "-{e}").unwrap();
280 has_diff = true;
281 }
282 (None, Some(a)) => {
283 writeln!(out, "+{a}").unwrap();
284 has_diff = true;
285 }
286 (None, None) => {}
287 }
288 }
289
290 if has_diff { out } else { String::new() }
291}
292
293#[must_use]
301pub fn current_test_profile() -> Option<TerminalProfile> {
302 std::env::var("FTUI_TEST_PROFILE")
303 .ok()
304 .and_then(|value| value.parse::<TerminalProfile>().ok())
305 .and_then(|profile| {
306 if profile == TerminalProfile::Detected {
307 None
308 } else {
309 Some(profile)
310 }
311 })
312}
313
314fn snapshot_name_with_profile(name: &str) -> String {
315 if let Some(profile) = current_test_profile() {
316 let suffix = format!("__{}", profile.as_str());
317 if name.ends_with(&suffix) {
318 return name.to_string();
319 }
320 return format!("{name}{suffix}");
321 }
322 name.to_string()
323}
324
325fn snapshot_path(base_dir: &Path, name: &str) -> PathBuf {
327 let resolved_name = snapshot_name_with_profile(name);
328 base_dir
329 .join("tests")
330 .join("snapshots")
331 .join(format!("{resolved_name}.snap"))
332}
333
334fn is_bless() -> bool {
336 std::env::var("BLESS").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
337}
338
339pub fn assert_buffer_snapshot(name: &str, buf: &Buffer, base_dir: &str, mode: MatchMode) {
361 let base = Path::new(base_dir);
362 let path = snapshot_path(base, name);
363 let actual = buffer_to_text(buf);
364
365 if is_bless() {
366 if let Some(parent) = path.parent() {
367 std::fs::create_dir_all(parent).expect("failed to create snapshot directory");
368 }
369 std::fs::write(&path, &actual).expect("failed to write snapshot");
370 return;
371 }
372
373 match std::fs::read_to_string(&path) {
374 Ok(expected) => {
375 let norm_expected = normalize(&expected, mode);
376 let norm_actual = normalize(&actual, mode);
377
378 if norm_expected != norm_actual {
379 let diff = diff_text(&norm_expected, &norm_actual);
380 std::panic::panic_any(format!(
381 "\n\
383 === Snapshot mismatch: '{name}' ===\n\
384 File: {}\n\
385 Mode: {mode:?}\n\
386 Set BLESS=1 to update.\n\n\
387 Diff (- expected, + actual):\n{diff}",
388 path.display()
389 ));
390 }
391 }
392 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
393 std::panic::panic_any(format!(
394 "\n\
396 === No snapshot found: '{name}' ===\n\
397 Expected at: {}\n\
398 Run with BLESS=1 to create it.\n\n\
399 Actual output ({w}x{h}):\n{actual}",
400 path.display(),
401 w = buf.width(),
402 h = buf.height(),
403 ));
404 }
405 Err(e) => {
406 std::panic::panic_any(format!(
407 "Failed to read snapshot '{}': {e}",
409 path.display()
410 ));
411 }
412 }
413}
414
415pub fn assert_buffer_snapshot_ansi(name: &str, buf: &Buffer, base_dir: &str) {
420 let base = Path::new(base_dir);
421 let resolved_name = snapshot_name_with_profile(name);
422 let path = base
423 .join("tests")
424 .join("snapshots")
425 .join(format!("{resolved_name}.ansi.snap"));
426 let actual = buffer_to_ansi(buf);
427
428 if is_bless() {
429 if let Some(parent) = path.parent() {
430 std::fs::create_dir_all(parent).expect("failed to create snapshot directory");
431 }
432 std::fs::write(&path, &actual).expect("failed to write snapshot");
433 return;
434 }
435
436 match std::fs::read_to_string(&path) {
437 Ok(expected) => {
438 if expected != actual {
439 let diff = diff_text(&expected, &actual);
440 std::panic::panic_any(format!(
441 "\n\
443 === ANSI snapshot mismatch: '{name}' ===\n\
444 File: {}\n\
445 Set BLESS=1 to update.\n\n\
446 Diff (- expected, + actual):\n{diff}",
447 path.display()
448 ));
449 }
450 }
451 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
452 std::panic::panic_any(format!(
453 "\n\
455 === No ANSI snapshot found: '{resolved_name}' ===\n\
456 Expected at: {}\n\
457 Run with BLESS=1 to create it.\n\n\
458 Actual output:\n{actual}",
459 path.display(),
460 ));
461 }
462 Err(e) => {
463 std::panic::panic_any(format!(
464 "Failed to read snapshot '{}': {e}",
466 path.display()
467 ));
468 }
469 }
470}
471
472#[macro_export]
490macro_rules! assert_snapshot {
491 ($name:expr, $buf:expr) => {
492 $crate::assert_buffer_snapshot(
493 $name,
494 $buf,
495 env!("CARGO_MANIFEST_DIR"),
496 $crate::MatchMode::TrimTrailing,
497 )
498 };
499 ($name:expr, $buf:expr, $mode:expr) => {
500 $crate::assert_buffer_snapshot($name, $buf, env!("CARGO_MANIFEST_DIR"), $mode)
501 };
502}
503
504#[macro_export]
508macro_rules! assert_snapshot_ansi {
509 ($name:expr, $buf:expr) => {
510 $crate::assert_buffer_snapshot_ansi($name, $buf, env!("CARGO_MANIFEST_DIR"))
511 };
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq)]
520pub enum ProfileCompareMode {
521 None,
523 Report,
525 Strict,
527}
528
529impl ProfileCompareMode {
530 #[must_use]
532 pub fn from_env() -> Self {
533 match std::env::var("FTUI_TEST_PROFILE_COMPARE")
534 .ok()
535 .map(|v| v.to_lowercase())
536 .as_deref()
537 {
538 Some("strict") | Some("1") | Some("true") => Self::Strict,
539 Some("report") | Some("log") => Self::Report,
540 _ => Self::None,
541 }
542 }
543}
544
545#[derive(Debug, Clone)]
547pub struct ProfileSnapshot {
548 pub profile: TerminalProfile,
549 pub text: String,
550 pub checksum: String,
551}
552
553pub fn profile_matrix_text<F>(profiles: &[TerminalProfile], mut render: F) -> Vec<ProfileSnapshot>
560where
561 F: FnMut(TerminalProfile, &TerminalCapabilities) -> String,
562{
563 profile_matrix_text_with_options(
564 profiles,
565 ProfileCompareMode::from_env(),
566 MatchMode::TrimTrailing,
567 &mut render,
568 )
569}
570
571pub fn profile_matrix_text_with_options<F>(
573 profiles: &[TerminalProfile],
574 compare: ProfileCompareMode,
575 mode: MatchMode,
576 render: &mut F,
577) -> Vec<ProfileSnapshot>
578where
579 F: FnMut(TerminalProfile, &TerminalCapabilities) -> String,
580{
581 let mut outputs = Vec::with_capacity(profiles.len());
582 for profile in profiles {
583 let caps = TerminalCapabilities::from_profile(*profile);
584 let text = render(*profile, &caps);
585 let checksum = crate::golden::compute_text_checksum(&text);
586 outputs.push(ProfileSnapshot {
587 profile: *profile,
588 text,
589 checksum,
590 });
591 }
592
593 if compare != ProfileCompareMode::None && outputs.len() > 1 {
594 let baseline = normalize(&outputs[0].text, mode);
595 let baseline_profile = outputs[0].profile;
596 for snapshot in outputs.iter().skip(1) {
597 let candidate = normalize(&snapshot.text, mode);
598 if baseline != candidate {
599 let diff = diff_text(&baseline, &candidate);
600 match compare {
601 ProfileCompareMode::Report => {
602 eprintln!(
603 "=== Profile comparison drift: {} vs {} ===\n{diff}",
604 baseline_profile.as_str(),
605 snapshot.profile.as_str()
606 );
607 }
608 ProfileCompareMode::Strict => {
609 std::panic::panic_any(format!(
610 "Profile comparison drift: {} vs {}\n{diff}",
612 baseline_profile.as_str(),
613 snapshot.profile.as_str()
614 ));
615 }
616 ProfileCompareMode::None => {}
617 }
618 }
619 }
620 }
621
622 outputs
623}
624
625#[cfg(test)]
630mod tests {
631 use super::*;
632 use ftui_render::cell::Cell;
633
634 #[test]
635 fn buffer_to_text_empty() {
636 let buf = Buffer::new(5, 2);
637 let text = buffer_to_text(&buf);
638 assert_eq!(text, " \n ");
639 }
640
641 #[test]
642 fn buffer_to_text_simple() {
643 let mut buf = Buffer::new(5, 1);
644 buf.set(0, 0, Cell::from_char('H'));
645 buf.set(1, 0, Cell::from_char('i'));
646 let text = buffer_to_text(&buf);
647 assert_eq!(text, "Hi ");
648 }
649
650 #[test]
651 fn buffer_to_text_multiline() {
652 let mut buf = Buffer::new(3, 2);
653 buf.set(0, 0, Cell::from_char('A'));
654 buf.set(1, 0, Cell::from_char('B'));
655 buf.set(0, 1, Cell::from_char('C'));
656 let text = buffer_to_text(&buf);
657 assert_eq!(text, "AB \nC ");
658 }
659
660 #[test]
661 fn buffer_to_text_wide_char() {
662 let mut buf = Buffer::new(4, 1);
663 buf.set(0, 0, Cell::from_char('中'));
665 buf.set(2, 0, Cell::from_char('!'));
666 let text = buffer_to_text(&buf);
667 assert_eq!(text, "中! ");
669 }
670
671 #[test]
672 fn buffer_to_ansi_no_style() {
673 let mut buf = Buffer::new(3, 1);
674 buf.set(0, 0, Cell::from_char('X'));
675 let ansi = buffer_to_ansi(&buf);
676 assert_eq!(ansi, "X ");
678 }
679
680 #[test]
681 fn buffer_to_ansi_with_style() {
682 let mut buf = Buffer::new(3, 1);
683 let styled = Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0));
684 buf.set(0, 0, styled);
685 let ansi = buffer_to_ansi(&buf);
686 assert!(ansi.contains("\x1b[38;2;255;0;0m"));
688 assert!(ansi.contains('R'));
689 assert!(ansi.contains("\x1b[0m"));
691 }
692
693 #[test]
694 fn diff_text_identical() {
695 let diff = diff_text("hello\nworld", "hello\nworld");
696 assert!(diff.is_empty());
697 }
698
699 #[test]
700 fn diff_text_single_line_change() {
701 let diff = diff_text("hello\nworld", "hello\nearth");
702 assert!(diff.contains("-world"));
703 assert!(diff.contains("+earth"));
704 assert!(diff.contains(" hello"));
705 }
706
707 #[test]
708 fn diff_text_added_lines() {
709 let diff = diff_text("A", "A\nB");
710 assert!(diff.contains("+B"));
711 }
712
713 #[test]
714 fn diff_text_removed_lines() {
715 let diff = diff_text("A\nB", "A");
716 assert!(diff.contains("-B"));
717 }
718
719 #[test]
720 fn normalize_exact() {
721 let text = " hello \n world ";
722 assert_eq!(normalize(text, MatchMode::Exact), text);
723 }
724
725 #[test]
726 fn normalize_trim_trailing() {
727 let text = "hello \n world ";
728 assert_eq!(normalize(text, MatchMode::TrimTrailing), "hello\n world");
729 }
730
731 #[test]
732 fn normalize_fuzzy() {
733 let text = " hello world \n foo bar ";
734 assert_eq!(normalize(text, MatchMode::Fuzzy), "hello world\nfoo bar");
735 }
736
737 #[test]
738 fn snapshot_path_construction() {
739 let p = snapshot_path(Path::new("/crates/my-crate"), "widget_test");
740 assert_eq!(
741 p,
742 PathBuf::from("/crates/my-crate/tests/snapshots/widget_test.snap")
743 );
744 }
745
746 #[test]
747 fn bless_creates_snapshot() {
748 let dir = std::env::temp_dir().join("ftui_harness_test_bless");
749 let _ = std::fs::remove_dir_all(&dir);
750
751 let mut buf = Buffer::new(3, 1);
752 buf.set(0, 0, Cell::from_char('X'));
753
754 let path = snapshot_path(&dir, "bless_test");
756 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
757 let text = buffer_to_text(&buf);
758 std::fs::write(&path, &text).unwrap();
759
760 let stored = std::fs::read_to_string(&path).unwrap();
762 assert_eq!(stored, "X ");
763
764 let _ = std::fs::remove_dir_all(&dir);
765 }
766
767 #[test]
768 fn snapshot_match_succeeds() {
769 let dir = std::env::temp_dir().join("ftui_harness_test_match");
770 let _ = std::fs::remove_dir_all(&dir);
771
772 let mut buf = Buffer::new(5, 1);
773 buf.set(0, 0, Cell::from_char('O'));
774 buf.set(1, 0, Cell::from_char('K'));
775
776 let path = snapshot_path(&dir, "match_test");
778 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
779 std::fs::write(&path, "OK ").unwrap();
780
781 assert_buffer_snapshot("match_test", &buf, dir.to_str().unwrap(), MatchMode::Exact);
783
784 let _ = std::fs::remove_dir_all(&dir);
785 }
786
787 #[test]
788 fn snapshot_trim_trailing_mode() {
789 let dir = std::env::temp_dir().join("ftui_harness_test_trim");
790 let _ = std::fs::remove_dir_all(&dir);
791
792 let mut buf = Buffer::new(5, 1);
793 buf.set(0, 0, Cell::from_char('A'));
794
795 let path = snapshot_path(&dir, "trim_test");
797 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
798 std::fs::write(&path, "A").unwrap();
799
800 assert_buffer_snapshot(
802 "trim_test",
803 &buf,
804 dir.to_str().unwrap(),
805 MatchMode::TrimTrailing,
806 );
807
808 let _ = std::fs::remove_dir_all(&dir);
809 }
810
811 #[test]
812 #[should_panic(expected = "Snapshot mismatch")]
813 fn snapshot_mismatch_panics() {
814 let dir = std::env::temp_dir().join("ftui_harness_test_mismatch");
815 let _ = std::fs::remove_dir_all(&dir);
816
817 let mut buf = Buffer::new(3, 1);
818 buf.set(0, 0, Cell::from_char('X'));
819
820 let path = snapshot_path(&dir, "mismatch_test");
822 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
823 std::fs::write(&path, "Y ").unwrap();
824
825 assert_buffer_snapshot(
826 "mismatch_test",
827 &buf,
828 dir.to_str().unwrap(),
829 MatchMode::Exact,
830 );
831 }
832
833 #[test]
834 #[should_panic(expected = "No snapshot found")]
835 fn missing_snapshot_panics() {
836 let dir = std::env::temp_dir().join("ftui_harness_test_missing");
837 let _ = std::fs::remove_dir_all(&dir);
838
839 let buf = Buffer::new(3, 1);
840 assert_buffer_snapshot("nonexistent", &buf, dir.to_str().unwrap(), MatchMode::Exact);
841 }
842
843 #[test]
844 fn profile_matrix_collects_outputs() {
845 let profiles = [TerminalProfile::Modern, TerminalProfile::Dumb];
846 let outputs = profile_matrix_text_with_options(
847 &profiles,
848 ProfileCompareMode::Report,
849 MatchMode::Exact,
850 &mut |profile, _caps| format!("profile:{}", profile.as_str()),
851 );
852 assert_eq!(outputs.len(), 2);
853 assert!(outputs.iter().all(|o| o.checksum.starts_with("sha256:")));
854 }
855
856 #[test]
857 fn profile_matrix_strict_allows_identical_output() {
858 let profiles = [TerminalProfile::Modern, TerminalProfile::Dumb];
859 let outputs = profile_matrix_text_with_options(
860 &profiles,
861 ProfileCompareMode::Strict,
862 MatchMode::Exact,
863 &mut |_profile, _caps| "same".to_string(),
864 );
865 assert_eq!(outputs.len(), 2);
866 }
867}