pub mod app;
pub mod clip;
pub mod complete;
pub mod crash;
pub mod exec;
pub mod fit;
pub mod grid;
pub mod hint;
pub mod journal;
pub(crate) mod market_view;
pub mod palette;
pub(crate) mod repeat_view;
pub(crate) mod venue_charts;
pub mod vocab;
pub mod wizard;
use app::{Action, Ending, TuiState, run_invocation};
use crossterm::event::{Event, EventStream, KeyEventKind, MouseEventKind};
use exec::{Plan, Route, WatchCommand};
use futures::StreamExt;
use futures::stream::BoxStream;
use journal::Journal;
use scope::chains::ChainClientFactory;
use scope::config::Config;
use scope::error::{Result, ScopeError};
use std::future::Future;
use std::io::{self, Write};
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::cli::interactive::SessionContext;
use crate::cli::output::{Captured, Output};
use crate::cli::prompt;
use crate::cli::screen;
const PROMPT_SIGNAL_WAIT: Duration = Duration::from_secs(1);
const LIVE_REDRAW: Duration = Duration::from_millis(100);
const OUTSIDE_COPY_LIMIT: usize = app::SCROLLBACK_LIMIT * 200;
const BANNER: &str = include_str!("../../../assets/banner.txt");
const SIGNAL_EXIT_BASE: i32 = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TuiExit {
pub code: i32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) enum Signal {
Term,
Hup,
Int,
Quit,
Tstp,
Cont,
}
impl Signal {
fn name(self) -> &'static str {
match self {
Signal::Term => "TERM",
Signal::Hup => "HUP",
Signal::Int => "INT",
Signal::Quit => "QUIT",
Signal::Tstp => "TSTP",
Signal::Cont => "CONT",
}
}
fn ends_suspended(self) -> bool {
matches!(
self,
Signal::Int | Signal::Term | Signal::Hup | Signal::Quit
)
}
fn exit_code(self) -> Option<i32> {
let number = match self {
Signal::Hup => 1,
Signal::Int => 2,
Signal::Quit => 3,
Signal::Term => 15,
Signal::Tstp | Signal::Cont => return None,
};
Some(SIGNAL_EXIT_BASE + number)
}
}
pub fn tui_terminal_ok(
stdin_tty: bool,
stdout_tty: bool,
term: Option<&str>,
) -> std::result::Result<(), String> {
if stdin_tty && stdout_tty && term != Some("dumb") {
return Ok(());
}
Err("scope tui needs an interactive terminal; run the command directly, e.g. scope market summary …".to_string())
}
pub struct Settings {
pub config: Config,
pub clients: Box<dyn ChainClientFactory>,
}
pub type Reload<'a> = &'a dyn Fn() -> Result<Settings>;
pub async fn run(no_banner: bool, settings: Settings, reload: Reload<'_>) -> Result<TuiExit> {
run_with(no_banner, None, settings, reload).await
}
pub async fn run_with(
no_banner: bool,
start: Option<crate::cli::Commands>,
settings: Settings,
reload: Reload<'_>,
) -> Result<TuiExit> {
let signals = signal_stream()?;
tokio::spawn(cancel_prompts_on(signal_stream()?, &prompt::CANCEL));
crash::install_panic_hook();
let mut host = RealHost {
terminal: screen::enter()?,
events: None,
signals,
on_screen: true,
};
let mut state = TuiState::new(
SessionContext::load(),
Journal::in_data_dir(),
vocab::Vocab::load(&settings.config),
);
welcome(&mut state, no_banner);
state.start = start;
session(
&mut host,
&mut state,
settings,
reload,
SessionContext::save,
)
.await
}
async fn session<H: Host>(
host: &mut H,
state: &mut TuiState,
settings: Settings,
reload: Reload<'_>,
save_ctx: impl FnOnce(&SessionContext) -> Result<()>,
) -> Result<TuiExit> {
let result = event_loop(host, state, settings, reload).await;
host.set_width(None);
host.restore();
let saved_history = state.journal().save_history(&state.history);
let saved_ctx = save_ctx(&state.ctx);
let exit = result?;
saved_history?;
saved_ctx?;
Ok(exit)
}
#[cfg(unix)]
fn signal_stream() -> Result<BoxStream<'static, Signal>> {
use tokio::signal::unix::{SignalKind, signal};
let kinds = [
(SignalKind::terminate(), Signal::Term),
(SignalKind::hangup(), Signal::Hup),
(SignalKind::interrupt(), Signal::Int),
(SignalKind::quit(), Signal::Quit),
(SignalKind::from_raw(libc::SIGTSTP), Signal::Tstp),
(SignalKind::from_raw(libc::SIGCONT), Signal::Cont),
];
let mut streams = Vec::with_capacity(kinds.len());
for (kind, sig) in kinds {
let mut handler = signal(kind).map_err(|e| {
ScopeError::io_context(e, format!("cannot install the SIG{} handler", sig.name()))
})?;
streams.push(futures::stream::poll_fn(move |cx| {
handler.poll_recv(cx).map(|got| got.map(|()| sig))
}));
}
Ok(futures::stream::select_all(streams).boxed())
}
#[cfg(windows)]
fn signal_stream() -> Result<BoxStream<'static, Signal>> {
let mut handler = tokio::signal::windows::ctrl_c()
.map_err(|e| ScopeError::io_context(e, "cannot install the Ctrl-C handler"))?;
Ok(futures::stream::poll_fn(move |cx| {
handler.poll_recv(cx).map(|got| got.map(|()| Signal::Int))
})
.boxed())
}
#[cfg(not(any(unix, windows)))]
fn signal_stream() -> Result<BoxStream<'static, Signal>> {
Ok(futures::stream::pending().boxed())
}
fn welcome(state: &mut TuiState, no_banner: bool) {
if !no_banner {
state.push_ansi(BANNER);
}
state.push_note("Type `help` for commands. `exit` or Ctrl-D leaves.");
state.push_note(
"Try: `market summary USDC --venue binance` · `venues list` · `insights <address>`",
);
state.push_note("Tab completes a command or flag; Ctrl-K opens the command palette.");
}
#[cfg(test)]
pub(crate) static DIAG_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub(crate) trait Host {
type B: ratatui::backend::Backend;
type Events: futures::Stream<Item = io::Result<Event>> + Unpin;
type Signals: futures::Stream<Item = Signal> + Unpin;
fn terminal(&mut self) -> &mut ratatui::Terminal<Self::B>;
fn streams(&mut self) -> (&mut Self::Events, &mut Self::Signals);
fn suspend(&mut self);
fn resume(&mut self) -> Result<()>;
fn restore(&mut self);
fn stop_self(&mut self) -> io::Result<()>;
fn clear(&mut self) -> io::Result<()>;
fn suspended_output(&self) -> Output;
fn write_raw(&mut self, text: &str) -> io::Result<()>;
fn set_width(&mut self, width: Option<usize>);
}
struct RealHost {
terminal: ratatui::DefaultTerminal,
events: Option<EventStream>,
signals: BoxStream<'static, Signal>,
on_screen: bool,
}
impl Host for RealHost {
type B = ratatui::backend::CrosstermBackend<io::Stdout>;
type Events = EventStream;
type Signals = BoxStream<'static, Signal>;
fn terminal(&mut self) -> &mut ratatui::Terminal<Self::B> {
&mut self.terminal
}
fn streams(&mut self) -> (&mut EventStream, &mut Self::Signals) {
(
self.events.get_or_insert_with(EventStream::new),
&mut self.signals,
)
}
fn suspend(&mut self) {
self.events = None;
self.restore();
}
fn resume(&mut self) -> Result<()> {
self.terminal = screen::enter()?;
self.on_screen = true;
Ok(())
}
fn restore(&mut self) {
if std::mem::take(&mut self.on_screen) {
screen::restore();
}
}
#[cfg(unix)]
fn stop_self(&mut self) -> io::Result<()> {
let rc = unsafe { libc::kill(libc::getpid(), libc::SIGSTOP) };
if rc == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
#[cfg(not(unix))]
fn stop_self(&mut self) -> io::Result<()> {
Err(io::Error::other("job control needs a unix host"))
}
fn clear(&mut self) -> io::Result<()> {
self.terminal.clear()
}
fn suspended_output(&self) -> Output {
Output::stdio()
}
fn write_raw(&mut self, text: &str) -> io::Result<()> {
let mut out = io::stdout();
out.write_all(text.as_bytes())?;
out.flush()
}
fn set_width(&mut self, width: Option<usize>) {
scope::display::terminal::set_width_override(width);
}
}
struct Outside {
terminal: Output,
out: Output,
copy: Captured,
}
impl Outside {
fn new(terminal: Output) -> Self {
let (out, copy) = terminal.teed(OUTSIDE_COPY_LIMIT);
Self {
terminal,
out,
copy,
}
}
}
type PaneFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + 'a>>;
const WHEEL_ROWS: isize = 3;
const SETUP_RELOADED_NOTE: &str = "The TUI now uses the saved settings.";
fn setup_reload_failed_note(e: &ScopeError) -> String {
format!(
"The settings could not be read again ({e}). Leave the TUI (exit) and start it again to use them."
)
}
async fn event_loop<H: Host>(
host: &mut H,
state: &mut TuiState,
settings: Settings,
reload: Reload<'_>,
) -> Result<TuiExit> {
let mut settings = std::rc::Rc::new(settings);
const LEFT: TuiExit = TuiExit { code: 0 };
let idle_buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
let mut idle_diag = Some(scope::diag::redirect(idle_buf.clone()));
let mut pending: Option<PaneFuture<'_>> = None;
let mut watch_pending: Option<PaneFuture<'_>> = None;
if let Some(command) = state.start.take() {
watch_pending = open_monitor(state, &settings, command);
}
let mut tick = tokio::time::interval(LIVE_REDRAW);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut cont_expected = false;
let mut unchanged = false;
loop {
drain_into(&idle_buf, state);
state.poll_watch();
if state.take_bell() {
let _ = host.write_raw("\x07");
}
state.poll_ask();
if !std::mem::take(&mut unchanged) {
host.terminal()
.draw(|f| {
state.render(f);
crate::cli::plain::finish_frame(f);
})
.map_err(|e| {
ScopeError::io_context(io::Error::other(e.to_string()), "terminal draw failed")
})?;
}
host.set_width(state.pane_width());
let (events, signals) = host.streams();
tokio::select! {
biased;
res = async { pending.as_mut().expect("guarded by precondition").await }, if pending.is_some() => {
pending = None;
state.finish(Ending::Finished(res));
}
res = async { watch_pending.as_mut().expect("guarded by precondition").await }, if watch_pending.is_some() => {
watch_pending = None;
state.watch_finished(res);
}
_ = tick.tick(), if state.running.is_some() || state.watch_running() => {}
Some(sig) = signals.next() => {
if let Some(code) = sig.exit_code() {
drop(pending.take());
state.finish(Ending::Cancelled);
state.record_signal(sig.name(), "session", "", &[]);
return Ok(TuiExit { code });
}
if sig == Signal::Tstp {
cont_expected = stop_and_return(host, state)?;
} else if !std::mem::take(&mut cont_expected) {
leave(host);
retake(host)?;
}
}
ev = events.next() => {
let key = match ev {
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => k,
Some(Ok(Event::Paste(text))) => {
state.paste(&text);
continue;
}
Some(Ok(Event::Mouse(m))) => {
match m.kind {
MouseEventKind::ScrollUp => state.wheel(WHEEL_ROWS),
MouseEventKind::ScrollDown => state.wheel(-WHEEL_ROWS),
_ => unchanged = true,
}
continue;
}
Some(Ok(_)) => continue,
Some(Err(e)) => return Err(ScopeError::IoError(e)),
None => return Ok(LEFT),
};
match state.handle_key(key) {
Action::None => {}
Action::Exit => return Ok(LEFT),
Action::Suspend => cont_expected = stop_and_return(host, state)?,
Action::Copy(text) => {
let result = host.write_raw(&clip::osc52(&text));
state.copied(text.lines().count(), result);
}
Action::StartWatch(list) => {
if let Some(run) = begin_watch(state, &settings, *list) {
watch_pending = Some(run);
}
}
Action::StopWatch => {
watch_pending = None;
state.stop_watch();
}
Action::Cancel => {
pending = None;
state.finish(Ending::Cancelled);
}
Action::Submit(line) => match state.submit(&line, &settings.config) {
Plan::Done => {}
Plan::Exit => return Ok(LEFT),
Plan::Watch(cmd) => match cmd {
WatchCommand::Open(list) => {
if let Some(run) = begin_watch(state, &settings, *list) {
watch_pending = Some(run);
}
}
WatchCommand::Stop => {
if state.watch_running() {
watch_pending = None;
state.stop_watch();
} else {
state.push_note("No watchlist runs.");
}
}
WatchCommand::Show => state.show_grid(),
},
Plan::Run(inv) => match inv.route {
Route::Pane => {
let out = state.start(&line, &inv);
let s = settings.clone();
pending = Some(Box::pin(async move {
run_invocation(*inv, &s.config, &*s.clients, &out).await
}));
}
Route::Suspend => {
drop(idle_diag.take());
leave(host);
let outside = Outside::new(host.suspended_output());
let argv = inv.argv.clone();
let command =
run_invocation(*inv, &settings.config, &*settings.clients, &outside.out);
let ended =
run_suspended(host, &line, &argv, &outside, command, state)
.await;
if let Some(exit) = ended {
return Ok(exit);
}
if argv.get(1).is_some_and(|c| c == "setup") {
match reload() {
Ok(new) => {
state.set_vocab(vocab::Vocab::load(&new.config));
settings = std::rc::Rc::new(new);
state.push_note(SETUP_RELOADED_NOTE);
state.settings_reloaded();
}
Err(e) => state.push_note(&setup_reload_failed_note(&e)),
}
}
host.resume()?;
idle_diag.replace(scope::diag::redirect(idle_buf.clone()));
}
Route::Wizard => {
if let Some(run) = open_monitor(state, &settings, inv.command) {
watch_pending = Some(run);
}
}
Route::Refuse(reason) => {
state.push_note(&format!("Not available here: {}.", reason));
}
},
},
}
}
}
}
}
const WATCH_BUSY_NOTE: &str =
"A watchlist runs already. Stop it first (`watch stop`, or Esc in the grid).";
fn begin_watch(
state: &mut TuiState,
settings: &std::rc::Rc<Settings>,
list: scope::watch::Watchlist,
) -> Option<PaneFuture<'static>> {
if state.watch_running() {
state.push_note(WATCH_BUSY_NOTE);
return None;
}
state.close_grid();
let (out, sink, tx) = state.start_watch(list.clone());
let s = settings.clone();
Some(Box::pin(scope::diag::scoped(sink, async move {
run_watch(list, &s, &out, tx).await
})))
}
fn open_monitor(
state: &mut TuiState,
settings: &std::rc::Rc<Settings>,
command: crate::cli::Commands,
) -> Option<PaneFuture<'static>> {
let crate::cli::Commands::Monitor(args) = command else {
return None;
};
match crate::cli::watch::monitor::watchlist_from_args(&args, &settings.config) {
Err(e) => {
state.push_styled_note(&format!("monitor: {e}"), crate::cli::theme::ERR);
None
}
Ok((list, notes)) if args.watch.yes => {
for n in ¬es {
state.push_note(n);
}
if args.watch.save.is_some() {
let path = scope::watch::file::path_in(&scope::paths::watchlists_dir(), &list.name);
match scope::watch::file::save(&path, &list) {
Ok(()) => {
state.push_note(&format!("Saved the watchlist to {}.", path.display()))
}
Err(e) => state.push_note(&format!("Could not save {}: {e}", path.display())),
}
}
begin_watch(state, settings, list)
}
Ok((list, notes)) => {
state.open_wizard(list, notes);
None
}
}
}
async fn run_watch(
list: scope::watch::Watchlist,
settings: &Settings,
out: &Output,
tx: std::sync::mpsc::Sender<grid::GridMsg>,
) -> Result<()> {
let src = crate::cli::watch::Sources {
config: &settings.config,
clients: &*settings.clients,
};
let ready = crate::cli::watch::prepare(&list, src, out).await?;
let _ = tx.send(grid::GridMsg::Ready(ready.clone()));
crate::cli::watch::run(&ready, src, out, |run, tick, events| {
let _ = tx.send(grid::GridMsg::Tick {
run,
tick: Box::new(tick.clone()),
events: events.to_vec(),
});
})
.await
.map(|_| ())
}
fn stop_and_return<H: Host>(host: &mut H, state: &mut TuiState) -> Result<bool> {
leave(host);
let stopped = match host.stop_self() {
Ok(()) => true,
Err(e) => {
state.push_note(&format!("Could not stop the process: {}", e));
false
}
};
retake(host)?;
Ok(stopped)
}
fn leave<H: Host>(host: &mut H) {
host.set_width(None);
host.suspend();
}
fn retake<H: Host>(host: &mut H) -> Result<()> {
host.resume()?;
host.clear()
.map_err(|e| ScopeError::io_context(e, "terminal clear failed"))
}
async fn run_suspended<H: Host>(
host: &mut H,
line: &str,
argv: &[String],
outside: &Outside,
command: impl Future<Output = Result<()>>,
state: &mut TuiState,
) -> Option<TuiExit> {
let started = Instant::now();
let started_at = chrono::Utc::now().to_rfc3339();
prompt::CANCEL.store(false, Ordering::SeqCst);
let mut terminal_errors = Vec::new();
crate::note!(outside.terminal, "\n── scope {} ──", line);
crate::note!(
outside.terminal,
"Running outside the TUI; it returns when the command ends (Ctrl-C cancels)."
);
let ended = tokio::select! {
biased;
Some(sig) = ending_signal(host.streams().1) => Err(sig),
result = command => Ok(result),
};
let ended = match ended {
Ok(_) if prompt::CANCEL.load(Ordering::SeqCst) => {
let queued =
tokio::time::timeout(PROMPT_SIGNAL_WAIT, ending_signal(host.streams().1)).await;
Err(queued.ok().flatten().unwrap_or(Signal::Int))
}
ended => ended,
};
let ending = match ended {
Ok(result) => Ending::Finished(result),
Err(Signal::Int) => Ending::Cancelled,
Err(sig) => return Some(end_suspended(state, line, argv, sig)),
};
let duration = started.elapsed();
if let Ending::Finished(Err(e)) = &ending
&& let Err(write_err) = crate::cli::errors::display_error(e, &outside.out)
{
terminal_errors.push(write_err.to_string());
}
state.show_outside(&outside.copy, &ending);
state.record_suspended(line, argv, duration, started_at, &ending);
for e in terminal_errors {
state.push_note(&format!("Terminal I/O error while outside the TUI: {}", e));
}
if matches!(ending, Ending::Cancelled)
&& let Some(sig) = drain_extra_ints(host.streams().1)
{
return Some(end_suspended(state, line, argv, sig));
}
None
}
fn drain_extra_ints<S: futures::Stream<Item = Signal> + Unpin>(signals: &mut S) -> Option<Signal> {
use futures::FutureExt;
while let Some(Some(sig)) = signals.next().now_or_never() {
if sig.ends_suspended() && sig != Signal::Int {
return Some(sig);
}
}
None
}
async fn ending_signal<S: futures::Stream<Item = Signal> + Unpin>(
signals: &mut S,
) -> Option<Signal> {
while let Some(sig) = signals.next().await {
if sig.ends_suspended() {
return Some(sig);
}
}
None
}
fn end_suspended(state: &mut TuiState, line: &str, argv: &[String], sig: Signal) -> TuiExit {
state.record_signal(sig.name(), "suspend", line, argv);
TuiExit {
code: sig.exit_code().expect("TERM, HUP and QUIT have exit codes"),
}
}
async fn cancel_prompts_on<S: futures::Stream<Item = Signal> + Unpin>(
mut signals: S,
flag: &AtomicBool,
) {
while let Some(sig) = signals.next().await {
if sig.ends_suspended() {
flag.store(true, Ordering::SeqCst);
}
}
}
fn drain_into(buf: &Mutex<Vec<u8>>, state: &mut TuiState) {
let bytes = std::mem::take(&mut *buf.lock().unwrap_or_else(|p| p.into_inner()));
if !bytes.is_empty() {
state.push_ansi(&String::from_utf8_lossy(&bytes));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use ansi_to_tui::IntoText;
use clap::Parser;
use scope::chains::mocks::MockClientFactory;
#[test]
fn test_tui_requires_a_terminal() {
assert!(tui_terminal_ok(true, true, Some("xterm-256color")).is_ok());
assert!(tui_terminal_ok(true, true, None).is_ok());
for (i, o, t) in [
(false, true, Some("xterm")),
(true, false, Some("xterm")),
(true, true, Some("dumb")),
] {
let e = tui_terminal_ok(i, o, t).unwrap_err();
assert!(
e.starts_with("scope tui needs an interactive terminal"),
"{e}"
);
}
}
const ADDR: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
const TX: &str = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
fn plain(ansi: &str) -> String {
ansi.into_text()
.unwrap()
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
async fn via_tui(line: &str, factory: &MockClientFactory) -> String {
let config = Config::default();
let mut s = TuiState::new(
SessionContext::default(),
Journal::default(),
vocab::Vocab::default(),
);
let Plan::Run(inv) = s.submit(line, &config) else {
panic!("`{}` did not plan a run", line)
};
assert_eq!(inv.route, Route::Pane);
let out = s.start(line, &inv);
let res = run_invocation(*inv, &config, factory, &out).await;
s.finish(Ending::Finished(res));
s.scrollback_text()
}
async fn via_cli(line: &str, factory: &MockClientFactory) -> String {
let mut argv = vec!["scope".to_string()];
argv.extend(shlex::split(line).unwrap());
let cli = Cli::try_parse_from(argv).unwrap();
let (o, cap) = Output::capture();
assert!(!o.is_tui());
let res =
crate::cli::dispatch::dispatch_with(cli.command, &Config::default(), factory, &o).await;
if let Err(e) = &res {
crate::cli::errors::display_error(e, &o).unwrap();
}
format!("{}\n{}", plain(&cap.out()), plain(&cap.err()))
}
async fn assert_same_result(line: &str) {
let _lock = DIAG_LOCK.lock().await;
let factory = MockClientFactory::new();
let cli = via_cli(line, &factory).await;
let tui = via_tui(line, &factory).await;
assert!(!cli.trim().is_empty(), "`{}` printed nothing", line);
for row in cli.lines().filter(|r| !r.trim().is_empty()) {
assert!(
tui.contains(row),
"`{}`: the TUI pane is missing the CLI line {:?}\n--- TUI ---\n{}",
line,
row,
tui
);
}
}
#[tokio::test]
async fn test_address_same_as_cli() {
assert_same_result(&format!("address {}", ADDR)).await;
}
#[tokio::test]
async fn test_tx_same_as_cli() {
assert_same_result(&format!("tx {}", TX)).await;
}
#[tokio::test]
async fn test_market_summary_dex_same_as_cli() {
assert_same_result("market summary USDC --venue eth").await;
}
#[tokio::test]
async fn test_venues_list_json_same_as_cli() {
assert_same_result("venues list --format json").await;
}
#[tokio::test]
async fn test_failed_command_shows_same_error_as_cli() {
assert_same_result("venues validate /nonexistent/venue.yaml").await;
}
use crossterm::event::{KeyCode, KeyEvent, KeyEventState, KeyModifiers};
use futures::channel::mpsc;
use futures::stream::BoxStream;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::task::Poll;
struct TestHost {
terminal: Terminal<TestBackend>,
keys: BoxStream<'static, io::Result<Event>>,
signals: mpsc::UnboundedReceiver<Signal>,
signal_tx: mpsc::UnboundedSender<Signal>,
suspend_signals: Vec<Signal>,
suspended: Output,
suspends: usize,
resumes: usize,
restores: usize,
stops: usize,
clears: usize,
draws: usize,
draws_at_stop: usize,
stop_fails: bool,
raw: String,
raw_fails: bool,
resume_fails: bool,
widths: Vec<(&'static str, Option<usize>)>,
width: Option<usize>,
}
impl Host for TestHost {
type B = TestBackend;
type Events = BoxStream<'static, io::Result<Event>>;
type Signals = mpsc::UnboundedReceiver<Signal>;
fn terminal(&mut self) -> &mut Terminal<TestBackend> {
self.draws += 1;
self.widths.push(("draw", self.width));
&mut self.terminal
}
fn streams(&mut self) -> (&mut Self::Events, &mut Self::Signals) {
(&mut self.keys, &mut self.signals)
}
fn suspend(&mut self) {
self.suspends += 1;
self.widths.push(("suspend", self.width));
for s in self.suspend_signals.drain(..) {
self.signal_tx.unbounded_send(s).unwrap();
}
}
fn resume(&mut self) -> Result<()> {
self.resumes += 1;
if self.resume_fails {
return Err(crate::cli::screen::not_taken(io::Error::other("no tty")));
}
Ok(())
}
fn restore(&mut self) {
self.restores += 1;
self.widths.push(("restore", self.width));
}
fn stop_self(&mut self) -> io::Result<()> {
self.stops += 1;
self.draws_at_stop = self.draws;
if self.stop_fails {
return Err(io::Error::other("kill failed"));
}
self.signal_tx.unbounded_send(Signal::Cont).unwrap();
Ok(())
}
fn clear(&mut self) -> io::Result<()> {
self.clears += 1;
self.terminal.clear().map_err(|never| match never {})
}
fn suspended_output(&self) -> Output {
self.suspended.clone()
}
fn write_raw(&mut self, text: &str) -> io::Result<()> {
self.raw.push_str(text);
if self.raw_fails {
return Err(io::Error::other("tty gone"));
}
Ok(())
}
fn set_width(&mut self, width: Option<usize>) {
self.width = width;
}
}
type Driven = (
Result<TuiExit>,
TuiState,
TestHost,
crate::cli::output::Captured,
);
fn press(code: KeyCode) -> io::Result<Event> {
Ok(Event::Key(KeyEvent::new(code, KeyModifiers::NONE)))
}
fn mouse(kind: MouseEventKind) -> io::Result<Event> {
Ok(Event::Mouse(crossterm::event::MouseEvent {
kind,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
}))
}
#[tokio::test]
async fn test_the_wheel_scrolls_and_a_mouse_move_draws_nothing() {
let _lock = DIAG_LOCK.lock().await;
let mut events = script(&["help"]);
events.push(mouse(MouseEventKind::ScrollDown));
let (res, state, host, _) = drive(events).await;
res.unwrap();
let after_one_notch = host.draws;
let scrolled = state.scrolled();
let mut events = script(&["help"]);
events.push(mouse(MouseEventKind::ScrollDown));
for _ in 0..20 {
events.push(mouse(MouseEventKind::Moved));
}
let (res, state2, host2, _) = drive(events).await;
res.unwrap();
assert_eq!(state2.scrolled(), scrolled, "a move scrolls nothing");
assert_eq!(host2.draws, after_one_notch, "and draws nothing");
let mut events = script(&["help"]);
events.push(mouse(MouseEventKind::ScrollDown));
events.push(mouse(MouseEventKind::ScrollDown));
events.push(mouse(MouseEventKind::ScrollUp));
let (res, state3, _, _) = drive(events).await;
res.unwrap();
assert_eq!(
state3.scrolled(),
scrolled,
"a notch is {WHEEL_ROWS} rows each way"
);
}
fn script(lines: &[&str]) -> Vec<io::Result<Event>> {
let mut events = Vec::new();
for line in lines {
if *line == "<esc>" {
events.push(press(KeyCode::Esc));
continue;
}
events.extend(line.chars().map(|c| press(KeyCode::Char(c))));
events.push(press(KeyCode::Enter));
}
events
}
async fn drive(events: Vec<io::Result<Event>>) -> Driven {
drive_with(Journal::default(), events, Vec::new()).await
}
async fn drive_with(
journal: Journal,
events: Vec<io::Result<Event>>,
signals: Vec<Signal>,
) -> Driven {
let (host, captured) = test_host(events, signals);
drive_host(journal, host, captured).await
}
fn test_host(
events: Vec<io::Result<Event>>,
signals: Vec<Signal>,
) -> (TestHost, crate::cli::output::Captured) {
let (sig_tx, sig_rx) = mpsc::unbounded();
let mut then = Some((signals, sig_tx.clone()));
let tail = futures::stream::poll_fn(move |_| match then.take() {
Some((sigs, tx)) if !sigs.is_empty() => {
for s in sigs {
tx.unbounded_send(s).unwrap();
}
Poll::Pending
}
_ => Poll::Ready(None),
});
let (suspended, captured) = Output::capture_merged();
let host = TestHost {
terminal: Terminal::new(TestBackend::new(100, 30)).unwrap(),
keys: futures::stream::iter(events).chain(tail).boxed(),
signals: sig_rx,
signal_tx: sig_tx,
suspend_signals: Vec::new(),
suspended,
suspends: 0,
resumes: 0,
restores: 0,
stops: 0,
clears: 0,
draws: 0,
draws_at_stop: 0,
stop_fails: false,
raw: String::new(),
raw_fails: false,
resume_fails: false,
widths: Vec::new(),
width: None,
};
(host, captured)
}
async fn drive_host(
journal: Journal,
host: TestHost,
captured: crate::cli::output::Captured,
) -> Driven {
drive_host_on(journal, host, captured, Box::new(MockClientFactory::new())).await
}
fn settings_with(clients: Box<dyn ChainClientFactory>) -> Settings {
Settings {
config: Config::default(),
clients,
}
}
async fn drive_host_on(
journal: Journal,
host: TestHost,
captured: crate::cli::output::Captured,
clients: Box<dyn ChainClientFactory>,
) -> Driven {
let reload = || Ok(settings_with(Box::new(MockClientFactory::new())));
drive_host_reloading(journal, host, captured, settings_with(clients), &reload).await
}
async fn drive_host_reloading(
journal: Journal,
mut host: TestHost,
captured: crate::cli::output::Captured,
settings: Settings,
reload: Reload<'_>,
) -> Driven {
let mut state = TuiState::new(SessionContext::default(), journal, vocab::Vocab::default());
welcome(&mut state, true);
let res = session(&mut host, &mut state, settings, reload, |_| Ok(())).await;
(res, state, host, captured)
}
struct PendingDex;
#[async_trait::async_trait]
impl scope::chains::DexDataSource for PendingDex {
async fn get_token_price(&self, _: &str, _: &str) -> Option<f64> {
std::future::pending().await
}
async fn get_native_token_price(&self, _: &str) -> Option<f64> {
std::future::pending().await
}
async fn get_token_data(
&self,
_: &str,
_: &str,
) -> scope::Result<scope::chains::dex::DexTokenData> {
std::future::pending().await
}
async fn search_tokens(
&self,
_: &str,
_: Option<&str>,
) -> scope::Result<Vec<scope::chains::dex::TokenSearchResult>> {
std::future::pending().await
}
}
struct PendingFactory(MockClientFactory);
impl ChainClientFactory for PendingFactory {
fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
scope::http::test_transport()
}
fn create_chain_client(
&self,
chain: &str,
) -> scope::Result<Box<dyn scope::chains::ChainClient>> {
self.0.create_chain_client(chain)
}
fn create_dex_client(&self) -> Box<dyn scope::chains::DexDataSource> {
Box::new(PendingDex)
}
}
const PENDING_REPEAT: &str = "market summary USDC --venue eth --every 30s --duration 1h";
async fn drive_pending(events: Vec<io::Result<Event>>, signals: Vec<Signal>) -> Driven {
let (host, captured) = test_host(events, signals);
let clients = PendingFactory(MockClientFactory::new());
drive_host_on(Journal::default(), host, captured, Box::new(clients)).await
}
async fn drive_signal_after(lines: &[&str], signal: Signal) -> Driven {
drive_with(Journal::default(), script(lines), vec![signal]).await
}
#[tokio::test]
async fn test_paste_event_fills_the_input_and_does_not_run_it() {
let _lock = DIAG_LOCK.lock().await;
let (res, state, _, _) = drive(vec![Ok(Event::Paste("ctx\nhelp\n".into()))]).await;
assert_eq!(res.unwrap().code, 0);
assert_eq!(state.input, "ctx help");
assert!(state.history.is_empty(), "nothing was submitted");
}
#[tokio::test]
async fn test_term_signal_saves_restores_and_journals() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
let (res, _, host, _) = drive_with(journal, script(&["ctx"]), vec![Signal::Term]).await;
assert_eq!(res.unwrap().code, 143);
assert_eq!(host.restores, 1, "terminal restored once");
let ev = std::fs::read_to_string(dir.path().join("e.jsonl")).unwrap();
assert!(ev.contains("\"outcome\":\"signal:TERM\""), "{ev}");
let history = std::fs::read_to_string(dir.path().join("h")).unwrap();
assert!(history.contains("ctx"), "{history}");
}
#[tokio::test]
async fn test_term_signal_cancels_running_pane_command() {
let _lock = DIAG_LOCK.lock().await;
let (res, state, _, _) = drive_pending(script(&[PENDING_REPEAT]), vec![Signal::Term]).await;
assert_eq!(res.unwrap().code, 143);
assert!(state.scrollback_text().contains("Cancelled"));
assert!(state.running.is_none());
}
#[tokio::test]
async fn test_each_fatal_signal_exits_with_128_plus_its_number() {
let _lock = DIAG_LOCK.lock().await;
for (sig, code) in [
(Signal::Hup, 129),
(Signal::Int, 130),
(Signal::Quit, 131),
(Signal::Term, 143),
] {
let (res, _, host, _) = drive_signal_after(&[], sig).await;
assert_eq!(res.unwrap().code, code, "{sig:?}");
assert_eq!(host.restores, 1, "{sig:?}");
}
}
#[tokio::test]
async fn test_stop_and_continue_signals_do_not_end_the_tui() {
let _lock = DIAG_LOCK.lock().await;
for sig in [Signal::Tstp, Signal::Cont] {
let (res, _, host, _) = drive_signal_after(&[], sig).await;
assert_eq!(res.unwrap().code, 0, "{sig:?}");
assert_eq!(host.restores, 1, "{sig:?}");
}
}
fn ctrl_z() -> io::Result<Event> {
Ok(Event::Key(KeyEvent::new(
KeyCode::Char('z'),
KeyModifiers::CONTROL,
)))
}
fn screen_text(host: &TestHost) -> String {
let buf = host.terminal.backend().buffer().clone();
buf.content().iter().map(|c| c.symbol()).collect()
}
fn widths_at(host: &TestHost, stage: &str) -> Vec<Option<usize>> {
host.widths
.iter()
.filter(|(s, _)| *s == stage)
.map(|(_, w)| *w)
.collect()
}
#[tokio::test]
async fn test_width_override_is_the_pane_and_is_removed_when_the_tui_leaves() {
let _lock = DIAG_LOCK.lock().await;
let mut events = script(&["setup --key nosuchkey"]);
events.push(ctrl_z());
events.push(press(KeyCode::Char('x')));
let (res, _, host, _) = drive(events).await;
res.unwrap();
assert_eq!(widths_at(&host, "suspend"), vec![None, None]);
assert_eq!(widths_at(&host, "restore"), vec![None]);
let draws = widths_at(&host, "draw");
assert_eq!(draws.last(), Some(&Some(100)), "set again after Ctrl-Z");
}
struct CountingFactory(Arc<std::sync::atomic::AtomicUsize>);
impl ChainClientFactory for CountingFactory {
fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
scope::http::test_transport()
}
fn create_chain_client(
&self,
chain: &str,
) -> scope::Result<Box<dyn scope::chains::ChainClient>> {
MockClientFactory::new().create_chain_client(chain)
}
fn create_dex_client(&self) -> Box<dyn scope::chains::DexDataSource> {
self.0.fetch_add(1, Ordering::SeqCst);
MockClientFactory::new().create_dex_client()
}
}
#[tokio::test]
async fn test_after_setup_the_next_command_uses_the_reloaded_clients() {
let _lock = DIAG_LOCK.lock().await;
let first = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let reloaded = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let reloads = std::cell::Cell::new(0);
let reload = || {
reloads.set(reloads.get() + 1);
Ok(settings_with(Box::new(CountingFactory(reloaded.clone()))))
};
let (host, captured) = test_host(script(&["setup --key nosuchkey", "crawl USDC"]), vec![]);
let (res, state, _, _) = drive_host_reloading(
Journal::default(),
host,
captured,
settings_with(Box::new(CountingFactory(first.clone()))),
&reload,
)
.await;
res.unwrap();
assert_eq!(reloads.get(), 1, "setup reads the settings once");
assert!(state.scrollback_text().contains(SETUP_RELOADED_NOTE));
assert_eq!(
first.load(Ordering::SeqCst),
0,
"the old clients are not used"
);
assert!(
reloaded.load(Ordering::SeqCst) > 0,
"crawl used the new clients"
);
}
#[tokio::test]
async fn test_a_failed_reload_keeps_the_settings_and_says_to_restart() {
let _lock = DIAG_LOCK.lock().await;
let reload = || Err(ScopeError::Other("bad yaml".into()));
let (host, captured) = test_host(script(&["setup --key nosuchkey"]), vec![]);
let (res, state, _, _) = drive_host_reloading(
Journal::default(),
host,
captured,
settings_with(Box::new(MockClientFactory::new())),
&reload,
)
.await;
res.unwrap();
let text = state.scrollback_text();
assert!(text.contains("could not be read again"), "{text}");
assert!(text.contains("bad yaml"), "{text}");
assert!(!text.contains(SETUP_RELOADED_NOTE), "{text}");
}
#[tokio::test]
async fn test_other_commands_do_not_reload() {
let _lock = DIAG_LOCK.lock().await;
let reloads = std::cell::Cell::new(0);
let reload = || {
reloads.set(reloads.get() + 1);
Ok(settings_with(Box::new(MockClientFactory::new())))
};
let (host, captured) = test_host(script(&["crawl USDC", "venues list"]), vec![]);
let (res, state, _, _) = drive_host_reloading(
Journal::default(),
host,
captured,
settings_with(Box::new(MockClientFactory::new())),
&reload,
)
.await;
res.unwrap();
assert_eq!(reloads.get(), 0);
assert!(!state.scrollback_text().contains(SETUP_RELOADED_NOTE));
}
#[tokio::test]
async fn test_ctrl_z_stops_then_redraws_with_the_draft() {
let _lock = DIAG_LOCK.lock().await;
let mut events: Vec<_> = "draf".chars().map(|c| press(KeyCode::Char(c))).collect();
events.push(ctrl_z());
let (res, state, host, _) = drive(events).await;
assert_eq!(res.unwrap().code, 0, "Ctrl-Z does not end the TUI");
assert_eq!(host.stops, 1);
assert_eq!((host.suspends, host.resumes, host.clears), (1, 1, 1));
assert_eq!(state.input, "draf");
assert!(screen_text(&host).contains("draf"));
}
#[tokio::test]
async fn test_tstp_signal_stops_like_ctrl_z() {
let _lock = DIAG_LOCK.lock().await;
let (res, _, host, _) = drive_signal_after(&[], Signal::Tstp).await;
assert_eq!(res.unwrap().code, 0);
assert_eq!(host.stops, 1);
assert_eq!((host.suspends, host.resumes, host.clears), (1, 1, 1));
}
#[tokio::test]
async fn test_cont_without_our_stop_retakes_and_clears_the_screen() {
let _lock = DIAG_LOCK.lock().await;
let (res, _, host, _) = drive_signal_after(&[], Signal::Cont).await;
assert_eq!(res.unwrap().code, 0);
assert_eq!(host.stops, 0, "SIGCONT does not stop the process");
assert_eq!((host.suspends, host.resumes, host.clears), (1, 1, 1));
}
#[tokio::test]
async fn test_ctrl_z_keeps_a_running_pane_command() {
let _lock = DIAG_LOCK.lock().await;
let mut events = script(&[PENDING_REPEAT]);
events.push(ctrl_z());
let (res, state, host, _) = drive_pending(events, Vec::new()).await;
assert_eq!(res.unwrap().code, 0);
assert_eq!(host.stops, 1);
assert!(state.running.is_some(), "the command still runs");
assert!(!state.scrollback_text().contains("Cancelled"));
let screen = screen_text(&host);
assert!(screen.contains("Esc cancels"), "{screen}");
}
#[tokio::test(start_paused = true)]
async fn test_a_long_stop_does_not_replay_the_missed_redraw_ticks() {
const STOPPED: Duration = Duration::from_secs(120);
const MAX_DRAWS_AFTER_STOP: usize = 10;
let _lock = DIAG_LOCK.lock().await;
let (mut host, _) = test_host(Vec::new(), Vec::new());
let after_stop = futures::stream::once(async {
tokio::time::advance(STOPPED).await;
press(KeyCode::Char('x'))
});
host.keys = futures::stream::iter(vec![ctrl_z()])
.chain(after_stop)
.boxed();
let mut state = TuiState::new(
SessionContext::default(),
Journal::default(),
vocab::Vocab::default(),
);
let config = Config::default();
let Plan::Run(inv) = state.submit("venues list", &config) else {
panic!("`venues list` did not plan a run")
};
let _out = state.start("venues list", &inv);
let reload = || Ok(settings_with(Box::new(MockClientFactory::new())));
let settings = settings_with(Box::new(MockClientFactory::new()));
let res = session(&mut host, &mut state, settings, &reload, |_| Ok(())).await;
assert_eq!(res.unwrap().code, 0);
assert!(state.running.is_some(), "the tick stayed on");
assert_eq!(host.stops, 1);
assert_eq!(state.input, "x", "the key after the stop is handled");
let draws = host.draws - host.draws_at_stop;
assert!(
draws <= MAX_DRAWS_AFTER_STOP,
"{draws} draws between the stop and the end"
);
}
#[tokio::test]
async fn test_a_failed_stop_says_so_and_keeps_the_tui() {
let _lock = DIAG_LOCK.lock().await;
let (mut host, captured) = test_host(vec![ctrl_z()], vec![Signal::Cont]);
host.stop_fails = true;
let (res, state, host, _) = drive_host(Journal::default(), host, captured).await;
assert_eq!(res.unwrap().code, 0);
assert!(
state
.scrollback_text()
.contains("Could not stop the process: kill failed")
);
assert_eq!(host.stops, 1);
assert_eq!((host.suspends, host.resumes, host.clears), (2, 2, 2));
}
#[tokio::test]
async fn test_a_terminal_that_cannot_be_taken_back_ends_the_session_with_the_terminal_error() {
let _lock = DIAG_LOCK.lock().await;
let (mut host, captured) = test_host(vec![ctrl_z()], vec![]);
host.resume_fails = true;
let (res, _, host, _) = drive_host(Journal::default(), host, captured).await;
assert_eq!(
res.unwrap_err().to_string(),
"I/O error: cannot take the terminal: no tty"
);
assert_eq!(host.resumes, 1);
assert_eq!(
host.restores, 1,
"the session still gives the terminal back"
);
}
async fn within_limit<T>(fut: impl Future<Output = T>) -> T {
tokio::time::timeout(Duration::from_secs(10), fut)
.await
.expect("the signal did not end the wait")
}
fn records(dir: &tempfile::TempDir) -> Vec<serde_json::Value> {
std::fs::read_to_string(dir.path().join("e.jsonl"))
.unwrap()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
#[tokio::test]
async fn test_term_signal_during_a_suspended_command_ends_it() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let mut state = TuiState::new(
SessionContext::default(),
Journal::at(dir.path().join("h"), dir.path().join("e.jsonl")),
vocab::Vocab::default(),
);
let (mut host, _) = test_host(Vec::new(), Vec::new());
host.signal_tx.unbounded_send(Signal::Term).unwrap();
let outside = Outside::new(host.suspended_output());
let argv = vec!["monitor".to_string()];
let never = futures::future::pending::<Result<()>>();
let run = run_suspended(&mut host, "monitor", &argv, &outside, never, &mut state);
let exit = within_limit(run).await;
assert_eq!(exit, Some(TuiExit { code: 143 }));
let rec = records(&dir);
assert_eq!(rec.len(), 1, "{rec:?}");
assert_eq!(rec[0]["outcome"], "signal:TERM");
assert_eq!(rec[0]["route"], "suspend");
assert_eq!(rec[0]["input"], "monitor");
}
fn journaled_state(dir: &tempfile::TempDir) -> TuiState {
TuiState::new(
SessionContext::default(),
Journal::at(dir.path().join("h"), dir.path().join("e.jsonl")),
vocab::Vocab::default(),
)
}
fn assert_ran_note(state: &TuiState, line: &str, outcome: &str) {
let text = state.scrollback_text();
let note = text
.lines()
.find_map(|l| {
l.strip_prefix("ℹ ")
.filter(|n| n.starts_with("Ran outside the TUI:"))
})
.unwrap_or_else(|| panic!("no note in {text}"));
let pattern = format!(
r"^Ran outside the TUI: {} \({}, \d+\.\ds\)\.$",
regex::escape(line),
outcome
);
assert!(
regex::Regex::new(&pattern).unwrap().is_match(note),
"{note:?} does not match {pattern}"
);
}
#[tokio::test]
async fn test_suspended_command_is_cancelled_by_sigint() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let mut state = journaled_state(&dir);
let (mut host, captured) = test_host(Vec::new(), Vec::new());
host.signal_tx.unbounded_send(Signal::Int).unwrap();
let outside = Outside::new(host.suspended_output());
let argv = vec!["crawl".to_string(), "USDC".to_string()];
let never = futures::future::pending::<Result<()>>();
let run = run_suspended(&mut host, "crawl USDC", &argv, &outside, never, &mut state);
assert_eq!(within_limit(run).await, None, "the session goes on");
assert_ran_note(&state, "crawl USDC", "cancelled");
let rec = records(&dir);
assert_eq!(rec.len(), 1, "{rec:?}");
assert_eq!(rec[0]["outcome"], "cancelled");
assert_eq!(rec[0]["route"], "suspend");
assert!(captured.out().contains(
"Running outside the TUI; it returns when the command ends (Ctrl-C cancels)."
));
}
#[tokio::test]
async fn test_sigint_cancel_returns_to_a_working_loop() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
let (mut host, captured) = test_host(script(&["setup --key nosuchkey", "ctx"]), Vec::new());
host.suspend_signals = vec![Signal::Int];
let (res, state, host, _) = within_limit(drive_host(journal, host, captured)).await;
assert_eq!(res.unwrap().code, 0);
assert_eq!((host.suspends, host.resumes), (1, 1));
assert_ran_note(&state, "setup --key nosuchkey", "cancelled");
assert!(state.scrollback_text().contains("Current Context"));
let rec = records(&dir);
assert_eq!(rec[0]["outcome"], "cancelled", "{rec:?}");
assert_eq!(rec[1]["input"], "ctx", "{rec:?}");
}
async fn prompt_stopped_by(
sig: Option<Signal>,
result: Result<()>,
) -> (Option<TuiExit>, TuiState, TestHost, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let mut state = journaled_state(&dir);
let (mut host, _) = test_host(Vec::new(), Vec::new());
let outside = Outside::new(host.suspended_output());
let tx = host.signal_tx.clone();
let argv = vec!["setup".to_string()];
let prompt_stopped = async move {
crate::cli::prompt::CANCEL.store(true, Ordering::SeqCst);
if let Some(sig) = sig {
tx.unbounded_send(sig).unwrap();
}
result
};
let run = run_suspended(
&mut host,
"setup",
&argv,
&outside,
prompt_stopped,
&mut state,
);
let exit = within_limit(run).await;
crate::cli::prompt::CANCEL.store(false, Ordering::SeqCst);
(exit, state, host, dir)
}
#[tokio::test]
async fn test_a_cancelled_prompt_is_recorded_as_cancelled_and_its_int_used_up() {
let _lock = DIAG_LOCK.lock().await;
for result in [
Err(ScopeError::IoError(io::Error::other("cancelled"))),
Ok(()),
] {
let (exit, state, mut host, dir) = prompt_stopped_by(Some(Signal::Int), result).await;
assert_eq!(exit, None);
assert_ran_note(&state, "setup", "cancelled");
assert_eq!(records(&dir)[0]["outcome"], "cancelled");
assert!(host.signals.try_recv().is_err(), "the INT is used up");
}
}
#[tokio::test]
async fn test_term_at_a_prompt_ends_the_session_from_suspend_mode() {
let _lock = DIAG_LOCK.lock().await;
let failed = Err(ScopeError::IoError(io::Error::other("cancelled")));
let (exit, _, _, dir) = prompt_stopped_by(Some(Signal::Term), failed).await;
assert_eq!(exit, Some(TuiExit { code: 143 }));
let rec = records(&dir);
assert_eq!(rec.len(), 1, "{rec:?}");
assert_eq!(rec[0]["outcome"], "signal:TERM");
assert_eq!(rec[0]["route"], "suspend");
}
#[tokio::test]
async fn test_a_suspended_command_starts_with_the_cancel_flag_clear() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let mut state = journaled_state(&dir);
let (mut host, _) = test_host(Vec::new(), Vec::new());
let outside = Outside::new(host.suspended_output());
crate::cli::prompt::CANCEL.store(true, Ordering::SeqCst);
let saw_clear = async {
assert!(!crate::cli::prompt::CANCEL.load(Ordering::SeqCst));
Ok(())
};
let run = run_suspended(&mut host, "setup", &[], &outside, saw_clear, &mut state);
assert_eq!(within_limit(run).await, None);
assert_ran_note(&state, "setup", "ok");
crate::cli::prompt::CANCEL.store(false, Ordering::SeqCst);
}
fn lines_before_note(state: &TuiState, first: &str) -> Vec<String> {
let text = state.scrollback_text();
let lines: Vec<String> = text.lines().map(str::to_string).collect();
let start = lines
.iter()
.position(|l| l.contains(first))
.unwrap_or_else(|| panic!("{first:?} is not in the scrollback:\n{text}"));
let note = lines
.iter()
.position(|l| l.contains("Ran outside the TUI:"))
.unwrap_or_else(|| panic!("no note:\n{text}"));
assert!(start < note, "the output comes before the note:\n{text}");
lines[start..note].to_vec()
}
#[tokio::test]
async fn test_a_suspended_report_shows_in_the_scrollback_and_is_the_copy() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let mut state = journaled_state(&dir);
let (mut host, captured) = test_host(Vec::new(), Vec::new());
let outside = Outside::new(host.suspended_output());
let out = &outside.out;
let report = async {
crate::outln!(out, "\x1b[1mToken Report\x1b[0m: USDC")?;
crate::errln!(out, "warning: partial data")?;
crate::outln!(out, "holders 42")?;
Ok(())
};
let run = run_suspended(&mut host, "crawl USDC", &[], &outside, report, &mut state);
assert_eq!(within_limit(run).await, None);
let shown = lines_before_note(&state, "Token Report: USDC");
assert_eq!(
shown,
["Token Report: USDC", "warning: partial data", "holders 42"]
);
assert!(captured.out().contains("holders 42"));
let Action::Copy(copy) =
state.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL))
else {
panic!("Ctrl-Y has nothing to copy")
};
assert_eq!(copy, "Token Report: USDC\nholders 42\n");
}
#[tokio::test]
async fn test_a_failed_suspended_command_shows_its_output_and_error() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let mut state = journaled_state(&dir);
let (mut host, _) = test_host(Vec::new(), Vec::new());
let outside = Outside::new(host.suspended_output());
let out = &outside.out;
let failed = async {
crate::outln!(out, "searching USDC")?;
Err(ScopeError::Other("crawl blew up".into()))
};
let run = run_suspended(&mut host, "crawl USDC", &[], &outside, failed, &mut state);
assert_eq!(within_limit(run).await, None);
assert_ran_note(&state, "crawl USDC", "failed");
let shown = lines_before_note(&state, "searching USDC").join("\n");
let error = shown.find("✗ crawl blew up").expect(&shown);
assert!(shown.find("searching USDC").unwrap() < error, "{shown}");
let copy = state.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
assert!(matches!(copy, Action::None), "no copy after a failure");
}
#[tokio::test(start_paused = true)]
async fn test_a_set_flag_without_a_signal_does_not_hang_the_tui() {
let _lock = DIAG_LOCK.lock().await;
let started = tokio::time::Instant::now();
let failed = Err(ScopeError::IoError(io::Error::other("cancelled")));
let (exit, state, _, dir) = prompt_stopped_by(None, failed).await;
assert_eq!(exit, None);
assert!(started.elapsed() <= PROMPT_SIGNAL_WAIT + Duration::from_millis(100));
assert_ran_note(&state, "setup", "cancelled");
assert_eq!(records(&dir)[0]["outcome"], "cancelled");
}
#[tokio::test]
async fn test_a_second_ctrl_c_does_not_end_the_session() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
let (mut host, captured) = test_host(script(&["setup --key nosuchkey", "ctx"]), Vec::new());
host.suspend_signals = vec![Signal::Int, Signal::Int];
let (res, state, host, _) = within_limit(drive_host(journal, host, captured)).await;
assert_eq!(res.unwrap().code, 0);
assert_eq!((host.suspends, host.resumes), (1, 1));
assert_ran_note(&state, "setup --key nosuchkey", "cancelled");
assert!(state.scrollback_text().contains("Current Context"));
let rec = records(&dir);
assert_eq!(rec.len(), 2, "{rec:?}");
assert_eq!(rec[1]["input"], "ctx", "{rec:?}");
}
#[tokio::test]
async fn test_a_term_queued_after_a_cancel_still_ends_the_session() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
let (mut host, captured) = test_host(script(&["setup --key nosuchkey", "ctx"]), Vec::new());
host.suspend_signals = vec![Signal::Int, Signal::Int, Signal::Term];
let (res, _, host, _) = within_limit(drive_host(journal, host, captured)).await;
assert_eq!(res.unwrap().code, 143);
assert_eq!(host.resumes, 0);
let rec = records(&dir);
assert_eq!(rec[0]["outcome"], "cancelled", "{rec:?}");
assert_eq!(rec[1]["outcome"], "signal:TERM", "{rec:?}");
assert_eq!(rec[1]["route"], "suspend", "{rec:?}");
}
#[tokio::test]
async fn test_ending_signals_set_the_prompt_cancel_flag() {
for (sigs, set) in [
(vec![Signal::Tstp, Signal::Cont], false),
(vec![Signal::Int], true),
(vec![Signal::Term], true),
(vec![Signal::Hup], true),
(vec![Signal::Quit], true),
] {
let flag = AtomicBool::new(false);
cancel_prompts_on(futures::stream::iter(sigs.clone()), &flag).await;
assert_eq!(flag.load(Ordering::SeqCst), set, "{sigs:?}");
}
}
struct BrokenTty;
impl io::Write for BrokenTty {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::other("tty gone"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn test_terminal_errors_outside_the_tui_are_reported_after_it() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let mut state = journaled_state(&dir);
let (mut host, _) = test_host(Vec::new(), Vec::new());
let outside = Outside::new(Output::from_writers(BrokenTty, BrokenTty));
let failed = async { Err(ScopeError::Other("boom".into())) };
let run = run_suspended(&mut host, "setup", &[], &outside, failed, &mut state);
assert_eq!(within_limit(run).await, None);
assert_ran_note(&state, "setup", "failed");
let text = state.scrollback_text();
assert_eq!(
text.matches("Terminal I/O error while outside the TUI: tty gone")
.count(),
1,
"the error text; the header is a note, and a failed note is dropped: {text}"
);
assert_eq!(records(&dir)[0]["outcome"], "error");
}
#[tokio::test]
async fn test_suspended_command_ignores_tstp_and_cont() {
let _lock = DIAG_LOCK.lock().await;
let (mut host, captured) = test_host(script(&["setup --key nosuchkey"]), Vec::new());
host.suspend_signals = vec![Signal::Tstp, Signal::Cont];
let (res, state, host, _) = drive_host(Journal::default(), host, captured).await;
assert_eq!(res.unwrap().code, 0);
assert_eq!((host.suspends, host.resumes), (1, 1));
assert_eq!((host.stops, host.clears), (0, 0));
assert_ran_note(&state, "setup --key nosuchkey", "ok");
}
#[tokio::test]
async fn test_event_stream_error_still_restores_once_and_saves() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
let mut events = script(&["ctx"]);
events.push(Err(io::Error::other("tty gone")));
let (res, _, host, _) = drive_with(journal, events, Vec::new()).await;
assert!(res.unwrap_err().to_string().contains("tty gone"));
assert_eq!(host.restores, 1);
let history = std::fs::read_to_string(dir.path().join("h")).unwrap();
assert!(history.contains("ctx"), "{history}");
}
#[tokio::test]
async fn test_normal_exit_restores_and_returns_code_zero() {
let _lock = DIAG_LOCK.lock().await;
let (res, _, host, _) = drive(script(&["exit"])).await;
assert_eq!(res.unwrap().code, 0);
assert_eq!(host.restores, 1);
}
#[tokio::test]
async fn test_loop_runs_session_and_pane_commands_then_exits() {
let _lock = DIAG_LOCK.lock().await;
let (res, state, host, _) =
drive(script(&["chain base", "venues list", "exit", "never runs"])).await;
res.unwrap();
let text = state.scrollback_text();
assert!(text.contains("Type `help` for commands"));
assert!(text.contains("Chain pinned to: base"));
assert!(text.contains("Available Venues"), "{}", text);
assert!(text.contains("Done in"));
assert!(!text.contains("never runs"), "nothing runs after exit");
let buf = host.terminal.backend().buffer().clone();
let screen: String = buf.content().iter().map(|c| c.symbol()).collect();
assert!(screen.contains("scope:base>"));
}
fn ctrl_y_event() -> io::Result<Event> {
Ok(Event::Key(KeyEvent::new(
KeyCode::Char('y'),
KeyModifiers::CONTROL,
)))
}
#[tokio::test]
async fn test_ctrl_y_sends_the_json_and_save_writes_it() {
use base64::Engine as _;
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("one.json");
let mut events = script(&[
"venues list --format json",
&format!("save '{}'", file.display()),
]);
events.push(ctrl_y_event());
let (res, state, host, _) = drive(events).await;
res.unwrap();
let body = std::fs::read_to_string(&file).unwrap();
serde_json::from_str::<serde_json::Value>(&body).unwrap();
let text = state.scrollback_text();
assert!(text.contains("copied "), "{text}");
let payload = host
.raw
.strip_prefix("\x1b]52;c;")
.and_then(|r| r.strip_suffix('\x07'))
.unwrap_or_else(|| panic!("not an OSC 52 sequence: {:?}", host.raw));
let sent = base64::engine::general_purpose::STANDARD
.decode(payload)
.unwrap();
assert_eq!(String::from_utf8(sent).unwrap(), body);
}
#[tokio::test]
async fn test_save_refuses_to_overwrite_then_force_replaces_and_both_are_journaled() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("two.json");
std::fs::write(&file, "old").unwrap();
let save = format!("save '{}'", file.display());
let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
let (res, state, _, _) = drive_with(
journal,
script(&[
"venues list --format json",
&save,
&format!("{save} --force"),
]),
Vec::new(),
)
.await;
res.unwrap();
let text = state.scrollback_text();
assert!(text.contains("exists; add --force"), "{text}");
assert!(text.contains("saved "), "{text}");
let body = std::fs::read_to_string(&file).unwrap();
assert_ne!(body, "old", "--force replaced the file");
serde_json::from_str::<serde_json::Value>(&body).unwrap();
let recs = records(&dir);
let refused = recs.iter().find(|r| r["outcome"] == "refused").unwrap();
assert_eq!(refused["input"], save);
assert!(refused["error"].as_str().unwrap().contains("exists"));
assert!(
recs.iter()
.any(|r| r["input"] == format!("{save} --force") && r["outcome"] == "session")
);
}
#[tokio::test]
async fn test_ctrl_y_results_are_journaled() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
let mut events = vec![ctrl_y_event()];
events.extend(script(&["venues list"]));
events.push(ctrl_y_event());
let (mut host, captured) = test_host(events, Vec::new());
host.raw_fails = true;
let (_, _, _, _) = drive_host(journal, host, captured).await;
let recs = records(&dir);
let ctrl_y: Vec<_> = recs.iter().filter(|r| r["input"] == "Ctrl-Y").collect();
assert_eq!(ctrl_y.len(), 2, "{recs:?}");
assert_eq!(ctrl_y[0]["outcome"], "refused");
assert_eq!(ctrl_y[0]["error"], "nothing to copy yet");
assert_eq!(ctrl_y[1]["outcome"], "error");
assert!(ctrl_y[1]["error"].as_str().unwrap().contains("tty gone"));
}
#[tokio::test]
async fn test_a_failed_copy_is_noted() {
let _lock = DIAG_LOCK.lock().await;
let mut events = script(&["venues list"]);
events.push(Ok(Event::Key(KeyEvent::new(
KeyCode::Char('y'),
KeyModifiers::CONTROL,
))));
let (mut host, captured) = test_host(events, Vec::new());
host.raw_fails = true;
let (_, state, _, _) = drive_host(Journal::default(), host, captured).await;
assert!(state.scrollback_text().contains("Could not copy: tty gone"));
}
#[tokio::test]
async fn test_watch_open_runs_the_grid_next_to_the_shell() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tokens.yaml");
std::fs::write(
&path,
"name: tokens\ninterval: 1h\ntargets:\n - kind: token\n id: \"0x1234567890123456789012345678901234567890\"\n chain: ethereum\n rules: [\"price_usd > 0\"]\n",
)
.unwrap();
let line = format!(
"watch open {}",
path.display().to_string().replace('\\', "/")
);
let (res, mut state, host, _) = drive(script(&[&line])).await;
res.unwrap();
state.poll_watch();
let g = state.grid.as_ref().expect("the grid opened");
assert!(state.grid_shown);
assert_eq!(g.list.name, "tokens");
assert!(g.ready.is_some(), "the targets were resolved");
let last = g.last.as_ref().expect("the first tick came");
assert_eq!(
last.snapshot.targets[0].status,
scope::watch::TargetStatus::Passed
);
assert!(g.is_running(), "a 1h run goes on");
assert!(
screen_text(&host).contains("watchlist tokens"),
"{}",
screen_text(&host)
);
assert!(state.scrollback_text().contains("Watchlist tokens started"));
}
#[tokio::test]
async fn test_esc_in_the_grid_stops_the_run_and_writes_the_report() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("w.yaml");
let report = dir.path().join("w.md");
std::fs::write(
&path,
format!(
"interval: 1h\noutputs:\n report: '{}'\ntargets:\n - kind: token\n id: \"0x1234567890123456789012345678901234567890\"\n rules: [\"price_usd > 0\"]\n",
report.display()
),
)
.unwrap();
let line = format!(
"watch open {}",
path.display().to_string().replace('\\', "/")
);
let (res, mut state, _, _) = drive(script(&[&line, "", "<esc>"])).await;
res.unwrap();
let g = state.grid.as_ref().unwrap();
assert!(!g.is_running());
let (footer, _) = g.ended.clone().unwrap();
assert!(footer.starts_with("Stopped after"), "{footer}");
assert!(g.last.is_some(), "the first tick landed before Esc");
assert!(footer.contains("Report saved to"), "{footer}");
assert!(
std::fs::read_to_string(&report)
.unwrap()
.contains("# Watchlist Report")
);
state.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(state.grid.is_none() && !state.grid_shown);
assert!(
state.scrollback_text().contains("watchlist w:"),
"{}",
state.scrollback_text()
);
}
const TOKEN: &str = "0x1234567890123456789012345678901234567890";
fn ctrl_key(c: char) -> io::Result<Event> {
Ok(Event::Key(KeyEvent::new(
KeyCode::Char(c),
KeyModifiers::CONTROL,
)))
}
#[tokio::test]
async fn test_monitor_opens_the_wizard_filled_from_the_arguments() {
let _lock = DIAG_LOCK.lock().await;
let (res, state, host, _) = drive(script(&[
"monitor USDN --venue weex --every 30s --duration 1h --layout feed",
]))
.await;
res.unwrap();
let w = state.wizard.as_ref().expect("the wizard opened");
assert_eq!(w.list.interval, std::time::Duration::from_secs(30));
let t = &w.list.targets[0];
assert_eq!(
(t.id.as_str(), t.venues.as_slice()),
("USDN", ["weex".to_string()].as_slice())
);
assert_eq!(t.preset, Some(scope::watch::Preset::MarketHealth));
assert!(state.grid.is_none(), "nothing runs before Start");
let screen = screen_text(&host);
assert!(screen.contains("monitor wizard"), "{screen}");
assert!(screen.contains("[x] market health"), "{screen}");
assert!(screen.contains("--layout has no effect"), "{screen}");
}
#[tokio::test]
async fn test_ctrl_r_in_the_wizard_starts_the_grid() {
let _lock = DIAG_LOCK.lock().await;
let mut events = script(&[&format!("monitor {TOKEN} --duration 1h")]);
events.push(ctrl_key('r'));
let (res, mut state, _, _) = drive(events).await;
res.unwrap();
state.poll_watch();
assert!(state.wizard.is_none());
let g = state.grid.as_ref().expect("the run started");
assert!(g.is_running());
let names: Vec<String> = g.list.targets[0]
.rules
.iter()
.map(ToString::to_string)
.collect();
assert_eq!(names, ["price_usd > 0", "volume_24h >= 0"]);
}
#[tokio::test]
async fn test_monitor_yes_starts_at_once_and_a_bad_argument_says_why() {
let _lock = DIAG_LOCK.lock().await;
let mut events = script(&[&format!("monitor {TOKEN} --yes")]);
events.push(ctrl_key('g'));
events.extend(script(&["monitor USDC --venue binance --every 0s"]));
let (res, mut state, _, _) = drive(events).await;
res.unwrap();
state.poll_watch();
assert!(state.wizard.is_none());
assert!(state.grid.as_ref().is_some_and(|g| g.is_running()));
assert!(
state
.scrollback_text()
.contains("monitor: --every: Duration must be positive"),
"{}",
state.scrollback_text()
);
}
#[tokio::test]
async fn test_scope_monitor_opens_the_tui_with_the_wizard() {
let _lock = DIAG_LOCK.lock().await;
let (mut host, _) = test_host(Vec::new(), Vec::new());
let mut state = TuiState::new(
SessionContext::default(),
Journal::default(),
vocab::Vocab::default(),
);
let cli = <crate::cli::Cli as clap::Parser>::try_parse_from([
"scope", "monitor", "USDN", "--venue", "weex",
])
.unwrap();
state.start = Some(cli.command);
let settings = settings_with(Box::new(MockClientFactory::new()));
let reload = || Ok(settings_with(Box::new(MockClientFactory::new())));
session(&mut host, &mut state, settings, &reload, |_| Ok(()))
.await
.unwrap();
assert!(state.wizard.is_some(), "the wizard is open at the start");
assert!(screen_text(&host).contains("monitor wizard"));
}
struct NoisyDex(scope::chains::mocks::MockDexSource);
const NOISE: &str = "noisy dex warning";
#[async_trait::async_trait]
impl scope::chains::DexDataSource for NoisyDex {
async fn get_token_price(&self, c: &str, a: &str) -> Option<f64> {
scope::notice!("{NOISE}");
self.0.get_token_price(c, a).await
}
async fn get_native_token_price(&self, c: &str) -> Option<f64> {
scope::notice!("{NOISE}");
self.0.get_native_token_price(c).await
}
async fn get_token_data(
&self,
c: &str,
a: &str,
) -> scope::Result<scope::chains::dex::DexTokenData> {
scope::notice!("{NOISE}");
self.0.get_token_data(c, a).await
}
async fn search_tokens(
&self,
q: &str,
c: Option<&str>,
) -> scope::Result<Vec<scope::chains::dex::TokenSearchResult>> {
scope::notice!("{NOISE}");
self.0.search_tokens(q, c).await
}
}
struct NoisyFactory(MockClientFactory);
impl ChainClientFactory for NoisyFactory {
fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
scope::http::test_transport()
}
fn create_chain_client(
&self,
chain: &str,
) -> scope::Result<Box<dyn scope::chains::ChainClient>> {
self.0.create_chain_client(chain)
}
fn create_dex_client(&self) -> Box<dyn scope::chains::DexDataSource> {
Box::new(NoisyDex(scope::chains::mocks::MockDexSource::new()))
}
}
fn token_watchlist(dir: &tempfile::TempDir) -> String {
let path = dir.path().join("noisy.yaml");
std::fs::write(
&path,
format!("interval: 1h\ntargets:\n - kind: token\n id: \"{TOKEN}\"\n rules: [\"price_usd > 0\"]\n"),
)
.unwrap();
format!(
"watch open {}",
path.display().to_string().replace('\\', "/")
)
}
#[tokio::test]
async fn test_a_watchlist_warning_stays_out_of_the_shell_and_comes_with_the_grid() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let mut events = script(&[&token_watchlist(&dir)]);
events.push(ctrl_key('g'));
events.extend(script(&["venues list"]));
let (host, captured) = test_host(events, Vec::new());
let (res, mut state, _, _) = drive_host_on(
Journal::default(),
host,
captured,
Box::new(NoisyFactory(MockClientFactory::new())),
)
.await;
res.unwrap();
state.poll_watch();
assert!(
state.grid.as_ref().is_some_and(|g| g.last.is_some()),
"the watchlist ticked"
);
let shell = state.scrollback_text();
assert!(shell.contains("binance"), "venues list ran: {shell}");
assert!(
!shell.contains(NOISE),
"no watchlist warning in the shell: {shell}"
);
state.stop_watch();
state.close_grid();
assert!(state.scrollback_text().contains(NOISE));
}
#[tokio::test]
async fn test_a_setup_reload_says_the_running_watchlist_uses_the_old_settings() {
let _lock = DIAG_LOCK.lock().await;
let dir = tempfile::tempdir().unwrap();
let mut events = script(&[&token_watchlist(&dir)]);
events.push(ctrl_key('g'));
events.extend(script(&["setup --key nosuchkey"]));
let reload = || Ok(settings_with(Box::new(MockClientFactory::new())));
let (host, captured) = test_host(events, Vec::new());
let (res, state, host, _) = drive_host_reloading(
Journal::default(),
host,
captured,
settings_with(Box::new(MockClientFactory::new())),
&reload,
)
.await;
res.unwrap();
let g = state.grid.as_ref().expect("the grid");
assert!(g.stale, "the grid knows");
assert!(state.scrollback_text().contains(grid::STALE_NOTE));
assert!(
screen_text(&host).contains("old settings"),
"{}",
screen_text(&host)
);
}
#[tokio::test]
async fn test_ctrl_g_without_a_grid_says_how_to_start_one() {
let _lock = DIAG_LOCK.lock().await;
let (res, state, _, _) = drive(vec![Ok(Event::Key(KeyEvent::new(
KeyCode::Char('g'),
KeyModifiers::CONTROL,
)))])
.await;
res.unwrap();
assert!(state.scrollback_text().contains("No watchlist runs"));
}
#[tokio::test]
async fn test_loop_esc_cancels_a_running_repeat_command() {
let _lock = DIAG_LOCK.lock().await;
let (res, state, _, _) =
drive_pending(script(&[PENDING_REPEAT, "<esc>"]), Vec::new()).await;
res.unwrap();
let text = state.scrollback_text();
assert!(text.contains("Cancelled after"), "{}", text);
assert!(state.running.is_none());
}
#[tokio::test]
async fn test_loop_suspend_mode_hands_over_the_terminal_and_records() {
let _lock = DIAG_LOCK.lock().await;
let (res, state, host, suspended) = drive(script(&["setup --key nosuchkey"])).await;
res.unwrap();
assert_eq!((host.suspends, host.resumes), (1, 1));
assert!(
suspended
.out()
.contains("── scope setup --key nosuchkey ──")
);
assert!(suspended.out().contains(
"Running outside the TUI; it returns when the command ends (Ctrl-C cancels)."
));
assert!(!suspended.out().contains("Press Enter"));
assert_ran_note(&state, "setup --key nosuchkey", "ok");
}
#[tokio::test]
async fn test_loop_ignores_non_press_keys_and_ends_on_stream_end() {
let _lock = DIAG_LOCK.lock().await;
let mut events = vec![Ok(Event::Resize(80, 24))];
let mut release = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
release.kind = KeyEventKind::Release;
release.state = KeyEventState::NONE;
events.push(Ok(Event::Key(release)));
let (res, state, _, _) = drive(events).await;
res.unwrap();
assert_eq!(state.input, "", "a key release must not type");
}
#[tokio::test]
async fn test_loop_ctrl_d_exits() {
let _lock = DIAG_LOCK.lock().await;
let (res, _, _, _) = drive(vec![Ok(Event::Key(KeyEvent::new(
KeyCode::Char('d'),
KeyModifiers::CONTROL,
)))])
.await;
res.unwrap();
}
#[tokio::test]
async fn test_loop_returns_event_stream_errors() {
let _lock = DIAG_LOCK.lock().await;
let (res, _, _, _) = drive(vec![Err(io::Error::other("tty gone"))]).await;
assert!(res.unwrap_err().to_string().contains("tty gone"));
}
#[tokio::test]
async fn test_library_warning_lands_in_the_running_command_output() {
let _lock = DIAG_LOCK.lock().await;
let mut s = TuiState::new(
SessionContext::default(),
Journal::default(),
vocab::Vocab::default(),
);
let Plan::Run(inv) = s.submit("venues list", &Config::default()) else {
panic!()
};
let _out = s.start("venues list", &inv);
scope::notice!(" ⚠ library warning");
s.finish(Ending::Finished(Ok(())));
assert!(s.scrollback_text().contains("⚠ library warning"));
}
#[cfg(unix)]
#[tokio::test]
async fn test_signal_stream_installs_all_handlers() {
assert!(signal_stream().is_ok());
}
}