//! Interactive behavior tests for the reedline input backend, run over a //! real pseudo-terminal. //! //! `pty_startup_tests.rs` covers *startup* on the default backend and //! `interactive_tests.rs` covers job control on the `basic` backend; this file //! is for behavior specific to reedline's line editing (key bindings, its //! interaction with the terminal). //! //! Unlike the basic backend, reedline queries the terminal for the cursor //! position (DSR, `ESC [ 6 n`) before it paints a prompt. A pty with nothing //! on the other end never answers, so these tests play the role of the //! terminal emulator and answer each query themselves (or, for the timeout //! test, deliberately withhold an answer). // Only compile this for platforms supported by expectrl's pty backend. #![cfg(any( target_os = "linux", target_os = "macos", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd" ))] #![cfg(test)] #![allow(clippy::panic_in_result_fn)] use std::time::Duration; use anyhow::Context; use expectrl::{ Expect, Session, process::unix::{PtyStream, UnixProcess}, stream::log::LogStream, }; const PROMPT: &str = "brush> "; const DSR_QUERY: &str = "\x1b[6n"; const DSR_REPLY: &str = "\x1b[1;1R"; /// OSC 633 markers bracketing a command: emitted before it runs, and after. const COMMAND_STARTED: &str = "\x1b]633;C"; const COMMAND_FINISHED: &str = "\x1b]633;D"; /// A key bound with `bind -x` must run its command and leave the shell /// alive. Regression test for reedline >= 0.48 delivering such keys as /// `Signal::HostCommand`, which previously fell through to a fatal /// "unexpected error occurred reading input". #[test] fn bound_key_runs_command_and_shell_survives() -> anyhow::Result<()> { let mut session = start_reedline_session()?; expect_next_prompt(&mut session, 0)?; // The bound command's output is split so the echoed keystrokes of the // `bind` line itself can't satisfy the expectation below. session.send_line(r#"bind -x '"\C-t": echo BOUND_""FIRED'"#)?; expect_next_prompt(&mut session, 0)?; // Ctrl+T. session.send("\x14")?; session .expect("BOUND_FIRED") .context("bound command did not run")?; expect_next_prompt(&mut session, 0).context("no prompt after bound command")?; // The shell must still be interactive afterwards. session.send_line("echo STILL_$((40+2))")?; session .expect("STILL_42") .context("shell did not survive the bound command")?; Ok(()) } /// Like readline, completion sees the whole line, not just the text before the cursor: /// e.g. `COMP_WORDS` includes words after it. #[test] fn completion_sees_text_after_cursor() -> anyhow::Result<()> { let mut session = start_reedline_session()?; expect_next_prompt(&mut session, 0)?; session.send_line( r#"_f() { echo "WORDS""=<${COMP_WORDS[*]}> CWORD=$COMP_CWORD" >&2; }; complete -F _f mycmd"#, )?; expect_next_prompt(&mut session, 0)?; // Type the line, move the cursor back before `b`, and press Tab. session.send("mycmd a b")?; session.send("\x1b[D\t")?; session .expect("WORDS= CWORD=2") .context("completion did not see the whole line")?; Ok(()) } /// `preexec` fires for lines the user typed, not for a command a key binding ran. The /// hooks exist to observe what the user is about to run; bash-preexec has the same split, /// because a `bind -x` command runs from readline rather than from the command line. #[test] fn bound_key_command_does_not_fire_preexec() -> anyhow::Result<()> { let mut session = start_reedline_session_with(|cmd| { cmd.arg("--enable-zsh-hooks"); })?; expect_next_prompt(&mut session, 0)?; // The hook counts its dispatches as well as reporting its argument, so a stray dispatch // for the bound command can't hide between the markers below. Markers are split so the // echoed keystrokes of the setup lines can't satisfy the expectations. session.send_line(r#"preexec() { n=$((n+1)); echo "PRE_""EXEC[$1]"; }"#)?; expect_next_prompt(&mut session, 0)?; // Dispatch 1, for this line. session.send_line(r#"bind -x '"\C-t": echo BOUND_""FIRED'"#)?; expect_next_prompt(&mut session, 0)?; // Ctrl+T. The bound command runs, but nothing was typed, so no dispatch. session.send("\x14")?; session .expect("BOUND_FIRED") .context("bound command did not run")?; expect_next_prompt(&mut session, 0).context("no prompt after bound command")?; // Dispatch 2, for this line -- `$n` expands after the hook has already run, so a third // dispatch anywhere would show up here. session.send_line("echo COUNT_$n")?; session .expect("PRE_EXEC[echo COUNT_$n]") .context("preexec did not fire for a typed line")?; session .expect("COUNT_2") .context("preexec fired for something other than the two typed lines")?; Ok(()) } /// A command a key binding runs is bracketed by the same OSC 633 marker pair as a typed one. /// Terminals track commands by that pair, so emitting only the closing marker -- as this path /// used to -- leaves one counting a command that never started. #[test] fn osc_command_markers_stay_paired_for_a_bound_command() -> anyhow::Result<()> { let mut session = start_reedline_session_with(|cmd| { cmd.arg("--enable-terminal-integration"); // OSC 633 is emitted only for terminals known to understand it. cmd.env("TERM_PROGRAM", "vscode"); })?; expect_next_prompt(&mut session, 0)?; session.send_line(r#"bind -x '"\C-t": echo BOUND_""FIRED'"#)?; expect_next_prompt(&mut session, 0)?; // Ctrl+T. The bound command runs without the user typing a line, but it is still a // command, so it gets both markers around it. session.send("\x14")?; session .expect(COMMAND_STARTED) .context("no command-started marker for the bound command")?; session .expect("BOUND_FIRED") .context("bound command did not run")?; session .expect(COMMAND_FINISHED) .context("no command-finished marker for the bound command")?; Ok(()) } /// A single unanswered cursor-position query (a transient terminal hiccup, /// e.g. right after a full-screen program hands the terminal back) must not /// terminate the shell; reedline asks again and uses the next answer. #[test] fn transient_cursor_query_timeout_is_tolerated() -> anyhow::Result<()> { let mut session = start_reedline_session()?; expect_next_prompt(&mut session, 0)?; // Withhold the answer to the query preceding the next prompt; crossterm // gives up on it after ~2s and the shell must carry on rather than exit. session.send_line("echo BEFORE_$((7*6))")?; session.expect("BEFORE_42")?; expect_next_prompt(&mut session, 1) .context("shell did not survive one unanswered cursor query")?; session.send_line("echo AFTER_$((6*7))")?; session .expect("AFTER_42") .context("shell not interactive after the unanswered query")?; Ok(()) } /// A terminal that never answers cursor-position queries still gets a /// prompt and a working shell; reedline (>= 0.52) falls back to anchoring /// the prompt at the bottom of the screen instead of failing the read. #[test] fn unanswered_cursor_queries_still_yield_a_prompt() -> anyhow::Result<()> { let mut session = start_reedline_session()?; expect_next_prompt(&mut session, 0)?; session.send_line("echo BEFORE_$((7*6))")?; session.expect("BEFORE_42")?; // Never answer from here on. session .expect(PROMPT) .context("no prompt without cursor-query answers")?; session.send_line("echo AFTER_$((6*7))")?; session .expect("AFTER_42") .context("shell not interactive without cursor-query answers")?; Ok(()) } // // Helpers // type ShellSession = Session>; /// Waits for the cursor-position query that precedes a prompt paint, answers /// it, and then waits for the prompt. The first `withhold` queries are left /// unanswered instead, so the shell has to re-issue them. fn expect_next_prompt(session: &mut ShellSession, mut withhold: usize) -> anyhow::Result<()> { loop { session .expect(DSR_QUERY) .context("no cursor-position query before prompt")?; if withhold > 0 { withhold -= 1; continue; } session.send(DSR_REPLY)?; session .expect(PROMPT) .context("no prompt after answered query")?; return Ok(()); } } fn start_reedline_session() -> anyhow::Result { start_reedline_session_with(|_| {}) } fn start_reedline_session_with( configure: impl FnOnce(&mut std::process::Command), ) -> anyhow::Result { let shell_path = assert_cmd::cargo::cargo_bin!("brush"); let mut cmd = std::process::Command::new(shell_path); cmd.args([ "--norc", "--noprofile", "--no-config", "--disable-bracketed-paste", "--disable-color", "--input-backend=reedline", ]); cmd.env("PS1", PROMPT); cmd.env("TERM", "xterm-256color"); configure(&mut cmd); let session = expectrl::session::Session::spawn(cmd)?; // N.B. Replace with `session` directly to disable logging of the session. let mut session = expectrl::session::log(session, std::io::stdout())?; // The timeout tests deliberately let ~2s crossterm timeouts elapse, // several in a row when the terminal never answers. session.set_expect_timeout(Some(Duration::from_secs(15))); Ok(session) }