use std::path::PathBuf;
use std::sync::MutexGuard;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::style::{Color, Modifier};
use crate::animation::AnimationState;
use crate::app::{App, Screen};
use crate::demo;
use crate::demo_flag;
use crate::preferences;
use crate::ui;
const TERM_WIDTH: u16 = 100;
const TERM_HEIGHT: u16 = 30;
struct VisualGuard {
_lock: MutexGuard<'static, ()>,
}
impl Drop for VisualGuard {
fn drop(&mut self) {
demo_flag::disable();
preferences::clear_path_override_for_tests();
}
}
#[must_use]
fn setup() -> VisualGuard {
let lock = preferences::GLOBAL_TEST_IO_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
ui::theme::init_with_mode(1);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let sentinel = std::env::temp_dir().join(format!(
"purple_vistest_nonexistent_{}_{}",
std::process::id(),
nanos,
));
preferences::set_path_override(sentinel);
VisualGuard { _lock: lock }
}
fn golden_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/visual_golden")
}
fn golden_path(name: &str) -> PathBuf {
golden_dir().join(format!("{name}.golden"))
}
fn color_name(c: Color) -> String {
match c {
Color::Reset => "Reset".into(),
Color::Black => "Black".into(),
Color::Red => "Red".into(),
Color::Green => "Green".into(),
Color::Yellow => "Yellow".into(),
Color::Blue => "Blue".into(),
Color::Magenta => "Magenta".into(),
Color::Cyan => "Cyan".into(),
Color::Gray => "Gray".into(),
Color::DarkGray => "DarkGray".into(),
Color::LightRed => "LightRed".into(),
Color::LightGreen => "LightGreen".into(),
Color::LightYellow => "LightYellow".into(),
Color::LightBlue => "LightBlue".into(),
Color::LightMagenta => "LightMagenta".into(),
Color::LightCyan => "LightCyan".into(),
Color::White => "White".into(),
Color::Rgb(r, g, b) => format!("Rgb({r},{g},{b})"),
Color::Indexed(i) => format!("Indexed({i})"),
}
}
fn modifier_name(m: Modifier) -> String {
if m.is_empty() {
return "-".into();
}
let mut parts = Vec::new();
if m.contains(Modifier::BOLD) {
parts.push("BOLD");
}
if m.contains(Modifier::DIM) {
parts.push("DIM");
}
if m.contains(Modifier::ITALIC) {
parts.push("ITALIC");
}
if m.contains(Modifier::UNDERLINED) {
parts.push("UNDERLINED");
}
if m.contains(Modifier::SLOW_BLINK) {
parts.push("SLOW_BLINK");
}
if m.contains(Modifier::RAPID_BLINK) {
parts.push("RAPID_BLINK");
}
if m.contains(Modifier::REVERSED) {
parts.push("REVERSED");
}
if m.contains(Modifier::HIDDEN) {
parts.push("HIDDEN");
}
if m.contains(Modifier::CROSSED_OUT) {
parts.push("CROSSED_OUT");
}
parts.join("|")
}
fn serialize_buffer(buf: &Buffer) -> String {
let mut out = String::new();
let area = buf.area;
for y in 0..area.height {
for x in 0..area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out.push_str("---STYLES---\n");
for y in 0..area.height {
for x in 0..area.width {
let cell = &buf[(x, y)];
let is_default_fg = matches!(cell.fg, Color::Reset);
let is_default_bg = matches!(cell.bg, Color::Reset);
let is_default_mod = cell.modifier.is_empty();
if is_default_fg && is_default_bg && is_default_mod {
continue;
}
out.push_str(&format!(
"({x},{y}) fg={} bg={} mod={}\n",
color_name(cell.fg),
color_name(cell.bg),
modifier_name(cell.modifier),
));
}
}
out
}
fn assert_golden(name: &str, actual: &str) {
let path = golden_path(name);
if std::env::var_os("UPDATE_GOLDEN").is_some() {
std::fs::create_dir_all(golden_dir()).expect("create golden dir");
std::fs::write(&path, actual).expect("write golden");
return;
}
let expected = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"failed to read golden {}: {e}. Run UPDATE_GOLDEN=1 cargo test --bin purple visual_regression to create it.",
path.display()
)
});
if expected != actual {
let actual_path = path.with_extension("actual");
let _ = std::fs::write(&actual_path, actual);
panic!(
"visual regression: {name} differs from baseline.\n golden: {}\n actual: {}\nIf the change is intentional, run ./scripts/update-golden.sh and review the diff.",
path.display(),
actual_path.display(),
);
}
}
fn render_screen(app: &mut App) -> String {
let backend = TestBackend::new(TERM_WIDTH, TERM_HEIGHT);
let mut terminal = Terminal::new(backend).expect("create terminal");
let mut anim = AnimationState::default();
terminal
.draw(|frame| ui::render(frame, app, &mut anim))
.expect("render frame");
let buf = terminal.backend().buffer().clone();
serialize_buffer(&buf)
}
#[test]
fn visual_host_list() {
let _g = setup();
let mut app = demo::build_demo_app();
let actual = render_screen(&mut app);
assert_golden("host_list", &actual);
}
#[test]
fn visual_host_list_search() {
let _g = setup();
let mut app = demo::build_demo_app();
app.start_search_with("aws");
let actual = render_screen(&mut app);
assert_golden("host_list_search", &actual);
}
#[test]
fn visual_host_list_detail_panel() {
let _g = setup();
let mut app = demo::build_demo_app();
app.hosts_state
.set_view_mode(crate::app::ViewMode::Detailed);
let actual = render_screen(&mut app);
assert_golden("host_list_detail_panel", &actual);
}
#[test]
fn visual_host_form_add() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::AddHost;
let actual = render_screen(&mut app);
assert_golden("host_form_add", &actual);
}
#[test]
fn visual_host_form_edit() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::EditHost {
alias: "bastion-ams".to_string(),
};
let actual = render_screen(&mut app);
assert_golden("host_form_edit", &actual);
}
#[test]
fn visual_host_detail() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::HostDetail { index: 0 };
let actual = render_screen(&mut app);
assert_golden("host_detail", &actual);
}
#[test]
fn visual_tunnel_list() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::TunnelList {
alias: "bastion-ams".to_string(),
};
let actual = render_screen(&mut app);
assert_golden("tunnel_list", &actual);
}
#[test]
fn visual_tunnels_overview() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Tunnels;
let actual = render_screen(&mut app);
assert_golden("tunnels_overview", &actual);
}
#[test]
fn visual_tunnels_overview_active() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Tunnels;
demo::seed_tunnel_live_snapshots(&mut app);
app.ui.tunnels_overview_state_mut().select(Some(0));
let actual = render_screen(&mut app);
assert_golden("tunnels_overview_active", &actual);
}
#[test]
fn visual_tunnels_overview_active_tall() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Tunnels;
demo::seed_tunnel_live_snapshots(&mut app);
app.ui.tunnels_overview_state_mut().select(Some(0));
let backend = TestBackend::new(120, 50);
let mut terminal = Terminal::new(backend).expect("create terminal");
let mut anim = AnimationState::default();
terminal
.draw(|frame| ui::render(frame, &mut app, &mut anim))
.expect("render frame");
let buf = terminal.backend().buffer().clone();
let actual = serialize_buffer(&buf);
assert_golden("tunnels_overview_active_tall", &actual);
}
#[test]
fn visual_tunnel_form() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::TunnelForm {
alias: "bastion-ams".to_string(),
editing: None,
};
let actual = render_screen(&mut app);
assert_golden("tunnel_form", &actual);
}
#[test]
fn visual_tunnel_host_picker() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Tunnels;
app.screen = Screen::TunnelHostPicker;
app.ui.tunnel_host_picker_state_mut().select(Some(0));
let actual = render_screen(&mut app);
assert_golden("tunnel_host_picker", &actual);
}
#[test]
fn visual_tunnel_host_picker_filtered() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Tunnels;
app.screen = Screen::TunnelHostPicker;
app.ui.set_tunnel_host_picker_query("db".to_string());
app.ui.tunnel_host_picker_state_mut().select(Some(0));
let actual = render_screen(&mut app);
assert_golden("tunnel_host_picker_filtered", &actual);
}
#[test]
fn visual_container_host_picker() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.screen = Screen::ContainerHostPicker;
app.ui.container_host_picker_state_mut().select(Some(0));
let actual = render_screen(&mut app);
assert_golden("container_host_picker", &actual);
}
#[test]
fn visual_keys_overview() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Keys;
app.keys.list_state_mut().select(Some(0));
let actual = render_screen(&mut app);
assert_golden("keys_overview", &actual);
}
#[test]
fn visual_keys_overview_hardware_key() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Keys;
app.keys.list_state_mut().select(Some(1));
let actual = render_screen(&mut app);
assert_golden("keys_overview_hardware", &actual);
}
#[test]
fn visual_keys_overview_no_vault() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Keys;
app.keys.list_state_mut().select(Some(0));
for host in app.hosts_state.list_mut() {
host.vault_ssh = None;
}
app.vault.clear_cert_cache();
let actual = render_screen(&mut app);
assert_golden("keys_overview_no_vault", &actual);
}
#[test]
fn visual_keys_overview_two_cards() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Keys;
app.keys.list_state_mut().select(Some(0));
let backend = TestBackend::new(200, 40);
let mut terminal = Terminal::new(backend).expect("create terminal");
let mut anim = AnimationState::default();
terminal
.draw(|frame| ui::render(frame, &mut app, &mut anim))
.expect("render frame");
let buf = terminal.backend().buffer().clone();
assert_golden("keys_overview_two_cards", &serialize_buffer(&buf));
}
#[test]
fn visual_keys_overview_many_linked_hosts() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Keys;
app.keys.list_state_mut().select(Some(0));
app.keys.list_mut()[0].fingerprint =
"SHA256:1LayGj+CVIvJfOnQqADAT52DoJHhSa30feF/23wbRuE".to_string();
let synthetic: Vec<String> = (1..=31).map(|i| format!("host-{:02}", i)).collect();
app.keys.list_mut()[0].linked_hosts = synthetic.clone();
for alias in &synthetic {
app.hosts_state
.list_mut()
.push(crate::ssh_config::model::HostEntry {
alias: alias.clone(),
hostname: format!("10.0.{}.{}", alias.len(), alias.len() * 3 % 250),
..Default::default()
});
}
let backend = TestBackend::new(200, 40);
let mut terminal = Terminal::new(backend).expect("create terminal");
let mut anim = AnimationState::default();
terminal
.draw(|frame| ui::render(frame, &mut app, &mut anim))
.expect("render frame");
let buf = terminal.backend().buffer().clone();
assert_golden("keys_overview_many_linked_hosts", &serialize_buffer(&buf));
}
#[test]
fn visual_keys_overview_narrow() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Keys;
app.keys.list_state_mut().select(Some(0));
let backend = TestBackend::new(80, 30);
let mut terminal = Terminal::new(backend).expect("create terminal");
let mut anim = AnimationState::default();
terminal
.draw(|frame| ui::render(frame, &mut app, &mut anim))
.expect("render frame");
let buf = terminal.backend().buffer().clone();
assert_golden("keys_overview_narrow", &serialize_buffer(&buf));
}
#[test]
fn visual_keys_overview_search() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Keys;
app.search.set_query(Some("rsa".to_string()));
app.keys.list_state_mut().select(Some(0));
let actual = render_screen(&mut app);
assert_golden("keys_overview_search", &actual);
}
#[test]
fn visual_keys_overview_search_no_match() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Keys;
app.search.set_query(Some("xyzzy".to_string()));
app.keys.list_state_mut().select(None);
let actual = render_screen(&mut app);
assert_golden("keys_overview_search_no_match", &actual);
}
#[test]
fn visual_keys_push_picker() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Keys;
app.keys.list_state_mut().select(Some(0));
app.screen = Screen::KeyPushPicker { key_index: 0 };
app.keys.push_mut().list_state.select(Some(0));
let actual = render_screen(&mut app);
assert_golden("keys_push_picker", &actual);
}
#[test]
fn visual_keys_push_picker_selected() {
let _g = setup();
let mut app = demo::build_demo_app();
assert_eq!(
app.hosts_state.list().len(),
31,
"demo host count drifted; update this assertion and regenerate the golden"
);
app.top_page = crate::app::TopPage::Keys;
app.keys.list_state_mut().select(Some(0));
app.screen = Screen::KeyPushPicker { key_index: 0 };
app.keys.push_mut().list_state.select(Some(0));
let to_select: Vec<String> = app
.hosts_state
.list()
.iter()
.filter(|h| h.vault_ssh.is_none())
.take(2)
.map(|h| h.alias.clone())
.collect();
for a in to_select {
app.keys.push_mut().selected.insert(a);
}
let actual = render_screen(&mut app);
assert_golden("keys_push_picker_selected", &actual);
}
#[test]
fn visual_keys_push_confirm() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Keys;
let aliases: Vec<String> = app
.hosts_state
.list()
.iter()
.filter(|h| h.vault_ssh.is_none())
.take(3)
.map(|h| h.alias.clone())
.collect();
app.keys.push_mut().committed = aliases;
app.screen = Screen::ConfirmKeyPush { key_index: 0 };
let actual = render_screen(&mut app);
assert_golden("keys_push_confirm", &actual);
}
#[test]
fn visual_keys_overview_empty() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Keys;
app.keys.list_mut().clear();
app.keys.list_state_mut().select(None);
let actual = render_screen(&mut app);
assert_golden("keys_overview_empty", &actual);
}
#[test]
fn visual_host_list_empty() {
let _g = setup();
let mut app = demo::build_demo_app();
app.hosts_state.list_mut().clear();
app.hosts_state.patterns_mut().clear();
app.hosts_state.display_list_mut().clear();
app.hosts_state.ssh_config_mut().elements = Vec::new();
app.tunnels.clear_active();
app.tunnels.demo_live_snapshots_mut().clear();
let actual = render_screen(&mut app);
assert_golden("host_list_empty", &actual);
}
#[test]
fn visual_containers_overview_empty() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.container_state.clear_cache();
let actual = render_screen(&mut app);
assert_golden("containers_overview_empty", &actual);
}
#[test]
fn visual_tunnels_overview_empty() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Tunnels;
app.hosts_state.ssh_config_mut().elements = Vec::new();
app.hosts_state.list_mut().clear();
app.tunnels.clear_active();
app.tunnels.demo_live_snapshots_mut().clear();
let actual = render_screen(&mut app);
assert_golden("tunnels_overview_empty", &actual);
}
#[test]
fn visual_containers_overview() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.ui.containers_overview_state_mut().select(Some(0));
let actual = render_screen(&mut app);
assert_golden("containers_overview", &actual);
}
#[test]
fn visual_containers_overview_alpha_container_mode() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.containers_overview.sort_mode = crate::app::ContainersSortMode::AlphaContainer;
app.ui.containers_overview_state_mut().select(Some(0));
let actual = render_screen(&mut app);
assert_golden("containers_overview_alpha_container", &actual);
}
#[test]
fn visual_containers_overview_with_detail() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
let items = crate::ui::containers_overview::visible_items(&app);
let first_container = items
.iter()
.position(|i| i.as_container().is_some())
.expect("demo cache has at least one container");
app.ui
.containers_overview_state_mut()
.select(Some(first_container));
let backend = TestBackend::new(200, 60);
let mut terminal = Terminal::new(backend).expect("create terminal");
let mut anim = AnimationState::default();
terminal
.draw(|frame| ui::render(frame, &mut app, &mut anim))
.expect("render frame");
let buf = terminal.backend().buffer().clone();
let actual = serialize_buffer(&buf);
assert_golden("containers_overview_with_detail", &actual);
}
#[test]
fn visual_containers_overview_podman_host_detail() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
let items = crate::ui::containers_overview::visible_items(&app);
let podman_header = items
.iter()
.position(|i| match i {
crate::ui::containers_overview::ContainerListItem::HostHeader { alias, .. } => {
alias == "podman-edge"
}
_ => false,
})
.expect("podman-edge header in demo");
app.ui
.containers_overview_state_mut()
.select(Some(podman_header));
let backend = TestBackend::new(200, 60);
let mut terminal = Terminal::new(backend).expect("create terminal");
let mut anim = AnimationState::default();
terminal
.draw(|frame| ui::render(frame, &mut app, &mut anim))
.expect("render frame");
let buf = terminal.backend().buffer().clone();
let actual = serialize_buffer(&buf);
assert_golden("containers_overview_podman_host_detail", &actual);
}
#[test]
fn visual_containers_overview_podman_container_detail() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
let items = crate::ui::containers_overview::visible_items(&app);
let caddy_row = items
.iter()
.position(|i| match i.as_container() {
Some(row) => row.alias == "podman-edge" && row.name == "caddy",
None => false,
})
.expect("podman-edge/caddy in demo");
app.ui
.containers_overview_state_mut()
.select(Some(caddy_row));
let backend = TestBackend::new(200, 60);
let mut terminal = Terminal::new(backend).expect("create terminal");
let mut anim = AnimationState::default();
terminal
.draw(|frame| ui::render(frame, &mut app, &mut anim))
.expect("render frame");
let buf = terminal.backend().buffer().clone();
let actual = serialize_buffer(&buf);
assert_golden("containers_overview_podman_container_detail", &actual);
}
#[test]
fn visual_containers_overview_host_detail() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
let items = crate::ui::containers_overview::visible_items(&app);
let first_header = items
.iter()
.position(|i| i.is_header())
.expect("AlphaHost mode emits a header before the first container");
app.ui
.containers_overview_state_mut()
.select(Some(first_header));
let backend = TestBackend::new(200, 60);
let mut terminal = Terminal::new(backend).expect("create terminal");
let mut anim = AnimationState::default();
terminal
.draw(|frame| ui::render(frame, &mut app, &mut anim))
.expect("render frame");
let buf = terminal.backend().buffer().clone();
let actual = serialize_buffer(&buf);
assert_golden("containers_overview_host_detail", &actual);
}
#[test]
fn visual_container_logs() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.screen = Screen::ContainerLogs {
alias: "aws-api-staging".to_string(),
container_id: "f9a0b1c2d3e4".to_string(),
container_name: "api".to_string(),
body: vec![
"2026-05-09 19:41:58 10.0.0.42 GET /api/v1/health 200 17ms".to_string(),
"2026-05-09 19:42:01 198.51.100.7 POST /webhooks/github 202 41ms".to_string(),
"2026-05-09 19:42:05 upstream timed out (110: Operation timed out)".to_string(),
"2026-05-09 19:42:11 10.0.0.42 GET /api/v1/health 200 16ms".to_string(),
],
fetched_at: crate::demo_flag::now_secs() - 3,
error: None,
scroll: 0,
last_render_height: 0,
search: None,
};
let actual = render_screen(&mut app);
assert_golden("container_logs", &actual);
}
#[test]
fn visual_container_logs_search_active() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
let body = vec![
"2026-05-09 19:41:58 10.0.0.42 GET /api/v1/health 200 17ms".to_string(),
"2026-05-09 19:42:01 198.51.100.7 POST /webhooks/github 202 41ms".to_string(),
"2026-05-09 19:42:05 upstream timed out (110: Operation timed out)".to_string(),
"2026-05-09 19:42:11 10.0.0.42 GET /api/v1/health 200 16ms".to_string(),
];
let matches: Vec<usize> = body
.iter()
.enumerate()
.filter_map(|(idx, line)| {
if crate::handler::container_logs::matches_line(line, "api") {
Some(idx)
} else {
None
}
})
.collect();
app.screen = Screen::ContainerLogs {
alias: "aws-api-staging".to_string(),
container_id: "f9a0b1c2d3e4".to_string(),
container_name: "api".to_string(),
body,
fetched_at: crate::demo_flag::now_secs() - 3,
error: None,
scroll: 0,
last_render_height: 0,
search: Some(crate::app::ContainerLogsSearch {
query: "api".to_string(),
matches,
current: 0,
cursor_pos: 3,
}),
};
let actual = render_screen(&mut app);
assert_golden("container_logs_search_active", &actual);
}
#[test]
fn visual_confirm_container_restart() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.screen = Screen::ConfirmContainerRestart {
alias: "aws-api-staging".to_string(),
container_id: "f9a0b1c2d3e4".to_string(),
container_name: "api".to_string(),
project: Some("aws-api-staging".to_string()),
uptime: Some("2d".to_string()),
};
let actual = render_screen(&mut app);
assert_golden("confirm_container_restart", &actual);
}
#[test]
fn visual_confirm_container_stop() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.screen = Screen::ConfirmContainerStop {
alias: "aws-api-staging".to_string(),
container_id: "f9a0b1c2d3e4".to_string(),
container_name: "api".to_string(),
project: Some("aws-api-staging".to_string()),
uptime: Some("2d".to_string()),
};
let actual = render_screen(&mut app);
assert_golden("confirm_container_stop", &actual);
}
#[test]
fn visual_confirm_stack_restart() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.screen = Screen::ConfirmStackRestart {
alias: "aws-api-staging".to_string(),
project: "aws-api-staging".to_string(),
members: vec![
crate::app::StackMember {
container_id: "f9a0b1c2d3e4".to_string(),
container_name: "api".to_string(),
uptime: Some("2d".to_string()),
},
crate::app::StackMember {
container_id: "a1b2c3d4e5f6".to_string(),
container_name: "datadog-agent".to_string(),
uptime: Some("2d".to_string()),
},
crate::app::StackMember {
container_id: "11223344aabb".to_string(),
container_name: "nginx".to_string(),
uptime: Some("2d".to_string()),
},
],
};
let actual = render_screen(&mut app);
assert_golden("confirm_stack_restart", &actual);
}
#[test]
fn visual_confirm_host_restart_all() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.screen = Screen::ConfirmHostRestartAll {
alias: "aws-api-staging".to_string(),
members: vec![
crate::app::StackMember {
container_id: "f9a0b1c2d3e4".to_string(),
container_name: "api".to_string(),
uptime: Some("2d".to_string()),
},
crate::app::StackMember {
container_id: "a1b2c3d4e5f6".to_string(),
container_name: "datadog-agent".to_string(),
uptime: Some("2d".to_string()),
},
crate::app::StackMember {
container_id: "11223344aabb".to_string(),
container_name: "nginx".to_string(),
uptime: Some("2d".to_string()),
},
],
};
let actual = render_screen(&mut app);
assert_golden("confirm_host_restart_all", &actual);
}
#[test]
fn visual_confirm_host_stop_all() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.screen = Screen::ConfirmHostStopAll {
alias: "aws-api-staging".to_string(),
members: vec![
crate::app::StackMember {
container_id: "f9a0b1c2d3e4".to_string(),
container_name: "api".to_string(),
uptime: Some("2d".to_string()),
},
crate::app::StackMember {
container_id: "a1b2c3d4e5f6".to_string(),
container_name: "datadog-agent".to_string(),
uptime: Some("2d".to_string()),
},
],
};
let actual = render_screen(&mut app);
assert_golden("confirm_host_stop_all", &actual);
}
#[test]
fn visual_containers_overview_compact() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.containers_overview.view_mode = crate::app::ViewMode::Compact;
app.ui.containers_overview_state_mut().select(Some(0));
let actual = render_screen(&mut app);
assert_golden("containers_overview_compact", &actual);
}
#[test]
fn visual_containers_overview_collapsed_group() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.containers_overview
.collapsed_hosts
.insert("aws-api-staging".to_string());
app.ui.containers_overview_state_mut().select(Some(0));
let actual = render_screen(&mut app);
assert_golden("containers_overview_collapsed_group", &actual);
}
#[test]
fn visual_containers_overview_paused() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
if let Some(entry) = app.container_state.cache_entry_mut("bastion-ams") {
if let Some(first) = entry.containers.first_mut() {
first.state = "paused".to_string();
first.status = "Paused".to_string();
}
}
app.ui.containers_overview_state_mut().select(Some(0));
let actual = render_screen(&mut app);
assert_golden("containers_overview_paused", &actual);
}
#[test]
fn visual_containers_overview_restarting() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
if let Some(entry) = app.container_state.cache_entry_mut("db-primary") {
if let Some(first) = entry.containers.first_mut() {
first.state = "restarting".to_string();
first.status = "Restarting (1) 2 seconds ago".to_string();
}
}
app.ui.containers_overview_state_mut().select(Some(0));
let actual = render_screen(&mut app);
assert_golden("containers_overview_restarting", &actual);
}
#[test]
fn visual_container_exec_prompt() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.screen = Screen::ContainerExecPrompt {
alias: "aws-api-staging".to_string(),
container_id: "f9a0b1c2d3e4".to_string(),
container_name: "api".to_string(),
query: "tail -n 50 /var/log/app.log".to_string(),
};
let actual = render_screen(&mut app);
assert_golden("container_exec_prompt", &actual);
}
#[test]
fn visual_tunnels_overview_delete_confirm() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Tunnels;
app.ui.tunnels_overview_state_mut().select(Some(0));
app.tunnels.request_delete(0);
let actual = render_screen(&mut app);
assert_golden("tunnels_overview_delete_confirm", &actual);
}
#[test]
fn visual_key_list() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::KeyList;
let actual = render_screen(&mut app);
assert_golden("key_list", &actual);
}
#[test]
fn visual_key_detail() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::KeyDetail { index: 0 };
let actual = render_screen(&mut app);
assert_golden("key_detail", &actual);
}
#[test]
fn visual_help() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::Help {
return_screen: Box::new(Screen::HostList),
};
let actual = render_screen(&mut app);
assert_golden("help", &actual);
}
#[test]
fn visual_confirm_delete() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::ConfirmDelete {
alias: "bastion-ams".to_string(),
};
let actual = render_screen(&mut app);
assert_golden("confirm_delete", &actual);
}
#[test]
fn visual_snippet_picker() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::SnippetPicker {
target_aliases: vec!["bastion-ams".to_string()],
};
let actual = render_screen(&mut app);
assert_golden("snippet_picker", &actual);
}
#[test]
fn visual_snippet_form() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::SnippetForm {
target_aliases: vec!["bastion-ams".to_string()],
editing: None,
};
let actual = render_screen(&mut app);
assert_golden("snippet_form", &actual);
}
#[test]
fn visual_snippet_output() {
let _g = setup();
let mut app = demo::build_demo_app();
app.snippets
.set_output(Some(crate::app::SnippetOutputState {
run_id: 1,
results: vec![crate::app::SnippetHostOutput {
alias: "bastion-ams".to_string(),
stdout: "load average: 0.12 0.18 0.21\n".to_string(),
stderr: String::new(),
exit_code: Some(0),
}],
scroll_offset: 0,
completed: 1,
total: 1,
all_done: true,
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
}));
app.screen = Screen::SnippetOutput {
snippet_name: "uptime".to_string(),
target_aliases: vec!["bastion-ams".to_string()],
};
let actual = render_screen(&mut app);
assert_golden("snippet_output", &actual);
}
#[test]
fn visual_snippet_param_form() {
let _g = setup();
let mut app = demo::build_demo_app();
let snippet = crate::snippet::Snippet {
name: "uptime".to_string(),
command: "uptime".to_string(),
description: "Server uptime and load".to_string(),
};
let params: Vec<crate::snippet::SnippetParam> = Vec::new();
app.snippets
.set_param_form(Some(crate::app::SnippetParamFormState::new(¶ms)));
app.screen = Screen::SnippetParamForm {
snippet,
target_aliases: vec!["bastion-ams".to_string()],
};
let actual = render_screen(&mut app);
assert_golden("snippet_param_form", &actual);
}
#[test]
fn visual_tag_picker() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::TagPicker;
let actual = render_screen(&mut app);
assert_golden("tag_picker", &actual);
}
#[test]
fn visual_theme_picker() {
let _g = setup();
let mut app = demo::build_demo_app();
app.ui.theme_picker_mut().builtins = ui::theme::ThemeDef::builtins();
app.ui.theme_picker_mut().custom = Vec::new();
app.ui.theme_picker_mut().saved_name = "Purple".to_string();
app.ui.theme_picker_mut().list.select(Some(0));
app.screen = Screen::ThemePicker;
let actual = render_screen(&mut app);
assert_golden("theme_picker", &actual);
}
#[test]
fn visual_containers() {
let _g = setup();
let mut app = demo::build_demo_app();
let alias = "bastion-ams".to_string();
let cached = app
.container_state
.cache_entry(&alias)
.map(|c| c.containers.clone())
.unwrap_or_default();
app.container_session = Some(crate::app::ContainerSession {
alias: alias.clone(),
askpass: None,
runtime: Some(crate::containers::ContainerRuntime::Docker),
containers: cached,
list_state: ratatui::widgets::ListState::default(),
loading: false,
error: None,
action_in_progress: None,
confirm_action: None,
});
app.screen = Screen::Containers { alias };
let actual = render_screen(&mut app);
assert_golden("containers", &actual);
}
#[test]
fn visual_file_browser() {
let _g = setup();
let mut app = demo::build_demo_app();
let alias = "bastion-ams".to_string();
app.file_browser_session = Some(crate::file_browser::FileBrowserSession {
alias: alias.clone(),
askpass: None,
active_pane: crate::file_browser::BrowserPane::Local,
local_path: std::path::PathBuf::from("/demo"),
local_entries: Vec::new(),
local_list_state: ratatui::widgets::ListState::default(),
local_selected: std::collections::HashSet::new(),
local_error: None,
remote_path: String::new(),
remote_entries: Vec::new(),
remote_list_state: ratatui::widgets::ListState::default(),
remote_selected: std::collections::HashSet::new(),
remote_error: None,
remote_loading: true,
show_hidden: false,
sort: crate::file_browser::BrowserSort::Name,
confirm_copy: None,
transferring: None,
transfer_error: None,
connection_recorded: true,
});
app.screen = Screen::FileBrowser { alias };
let actual = render_screen(&mut app);
assert_golden("file_browser", &actual);
}
#[test]
fn visual_jump() {
let _g = setup();
let mut app = demo::build_demo_app();
app.jump = Some(crate::app::JumpState::default());
app.recompute_jump_hits();
let actual = render_screen(&mut app);
assert_golden("jump", &actual);
}
#[test]
fn visual_jump_query() {
let _g = setup();
let mut app = demo::build_demo_app();
app.jump = Some(crate::app::JumpState::default());
if let Some(p) = app.jump.as_mut() {
for c in "files".chars() {
p.push_query(c);
}
}
app.recompute_jump_hits();
let actual = render_screen(&mut app);
assert_golden("jump_query", &actual);
}
#[test]
fn visual_jump_no_results() {
let _g = setup();
let mut app = demo::build_demo_app();
app.jump = Some(crate::app::JumpState::default());
if let Some(p) = app.jump.as_mut() {
for c in "zzzqqq".chars() {
p.push_query(c);
}
}
app.recompute_jump_hits();
let actual = render_screen(&mut app);
assert_golden("jump_no_results", &actual);
}
#[test]
fn visual_jump_with_recents() {
let _g = setup();
let mut app = demo::build_demo_app();
let mut state = crate::app::JumpState::default();
let n_action = crate::app::JumpAction::all()
.iter()
.find(|a| a.key == 'n')
.copied()
.expect("'n' (What's new) action present");
state.set_recents(vec![
crate::app::JumpHit::Host(crate::app::HostHit {
alias: "bastion-ams".into(),
hostname: "bastion.ams.example".into(),
tags: vec![],
provider: None,
user: String::new(),
identity_file: String::new(),
proxy_jump: String::new(),
vault_ssh: None,
}),
crate::app::JumpHit::Action(n_action),
]);
app.jump = Some(state);
app.recompute_jump_hits();
let actual = render_screen(&mut app);
assert_golden("jump_with_recents", &actual);
}
#[test]
fn visual_jump_over_tunnels() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Tunnels;
app.jump = Some(crate::app::JumpState::for_mode(
crate::app::JumpMode::Tunnels,
));
app.recompute_jump_hits();
let actual = render_screen(&mut app);
assert_golden("jump_over_tunnels", &actual);
}
#[test]
fn visual_jump_over_containers() {
let _g = setup();
let mut app = demo::build_demo_app();
app.top_page = crate::app::TopPage::Containers;
app.jump = Some(crate::app::JumpState::for_mode(
crate::app::JumpMode::Containers,
));
app.recompute_jump_hits();
let actual = render_screen(&mut app);
assert_golden("jump_over_containers", &actual);
}
#[test]
fn visual_bulk_tag_editor() {
let _g = setup();
let mut app = demo::build_demo_app();
app.hosts_state.multi_select_mut().insert(0);
app.hosts_state.multi_select_mut().insert(1);
app.screen = Screen::BulkTagEditor;
let actual = render_screen(&mut app);
assert_golden("bulk_tag_editor", &actual);
}
#[test]
fn visual_provider_list() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::Providers;
let actual = render_screen(&mut app);
assert_golden("provider_list", &actual);
}
#[test]
fn visual_provider_form() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::ProviderForm {
id: crate::providers::config::ProviderConfigId::bare("aws"),
};
let actual = render_screen(&mut app);
assert_golden("provider_form", &actual);
}
#[test]
fn visual_provider_form_label_entry() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::ProviderForm {
id: crate::providers::config::ProviderConfigId::labeled("aws", ""),
};
*app.providers.form_mut() = crate::app::ProviderFormFields {
label: String::new(),
label_entry: true,
url: String::new(),
token: String::new(),
profile: String::new(),
project: String::new(),
compartment: String::new(),
regions: String::new(),
alias_prefix: "aws".to_string(),
user: "root".to_string(),
identity_file: String::new(),
verify_tls: true,
auto_sync: true,
vault_role: String::new(),
vault_addr: String::new(),
focused_field: crate::app::ProviderFormField::Label,
cursor_pos: 0,
expanded: false,
};
let actual = render_screen(&mut app);
assert_golden("provider_form_label_entry", &actual);
}
#[test]
fn visual_provider_label_migration() {
let _g = setup();
let mut app = demo::build_demo_app();
app.providers
.set_pending_label_migration(Some(crate::app::PendingLabelMigration {
provider: "hetzner".to_string(),
existing_label: "default".to_string(),
new_label: String::new(),
focused: crate::app::LabelMigrationField::Existing,
cursor_pos: "default".chars().count(),
}));
app.screen = Screen::ProviderLabelMigration {
provider: "hetzner".to_string(),
};
let actual = render_screen(&mut app);
assert_golden("provider_label_migration", &actual);
}
#[test]
fn visual_confirm_host_key_reset() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::ConfirmHostKeyReset {
alias: "bastion-ams".to_string(),
hostname: "bastion.example.com".to_string(),
known_hosts_path: "/demo/.ssh/known_hosts".to_string(),
askpass: None,
};
let actual = render_screen(&mut app);
assert_golden("confirm_host_key_reset", &actual);
}
#[test]
fn visual_confirm_import() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::ConfirmImport { count: 5 };
let actual = render_screen(&mut app);
assert_golden("confirm_import", &actual);
}
#[test]
fn visual_confirm_purge_stale() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::ConfirmPurgeStale {
aliases: vec!["aws-old-1".to_string(), "aws-old-2".to_string()],
provider: Some("aws".to_string()),
};
let actual = render_screen(&mut app);
assert_golden("confirm_purge_stale", &actual);
}
#[test]
fn visual_confirm_vault_sign() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::ConfirmVaultSign {
signable: Vec::new(),
};
let actual = render_screen(&mut app);
assert_golden("confirm_vault_sign", &actual);
}
#[test]
fn visual_welcome() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::Welcome {
has_backup: true,
host_count: 22,
known_hosts_count: 47,
};
let actual = render_screen(&mut app);
assert_golden("welcome", &actual);
}
#[test]
fn visual_whats_new() {
let _g = setup();
let mut app = demo::build_demo_app();
app.screen = Screen::WhatsNew(crate::app::WhatsNewState::default());
let fixture = std::fs::read_to_string("tests/fixtures/changelog/simple.md").unwrap();
crate::changelog::set_test_override(fixture);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let actual = render_screen(&mut app);
assert_golden("whats_new", &actual);
}));
crate::changelog::clear_test_override();
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
fn select_host_by_alias(app: &mut App, alias: &str) {
use crate::app::HostListItem;
let pos = app.hosts_state.display_list().iter().position(|item| {
if let HostListItem::Host { index } = item {
app.hosts_state
.list()
.get(*index)
.map(|h| h.alias == alias)
.unwrap_or(false)
} else {
false
}
});
app.ui.list_state_mut().select(pos);
}
#[test]
fn visual_host_detail_vault_expired() {
let _g = setup();
let mut app = demo::build_demo_app();
app.vault.insert_cert(
"gateway-vpn".to_string(),
(
std::time::Instant::now(),
crate::vault_ssh::CertStatus::Expired,
None,
),
);
select_host_by_alias(&mut app, "gateway-vpn");
app.hosts_state
.set_view_mode(crate::app::ViewMode::Detailed);
let actual = render_screen(&mut app);
assert_golden("host_detail_vault_expired", &actual);
}
#[test]
fn visual_host_detail_long_proxy_chain() {
let _g = setup();
let mut app = demo::build_demo_app();
if let Some(h) = app
.hosts_state
.list_mut()
.iter_mut()
.find(|h| h.alias == "customer-db-1")
{
h.proxy_jump = "customer-jump,bastion-ams,gateway-vpn".to_string();
}
select_host_by_alias(&mut app, "customer-db-1");
app.hosts_state
.set_view_mode(crate::app::ViewMode::Detailed);
let actual = render_screen(&mut app);
assert_golden("host_detail_long_proxy_chain", &actual);
}
#[test]
fn visual_host_detail_no_provider_tag() {
let _g = setup();
let mut app = demo::build_demo_app();
app.vault.remove_cert("prod-eu2");
select_host_by_alias(&mut app, "prod-eu2");
app.hosts_state
.set_view_mode(crate::app::ViewMode::Detailed);
let actual = render_screen(&mut app);
assert_golden("host_detail_no_provider_tag", &actual);
}
#[test]
fn visual_host_detail_no_containers() {
let _g = setup();
let mut app = demo::build_demo_app();
app.container_state.remove_cache_entry("prod-eu1");
select_host_by_alias(&mut app, "prod-eu1");
app.hosts_state
.set_view_mode(crate::app::ViewMode::Detailed);
let actual = render_screen(&mut app);
assert_golden("host_detail_no_containers", &actual);
}
#[test]
fn visual_host_detail_with_tags() {
let _g = setup();
let mut app = demo::build_demo_app();
select_host_by_alias(&mut app, "aws-api-prod");
app.hosts_state
.set_view_mode(crate::app::ViewMode::Detailed);
let actual = render_screen(&mut app);
assert_golden("host_detail_with_tags", &actual);
}