use std::{ sync::atomic::{AtomicUsize, Ordering}, time::{Duration, Instant}, }; use alacritty_terminal::{event::Event, grid::Dimensions, index::Line}; use futures::channel::mpsc::TryRecvError; use super::*; use crate::{ settings::Settings, term::{Events, Pty, PtyEvent, config, marks::Mark, spawn}, }; /// A folder name with a space, a percent, a bell and a newline. const INNER: &str = "in ner%\u{7}x\ny"; /// A fresh folder under the temp dir, by its real path. fn folder(name: &str) -> PathBuf { let folder = env::temp_dir().join(format!("elyterminal-{name}-{}", std::process::id())); if folder.exists() { fs::remove_dir_all(&folder).expect("a stale test folder goes"); } fs::create_dir_all(&folder).expect("a test folder"); folder.canonicalize().expect("a real path") } fn installed(shell: &str) -> String { let found = std::process::Command::new("/bin/sh") .args(["-c", &format!("command -v {shell}")]) .output() .expect("sh runs"); assert!(found.status.success(), "{shell} is not installed"); String::from_utf8(found.stdout) .expect("utf-8") .trim() .to_string() } /// A shell started with integration under a fake HOME, and what it reported. struct Run { shell: String, pty: Pty, events: Events, root: PathBuf, start: PathBuf, folders: Vec, titles: Vec, marks: Vec, /// Lines typed, each followed by a prompt of its own. lines: usize, deadline: Instant, } impl Run { /// Starts `shell` in a fresh folder; `files` go under its HOME first. fn start(shell: &str, files: &[(&str, &str)]) -> Self { crate::testlog::init(); // Two tests may run one shell at once. static RUNS: AtomicUsize = AtomicUsize::new(0); let root = folder(&format!( "shell-{}-{}", shell.rsplit('/').next().expect("a name"), RUNS.fetch_add(1, Ordering::Relaxed) )); let (dir, home, start) = ( root.join("integration"), root.join("home"), root.join("start"), ); fs::create_dir_all(start.join(INNER)).expect("the inner folder"); fs::create_dir_all(&home).expect("home"); for (name, text) in files { let path = home.join(name); fs::create_dir_all(path.parent().expect("a folder")).expect("a home folder"); fs::write(path, text).expect("a home file"); } install(&dir).expect("scripts install"); let mut launch = launch(shell, start.clone(), &dir).expect("a launch"); launch.env.push(("HOME".into(), home.display().to_string())); let (pty, events) = spawn(&launch, (100, 30), (8, 16), config(&Settings::default())) .expect("the shell starts"); Self { shell: shell.into(), pty, events, root, start, folders: Vec::new(), titles: Vec::new(), marks: Vec::new(), lines: 0, deadline: Instant::now() + Duration::from_secs(30), } } /// Reads events, answering the shell's terminal queries, until `done` holds. fn until(&mut self, what: &str, done: impl Fn(&Self) -> bool) { while !done(self) { assert!( Instant::now() < self.deadline, "{}: no {what}; folders {:?}, titles {:?}, marks {:?}, screen\n{}", self.shell, self.folders, self.titles, self.marks, self.screen() ); match self.events.try_recv() { Ok(PtyEvent::Folder(folder)) => self.folders.push(folder), Ok(PtyEvent::Title(title)) => self.titles.push(title), Ok(PtyEvent::Mark(mark)) => self.marks.push(mark), Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()), Ok(PtyEvent::Term(Event::ChildExit(status))) => { panic!("{} ended, {status}:\n{}", self.shell, self.screen()) } Ok(_) => {} Err(TryRecvError::Closed) => panic!("{}'s events ended", self.shell), Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)), } } } /// Types `line` once the line editor reads raw keys. fn send(&mut self, line: &str) { self.until("line editor", |run| run.pty.reads_keys()); self.pty.write(format!("{line}\r").into_bytes()); self.lines += 1; } fn shows(&self, text: &str) -> bool { self.screen().contains(text) } fn screen(&self) -> String { let term = self.pty.term.lock(); (0..term.screen_lines()) .map(|line| { let row = &term.grid()[Line(line as i32)]; let text: String = row.into_iter().map(|cell| cell.c).collect(); text.trim_end().to_string() }) .collect::>() .join("\n") } /// The prompt's folder, the script loaded by `probe`, then a cd into the inner folder. fn walk(&mut self, probe: &str) { let first = [Mark::Prompt, Mark::Input]; self.until("the first prompt's marks", |run| { run.marks.starts_with(&first) }); let start = self.start.clone(); self.send(probe); self.until("first folder", |run| run.folders.contains(&start)); assert_eq!(self.folders[0], start); self.until("loaded script", |run| run.shows("ELY-LOADED")); self.send("cd in*"); let inner = start.join(INNER); self.until("inner folder", |run| run.folders.contains(&inner)); } /// Types `line` once the prompt before it is marked, then waits for the marks it ends with. fn ends(&mut self, line: &str, marks: &[Mark]) { let prompts = self.lines + 1; self.until("the prompt's marks", |run| { run.marks .iter() .filter(|mark| **mark == Mark::Input) .count() >= prompts }); let from = self.marks.len(); self.send(line); self.until(&format!("the marks of {line}"), |run| { run.marks[from..].ends_with(marks) }); } fn finish(mut self) { self.send("exit"); loop { assert!( Instant::now() < self.deadline, "{} did not exit", self.shell ); match self.events.try_recv() { Ok(PtyEvent::Term(Event::ChildExit(_))) | Err(TryRecvError::Closed) => break, Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()), Ok(_) => {} Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)), } } let root = self.root.clone(); drop(self); fs::remove_dir_all(&root).expect("cleanup"); } } /// Whether this bash has PS0, 4.4 on, so it marks a command's start. fn bash_ps0(shell: &str) -> bool { let version = std::process::Command::new(shell) .args(["-c", "echo $((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1]))"]) .output() .expect("bash runs"); let version: u32 = String::from_utf8(version.stdout) .expect("utf-8") .trim() .parse() .expect("a version"); version >= 404 } /// The marks a bash command that ends with `status` leaves; bash 3.2 marks no start. fn bash_ended(ps0: bool, status: i32) -> Vec { ended(status)[usize::from(!ps0)..].to_vec() } /// The marks a command that ends with `status` leaves: its output, its end, the next prompt. fn ended(status: i32) -> [Mark; 4] { [ Mark::Output, Mark::Done(Some(status)), Mark::Prompt, Mark::Input, ] } #[test] fn the_scripts_install_once_and_mend_a_changed_one() { let dir = folder("install"); install(&dir).expect("installs"); let zshenv = dir.join("zsh/.zshenv"); assert_eq!(fs::read_to_string(&zshenv).expect("written"), SCRIPTS[0].1); fs::write(&zshenv, [0xff, 0xfe]).expect("write"); install(&dir).expect("installs again"); assert_eq!(fs::read_to_string(&zshenv).expect("mended"), SCRIPTS[0].1); fs::remove_dir_all(dir).expect("cleanup"); } #[test] fn a_script_it_cannot_read_fails_the_install() { use std::os::unix::fs::PermissionsExt; let dir = folder("unreadable"); install(&dir).expect("installs"); let bash = dir.join("bash/elyterminal.bash"); fs::set_permissions(&bash, fs::Permissions::from_mode(0o200)).expect("200"); let error = install(&dir).expect_err("an unreadable script fails"); fs::set_permissions(&bash, fs::Permissions::from_mode(0o600)).expect("600"); assert!( format!("{error:#}").contains("bash/elyterminal.bash"), "{error:#}" ); fs::remove_dir_all(dir).expect("cleanup"); } #[test] fn zsh_reports_its_folder_byte_for_byte_and_its_titles() { // A theme that sets PS1 in its own hook, which runs after ours. let theme = "autoload -Uz add-zsh-hook\n_theme() { PS1='THEME %? > ' }\nadd-zsh-hook precmd _theme"; let mut run = Run::start(&installed("zsh"), &[(".zshrc", theme)]); run.walk("(( $+functions[_ely_precmd] )) && echo ELY-LOAD''ED"); run.until("title", |run| { run.titles.iter().any(|title| title == "in ner%xy") }); assert!( run.titles.contains(&"cd in*".to_string()), "{:?}", run.titles ); run.ends("false", &ended(1)); run.until("the theme's prompt", |run| { run.shows("THEME 0 > false") && run.shows("THEME 1 >") }); run.send("false | true"); run.send("echo PIPE=$pipestatus"); run.until("the pipeline's statuses", |run| run.shows("PIPE=1 0")); run.finish(); } #[test] fn zsh_says_why_it_marks_no_prompt_without_prompt_percent() { let mut run = Run::start(&installed("zsh"), &[(".zshrc", "unsetopt prompt_percent")]); run.until("the warning", |run| { run.shows("ELYTerminal: zsh marks no prompt while prompt_percent is off") }); run.send("false"); let ended = [Mark::Output, Mark::Done(Some(1))]; run.until("the command's marks", |run| run.marks.ends_with(&ended)); assert!(!run.marks.contains(&Mark::Prompt), "{:?}", run.marks); run.finish(); } #[test] fn bash_reports_its_folder_byte_for_byte_and_keeps_the_status() { let status = r#"PROMPT_COMMAND='printf "USER_STATUS=%s\n" "$?"'; shopt -s extdebug"#; let files = [(".bashrc", status), (".bash_profile", status)]; let shell = installed("bash"); let ps0 = bash_ps0(&shell); let mut run = Run::start(&shell, &files); run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED"); run.until("title", |run| { run.titles.iter().any(|title| title == "in ner%xy") }); run.ends("false", &bash_ended(ps0, 1)); run.until("kept status", |run| run.shows("USER_STATUS=1")); assert!(run.shows("$ false"), "{}", run.screen()); // Under extdebug a DEBUG trap that fails skips its command. run.ends("touch ran", &bash_ended(ps0, 0)); assert!(run.start.join(INNER).join("ran").exists()); run.ends("(exit 7)", &bash_ended(ps0, 7)); run.send(r#"ls / >/dev/null; echo "LAST=$_""#); run.until("the last argument", |run| run.shows("LAST=/\n")); run.finish(); } #[test] fn bash_leaves_the_user_s_trap_and_keys_alone() { let rc = r#"_user_trap() { USER_TRAP=yes; }; trap '_user_trap "$_"' DEBUG bind -x '"\C-t": :'"#; let files = [(".bashrc", rc), (".bash_profile", rc)]; let shell = installed("bash"); let ps0 = bash_ps0(&shell); let mut run = Run::start(&shell, &files); run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED"); run.ends("false", &bash_ended(ps0, 1)); // A key binding runs no command, so it marks none. let from = run.marks.len(); run.pty.write(b"\x14".to_vec()); run.ends(r#"echo "TRAP=$USER_TRAP""#, &bash_ended(ps0, 0)); assert!(run.shows("TRAP=yes"), "{}", run.screen()); let starts = run.marks[from..] .iter() .filter(|mark| **mark == Mark::Output); assert_eq!(starts.count(), usize::from(ps0), "{:?}", &run.marks[from..]); run.finish(); } #[test] fn bash_says_why_it_marks_no_command_without_promptvars() { let files = [ (".bashrc", "shopt -u promptvars"), (".bash_profile", "shopt -u promptvars"), ]; let mut run = Run::start(&installed("bash"), &files); run.until("the warning", |run| { run.shows("ELYTerminal: bash marks no command while promptvars is off") }); run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED"); run.ends("false", &[Mark::Prompt, Mark::Input]); run.finish(); } #[test] fn fish_loads_its_script_and_restores_its_data_dirs() { let mut run = Run::start(&installed("fish"), &[]); run.walk("functions -q _ely_prompt; and echo ELY-LOAD''ED"); let previous = env::var("XDG_DATA_DIRS").unwrap_or_default(); run.send(r#"echo "DIRS[$ELY_FISH_XDG_DATA_DIRS][$XDG_DATA_DIRS]""#); let restored = format!("DIRS[][{previous}]"); run.until("restored dirs", |run| run.shows(&restored)); // The prompt hears each command's own status, none failed so far. assert!(!run.shows("[1]"), "{}", run.screen()); run.ends("false", &ended(1)); run.until("the failed status", |run| run.shows("[1]>")); run.finish(); } #[cfg(target_os = "macos")] #[test] fn powershell_reports_its_folder_byte_for_byte_and_keeps_the_status() { let prompt = r#"function global:prompt { "USER_SUCCESS=$? > " }"#; let files = [( ".config/powershell/Microsoft.PowerShell_profile.ps1", prompt, )]; let mut run = Run::start(&installed("pwsh"), &files); run.walk("if ($global:ElyPrompt) { 'ELY-LOAD' + 'ED' }"); run.until("title", |run| { run.titles.iter().any(|title| title == "in ner%xy") }); run.ends("/usr/bin/false", &ended(1)); run.until("kept status", |run| run.shows("USER_SUCCESS=False")); assert!(run.shows("> /usr/bin/false"), "{}", run.screen()); // A cmdlet's failure leaves no error behind, a native code may repeat, and the line's last pipeline counts. run.ends("/usr/bin/true", &ended(0)); run.ends("Get-Item ./missing -ErrorAction Ignore", &ended(1)); run.ends("sh -c 'exit 3'", &ended(3)); run.ends("sh -c 'exit 3'", &ended(3)); run.ends("Get-Item ./missing", &ended(1)); run.ends("sh -c 'exit 7'; Get-Item ./missing", &ended(1)); run.ends( "sh -c 'exit 7'; Get-Item ./missing -ErrorAction Ignore", &ended(1), ); run.ends("Get-Item ./missing; sh -c 'exit 7'", &ended(7)); // A call through a variable runs a program the line does not name. run.send("$exe = 'sh'"); run.ends("& $exe -c 'exit 7'", &ended(7)); run.ends("& \"$exe\" -c 'exit 9'", &ended(9)); let native = "$PSNativeCommandUseErrorActionPreference = $true; sh -c 'exit 7'"; run.ends(native, &ended(7)); run.finish(); } #[test] fn a_shell_named_bare_runs_from_its_full_path() { let started = launch("sh", PathBuf::from("/"), Path::new("/nonexistent")).expect("a launch"); let (program, _) = started.program.expect("a program"); assert_eq!(program, crate::settings::program_path("sh").expect("sh")); assert!(Path::new(&program).is_absolute(), "{program}"); assert!(launch("no-such-shell", PathBuf::from("/"), Path::new("/")).is_err()); } #[test] fn an_unknown_shell_starts_without_integration() { let dir = PathBuf::from("/nonexistent"); let launch = launch("/bin/sh", PathBuf::from("/"), &dir).expect("a launch"); assert_eq!(launch.program, Some(("/bin/sh".into(), Vec::new()))); assert!(launch.env.is_empty()); }