// 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 . //! Multi-tab broadcasting integration tests. //! //! Verifies the classic mrxvt "killer feature": keystrokes typed in one tab //! are mirrored to other tabs (or only tagged-group tabs) when broadcasting //! is active. use std::collections::HashMap; use std::time::{Duration, Instant}; use mrxvt::alacritty_terminal::grid::Dimensions; use mrxvt::config::{Config, Profile}; use mrxvt::terminal::manager::{BroadcastTarget, TerminalManager}; fn cat_profile(tag: Option) -> Profile { Profile { command: vec!["cat".into()], cwd: None, tag, env: HashMap::new(), } } fn wait_for_marker( manager: &mut TerminalManager, tab_idx: usize, needle: &str, timeout: Duration, ) -> bool { let deadline = Instant::now() + timeout; while Instant::now() < deadline { let _ = manager.poll_all(); if let Some(tab) = manager.tabs.get(tab_idx) { 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); s.push(grid[point].c); } if s.contains(needle) { return true; } } } std::thread::sleep(Duration::from_millis(20)); } false } #[test] fn broadcast_all_sends_to_every_tab() { let cfg = Config::default(); let mut mgr = TerminalManager::new(&cfg); // Three `cat` tabs. let p = cat_profile(None); let _ = mgr.open_tab(&p, Some("t1".into()), 60, 10); let _ = mgr.open_tab(&p, Some("t2".into()), 60, 10); let _ = mgr.open_tab(&p, Some("t3".into()), 60, 10); assert_eq!(mgr.tabs.len(), 3); // Give cat time to start. std::thread::sleep(Duration::from_millis(150)); // Enable broadcast-all and send a line. mgr.broadcast = BroadcastTarget::All; mgr.route_input(b"broadcast_all_marker\n").unwrap(); // Every tab should receive the marker (cat echoes it back). for i in 0..3 { assert!( wait_for_marker(&mut mgr, i, "broadcast_all_marker", Duration::from_secs(3)), "tab {i} did not receive broadcast_all_marker" ); } } #[test] fn broadcast_active_sends_only_to_focused() { let cfg = Config::default(); let mut mgr = TerminalManager::new(&cfg); let p = cat_profile(None); let _ = mgr.open_tab(&p, Some("t1".into()), 60, 10); let _ = mgr.open_tab(&p, Some("t2".into()), 60, 10); std::thread::sleep(Duration::from_millis(150)); // Active = tab 0 (default). assert_eq!(mgr.active, 0); mgr.route_input(b"only_active_marker\n").unwrap(); assert!( wait_for_marker(&mut mgr, 0, "only_active_marker", Duration::from_secs(3)), "active tab should receive marker" ); // Tab 1 should NOT have the marker. // (Poll tab 1 a few times to give it a chance to receive if it was going to.) std::thread::sleep(Duration::from_millis(300)); let _ = mgr.poll_all(); let has_marker = { let tab = &mgr.tabs[1]; let grid = tab.term.grid(); let cols = grid.columns(); let lines = grid.screen_lines(); let start = -(grid.display_offset() as i32); let mut found = false; 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); s.push(grid[point].c); } if s.contains("only_active_marker") { found = true; break; } } found }; assert!(!has_marker, "inactive tab should not receive active-mode input"); } #[test] fn broadcast_group_sends_to_tagged_tabs_only() { let cfg = Config::default(); let mut mgr = TerminalManager::new(&cfg); // 3 tabs: two tagged "web", one untagged. let p_web = cat_profile(Some("web".into())); let p_other = cat_profile(None); let _ = mgr.open_tab(&p_web, Some("web1".into()), 60, 10); let _ = mgr.open_tab(&p_web, Some("web2".into()), 60, 10); let _ = mgr.open_tab(&p_other, Some("db1".into()), 60, 10); std::thread::sleep(Duration::from_millis(150)); // Broadcast to "web" tag. mgr.broadcast = BroadcastTarget::Group("web".into()); mgr.route_input(b"group_web_marker\n").unwrap(); assert!( wait_for_marker(&mut mgr, 0, "group_web_marker", Duration::from_secs(3)), "web1 should receive marker" ); assert!( wait_for_marker(&mut mgr, 1, "group_web_marker", Duration::from_secs(3)), "web2 should receive marker" ); // The untagged db1 tab should NOT have the marker. std::thread::sleep(Duration::from_millis(300)); let _ = mgr.poll_all(); let has_marker = { let tab = &mgr.tabs[2]; let grid = tab.term.grid(); let cols = grid.columns(); let lines = grid.screen_lines(); let start = -(grid.display_offset() as i32); let mut found = false; 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); s.push(grid[point].c); } if s.contains("group_web_marker") { found = true; break; } } found }; assert!(!has_marker, "untagged tab should not receive group broadcast"); } #[test] fn broadcast_group_falls_back_to_active_when_no_match() { let cfg = Config::default(); let mut mgr = TerminalManager::new(&cfg); let p = cat_profile(None); let _ = mgr.open_tab(&p, Some("t1".into()), 60, 10); std::thread::sleep(Duration::from_millis(150)); // No tab has tag "ghost" — should fall back to active (tab 0). mgr.broadcast = BroadcastTarget::Group("ghost".into()); mgr.route_input(b"fallback_marker\n").unwrap(); assert!( wait_for_marker(&mut mgr, 0, "fallback_marker", Duration::from_secs(3)), "active tab should receive marker when group has no matches" ); } #[test] fn toggle_broadcast_all_round_trips() { let cfg = Config::default(); let mut mgr = TerminalManager::new(&cfg); assert_eq!(mgr.broadcast, BroadcastTarget::Active); mgr.toggle_broadcast_all(); assert_eq!(mgr.broadcast, BroadcastTarget::All); mgr.toggle_broadcast_all(); assert_eq!(mgr.broadcast, BroadcastTarget::Active); } #[test] fn toggle_broadcast_group_cycles() { let cfg = Config::default(); let mut mgr = TerminalManager::new(&cfg); mgr.toggle_broadcast_group("web".into()); assert_eq!(mgr.broadcast, BroadcastTarget::Group("web".into())); // Toggling same tag returns to Active. mgr.toggle_broadcast_group("web".into()); assert_eq!(mgr.broadcast, BroadcastTarget::Active); // Different tag switches. mgr.toggle_broadcast_group("db".into()); assert_eq!(mgr.broadcast, BroadcastTarget::Group("db".into())); } #[test] fn tag_active_overrides_existing_tag() { let cfg = Config::default(); let mut mgr = TerminalManager::new(&cfg); let p = cat_profile(Some("old".into())); let _ = mgr.open_tab(&p, Some("t1".into()), 60, 10); assert_eq!(mgr.active_tab().unwrap().tag.as_deref(), Some("old")); mgr.tag_active("new".into()); assert_eq!(mgr.active_tab().unwrap().tag.as_deref(), Some("new")); }