use super::support::{DEFAULT_CONFIG, PROMPT, Terminal, run_case, run_case_with};
use anyhow::{Result, ensure};
use std::path::Path;
use tui_test::{MouseAction, Operation, OperationResult};
fn wait_for_prompt_after_ui(terminal: &Terminal, description: &str) -> Result<()> {
terminal.wait_for(description, |state, line| {
state.exited.is_none() && line.trim_end() == PROMPT
})
}
fn open_history_schema(terminal: &Terminal) -> Result<()> {
terminal.enter(":history schema")?;
terminal.wait_for_screen_line("history schema pager", 0, |state, line| {
state.exited.is_none() && line.contains("History Schema") && line.contains("[1/")
})
}
#[test]
fn help_browser_exits_with_escape_and_returns_to_r() -> Result<()> {
run_case(
"help-browser",
&["--no-auto-match", "--no-completion"],
|terminal| {
terminal.enter(":h")?;
terminal.wait_for("help browser", |state, _| {
state.exited.is_none()
&& state.text.contains("Filter: _")
&& state.text.contains("Esc exit")
})?;
ensure!(
terminal.output()?.contains("Help Search"),
"help search header was not emitted to the terminal stream"
);
terminal.key("Escape")?;
wait_for_prompt_after_ui(terminal, "help browser exits")?;
terminal.submit("42", "[1] 42", PROMPT)
},
)
}
#[test]
fn history_schema_pager_exits_with_q_and_returns_to_r() -> Result<()> {
run_case(
"history-schema",
&["--no-auto-match", "--no-completion"],
|terminal| {
open_history_schema(terminal)?;
terminal.key("q")?;
wait_for_prompt_after_ui(terminal, "history schema exits")?;
terminal.submit("42", "[1] 42", PROMPT)
},
)
}
#[test]
fn r_help_keeps_standard_printer_by_default() -> Result<()> {
run_case(
"r-help-default",
&["--no-auto-match", "--no-completion"],
|terminal| {
terminal.submit(
r#"identical(getS3method("print", "help_files_with_topic"), utils:::print.help_files_with_topic)"#,
"[1] TRUE",
PROMPT,
)
},
)
}
#[test]
fn r_help_opens_native_pager_and_q_returns_to_prompt() -> Result<()> {
run_case_with(
Terminal::builder("r-help-pager")
.args(["--no-auto-match", "--no-completion"])
.config(format!(
r#"{DEFAULT_CONFIG}
[experimental.r_help]
viewer = "auto"
"#
)),
|terminal| {
terminal.wait_for_first_prompt()?;
terminal.enter("?mean")?;
terminal.wait_for_screen_line("mean help pager", 0, |state, line| {
state.exited.is_none() && line.contains("base::mean")
})?;
terminal.key("Enter")?;
terminal.key("n")?;
terminal.key("Shift+n")?;
terminal.write("/qjk/")?;
terminal.wait_for("pager keys stay in search input", |state, _| {
state.text.contains("/qjk/| Enter search Esc cancel")
})?;
terminal.key("Escape")?;
terminal.wait_for("search input cancels without leaving pager", |state, _| {
state.text.contains("base::mean") && state.text.contains("q/Esc exit")
})?;
terminal.write("/mean")?;
terminal.key("Enter")?;
terminal.wait_for("first page search match", |state, _| {
state.text.contains("/mean [1/")
})?;
terminal.key("Enter")?;
terminal.key("n")?;
terminal.wait_for("next page search match", |state, _| {
state.text.contains("/mean [2/")
})?;
terminal.key("Shift+n")?;
terminal.wait_for("previous page search match", |state, _| {
state.text.contains("/mean [1/")
})?;
terminal.write("/arithmetic mean")?;
terminal.key("Enter")?;
terminal.wait_for("phrase search before resize", |state, _| {
state.text.contains("/arithmetic mean [1/")
})?;
terminal.execute(Operation::Resize { cols: 45, rows: 18 })?;
terminal.wait_for(
"resize keeps phrase search and clears selection",
|state, _| {
state.text.contains("/arithmetic mean [0/")
&& !state.text.contains("No matches for:")
},
)?;
terminal.key("n")?;
terminal.wait_for("wrapped phrase remains searchable", |state, _| {
state.text.contains("/arithmetic mean [1/")
})?;
terminal.key("Tab")?;
terminal.wait_for(
"selected link action replaces search-only footer",
|state, _| state.text.contains("Enter open /arithmetic mean [1/"),
)?;
terminal.key("n")?;
terminal.wait_for(
"search navigation restores search-only footer",
|state, _| {
state.text.contains("/arithmetic mean [") && !state.text.contains("Enter open")
},
)?;
terminal.key("q")?;
terminal.wait_for(
"q clears search and keeps the help page open",
|state, _| {
state.text.contains("base::mean")
&& state.text.contains("q/Esc exit")
&& !state.text.contains("n/N next/previous")
&& !state.text.contains("/arithmetic mean [")
},
)?;
terminal.key("q")?;
wait_for_prompt_after_ui(terminal, "mean help pager exits")?;
terminal.enter("?stats::lm")?;
terminal.wait_for_screen_line("qualified lm help pager", 0, |state, line| {
state.exited.is_none() && line.contains("stats::lm")
})?;
terminal.key("q")?;
wait_for_prompt_after_ui(terminal, "qualified lm help pager exits")?;
terminal.submit("42", "[1] 42", PROMPT)?;
terminal.quit()
},
)
}
fn follow_first_help_link_and_return(terminal: &Terminal) -> Result<()> {
terminal.key("Enter")?;
terminal.key("Tab")?;
terminal.wait_for("R help link is selected", |state, _| {
state.text.contains("base::mean") && state.text.contains("Enter open")
})?;
let state = terminal.state()?;
let original_header = terminal.screen_line(0, state.cols)?;
terminal.key("Enter")?;
terminal.wait_for_screen_line("selected R help link opens", 0, |state, line| {
state.exited.is_none() && line.contains("::") && !line.contains("base::mean")
})?;
let response = terminal
.start_ipc(&["eval", "1 + 1", "--timeout", "3000"])?
.finish_with_status()?;
ensure!(
response.status.code() == Some(4) && response.json["error"]["code"] == "R_NOT_AT_PROMPT",
"IPC must stay guarded during navigation: {}",
response.json
);
terminal.key("Backspace")?;
terminal.wait_for_screen_line("back restores mean title and scroll", 0, |_, line| {
line == original_header
})?;
terminal.wait_for("back restores selected link", |state, _| {
state.text.contains("Enter open")
})?;
Ok(())
}
#[test]
fn r_help_and_browser_share_keyboard_navigation_and_keep_ipc_guarded() -> Result<()> {
run_case_with(
Terminal::builder("r-help-navigation")
.args([
"--no-auto-match",
"--no-completion",
"--with-ipc",
"--ipc-eval-unrestricted",
])
.config(format!(
r#"{DEFAULT_CONFIG}
[experimental.r_help]
viewer = "auto"
"#
)),
|terminal| {
terminal.wait_for_first_prompt()?;
terminal.enter("?mean")?;
terminal.wait_for_screen_line("mean native help page", 0, |_, line| {
line.contains("base::mean")
})?;
follow_first_help_link_and_return(terminal)?;
terminal.key("q")?;
wait_for_prompt_after_ui(terminal, "native help navigation exits")?;
terminal.enter(":help base::mean")?;
terminal.wait_for("browser finds mean", |state, _| {
state.text.contains("Filter: base::mean_")
&& state.text.contains("Tab/Enter select")
})?;
let state = terminal.state()?;
let mut mean_row = None;
let mut selected_row = None;
for row in 0..state.rows.saturating_sub(2) {
let line = terminal.screen_line(row, state.cols)?;
if line.trim_start().starts_with("> ") {
selected_row = Some(row);
}
if line
.trim_start()
.trim_start_matches("> ")
.starts_with("base::mean ")
{
mean_row = Some(row);
}
}
let row = mean_row
.ok_or_else(|| anyhow::anyhow!("mean row is missing from browser results"))?;
let selected_row = selected_row
.ok_or_else(|| anyhow::anyhow!("selected row is missing from browser results"))?;
for _ in selected_row..row {
terminal.key("Down")?;
}
terminal.wait_for_screen_line("browser explicitly selects mean", row, |_, line| {
line.trim_start().starts_with("> base::mean ")
})?;
terminal.key("Enter")?;
terminal.wait_for_screen_line("browser opens mean page", 0, |_, line| {
line.contains("base::mean")
})?;
follow_first_help_link_and_return(terminal)?;
terminal.key("q")?;
terminal.wait_for("navigation returns to topic browser", |state, _| {
state.text.contains("Filter:") && state.text.contains("Tab/Enter select")
})?;
terminal.key("Escape")?;
wait_for_prompt_after_ui(terminal, "topic browser exits after navigation")?;
let response = terminal
.start_ipc(&["eval", "1 + 1", "--timeout", "10000"])?
.finish()?;
ensure!(
response["value"] == "[1] 2",
"IPC must resume after help exits: {response}"
);
terminal.submit("42", "[1] 42", PROMPT)?;
terminal.quit()
},
)
}
#[test]
fn history_schema_copy_uses_clipboard_and_shows_feedback() -> Result<()> {
run_case(
"history-schema-copy",
&["--no-auto-match", "--no-completion"],
|terminal| {
open_history_schema(terminal)?;
terminal.key("c")?;
terminal.wait_for("history schema copy feedback", |state, _| {
state.text.contains("Copied R example to clipboard")
})?;
terminal.execute(Operation::wait_clipboard_match("library(DBI)", Some(5_000)))?;
let clipboard = match terminal.execute(Operation::GetClipboard)? {
OperationResult::Clipboard(value) => value,
result => return Err(anyhow::anyhow!("unexpected clipboard result: {result:?}")),
};
ensure!(clipboard.contains("dbConnect("));
terminal.key("q")?;
wait_for_prompt_after_ui(terminal, "history schema copy exits")?;
terminal.submit("42", "[1] 42", PROMPT)
},
)
}
#[test]
fn history_schema_mouse_scroll_moves_down_and_up() -> Result<()> {
run_case(
"history-schema-mouse",
&["--no-auto-match", "--no-completion"],
|terminal| {
open_history_schema(terminal)?;
let scroll_down = Operation::Mouse {
action: MouseAction::Scroll {
direction: "down".to_owned(),
amount: 1,
},
};
ensure!(matches!(
terminal.execute(scroll_down)?,
OperationResult::Unit
));
terminal.wait_for_screen_line("history schema scrolls down", 0, |state, line| {
state.exited.is_none() && line.contains("History Schema") && line.contains("[2/")
})?;
let scroll_up = Operation::Mouse {
action: MouseAction::Scroll {
direction: "up".to_owned(),
amount: 1,
},
};
ensure!(matches!(
terminal.execute(scroll_up)?,
OperationResult::Unit
));
terminal.wait_for_screen_line("history schema scrolls up", 0, |state, line| {
state.exited.is_none() && line.contains("History Schema") && line.contains("[1/")
})?;
terminal.key("q")?;
wait_for_prompt_after_ui(terminal, "history schema mouse exits")?;
terminal.submit("42", "[1] 42", PROMPT)
},
)
}
#[test]
fn history_browser_persists_between_sessions() -> Result<()> {
let history = tempfile::tempdir()?;
let unique = "history_persist_unique_value";
run_session_with_history("history-persist-write", history.path(), |terminal| {
terminal.submit(&format!("{unique} <- 123; {unique}"), "[1] 123", PROMPT)?;
terminal.quit()
})?;
run_session_with_history("history-persist-read", history.path(), |terminal| {
terminal.enter(":history browse")?;
terminal.wait_for("history browser restored command", |state, _| {
state.text.contains("Filter: host:")
&& state.text.contains(unique)
&& state.text.contains("q exit")
})?;
ensure!(
terminal.output()?.contains("History Browser"),
"history browser header was not emitted to the terminal stream"
);
terminal.key("q")?;
wait_for_prompt_after_ui(terminal, "history browser exits")?;
terminal.submit("42", "[1] 42", PROMPT)
})
}
fn run_session_with_history(
name: &str,
history: &Path,
test: impl FnOnce(&Terminal) -> Result<()>,
) -> Result<()> {
run_case_with(
Terminal::builder(name)
.args(["--no-auto-match", "--no-completion"])
.history_dir(history),
|terminal| {
terminal.wait_for_first_prompt()?;
test(terminal)
},
)
}