rs-mrxvt/tests/integration.rs

193 lines
6.7 KiB
Rust

// SPDX-License-Identifier: GPL-2.0-only
//
// rs-mrxvt — a modernized, distro-agnostic mrxvt-inspired terminal emulator.
//
// Copyright (C) 2024 rs-mrxvt contributors
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, see <https://www.gnu.org/licenses/>.
//! Integration tests: end-to-end PTY + terminal-emulation round-trip.
//!
//! These tests spawn real subprocesses via portable-pty and verify that
//! the alacritty_terminal emulator receives and renders the output. They
//! run on any Linux (and macOS) without any system graphics deps.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use mrxvt::alacritty_terminal::grid::Dimensions;
use mrxvt::config::{Config, Profile};
use mrxvt::terminal::tab::TerminalTab;
fn sh_profile(cmd: &str) -> Profile {
Profile {
command: vec!["sh".into(), "-c".into(), cmd.into()],
cwd: None,
tag: None,
env: HashMap::new(),
}
}
/// Poll a tab until its visible grid contains `needle`, or panic.
fn wait_for(tab: &mut TerminalTab, needle: &str, timeout: Duration) {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let _ = tab.poll_pty();
if grid_contains(tab, needle) {
return;
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("timeout waiting for {needle:?} in terminal grid");
}
/// Walk the visible grid and check for a substring match across each row.
fn grid_contains(tab: &TerminalTab, needle: &str) -> bool {
let grid = tab.term.grid();
let cols = grid.columns();
let lines = grid.screen_lines();
let display_offset = grid.display_offset();
let start = -(display_offset as i32);
for row in 0..lines as i32 {
let mut s = String::with_capacity(cols);
for col in 0..cols {
let point = mrxvt::re_export_point(start + row, col);
let cell = &grid[point];
s.push(cell.c);
}
if s.contains(needle) {
return true;
}
}
false
}
#[test]
fn echo_appears_in_grid() {
let p = sh_profile("echo integration_test_marker_42");
let mut tab = TerminalTab::new("t".into(), &p, "/bin/sh", 60, 10, 1_000).unwrap();
wait_for(&mut tab, "integration_test_marker_42", Duration::from_secs(3));
}
#[test]
fn colored_output_is_parsed() {
// Print "RED" in red, then "PLAIN" in default.
let p = sh_profile("printf '\\033[31mRED\\033[0m PLAIN'");
let mut tab = TerminalTab::new("t".into(), &p, "/bin/sh", 40, 5, 1_000).unwrap();
wait_for(&mut tab, "RED", Duration::from_secs(3));
wait_for(&mut tab, "PLAIN", Duration::from_secs(3));
}
#[test]
fn input_round_trip() {
// Start an interactive `cat` — echo back what we type.
let p = sh_profile("cat");
let mut tab = TerminalTab::new("t".into(), &p, "/bin/sh", 40, 5, 1_000).unwrap();
// Give cat a moment to start.
std::thread::sleep(Duration::from_millis(100));
// Send some input.
tab.write_input(b"hello_round_trip\n").unwrap();
wait_for(&mut tab, "hello_round_trip", Duration::from_secs(3));
}
#[test]
fn resize_preserves_content() {
let p = sh_profile("echo preserve_me; sleep 5");
let mut tab = TerminalTab::new("t".into(), &p, "/bin/sh", 60, 10, 1_000).unwrap();
wait_for(&mut tab, "preserve_me", Duration::from_secs(3));
// Resize to smaller, then larger. Content should still be visible.
tab.resize(40, 5).unwrap();
tab.resize(80, 24).unwrap();
// After resize, the marker may be in scrollback. Switch to grid view
// and just assert the terminal didn't crash.
let (c, r) = tab.size();
assert!(c > 0 && r > 0);
}
#[test]
fn eof_detected_after_child_exits() {
let p = sh_profile("true"); // exits immediately
let mut tab = TerminalTab::new("t".into(), &p, "/bin/sh", 40, 5, 1_000).unwrap();
// Poll until EOF is observed (the reader thread sends an empty-vec sentinel).
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
let _ = tab.poll_pty();
if tab.is_dead() {
return;
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("tab never observed EOF");
}
#[test]
fn ansi_cursor_movement() {
// Print "ABC", move cursor back two, overwrite with "XY" → "AXY"
let p = sh_profile("printf 'ABC\\b\\bXY'");
let mut tab = TerminalTab::new("t".into(), &p, "/bin/sh", 40, 5, 1_000).unwrap();
wait_for(&mut tab, "AXY", Duration::from_secs(3));
}
#[test]
fn large_output_doesnt_crash() {
// Print 1000 lines.
let p = sh_profile("for i in $(seq 1 1000); do echo line_$i; done; sleep 1");
let mut tab = TerminalTab::new("t".into(), &p, "/bin/sh", 80, 24, 5_000).unwrap();
// We just need the last line to appear eventually.
wait_for(&mut tab, "line_1000", Duration::from_secs(10));
}
#[test]
fn tabs_can_be_created_independently() {
let p1 = sh_profile("echo tab_one_marker; sleep 5");
let p2 = sh_profile("echo tab_two_marker; sleep 5");
let mut t1 = TerminalTab::new("t1".into(), &p1, "/bin/sh", 60, 10, 1_000).unwrap();
let mut t2 = TerminalTab::new("t2".into(), &p2, "/bin/sh", 60, 10, 1_000).unwrap();
wait_for(&mut t1, "tab_one_marker", Duration::from_secs(3));
wait_for(&mut t2, "tab_two_marker", Duration::from_secs(3));
// Each tab got its own marker.
assert!(grid_contains(&t1, "tab_one_marker"));
assert!(!grid_contains(&t1, "tab_two_marker"));
assert!(grid_contains(&t2, "tab_two_marker"));
assert!(!grid_contains(&t2, "tab_one_marker"));
}
#[test]
fn config_loads_from_temp_file() {
let toml = r#"
[terminal]
cols = 100
rows = 30
[profiles.default]
command = ["bash"]
"#;
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), toml).unwrap();
let cfg = Config::load(Some(tmp.path())).unwrap();
assert_eq!(cfg.terminal.cols, 100);
assert_eq!(cfg.terminal.rows, 30);
assert_eq!(cfg.profiles["default"].command, vec!["bash".to_string()]);
}
#[test]
fn default_config_is_sane() {
let cfg = Config::default();
assert!(cfg.terminal.cols >= 80);
assert!(!cfg.terminal.shell.is_empty());
assert_eq!(cfg.default_profile, "default");
}