chore: new test infra (#5269)

* initial implementation

* refactoring and cleanups

* port basic e2e tests

* fix tab rotation

* migrate client and mouse tests

* migrate the rest of the tests

* remove old e2e tests

* rustfmt

* add e2e note

* fix stale test

* fix: cross platform locking/cleanup

* fix: change mode race discovered by tests

* rustfmt

* fix: attach/detach race in tests

* fix plugin test assertion

* add pr
This commit is contained in:
Aram Drevekenin
2026-06-16 15:44:22 +02:00
committed by GitHub
parent b6a5ad0e5a
commit 8c7e41c9aa
87 changed files with 4912 additions and 3270 deletions
+3
View File
@@ -0,0 +1,3 @@
[profile.default]
slow-timeout = { period = "30s", terminate-after = 2 }
fail-fast = false
+31
View File
@@ -113,6 +113,37 @@ jobs:
- name: Test zellij-client
run: cargo test --no-default-features -p zellij-client --lib
integration-test:
name: Integration Test
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
rustflags: ""
- name: Install cargo-nextest
uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
- name: Run whole-app integration tests
run: cargo xtask integration-test
test-no-web:
name: Test (No Web)
runs-on: ubuntu-latest
+1
View File
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
* feat: allow tabs to have different sizes if clients aren't focused on the same one (https://github.com/zellij-org/zellij/pull/5133)
* feat: PWA support for the web client (manifest + icons + iOS meta tags) so the page can be installed as a standalone app (https://github.com/zellij-org/zellij/pull/5184)
* feat: mobile UI (https://github.com/zellij-org/zellij/pull/5241)
* chore: new integration testing infra (https://github.com/zellij-org/zellij/pull/5269)
## [0.44.3] - 2026-05-13
* fix(windows): bump windows-sys to 0.59 to align manifest with code, fixing source builds via `cargo install`/`cargo binstall` (https://github.com/zellij-org/zellij/pull/5139)
Generated
+18
View File
@@ -4870,6 +4870,24 @@ dependencies = [
"zellij-utils",
]
[[package]]
name = "zellij-integration-tests"
version = "0.45.0"
dependencies = [
"anyhow",
"async-trait",
"crossbeam",
"insta",
"interprocess",
"log",
"regex",
"tokio",
"vte 0.11.0",
"zellij-client",
"zellij-server",
"zellij-utils",
]
[[package]]
name = "zellij-server"
version = "0.45.0"
+1
View File
@@ -60,6 +60,7 @@ members = [
"default-plugins/link",
"default-plugins/mobile",
"zellij-client",
"zellij-integration-tests",
"zellij-server",
"zellij-utils",
"zellij-tile",
+6 -2
View File
@@ -589,8 +589,12 @@ fn attach_with_cli_client(
let get_current_dir = || std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
match Action::actions_from_cli(cli_action, Box::new(get_current_dir), config) {
Ok(actions) => {
zellij_client::cli_client::start_cli_client(Box::new(os_input), session_name, actions);
std::process::exit(0);
let exit_status = zellij_client::cli_client::start_cli_client(
Box::new(os_input),
session_name,
actions,
);
std::process::exit(exit_status);
},
Err(e) => {
eprintln!("{}", e);
+7 -2524
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1,3 +1,2 @@
pub mod cases;
mod remote_runner;
mod steps;
-503
View File
@@ -12,15 +12,11 @@ use ssh2::Session;
use std::io::prelude::*;
use std::net::TcpStream;
use std::path::Path;
use std::cell::RefCell;
use std::rc::Rc;
const ZELLIJ_EXECUTABLE_LOCATION: &str = "/usr/src/zellij/zellij";
const SET_ENV_VARIABLES: &str = "EDITOR=/usr/bin/vi";
const ZELLIJ_CONFIG_PATH: &str = "/usr/src/zellij/fixtures/configs";
const ZELLIJ_CONFIG_DIRS_PATH: &str = "/usr/src/zellij/fixtures/config-dirs";
const ZELLIJ_DATA_DIR: &str = "/usr/src/zellij/e2e-data";
const ZELLIJ_FIXTURE_PATH: &str = "/usr/src/zellij/fixtures";
const CONNECTION_STRING: &str = "127.0.0.1:2222";
@@ -40,16 +36,6 @@ fn ssh_connect() -> ssh2::Session {
sess
}
fn ssh_connect_without_timeout() -> ssh2::Session {
let tcp = TcpStream::connect(CONNECTION_STRING).unwrap();
let mut sess = Session::new().unwrap();
sess.set_tcp_stream(tcp);
sess.handshake().unwrap();
sess.userauth_password(CONNECTION_USERNAME, CONNECTION_PASSWORD)
.unwrap();
sess
}
fn setup_remote_environment(channel: &mut ssh2::Channel, win_size: Size) {
let (columns, rows) = (win_size.cols as u32, win_size.rows as u32);
channel
@@ -99,34 +85,6 @@ fn start_zellij(channel: &mut ssh2::Channel) {
channel.flush().unwrap();
}
fn start_zellij_with_config_dir(channel: &mut ssh2::Channel, config_dir: &str) {
stop_zellij(channel);
channel
.write_all(
format!(
"{} {} --session {} --data-dir {} --config-dir {} options --show-release-notes false --show-startup-tips false\n",
SET_ENV_VARIABLES, ZELLIJ_EXECUTABLE_LOCATION, SESSION_NAME, ZELLIJ_DATA_DIR, format!("{}/{}", ZELLIJ_CONFIG_DIRS_PATH, config_dir)
)
.as_bytes(),
)
.unwrap();
channel.flush().unwrap();
}
fn start_zellij_mirrored_session(channel: &mut ssh2::Channel) {
stop_zellij(channel);
channel
.write_all(
format!(
"{} {} --session {} --data-dir {} options --show-release-notes false --show-startup-tips false --mirror-session true --serialization-interval 1\n",
SET_ENV_VARIABLES, ZELLIJ_EXECUTABLE_LOCATION, SESSION_NAME, ZELLIJ_DATA_DIR
)
.as_bytes(),
)
.unwrap();
channel.flush().unwrap();
}
fn start_zellij_mirrored_session_with_layout(channel: &mut ssh2::Channel, layout_file_name: &str) {
stop_zellij(channel);
channel
@@ -145,103 +103,6 @@ fn start_zellij_mirrored_session_with_layout(channel: &mut ssh2::Channel, layout
channel.flush().unwrap();
}
fn start_zellij_mirrored_session_with_layout_and_viewport_serialization(
channel: &mut ssh2::Channel,
layout_file_name: &str,
) {
stop_zellij(channel);
channel
.write_all(
format!(
"{} {} --session {} --data-dir {} --new-session-with-layout {} options --show-release-notes false --show-startup-tips false --mirror-session true --serialize-pane-viewport true --serialization-interval 1\n",
SET_ENV_VARIABLES,
ZELLIJ_EXECUTABLE_LOCATION,
SESSION_NAME,
ZELLIJ_DATA_DIR,
format!("{}/{}", ZELLIJ_FIXTURE_PATH, layout_file_name)
)
.as_bytes(),
)
.unwrap();
channel.flush().unwrap();
}
fn start_zellij_in_session(channel: &mut ssh2::Channel, session_name: &str, mirrored: bool) {
stop_zellij(channel);
channel
.write_all(
format!(
"{} {} --session {} --data-dir {} options --show-release-notes false --show-startup-tips false --mirror-session {}\n",
SET_ENV_VARIABLES,
ZELLIJ_EXECUTABLE_LOCATION,
session_name,
ZELLIJ_DATA_DIR,
mirrored
)
.as_bytes(),
)
.unwrap();
channel.flush().unwrap();
}
fn attach_to_existing_session(channel: &mut ssh2::Channel, session_name: &str) {
channel
.write_all(
format!(
"{} {} attach {}\n",
SET_ENV_VARIABLES, ZELLIJ_EXECUTABLE_LOCATION, session_name
)
.as_bytes(),
)
.unwrap();
channel.flush().unwrap();
}
fn watch_existing_session(channel: &mut ssh2::Channel, session_name: &str) {
channel
.write_all(
format!(
"{} {} watch {}\n",
SET_ENV_VARIABLES, ZELLIJ_EXECUTABLE_LOCATION, session_name
)
.as_bytes(),
)
.unwrap();
channel.flush().unwrap();
}
fn start_zellij_without_frames(channel: &mut ssh2::Channel) {
stop_zellij(channel);
channel
.write_all(
format!(
"{} {} --session {} --data-dir {} options --show-release-notes false --show-startup-tips false --pane-frames false\n",
SET_ENV_VARIABLES, ZELLIJ_EXECUTABLE_LOCATION, SESSION_NAME, ZELLIJ_DATA_DIR
)
.as_bytes(),
)
.unwrap();
channel.flush().unwrap();
}
fn start_zellij_with_config(channel: &mut ssh2::Channel, config_path: &str) {
stop_zellij(channel);
channel
.write_all(
format!(
"{} {} --config {} --session {} --data-dir {} options --show-release-notes false --show-startup-tips false\n",
SET_ENV_VARIABLES,
ZELLIJ_EXECUTABLE_LOCATION,
config_path,
SESSION_NAME,
ZELLIJ_DATA_DIR
)
.as_bytes(),
)
.unwrap();
channel.flush().unwrap();
}
fn wait_for_startup(last_snapshot: &Arc<Mutex<String>>) {
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(10);
@@ -441,10 +302,6 @@ impl RemoteTerminal {
pub fn snapshot_contains(&self, text: &str) -> bool {
self.last_snapshot.lock().unwrap().contains(text)
}
pub fn lines(&self) -> Vec<String> {
let s = self.last_snapshot.lock().unwrap();
s.lines().map(|s| s.to_owned()).collect::<Vec<_>>()
}
#[allow(unused)]
pub fn current_snapshot(&self) -> String {
// convenience method for writing tests,
@@ -483,29 +340,6 @@ impl RemoteTerminal {
} // release mutex before sleeping so the reader thread can process Zellij's startup output
std::thread::sleep(std::time::Duration::from_secs(1)); // wait until Zellij stops parsing startup ANSI codes from the terminal STDIN
}
pub fn run_zellij_action(&mut self, action_and_arguments: &str) {
let mut channel = self.channel.lock().unwrap();
channel
.write_all(
format!(
"{} action {}",
ZELLIJ_EXECUTABLE_LOCATION, action_and_arguments
)
.as_bytes(),
)
.unwrap();
channel.flush().unwrap();
}
pub fn send_command_through_the_cli(&mut self, command: &str) {
let mut channel = self.channel.lock().unwrap();
channel
.write_all(
// note that this is run with the -s flag that suspends the command on startup
format!("{} run -s -- \"{}\"", ZELLIJ_EXECUTABLE_LOCATION, command).as_bytes(),
)
.unwrap();
channel.flush().unwrap();
}
pub fn send_blocking_command_through_the_cli(&mut self, command: &str) {
let mut channel = self.channel.lock().unwrap();
channel
@@ -519,16 +353,6 @@ impl RemoteTerminal {
.unwrap();
channel.flush().unwrap();
}
pub fn path_to_fixture_folder(&self) -> String {
ZELLIJ_FIXTURE_PATH.to_string()
}
pub fn load_fixture(&mut self, name: &str) {
let mut channel = self.channel.lock().unwrap();
channel
.write_all(format!("cat {ZELLIJ_FIXTURE_PATH}/{name}\n").as_bytes())
.unwrap();
channel.flush().unwrap();
}
}
#[derive(Clone)]
@@ -544,7 +368,6 @@ pub struct RemoteRunner {
currently_running_step: Option<String>,
retries_left: usize,
retry_pause_ms: usize,
panic_on_no_retries_left: bool,
last_snapshot: Arc<Mutex<String>>,
cursor_coordinates: Arc<Mutex<(usize, usize)>>, // x, y
reader_thread: (Arc<AtomicBool>, std::thread::JoinHandle<()>),
@@ -585,85 +408,6 @@ impl RemoteRunner {
retries_left: RETRIES,
retry_pause_ms: 100,
test_timed_out: false,
panic_on_no_retries_left: true,
last_snapshot,
cursor_coordinates,
reader_thread,
}
}
pub fn new_with_config_dir(win_size: Size, config_dir_name: &str) -> Self {
let sess = ssh_connect();
let mut channel = sess.channel_session().unwrap();
let mut rows = Dimension::fixed(win_size.rows);
let mut cols = Dimension::fixed(win_size.cols);
rows.set_inner(win_size.rows);
cols.set_inner(win_size.cols);
let pane_geom = PaneGeom {
x: 0,
y: 0,
rows,
cols,
stacked: None,
is_pinned: false,
logical_position: None,
};
setup_remote_environment(&mut channel, win_size);
start_zellij_with_config_dir(&mut channel, config_dir_name);
let channel = Arc::new(Mutex::new(channel));
let last_snapshot = Arc::new(Mutex::new(String::new()));
let cursor_coordinates = Arc::new(Mutex::new((0, 0)));
sess.set_blocking(false);
let reader_thread =
read_from_channel(&channel, &last_snapshot, &cursor_coordinates, &pane_geom);
wait_for_startup(&last_snapshot);
RemoteRunner {
steps: vec![],
channel,
currently_running_step: None,
current_step_index: 0,
retries_left: RETRIES,
retry_pause_ms: 100,
test_timed_out: false,
panic_on_no_retries_left: true,
last_snapshot,
cursor_coordinates,
reader_thread,
}
}
pub fn new_mirrored_session(win_size: Size) -> Self {
let sess = ssh_connect();
let mut channel = sess.channel_session().unwrap();
let mut rows = Dimension::fixed(win_size.rows);
let mut cols = Dimension::fixed(win_size.cols);
rows.set_inner(win_size.rows);
cols.set_inner(win_size.cols);
let pane_geom = PaneGeom {
x: 0,
y: 0,
rows,
cols,
stacked: None,
is_pinned: false,
logical_position: None,
};
setup_remote_environment(&mut channel, win_size);
start_zellij_mirrored_session(&mut channel);
let channel = Arc::new(Mutex::new(channel));
let last_snapshot = Arc::new(Mutex::new(String::new()));
let cursor_coordinates = Arc::new(Mutex::new((0, 0)));
sess.set_blocking(false);
let reader_thread =
read_from_channel(&channel, &last_snapshot, &cursor_coordinates, &pane_geom);
wait_for_startup(&last_snapshot);
RemoteRunner {
steps: vec![],
channel,
currently_running_step: None,
current_step_index: 0,
retries_left: RETRIES,
retry_pause_ms: 100,
test_timed_out: false,
panic_on_no_retries_left: true,
last_snapshot,
cursor_coordinates,
reader_thread,
@@ -702,52 +446,6 @@ impl RemoteRunner {
retries_left: RETRIES,
retry_pause_ms: 100,
test_timed_out: false,
panic_on_no_retries_left: true,
last_snapshot,
cursor_coordinates,
reader_thread,
}
}
pub fn new_mirrored_session_with_layout_and_viewport_serialization(
win_size: Size,
layout_file_name: &str,
) -> Self {
let sess = ssh_connect();
let mut channel = sess.channel_session().unwrap();
let mut rows = Dimension::fixed(win_size.rows);
let mut cols = Dimension::fixed(win_size.cols);
rows.set_inner(win_size.rows);
cols.set_inner(win_size.cols);
let pane_geom = PaneGeom {
x: 0,
y: 0,
rows,
cols,
stacked: None,
is_pinned: false,
logical_position: None,
};
setup_remote_environment(&mut channel, win_size);
start_zellij_mirrored_session_with_layout_and_viewport_serialization(
&mut channel,
layout_file_name,
);
let channel = Arc::new(Mutex::new(channel));
let last_snapshot = Arc::new(Mutex::new(String::new()));
let cursor_coordinates = Arc::new(Mutex::new((0, 0)));
sess.set_blocking(false);
let reader_thread =
read_from_channel(&channel, &last_snapshot, &cursor_coordinates, &pane_geom);
wait_for_startup(&last_snapshot);
RemoteRunner {
steps: vec![],
channel,
currently_running_step: None,
current_step_index: 0,
retries_left: RETRIES,
retry_pause_ms: 100,
test_timed_out: false,
panic_on_no_retries_left: true,
last_snapshot,
cursor_coordinates,
reader_thread,
@@ -765,207 +463,6 @@ impl RemoteRunner {
sess.set_blocking(false);
wait_for_shell_output(&mut channel, CLEANUP_DONE_MARKER);
}
pub fn new_with_session_name(win_size: Size, session_name: &str, mirrored: bool) -> Self {
// notice that this method does not have a timeout, so use with caution!
let sess = ssh_connect_without_timeout();
let mut channel = sess.channel_session().unwrap();
let mut rows = Dimension::fixed(win_size.rows);
let mut cols = Dimension::fixed(win_size.cols);
rows.set_inner(win_size.rows);
cols.set_inner(win_size.cols);
let pane_geom = PaneGeom {
x: 0,
y: 0,
rows,
cols,
stacked: None,
is_pinned: false,
logical_position: None,
};
setup_remote_environment(&mut channel, win_size);
start_zellij_in_session(&mut channel, session_name, mirrored);
let channel = Arc::new(Mutex::new(channel));
let last_snapshot = Arc::new(Mutex::new(String::new()));
let cursor_coordinates = Arc::new(Mutex::new((0, 0)));
sess.set_blocking(false);
let reader_thread =
read_from_channel(&channel, &last_snapshot, &cursor_coordinates, &pane_geom);
wait_for_startup(&last_snapshot);
RemoteRunner {
steps: vec![],
channel,
currently_running_step: None,
current_step_index: 0,
retries_left: RETRIES,
retry_pause_ms: 100,
test_timed_out: false,
panic_on_no_retries_left: true,
last_snapshot,
cursor_coordinates,
reader_thread,
}
}
pub fn new_existing_session(win_size: Size, session_name: &str) -> Self {
let sess = ssh_connect_without_timeout();
let mut channel = sess.channel_session().unwrap();
let mut rows = Dimension::fixed(win_size.rows);
let mut cols = Dimension::fixed(win_size.cols);
rows.set_inner(win_size.rows);
cols.set_inner(win_size.cols);
let pane_geom = PaneGeom {
x: 0,
y: 0,
rows,
cols,
stacked: None,
is_pinned: false,
logical_position: None,
};
setup_remote_environment(&mut channel, win_size);
attach_to_existing_session(&mut channel, session_name);
let channel = Arc::new(Mutex::new(channel));
let last_snapshot = Arc::new(Mutex::new(String::new()));
let cursor_coordinates = Arc::new(Mutex::new((0, 0)));
sess.set_blocking(false);
let reader_thread =
read_from_channel(&channel, &last_snapshot, &cursor_coordinates, &pane_geom);
wait_for_startup(&last_snapshot);
RemoteRunner {
steps: vec![],
channel,
currently_running_step: None,
current_step_index: 0,
retries_left: RETRIES,
retry_pause_ms: 100,
test_timed_out: false,
panic_on_no_retries_left: true,
last_snapshot,
cursor_coordinates,
reader_thread,
}
}
pub fn new_watcher_session(win_size: Size, session_name: &str) -> Self {
let sess = ssh_connect_without_timeout();
let mut channel = sess.channel_session().unwrap();
let mut rows = Dimension::fixed(win_size.rows);
let mut cols = Dimension::fixed(win_size.cols);
rows.set_inner(win_size.rows);
cols.set_inner(win_size.cols);
let pane_geom = PaneGeom {
x: 0,
y: 0,
rows,
cols,
stacked: None,
is_pinned: false,
logical_position: None,
};
setup_remote_environment(&mut channel, win_size);
watch_existing_session(&mut channel, session_name);
let channel = Arc::new(Mutex::new(channel));
let last_snapshot = Arc::new(Mutex::new(String::new()));
let cursor_coordinates = Arc::new(Mutex::new((0, 0)));
sess.set_blocking(false);
let reader_thread =
read_from_channel(&channel, &last_snapshot, &cursor_coordinates, &pane_geom);
wait_for_startup(&last_snapshot);
RemoteRunner {
steps: vec![],
channel,
currently_running_step: None,
current_step_index: 0,
retries_left: RETRIES,
retry_pause_ms: 100,
test_timed_out: false,
panic_on_no_retries_left: true,
last_snapshot,
cursor_coordinates,
reader_thread,
}
}
pub fn new_without_frames(win_size: Size) -> Self {
let sess = ssh_connect();
let mut channel = sess.channel_session().unwrap();
let mut rows = Dimension::fixed(win_size.rows);
let mut cols = Dimension::fixed(win_size.cols);
rows.set_inner(win_size.rows);
cols.set_inner(win_size.cols);
let pane_geom = PaneGeom {
x: 0,
y: 0,
rows,
cols,
stacked: None,
is_pinned: false,
logical_position: None,
};
setup_remote_environment(&mut channel, win_size);
start_zellij_without_frames(&mut channel);
let channel = Arc::new(Mutex::new(channel));
let last_snapshot = Arc::new(Mutex::new(String::new()));
let cursor_coordinates = Arc::new(Mutex::new((0, 0)));
sess.set_blocking(false);
let reader_thread =
read_from_channel(&channel, &last_snapshot, &cursor_coordinates, &pane_geom);
wait_for_startup(&last_snapshot);
RemoteRunner {
steps: vec![],
channel,
currently_running_step: None,
current_step_index: 0,
retries_left: RETRIES,
retry_pause_ms: 100,
test_timed_out: false,
panic_on_no_retries_left: true,
last_snapshot,
cursor_coordinates,
reader_thread,
}
}
pub fn new_with_config(win_size: Size, config_file_name: &'static str) -> Self {
let remote_path = Path::new(ZELLIJ_CONFIG_PATH).join(config_file_name);
let sess = ssh_connect();
let mut channel = sess.channel_session().unwrap();
let mut rows = Dimension::fixed(win_size.rows);
let mut cols = Dimension::fixed(win_size.cols);
rows.set_inner(win_size.rows);
cols.set_inner(win_size.cols);
let pane_geom = PaneGeom {
x: 0,
y: 0,
rows,
cols,
stacked: None,
is_pinned: false,
logical_position: None,
};
setup_remote_environment(&mut channel, win_size);
start_zellij_with_config(&mut channel, &remote_path.to_string_lossy());
let channel = Arc::new(Mutex::new(channel));
let last_snapshot = Arc::new(Mutex::new(String::new()));
let cursor_coordinates = Arc::new(Mutex::new((0, 0)));
sess.set_blocking(false);
let reader_thread =
read_from_channel(&channel, &last_snapshot, &cursor_coordinates, &pane_geom);
wait_for_startup(&last_snapshot);
RemoteRunner {
steps: vec![],
channel,
currently_running_step: None,
current_step_index: 0,
retries_left: RETRIES,
retry_pause_ms: 100,
test_timed_out: false,
panic_on_no_retries_left: true,
last_snapshot,
cursor_coordinates,
reader_thread,
}
}
pub fn dont_panic(mut self) -> Self {
self.panic_on_no_retries_left = false;
self
}
#[allow(unused)]
pub fn retry_pause_ms(mut self, retry_pause_ms: usize) -> Self {
self.retry_pause_ms = retry_pause_ms;
-152
View File
@@ -1,152 +0,0 @@
use super::cases::{
MOVE_FOCUS_LEFT_IN_NORMAL_MODE, MOVE_TAB_LEFT, MOVE_TAB_RIGHT, NEW_TAB_IN_TAB_MODE,
SECOND_TAB_CONTENT, TAB_MODE,
};
use super::remote_runner::{RemoteTerminal, Step};
pub fn new_tab() -> Step {
Step {
name: "Open new tab",
instruction: |mut remote_terminal: RemoteTerminal| -> bool {
let mut step_is_complete = false;
if remote_terminal.status_bar_appears() {
remote_terminal.send_key(&TAB_MODE);
std::thread::sleep(std::time::Duration::from_millis(100));
remote_terminal.send_key(&NEW_TAB_IN_TAB_MODE);
step_is_complete = true;
}
step_is_complete
},
}
}
pub fn check_second_tab_opened() -> Step {
Step {
name: "Check second tab opened",
instruction: |remote_terminal: RemoteTerminal| -> bool {
remote_terminal.status_bar_appears() && remote_terminal.snapshot_contains("Tab #2")
},
}
}
pub fn move_tab_left() -> Step {
Step {
name: "Move tab left",
instruction: |mut remote_terminal: RemoteTerminal| -> bool {
let mut step_is_complete = false;
if remote_terminal.status_bar_appears() {
remote_terminal.send_key(&MOVE_TAB_LEFT);
std::thread::sleep(std::time::Duration::from_millis(100));
step_is_complete = true;
}
step_is_complete
},
}
}
pub fn check_third_tab_moved_left() -> Step {
Step {
name: "Check third tab is in the middle",
instruction: |remote_terminal: RemoteTerminal| -> bool {
remote_terminal.status_bar_appears()
&& remote_terminal.snapshot_contains("Tab #1  Tab #3  Tab #2")
},
}
}
pub fn type_second_tab_content() -> Step {
Step {
name: "Type second tab content",
instruction: |mut remote_terminal: RemoteTerminal| -> bool {
if remote_terminal.snapshot_contains("Tab #2 content") {
return true;
}
// send (or re-send if the previous attempt arrived garbled) and wait for the next
// retry to confirm the content appeared correctly in the terminal
if remote_terminal.status_bar_appears() {
remote_terminal.send_key(&SECOND_TAB_CONTENT);
// sleep longer than the reader's idle poll (100ms) so the snapshot is updated
// before the next retry fires, preventing a spurious re-send
std::thread::sleep(std::time::Duration::from_millis(300));
}
false
},
}
}
pub fn check_third_tab_opened() -> Step {
Step {
name: "Check third tab opened",
instruction: |remote_terminal: RemoteTerminal| -> bool {
remote_terminal.status_bar_appears() && remote_terminal.snapshot_contains("Tab #3")
},
}
}
pub fn switch_focus_to_left_tab() -> Step {
Step {
name: "Move focus to tab on the left",
instruction: |mut remote_terminal: RemoteTerminal| -> bool {
let mut step_is_complete = false;
if remote_terminal.status_bar_appears() {
remote_terminal.send_key(&MOVE_FOCUS_LEFT_IN_NORMAL_MODE);
step_is_complete = true;
}
step_is_complete
},
}
}
pub fn check_focus_on_second_tab() -> Step {
Step {
name: "Check focus is on the second tab",
instruction: |remote_terminal: RemoteTerminal| -> bool {
remote_terminal.status_bar_appears()
&& remote_terminal.snapshot_contains("Tab #2 content")
},
}
}
pub fn move_tab_right() -> Step {
Step {
name: "Move tab right",
instruction: |mut remote_terminal: RemoteTerminal| -> bool {
let mut step_is_complete = false;
if remote_terminal.status_bar_appears() {
remote_terminal.send_key(&MOVE_TAB_RIGHT);
step_is_complete = true;
}
step_is_complete
},
}
}
pub fn check_third_tab_moved_to_beginning() -> Step {
Step {
name: "Check third tab moved to beginning",
instruction: |remote_terminal: RemoteTerminal| -> bool {
remote_terminal.status_bar_appears()
&& remote_terminal.snapshot_contains("Tab #3  Tab #1  Tab #2")
},
}
}
pub fn check_third_tab_is_left_wrapped() -> Step {
Step {
name: "Check third tab is in last position",
instruction: |remote_terminal: RemoteTerminal| -> bool {
remote_terminal.status_bar_appears()
&& remote_terminal.snapshot_contains("Tab #2  Tab #1  Tab #3")
},
}
}
pub fn check_third_tab_is_right_wrapped() -> Step {
Step {
name: "Check third tab is in last position",
instruction: |remote_terminal: RemoteTerminal| -> bool {
remote_terminal.status_bar_appears()
&& remote_terminal.snapshot_contains("Tab #3  Tab #2  Tab #1")
},
}
}
+20
View File
@@ -105,6 +105,17 @@ xflags::xflags! {
repeated args: OsString
}
/// Run the in-process whole-app integration tests
cmd integration-test {
/// Build with the default dev profile instead of dev-opt
/// (skips the one-time optimized dependency build, tests run ~7x slower)
optional --no-opt
/// Run the tests one at a time instead of in parallel
optional --serial
/// Arguments to pass to the test runner
repeated args: OsString
}
/// Build the application and all plugins
cmd build {
/// Build in release mode without debug symbols
@@ -139,6 +150,7 @@ pub enum XtaskCmd {
Run(Run),
Format(Format),
Test(Test),
IntegrationTest(IntegrationTest),
Build(Build),
}
@@ -232,6 +244,14 @@ pub struct Test {
pub no_web: bool,
}
#[derive(Debug)]
pub struct IntegrationTest {
pub args: Vec<OsString>,
pub no_opt: bool,
pub serial: bool,
}
#[derive(Debug)]
pub struct Build {
pub release: bool,
+47
View File
@@ -0,0 +1,47 @@
use crate::flags;
use anyhow::Context;
use xshell::{cmd, Shell};
pub fn integration_test(sh: &Shell, flags: flags::IntegrationTest) -> anyhow::Result<()> {
let err_context = "failed to run task 'integration-test'";
let _pd = sh.push_dir(crate::project_root());
let cargo = crate::cargo().context(err_context)?;
let msg = ">> Running whole-app integration tests";
crate::status(msg);
println!("{}", msg);
let profile = if flags.no_opt { "dev" } else { "dev-opt" };
let nextest_available = cmd!(sh, "{cargo} nextest --version")
.quiet()
.ignore_stderr()
.read()
.is_ok();
if nextest_available {
let serial_args: &[&str] = if flags.serial {
&["--test-threads=1"]
} else {
&[]
};
cmd!(
sh,
"{cargo} nextest run --cargo-profile {profile} -p zellij-integration-tests"
)
.args(serial_args)
.args(&flags.args)
.run()
.context(err_context)?;
} else {
println!(">> cargo-nextest not found, falling back to `cargo test -- --test-threads=1`");
cmd!(
sh,
"{cargo} test --profile {profile} -p zellij-integration-tests -- --test-threads=1"
)
.args(&flags.args)
.run()
.context(err_context)?;
}
Ok(())
}
+2
View File
@@ -12,6 +12,7 @@ mod clippy;
mod dist;
mod flags;
mod format;
mod integration_test;
mod metadata;
mod pipelines;
mod test;
@@ -131,6 +132,7 @@ fn main() -> anyhow::Result<()> {
flags::XtaskCmd::Clippy(flags) => clippy::clippy(shell, flags),
flags::XtaskCmd::Format(flags) => format::format(shell, flags),
flags::XtaskCmd::Test(flags) => test::test(shell, flags),
flags::XtaskCmd::IntegrationTest(flags) => integration_test::integration_test(shell, flags),
flags::XtaskCmd::Manpage(_flags) => build::manpage(shell),
// Pipelines
// These are composite commands, made up of multiple "stages" defined above.
+14 -9
View File
@@ -20,7 +20,7 @@ pub fn start_cli_client(
mut os_input: Box<dyn ClientOsApi>,
session_name: &str,
actions: Vec<Action>,
) {
) -> i32 {
let zellij_ipc_pipe: PathBuf = {
let mut sock_dir = zellij_utils::consts::ZELLIJ_SOCK_DIR.clone();
fs::create_dir_all(&sock_dir).unwrap();
@@ -68,11 +68,16 @@ pub fn start_cli_client(
);
},
action => {
individual_messages_client(&mut os_input, action, pane_id);
if let Some(exit_status) =
individual_messages_client(&mut os_input, action, pane_id)
{
return exit_status;
}
},
}
}
os_input.send_to_server(ClientToServerMsg::ClientExited);
0
}
fn pipe_client(
@@ -208,7 +213,7 @@ fn individual_messages_client(
os_input: &mut Box<dyn ClientOsApi>,
action: Action,
pane_id: Option<u32>,
) {
) -> Option<i32> {
let is_blocking = matches!(
&action,
Action::NewBlockingPane {
@@ -226,26 +231,26 @@ fn individual_messages_client(
loop {
match os_input.recv_from_server() {
Some((ServerToClientMsg::UnblockInputThread, _)) if !is_blocking => {
break;
return None;
},
Some((ServerToClientMsg::Log { lines: log_lines }, _)) => {
log_lines.iter().for_each(|line| println!("{line}"));
break;
return None;
},
Some((ServerToClientMsg::LogError { lines: log_lines }, _)) => {
log_lines.iter().for_each(|line| eprintln!("{line}"));
process::exit(2);
return Some(2);
},
Some((ServerToClientMsg::Exit { exit_reason }, _)) => match exit_reason {
ExitReason::Error(e) => {
eprintln!("{}", e);
process::exit(2);
return Some(2);
},
ExitReason::CustomExitStatus(exit_status) => {
process::exit(exit_status);
return Some(exit_status);
},
_ => {
break;
return None;
},
},
_ => {},
+18 -16
View File
@@ -879,7 +879,7 @@ pub fn start_client(
os_input.update_session_name(name);
let ipc_pipe = create_ipc_pipe();
spawn_server(&*ipc_pipe, cli_args.debug).unwrap();
os_input.spawn_server(&*ipc_pipe, cli_args.debug).unwrap();
if should_start_web_server {
if let Err(e) = spawn_web_server(&cli_args) {
log::error!("Failed to start web server: {}", e);
@@ -933,7 +933,7 @@ pub fn start_client(
os_input.update_session_name(name);
let ipc_pipe = create_ipc_pipe();
spawn_server(&*ipc_pipe, cli_args.debug).unwrap();
os_input.spawn_server(&*ipc_pipe, cli_args.debug).unwrap();
if should_start_web_server {
if let Err(e) = spawn_web_server(&cli_args) {
log::error!("Failed to start web server: {}", e);
@@ -971,18 +971,20 @@ pub fn start_client(
> = channels::bounded(50);
let send_input_instructions = SenderWithContext::new(send_input_instructions);
std::panic::set_hook({
use zellij_utils::errors::handle_panic;
let send_client_instructions = send_client_instructions.clone();
let os_input = os_input.clone();
Box::new(move |info| {
os_input.disable_mouse().non_fatal();
os_input.restore_console_mode();
if let Ok(()) = os_input.unset_raw_mode() {
handle_panic(info, Some(&send_client_instructions));
}
})
});
if os_input.should_install_panic_hook() {
std::panic::set_hook({
use zellij_utils::errors::handle_panic;
let send_client_instructions = send_client_instructions.clone();
let os_input = os_input.clone();
Box::new(move |info| {
os_input.disable_mouse().non_fatal();
os_input.restore_console_mode();
if let Ok(()) = os_input.unset_raw_mode() {
handle_panic(info, Some(&send_client_instructions));
}
})
});
}
let on_force_close = config_options.on_force_close.unwrap_or_default();
let stdin_ansi_parser = Arc::new(Mutex::new(StdinAnsiParser::new()));
@@ -1333,7 +1335,7 @@ pub fn start_server_detached(
os_input.update_session_name(name);
let ipc_pipe = create_ipc_pipe();
spawn_server(&*ipc_pipe, cli_args.debug).unwrap();
os_input.spawn_server(&*ipc_pipe, cli_args.debug).unwrap();
if should_start_web_server {
if let Err(e) = spawn_web_server(&cli_args) {
log::error!("Failed to start web server: {}", e);
@@ -1388,7 +1390,7 @@ pub fn start_server_detached(
os_input.update_session_name(name);
let ipc_pipe = create_ipc_pipe();
spawn_server(&*ipc_pipe, cli_args.debug).unwrap();
os_input.spawn_server(&*ipc_pipe, cli_args.debug).unwrap();
if should_start_web_server {
if let Err(e) = spawn_web_server(&cli_args) {
log::error!("Failed to start web server: {}", e);
+6
View File
@@ -137,6 +137,12 @@ pub trait ClientOsApi: Send + Sync + std::fmt::Debug {
);
/// Establish a connection with the server socket.
fn connect_to_server(&self, path: &Path);
fn spawn_server(&self, socket_path: &Path, debug: bool) -> Result<(), std::io::Error> {
crate::spawn_server(socket_path, debug)
}
fn should_install_panic_hook(&self) -> bool {
true
}
fn load_palette(&self) -> Palette;
fn enable_mouse(&self) -> Result<()>;
fn disable_mouse(&self) -> Result<()>;
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "zellij-integration-tests"
description = "In-process whole-app integration test harness for Zellij"
publish = false
license.workspace = true
repository.workspace = true
edition.workspace = true
version.workspace = true
rust-version.workspace = true
[dependencies]
zellij-client = { path = "../zellij-client", version = "0.45.0" }
zellij-server = { path = "../zellij-server", version = "0.45.0" }
zellij-utils = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
crossbeam = { version = "0.8", default-features = false, features = ["crossbeam-channel", "std"] }
interprocess = { workspace = true }
insta = { version = "1.6.0", features = ["backtrace"] }
log = { workspace = true }
regex = { workspace = true }
tokio = { workspace = true }
vte = { workspace = true }
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 || $# -gt 2 ]]; then
echo "usage: $0 <test_name> [file]" >&2
exit 1
fi
test_name=$1
file=${2:-}
snapshot_dir=tests/snapshots
if [[ ! -d $snapshot_dir ]]; then
echo "error: $snapshot_dir not found (run from the zellij-integration-tests folder)" >&2
exit 1
fi
prefix=${file:-*}
shopt -s nullglob
candidates=("$snapshot_dir"/${prefix}__"$test_name".snap "$snapshot_dir"/${prefix}__"$test_name"-*.snap)
shopt -u nullglob
snapshots=()
for candidate in "${candidates[@]}"; do
[[ -f $candidate ]] && snapshots+=("$candidate")
done
if [[ ${#snapshots[@]} -eq 0 ]]; then
echo "error: no snapshots found for test '$test_name'${file:+ in file '$file'}" >&2
exit 1
fi
if [[ -z $file ]]; then
declare -A files_seen=()
for snapshot in "${snapshots[@]}"; do
base=${snapshot##*/}
files_seen[${base%%__*}]=1
done
if [[ ${#files_seen[@]} -gt 1 ]]; then
files=("${!files_seen[@]}")
echo "error: test '$test_name' exists in multiple files: ${files[*]}" >&2
echo "re-run with the file, e.g. $0 $test_name ${files[0]}" >&2
exit 1
fi
fi
for snapshot in "${snapshots[@]}"; do
zellij action new-pane --stacked --close-on-exit -- vim "$(realpath "$snapshot")"
done
@@ -0,0 +1,232 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::{Arc, Condvar, Mutex};
use std::time::Instant;
use zellij_server::panes::{LinkHandler, TerminalPane};
use zellij_utils::data::{Palette, Style};
use zellij_utils::pane_size::{Dimension, PaneGeom, Size, SizeInPixels};
#[derive(Default)]
struct ReceivedBytes {
bytes: Vec<u8>,
generation: u64,
}
#[derive(Default)]
struct ReceivedBytesWithChangeSignal {
received_bytes: Mutex<ReceivedBytes>,
change_signal: Condvar,
}
#[derive(Clone)]
pub struct ClientScreen {
inner: Arc<ReceivedBytesWithChangeSignal>,
size: Arc<Mutex<Size>>,
}
impl ClientScreen {
pub fn new(size: Arc<Mutex<Size>>) -> Self {
ClientScreen {
inner: Arc::new(ReceivedBytesWithChangeSignal::default()),
size,
}
}
pub fn writer(&self) -> Box<dyn std::io::Write> {
Box::new(ClientScreenWriter {
inner: self.inner.clone(),
})
}
pub fn snapshot(&self) -> GridSnapshot {
let size = *self.size.lock().unwrap();
let bytes = self.inner.received_bytes.lock().unwrap().bytes.clone();
render_bytes(&bytes, size)
}
pub fn wait_until(
&self,
what: &str,
predicate: impl Fn(&GridSnapshot) -> bool,
) -> GridSnapshot {
let deadline = Instant::now() + crate::default_timeout();
let mut received_bytes = self.inner.received_bytes.lock().unwrap();
loop {
let size = *self.size.lock().unwrap();
let grid_snapshot = render_bytes(&received_bytes.bytes, size);
if predicate(&grid_snapshot) {
return grid_snapshot;
}
let now = Instant::now();
if now >= deadline {
panic!(
"timed out waiting for: {}\ncursor: {:?}\nlast rendered grid:\n{}\n=== (received {} stdout bytes, generation {}) ===\n=== zellij log tail ({}) ===\n{}",
what,
grid_snapshot.cursor,
grid_snapshot.text,
received_bytes.bytes.len(),
received_bytes.generation,
crate::test_env::log_file_path().display(),
crate::test_env::log_tail(40),
);
}
let last_generation = received_bytes.generation;
let (guard, _) = self
.inner
.change_signal
.wait_timeout(received_bytes, deadline - now)
.unwrap();
received_bytes = guard;
if received_bytes.generation == last_generation {
continue;
}
}
}
}
struct ClientScreenWriter {
inner: Arc<ReceivedBytesWithChangeSignal>,
}
impl std::io::Write for ClientScreenWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let mut received_bytes = self.inner.received_bytes.lock().unwrap();
received_bytes.bytes.extend_from_slice(buf);
received_bytes.generation += 1;
self.inner.change_signal.notify_all();
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CursorPosition {
pub x: usize,
pub y: usize,
}
#[derive(Clone, Debug)]
pub struct GridSnapshot {
pub text: String,
pub cursor: Option<CursorPosition>,
}
impl GridSnapshot {
pub fn contains(&self, needle: &str) -> bool {
self.text.contains(needle)
}
pub fn cursor_is_at(&self, x: usize, y: usize) -> bool {
self.cursor == Some(CursorPosition { x, y })
}
pub fn status_bar_appears(&self) -> bool {
self.text.contains("Ctrl +") && self.text.contains("LOCK")
}
pub fn tab_bar_appears(&self) -> bool {
self.text.contains("Tab #1")
}
pub fn lines(&self) -> Vec<String> {
self.text.lines().map(|l| l.to_owned()).collect()
}
}
impl std::fmt::Display for GridSnapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.text)
}
}
fn render_bytes(bytes: &[u8], win_size: Size) -> GridSnapshot {
let mut terminal_pane = build_terminal_pane(win_size);
let mut vte_parser = vte::Parser::new();
for &byte in bytes {
vte_parser.advance(&mut terminal_pane.grid, byte);
}
let cursor = terminal_pane
.cursor_coordinates()
.and_then(|(x, y, visible)| {
if visible {
Some(CursorPosition { x, y })
} else {
None
}
});
let mut text = String::new();
let output_lines = terminal_pane.read_buffer_as_lines();
for (line_index, line) in output_lines.iter().enumerate() {
for (character_index, terminal_character) in line.iter().enumerate() {
let character_position = CursorPosition {
x: character_index,
y: line_index,
};
if cursor == Some(character_position) {
text.push('█');
continue;
}
text.push(terminal_character.character);
}
if line_index != output_lines.len() - 1 {
text.push('\n');
}
}
GridSnapshot { text, cursor }
}
fn build_terminal_pane(win_size: Size) -> TerminalPane {
let mut rows = Dimension::fixed(win_size.rows);
let mut cols = Dimension::fixed(win_size.cols);
rows.set_inner(win_size.rows);
cols.set_inner(win_size.cols);
let position_and_size = PaneGeom {
x: 0,
y: 0,
rows,
cols,
stacked: None,
is_pinned: false,
logical_position: None,
};
let pid = 0;
let pane_index = 0;
let pane_name = String::new();
let link_handler = Rc::new(RefCell::new(LinkHandler::new()));
let character_cell_size = Rc::new(RefCell::new(Some(SizeInPixels {
height: 21,
width: 8,
})));
let sixel_image_store = Rc::new(RefCell::new(Default::default()));
let terminal_emulator_colors = Rc::new(RefCell::new(Palette::default()));
let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new()));
let initial_pane_title = None;
let invoked_with = None;
let debug = false;
let arrow_fonts = true;
let styled_underlines = true;
let osc8_hyperlinks = true;
let explicitly_disable_keyboard_protocol = false;
let notification_end = None;
TerminalPane::new(
pid,
position_and_size,
Style::default(),
pane_index,
pane_name,
link_handler,
character_cell_size,
sixel_image_store,
terminal_emulator_colors,
terminal_emulator_color_codes,
initial_pane_title,
invoked_with,
debug,
arrow_fonts,
styled_underlines,
osc8_hyperlinks,
explicitly_disable_keyboard_protocol,
notification_end,
)
}
@@ -0,0 +1,182 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use zellij_client::os_input_output::{ClientOsApi, SignalEvent};
use zellij_utils::data::Palette;
use zellij_utils::errors::ErrorContext;
use zellij_utils::ipc::{
ClientToServerMsg, IpcReceiverWithContext, IpcSenderWithContext, ServerToClientMsg,
};
use zellij_utils::pane_size::Size;
use zellij_utils::shared::default_palette;
use crate::client_screen::ClientScreen;
type ServerSpawner = Box<dyn FnOnce(PathBuf) + Send>;
pub struct FakeClientOsApi {
client_screen: ClientScreen,
size: Arc<Mutex<Size>>,
stdin_rx: crossbeam::channel::Receiver<Vec<u8>>,
signal_rx: crossbeam::channel::Receiver<SignalEvent>,
send_instructions_to_server: Arc<Mutex<Option<IpcSenderWithContext<ClientToServerMsg>>>>,
receive_instructions_from_server: Arc<Mutex<Option<IpcReceiverWithContext<ServerToClientMsg>>>>,
session_name: Arc<Mutex<Option<String>>>,
server_spawner: Arc<Mutex<Option<ServerSpawner>>>,
}
impl Clone for FakeClientOsApi {
fn clone(&self) -> Self {
FakeClientOsApi {
client_screen: self.client_screen.clone(),
size: self.size.clone(),
stdin_rx: self.stdin_rx.clone(),
signal_rx: self.signal_rx.clone(),
send_instructions_to_server: self.send_instructions_to_server.clone(),
receive_instructions_from_server: self.receive_instructions_from_server.clone(),
session_name: self.session_name.clone(),
server_spawner: self.server_spawner.clone(),
}
}
}
impl std::fmt::Debug for FakeClientOsApi {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FakeClientOsApi").finish()
}
}
pub struct FakeClientHandle {
pub client_screen: ClientScreen,
pub size: Arc<Mutex<Size>>,
pub stdin_tx: crossbeam::channel::Sender<Vec<u8>>,
pub signal_tx: crossbeam::channel::Sender<SignalEvent>,
}
impl FakeClientOsApi {
pub fn new(
initial_size: Size,
server_spawner: Option<ServerSpawner>,
) -> (Self, FakeClientHandle) {
let size = Arc::new(Mutex::new(initial_size));
let client_screen = ClientScreen::new(size.clone());
let (stdin_tx, stdin_rx) = crossbeam::channel::unbounded();
let (signal_tx, signal_rx) = crossbeam::channel::unbounded();
let fake_client_os_api = FakeClientOsApi {
client_screen: client_screen.clone(),
size: size.clone(),
stdin_rx,
signal_rx,
send_instructions_to_server: Arc::new(Mutex::new(None)),
receive_instructions_from_server: Arc::new(Mutex::new(None)),
session_name: Arc::new(Mutex::new(None)),
server_spawner: Arc::new(Mutex::new(server_spawner)),
};
let fake_client_handle = FakeClientHandle {
client_screen,
size,
stdin_tx,
signal_tx,
};
(fake_client_os_api, fake_client_handle)
}
}
impl ClientOsApi for FakeClientOsApi {
fn get_terminal_size(&self) -> Size {
*self.size.lock().unwrap()
}
fn set_raw_mode(&mut self) {}
fn unset_raw_mode(&self) -> Result<(), std::io::Error> {
Ok(())
}
fn get_stdout_writer(&self) -> Box<dyn std::io::Write> {
self.client_screen.writer()
}
fn get_stdin_reader(&self) -> Box<dyn std::io::BufRead> {
Box::new(std::io::BufReader::new(std::io::empty()))
}
fn update_session_name(&mut self, new_session_name: String) {
*self.session_name.lock().unwrap() = Some(new_session_name);
}
fn read_from_stdin(&mut self) -> Result<Vec<u8>, &'static str> {
self.stdin_rx.recv().map_err(|_| "fake client stdin closed")
}
fn box_clone(&self) -> Box<dyn ClientOsApi> {
Box::new(self.clone())
}
fn send_to_server(&self, msg: ClientToServerMsg) {
match self.send_instructions_to_server.lock().unwrap().as_mut() {
Some(ipc_sender) => {
let _ = ipc_sender.send_client_msg(msg);
},
None => {
log::warn!("Server not ready, dropping message.");
},
}
}
fn recv_from_server(&self) -> Option<(ServerToClientMsg, ErrorContext)> {
self.receive_instructions_from_server
.lock()
.unwrap()
.as_mut()
.unwrap()
.recv_server_msg()
}
fn handle_signals(
&self,
sigwinch_cb: Box<dyn Fn()>,
quit_cb: Box<dyn Fn()>,
_resize_receiver: Option<std::sync::mpsc::Receiver<()>>,
) {
loop {
match self.signal_rx.recv() {
Ok(SignalEvent::Resize) => sigwinch_cb(),
Ok(SignalEvent::Quit) => {
quit_cb();
break;
},
Err(_) => break,
}
}
}
fn connect_to_server(&self, path: &Path) {
let socket;
loop {
match zellij_utils::consts::ipc_connect(path) {
Ok(sock) => {
socket = sock;
break;
},
Err(_) => {
std::thread::sleep(std::time::Duration::from_millis(10));
},
}
}
let ipc_sender = IpcSenderWithContext::new(socket);
let ipc_receiver = ipc_sender.get_receiver();
*self.send_instructions_to_server.lock().unwrap() = Some(ipc_sender);
*self.receive_instructions_from_server.lock().unwrap() = Some(ipc_receiver);
}
fn spawn_server(&self, socket_path: &Path, _debug: bool) -> Result<(), std::io::Error> {
if let Some(server_spawner) = self.server_spawner.lock().unwrap().take() {
server_spawner(socket_path.to_path_buf());
}
Ok(())
}
fn load_palette(&self) -> Palette {
default_palette()
}
fn enable_mouse(&self) -> anyhow::Result<()> {
Ok(())
}
fn disable_mouse(&self) -> anyhow::Result<()> {
Ok(())
}
fn env_variable(&self, _name: &str) -> Option<String> {
None
}
fn should_install_panic_hook(&self) -> bool {
false
}
}
+365
View File
@@ -0,0 +1,365 @@
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use std::time::Instant;
use async_trait::async_trait;
use zellij_server::os_input_output::AsyncReader;
use zellij_server::panes::PaneId;
use zellij_utils::input::command::{RunCommand, TerminalAction};
pub(crate) type QuitCb = Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>;
pub(crate) const FAKE_PID_BASE: u32 = 100_000;
pub(crate) struct FakePtyState {
pub terminal_action: Option<TerminalAction>,
pub output_tx: Option<tokio::sync::mpsc::UnboundedSender<Vec<u8>>>,
pub stdin: Vec<u8>,
pub size: Option<(u16, u16)>,
pub quit_cb: Option<QuitCb>,
pub exited: bool,
pub echo: bool,
}
impl FakePtyState {
fn run_command(&self) -> RunCommand {
match &self.terminal_action {
Some(TerminalAction::RunCommand(run_command)) => run_command.clone(),
_ => RunCommand::default(),
}
}
fn describe(&self) -> String {
format!(
"size={:?} exited={} stdin_bytes={}",
self.size,
self.exited,
self.stdin.len()
)
}
}
#[derive(Default)]
pub(crate) struct FakePtyRegistry {
next_terminal_id: u32,
pub fake_pty_states: HashMap<u32, FakePtyState>,
pub spawn_queue: VecDeque<u32>,
}
impl FakePtyRegistry {
fn describe_all(&self) -> String {
let mut fake_pty_state_lines: Vec<String> = self
.fake_pty_states
.iter()
.map(|(terminal_id, fake_pty_state)| {
format!("terminal {}: {}", terminal_id, fake_pty_state.describe())
})
.collect();
fake_pty_state_lines.sort();
format!(
"{}\nspawn queue: {:?}",
fake_pty_state_lines.join("\n"),
self.spawn_queue
)
}
}
#[derive(Default)]
struct RegistryWithChangeSignal {
fake_pty_registry: Mutex<FakePtyRegistry>,
change_signal: Condvar,
}
#[derive(Clone, Default)]
pub struct SharedPtys {
inner: Arc<RegistryWithChangeSignal>,
}
impl SharedPtys {
fn lock_registry(&self) -> MutexGuard<'_, FakePtyRegistry> {
self.inner.fake_pty_registry.lock().unwrap()
}
pub(crate) fn read<T>(&self, reader: impl FnOnce(&FakePtyRegistry) -> T) -> T {
reader(&self.lock_registry())
}
pub(crate) fn mutate<T>(&self, mutator: impl FnOnce(&mut FakePtyRegistry) -> T) -> T {
let result = mutator(&mut self.lock_registry());
self.inner.change_signal.notify_all();
result
}
pub(crate) fn next_terminal_id(&self) -> u32 {
self.mutate(|fake_pty_registry| {
let terminal_id = fake_pty_registry.next_terminal_id;
fake_pty_registry.next_terminal_id += 1;
terminal_id
})
}
pub(crate) fn register(
&self,
terminal_id: u32,
terminal_action: Option<TerminalAction>,
quit_cb: Option<QuitCb>,
) -> Box<dyn AsyncReader> {
let (output_tx, output_rx) = tokio::sync::mpsc::unbounded_channel();
self.mutate(|fake_pty_registry| {
fake_pty_registry.fake_pty_states.insert(
terminal_id,
FakePtyState {
terminal_action,
output_tx: Some(output_tx),
stdin: Vec::new(),
size: None,
quit_cb,
exited: false,
echo: true,
},
);
fake_pty_registry.spawn_queue.push_back(terminal_id);
});
Box::new(FakeAsyncReader {
output_rx,
pending: VecDeque::new(),
})
}
pub(crate) fn rerun(&self, terminal_id: u32, quit_cb: QuitCb) -> Box<dyn AsyncReader> {
let (output_tx, output_rx) = tokio::sync::mpsc::unbounded_channel();
self.mutate(|fake_pty_registry| {
let fake_pty_state = fake_pty_registry
.fake_pty_states
.get_mut(&terminal_id)
.expect("re-run for unknown terminal id");
fake_pty_state.output_tx = Some(output_tx);
fake_pty_state.quit_cb = Some(quit_cb);
fake_pty_state.exited = false;
});
Box::new(FakeAsyncReader {
output_rx,
pending: VecDeque::new(),
})
}
pub(crate) fn exit_terminal(&self, terminal_id: u32, exit_status: Option<i32>) {
let newly_exited = self.mutate(|fake_pty_registry| {
let fake_pty_state = fake_pty_registry.fake_pty_states.get_mut(&terminal_id)?;
if fake_pty_state.exited {
return None;
}
fake_pty_state.exited = true;
fake_pty_state.output_tx.take();
Some((fake_pty_state.quit_cb.take(), fake_pty_state.run_command()))
});
if let Some((Some(quit_cb), run_command)) = newly_exited {
quit_cb(PaneId::Terminal(terminal_id), exit_status, run_command);
}
}
pub(crate) fn remove(&self, terminal_id: u32) {
self.mutate(|fake_pty_registry| {
fake_pty_registry.fake_pty_states.remove(&terminal_id);
});
}
pub(crate) fn set_size(&self, terminal_id: u32, cols: u16, rows: u16) {
self.mutate(|fake_pty_registry| {
if let Some(fake_pty_state) = fake_pty_registry.fake_pty_states.get_mut(&terminal_id) {
fake_pty_state.size = Some((cols, rows));
}
});
}
pub(crate) fn write_output(&self, terminal_id: u32, bytes: &[u8]) {
self.read(|fake_pty_registry| {
if let Some(fake_pty_state) = fake_pty_registry.fake_pty_states.get(&terminal_id) {
if let Some(output_tx) = fake_pty_state.output_tx.as_ref() {
let _ = output_tx.send(bytes.to_vec());
}
}
});
}
pub(crate) fn append_stdin(&self, terminal_id: u32, bytes: &[u8]) -> bool {
self.mutate(|fake_pty_registry| {
match fake_pty_registry.fake_pty_states.get_mut(&terminal_id) {
Some(fake_pty_state) => {
fake_pty_state.stdin.extend_from_slice(bytes);
if fake_pty_state.echo {
if let Some(output_tx) = fake_pty_state.output_tx.as_ref() {
let _ = output_tx.send(bytes.to_vec());
}
}
true
},
None => false,
}
})
}
pub(crate) fn wait_for<T>(
&self,
what: &str,
mut condition: impl FnMut(&mut FakePtyRegistry) -> Option<T>,
) -> T {
let deadline = Instant::now() + crate::default_timeout();
let mut fake_pty_registry = self.lock_registry();
loop {
if let Some(result) = condition(&mut fake_pty_registry) {
return result;
}
let now = Instant::now();
if now >= deadline {
panic!(
"timed out waiting for: {}\n{}\n=== zellij log tail ({}) ===\n{}",
what,
fake_pty_registry.describe_all(),
crate::test_env::log_file_path().display(),
crate::test_env::log_tail(40),
);
}
let (guard, _) = self
.inner
.change_signal
.wait_timeout(fake_pty_registry, deadline - now)
.unwrap();
fake_pty_registry = guard;
}
}
}
struct FakeAsyncReader {
output_rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
pending: VecDeque<u8>,
}
#[async_trait]
impl AsyncReader for FakeAsyncReader {
async fn read(&mut self, buf: &mut [u8]) -> Result<usize, std::io::Error> {
if self.pending.is_empty() {
match self.output_rx.recv().await {
Some(bytes) => self.pending.extend(bytes),
None => return Ok(0),
}
}
let mut written = 0;
while written < buf.len() {
match self.pending.pop_front() {
Some(byte) => {
buf[written] = byte;
written += 1;
},
None => break,
}
}
Ok(written)
}
}
#[derive(Clone)]
pub struct FakePtyHandle {
pub(crate) terminal_id: u32,
pub(crate) shared_ptys: SharedPtys,
}
impl FakePtyHandle {
pub fn terminal_id(&self) -> u32 {
self.terminal_id
}
pub fn terminal_action(&self) -> Option<TerminalAction> {
self.shared_ptys.read(|fake_pty_registry| {
fake_pty_registry
.fake_pty_states
.get(&self.terminal_id)
.and_then(|fake_pty_state| fake_pty_state.terminal_action.clone())
})
}
pub fn disable_echo(&self) {
self.shared_ptys.mutate(|fake_pty_registry| {
if let Some(fake_pty_state) =
fake_pty_registry.fake_pty_states.get_mut(&self.terminal_id)
{
fake_pty_state.echo = false;
}
});
}
pub fn output(&self, bytes: &[u8]) {
self.shared_ptys.read(|fake_pty_registry| {
let fake_pty_state = fake_pty_registry
.fake_pty_states
.get(&self.terminal_id)
.unwrap_or_else(|| panic!("unknown terminal id {}", self.terminal_id));
let output_tx = fake_pty_state
.output_tx
.as_ref()
.unwrap_or_else(|| panic!("terminal {} already exited", self.terminal_id));
let _ = output_tx.send(bytes.to_vec());
})
}
pub fn exit(&self, exit_status: Option<i32>) {
self.shared_ptys
.exit_terminal(self.terminal_id, exit_status);
}
pub fn is_exited(&self) -> bool {
self.shared_ptys.read(|fake_pty_registry| {
fake_pty_registry
.fake_pty_states
.get(&self.terminal_id)
.map(|fake_pty_state| fake_pty_state.exited)
.unwrap_or(true)
})
}
pub fn stdin_bytes(&self) -> Vec<u8> {
self.shared_ptys.read(|fake_pty_registry| {
fake_pty_registry
.fake_pty_states
.get(&self.terminal_id)
.map(|fake_pty_state| fake_pty_state.stdin.clone())
.unwrap_or_default()
})
}
pub fn wait_for_stdin(&self, what: &str, predicate: impl Fn(&[u8]) -> bool) -> Vec<u8> {
let terminal_id = self.terminal_id;
self.shared_ptys.wait_for(what, move |fake_pty_registry| {
fake_pty_registry
.fake_pty_states
.get(&terminal_id)
.and_then(|fake_pty_state| {
if predicate(&fake_pty_state.stdin) {
Some(fake_pty_state.stdin.clone())
} else {
None
}
})
})
}
pub fn size(&self) -> Option<(u16, u16)> {
self.shared_ptys.read(|fake_pty_registry| {
fake_pty_registry
.fake_pty_states
.get(&self.terminal_id)
.and_then(|fake_pty_state| fake_pty_state.size)
})
}
pub fn wait_for_size(&self, what: &str, predicate: impl Fn(u16, u16) -> bool) -> (u16, u16) {
let terminal_id = self.terminal_id;
self.shared_ptys.wait_for(what, move |fake_pty_registry| {
fake_pty_registry
.fake_pty_states
.get(&terminal_id)
.and_then(|fake_pty_state| fake_pty_state.size)
.filter(|(cols, rows)| predicate(*cols, *rows))
})
}
}
@@ -0,0 +1,201 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use interprocess::local_socket::Stream as LocalSocketStream;
use zellij_server::os_input_output::{AsyncReader, ServerOsApi};
use zellij_server::panes::PaneId;
use zellij_server::ClientId;
use zellij_utils::data::Palette;
use zellij_utils::errors::prelude::*;
use zellij_utils::input::command::{RunCommand, TerminalAction};
use zellij_utils::ipc::{
ClientToServerMsg, IpcReceiverWithContext, IpcSenderWithContext, ServerToClientMsg,
};
use zellij_utils::shared::default_palette;
use crate::fake_pty::{SharedPtys, FAKE_PID_BASE};
#[derive(Clone)]
struct NonBlockingClientSender {
buffer_tx: crossbeam::channel::Sender<ServerToClientMsg>,
}
impl NonBlockingClientSender {
fn new(mut ipc_sender: IpcSenderWithContext<ServerToClientMsg>) -> Self {
let (buffer_tx, buffer_rx) = crossbeam::channel::bounded::<ServerToClientMsg>(5000);
std::thread::Builder::new()
.name("non_blocking_client_sender".to_string())
.spawn(move || {
for msg in buffer_rx.iter() {
if ipc_sender.send_server_msg(msg).is_err() {
break;
}
}
})
.unwrap();
NonBlockingClientSender { buffer_tx }
}
}
#[derive(Clone, Default)]
pub struct FakeServerOsApi {
pub shared_ptys: SharedPtys,
non_blocking_client_senders: Arc<Mutex<HashMap<ClientId, NonBlockingClientSender>>>,
fake_filesystem: Arc<Mutex<HashMap<String, String>>>,
}
impl FakeServerOsApi {
pub fn written_files(&self) -> HashMap<String, String> {
self.fake_filesystem.lock().unwrap().clone()
}
pub fn connected_client_count(&self) -> usize {
self.non_blocking_client_senders.lock().unwrap().len()
}
}
impl ServerOsApi for FakeServerOsApi {
fn set_terminal_size_using_terminal_id(
&self,
id: u32,
cols: u16,
rows: u16,
_width_in_pixels: Option<u16>,
_height_in_pixels: Option<u16>,
) -> Result<()> {
self.shared_ptys.set_size(id, cols, rows);
Ok(())
}
fn spawn_terminal(
&self,
terminal_action: TerminalAction,
quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
_default_editor: Option<PathBuf>,
) -> Result<(u32, Box<dyn AsyncReader>, Option<u32>)> {
let terminal_id = self.shared_ptys.next_terminal_id();
let opened_file_contents = match &terminal_action {
TerminalAction::OpenFile(payload) => self
.fake_filesystem
.lock()
.unwrap()
.get(&payload.path.to_string_lossy().to_string())
.cloned(),
_ => None,
};
let fake_async_reader =
self.shared_ptys
.register(terminal_id, Some(terminal_action), Some(quit_cb));
if let Some(contents) = opened_file_contents {
let contents_with_carriage_returns =
contents.replace("\r\n", "\n").replace('\n', "\r\n");
self.shared_ptys
.write_output(terminal_id, contents_with_carriage_returns.as_bytes());
}
Ok((
terminal_id,
fake_async_reader,
Some(FAKE_PID_BASE + terminal_id),
))
}
fn reserve_terminal_id(&self) -> Result<u32> {
let terminal_id = self.shared_ptys.next_terminal_id();
let _ = self.shared_ptys.register(terminal_id, None, None);
Ok(terminal_id)
}
fn write_to_tty_stdin(&self, terminal_id: u32, buf: &[u8]) -> Result<usize> {
if self.shared_ptys.append_stdin(terminal_id, buf) {
Ok(buf.len())
} else {
Err(anyhow!("no fake pty for terminal id {terminal_id}"))
}
}
fn tcdrain(&self, _terminal_id: u32) -> Result<()> {
Ok(())
}
fn kill(&self, pid: u32) -> Result<()> {
self.shared_ptys
.exit_terminal(pid.saturating_sub(FAKE_PID_BASE), None);
Ok(())
}
fn force_kill(&self, pid: u32) -> Result<()> {
self.shared_ptys
.exit_terminal(pid.saturating_sub(FAKE_PID_BASE), None);
Ok(())
}
fn send_sigint(&self, pid: u32) -> Result<()> {
self.shared_ptys
.exit_terminal(pid.saturating_sub(FAKE_PID_BASE), None);
Ok(())
}
fn box_clone(&self) -> Box<dyn ServerOsApi> {
Box::new(self.clone())
}
fn send_to_client(&self, client_id: ClientId, msg: ServerToClientMsg) -> Result<()> {
if let Some(non_blocking_client_sender) = self
.non_blocking_client_senders
.lock()
.unwrap()
.get(&client_id)
{
non_blocking_client_sender
.buffer_tx
.try_send(msg)
.with_context(|| format!("failed to buffer message for client {client_id}"))?;
}
Ok(())
}
fn new_client(
&mut self,
client_id: ClientId,
stream: LocalSocketStream,
) -> Result<IpcReceiverWithContext<ClientToServerMsg>> {
let ipc_receiver = IpcReceiverWithContext::new(stream);
let non_blocking_client_sender = NonBlockingClientSender::new(ipc_receiver.get_sender());
self.non_blocking_client_senders
.lock()
.unwrap()
.insert(client_id, non_blocking_client_sender);
Ok(ipc_receiver)
}
fn new_client_with_reply(
&mut self,
_client_id: ClientId,
_stream: LocalSocketStream,
_reply_stream: LocalSocketStream,
) -> Result<IpcReceiverWithContext<ClientToServerMsg>> {
unimplemented!("windows dual-pipe IPC is not used by the test harness")
}
fn remove_client(&mut self, client_id: ClientId) -> Result<()> {
self.non_blocking_client_senders
.lock()
.unwrap()
.remove(&client_id);
Ok(())
}
fn load_palette(&self) -> Palette {
default_palette()
}
fn get_cwd(&self, _pid: u32) -> Option<PathBuf> {
None
}
fn write_to_file(&mut self, buf: String, file: Option<String>) -> Result<()> {
if let Some(file) = file {
self.fake_filesystem.lock().unwrap().insert(file, buf);
}
Ok(())
}
fn re_run_command_in_terminal(
&self,
terminal_id: u32,
_run_command: RunCommand,
quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
) -> Result<(Box<dyn AsyncReader>, Option<u32>)> {
let fake_async_reader = self.shared_ptys.rerun(terminal_id, quit_cb);
Ok((fake_async_reader, Some(FAKE_PID_BASE + terminal_id)))
}
fn clear_terminal_id(&self, terminal_id: u32) -> Result<()> {
self.shared_ptys.remove(terminal_id);
Ok(())
}
}
+63
View File
@@ -0,0 +1,63 @@
const ESC_BYTE: u8 = 0x1b;
const fn ctrl(character: u8) -> [u8; 1] {
[character & 0x1f]
}
const fn alt(character: u8) -> [u8; 2] {
[ESC_BYTE, character]
}
pub const QUIT: [u8; 1] = ctrl(b'q');
pub const ESC: [u8; 1] = [ESC_BYTE];
pub const ENTER: [u8; 1] = [b'\r'];
pub const SPACE: [u8; 1] = [b' '];
pub const LOCK_MODE: [u8; 1] = ctrl(b'g');
pub const MOVE_FOCUS_LEFT_IN_NORMAL_MODE: [u8; 2] = alt(b'h');
pub const MOVE_FOCUS_RIGHT_IN_NORMAL_MODE: [u8; 2] = alt(b'l');
pub const PANE_MODE: [u8; 1] = ctrl(b'p');
pub const TMUX_MODE: [u8; 1] = ctrl(b'b');
pub const SPAWN_TERMINAL_IN_PANE_MODE: [u8; 1] = [b'n'];
pub const MOVE_FOCUS_IN_PANE_MODE: [u8; 1] = [b'p'];
pub const SPLIT_DOWN_IN_PANE_MODE: [u8; 1] = [b'd'];
pub const SPLIT_RIGHT_IN_PANE_MODE: [u8; 1] = [b'r'];
pub const SPLIT_RIGHT_IN_TMUX_MODE: [u8; 1] = [b'%'];
pub const TOGGLE_ACTIVE_TERMINAL_FULLSCREEN_IN_PANE_MODE: [u8; 1] = [b'f'];
pub const TOGGLE_FLOATING_PANES: [u8; 1] = [b'w'];
pub const CLOSE_PANE_IN_PANE_MODE: [u8; 1] = [b'x'];
pub const MOVE_FOCUS_DOWN_IN_PANE_MODE: [u8; 1] = [b'j'];
pub const MOVE_FOCUS_UP_IN_PANE_MODE: [u8; 1] = [b'k'];
pub const MOVE_FOCUS_LEFT_IN_PANE_MODE: [u8; 1] = [b'h'];
pub const MOVE_FOCUS_RIGHT_IN_PANE_MODE: [u8; 1] = [b'l'];
pub const RENAME_PANE_MODE: [u8; 1] = [b'c'];
pub const SCROLL_MODE: [u8; 1] = ctrl(b's');
pub const SCROLL_UP_IN_SCROLL_MODE: [u8; 1] = [b'k'];
pub const SCROLL_DOWN_IN_SCROLL_MODE: [u8; 1] = [b'j'];
pub const SCROLL_PAGE_UP_IN_SCROLL_MODE: [u8; 1] = ctrl(b'b');
pub const SCROLL_PAGE_DOWN_IN_SCROLL_MODE: [u8; 1] = ctrl(b'f');
pub const EDIT_SCROLLBACK: [u8; 1] = [b'e'];
pub const RESIZE_MODE: [u8; 1] = ctrl(b'n');
pub const RESIZE_DOWN_IN_RESIZE_MODE: [u8; 1] = [b'j'];
pub const RESIZE_UP_IN_RESIZE_MODE: [u8; 1] = [b'k'];
pub const RESIZE_LEFT_IN_RESIZE_MODE: [u8; 1] = [b'h'];
pub const RESIZE_RIGHT_IN_RESIZE_MODE: [u8; 1] = [b'l'];
pub const TAB_MODE: [u8; 1] = ctrl(b't');
pub const NEW_TAB_IN_TAB_MODE: [u8; 1] = [b'n'];
pub const SWITCH_NEXT_TAB_IN_TAB_MODE: [u8; 1] = [b'l'];
pub const SWITCH_PREV_TAB_IN_TAB_MODE: [u8; 1] = [b'h'];
pub const CLOSE_TAB_IN_TAB_MODE: [u8; 1] = [b'x'];
pub const RENAME_TAB_MODE: [u8; 1] = [b'r'];
pub const MOVE_TAB_LEFT: [u8; 2] = alt(b'i');
pub const MOVE_TAB_RIGHT: [u8; 2] = alt(b'o');
pub const SESSION_MODE: [u8; 1] = ctrl(b'o');
pub const DETACH_IN_SESSION_MODE: [u8; 1] = [b'd'];
pub const BRACKETED_PASTE_START: [u8; 6] = *b"\x1b[200~";
pub const BRACKETED_PASTE_END: [u8; 6] = *b"\x1b[201~";
+25
View File
@@ -0,0 +1,25 @@
#![cfg(unix)]
pub mod client_screen;
pub mod fake_client_os_api;
pub mod fake_pty;
pub mod fake_server_os_api;
pub mod keys;
pub mod runner;
pub mod test_env;
pub use client_screen::{ClientScreen, CursorPosition, GridSnapshot};
pub use fake_pty::FakePtyHandle;
pub use runner::{normalized, TestClient, TestRunner, TestSession};
pub use zellij_utils::data::LayoutInfo;
pub use zellij_utils::pane_size::Size;
use std::time::Duration;
pub fn default_timeout() -> Duration {
std::env::var("ZELLIJ_TEST_TIMEOUT_MS")
.ok()
.and_then(|v| v.parse().ok())
.map(Duration::from_millis)
.unwrap_or(Duration::from_secs(10))
}
+567
View File
@@ -0,0 +1,567 @@
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use zellij_client::os_input_output::SignalEvent;
use zellij_client::ClientInfo;
use zellij_utils::cli::{CliAction, CliArgs};
use zellij_utils::data::{ConnectToSession, LayoutInfo};
use zellij_utils::input::actions::Action;
use zellij_utils::input::options::Options;
use zellij_utils::pane_size::Size;
use zellij_utils::setup::Setup;
use crate::client_screen::GridSnapshot;
use crate::fake_client_os_api::{FakeClientHandle, FakeClientOsApi};
use crate::fake_pty::FakePtyHandle;
use crate::fake_server_os_api::FakeServerOsApi;
use crate::{keys, test_env};
pub struct TestRunner {
size: Size,
extra_config_kdl: String,
layout: Option<LayoutInfo>,
}
impl TestRunner {
pub fn new(size: Size) -> Self {
TestRunner {
size,
extra_config_kdl: String::new(),
layout: None,
}
}
pub fn with_config(mut self, extra_config_kdl: &str) -> Self {
self.extra_config_kdl.push('\n');
self.extra_config_kdl.push_str(extra_config_kdl);
self
}
pub fn with_layout(mut self, layout: LayoutInfo) -> Self {
self.layout = Some(layout);
self
}
pub fn start(self) -> TestSession {
test_env::init();
let session_name = test_env::unique_session_name();
let config_path = test_env::write_config(&session_name, &self.extra_config_kdl);
let data_dir = test_env::init().join("data");
let cli_args = CliArgs {
config: Some(config_path),
data_dir: Some(data_dir),
..Default::default()
};
let (config, default_layout_info, config_options, _, _) =
Setup::from_cli_args(&cli_args).expect("failed to load harness config");
let concurrency_slot = test_env::acquire_concurrency_slot();
let fake_server_os_api = FakeServerOsApi::default();
let server_thread: Arc<Mutex<Option<JoinHandle<()>>>> = Arc::new(Mutex::new(None));
let server_spawner = in_process_server_spawner(fake_server_os_api.clone(), &server_thread);
let (fake_client_os_api, fake_client_handle) =
FakeClientOsApi::new(self.size, Some(server_spawner));
let layout_info = self.layout.or(default_layout_info);
let client_thread = spawn_client_thread(
fake_client_os_api,
cli_args.clone(),
config.clone(),
config_options.clone(),
ClientInfo::New(session_name.clone(), layout_info, None),
);
TestSession {
session_name,
size: self.size,
cli_args,
config,
config_options,
fake_server_os_api,
server_thread,
main_client: TestClient {
fake_client_handle,
thread: Some(client_thread),
},
_concurrency_slot: concurrency_slot,
}
}
}
fn in_process_server_spawner(
fake_server_os_api: FakeServerOsApi,
server_thread: &Arc<Mutex<Option<JoinHandle<()>>>>,
) -> Box<dyn FnOnce(std::path::PathBuf) + Send> {
let server_thread = server_thread.clone();
Box::new(move |socket_path: std::path::PathBuf| {
let install_panic_hook = false;
let join_handle = std::thread::Builder::new()
.name("in_process_zellij_server".to_string())
.spawn(move || {
zellij_server::start_server_impl(
Box::new(fake_server_os_api),
socket_path,
install_panic_hook,
);
})
.unwrap();
*server_thread.lock().unwrap() = Some(join_handle);
})
}
fn new_pane_cli_action(
command: &[&str],
floating: bool,
close_on_exit: bool,
start_suspended: bool,
block_until_exit: bool,
) -> CliAction {
CliAction::NewPane {
direction: None,
command: command.iter().map(|part| part.to_string()).collect(),
plugin: None,
cwd: None,
floating,
in_place: false,
close_replaced_pane: false,
name: None,
close_on_exit,
start_suspended,
configuration: None,
skip_plugin_cache: false,
x: None,
y: None,
width: None,
height: None,
pinned: None,
stacked: false,
blocking: false,
block_until_exit_success: false,
block_until_exit_failure: false,
block_until_exit,
unblock_condition: None,
near_current_pane: false,
borderless: None,
tab_id: None,
}
}
fn referenced_contents_files(layout: &str) -> impl Iterator<Item = &str> {
layout
.match_indices("contents_file=\"")
.filter_map(|(start, marker)| {
let rest = &layout[start + marker.len()..];
rest.find('"').map(|end| &rest[..end])
})
}
fn spawn_client_thread(
fake_client_os_api: FakeClientOsApi,
cli_args: CliArgs,
config: zellij_utils::input::config::Config,
config_options: Options,
client_info: ClientInfo,
) -> JoinHandle<Option<ConnectToSession>> {
std::thread::Builder::new()
.name("in_process_zellij_client".to_string())
.spawn(move || {
let tab_position_to_focus = None;
let pane_id_to_focus = None;
let is_a_reconnect = false;
let start_detached_and_exit = false;
zellij_client::start_client(
Box::new(fake_client_os_api),
cli_args,
config,
config_options,
client_info,
tab_position_to_focus,
pane_id_to_focus,
is_a_reconnect,
start_detached_and_exit,
)
})
.unwrap()
}
pub struct TestClient {
fake_client_handle: FakeClientHandle,
thread: Option<JoinHandle<Option<ConnectToSession>>>,
}
impl TestClient {
pub fn send_stdin(&self, bytes: &[u8]) {
self.fake_client_handle
.stdin_tx
.send(bytes.to_vec())
.expect("client stdin closed");
}
pub fn resize(&self, new_size: Size) {
*self.fake_client_handle.size.lock().unwrap() = new_size;
let _ = self.fake_client_handle.signal_tx.send(SignalEvent::Resize);
}
pub fn wait_until(
&self,
what: &str,
predicate: impl Fn(&GridSnapshot) -> bool,
) -> GridSnapshot {
self.fake_client_handle
.client_screen
.wait_until(what, predicate)
}
pub fn snapshot(&self) -> GridSnapshot {
self.fake_client_handle.client_screen.snapshot()
}
pub fn quit(mut self) {
self.send_stdin(&keys::QUIT);
self.join();
}
fn join(&mut self) -> Option<ConnectToSession> {
self.thread
.take()
.and_then(|join_handle: JoinHandle<Option<ConnectToSession>>| {
join_handle.join().expect("client thread panicked")
})
}
}
pub struct TestSession {
session_name: String,
size: Size,
cli_args: CliArgs,
config: zellij_utils::input::config::Config,
config_options: Options,
fake_server_os_api: FakeServerOsApi,
server_thread: Arc<Mutex<Option<JoinHandle<()>>>>,
main_client: TestClient,
_concurrency_slot: test_env::ConcurrencySlot,
}
pub struct CliClientHandle {
thread: JoinHandle<i32>,
}
impl CliClientHandle {
pub fn wait_for_exit(self) -> i32 {
self.thread.join().expect("cli client thread panicked")
}
}
impl TestSession {
pub fn session_name(&self) -> &str {
&self.session_name
}
pub fn expect_pty_spawn(&self) -> FakePtyHandle {
let terminal_id = self
.fake_server_os_api
.shared_ptys
.wait_for("a pty spawn", |fake_pty_registry| {
fake_pty_registry.spawn_queue.pop_front()
});
FakePtyHandle {
terminal_id,
shared_ptys: self.fake_server_os_api.shared_ptys.clone(),
}
}
pub fn send_stdin(&self, bytes: &[u8]) {
self.main_client.send_stdin(bytes);
}
pub fn resize(&self, new_size: Size) {
self.main_client.resize(new_size);
}
pub fn wait_until(
&self,
what: &str,
predicate: impl Fn(&GridSnapshot) -> bool,
) -> GridSnapshot {
self.main_client.wait_until(what, predicate)
}
pub fn snapshot(&self) -> GridSnapshot {
self.main_client.snapshot()
}
pub fn wait_for_app_load(&self) -> GridSnapshot {
self.main_client.wait_until("app to load", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.tab_bar_appears()
&& grid_snapshot.cursor.is_some()
})
}
pub fn written_files(&self) -> std::collections::HashMap<String, String> {
self.fake_server_os_api.written_files()
}
pub fn attach_client(&self, size: Size) -> TestClient {
let (fake_client_os_api, fake_client_handle) = FakeClientOsApi::new(size, None);
let thread = spawn_client_thread(
fake_client_os_api,
self.cli_args.clone(),
self.config.clone(),
self.config_options.clone(),
ClientInfo::Attach(self.session_name.clone(), self.config_options.clone()),
);
TestClient {
fake_client_handle,
thread: Some(thread),
}
}
pub fn attach_watcher(&self, size: Size) -> TestClient {
let (fake_client_os_api, fake_client_handle) = FakeClientOsApi::new(size, None);
let thread = spawn_client_thread(
fake_client_os_api,
self.cli_args.clone(),
self.config.clone(),
self.config_options.clone(),
ClientInfo::Watch(self.session_name.clone(), self.config_options.clone()),
);
TestClient {
fake_client_handle,
thread: Some(thread),
}
}
pub fn run_cli_action(&self, cli_action: CliAction) -> i32 {
self.spawn_cli_client(cli_action)
.join()
.expect("cli client thread panicked")
}
fn spawn_cli_client(&self, cli_action: CliAction) -> JoinHandle<i32> {
let actions = Action::actions_from_cli(
cli_action,
Box::new(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"))),
Some(self.config.clone()),
)
.expect("failed to build cli actions");
let (fake_client_os_api, _fake_client_handle) = FakeClientOsApi::new(self.size, None);
let session_name = self.session_name.clone();
std::thread::Builder::new()
.name("in_process_cli_client".to_string())
.spawn(move || {
zellij_client::cli_client::start_cli_client(
Box::new(fake_client_os_api),
&session_name,
actions,
)
})
.unwrap()
}
pub fn save_session(&self) {
self.run_cli_action(CliAction::SaveSession);
}
pub fn wait_for_serialized_session(&self) {
let layout_path = zellij_utils::consts::session_layout_cache_file_name(&self.session_name);
let session_dir = layout_path.parent().map(|parent| parent.to_path_buf());
let deadline = std::time::Instant::now() + crate::default_timeout();
loop {
if let Ok(layout) = std::fs::read_to_string(&layout_path) {
let referenced_contents_files_exist = session_dir.as_ref().map_or(true, |dir| {
referenced_contents_files(&layout).all(|name| dir.join(name).exists())
});
if referenced_contents_files_exist {
return;
}
}
if std::time::Instant::now() >= deadline {
panic!(
"timed out waiting for session serialization at {}",
layout_path.display()
);
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
pub fn resurrect(&mut self, size: Size) {
let layout_path = zellij_utils::consts::session_layout_cache_file_name(&self.session_name);
assert!(
layout_path.exists(),
"no serialized layout to resurrect from at {}",
layout_path.display()
);
let mut cli_args = self.cli_args.clone();
if cli_args.config_dir.is_none() {
cli_args.config_dir = cli_args
.config
.as_ref()
.and_then(|config| config.parent().map(|parent| parent.to_path_buf()));
}
let fake_server_os_api = FakeServerOsApi::default();
let server_thread: Arc<Mutex<Option<JoinHandle<()>>>> = Arc::new(Mutex::new(None));
let server_spawner = in_process_server_spawner(fake_server_os_api.clone(), &server_thread);
let (fake_client_os_api, fake_client_handle) =
FakeClientOsApi::new(size, Some(server_spawner));
let client_thread = spawn_client_thread(
fake_client_os_api,
cli_args,
self.config.clone(),
self.config_options.clone(),
ClientInfo::Resurrect(self.session_name.clone(), layout_path, false, None),
);
self.fake_server_os_api = fake_server_os_api;
self.server_thread = server_thread;
self.main_client = TestClient {
fake_client_handle,
thread: Some(client_thread),
};
}
pub fn override_layout(&self, layout_name: &str) {
self.run_cli_action(CliAction::OverrideLayout {
layout: Some(PathBuf::from(layout_name)),
layout_string: None,
layout_dir: None,
retain_existing_terminal_panes: false,
retain_existing_plugin_panes: false,
apply_only_to_active_tab: false,
});
}
pub fn run_suspended_command(&self, command: &[&str]) {
let start_suspended = true;
self.run_cli_action(new_pane_cli_action(
command,
false,
false,
start_suspended,
false,
));
}
pub fn run_blocking_command(&self, command: &[&str]) -> CliClientHandle {
let floating = false;
let close_on_exit = true;
let block_until_exit = true;
CliClientHandle {
thread: self.spawn_cli_client(new_pane_cli_action(
command,
floating,
close_on_exit,
false,
block_until_exit,
)),
}
}
pub fn run_blocking_floating_command(&self, command: &[&str]) -> CliClientHandle {
let floating = true;
let close_on_exit = true;
let block_until_exit = true;
CliClientHandle {
thread: self.spawn_cli_client(new_pane_cli_action(
command,
floating,
close_on_exit,
false,
block_until_exit,
)),
}
}
pub fn detach_main_client(&mut self) {
let connected_clients_before_detach = self.fake_server_os_api.connected_client_count();
self.send_stdin(&keys::SESSION_MODE);
self.send_stdin(&keys::DETACH_IN_SESSION_MODE);
self.main_client.join();
self.wait_for_server_to_release_a_client(connected_clients_before_detach);
}
fn wait_for_server_to_release_a_client(&self, connected_clients_before_detach: usize) {
let deadline = std::time::Instant::now() + crate::default_timeout();
loop {
if self.fake_server_os_api.connected_client_count() < connected_clients_before_detach {
return;
}
if std::time::Instant::now() >= deadline {
panic!("timed out waiting for server to release the detached client");
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
pub fn quit(&mut self) {
if self.main_client.thread.is_some() {
self.send_stdin(&keys::QUIT);
self.main_client.join();
}
if let Some(server_thread) = self.server_thread.lock().unwrap().take() {
server_thread.join().expect("server thread panicked");
}
}
fn send_quit_without_joining(&self) {
let _ = self
.main_client
.fake_client_handle
.stdin_tx
.send(keys::QUIT.to_vec());
}
}
impl Drop for TestSession {
fn drop(&mut self) {
if self.main_client.thread.is_some() {
self.send_quit_without_joining();
}
}
}
pub fn normalized(grid_snapshot: &GridSnapshot) -> String {
let text = replace_unique_session_name(&grid_snapshot.text);
let text = strip_swap_layout_indication(&text);
strip_trailing_whitespace(&text)
}
fn replace_unique_session_name(text: &str) -> String {
regex::Regex::new(r"Zellij \(test-\d+\)")
.unwrap()
.replace_all(text, "Zellij (test)")
.to_string()
}
fn strip_swap_layout_indication(text: &str) -> String {
let swap_layout_name = "BASE|VERTICAL|HORIZONTAL|STACKED|STAGGERED|ENLARGED|SPREAD";
let powerline_separated_or_plain = |prefix: &str| {
regex::Regex::new(&format!(
"{} [\u{e0b0}]? ?(?:{}) ?[\u{e0b0}]?[ ]*\n",
prefix, swap_layout_name
))
.unwrap()
};
let normal_mode = powerline_separated_or_plain("Alt <\\[\\]>");
let tmux_mode_1 = powerline_separated_or_plain("Alt \\[\\|SPACE\\|Alt \\]");
let tmux_mode_2 = powerline_separated_or_plain("Alt \\[\\|Alt \\]\\|SPACE");
let tab_bar = regex::Regex::new(&format!(
" {{2,}}[\u{e0b0}]? ?(?:{}) ?[\u{e0b0}]?[ ]*\n",
swap_layout_name
))
.unwrap();
let text = normal_mode.replace_all(text, "\n");
let text = tmux_mode_1.replace_all(&text, "\n");
let text = tmux_mode_2.replace_all(&text, "\n");
tab_bar.replace_all(&text, "\n").to_string()
}
fn strip_trailing_whitespace(text: &str) -> String {
regex::Regex::new(r"\s*\n")
.unwrap()
.replace_all(text, "\n")
.to_string()
}
+202
View File
@@ -0,0 +1,202 @@
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::OnceLock;
use std::time::Duration;
const TEMP_PARENT: &str = "/tmp/zellij-test";
const TEST_ROOT_SUBDIRS: [&str; 8] = [
"sock", "home", "cache", "data", "config", "runtime", "cwd", "tmp",
];
static TEST_ROOT: OnceLock<PathBuf> = OnceLock::new();
static OWN_PROCESS_LOCK: OnceLock<File> = OnceLock::new();
static SESSION_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub fn init() -> &'static Path {
TEST_ROOT
.get_or_init(|| {
let test_root = create_test_root();
isolate_process_environment(&test_root);
zellij_utils::logging::configure_logger();
test_root
})
.as_path()
}
fn create_test_root() -> PathBuf {
let parent = PathBuf::from(TEMP_PARENT);
std::fs::create_dir_all(&parent).unwrap();
hold_own_process_lock(&parent);
remove_roots_of_dead_test_processes(&parent);
let test_root = parent.join(std::process::id().to_string());
for subdir in TEST_ROOT_SUBDIRS {
std::fs::create_dir_all(test_root.join(subdir)).unwrap();
}
test_root
}
fn process_lock_path(parent: &Path, pid: u32) -> PathBuf {
parent.join(format!("{}.lock", pid))
}
fn hold_own_process_lock(parent: &Path) {
let lock_file = OpenOptions::new()
.create(true)
.write(true)
.open(process_lock_path(parent, std::process::id()))
.unwrap();
lock_file.lock().unwrap();
let _ = OWN_PROCESS_LOCK.set(lock_file);
}
fn remove_roots_of_dead_test_processes(parent: &Path) {
let Ok(entries) = std::fs::read_dir(parent) else {
return;
};
for entry in entries.flatten() {
let file_name = entry.file_name();
let Some(owner_pid) = file_name
.to_str()
.and_then(|name| name.strip_suffix(".lock"))
.and_then(|pid| pid.parse::<u32>().ok())
else {
continue;
};
if owner_pid == std::process::id() {
continue;
}
let Ok(lock_file) = OpenOptions::new().write(true).open(entry.path()) else {
continue;
};
if lock_file.try_lock().is_ok() {
let _ = std::fs::remove_dir_all(parent.join(owner_pid.to_string()));
let _ = std::fs::remove_file(entry.path());
let _ = lock_file.unlock();
}
}
}
fn isolate_process_environment(test_root: &Path) {
std::env::set_var("TMPDIR", test_root.join("tmp"));
std::env::set_var("ZELLIJ_SOCKET_DIR", test_root.join("sock"));
std::env::set_var("HOME", test_root.join("home"));
std::env::set_var("XDG_CACHE_HOME", test_root.join("cache"));
std::env::set_var("XDG_DATA_HOME", test_root.join("data"));
std::env::set_var("XDG_CONFIG_HOME", test_root.join("config"));
std::env::set_var("XDG_RUNTIME_DIR", test_root.join("runtime"));
std::env::remove_var("ZELLIJ");
std::env::remove_var("ZELLIJ_SESSION_NAME");
std::env::remove_var("ZELLIJ_CONFIG_FILE");
std::env::remove_var("ZELLIJ_CONFIG_DIR");
std::env::set_current_dir(test_root.join("cwd")).unwrap();
}
pub fn unique_session_name() -> String {
let session_index = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("test-{}", session_index)
}
pub struct ConcurrencySlot {
_slot_file: File,
}
fn is_truthy(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes"
)
}
fn slot_count() -> usize {
if std::env::var("ZELLIJ_TEST_SERIAL")
.map(|v| is_truthy(&v))
.unwrap_or(false)
{
return 1;
}
if let Some(explicit) = std::env::var("ZELLIJ_TEST_CONCURRENCY")
.ok()
.and_then(|v| v.parse::<usize>().ok())
{
return explicit.max(1);
}
let cpus = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4);
(cpus / 4).max(1)
}
pub fn acquire_concurrency_slot() -> ConcurrencySlot {
let slots = slot_count();
let slot_dir = PathBuf::from(TEMP_PARENT).join("concurrency-slots");
std::fs::create_dir_all(&slot_dir).unwrap();
loop {
for slot_index in 0..slots {
let slot_file = OpenOptions::new()
.create(true)
.write(true)
.open(slot_dir.join(format!("slot-{}", slot_index)))
.unwrap();
if slot_file.try_lock().is_ok() {
return ConcurrencySlot {
_slot_file: slot_file,
};
}
}
std::thread::sleep(Duration::from_millis(10));
}
}
pub const DEFAULT_TEST_CONFIG: &str = r#"
show_startup_tips false
show_release_notes false
session_serialization false
disable_session_metadata true
visual_bell false
mouse_mode false
advanced_mouse_actions false
theme "default"
"#;
pub fn write_config(session_name: &str, extra_config_kdl: &str) -> PathBuf {
let test_root = init();
let config_path = test_root
.join("config")
.join(format!("{}-config.kdl", session_name));
let contents = format!(
"{}\n{}\n",
defaults_overridden_by(extra_config_kdl),
extra_config_kdl
);
std::fs::write(&config_path, contents).unwrap();
config_path
}
fn defaults_overridden_by(extra_config_kdl: &str) -> String {
let overridden_keys: std::collections::HashSet<&str> = extra_config_kdl
.lines()
.filter_map(|line| line.split_whitespace().next())
.collect();
DEFAULT_TEST_CONFIG
.lines()
.filter(|line| {
line.split_whitespace()
.next()
.map_or(true, |key| !overridden_keys.contains(key))
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn log_file_path() -> PathBuf {
init();
zellij_utils::consts::ZELLIJ_TMP_LOG_FILE.clone()
}
pub fn log_tail(max_lines: usize) -> String {
let contents = std::fs::read_to_string(log_file_path()).unwrap_or_default();
let lines: Vec<&str> = contents.lines().collect();
let start = lines.len().saturating_sub(max_lines);
lines[start..].join("\n")
}
+157
View File
@@ -0,0 +1,157 @@
#![cfg(unix)]
use insta::assert_snapshot;
use zellij_integration_tests::{keys, normalized, FakePtyHandle, Size, TestRunner, TestSession};
const TERMINAL_SIZE: Size = Size {
cols: 120,
rows: 24,
};
const PROMPT: &[u8] = b"$ ";
const PROMPT_ROW: usize = 2;
const FIRST_PANE_PROMPT_X: usize = 3;
const RIGHT_PANE_PROMPT_X: usize = 63;
const CHANGED_KEYS_CONFIG: &str = r#"
keybinds clear-defaults=true {
normal {
bind "F1" { SwitchToMode "Locked"; }
bind "F2" { SwitchToMode "Pane"; }
bind "F3" { SwitchToMode "Tab"; }
bind "F4" { SwitchToMode "Resize"; }
bind "F5" { SwitchToMode "Move"; }
bind "F6" { SwitchToMode "Scroll"; }
bind "Alt F7" { SwitchToMode "Session"; }
bind "Ctrl F8" { Quit; }
}
}
"#;
fn claim_first_terminal_and_wait_for_prompt(zellij: &TestSession) -> FakePtyHandle {
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until(
"first terminal prompt rendered in loaded app",
|grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
},
);
terminal
}
fn split_right_and_wait_for_prompt(zellij: &TestSession) -> FakePtyHandle {
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::SPLIT_RIGHT_IN_PANE_MODE);
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("right terminal prompt rendered", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(RIGHT_PANE_PROMPT_X, PROMPT_ROW)
});
terminal
}
#[test]
fn exit_zellij() {
let zellij = TestRunner::new(TERMINAL_SIZE).start();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::QUIT);
zellij.wait_until("zellij said goodbye", |grid_snapshot| {
!grid_snapshot.status_bar_appears() && grid_snapshot.contains("Bye from Zellij!")
});
}
#[test]
fn resize_terminal_window() {
let mut zellij = TestRunner::new(TERMINAL_SIZE).start();
let first_terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
let right_terminal = split_right_and_wait_for_prompt(&zellij);
zellij.resize(Size {
cols: 100,
rows: 24,
});
right_terminal.wait_for_size("right terminal reacted to window resize", |cols, _| {
cols < (TERMINAL_SIZE.cols / 2) as u16
});
first_terminal.wait_for_size("left terminal reacted to window resize", |cols, _| {
cols < (TERMINAL_SIZE.cols / 2) as u16
});
let grid_snapshot =
zellij.wait_until("app re-rendered at the new window size", |grid_snapshot| {
grid_snapshot.contains("Ctrl +")
&& grid_snapshot.tab_bar_appears()
&& grid_snapshot.cursor_is_at(53, PROMPT_ROW)
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn status_bar_loads_custom_keybindings() {
let zellij = TestRunner::new(TERMINAL_SIZE)
.with_config(CHANGED_KEYS_CONFIG)
.start();
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
let grid_snapshot = zellij.wait_until(
"status bar reflects the custom keybindings",
|grid_snapshot| {
grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
&& grid_snapshot.contains("LOCK")
},
);
assert_snapshot!(normalized(&grid_snapshot));
}
#[test]
fn use_custom_layout_with_relative_path() {
let layout_dir = format!(
"{}/../src/tests/fixtures/config-dirs/e2e-upside-down/layouts",
env!("CARGO_MANIFEST_DIR")
);
let config = format!(
"layout_dir \"{}\"\ndefault_layout \"upside-down\"\n",
layout_dir
);
let mut zellij = TestRunner::new(TERMINAL_SIZE).with_config(&config).start();
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
let grid_snapshot = zellij.wait_until("upside-down layout loaded from disk", |grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.contains("Zellij (test")
&& grid_snapshot.cursor.is_some()
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn edit_scrollback() {
let mut zellij = TestRunner::new(TERMINAL_SIZE).start();
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
terminal.output(b"SCROLLBACK_MARKER_ONE\r\nSCROLLBACK_MARKER_TWO\r\n");
zellij.wait_until("scrollback content rendered", |grid_snapshot| {
grid_snapshot.contains("SCROLLBACK_MARKER_TWO")
});
zellij.send_stdin(&keys::SCROLL_MODE);
zellij.send_stdin(&keys::EDIT_SCROLLBACK);
zellij.expect_pty_spawn();
let grid_snapshot = zellij.wait_until("scrollback editor pane open", |grid_snapshot| {
grid_snapshot.contains("EDITING SCROLLBACK")
&& grid_snapshot.contains("SCROLLBACK_MARKER_ONE")
&& grid_snapshot.contains("SCROLLBACK_MARKER_TWO")
&& grid_snapshot.status_bar_appears()
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
+128
View File
@@ -0,0 +1,128 @@
#![cfg(unix)]
use insta::assert_snapshot;
use zellij_integration_tests::{keys, normalized, FakePtyHandle, Size, TestRunner, TestSession};
const TERMINAL_SIZE: Size = Size {
cols: 120,
rows: 24,
};
const PROMPT: &[u8] = b"$ ";
const PROMPT_ROW: usize = 2;
const FIRST_PANE_PROMPT_X: usize = 3;
const RIGHT_PANE_PROMPT_X: usize = 63;
fn start_zellij() -> TestSession {
TestRunner::new(TERMINAL_SIZE).start()
}
fn claim_first_terminal_and_wait_for_prompt(zellij: &TestSession) -> FakePtyHandle {
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until(
"first terminal prompt rendered in loaded app",
|grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
},
);
terminal
}
fn split_pane_right(zellij: &TestSession) {
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::SPLIT_RIGHT_IN_PANE_MODE);
}
fn split_right_and_wait_for_prompt(zellij: &TestSession) -> FakePtyHandle {
split_pane_right(zellij);
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("right terminal prompt rendered", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(RIGHT_PANE_PROMPT_X, PROMPT_ROW)
});
terminal
}
fn resize_focused_pane_left(zellij: &TestSession) {
zellij.send_stdin(&keys::RESIZE_MODE);
zellij.send_stdin(&keys::RESIZE_LEFT_IN_RESIZE_MODE);
zellij.send_stdin(&keys::ENTER);
}
fn column_count(terminal: &FakePtyHandle, what: &str) -> u16 {
terminal.wait_for_size(what, |_, _| true).0
}
#[test]
fn starts_with_one_terminal() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let grid_snapshot = zellij.wait_until("steady loaded state", |grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn split_terminals_vertically() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
split_right_and_wait_for_prompt(&zellij);
let grid_snapshot = zellij.wait_until("split rendered in normal mode", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(RIGHT_PANE_PROMPT_X, PROMPT_ROW)
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn resize_pane() {
let mut zellij = start_zellij();
let first_terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
let right_terminal = split_right_and_wait_for_prompt(&zellij);
let initial_cols = column_count(&right_terminal, "right terminal initial width");
resize_focused_pane_left(&zellij);
let resized_cols = right_terminal
.wait_for_size("right terminal grew", |cols, _| cols > initial_cols)
.0;
first_terminal.wait_for_size("left terminal shrank", |cols, _| cols < initial_cols);
let cursor_shift_left = (resized_cols.saturating_sub(initial_cols)) as usize;
let grid_snapshot = zellij.wait_until("resized layout in normal mode", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(RIGHT_PANE_PROMPT_X - cursor_shift_left, PROMPT_ROW)
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn command_pane_closes_on_exit() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let right_terminal = split_right_and_wait_for_prompt(&zellij);
right_terminal.output(b"about to exit");
zellij.wait_until("right terminal output rendered", |grid_snapshot| {
grid_snapshot.contains("about to exit")
});
right_terminal.exit(Some(0));
zellij.wait_until(
"right terminal closed, focus back on first",
|grid_snapshot| {
!grid_snapshot.contains("about to exit")
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
},
);
zellij.quit();
}
+164
View File
@@ -0,0 +1,164 @@
#![cfg(unix)]
use insta::assert_snapshot;
use zellij_integration_tests::{keys, normalized, FakePtyHandle, Size, TestRunner, TestSession};
const TERMINAL_SIZE: Size = Size {
cols: 120,
rows: 24,
};
const PROMPT: &[u8] = b"$ ";
const PROMPT_ROW: usize = 2;
const FIRST_PANE_PROMPT_X: usize = 3;
fn start_zellij() -> TestSession {
TestRunner::new(TERMINAL_SIZE).start()
}
fn claim_first_terminal_and_wait_for_prompt(zellij: &TestSession) -> FakePtyHandle {
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("first terminal prompt rendered", |grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
terminal
}
#[test]
fn override_layout_from_default_to_compact() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.override_layout("compact");
let grid_snapshot =
zellij.wait_until("compact bar rendered with session name", |grid_snapshot| {
grid_snapshot.contains("Zellij (test")
&& grid_snapshot.contains("NORMAL")
&& grid_snapshot.contains("Tab #1")
&& !grid_snapshot.status_bar_appears()
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn send_command_through_the_cli() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.run_suspended_command(&["suspended-command"]);
let command_terminal = zellij.expect_pty_spawn();
zellij.wait_until("suspended command pane waiting to run", |grid_snapshot| {
grid_snapshot.contains("<Ctrl-c>")
});
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("command running", |grid_snapshot| {
!grid_snapshot.contains("<Ctrl-c>")
});
command_terminal.output(b"foo\r\n");
zellij.wait_until("first run printed foo", |grid_snapshot| {
grid_snapshot.contains("foo")
});
command_terminal.exit(Some(0));
zellij.wait_until("command pane held again after exit", |grid_snapshot| {
grid_snapshot.contains("EXIT CODE")
});
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("command re-running", |grid_snapshot| {
!grid_snapshot.contains("EXIT CODE")
});
command_terminal.output(b"foo\r\nfoo\r\n");
let grid_snapshot = zellij.wait_until("command re-ran", |grid_snapshot| {
grid_snapshot.text.matches("foo").count() >= 2
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn send_blocking_command_through_the_cli() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let blocking_command = zellij.run_blocking_floating_command(&["blocking-command"]);
let floating_terminal = zellij.expect_pty_spawn();
zellij.wait_until("floating command pane appeared", |grid_snapshot| {
grid_snapshot.contains("PIN")
});
floating_terminal.exit(Some(42));
assert_eq!(blocking_command.wait_for_exit(), 42);
let grid_snapshot = zellij.wait_until("floating pane closed on exit", |grid_snapshot| {
!grid_snapshot.contains("PIN")
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn watcher_client_functionality() {
let mut zellij = start_zellij();
let first_terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
first_terminal.output(b"\r\nWATCHER_OUTPUT_1\r\n");
zellij.wait_until("first output line", |grid_snapshot| {
grid_snapshot.contains("WATCHER_OUTPUT_1")
});
let watcher = zellij.attach_watcher(TERMINAL_SIZE);
watcher.wait_until("watcher connected and sees the output", |grid_snapshot| {
grid_snapshot.status_bar_appears() && grid_snapshot.contains("WATCHER_OUTPUT_1")
});
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::SPLIT_RIGHT_IN_PANE_MODE);
let right_terminal = zellij.expect_pty_spawn();
right_terminal.output(PROMPT);
watcher.wait_until("watcher sees the split", |grid_snapshot| {
grid_snapshot.contains("┐┌")
});
let ignored_new_tab_from_watcher = [keys::TAB_MODE, keys::NEW_TAB_IN_TAB_MODE].concat();
watcher.send_stdin(&ignored_new_tab_from_watcher);
zellij.detach_main_client();
first_terminal.output(b"WATCHER_OUTPUT_2\r\nWATCHER_OUTPUT_3\r\nWATCHER_DONE\r\n");
watcher.wait_until(
"watcher keeps receiving output while no main client is attached",
|grid_snapshot| {
grid_snapshot.contains("WATCHER_DONE")
&& grid_snapshot.contains("┐┌")
&& !grid_snapshot.contains("Tab #2")
},
);
let main = zellij.attach_client(TERMINAL_SIZE);
let main_snapshot = main.wait_until(
"re-attached main converges on the same state",
|grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("WATCHER_DONE")
&& grid_snapshot.contains("┐┌")
},
);
assert_snapshot!(normalized(&main_snapshot));
let watcher_snapshot =
watcher.wait_until("watcher mirrors the re-attached session", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("WATCHER_DONE")
&& grid_snapshot.contains("┐┌")
&& !grid_snapshot.contains("Tab #2")
});
assert_snapshot!(normalized(&watcher_snapshot));
main.quit();
watcher.quit();
zellij.quit();
}
+217
View File
@@ -0,0 +1,217 @@
#![cfg(unix)]
use insta::assert_snapshot;
use zellij_integration_tests::{
keys, normalized, FakePtyHandle, GridSnapshot, Size, TestRunner, TestSession,
};
const TERMINAL_SIZE: Size = Size {
cols: 120,
rows: 24,
};
const PROMPT: &[u8] = b"$ ";
const PROMPT_ROW: usize = 2;
const FIRST_PANE_PROMPT_X: usize = 3;
const RIGHT_PANE_PROMPT_X: usize = 63;
fn start_zellij() -> TestSession {
TestRunner::new(TERMINAL_SIZE).start()
}
fn claim_first_terminal_and_wait_for_prompt(zellij: &TestSession) -> FakePtyHandle {
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until(
"first terminal prompt rendered in loaded app",
|grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
},
);
terminal
}
#[test]
fn mirrored_sessions() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config("mirror_session true")
.start();
claim_first_terminal_and_wait_for_prompt(&zellij);
let second_client = zellij.attach_client(TERMINAL_SIZE);
second_client.wait_until("second client loaded on the first tab", |grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
second_client.send_stdin(&keys::PANE_MODE);
second_client.send_stdin(&keys::SPLIT_RIGHT_IN_PANE_MODE);
let right_terminal = zellij.expect_pty_spawn();
right_terminal.output(PROMPT);
let mirror_focused_right_pane = |grid_snapshot: &GridSnapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("Pane #2")
&& grid_snapshot.cursor_is_at(RIGHT_PANE_PROMPT_X, PROMPT_ROW)
};
let main_grid = zellij.wait_until(
"main client follows the second client's focus into the split it never asked for",
mirror_focused_right_pane,
);
let second_grid = second_client.wait_until(
"second client shows the split it created",
mirror_focused_right_pane,
);
assert_eq!(
normalized(&main_grid),
normalized(&second_grid),
"mirrored clients must render an identical view"
);
assert_snapshot!(normalized(&main_grid));
second_client.quit();
zellij.quit();
}
#[test]
fn multiple_users_in_same_pane_and_tab() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let second_client = zellij.attach_client(TERMINAL_SIZE);
let second_grid =
second_client.wait_until("second client shares the focused pane", |grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("MY FOCUS")
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
let main_grid = zellij.wait_until(
"main client shows the shared-focus indicator",
|grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("MY FOCUS")
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
},
);
assert_snapshot!(normalized(&main_grid));
assert_snapshot!(normalized(&second_grid));
second_client.quit();
zellij.quit();
}
#[test]
fn multiple_users_in_different_panes_and_same_tab() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let second_client = zellij.attach_client(TERMINAL_SIZE);
second_client.wait_until("second client loaded on the first tab", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
second_client.send_stdin(&keys::PANE_MODE);
second_client.send_stdin(&keys::SPLIT_RIGHT_IN_PANE_MODE);
let right_terminal = zellij.expect_pty_spawn();
right_terminal.output(PROMPT);
let second_grid = second_client.wait_until(
"second client focused the new right pane",
|grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(RIGHT_PANE_PROMPT_X, PROMPT_ROW)
},
);
let main_grid = zellij.wait_until(
"main client sees the second client's split while staying on the left pane",
|grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.contains("Pane #2")
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
},
);
assert_snapshot!(normalized(&main_grid));
assert_snapshot!(normalized(&second_grid));
second_client.quit();
zellij.quit();
}
#[test]
fn multiple_users_in_different_tabs() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let second_client = zellij.attach_client(TERMINAL_SIZE);
second_client.wait_until("second client loaded on the first tab", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
second_client.send_stdin(&keys::TAB_MODE);
second_client.send_stdin(&keys::NEW_TAB_IN_TAB_MODE);
let second_tab_terminal = zellij.expect_pty_spawn();
second_tab_terminal.output(PROMPT);
let second_grid =
second_client.wait_until("second client moved to the new tab", |grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("Tab #2")
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
let main_grid = zellij.wait_until(
"main client sees the new tab while staying on the first tab",
|grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("Tab #2")
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
},
);
assert_snapshot!(normalized(&main_grid));
assert_snapshot!(normalized(&second_grid));
second_client.quit();
zellij.quit();
}
#[test]
fn detach_and_attach_session() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::SPLIT_RIGHT_IN_PANE_MODE);
let right_terminal = zellij.expect_pty_spawn();
right_terminal.output(PROMPT);
zellij.wait_until("right terminal prompt rendered", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(RIGHT_PANE_PROMPT_X, PROMPT_ROW)
});
right_terminal.output(b"I am some text");
zellij.wait_until("text rendered in the right terminal", |grid_snapshot| {
grid_snapshot.contains("I am some text")
});
zellij.detach_main_client();
let reattached_client = zellij.attach_client(TERMINAL_SIZE);
let grid_snapshot = reattached_client.wait_until(
"reattached client sees the restored split and text",
|grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("Pane #2")
&& grid_snapshot.contains("I am some text")
},
);
assert_snapshot!(normalized(&grid_snapshot));
reattached_client.quit();
zellij.quit();
}
+137
View File
@@ -0,0 +1,137 @@
#![cfg(unix)]
use insta::assert_snapshot;
use zellij_integration_tests::{keys, normalized, FakePtyHandle, Size, TestRunner, TestSession};
const TERMINAL_SIZE: Size = Size {
cols: 120,
rows: 24,
};
const PROMPT: &[u8] = b"$ ";
const PROMPT_ROW: usize = 2;
const FIRST_PANE_PROMPT_X: usize = 3;
const RIGHT_PANE_PROMPT_X: usize = 63;
fn start_zellij() -> TestSession {
TestRunner::new(TERMINAL_SIZE).start()
}
fn claim_first_terminal_and_wait_for_prompt(zellij: &TestSession) -> FakePtyHandle {
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until(
"first terminal prompt rendered in loaded app",
|grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
},
);
terminal
}
#[test]
fn lock_mode() {
let mut zellij = start_zellij();
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::LOCK_MODE);
zellij.wait_until("interface locked", |grid_snapshot| {
grid_snapshot.contains("LOCK") && !grid_snapshot.contains("PANE")
});
zellij.send_stdin(&keys::TAB_MODE);
zellij.send_stdin(&keys::NEW_TAB_IN_TAB_MODE);
zellij.send_stdin(b"abc");
terminal.wait_for_stdin("forwarded keys reached the pane", |stdin_bytes| {
stdin_bytes.windows(3).any(|window| window == b"abc")
});
let grid_snapshot =
zellij.wait_until("forwarded keys rendered in locked pane", |grid_snapshot| {
grid_snapshot.contains("abc")
&& !grid_snapshot.contains("PANE")
&& !grid_snapshot.contains("Tab #2")
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.send_stdin(&keys::LOCK_MODE);
zellij.wait_until("interface unlocked", |grid_snapshot| {
grid_snapshot.contains("PANE")
});
zellij.quit();
}
#[test]
fn tmux_mode() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::TMUX_MODE);
zellij.send_stdin(&keys::SPLIT_RIGHT_IN_TMUX_MODE);
let right_terminal = zellij.expect_pty_spawn();
right_terminal.output(PROMPT);
let grid_snapshot = zellij.wait_until("right terminal opened via tmux mode", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(RIGHT_PANE_PROMPT_X, PROMPT_ROW)
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn bracketed_paste() {
let mut zellij = start_zellij();
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::BRACKETED_PASTE_START);
zellij.send_stdin(&keys::TAB_MODE);
zellij.send_stdin(&keys::NEW_TAB_IN_TAB_MODE);
zellij.send_stdin(b"abc");
zellij.send_stdin(&keys::BRACKETED_PASTE_END);
terminal.wait_for_stdin("pasted text reached the pane", |stdin_bytes| {
stdin_bytes.windows(3).any(|window| window == b"abc")
});
let grid_snapshot = zellij.wait_until("pasted text rendered, no new tab", |grid_snapshot| {
grid_snapshot.contains("abc")
&& grid_snapshot.status_bar_appears()
&& !grid_snapshot.contains("Tab #2")
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn scrolling_inside_a_pane() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::SPLIT_RIGHT_IN_PANE_MODE);
let right_terminal = zellij.expect_pty_spawn();
right_terminal.output(PROMPT);
zellij.wait_until("right terminal prompt rendered", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(RIGHT_PANE_PROMPT_X, PROMPT_ROW)
});
for line in 1..=21 {
right_terminal.output(format!("line{}\r\n", line).as_bytes());
}
zellij.wait_until("right terminal filled past its viewport", |grid_snapshot| {
grid_snapshot.contains("line21")
});
zellij.send_stdin(&keys::SCROLL_MODE);
zellij.send_stdin(&keys::SCROLL_UP_IN_SCROLL_MODE);
zellij.send_stdin(&keys::SCROLL_UP_IN_SCROLL_MODE);
let grid_snapshot =
zellij.wait_until("scrolled up one line inside the pane", |grid_snapshot| {
grid_snapshot.contains("SCROLL: 2/2") && grid_snapshot.contains("PgDn|PgUp")
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
+132
View File
@@ -0,0 +1,132 @@
#![cfg(unix)]
use insta::assert_snapshot;
use zellij_integration_tests::{keys, normalized, FakePtyHandle, Size, TestRunner, TestSession};
const TERMINAL_SIZE: Size = Size {
cols: 120,
rows: 24,
};
const PROMPT: &[u8] = b"$ ";
const PROMPT_ROW: usize = 2;
const FIRST_PANE_PROMPT_X: usize = 3;
const RIGHT_PANE_PROMPT_X: usize = 63;
fn start_zellij() -> TestSession {
TestRunner::new(TERMINAL_SIZE)
.with_config("mouse_mode true")
.start()
}
fn sgr_mouse_report(column: usize, line: usize, button: u8) -> Vec<u8> {
format!("\u{1b}[<{};{};{}M", button, column, line).into_bytes()
}
fn claim_first_terminal_and_wait_for_prompt(zellij: &TestSession) -> FakePtyHandle {
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until(
"first terminal prompt rendered in loaded app",
|grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
},
);
terminal
}
fn split_right_and_wait_for_prompt(zellij: &TestSession) -> FakePtyHandle {
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::SPLIT_RIGHT_IN_PANE_MODE);
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("right terminal prompt rendered", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(RIGHT_PANE_PROMPT_X, PROMPT_ROW)
});
terminal
}
#[test]
fn focus_pane_with_mouse() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
split_right_and_wait_for_prompt(&zellij);
zellij.send_stdin(&sgr_mouse_report(2, 5, 0));
let grid_snapshot = zellij.wait_until("focus moved back to the left pane", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn scrolling_inside_a_pane_with_mouse() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let right_terminal = split_right_and_wait_for_prompt(&zellij);
for line in 1..=21 {
right_terminal.output(format!("line{}\r\n", line).as_bytes());
}
zellij.wait_until("right pane filled past its viewport", |grid_snapshot| {
grid_snapshot.contains("line21")
});
zellij.send_stdin(&sgr_mouse_report(64, 2, 64));
let grid_snapshot = zellij.wait_until("scrolled up inside the right pane", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("SCROLL:")
&& grid_snapshot.contains("line1 ")
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn pin_floating_panes() {
let mut zellij = start_zellij();
let tiled_terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::TOGGLE_FLOATING_PANES);
let floating_terminal = zellij.expect_pty_spawn();
floating_terminal.output(PROMPT);
zellij.wait_until("floating pane shows the unpinned button", |grid_snapshot| {
grid_snapshot.contains("PIN [ ]")
});
zellij.send_stdin(&sgr_mouse_report(87, 8, 0));
zellij.wait_until("floating pane became pinned", |grid_snapshot| {
grid_snapshot.contains("PIN [+]")
});
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::TOGGLE_FLOATING_PANES);
zellij.wait_until(
"focus settled on the tiled pane while the pinned pane stays up",
|grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("PIN [+]")
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
},
);
for line in 1..=13 {
tiled_terminal.output(format!("line{}{}\r\n", line, "a".repeat(112)).as_bytes());
}
let grid_snapshot = zellij.wait_until(
"pinned pane stays visible over the filled tiled pane",
|grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("PIN [+]")
&& grid_snapshot.contains("line13")
},
);
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
+228
View File
@@ -0,0 +1,228 @@
#![cfg(unix)]
use insta::assert_snapshot;
use zellij_integration_tests::{keys, normalized, FakePtyHandle, Size, TestRunner, TestSession};
const TERMINAL_SIZE: Size = Size {
cols: 120,
rows: 24,
};
const PROMPT: &[u8] = b"$ ";
const PROMPT_ROW: usize = 2;
const FIRST_PANE_PROMPT_X: usize = 3;
const RIGHT_PANE_PROMPT_X: usize = 63;
fn start_zellij() -> TestSession {
TestRunner::new(TERMINAL_SIZE).start()
}
fn claim_first_terminal_and_wait_for_prompt(zellij: &TestSession) -> FakePtyHandle {
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until(
"first terminal prompt rendered in loaded app",
|grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
},
);
terminal
}
fn split_right_and_wait_for_prompt(zellij: &TestSession) -> FakePtyHandle {
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::SPLIT_RIGHT_IN_PANE_MODE);
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("right terminal prompt rendered", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(RIGHT_PANE_PROMPT_X, PROMPT_ROW)
});
terminal
}
#[test]
fn cannot_split_terminals_vertically_when_active_terminal_is_too_small() {
let mut zellij = TestRunner::new(Size { cols: 8, rows: 20 }).start();
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("first terminal prompt rendered", |grid_snapshot| {
grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
let (width_before_split, _) = terminal.wait_for_size("first terminal sized", |_, _| true);
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::SPLIT_RIGHT_IN_PANE_MODE);
terminal.output(b"done");
let tab_bar_glyph = '\u{e0b0}';
let grid_snapshot = zellij.wait_until(
"split attempt processed, chrome rendered",
|grid_snapshot| {
grid_snapshot.contains("done")
&& grid_snapshot.contains("Ctrl +")
&& grid_snapshot
.lines()
.first()
.is_some_and(|first_line| first_line.contains(tab_bar_glyph))
},
);
assert_eq!(terminal.size().unwrap().0, width_before_split);
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn key_after_mode_switch_is_interpreted_in_new_mode_not_leaked_to_pane() {
let mut zellij = start_zellij();
let first_terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::SPLIT_RIGHT_IN_PANE_MODE);
let second_terminal = zellij.expect_pty_spawn();
second_terminal.output(PROMPT);
zellij.wait_until("split-right interpreted in pane mode", |grid_snapshot| {
grid_snapshot.cursor_is_at(RIGHT_PANE_PROMPT_X, PROMPT_ROW)
});
assert!(!first_terminal
.stdin_bytes()
.contains(&keys::SPLIT_RIGHT_IN_PANE_MODE[0]));
zellij.quit();
}
#[test]
fn toggle_pane_fullscreen() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
split_right_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::TOGGLE_ACTIVE_TERMINAL_FULLSCREEN_IN_PANE_MODE);
let grid_snapshot = zellij.wait_until("focused pane is fullscreen", |grid_snapshot| {
grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
&& grid_snapshot.contains("LOCK")
&& grid_snapshot.contains("(FULLSCREEN)")
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn close_pane() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
split_right_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::CLOSE_PANE_IN_PANE_MODE);
let grid_snapshot =
zellij.wait_until("right pane closed, focus back on first", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn closing_last_pane_exits_zellij() {
let zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::CLOSE_PANE_IN_PANE_MODE);
zellij.wait_until(
"zellij exited after closing the last pane",
|grid_snapshot| grid_snapshot.contains("Bye from Zellij!"),
);
}
#[test]
fn pane_closes_when_its_process_exits() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let right_terminal = split_right_and_wait_for_prompt(&zellij);
right_terminal.exit(Some(0));
let grid_snapshot =
zellij.wait_until("right pane closed, focus back on first", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn toggle_floating_panes() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::TOGGLE_FLOATING_PANES);
let floating_terminal = zellij.expect_pty_spawn();
floating_terminal.output(PROMPT);
let grid_snapshot = zellij.wait_until("floating pane appeared", |grid_snapshot| {
grid_snapshot.cursor_is_at(33, 8)
&& grid_snapshot.contains("STAGGERED")
&& grid_snapshot.contains("LOCK")
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn undo_rename_pane() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::RENAME_PANE_MODE);
zellij.send_stdin(b"aa");
zellij.send_stdin(&keys::ESC);
zellij.send_stdin(&keys::ESC);
let grid_snapshot = zellij.wait_until("pane name reverted to default", |grid_snapshot| {
grid_snapshot.contains("Pane #1") && grid_snapshot.contains("LOCK")
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn start_without_pane_frames() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config("pane_frames false")
.start();
let first_terminal = zellij.expect_pty_spawn();
first_terminal.output(PROMPT);
zellij.wait_until(
"first frameless terminal prompt rendered",
|grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(2, 1)
},
);
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::SPLIT_RIGHT_IN_PANE_MODE);
let right_terminal = zellij.expect_pty_spawn();
right_terminal.output(PROMPT);
let grid_snapshot = zellij.wait_until(
"right frameless terminal prompt rendered",
|grid_snapshot| grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(62, 1),
);
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
+41
View File
@@ -0,0 +1,41 @@
#![cfg(unix)]
use insta::assert_snapshot;
use zellij_integration_tests::{normalized, Size, TestRunner};
const TERMINAL_SIZE: Size = Size {
cols: 120,
rows: 24,
};
fn load_background_plugin_config() -> String {
let plugin_path = format!(
"{}/../zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm",
env!("CARGO_MANIFEST_DIR")
);
format!(
"plugins {{\n permission-requester location=\"file:{}\"\n}}\nload_plugins {{\n \"permission-requester\"\n}}\n",
plugin_path
)
}
#[test]
fn load_plugins_in_background_on_startup() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config(&load_background_plugin_config())
.start();
let terminal = zellij.expect_pty_spawn();
terminal.output(b"$ ");
let grid_snapshot = zellij
.wait_until("background plugin requests permissions", |grid_snapshot| {
grid_snapshot.contains("Allow? (y/n)") && grid_snapshot.tab_bar_appears()
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.send_stdin(b"y");
zellij.wait_until(
"permission prompt dismissed after granting",
|grid_snapshot| !grid_snapshot.contains("Allow? (y/n)"),
);
zellij.quit();
}
@@ -0,0 +1,108 @@
#![cfg(unix)]
use insta::assert_snapshot;
use zellij_integration_tests::{keys, normalized, LayoutInfo, Size, TestRunner, TestSession};
const TERMINAL_SIZE: Size = Size {
cols: 120,
rows: 24,
};
const PROMPT: &[u8] = b"$ ";
const RESURRECT_LAYOUT: &str = r#"
layout {
default_tab_template {
pane size=1 borderless=true {
plugin location="tab-bar"
}
children
pane size=1 borderless=true {
plugin location="status-bar"
}
}
tab name="alpha" {
pane
}
tab name="beta" focus=true {
pane
}
}
"#;
fn start_serializing_session(extra_config: &str) -> TestSession {
TestRunner::new(TERMINAL_SIZE)
.with_config(&format!("session_serialization true\n{}", extra_config))
.with_layout(LayoutInfo::Stringified(RESURRECT_LAYOUT.to_string()))
.start()
}
fn wait_for_layout_loaded(zellij: &TestSession) {
zellij.wait_until("layout loaded with both tabs", |grid_snapshot| {
grid_snapshot.contains("alpha")
&& grid_snapshot.contains("beta")
&& grid_snapshot.status_bar_appears()
});
}
#[test]
fn quit_and_resurrect_session() {
let mut zellij = start_serializing_session("");
wait_for_layout_loaded(&zellij);
zellij.send_stdin(&keys::PANE_MODE);
zellij.send_stdin(&keys::SPLIT_RIGHT_IN_PANE_MODE);
let new_pane = zellij.expect_pty_spawn();
new_pane.output(PROMPT);
zellij.wait_until("new pane opened before serialization", |grid_snapshot| {
grid_snapshot.contains("┐┌")
});
zellij.save_session();
zellij.wait_for_serialized_session();
zellij.quit();
zellij.resurrect(TERMINAL_SIZE);
let grid_snapshot = zellij.wait_until(
"resurrected session restored the runtime pane",
|grid_snapshot| {
grid_snapshot.contains("Zellij (test")
&& grid_snapshot.contains("alpha")
&& grid_snapshot.contains("beta")
&& grid_snapshot.contains("┐┌")
&& grid_snapshot.status_bar_appears()
},
);
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn quit_and_resurrect_session_with_viewport_serialization() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config("session_serialization true\nserialize_pane_viewport true")
.start();
let terminal = zellij.expect_pty_spawn();
terminal.output(b"VIEWPORT_CONTENT_TO_RESTORE\n");
zellij.wait_until(
"pane content rendered before serialization",
|grid_snapshot| {
grid_snapshot.contains("VIEWPORT_CONTENT_TO_RESTORE")
&& grid_snapshot.status_bar_appears()
},
);
zellij.save_session();
zellij.wait_for_serialized_session();
zellij.quit();
zellij.resurrect(TERMINAL_SIZE);
let grid_snapshot =
zellij.wait_until("resurrected viewport and chrome settled", |grid_snapshot| {
grid_snapshot.contains("VIEWPORT_CONTENT_TO_RESTORE")
&& grid_snapshot.contains("Zellij (test")
&& grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/app.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ EDITING SCROLLBACK ──────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ SCROLLBACK_MARKER_ONE │
│SCROLLBACK_MARKER_TWO │
│█ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/app.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ───────────────────────────────────────┐┌ Pane #2 ───────────────────────────────────────┐
│$ ││$ █ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└────────────────────────────────────────────────┘└────────────────────────────────────────────────┘
Ctrl + g  p  t  n  h  s  o  q  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/app.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
F1  LOCK  F2  PANE  F3  TAB  F4  RESIZE  F5  MOVE  F6  SEARCH  Alt F7  SESSION  Ctrl F8  QUIT 
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/app.rs
expression: normalized(&grid_snapshot)
---
Tip: UNBOUND => open new pane. UNBOUND => navigate between panes. UNBOUND => increase/decrease pane size.
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Zellij (test)  Tab #1 
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/basic.rs
expression: normalized(&snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ───────────────────────────────────────────┐┌ Pane #2 ───────────────────────────────────────────────────────┐
│$ ││$ █ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└────────────────────────────────────────────────────┘└────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/basic.rs
expression: normalized(&snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
│$ ││$ █ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/basic.rs
expression: normalized(&snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/cli.rs
expression: normalized(&grid_snapshot)
---
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Zellij (test) NORMAL  Tab #1 
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/cli.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/cli.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────┐┌ suspended-command ───────────────────────────────────────┐
│$ ││foo │
│ ││foo │
│ ││█ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/cli.rs
expression: normalized(&watcher_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
│$ ││$ █ │
│WATCHER_OUTPUT_1 ││ │
│WATCHER_OUTPUT_2 ││ │
│WATCHER_OUTPUT_3 ││ │
│WATCHER_DONE ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/cli.rs
expression: normalized(&main_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
│$ ││$ █ │
│WATCHER_OUTPUT_1 ││ │
│WATCHER_OUTPUT_2 ││ │
│WATCHER_OUTPUT_3 ││ │
│WATCHER_DONE ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/clients.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
│$ ││$ I am some text█ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/clients.rs
expression: normalized(&main_grid)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
│$ ││$ █ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/clients.rs
expression: normalized(&second_grid)
---
Zellij (test)  Tab #1 [ ]
┌ Pane #1 ───────────┤ FOCUSED USER: ├───────────────────┐┌ Pane #2 ──────────────┤ MY FOCUS ├───────────────────────┐
│$ ││$ █ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/clients.rs
expression: normalized(&main_grid)
---
Zellij (test)  Tab #1 [ ]
┌ Pane #1 ──────────────┤ MY FOCUS ├───────────────────────┐┌ Pane #2 ───────────┤ FOCUSED USER: ├───────────────────┐
│$ █ ││$ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/clients.rs
expression: normalized(&second_grid)
---
Zellij (test)  Tab #1 [ ] Tab #2 
┌ Pane #1 ────────────────────────────────────────────┤ MY FOCUS ├─────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/clients.rs
expression: normalized(&main_grid)
---
Zellij (test)  Tab #1  Tab #2 [ ]
┌ Pane #1 ────────────────────────────────────────────┤ MY FOCUS ├─────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/clients.rs
expression: normalized(&second_grid)
---
Zellij (test)  Tab #1 [ ]
┌ Pane #1 ─────────────────────────────────────────┤ MY FOCUS AND: ├─────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/clients.rs
expression: normalized(&main_grid)
---
Zellij (test)  Tab #1 [ ]
┌ Pane #1 ─────────────────────────────────────────┤ MY FOCUS AND: ├─────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/modes.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ nabc█ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/modes.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ nabc█ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK 
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/modes.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────── SCROLL: 2/2 ┐
│$ ││$ line1 │
│ ││line2 │
│ ││line3 │
│ ││line4 │
│ ││line5 │
│ ││line6 │
│ ││line7 │
│ ││line8 │
│ ││line9 │
│ ││line10 │
│ ││line11 │
│ ││line12 │
│ ││line13 │
│ ││line14 │
│ ││line15 │
│ ││line16 │
│ ││line17 │
│ ││line18 │
│ ││line19 │
│ ││█ine20 │
└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
Ctrl + <s> SEARCH   s  Search  ↓↑  Scroll  PgDn|PgUp  Scroll  d|u  Scroll  e  Edit  ENTER  Select 
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/modes.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
│$ ││$ █ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/mouse.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
│$ █ ││$ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/mouse.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ line1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa│
│a │
│line2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa │
│line3aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa │
│line4aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa │
│line5aaaaaaaaaaaaaaaaaaaaaaaa┌ Pane #2 ──────────────────────────────────────── PIN [+] ┐aaaaaaaaaaaaaaaaaaaaaaaaaaaa │
│line6aaaaaaaaaaaaaaaaaaaaaaaa│$ │aaaaaaaaaaaaaaaaaaaaaaaaaaaa │
│line7aaaaaaaaaaaaaaaaaaaaaaaa│ │aaaaaaaaaaaaaaaaaaaaaaaaaaaa │
│line8aaaaaaaaaaaaaaaaaaaaaaaa│ │aaaaaaaaaaaaaaaaaaaaaaaaaaaa │
│line9aaaaaaaaaaaaaaaaaaaaaaaa│ │aaaaaaaaaaaaaaaaaaaaaaaaaaaa │
│line10aaaaaaaaaaaaaaaaaaaaaaa│ │aaaaaaaaaaaaaaaaaaaaaaaaaaaaa│
│line11aaaaaaaaaaaaaaaaaaaaaaa│ │aaaaaaaaaaaaaaaaaaaaaaaaaaaaa│
│line12aaaaaaaaaaaaaaaaaaaaaaa│ │aaaaaaaaaaaaaaaaaaaaaaaaaaaaa│
│line13aaaaaaaaaaaaaaaaaaaaaaa│ │aaaaaaaaaaaaaaaaaaaaaaaaaaaaa│
│█ │ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/mouse.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────── SCROLL: 2/2 ┐
│$ ││$ line1 │
│ ││line2 │
│ ││line3 │
│ ││line4 │
│ ││line5 │
│ ││line6 │
│ ││line7 │
│ ││line8 │
│ ││line9 │
│ ││line10 │
│ ││line11 │
│ ││line12 │
│ ││line13 │
│ ││line14 │
│ ││line15 │
│ ││line16 │
│ ││line17 │
│ ││line18 │
│ ││line19 │
│ ││█ine20 │
└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,24 @@
---
source: zellij-integration-tests/tests/panes.rs
expression: normalized(&grid_snapshot)
---
┌──────┐
│$ done│
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────┘
Ctrl +
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/panes.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/panes.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/panes.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
$ │$ █
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/panes.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ │
│ │
│ │
│ │
│ │
│ ┌ Pane #2 ──────────────────────────────────────── PIN [ ] ┐ │
│ │$ █ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/panes.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 (FULLSCREEN) 
┌ Pane #2 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/panes.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/plugins.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ │
│ │
│ │
│ │
│ ┌ (.) - permission-requester ───────────────────── PIN [ ] ┐ │
│ │This plugin asks permission to: ReadApplicationState, Chan│ │
│ │geApplicationState, OpenFiles, RunCommands, OpenTerminalsO│ │
│ │rPlugins, WriteToStdin, WebAccess, ReadCliPipes, MessageAn│ │
│ │dLaunchOtherPlugins, Reconfigure, RunActionsAsUser, WriteT│ │
│ │oClipboard, ReadSessionEnvironmentVariables. Allow? (y/n) │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/resurrection.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  alpha  beta 
┌ Pane #1 ─────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
│ ││█ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/resurrection.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│VIEWPORT_CONTENT_TO_RESTORE │
│ │
│█ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/tabs.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/tabs.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1  Tab #3  Tab #2 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/tabs.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1  Tab #2  Tab #3 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/tabs.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1  Tab #3  Tab #2 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/tabs.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #3  Tab #1  Tab #2 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/tabs.rs
expression: normalized(&zellij.snapshot())
---
Zellij (test)  Tab #1  Tab #2 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
@@ -0,0 +1,28 @@
---
source: zellij-integration-tests/tests/tabs.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1 
┌ Pane #1 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│$ █ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
+184
View File
@@ -0,0 +1,184 @@
#![cfg(unix)]
use insta::assert_snapshot;
use zellij_integration_tests::{
keys, normalized, FakePtyHandle, GridSnapshot, Size, TestRunner, TestSession,
};
const TERMINAL_SIZE: Size = Size {
cols: 120,
rows: 24,
};
const PROMPT: &[u8] = b"$ ";
const PROMPT_ROW: usize = 2;
const FIRST_PANE_PROMPT_X: usize = 3;
fn start_zellij() -> TestSession {
TestRunner::new(TERMINAL_SIZE).start()
}
fn claim_first_terminal_and_wait_for_prompt(zellij: &TestSession) -> FakePtyHandle {
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until(
"first terminal prompt rendered in loaded app",
|grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
},
);
terminal
}
fn tabs_in_order(grid_snapshot: &GridSnapshot, labels: &[&str]) -> bool {
let mut search_from = 0;
for label in labels {
match grid_snapshot.text[search_from..].find(label) {
Some(offset) => search_from += offset + label.len(),
None => return false,
}
}
true
}
fn open_new_tab_and_wait_for_prompt(zellij: &TestSession, expected_tab: &str) -> FakePtyHandle {
zellij.send_stdin(&keys::TAB_MODE);
zellij.send_stdin(&keys::NEW_TAB_IN_TAB_MODE);
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
let expected_tab = expected_tab.to_owned();
zellij.wait_until("new tab opened with prompt", move |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.contains(&expected_tab)
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
terminal
}
#[test]
fn open_new_tab() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_new_tab_and_wait_for_prompt(&zellij, "Tab #2");
let grid_snapshot = zellij.wait_until("second tab steady in normal mode", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("Tab #2")
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn close_tab() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_new_tab_and_wait_for_prompt(&zellij, "Tab #2");
zellij.send_stdin(&keys::TAB_MODE);
zellij.send_stdin(&keys::CLOSE_TAB_IN_TAB_MODE);
let grid_snapshot = zellij.wait_until(
"second tab closed, only first tab remains",
|grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("Tab #1")
&& !grid_snapshot.contains("Tab #2")
&& grid_snapshot.cursor_is_at(FIRST_PANE_PROMPT_X, PROMPT_ROW)
},
);
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn undo_rename_tab() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::TAB_MODE);
zellij.send_stdin(&keys::RENAME_TAB_MODE);
zellij.send_stdin(b"aa");
zellij.send_stdin(&keys::ESC);
zellij.send_stdin(&keys::ESC);
let grid_snapshot = zellij.wait_until("tab name reverted to default", |grid_snapshot| {
grid_snapshot.contains("Tab #1") && grid_snapshot.contains("LOCK")
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn move_tab_to_left() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_new_tab_and_wait_for_prompt(&zellij, "Tab #2");
open_new_tab_and_wait_for_prompt(&zellij, "Tab #3");
zellij.send_stdin(&keys::MOVE_TAB_LEFT);
let grid_snapshot = zellij.wait_until("third tab moved one position left", |grid_snapshot| {
tabs_in_order(grid_snapshot, &["Tab #1", "Tab #3", "Tab #2"])
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn move_tab_to_right() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_new_tab_and_wait_for_prompt(&zellij, "Tab #2");
open_new_tab_and_wait_for_prompt(&zellij, "Tab #3");
zellij.send_stdin(&keys::MOVE_FOCUS_LEFT_IN_NORMAL_MODE);
zellij.send_stdin(&keys::MOVE_TAB_RIGHT);
let grid_snapshot = zellij.wait_until("second tab moved one position right", |grid_snapshot| {
tabs_in_order(grid_snapshot, &["Tab #1", "Tab #3", "Tab #2"])
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn move_tab_to_left_until_it_wraps_around() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_new_tab_and_wait_for_prompt(&zellij, "Tab #2");
open_new_tab_and_wait_for_prompt(&zellij, "Tab #3");
zellij.send_stdin(&keys::MOVE_TAB_LEFT);
zellij.wait_until("third tab moved one position left", |grid_snapshot| {
tabs_in_order(grid_snapshot, &["Tab #1", "Tab #3", "Tab #2"])
});
zellij.send_stdin(&keys::MOVE_TAB_LEFT);
zellij.wait_until("third tab moved to the beginning", |grid_snapshot| {
tabs_in_order(grid_snapshot, &["Tab #3", "Tab #1", "Tab #2"])
});
zellij.send_stdin(&keys::MOVE_TAB_LEFT);
let grid_snapshot = zellij.wait_until("third tab wrapped to the end", |grid_snapshot| {
tabs_in_order(grid_snapshot, &["Tab #1", "Tab #2", "Tab #3"])
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn move_tab_to_right_until_it_wraps_around() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_new_tab_and_wait_for_prompt(&zellij, "Tab #2");
open_new_tab_and_wait_for_prompt(&zellij, "Tab #3");
zellij.send_stdin(&keys::MOVE_TAB_RIGHT);
let grid_snapshot = zellij.wait_until("third tab wrapped to the beginning", |grid_snapshot| {
tabs_in_order(grid_snapshot, &["Tab #3", "Tab #1", "Tab #2"])
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
+34 -14
View File
@@ -111,7 +111,7 @@ pub enum ServerInstruction {
client_id: ClientId,
},
DisconnectAllClientsExcept(ClientId),
ChangeMode(ClientId, InputMode),
ChangeMode(ClientId, InputMode, Option<NotificationEnd>),
ChangeModeForAllClients(InputMode),
Reconfigure {
client_id: ClientId,
@@ -795,7 +795,7 @@ mod session_state_tests {
}
}
pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
pub fn start_server(os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
info!("Starting Zellij server!");
#[cfg(unix)]
@@ -825,6 +825,14 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
}
}
start_server_impl(os_input, socket_path, true);
}
pub fn start_server_impl(
mut os_input: Box<dyn ServerOsApi>,
socket_path: PathBuf,
install_panic_hook: bool,
) {
envs::set_zellij("0".to_string());
let (to_server, server_receiver): ChannelWithContext<ServerInstruction> = channels::bounded(50);
@@ -832,13 +840,15 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
let session_data: Arc<RwLock<Option<SessionMetaData>>> = Arc::new(RwLock::new(None));
let session_state = Arc::new(RwLock::new(SessionState::new()));
std::panic::set_hook({
use zellij_utils::errors::handle_panic;
let to_server = to_server.clone();
Box::new(move |info| {
handle_panic(info, Some(&to_server));
})
});
if install_panic_hook {
std::panic::set_hook({
use zellij_utils::errors::handle_panic;
let to_server = to_server.clone();
Box::new(move |info| {
handle_panic(info, Some(&to_server));
})
});
}
let _ = thread::Builder::new()
.name("server_listener".to_string())
@@ -1628,14 +1638,24 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
.unwrap()
.associate_pipe_with_client(pipe_id, client_id);
},
ServerInstruction::ChangeMode(client_id, input_mode) => {
ServerInstruction::ChangeMode(client_id, input_mode, completion) => {
let mut session_data = session_data.write().unwrap();
let session_data = session_data.as_mut().unwrap();
let base_mode = session_data
.session_configuration
.get_client_default_input_mode(&client_id);
session_data
.write()
.unwrap()
.as_mut()
.unwrap()
.current_input_modes
.insert(client_id, input_mode);
session_data
.senders
.send_to_screen(ScreenInstruction::ChangeMode(
input_mode,
Some(base_mode),
client_id,
completion,
))
.unwrap();
},
ServerInstruction::ChangeModeForAllClients(input_mode) => {
session_data
+14 -7
View File
@@ -1121,8 +1121,8 @@ pub fn switch_to_mode_plugin_command() {
// destructor removes the directory
let plugin_host_folder = PathBuf::from(temp_folder.path());
let cache_path = plugin_host_folder.join("permissions_test.kdl");
let (plugin_thread_sender, screen_receiver, teardown) =
create_plugin_thread(Some(plugin_host_folder), None);
let (plugin_thread_sender, server_receiver, screen_receiver, teardown) =
create_plugin_thread_with_server_receiver(Some(plugin_host_folder), None);
let plugin_should_float = Some(false);
let plugin_title = Some("test_plugin".to_owned());
let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin {
@@ -1137,10 +1137,17 @@ pub fn switch_to_mode_plugin_command() {
cols: 121,
rows: 20,
};
let received_server_instructions = Arc::new(Mutex::new(vec![]));
let server_thread = log_actions_in_thread!(
received_server_instructions,
ServerInstruction::ChangeMode,
server_receiver,
1
);
let received_screen_instructions = Arc::new(Mutex::new(vec![]));
let screen_thread = grant_permissions_and_log_actions_in_thread!(
let _screen_thread = grant_permissions_and_log_actions_in_thread_naked_variant!(
received_screen_instructions,
ScreenInstruction::ChangeMode,
ScreenInstruction::Exit,
screen_receiver,
1,
&PermissionType::ChangeApplicationState,
@@ -1174,14 +1181,14 @@ pub fn switch_to_mode_plugin_command() {
Event::Key(KeyWithModifier::new(BareKey::Char('a'))), // this triggers a SwitchToMode(Tab) command in the fixture
// plugin
)]));
screen_thread.join().unwrap(); // this might take a while if the cache is cold
server_thread.join().unwrap(); // this might take a while if the cache is cold
teardown();
let switch_to_mode_event = received_screen_instructions
let switch_to_mode_event = received_server_instructions
.lock()
.unwrap()
.iter()
.find_map(|i| {
if let ScreenInstruction::ChangeMode(..) = i {
if let ServerInstruction::ChangeMode(..) = i {
Some(i.clone())
} else {
None
@@ -4,11 +4,8 @@ expression: "format!(\"{:#?}\", switch_to_mode_event)"
---
Some(
ChangeMode(
Tab,
Some(
Normal,
),
1,
Tab,
Some(
NotificationEnd {
channel: None,
+2 -9
View File
@@ -322,19 +322,12 @@ pub(crate) fn route_action(
},
Action::SwitchToMode { input_mode } => {
senders
.send_to_server(ServerInstruction::ChangeMode(client_id, input_mode))
.with_context(err_context)?;
senders
.send_to_screen(ScreenInstruction::ChangeMode(
input_mode,
Some(default_mode),
.send_to_server(ServerInstruction::ChangeMode(
client_id,
input_mode,
Some(NotificationEnd::new(completion_tx)),
))
.with_context(err_context)?;
senders
.send_to_screen(ScreenInstruction::Render)
.with_context(err_context)?;
},
Action::Resize { resize, direction } => {
let screen_instr = ScreenInstruction::Resize(
+39 -15
View File
@@ -4036,13 +4036,11 @@ impl Screen {
match self.get_active_tab(client_id) {
Ok(active_tab) => {
let active_tab_pos = active_tab.position;
let left_tab_pos = if active_tab_pos == 0 {
self.tabs.len() - 1
if active_tab_pos == 0 {
self.rotate_tab_positions_left();
} else {
active_tab_pos - 1
};
self.switch_tabs(active_tab_pos, left_tab_pos);
self.switch_tabs(active_tab_pos, active_tab_pos.saturating_sub(1));
}
self.log_and_report_session_state()
.context("failed to move tab to left")?;
},
@@ -4115,6 +4113,24 @@ impl Screen {
self.tabs.insert(other_tab_id, other_tab);
}
fn rotate_tab_positions_left(&mut self) {
let last_position = self.tabs.len().saturating_sub(1);
for tab in self.tabs.values_mut() {
tab.position = if tab.position == 0 {
last_position
} else {
tab.position.saturating_sub(1)
};
}
}
fn rotate_tab_positions_right(&mut self) {
let num_tabs = self.tabs.len();
for tab in self.tabs.values_mut() {
tab.position = (tab.position + 1) % num_tabs;
}
}
pub fn move_active_tab_to_right(&mut self, client_id: ClientId) -> Result<()> {
let err_context = || "Failed to move active tab right ";
if self.tabs.len() < 2 {
@@ -4128,9 +4144,11 @@ impl Screen {
match self.get_active_tab(client_id) {
Ok(active_tab) => {
let active_tab_pos = active_tab.position;
let right_tab_pos = (active_tab_pos + 1) % self.tabs.len();
self.switch_tabs(active_tab_pos, right_tab_pos);
if active_tab_pos == self.tabs.len().saturating_sub(1) {
self.rotate_tab_positions_right();
} else {
self.switch_tabs(active_tab_pos, active_tab_pos + 1);
}
self.log_and_report_session_state()
.context("failed to move tab to the right")?;
},
@@ -4145,18 +4163,24 @@ impl Screen {
}
if let Some(tab) = self.tabs.get(&tab_id) {
let tab_pos = tab.position;
let swap_pos = match direction {
let num_tabs = self.tabs.len();
match direction {
Direction::Left => {
if tab_pos == 0 {
self.tabs.len() - 1
self.rotate_tab_positions_left();
} else {
tab_pos - 1
self.switch_tabs(tab_pos, tab_pos.saturating_sub(1));
}
},
Direction::Right => {
if tab_pos == num_tabs.saturating_sub(1) {
self.rotate_tab_positions_right();
} else {
self.switch_tabs(tab_pos, tab_pos + 1);
}
},
Direction::Right => (tab_pos + 1) % self.tabs.len(),
_ => return Ok(()),
};
self.switch_tabs(tab_pos, swap_pos);
}
self.log_and_report_session_state()?;
} else {
log::error!("Tab with id {} not found", tab_id);
+13 -13
View File
@@ -1144,8 +1144,8 @@ fn tab_id_remains_stable_after_switch() {
assert_eq!(screen.tabs.get(&2).unwrap().id, 2);
assert_eq!(screen.tabs.get(&2).unwrap().position, 2);
// Move active tab (position 2, ID 2) to right, which wraps to position 0
// This switches tabs at positions 2 and 0 (tab IDs 2 and 0)
// Move active tab (position 2, ID 2) to right, which wraps to position 0 and rotates the
// others right by one (IDs stay stable, only positions change)
screen.move_active_tab_to_right(1).expect("TEST");
// Verify BTreeMap keys (IDs) remain unchanged
@@ -1156,8 +1156,8 @@ fn tab_id_remains_stable_after_switch() {
"Tab IDs in BTreeMap should remain 0, 1, 2 after switch"
);
// Verify IDs remain stable but positions are swapped
// Tab 0: was at position 0, now at position 2 (swapped with tab 2)
// Verify IDs remain stable but positions rotate
// Tab 0: was at position 0, rotated to position 1
assert_eq!(
screen.tabs.get(&0).unwrap().id,
0,
@@ -1165,11 +1165,11 @@ fn tab_id_remains_stable_after_switch() {
);
assert_eq!(
screen.tabs.get(&0).unwrap().position,
2,
"Tab with ID 0 should now be at position 2"
1,
"Tab with ID 0 should now be at position 1"
);
// Tab 1: remains unchanged at position 1
// Tab 1: was at position 1, rotated to position 2
assert_eq!(
screen.tabs.get(&1).unwrap().id,
1,
@@ -1177,11 +1177,11 @@ fn tab_id_remains_stable_after_switch() {
);
assert_eq!(
screen.tabs.get(&1).unwrap().position,
1,
"Tab with ID 1 should remain at position 1"
2,
"Tab with ID 1 should now be at position 2"
);
// Tab 2: was at position 2, now at position 0 (swapped with tab 0)
// Tab 2: was at position 2, rotated over the edge to position 0
assert_eq!(
screen.tabs.get(&2).unwrap().id,
2,
@@ -1193,15 +1193,15 @@ fn tab_id_remains_stable_after_switch() {
"Tab with ID 2 should now be at position 0"
);
// Verify that lookup by position works correctly after switch
// Verify that lookup by position works correctly after the rotation
let tab_at_pos_0 = screen.tabs.values().find(|t| t.position == 0).unwrap();
assert_eq!(tab_at_pos_0.id, 2, "Tab at position 0 should have ID 2");
let tab_at_pos_1 = screen.tabs.values().find(|t| t.position == 1).unwrap();
assert_eq!(tab_at_pos_1.id, 1, "Tab at position 1 should have ID 1");
assert_eq!(tab_at_pos_1.id, 0, "Tab at position 1 should have ID 0");
let tab_at_pos_2 = screen.tabs.values().find(|t| t.position == 2).unwrap();
assert_eq!(tab_at_pos_2.id, 0, "Tab at position 2 should have ID 0");
assert_eq!(tab_at_pos_2.id, 1, "Tab at position 2 should have ID 1");
}
#[test]
@@ -2,4 +2,4 @@
source: zellij-server/src/./unit/screen_tests.rs
expression: "format!(\"{:?}\", switch_mode_action)"
---
Some(ChangeMode(10, Locked))
Some(ChangeMode(10, Locked, Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None })))