#![forbid(unsafe_code)]
pub mod artifact_manifest;
pub mod asciicast;
pub mod baseline_capture;
pub mod benchmark_gate;
pub mod cost_surface;
pub mod determinism;
pub mod doctor_cost_profile;
pub mod doctor_topology;
pub mod failure_signatures;
pub mod fixture_runner;
pub mod fixture_suite;
pub mod flicker_detection;
pub mod frame_comparison;
pub mod golden;
pub mod hdd;
pub mod hotspot_extraction;
pub mod input_storm;
pub mod lab_integration;
pub mod layout_reuse;
pub mod optimization_policy;
pub mod presenter_equivalence;
pub mod proof_oracle;
pub mod proptest_support;
pub mod render_certificate;
pub mod resize_storm;
pub mod rollout_runbook;
pub mod rollout_scorecard;
pub mod shadow_run;
pub mod terminal_model;
pub mod time_travel;
pub mod time_travel_inspector;
pub mod trace_replay;
pub mod validation_matrix;
#[cfg(feature = "pty-capture")]
pub mod pty_capture;
use std::fmt::Write as FmtWrite;
use std::path::{Path, PathBuf};
use ftui_core::terminal_capabilities::{TerminalCapabilities, TerminalProfile};
use ftui_render::buffer::Buffer;
use ftui_render::cell::{PackedRgba, StyleFlags};
use ftui_render::grapheme_pool::GraphemePool;
pub use determinism::{
DeterminismFixture, JsonValue, LabScenario, LabScenarioContext, LabScenarioResult,
LabScenarioRun, TestJsonlLogger, lab_scenarios_run_total,
};
pub use ftui_core::geometry::Rect;
pub use ftui_render::buffer;
pub use ftui_render::cell;
pub use lab_integration::{
Lab, LabConfig, LabOutput, LabSession, Recording, ReplayResult, assert_outputs_match,
lab_recordings_total, lab_replays_total,
};
pub use time_travel_inspector::TimeTravelInspector;
pub use benchmark_gate::{BenchmarkGate, GateResult, Measurement, MetricVerdict, Threshold};
pub use rollout_scorecard::{
RolloutEvidenceBundle, RolloutScorecard, RolloutScorecardConfig, RolloutSummary, RolloutVerdict,
};
pub use shadow_run::{ShadowRun, ShadowRunConfig, ShadowRunResult, ShadowVerdict};
pub fn buffer_to_text(buf: &Buffer) -> String {
let capacity = (buf.width() as usize + 1) * buf.height() as usize;
let mut out = String::with_capacity(capacity);
for y in 0..buf.height() {
if y > 0 {
out.push('\n');
}
for x in 0..buf.width() {
let cell = buf.get(x, y).unwrap();
if cell.is_continuation() {
continue;
}
if cell.is_empty() {
out.push(' ');
} else if let Some(c) = cell.content.as_char() {
out.push(c);
} else {
let w = cell.content.width();
for _ in 0..w.max(1) {
out.push('?');
}
}
}
}
out
}
pub fn buffer_to_text_with_pool(buf: &Buffer, pool: Option<&GraphemePool>) -> String {
let capacity = (buf.width() as usize + 1) * buf.height() as usize;
let mut out = String::with_capacity(capacity);
for y in 0..buf.height() {
if y > 0 {
out.push('\n');
}
for x in 0..buf.width() {
let cell = buf.get(x, y).unwrap();
if cell.is_continuation() {
continue;
}
if cell.is_empty() {
out.push(' ');
} else if let Some(c) = cell.content.as_char() {
out.push(c);
} else if let (Some(pool), Some(gid)) = (pool, cell.content.grapheme_id()) {
if let Some(text) = pool.get(gid) {
out.push_str(text);
} else {
let w = cell.content.width();
for _ in 0..w.max(1) {
out.push('?');
}
}
} else {
let w = cell.content.width();
for _ in 0..w.max(1) {
out.push('?');
}
}
}
}
out
}
pub fn buffer_to_ansi(buf: &Buffer) -> String {
let capacity = (buf.width() as usize + 32) * buf.height() as usize;
let mut out = String::with_capacity(capacity);
for y in 0..buf.height() {
if y > 0 {
out.push('\n');
}
let mut prev_fg = PackedRgba::WHITE; let mut prev_bg = PackedRgba::TRANSPARENT; let mut prev_flags = StyleFlags::empty();
let mut style_active = false;
for x in 0..buf.width() {
let cell = buf.get(x, y).unwrap();
if cell.is_continuation() {
continue;
}
let fg = cell.fg;
let bg = cell.bg;
let flags = cell.attrs.flags();
let style_changed = fg != prev_fg || bg != prev_bg || flags != prev_flags;
if style_changed {
let has_style =
fg != PackedRgba::WHITE || bg != PackedRgba::TRANSPARENT || !flags.is_empty();
if has_style {
if style_active {
out.push_str("\x1b[0m");
}
let mut params: Vec<String> = Vec::new();
if !flags.is_empty() {
if flags.contains(StyleFlags::BOLD) {
params.push("1".into());
}
if flags.contains(StyleFlags::DIM) {
params.push("2".into());
}
if flags.contains(StyleFlags::ITALIC) {
params.push("3".into());
}
if flags.contains(StyleFlags::UNDERLINE) {
params.push("4".into());
}
if flags.contains(StyleFlags::BLINK) {
params.push("5".into());
}
if flags.contains(StyleFlags::REVERSE) {
params.push("7".into());
}
if flags.contains(StyleFlags::HIDDEN) {
params.push("8".into());
}
if flags.contains(StyleFlags::STRIKETHROUGH) {
params.push("9".into());
}
}
if fg.a() > 0 && fg != PackedRgba::WHITE {
params.push(format!("38;2;{};{};{}", fg.r(), fg.g(), fg.b()));
}
if bg.a() > 0 && bg != PackedRgba::TRANSPARENT {
params.push(format!("48;2;{};{};{}", bg.r(), bg.g(), bg.b()));
}
if !params.is_empty() {
write!(out, "\x1b[{}m", params.join(";")).unwrap();
style_active = true;
}
} else if style_active {
out.push_str("\x1b[0m");
style_active = false;
}
prev_fg = fg;
prev_bg = bg;
prev_flags = flags;
}
if cell.is_empty() {
out.push(' ');
} else if let Some(c) = cell.content.as_char() {
out.push(c);
} else {
let w = cell.content.width();
for _ in 0..w.max(1) {
out.push('?');
}
}
}
if style_active {
out.push_str("\x1b[0m");
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchMode {
Exact,
TrimTrailing,
Fuzzy,
}
fn normalize(text: &str, mode: MatchMode) -> String {
match mode {
MatchMode::Exact => text.to_string(),
MatchMode::TrimTrailing => text
.lines()
.map(|l| l.trim_end())
.collect::<Vec<_>>()
.join("\n"),
MatchMode::Fuzzy => text
.lines()
.map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.join("\n"),
}
}
pub fn diff_text(expected: &str, actual: &str) -> String {
let expected_lines: Vec<&str> = expected.lines().collect();
let actual_lines: Vec<&str> = actual.lines().collect();
let max_lines = expected_lines.len().max(actual_lines.len());
let mut out = String::new();
let mut has_diff = false;
for i in 0..max_lines {
let exp = expected_lines.get(i).copied();
let act = actual_lines.get(i).copied();
match (exp, act) {
(Some(e), Some(a)) if e == a => {
writeln!(out, " {e}").unwrap();
}
(Some(e), Some(a)) => {
writeln!(out, "-{e}").unwrap();
writeln!(out, "+{a}").unwrap();
has_diff = true;
}
(Some(e), None) => {
writeln!(out, "-{e}").unwrap();
has_diff = true;
}
(None, Some(a)) => {
writeln!(out, "+{a}").unwrap();
has_diff = true;
}
(None, None) => {}
}
}
if has_diff { out } else { String::new() }
}
#[must_use]
pub fn current_test_profile() -> Option<TerminalProfile> {
std::env::var("FTUI_TEST_PROFILE")
.ok()
.and_then(|value| value.parse::<TerminalProfile>().ok())
.filter(|profile| *profile != TerminalProfile::Detected)
}
fn snapshot_name_with_profile(name: &str) -> String {
if let Some(profile) = current_test_profile() {
let suffix = format!("__{}", profile.as_str());
if name.ends_with(&suffix) {
return name.to_string();
}
return format!("{name}{suffix}");
}
name.to_string()
}
fn snapshot_path(base_dir: &Path, name: &str) -> PathBuf {
let resolved_name = snapshot_name_with_profile(name);
base_dir
.join("tests")
.join("snapshots")
.join(format!("{resolved_name}.snap"))
}
fn is_bless() -> bool {
std::env::var("BLESS").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
pub fn assert_buffer_snapshot(name: &str, buf: &Buffer, base_dir: &str, mode: MatchMode) {
let base = Path::new(base_dir);
let path = snapshot_path(base, name);
let actual = buffer_to_text(buf);
if is_bless() {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("failed to create snapshot directory");
}
std::fs::write(&path, &actual).expect("failed to write snapshot");
return;
}
match std::fs::read_to_string(&path) {
Ok(expected) => {
let norm_expected = normalize(&expected, mode);
let norm_actual = normalize(&actual, mode);
if norm_expected != norm_actual {
let diff = diff_text(&norm_expected, &norm_actual);
std::panic::panic_any(format!(
"\n\
=== Snapshot mismatch: '{name}' ===\n\
File: {}\n\
Mode: {mode:?}\n\
Set BLESS=1 to update.\n\n\
Diff (- expected, + actual):\n{diff}",
path.display()
));
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
std::panic::panic_any(format!(
"\n\
=== No snapshot found: '{name}' ===\n\
Expected at: {}\n\
Run with BLESS=1 to create it.\n\n\
Actual output ({w}x{h}):\n{actual}",
path.display(),
w = buf.width(),
h = buf.height(),
));
}
Err(e) => {
std::panic::panic_any(format!(
"Failed to read snapshot '{}': {e}",
path.display()
));
}
}
}
pub fn assert_buffer_snapshot_ansi(name: &str, buf: &Buffer, base_dir: &str) {
let base = Path::new(base_dir);
let resolved_name = snapshot_name_with_profile(name);
let path = base
.join("tests")
.join("snapshots")
.join(format!("{resolved_name}.ansi.snap"));
let actual = buffer_to_ansi(buf);
if is_bless() {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("failed to create snapshot directory");
}
std::fs::write(&path, &actual).expect("failed to write snapshot");
return;
}
match std::fs::read_to_string(&path) {
Ok(expected) => {
if expected != actual {
let diff = diff_text(&expected, &actual);
std::panic::panic_any(format!(
"\n\
=== ANSI snapshot mismatch: '{name}' ===\n\
File: {}\n\
Set BLESS=1 to update.\n\n\
Diff (- expected, + actual):\n{diff}",
path.display()
));
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
std::panic::panic_any(format!(
"\n\
=== No ANSI snapshot found: '{resolved_name}' ===\n\
Expected at: {}\n\
Run with BLESS=1 to create it.\n\n\
Actual output:\n{actual}",
path.display(),
));
}
Err(e) => {
std::panic::panic_any(format!(
"Failed to read snapshot '{}': {e}",
path.display()
));
}
}
}
#[macro_export]
macro_rules! assert_snapshot {
($name:expr, $buf:expr) => {
$crate::assert_buffer_snapshot(
$name,
$buf,
env!("CARGO_MANIFEST_DIR"),
$crate::MatchMode::TrimTrailing,
)
};
($name:expr, $buf:expr, $mode:expr) => {
$crate::assert_buffer_snapshot($name, $buf, env!("CARGO_MANIFEST_DIR"), $mode)
};
}
#[macro_export]
macro_rules! assert_snapshot_ansi {
($name:expr, $buf:expr) => {
$crate::assert_buffer_snapshot_ansi($name, $buf, env!("CARGO_MANIFEST_DIR"))
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProfileCompareMode {
None,
Report,
Strict,
}
impl ProfileCompareMode {
#[must_use]
pub fn from_env() -> Self {
match std::env::var("FTUI_TEST_PROFILE_COMPARE")
.ok()
.map(|v| v.to_lowercase())
.as_deref()
{
Some("strict") | Some("1") | Some("true") => Self::Strict,
Some("report") | Some("log") => Self::Report,
_ => Self::None,
}
}
}
#[derive(Debug, Clone)]
pub struct ProfileSnapshot {
pub profile: TerminalProfile,
pub text: String,
pub checksum: String,
}
pub fn profile_matrix_text<F>(profiles: &[TerminalProfile], mut render: F) -> Vec<ProfileSnapshot>
where
F: FnMut(TerminalProfile, &TerminalCapabilities) -> String,
{
profile_matrix_text_with_options(
profiles,
ProfileCompareMode::from_env(),
MatchMode::TrimTrailing,
&mut render,
)
}
pub fn profile_matrix_text_with_options<F>(
profiles: &[TerminalProfile],
compare: ProfileCompareMode,
mode: MatchMode,
render: &mut F,
) -> Vec<ProfileSnapshot>
where
F: FnMut(TerminalProfile, &TerminalCapabilities) -> String,
{
let mut outputs = Vec::with_capacity(profiles.len());
for profile in profiles {
let caps = TerminalCapabilities::from_profile(*profile);
let text = render(*profile, &caps);
let checksum = crate::golden::compute_text_checksum(&text);
outputs.push(ProfileSnapshot {
profile: *profile,
text,
checksum,
});
}
if compare != ProfileCompareMode::None && outputs.len() > 1 {
let baseline = normalize(&outputs[0].text, mode);
let baseline_profile = outputs[0].profile;
for snapshot in outputs.iter().skip(1) {
let candidate = normalize(&snapshot.text, mode);
if baseline != candidate {
let diff = diff_text(&baseline, &candidate);
match compare {
ProfileCompareMode::Report => {
eprintln!(
"=== Profile comparison drift: {} vs {} ===\n{diff}",
baseline_profile.as_str(),
snapshot.profile.as_str()
);
}
ProfileCompareMode::Strict => {
std::panic::panic_any(format!(
"Profile comparison drift: {} vs {}\n{diff}",
baseline_profile.as_str(),
snapshot.profile.as_str()
));
}
ProfileCompareMode::None => {}
}
}
}
}
outputs
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::cell::{Cell, CellContent, GraphemeId};
#[test]
fn buffer_to_text_empty() {
let buf = Buffer::new(5, 2);
let text = buffer_to_text(&buf);
assert_eq!(text, " \n ");
}
#[test]
fn buffer_to_text_simple() {
let mut buf = Buffer::new(5, 1);
buf.set(0, 0, Cell::from_char('H'));
buf.set(1, 0, Cell::from_char('i'));
let text = buffer_to_text(&buf);
assert_eq!(text, "Hi ");
}
#[test]
fn buffer_to_text_multiline() {
let mut buf = Buffer::new(3, 2);
buf.set(0, 0, Cell::from_char('A'));
buf.set(1, 0, Cell::from_char('B'));
buf.set(0, 1, Cell::from_char('C'));
let text = buffer_to_text(&buf);
assert_eq!(text, "AB \nC ");
}
#[test]
fn buffer_to_text_wide_char() {
let mut buf = Buffer::new(4, 1);
buf.set(0, 0, Cell::from_char('中'));
buf.set(2, 0, Cell::from_char('!'));
let text = buffer_to_text(&buf);
assert_eq!(text, "中! ");
}
#[test]
fn buffer_to_text_grapheme_width_correct_placeholder() {
let gid = GraphemeId::new(1, 0, 2); let content = CellContent::from_grapheme(gid);
let mut buf = Buffer::new(6, 1);
buf.set(0, 0, Cell::new(content));
buf.set(2, 0, Cell::from_char('A'));
buf.set(3, 0, Cell::from_char('B'));
let text = buffer_to_text(&buf);
assert_eq!(text, "??AB ");
}
#[test]
fn buffer_to_text_with_pool_resolves_grapheme() {
let mut pool = GraphemePool::new();
let gid = pool.intern("⚙\u{fe0f}", 2);
let content = CellContent::from_grapheme(gid);
let mut buf = Buffer::new(6, 1);
buf.set(0, 0, Cell::new(content));
buf.set(2, 0, Cell::from_char('A'));
let text = buffer_to_text_with_pool(&buf, Some(&pool));
assert_eq!(text, "⚙\u{fe0f}A ");
}
#[test]
fn buffer_to_text_with_pool_none_falls_back() {
let gid = GraphemeId::new(1, 0, 2);
let content = CellContent::from_grapheme(gid);
let mut buf = Buffer::new(4, 1);
buf.set(0, 0, Cell::new(content));
buf.set(2, 0, Cell::from_char('!'));
let text = buffer_to_text_with_pool(&buf, None);
assert_eq!(text, "??! ");
}
#[test]
fn buffer_to_ansi_grapheme_width_correct_placeholder() {
let gid = GraphemeId::new(1, 0, 2);
let content = CellContent::from_grapheme(gid);
let mut buf = Buffer::new(4, 1);
buf.set(0, 0, Cell::new(content));
buf.set(2, 0, Cell::from_char('X'));
let ansi = buffer_to_ansi(&buf);
assert_eq!(ansi, "??X ");
}
#[test]
fn buffer_to_ansi_no_style() {
let mut buf = Buffer::new(3, 1);
buf.set(0, 0, Cell::from_char('X'));
let ansi = buffer_to_ansi(&buf);
assert_eq!(ansi, "X ");
}
#[test]
fn buffer_to_ansi_with_style() {
let mut buf = Buffer::new(3, 1);
let styled = Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0));
buf.set(0, 0, styled);
let ansi = buffer_to_ansi(&buf);
assert!(ansi.contains("\x1b[38;2;255;0;0m"));
assert!(ansi.contains('R'));
assert!(ansi.contains("\x1b[0m"));
}
#[test]
fn diff_text_identical() {
let diff = diff_text("hello\nworld", "hello\nworld");
assert!(diff.is_empty());
}
#[test]
fn diff_text_single_line_change() {
let diff = diff_text("hello\nworld", "hello\nearth");
assert!(diff.contains("-world"));
assert!(diff.contains("+earth"));
assert!(diff.contains(" hello"));
}
#[test]
fn diff_text_added_lines() {
let diff = diff_text("A", "A\nB");
assert!(diff.contains("+B"));
}
#[test]
fn diff_text_removed_lines() {
let diff = diff_text("A\nB", "A");
assert!(diff.contains("-B"));
}
#[test]
fn normalize_exact() {
let text = " hello \n world ";
assert_eq!(normalize(text, MatchMode::Exact), text);
}
#[test]
fn normalize_trim_trailing() {
let text = "hello \n world ";
assert_eq!(normalize(text, MatchMode::TrimTrailing), "hello\n world");
}
#[test]
fn normalize_fuzzy() {
let text = " hello world \n foo bar ";
assert_eq!(normalize(text, MatchMode::Fuzzy), "hello world\nfoo bar");
}
#[test]
fn snapshot_path_construction() {
let p = snapshot_path(Path::new("/crates/my-crate"), "widget_test");
assert_eq!(
p,
PathBuf::from("/crates/my-crate/tests/snapshots/widget_test.snap")
);
}
#[test]
fn bless_creates_snapshot() {
let dir = std::env::temp_dir().join("ftui_harness_test_bless");
let _ = std::fs::remove_dir_all(&dir);
let mut buf = Buffer::new(3, 1);
buf.set(0, 0, Cell::from_char('X'));
let path = snapshot_path(&dir, "bless_test");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let text = buffer_to_text(&buf);
std::fs::write(&path, &text).unwrap();
let stored = std::fs::read_to_string(&path).unwrap();
assert_eq!(stored, "X ");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn snapshot_match_succeeds() {
let dir = std::env::temp_dir().join("ftui_harness_test_match");
let _ = std::fs::remove_dir_all(&dir);
let mut buf = Buffer::new(5, 1);
buf.set(0, 0, Cell::from_char('O'));
buf.set(1, 0, Cell::from_char('K'));
let path = snapshot_path(&dir, "match_test");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "OK ").unwrap();
assert_buffer_snapshot("match_test", &buf, dir.to_str().unwrap(), MatchMode::Exact);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn snapshot_trim_trailing_mode() {
let dir = std::env::temp_dir().join("ftui_harness_test_trim");
let _ = std::fs::remove_dir_all(&dir);
let mut buf = Buffer::new(5, 1);
buf.set(0, 0, Cell::from_char('A'));
let path = snapshot_path(&dir, "trim_test");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "A").unwrap();
assert_buffer_snapshot(
"trim_test",
&buf,
dir.to_str().unwrap(),
MatchMode::TrimTrailing,
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[should_panic(expected = "Snapshot mismatch")]
fn snapshot_mismatch_panics() {
let dir = std::env::temp_dir().join("ftui_harness_test_mismatch");
let _ = std::fs::remove_dir_all(&dir);
let mut buf = Buffer::new(3, 1);
buf.set(0, 0, Cell::from_char('X'));
let path = snapshot_path(&dir, "mismatch_test");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "Y ").unwrap();
assert_buffer_snapshot(
"mismatch_test",
&buf,
dir.to_str().unwrap(),
MatchMode::Exact,
);
}
#[test]
#[should_panic(expected = "No snapshot found")]
fn missing_snapshot_panics() {
let dir = std::env::temp_dir().join("ftui_harness_test_missing");
let _ = std::fs::remove_dir_all(&dir);
let buf = Buffer::new(3, 1);
assert_buffer_snapshot("nonexistent", &buf, dir.to_str().unwrap(), MatchMode::Exact);
}
#[test]
fn profile_matrix_collects_outputs() {
let profiles = [TerminalProfile::Modern, TerminalProfile::Dumb];
let outputs = profile_matrix_text_with_options(
&profiles,
ProfileCompareMode::Report,
MatchMode::Exact,
&mut |profile, _caps| format!("profile:{}", profile.as_str()),
);
assert_eq!(outputs.len(), 2);
assert!(outputs.iter().all(|o| o.checksum.starts_with("blake3:")));
}
#[test]
fn profile_matrix_strict_allows_identical_output() {
let profiles = [TerminalProfile::Modern, TerminalProfile::Dumb];
let outputs = profile_matrix_text_with_options(
&profiles,
ProfileCompareMode::Strict,
MatchMode::Exact,
&mut |_profile, _caps| "same".to_string(),
);
assert_eq!(outputs.len(), 2);
}
}