1pub mod app;
32pub mod clip;
33pub mod complete;
34pub mod crash;
35pub mod exec;
36pub mod fit;
37pub mod grid;
38pub mod hint;
39pub mod journal;
40pub(crate) mod market_view;
41pub mod palette;
42pub(crate) mod repeat_view;
43pub mod vocab;
44pub mod wizard;
45
46use app::{Action, Ending, TuiState, run_invocation};
47use crossterm::event::{Event, EventStream, KeyEventKind, MouseEventKind};
48use exec::{Plan, Route, WatchCommand};
49use futures::StreamExt;
50use futures::stream::BoxStream;
51use journal::Journal;
52use scope::chains::ChainClientFactory;
53use scope::config::Config;
54use scope::error::{Result, ScopeError};
55use std::future::Future;
56use std::io::{self, Write};
57use std::pin::Pin;
58use std::sync::atomic::{AtomicBool, Ordering};
59use std::sync::{Arc, Mutex};
60use std::time::{Duration, Instant};
61
62use crate::cli::interactive::SessionContext;
63use crate::cli::output::{Captured, Output};
64use crate::cli::prompt;
65use crate::cli::screen;
66
67const PROMPT_SIGNAL_WAIT: Duration = Duration::from_secs(1);
70
71const LIVE_REDRAW: Duration = Duration::from_millis(100);
74
75const OUTSIDE_COPY_LIMIT: usize = app::SCROLLBACK_LIMIT * 200;
79
80const BANNER: &str = include_str!("../../../assets/banner.txt");
82
83const SIGNAL_EXIT_BASE: i32 = 128;
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct TuiExit {
90 pub code: i32,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97#[cfg_attr(not(unix), allow(dead_code))]
98pub(crate) enum Signal {
99 Term,
100 Hup,
101 Int,
102 Quit,
103 Tstp,
104 Cont,
105}
106
107impl Signal {
108 fn name(self) -> &'static str {
110 match self {
111 Signal::Term => "TERM",
112 Signal::Hup => "HUP",
113 Signal::Int => "INT",
114 Signal::Quit => "QUIT",
115 Signal::Tstp => "TSTP",
116 Signal::Cont => "CONT",
117 }
118 }
119
120 fn ends_suspended(self) -> bool {
123 matches!(
124 self,
125 Signal::Int | Signal::Term | Signal::Hup | Signal::Quit
126 )
127 }
128
129 fn exit_code(self) -> Option<i32> {
132 let number = match self {
133 Signal::Hup => 1,
134 Signal::Int => 2,
135 Signal::Quit => 3,
136 Signal::Term => 15,
137 Signal::Tstp | Signal::Cont => return None,
138 };
139 Some(SIGNAL_EXIT_BASE + number)
140 }
141}
142
143pub fn tui_terminal_ok(
152 stdin_tty: bool,
153 stdout_tty: bool,
154 term: Option<&str>,
155) -> std::result::Result<(), String> {
156 if stdin_tty && stdout_tty && term != Some("dumb") {
157 return Ok(());
158 }
159 Err("scope tui needs an interactive terminal; run the command directly, e.g. scope market summary …".to_string())
160}
161
162pub struct Settings {
165 pub config: Config,
166 pub clients: Box<dyn ChainClientFactory>,
167}
168
169pub type Reload<'a> = &'a dyn Fn() -> Result<Settings>;
171
172pub async fn run(no_banner: bool, settings: Settings, reload: Reload<'_>) -> Result<TuiExit> {
180 run_with(no_banner, None, settings, reload).await
181}
182
183pub async fn run_with(
190 no_banner: bool,
191 start: Option<crate::cli::Commands>,
192 settings: Settings,
193 reload: Reload<'_>,
194) -> Result<TuiExit> {
195 let signals = signal_stream()?;
196 tokio::spawn(cancel_prompts_on(signal_stream()?, &prompt::CANCEL));
200 crash::install_panic_hook();
205 let mut host = RealHost {
206 terminal: screen::enter()?,
209 events: None,
210 signals,
211 on_screen: true,
212 };
213 let mut state = TuiState::new(
214 SessionContext::load(),
215 Journal::in_data_dir(),
216 vocab::Vocab::load(&settings.config),
217 );
218 welcome(&mut state, no_banner);
219 state.start = start;
220 session(
221 &mut host,
222 &mut state,
223 settings,
224 reload,
225 SessionContext::save,
226 )
227 .await
228}
229
230async fn session<H: Host>(
233 host: &mut H,
234 state: &mut TuiState,
235 settings: Settings,
236 reload: Reload<'_>,
237 save_ctx: impl FnOnce(&SessionContext) -> Result<()>,
238) -> Result<TuiExit> {
239 let result = event_loop(host, state, settings, reload).await;
240 host.set_width(None);
243 host.restore();
244
245 let saved_history = state.journal().save_history(&state.history);
247 let saved_ctx = save_ctx(&state.ctx);
248 let exit = result?;
249 saved_history?;
250 saved_ctx?;
251 Ok(exit)
252}
253
254#[cfg(unix)]
257fn signal_stream() -> Result<BoxStream<'static, Signal>> {
258 use tokio::signal::unix::{SignalKind, signal};
259 let kinds = [
260 (SignalKind::terminate(), Signal::Term),
261 (SignalKind::hangup(), Signal::Hup),
262 (SignalKind::interrupt(), Signal::Int),
263 (SignalKind::quit(), Signal::Quit),
264 (SignalKind::from_raw(libc::SIGTSTP), Signal::Tstp),
265 (SignalKind::from_raw(libc::SIGCONT), Signal::Cont),
266 ];
267 let mut streams = Vec::with_capacity(kinds.len());
268 for (kind, sig) in kinds {
269 let mut handler = signal(kind).map_err(|e| {
270 ScopeError::io_context(e, format!("cannot install the SIG{} handler", sig.name()))
271 })?;
272 streams.push(futures::stream::poll_fn(move |cx| {
273 handler.poll_recv(cx).map(|got| got.map(|()| sig))
274 }));
275 }
276 Ok(futures::stream::select_all(streams).boxed())
277}
278
279#[cfg(windows)]
284fn signal_stream() -> Result<BoxStream<'static, Signal>> {
285 let mut handler = tokio::signal::windows::ctrl_c()
286 .map_err(|e| ScopeError::io_context(e, "cannot install the Ctrl-C handler"))?;
287 Ok(futures::stream::poll_fn(move |cx| {
288 handler.poll_recv(cx).map(|got| got.map(|()| Signal::Int))
289 })
290 .boxed())
291}
292
293#[cfg(not(any(unix, windows)))]
295fn signal_stream() -> Result<BoxStream<'static, Signal>> {
296 Ok(futures::stream::pending().boxed())
297}
298
299fn welcome(state: &mut TuiState, no_banner: bool) {
300 if !no_banner {
301 state.push_ansi(BANNER);
302 }
303 state.push_note("Type `help` for commands. `exit` or Ctrl-D leaves.");
304 state.push_note(
307 "Try: `market summary USDC --venue binance` · `venues list` · `insights <address>`",
308 );
309 state.push_note("Tab completes a command or flag; Ctrl-K opens the command palette.");
310}
311
312#[cfg(test)]
317pub(crate) static DIAG_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
318
319pub(crate) trait Host {
322 type B: ratatui::backend::Backend;
323 type Events: futures::Stream<Item = io::Result<Event>> + Unpin;
324 type Signals: futures::Stream<Item = Signal> + Unpin;
325 fn terminal(&mut self) -> &mut ratatui::Terminal<Self::B>;
326 fn streams(&mut self) -> (&mut Self::Events, &mut Self::Signals);
329 fn suspend(&mut self);
331 fn resume(&mut self) -> Result<()>;
337 fn restore(&mut self);
339 fn stop_self(&mut self) -> io::Result<()>;
343 fn clear(&mut self) -> io::Result<()>;
345 fn suspended_output(&self) -> Output;
347 fn write_raw(&mut self, text: &str) -> io::Result<()>;
349 fn set_width(&mut self, width: Option<usize>);
352}
353
354struct RealHost {
356 terminal: ratatui::DefaultTerminal,
357 events: Option<EventStream>,
360 signals: BoxStream<'static, Signal>,
361 on_screen: bool,
363}
364
365impl Host for RealHost {
366 type B = ratatui::backend::CrosstermBackend<io::Stdout>;
367 type Events = EventStream;
368 type Signals = BoxStream<'static, Signal>;
369
370 fn terminal(&mut self) -> &mut ratatui::Terminal<Self::B> {
371 &mut self.terminal
372 }
373 fn streams(&mut self) -> (&mut EventStream, &mut Self::Signals) {
374 (
375 self.events.get_or_insert_with(EventStream::new),
376 &mut self.signals,
377 )
378 }
379 fn suspend(&mut self) {
380 self.events = None;
381 self.restore();
382 }
383 fn resume(&mut self) -> Result<()> {
384 self.terminal = screen::enter()?;
385 self.on_screen = true;
386 Ok(())
387 }
388 fn restore(&mut self) {
391 if std::mem::take(&mut self.on_screen) {
392 screen::restore();
393 }
394 }
395 #[cfg(unix)]
398 fn stop_self(&mut self) -> io::Result<()> {
399 let rc = unsafe { libc::kill(libc::getpid(), libc::SIGSTOP) };
401 if rc == 0 {
402 Ok(())
403 } else {
404 Err(io::Error::last_os_error())
405 }
406 }
407 #[cfg(not(unix))]
408 fn stop_self(&mut self) -> io::Result<()> {
409 Err(io::Error::other("job control needs a unix host"))
410 }
411 fn clear(&mut self) -> io::Result<()> {
412 self.terminal.clear()
413 }
414 fn suspended_output(&self) -> Output {
415 Output::stdio()
416 }
417 fn write_raw(&mut self, text: &str) -> io::Result<()> {
418 let mut out = io::stdout();
419 out.write_all(text.as_bytes())?;
420 out.flush()
421 }
422 fn set_width(&mut self, width: Option<usize>) {
423 scope::display::terminal::set_width_override(width);
424 }
425}
426
427struct Outside {
429 terminal: Output,
432 out: Output,
436 copy: Captured,
437}
438
439impl Outside {
440 fn new(terminal: Output) -> Self {
441 let (out, copy) = terminal.teed(OUTSIDE_COPY_LIMIT);
442 Self {
443 terminal,
444 out,
445 copy,
446 }
447 }
448}
449
450type PaneFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + 'a>>;
451
452const WHEEL_ROWS: isize = 3;
454
455const SETUP_RELOADED_NOTE: &str = "The TUI now uses the saved settings.";
457
458fn setup_reload_failed_note(e: &ScopeError) -> String {
460 format!(
461 "The settings could not be read again ({e}). Leave the TUI (exit) and start it again to use them."
462 )
463}
464
465async fn event_loop<H: Host>(
466 host: &mut H,
467 state: &mut TuiState,
468 settings: Settings,
469 reload: Reload<'_>,
470) -> Result<TuiExit> {
471 let mut settings = std::rc::Rc::new(settings);
474 const LEFT: TuiExit = TuiExit { code: 0 };
475 let idle_buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
477 let mut idle_diag = Some(scope::diag::redirect(idle_buf.clone()));
478 let mut pending: Option<PaneFuture<'_>> = None;
479 let mut watch_pending: Option<PaneFuture<'_>> = None;
482 if let Some(command) = state.start.take() {
483 watch_pending = open_monitor(state, &settings, command);
484 }
485 let mut tick = tokio::time::interval(LIVE_REDRAW);
486 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
489 let mut cont_expected = false;
492 let mut unchanged = false;
495
496 loop {
497 drain_into(&idle_buf, state);
498 state.poll_watch();
499 if state.take_bell() {
500 let _ = host.write_raw("\x07");
502 }
503 state.poll_ask();
506 if !std::mem::take(&mut unchanged) {
507 host.terminal()
508 .draw(|f| {
509 state.render(f);
510 crate::cli::plain::finish_frame(f);
511 })
512 .map_err(|e| {
515 ScopeError::io_context(io::Error::other(e.to_string()), "terminal draw failed")
516 })?;
517 }
518 host.set_width(state.pane_width());
521
522 let (events, signals) = host.streams();
526 tokio::select! {
527 biased;
528 res = async { pending.as_mut().expect("guarded by precondition").await }, if pending.is_some() => {
529 pending = None;
530 state.finish(Ending::Finished(res));
531 }
532 res = async { watch_pending.as_mut().expect("guarded by precondition").await }, if watch_pending.is_some() => {
533 watch_pending = None;
534 state.watch_finished(res);
535 }
536 _ = tick.tick(), if state.running.is_some() || state.watch_running() => {}
537 Some(sig) = signals.next() => {
538 if let Some(code) = sig.exit_code() {
539 drop(pending.take());
541 state.finish(Ending::Cancelled);
542 state.record_signal(sig.name(), "session", "", &[]);
543 return Ok(TuiExit { code });
544 }
545 if sig == Signal::Tstp {
551 cont_expected = stop_and_return(host, state)?;
552 } else if !std::mem::take(&mut cont_expected) {
553 leave(host);
556 retake(host)?;
557 }
558 }
559 ev = events.next() => {
560 let key = match ev {
561 Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => k,
562 Some(Ok(Event::Paste(text))) => {
563 state.paste(&text);
564 continue;
565 }
566 Some(Ok(Event::Mouse(m))) => {
569 match m.kind {
570 MouseEventKind::ScrollUp => state.wheel(WHEEL_ROWS),
571 MouseEventKind::ScrollDown => state.wheel(-WHEEL_ROWS),
572 _ => unchanged = true,
573 }
574 continue;
575 }
576 Some(Ok(_)) => continue,
577 Some(Err(e)) => return Err(ScopeError::IoError(e)),
578 None => return Ok(LEFT),
579 };
580 match state.handle_key(key) {
581 Action::None => {}
582 Action::Exit => return Ok(LEFT),
583 Action::Suspend => cont_expected = stop_and_return(host, state)?,
584 Action::Copy(text) => {
585 let result = host.write_raw(&clip::osc52(&text));
586 state.copied(text.lines().count(), result);
587 }
588 Action::StartWatch(list) => {
589 if let Some(run) = begin_watch(state, &settings, *list) {
590 watch_pending = Some(run);
591 }
592 }
593 Action::StopWatch => {
594 watch_pending = None;
596 state.stop_watch();
597 }
598 Action::Cancel => {
599 pending = None;
602 state.finish(Ending::Cancelled);
603 }
604 Action::Submit(line) => match state.submit(&line, &settings.config) {
605 Plan::Done => {}
606 Plan::Exit => return Ok(LEFT),
607 Plan::Watch(cmd) => match cmd {
608 WatchCommand::Open(list) => {
609 if let Some(run) = begin_watch(state, &settings, *list) {
610 watch_pending = Some(run);
611 }
612 }
613 WatchCommand::Stop => {
614 if state.watch_running() {
615 watch_pending = None;
616 state.stop_watch();
617 } else {
618 state.push_note("No watchlist runs.");
619 }
620 }
621 WatchCommand::Show => state.show_grid(),
622 },
623 Plan::Run(inv) => match inv.route {
624 Route::Pane => {
625 let out = state.start(&line, &inv);
626 let s = settings.clone();
627 pending = Some(Box::pin(async move {
628 run_invocation(*inv, &s.config, &*s.clients, &out).await
629 }));
630 }
631 Route::Suspend => {
632 drop(idle_diag.take());
633 leave(host);
634 let outside = Outside::new(host.suspended_output());
635 let argv = inv.argv.clone();
636 let command =
637 run_invocation(*inv, &settings.config, &*settings.clients, &outside.out);
638 let ended =
639 run_suspended(host, &line, &argv, &outside, command, state)
640 .await;
641 if let Some(exit) = ended {
642 return Ok(exit);
645 }
646 if argv.get(1).is_some_and(|c| c == "setup") {
647 match reload() {
650 Ok(new) => {
651 state.set_vocab(vocab::Vocab::load(&new.config));
652 settings = std::rc::Rc::new(new);
653 state.push_note(SETUP_RELOADED_NOTE);
654 state.settings_reloaded();
655 }
656 Err(e) => state.push_note(&setup_reload_failed_note(&e)),
657 }
658 }
659 host.resume()?;
660 idle_diag.replace(scope::diag::redirect(idle_buf.clone()));
661 }
662 Route::Wizard => {
663 if let Some(run) = open_monitor(state, &settings, inv.command) {
664 watch_pending = Some(run);
665 }
666 }
667 Route::Refuse(reason) => {
668 state.push_note(&format!("Not available here: {}.", reason));
671 }
672 },
673 },
674 }
675 }
676 }
677 }
678}
679
680const WATCH_BUSY_NOTE: &str =
682 "A watchlist runs already. Stop it first (`watch stop`, or Esc in the grid).";
683
684fn begin_watch(
686 state: &mut TuiState,
687 settings: &std::rc::Rc<Settings>,
688 list: scope::watch::Watchlist,
689) -> Option<PaneFuture<'static>> {
690 if state.watch_running() {
691 state.push_note(WATCH_BUSY_NOTE);
692 return None;
693 }
694 state.close_grid();
696 let (out, sink, tx) = state.start_watch(list.clone());
697 let s = settings.clone();
698 Some(Box::pin(scope::diag::scoped(sink, async move {
699 run_watch(list, &s, &out, tx).await
700 })))
701}
702
703fn open_monitor(
706 state: &mut TuiState,
707 settings: &std::rc::Rc<Settings>,
708 command: crate::cli::Commands,
709) -> Option<PaneFuture<'static>> {
710 let crate::cli::Commands::Monitor(args) = command else {
711 return None;
712 };
713 match crate::cli::watch::monitor::watchlist_from_args(&args, &settings.config) {
714 Err(e) => {
715 state.push_styled_note(&format!("monitor: {e}"), crate::cli::theme::ERR);
716 None
717 }
718 Ok((list, notes)) if args.watch.yes => {
719 for n in ¬es {
720 state.push_note(n);
721 }
722 if args.watch.save.is_some() {
723 let path = scope::watch::file::path_in(&scope::paths::watchlists_dir(), &list.name);
724 match scope::watch::file::save(&path, &list) {
725 Ok(()) => {
726 state.push_note(&format!("Saved the watchlist to {}.", path.display()))
727 }
728 Err(e) => state.push_note(&format!("Could not save {}: {e}", path.display())),
729 }
730 }
731 begin_watch(state, settings, list)
732 }
733 Ok((list, notes)) => {
734 state.open_wizard(list, notes);
735 None
736 }
737 }
738}
739
740async fn run_watch(
744 list: scope::watch::Watchlist,
745 settings: &Settings,
746 out: &Output,
747 tx: std::sync::mpsc::Sender<grid::GridMsg>,
748) -> Result<()> {
749 let src = crate::cli::watch::Sources {
750 config: &settings.config,
751 clients: &*settings.clients,
752 };
753 let ready = crate::cli::watch::prepare(&list, src, out).await?;
754 let _ = tx.send(grid::GridMsg::Ready(ready.clone()));
755 crate::cli::watch::run(&ready, src, out, |run, tick, events| {
756 let _ = tx.send(grid::GridMsg::Tick {
757 run,
758 tick: Box::new(tick.clone()),
759 events: events.to_vec(),
760 });
761 })
762 .await
763 .map(|_| ())
764}
765
766fn stop_and_return<H: Host>(host: &mut H, state: &mut TuiState) -> Result<bool> {
771 leave(host);
772 let stopped = match host.stop_self() {
773 Ok(()) => true,
774 Err(e) => {
775 state.push_note(&format!("Could not stop the process: {}", e));
776 false
777 }
778 };
779 retake(host)?;
780 Ok(stopped)
781}
782
783fn leave<H: Host>(host: &mut H) {
786 host.set_width(None);
787 host.suspend();
788}
789
790fn retake<H: Host>(host: &mut H) -> Result<()> {
793 host.resume()?;
794 host.clear()
795 .map_err(|e| ScopeError::io_context(e, "terminal clear failed"))
796}
797
798async fn run_suspended<H: Host>(
805 host: &mut H,
806 line: &str,
807 argv: &[String],
808 outside: &Outside,
809 command: impl Future<Output = Result<()>>,
810 state: &mut TuiState,
811) -> Option<TuiExit> {
812 let started = Instant::now();
813 let started_at = chrono::Utc::now().to_rfc3339();
814 prompt::CANCEL.store(false, Ordering::SeqCst);
816 let mut terminal_errors = Vec::new();
817 crate::note!(outside.terminal, "\n── scope {} ──", line);
818 crate::note!(
819 outside.terminal,
820 "Running outside the TUI; it returns when the command ends (Ctrl-C cancels)."
821 );
822 let ended = tokio::select! {
823 biased;
824 Some(sig) = ending_signal(host.streams().1) => Err(sig),
825 result = command => Ok(result),
826 };
827 let ended = match ended {
834 Ok(_) if prompt::CANCEL.load(Ordering::SeqCst) => {
835 let queued =
836 tokio::time::timeout(PROMPT_SIGNAL_WAIT, ending_signal(host.streams().1)).await;
837 Err(queued.ok().flatten().unwrap_or(Signal::Int))
838 }
839 ended => ended,
840 };
841 let ending = match ended {
842 Ok(result) => Ending::Finished(result),
843 Err(Signal::Int) => Ending::Cancelled,
844 Err(sig) => return Some(end_suspended(state, line, argv, sig)),
845 };
846 let duration = started.elapsed();
847 if let Ending::Finished(Err(e)) = &ending
848 && let Err(write_err) = crate::cli::errors::display_error(e, &outside.out)
849 {
850 terminal_errors.push(write_err.to_string());
851 }
852 state.show_outside(&outside.copy, &ending);
853 state.record_suspended(line, argv, duration, started_at, &ending);
854 for e in terminal_errors {
855 state.push_note(&format!("Terminal I/O error while outside the TUI: {}", e));
856 }
857 if matches!(ending, Ending::Cancelled)
858 && let Some(sig) = drain_extra_ints(host.streams().1)
859 {
860 return Some(end_suspended(state, line, argv, sig));
861 }
862 None
863}
864
865fn drain_extra_ints<S: futures::Stream<Item = Signal> + Unpin>(signals: &mut S) -> Option<Signal> {
870 use futures::FutureExt;
871 while let Some(Some(sig)) = signals.next().now_or_never() {
872 if sig.ends_suspended() && sig != Signal::Int {
873 return Some(sig);
874 }
875 }
876 None
877}
878
879async fn ending_signal<S: futures::Stream<Item = Signal> + Unpin>(
883 signals: &mut S,
884) -> Option<Signal> {
885 while let Some(sig) = signals.next().await {
886 if sig.ends_suspended() {
887 return Some(sig);
888 }
889 }
890 None
891}
892
893fn end_suspended(state: &mut TuiState, line: &str, argv: &[String], sig: Signal) -> TuiExit {
896 state.record_signal(sig.name(), "suspend", line, argv);
897 TuiExit {
898 code: sig.exit_code().expect("TERM, HUP and QUIT have exit codes"),
899 }
900}
901
902async fn cancel_prompts_on<S: futures::Stream<Item = Signal> + Unpin>(
906 mut signals: S,
907 flag: &AtomicBool,
908) {
909 while let Some(sig) = signals.next().await {
910 if sig.ends_suspended() {
911 flag.store(true, Ordering::SeqCst);
912 }
913 }
914}
915
916fn drain_into(buf: &Mutex<Vec<u8>>, state: &mut TuiState) {
918 let bytes = std::mem::take(&mut *buf.lock().unwrap_or_else(|p| p.into_inner()));
919 if !bytes.is_empty() {
920 state.push_ansi(&String::from_utf8_lossy(&bytes));
921 }
922}
923
924#[cfg(test)]
929mod tests {
930 use super::*;
931
932 use crate::cli::Cli;
933 use ansi_to_tui::IntoText;
934 use clap::Parser;
935 use scope::chains::mocks::MockClientFactory;
936
937 #[test]
938 fn test_tui_requires_a_terminal() {
939 assert!(tui_terminal_ok(true, true, Some("xterm-256color")).is_ok());
940 assert!(tui_terminal_ok(true, true, None).is_ok());
941 for (i, o, t) in [
942 (false, true, Some("xterm")),
943 (true, false, Some("xterm")),
944 (true, true, Some("dumb")),
945 ] {
946 let e = tui_terminal_ok(i, o, t).unwrap_err();
947 assert!(
948 e.starts_with("scope tui needs an interactive terminal"),
949 "{e}"
950 );
951 }
952 }
953
954 const ADDR: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
955 const TX: &str = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
956
957 fn plain(ansi: &str) -> String {
959 ansi.into_text()
960 .unwrap()
961 .lines
962 .iter()
963 .map(|l| {
964 l.spans
965 .iter()
966 .map(|s| s.content.as_ref())
967 .collect::<String>()
968 })
969 .collect::<Vec<_>>()
970 .join("\n")
971 }
972
973 async fn via_tui(line: &str, factory: &MockClientFactory) -> String {
975 let config = Config::default();
976 let mut s = TuiState::new(
977 SessionContext::default(),
978 Journal::default(),
979 vocab::Vocab::default(),
980 );
981 let Plan::Run(inv) = s.submit(line, &config) else {
982 panic!("`{}` did not plan a run", line)
983 };
984 assert_eq!(inv.route, Route::Pane);
985 let out = s.start(line, &inv);
986 let res = run_invocation(*inv, &config, factory, &out).await;
987 s.finish(Ending::Finished(res));
988 s.scrollback_text()
989 }
990
991 async fn via_cli(line: &str, factory: &MockClientFactory) -> String {
995 let mut argv = vec!["scope".to_string()];
996 argv.extend(shlex::split(line).unwrap());
997 let cli = Cli::try_parse_from(argv).unwrap();
998 let (o, cap) = Output::capture();
999 assert!(!o.is_tui());
1000 let res =
1001 crate::cli::dispatch::dispatch_with(cli.command, &Config::default(), factory, &o).await;
1002 if let Err(e) = &res {
1003 crate::cli::errors::display_error(e, &o).unwrap();
1004 }
1005 format!("{}\n{}", plain(&cap.out()), plain(&cap.err()))
1006 }
1007
1008 async fn assert_same_result(line: &str) {
1011 let _lock = DIAG_LOCK.lock().await;
1012 let factory = MockClientFactory::new();
1013 let cli = via_cli(line, &factory).await;
1014 let tui = via_tui(line, &factory).await;
1015 assert!(!cli.trim().is_empty(), "`{}` printed nothing", line);
1016 for row in cli.lines().filter(|r| !r.trim().is_empty()) {
1017 assert!(
1018 tui.contains(row),
1019 "`{}`: the TUI pane is missing the CLI line {:?}\n--- TUI ---\n{}",
1020 line,
1021 row,
1022 tui
1023 );
1024 }
1025 }
1026
1027 #[tokio::test]
1028 async fn test_address_same_as_cli() {
1029 assert_same_result(&format!("address {}", ADDR)).await;
1030 }
1031
1032 #[tokio::test]
1033 async fn test_tx_same_as_cli() {
1034 assert_same_result(&format!("tx {}", TX)).await;
1035 }
1036
1037 #[tokio::test]
1038 async fn test_market_summary_dex_same_as_cli() {
1039 assert_same_result("market summary USDC --venue eth").await;
1040 }
1041
1042 #[tokio::test]
1043 async fn test_venues_list_json_same_as_cli() {
1044 assert_same_result("venues list --format json").await;
1045 }
1046
1047 #[tokio::test]
1048 async fn test_failed_command_shows_same_error_as_cli() {
1049 assert_same_result("venues validate /nonexistent/venue.yaml").await;
1051 }
1052
1053 use crossterm::event::{KeyCode, KeyEvent, KeyEventState, KeyModifiers};
1056 use futures::channel::mpsc;
1057 use futures::stream::BoxStream;
1058 use ratatui::Terminal;
1059 use ratatui::backend::TestBackend;
1060 use std::task::Poll;
1061
1062 struct TestHost {
1065 terminal: Terminal<TestBackend>,
1066 keys: BoxStream<'static, io::Result<Event>>,
1067 signals: mpsc::UnboundedReceiver<Signal>,
1068 signal_tx: mpsc::UnboundedSender<Signal>,
1069 suspend_signals: Vec<Signal>,
1072 suspended: Output,
1073 suspends: usize,
1074 resumes: usize,
1075 restores: usize,
1076 stops: usize,
1077 clears: usize,
1078 draws: usize,
1080 draws_at_stop: usize,
1082 stop_fails: bool,
1084 raw: String,
1086 raw_fails: bool,
1088 resume_fails: bool,
1090 widths: Vec<(&'static str, Option<usize>)>,
1092 width: Option<usize>,
1094 }
1095
1096 impl Host for TestHost {
1097 type B = TestBackend;
1098 type Events = BoxStream<'static, io::Result<Event>>;
1099 type Signals = mpsc::UnboundedReceiver<Signal>;
1100 fn terminal(&mut self) -> &mut Terminal<TestBackend> {
1101 self.draws += 1;
1102 self.widths.push(("draw", self.width));
1103 &mut self.terminal
1104 }
1105 fn streams(&mut self) -> (&mut Self::Events, &mut Self::Signals) {
1106 (&mut self.keys, &mut self.signals)
1107 }
1108 fn suspend(&mut self) {
1109 self.suspends += 1;
1110 self.widths.push(("suspend", self.width));
1111 for s in self.suspend_signals.drain(..) {
1112 self.signal_tx.unbounded_send(s).unwrap();
1113 }
1114 }
1115 fn resume(&mut self) -> Result<()> {
1116 self.resumes += 1;
1117 if self.resume_fails {
1118 return Err(crate::cli::screen::not_taken(io::Error::other("no tty")));
1119 }
1120 Ok(())
1121 }
1122 fn restore(&mut self) {
1123 self.restores += 1;
1124 self.widths.push(("restore", self.width));
1125 }
1126 fn stop_self(&mut self) -> io::Result<()> {
1129 self.stops += 1;
1130 self.draws_at_stop = self.draws;
1131 if self.stop_fails {
1132 return Err(io::Error::other("kill failed"));
1133 }
1134 self.signal_tx.unbounded_send(Signal::Cont).unwrap();
1135 Ok(())
1136 }
1137 fn clear(&mut self) -> io::Result<()> {
1138 self.clears += 1;
1139 self.terminal.clear().map_err(|never| match never {})
1140 }
1141 fn suspended_output(&self) -> Output {
1142 self.suspended.clone()
1143 }
1144 fn write_raw(&mut self, text: &str) -> io::Result<()> {
1145 self.raw.push_str(text);
1146 if self.raw_fails {
1147 return Err(io::Error::other("tty gone"));
1148 }
1149 Ok(())
1150 }
1151 fn set_width(&mut self, width: Option<usize>) {
1153 self.width = width;
1154 }
1155 }
1156
1157 type Driven = (
1160 Result<TuiExit>,
1161 TuiState,
1162 TestHost,
1163 crate::cli::output::Captured,
1164 );
1165
1166 fn press(code: KeyCode) -> io::Result<Event> {
1167 Ok(Event::Key(KeyEvent::new(code, KeyModifiers::NONE)))
1168 }
1169
1170 fn mouse(kind: MouseEventKind) -> io::Result<Event> {
1172 Ok(Event::Mouse(crossterm::event::MouseEvent {
1173 kind,
1174 column: 0,
1175 row: 0,
1176 modifiers: KeyModifiers::NONE,
1177 }))
1178 }
1179
1180 #[tokio::test]
1181 async fn test_the_wheel_scrolls_and_a_mouse_move_draws_nothing() {
1182 let _lock = DIAG_LOCK.lock().await;
1183 let mut events = script(&["help"]);
1185 events.push(mouse(MouseEventKind::ScrollDown));
1186 let (res, state, host, _) = drive(events).await;
1187 res.unwrap();
1188 let after_one_notch = host.draws;
1189 let scrolled = state.scrolled();
1190
1191 let mut events = script(&["help"]);
1192 events.push(mouse(MouseEventKind::ScrollDown));
1193 for _ in 0..20 {
1194 events.push(mouse(MouseEventKind::Moved));
1195 }
1196 let (res, state2, host2, _) = drive(events).await;
1197 res.unwrap();
1198 assert_eq!(state2.scrolled(), scrolled, "a move scrolls nothing");
1199 assert_eq!(host2.draws, after_one_notch, "and draws nothing");
1200
1201 let mut events = script(&["help"]);
1203 events.push(mouse(MouseEventKind::ScrollDown));
1204 events.push(mouse(MouseEventKind::ScrollDown));
1205 events.push(mouse(MouseEventKind::ScrollUp));
1206 let (res, state3, _, _) = drive(events).await;
1207 res.unwrap();
1208 assert_eq!(
1209 state3.scrolled(),
1210 scrolled,
1211 "a notch is {WHEEL_ROWS} rows each way"
1212 );
1213 }
1214
1215 fn script(lines: &[&str]) -> Vec<io::Result<Event>> {
1217 let mut events = Vec::new();
1218 for line in lines {
1219 if *line == "<esc>" {
1220 events.push(press(KeyCode::Esc));
1221 continue;
1222 }
1223 events.extend(line.chars().map(|c| press(KeyCode::Char(c))));
1224 events.push(press(KeyCode::Enter));
1225 }
1226 events
1227 }
1228
1229 async fn drive(events: Vec<io::Result<Event>>) -> Driven {
1232 drive_with(Journal::default(), events, Vec::new()).await
1233 }
1234
1235 async fn drive_with(
1239 journal: Journal,
1240 events: Vec<io::Result<Event>>,
1241 signals: Vec<Signal>,
1242 ) -> Driven {
1243 let (host, captured) = test_host(events, signals);
1244 drive_host(journal, host, captured).await
1245 }
1246
1247 fn test_host(
1249 events: Vec<io::Result<Event>>,
1250 signals: Vec<Signal>,
1251 ) -> (TestHost, crate::cli::output::Captured) {
1252 let (sig_tx, sig_rx) = mpsc::unbounded();
1253 let mut then = Some((signals, sig_tx.clone()));
1254 let tail = futures::stream::poll_fn(move |_| match then.take() {
1258 Some((sigs, tx)) if !sigs.is_empty() => {
1259 for s in sigs {
1260 tx.unbounded_send(s).unwrap();
1261 }
1262 Poll::Pending
1263 }
1264 _ => Poll::Ready(None),
1265 });
1266 let (suspended, captured) = Output::capture_merged();
1267 let host = TestHost {
1268 terminal: Terminal::new(TestBackend::new(100, 30)).unwrap(),
1269 keys: futures::stream::iter(events).chain(tail).boxed(),
1270 signals: sig_rx,
1271 signal_tx: sig_tx,
1272 suspend_signals: Vec::new(),
1273 suspended,
1274 suspends: 0,
1275 resumes: 0,
1276 restores: 0,
1277 stops: 0,
1278 clears: 0,
1279 draws: 0,
1280 draws_at_stop: 0,
1281 stop_fails: false,
1282 raw: String::new(),
1283 raw_fails: false,
1284 resume_fails: false,
1285 widths: Vec::new(),
1286 width: None,
1287 };
1288 (host, captured)
1289 }
1290
1291 async fn drive_host(
1293 journal: Journal,
1294 host: TestHost,
1295 captured: crate::cli::output::Captured,
1296 ) -> Driven {
1297 drive_host_on(journal, host, captured, Box::new(MockClientFactory::new())).await
1298 }
1299
1300 fn settings_with(clients: Box<dyn ChainClientFactory>) -> Settings {
1302 Settings {
1303 config: Config::default(),
1304 clients,
1305 }
1306 }
1307
1308 async fn drive_host_on(
1311 journal: Journal,
1312 host: TestHost,
1313 captured: crate::cli::output::Captured,
1314 clients: Box<dyn ChainClientFactory>,
1315 ) -> Driven {
1316 let reload = || Ok(settings_with(Box::new(MockClientFactory::new())));
1317 drive_host_reloading(journal, host, captured, settings_with(clients), &reload).await
1318 }
1319
1320 async fn drive_host_reloading(
1322 journal: Journal,
1323 mut host: TestHost,
1324 captured: crate::cli::output::Captured,
1325 settings: Settings,
1326 reload: Reload<'_>,
1327 ) -> Driven {
1328 let mut state = TuiState::new(SessionContext::default(), journal, vocab::Vocab::default());
1329 welcome(&mut state, true);
1330 let res = session(&mut host, &mut state, settings, reload, |_| Ok(())).await;
1331 (res, state, host, captured)
1332 }
1333
1334 struct PendingDex;
1337
1338 #[async_trait::async_trait]
1339 impl scope::chains::DexDataSource for PendingDex {
1340 async fn get_token_price(&self, _: &str, _: &str) -> Option<f64> {
1341 std::future::pending().await
1342 }
1343 async fn get_native_token_price(&self, _: &str) -> Option<f64> {
1344 std::future::pending().await
1345 }
1346 async fn get_token_data(
1347 &self,
1348 _: &str,
1349 _: &str,
1350 ) -> scope::Result<scope::chains::dex::DexTokenData> {
1351 std::future::pending().await
1352 }
1353 async fn search_tokens(
1354 &self,
1355 _: &str,
1356 _: Option<&str>,
1357 ) -> scope::Result<Vec<scope::chains::dex::TokenSearchResult>> {
1358 std::future::pending().await
1359 }
1360 }
1361
1362 struct PendingFactory(MockClientFactory);
1364
1365 impl ChainClientFactory for PendingFactory {
1366 fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
1367 scope::http::test_transport()
1368 }
1369 fn create_chain_client(
1370 &self,
1371 chain: &str,
1372 ) -> scope::Result<Box<dyn scope::chains::ChainClient>> {
1373 self.0.create_chain_client(chain)
1374 }
1375 fn create_dex_client(&self) -> Box<dyn scope::chains::DexDataSource> {
1376 Box::new(PendingDex)
1377 }
1378 }
1379
1380 const PENDING_REPEAT: &str = "market summary USDC --venue eth --every 30s --duration 1h";
1383
1384 async fn drive_pending(events: Vec<io::Result<Event>>, signals: Vec<Signal>) -> Driven {
1386 let (host, captured) = test_host(events, signals);
1387 let clients = PendingFactory(MockClientFactory::new());
1388 drive_host_on(Journal::default(), host, captured, Box::new(clients)).await
1389 }
1390
1391 async fn drive_signal_after(lines: &[&str], signal: Signal) -> Driven {
1393 drive_with(Journal::default(), script(lines), vec![signal]).await
1394 }
1395
1396 #[tokio::test]
1397 async fn test_paste_event_fills_the_input_and_does_not_run_it() {
1398 let _lock = DIAG_LOCK.lock().await;
1400 let (res, state, _, _) = drive(vec![Ok(Event::Paste("ctx\nhelp\n".into()))]).await;
1401 assert_eq!(res.unwrap().code, 0);
1402 assert_eq!(state.input, "ctx help");
1403 assert!(state.history.is_empty(), "nothing was submitted");
1404 }
1405
1406 #[tokio::test]
1407 async fn test_term_signal_saves_restores_and_journals() {
1408 let _lock = DIAG_LOCK.lock().await;
1410 let dir = tempfile::tempdir().unwrap();
1411 let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
1412 let (res, _, host, _) = drive_with(journal, script(&["ctx"]), vec![Signal::Term]).await;
1413 assert_eq!(res.unwrap().code, 143);
1414 assert_eq!(host.restores, 1, "terminal restored once");
1415 let ev = std::fs::read_to_string(dir.path().join("e.jsonl")).unwrap();
1416 assert!(ev.contains("\"outcome\":\"signal:TERM\""), "{ev}");
1417 let history = std::fs::read_to_string(dir.path().join("h")).unwrap();
1418 assert!(history.contains("ctx"), "{history}");
1419 }
1420
1421 #[tokio::test]
1422 async fn test_term_signal_cancels_running_pane_command() {
1423 let _lock = DIAG_LOCK.lock().await;
1426 let (res, state, _, _) = drive_pending(script(&[PENDING_REPEAT]), vec![Signal::Term]).await;
1427 assert_eq!(res.unwrap().code, 143);
1428 assert!(state.scrollback_text().contains("Cancelled"));
1429 assert!(state.running.is_none());
1430 }
1431
1432 #[tokio::test]
1433 async fn test_each_fatal_signal_exits_with_128_plus_its_number() {
1434 let _lock = DIAG_LOCK.lock().await;
1436 for (sig, code) in [
1437 (Signal::Hup, 129),
1438 (Signal::Int, 130),
1439 (Signal::Quit, 131),
1440 (Signal::Term, 143),
1441 ] {
1442 let (res, _, host, _) = drive_signal_after(&[], sig).await;
1443 assert_eq!(res.unwrap().code, code, "{sig:?}");
1444 assert_eq!(host.restores, 1, "{sig:?}");
1445 }
1446 }
1447
1448 #[tokio::test]
1449 async fn test_stop_and_continue_signals_do_not_end_the_tui() {
1450 let _lock = DIAG_LOCK.lock().await;
1453 for sig in [Signal::Tstp, Signal::Cont] {
1454 let (res, _, host, _) = drive_signal_after(&[], sig).await;
1455 assert_eq!(res.unwrap().code, 0, "{sig:?}");
1456 assert_eq!(host.restores, 1, "{sig:?}");
1457 }
1458 }
1459
1460 fn ctrl_z() -> io::Result<Event> {
1461 Ok(Event::Key(KeyEvent::new(
1462 KeyCode::Char('z'),
1463 KeyModifiers::CONTROL,
1464 )))
1465 }
1466
1467 fn screen_text(host: &TestHost) -> String {
1469 let buf = host.terminal.backend().buffer().clone();
1470 buf.content().iter().map(|c| c.symbol()).collect()
1471 }
1472
1473 fn widths_at(host: &TestHost, stage: &str) -> Vec<Option<usize>> {
1475 host.widths
1476 .iter()
1477 .filter(|(s, _)| *s == stage)
1478 .map(|(_, w)| *w)
1479 .collect()
1480 }
1481
1482 #[tokio::test]
1483 async fn test_width_override_is_the_pane_and_is_removed_when_the_tui_leaves() {
1484 let _lock = DIAG_LOCK.lock().await;
1488 let mut events = script(&["setup --key nosuchkey"]);
1489 events.push(ctrl_z());
1490 events.push(press(KeyCode::Char('x')));
1491 let (res, _, host, _) = drive(events).await;
1492 res.unwrap();
1493 assert_eq!(widths_at(&host, "suspend"), vec![None, None]);
1495 assert_eq!(widths_at(&host, "restore"), vec![None]);
1496 let draws = widths_at(&host, "draw");
1498 assert_eq!(draws.last(), Some(&Some(100)), "set again after Ctrl-Z");
1499 }
1500
1501 struct CountingFactory(Arc<std::sync::atomic::AtomicUsize>);
1503
1504 impl ChainClientFactory for CountingFactory {
1505 fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
1506 scope::http::test_transport()
1507 }
1508 fn create_chain_client(
1509 &self,
1510 chain: &str,
1511 ) -> scope::Result<Box<dyn scope::chains::ChainClient>> {
1512 MockClientFactory::new().create_chain_client(chain)
1513 }
1514 fn create_dex_client(&self) -> Box<dyn scope::chains::DexDataSource> {
1515 self.0.fetch_add(1, Ordering::SeqCst);
1516 MockClientFactory::new().create_dex_client()
1517 }
1518 }
1519
1520 #[tokio::test]
1521 async fn test_after_setup_the_next_command_uses_the_reloaded_clients() {
1522 let _lock = DIAG_LOCK.lock().await;
1525 let first = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1526 let reloaded = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1527 let reloads = std::cell::Cell::new(0);
1528 let reload = || {
1529 reloads.set(reloads.get() + 1);
1530 Ok(settings_with(Box::new(CountingFactory(reloaded.clone()))))
1531 };
1532 let (host, captured) = test_host(script(&["setup --key nosuchkey", "crawl USDC"]), vec![]);
1533 let (res, state, _, _) = drive_host_reloading(
1534 Journal::default(),
1535 host,
1536 captured,
1537 settings_with(Box::new(CountingFactory(first.clone()))),
1538 &reload,
1539 )
1540 .await;
1541 res.unwrap();
1542 assert_eq!(reloads.get(), 1, "setup reads the settings once");
1543 assert!(state.scrollback_text().contains(SETUP_RELOADED_NOTE));
1544 assert_eq!(
1545 first.load(Ordering::SeqCst),
1546 0,
1547 "the old clients are not used"
1548 );
1549 assert!(
1550 reloaded.load(Ordering::SeqCst) > 0,
1551 "crawl used the new clients"
1552 );
1553 }
1554
1555 #[tokio::test]
1556 async fn test_a_failed_reload_keeps_the_settings_and_says_to_restart() {
1557 let _lock = DIAG_LOCK.lock().await;
1558 let reload = || Err(ScopeError::Other("bad yaml".into()));
1559 let (host, captured) = test_host(script(&["setup --key nosuchkey"]), vec![]);
1560 let (res, state, _, _) = drive_host_reloading(
1561 Journal::default(),
1562 host,
1563 captured,
1564 settings_with(Box::new(MockClientFactory::new())),
1565 &reload,
1566 )
1567 .await;
1568 res.unwrap();
1569 let text = state.scrollback_text();
1570 assert!(text.contains("could not be read again"), "{text}");
1571 assert!(text.contains("bad yaml"), "{text}");
1572 assert!(!text.contains(SETUP_RELOADED_NOTE), "{text}");
1573 }
1574
1575 #[tokio::test]
1576 async fn test_other_commands_do_not_reload() {
1577 let _lock = DIAG_LOCK.lock().await;
1578 let reloads = std::cell::Cell::new(0);
1579 let reload = || {
1580 reloads.set(reloads.get() + 1);
1581 Ok(settings_with(Box::new(MockClientFactory::new())))
1582 };
1583 let (host, captured) = test_host(script(&["crawl USDC", "venues list"]), vec![]);
1584 let (res, state, _, _) = drive_host_reloading(
1585 Journal::default(),
1586 host,
1587 captured,
1588 settings_with(Box::new(MockClientFactory::new())),
1589 &reload,
1590 )
1591 .await;
1592 res.unwrap();
1593 assert_eq!(reloads.get(), 0);
1594 assert!(!state.scrollback_text().contains(SETUP_RELOADED_NOTE));
1595 }
1596
1597 #[tokio::test]
1598 async fn test_ctrl_z_stops_then_redraws_with_the_draft() {
1599 let _lock = DIAG_LOCK.lock().await;
1604 let mut events: Vec<_> = "draf".chars().map(|c| press(KeyCode::Char(c))).collect();
1605 events.push(ctrl_z());
1606 let (res, state, host, _) = drive(events).await;
1607 assert_eq!(res.unwrap().code, 0, "Ctrl-Z does not end the TUI");
1608 assert_eq!(host.stops, 1);
1609 assert_eq!((host.suspends, host.resumes, host.clears), (1, 1, 1));
1610 assert_eq!(state.input, "draf");
1611 assert!(screen_text(&host).contains("draf"));
1612 }
1613
1614 #[tokio::test]
1615 async fn test_tstp_signal_stops_like_ctrl_z() {
1616 let _lock = DIAG_LOCK.lock().await;
1619 let (res, _, host, _) = drive_signal_after(&[], Signal::Tstp).await;
1620 assert_eq!(res.unwrap().code, 0);
1621 assert_eq!(host.stops, 1);
1622 assert_eq!((host.suspends, host.resumes, host.clears), (1, 1, 1));
1623 }
1624
1625 #[tokio::test]
1626 async fn test_cont_without_our_stop_retakes_and_clears_the_screen() {
1627 let _lock = DIAG_LOCK.lock().await;
1630 let (res, _, host, _) = drive_signal_after(&[], Signal::Cont).await;
1631 assert_eq!(res.unwrap().code, 0);
1632 assert_eq!(host.stops, 0, "SIGCONT does not stop the process");
1633 assert_eq!((host.suspends, host.resumes, host.clears), (1, 1, 1));
1634 }
1635
1636 #[tokio::test]
1637 async fn test_ctrl_z_keeps_a_running_pane_command() {
1638 let _lock = DIAG_LOCK.lock().await;
1641 let mut events = script(&[PENDING_REPEAT]);
1642 events.push(ctrl_z());
1643 let (res, state, host, _) = drive_pending(events, Vec::new()).await;
1644 assert_eq!(res.unwrap().code, 0);
1645 assert_eq!(host.stops, 1);
1646 assert!(state.running.is_some(), "the command still runs");
1647 assert!(!state.scrollback_text().contains("Cancelled"));
1648 let screen = screen_text(&host);
1649 assert!(screen.contains("Esc cancels"), "{screen}");
1650 }
1651
1652 #[tokio::test(start_paused = true)]
1653 async fn test_a_long_stop_does_not_replay_the_missed_redraw_ticks() {
1654 const STOPPED: Duration = Duration::from_secs(120);
1659 const MAX_DRAWS_AFTER_STOP: usize = 10;
1660 let _lock = DIAG_LOCK.lock().await;
1661 let (mut host, _) = test_host(Vec::new(), Vec::new());
1662 let after_stop = futures::stream::once(async {
1664 tokio::time::advance(STOPPED).await;
1665 press(KeyCode::Char('x'))
1666 });
1667 host.keys = futures::stream::iter(vec![ctrl_z()])
1668 .chain(after_stop)
1669 .boxed();
1670 let mut state = TuiState::new(
1674 SessionContext::default(),
1675 Journal::default(),
1676 vocab::Vocab::default(),
1677 );
1678 let config = Config::default();
1679 let Plan::Run(inv) = state.submit("venues list", &config) else {
1680 panic!("`venues list` did not plan a run")
1681 };
1682 let _out = state.start("venues list", &inv);
1683 let reload = || Ok(settings_with(Box::new(MockClientFactory::new())));
1684 let settings = settings_with(Box::new(MockClientFactory::new()));
1685 let res = session(&mut host, &mut state, settings, &reload, |_| Ok(())).await;
1686 assert_eq!(res.unwrap().code, 0);
1687 assert!(state.running.is_some(), "the tick stayed on");
1688 assert_eq!(host.stops, 1);
1689 assert_eq!(state.input, "x", "the key after the stop is handled");
1690 let draws = host.draws - host.draws_at_stop;
1691 assert!(
1692 draws <= MAX_DRAWS_AFTER_STOP,
1693 "{draws} draws between the stop and the end"
1694 );
1695 }
1696
1697 #[tokio::test]
1698 async fn test_a_failed_stop_says_so_and_keeps_the_tui() {
1699 let _lock = DIAG_LOCK.lock().await;
1702 let (mut host, captured) = test_host(vec![ctrl_z()], vec![Signal::Cont]);
1703 host.stop_fails = true;
1704 let (res, state, host, _) = drive_host(Journal::default(), host, captured).await;
1705 assert_eq!(res.unwrap().code, 0);
1706 assert!(
1707 state
1708 .scrollback_text()
1709 .contains("Could not stop the process: kill failed")
1710 );
1711 assert_eq!(host.stops, 1);
1712 assert_eq!((host.suspends, host.resumes, host.clears), (2, 2, 2));
1714 }
1715
1716 #[tokio::test]
1717 async fn test_a_terminal_that_cannot_be_taken_back_ends_the_session_with_the_terminal_error() {
1718 let _lock = DIAG_LOCK.lock().await;
1722 let (mut host, captured) = test_host(vec![ctrl_z()], vec![]);
1723 host.resume_fails = true;
1724 let (res, _, host, _) = drive_host(Journal::default(), host, captured).await;
1725 assert_eq!(
1726 res.unwrap_err().to_string(),
1727 "I/O error: cannot take the terminal: no tty"
1728 );
1729 assert_eq!(host.resumes, 1);
1730 assert_eq!(
1731 host.restores, 1,
1732 "the session still gives the terminal back"
1733 );
1734 }
1735
1736 async fn within_limit<T>(fut: impl Future<Output = T>) -> T {
1739 tokio::time::timeout(Duration::from_secs(10), fut)
1740 .await
1741 .expect("the signal did not end the wait")
1742 }
1743
1744 fn records(dir: &tempfile::TempDir) -> Vec<serde_json::Value> {
1746 std::fs::read_to_string(dir.path().join("e.jsonl"))
1747 .unwrap()
1748 .lines()
1749 .map(|l| serde_json::from_str(l).unwrap())
1750 .collect()
1751 }
1752
1753 #[tokio::test]
1754 async fn test_term_signal_during_a_suspended_command_ends_it() {
1755 let _lock = DIAG_LOCK.lock().await;
1758 let dir = tempfile::tempdir().unwrap();
1759 let mut state = TuiState::new(
1760 SessionContext::default(),
1761 Journal::at(dir.path().join("h"), dir.path().join("e.jsonl")),
1762 vocab::Vocab::default(),
1763 );
1764 let (mut host, _) = test_host(Vec::new(), Vec::new());
1765 host.signal_tx.unbounded_send(Signal::Term).unwrap();
1766 let outside = Outside::new(host.suspended_output());
1767 let argv = vec!["monitor".to_string()];
1768 let never = futures::future::pending::<Result<()>>();
1769 let run = run_suspended(&mut host, "monitor", &argv, &outside, never, &mut state);
1770 let exit = within_limit(run).await;
1771 assert_eq!(exit, Some(TuiExit { code: 143 }));
1772 let rec = records(&dir);
1773 assert_eq!(rec.len(), 1, "{rec:?}");
1774 assert_eq!(rec[0]["outcome"], "signal:TERM");
1775 assert_eq!(rec[0]["route"], "suspend");
1776 assert_eq!(rec[0]["input"], "monitor");
1777 }
1778
1779 fn journaled_state(dir: &tempfile::TempDir) -> TuiState {
1781 TuiState::new(
1782 SessionContext::default(),
1783 Journal::at(dir.path().join("h"), dir.path().join("e.jsonl")),
1784 vocab::Vocab::default(),
1785 )
1786 }
1787
1788 fn assert_ran_note(state: &TuiState, line: &str, outcome: &str) {
1791 let text = state.scrollback_text();
1792 let note = text
1793 .lines()
1794 .find_map(|l| {
1795 l.strip_prefix("ℹ ")
1796 .filter(|n| n.starts_with("Ran outside the TUI:"))
1797 })
1798 .unwrap_or_else(|| panic!("no note in {text}"));
1799 let pattern = format!(
1800 r"^Ran outside the TUI: {} \({}, \d+\.\ds\)\.$",
1801 regex::escape(line),
1802 outcome
1803 );
1804 assert!(
1805 regex::Regex::new(&pattern).unwrap().is_match(note),
1806 "{note:?} does not match {pattern}"
1807 );
1808 }
1809
1810 #[tokio::test]
1811 async fn test_suspended_command_is_cancelled_by_sigint() {
1812 let _lock = DIAG_LOCK.lock().await;
1815 let dir = tempfile::tempdir().unwrap();
1816 let mut state = journaled_state(&dir);
1817 let (mut host, captured) = test_host(Vec::new(), Vec::new());
1818 host.signal_tx.unbounded_send(Signal::Int).unwrap();
1819 let outside = Outside::new(host.suspended_output());
1820 let argv = vec!["crawl".to_string(), "USDC".to_string()];
1821 let never = futures::future::pending::<Result<()>>();
1822 let run = run_suspended(&mut host, "crawl USDC", &argv, &outside, never, &mut state);
1823 assert_eq!(within_limit(run).await, None, "the session goes on");
1824 assert_ran_note(&state, "crawl USDC", "cancelled");
1825 let rec = records(&dir);
1826 assert_eq!(rec.len(), 1, "{rec:?}");
1827 assert_eq!(rec[0]["outcome"], "cancelled");
1828 assert_eq!(rec[0]["route"], "suspend");
1829 assert!(captured.out().contains(
1830 "Running outside the TUI; it returns when the command ends (Ctrl-C cancels)."
1831 ));
1832 }
1833
1834 #[tokio::test]
1835 async fn test_sigint_cancel_returns_to_a_working_loop() {
1836 let _lock = DIAG_LOCK.lock().await;
1839 let dir = tempfile::tempdir().unwrap();
1840 let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
1841 let (mut host, captured) = test_host(script(&["setup --key nosuchkey", "ctx"]), Vec::new());
1842 host.suspend_signals = vec![Signal::Int];
1843 let (res, state, host, _) = within_limit(drive_host(journal, host, captured)).await;
1844 assert_eq!(res.unwrap().code, 0);
1845 assert_eq!((host.suspends, host.resumes), (1, 1));
1846 assert_ran_note(&state, "setup --key nosuchkey", "cancelled");
1847 assert!(state.scrollback_text().contains("Current Context"));
1848 let rec = records(&dir);
1849 assert_eq!(rec[0]["outcome"], "cancelled", "{rec:?}");
1850 assert_eq!(rec[1]["input"], "ctx", "{rec:?}");
1851 }
1852
1853 async fn prompt_stopped_by(
1863 sig: Option<Signal>,
1864 result: Result<()>,
1865 ) -> (Option<TuiExit>, TuiState, TestHost, tempfile::TempDir) {
1866 let dir = tempfile::tempdir().unwrap();
1867 let mut state = journaled_state(&dir);
1868 let (mut host, _) = test_host(Vec::new(), Vec::new());
1869 let outside = Outside::new(host.suspended_output());
1870 let tx = host.signal_tx.clone();
1871 let argv = vec!["setup".to_string()];
1872 let prompt_stopped = async move {
1875 crate::cli::prompt::CANCEL.store(true, Ordering::SeqCst);
1876 if let Some(sig) = sig {
1877 tx.unbounded_send(sig).unwrap();
1878 }
1879 result
1880 };
1881 let run = run_suspended(
1882 &mut host,
1883 "setup",
1884 &argv,
1885 &outside,
1886 prompt_stopped,
1887 &mut state,
1888 );
1889 let exit = within_limit(run).await;
1890 crate::cli::prompt::CANCEL.store(false, Ordering::SeqCst);
1891 (exit, state, host, dir)
1892 }
1893
1894 #[tokio::test]
1895 async fn test_a_cancelled_prompt_is_recorded_as_cancelled_and_its_int_used_up() {
1896 let _lock = DIAG_LOCK.lock().await;
1900 for result in [
1901 Err(ScopeError::IoError(io::Error::other("cancelled"))),
1902 Ok(()),
1903 ] {
1904 let (exit, state, mut host, dir) = prompt_stopped_by(Some(Signal::Int), result).await;
1905 assert_eq!(exit, None);
1906 assert_ran_note(&state, "setup", "cancelled");
1907 assert_eq!(records(&dir)[0]["outcome"], "cancelled");
1908 assert!(host.signals.try_next().is_err(), "the INT is used up");
1909 }
1910 }
1911
1912 #[tokio::test]
1913 async fn test_term_at_a_prompt_ends_the_session_from_suspend_mode() {
1914 let _lock = DIAG_LOCK.lock().await;
1915 let failed = Err(ScopeError::IoError(io::Error::other("cancelled")));
1916 let (exit, _, _, dir) = prompt_stopped_by(Some(Signal::Term), failed).await;
1917 assert_eq!(exit, Some(TuiExit { code: 143 }));
1918 let rec = records(&dir);
1919 assert_eq!(rec.len(), 1, "{rec:?}");
1920 assert_eq!(rec[0]["outcome"], "signal:TERM");
1921 assert_eq!(rec[0]["route"], "suspend");
1922 }
1923
1924 #[tokio::test]
1925 async fn test_a_suspended_command_starts_with_the_cancel_flag_clear() {
1926 let _lock = DIAG_LOCK.lock().await;
1929 let dir = tempfile::tempdir().unwrap();
1930 let mut state = journaled_state(&dir);
1931 let (mut host, _) = test_host(Vec::new(), Vec::new());
1932 let outside = Outside::new(host.suspended_output());
1933 crate::cli::prompt::CANCEL.store(true, Ordering::SeqCst);
1934 let saw_clear = async {
1935 assert!(!crate::cli::prompt::CANCEL.load(Ordering::SeqCst));
1936 Ok(())
1937 };
1938 let run = run_suspended(&mut host, "setup", &[], &outside, saw_clear, &mut state);
1939 assert_eq!(within_limit(run).await, None);
1940 assert_ran_note(&state, "setup", "ok");
1941 crate::cli::prompt::CANCEL.store(false, Ordering::SeqCst);
1942 }
1943
1944 fn lines_before_note(state: &TuiState, first: &str) -> Vec<String> {
1947 let text = state.scrollback_text();
1948 let lines: Vec<String> = text.lines().map(str::to_string).collect();
1949 let start = lines
1950 .iter()
1951 .position(|l| l.contains(first))
1952 .unwrap_or_else(|| panic!("{first:?} is not in the scrollback:\n{text}"));
1953 let note = lines
1954 .iter()
1955 .position(|l| l.contains("Ran outside the TUI:"))
1956 .unwrap_or_else(|| panic!("no note:\n{text}"));
1957 assert!(start < note, "the output comes before the note:\n{text}");
1958 lines[start..note].to_vec()
1959 }
1960
1961 #[tokio::test]
1962 async fn test_a_suspended_report_shows_in_the_scrollback_and_is_the_copy() {
1963 let _lock = DIAG_LOCK.lock().await;
1966 let dir = tempfile::tempdir().unwrap();
1967 let mut state = journaled_state(&dir);
1968 let (mut host, captured) = test_host(Vec::new(), Vec::new());
1969 let outside = Outside::new(host.suspended_output());
1970 let out = &outside.out;
1971 let report = async {
1972 crate::outln!(out, "\x1b[1mToken Report\x1b[0m: USDC")?;
1973 crate::errln!(out, "warning: partial data")?;
1974 crate::outln!(out, "holders 42")?;
1975 Ok(())
1976 };
1977 let run = run_suspended(&mut host, "crawl USDC", &[], &outside, report, &mut state);
1978 assert_eq!(within_limit(run).await, None);
1979 let shown = lines_before_note(&state, "Token Report: USDC");
1980 assert_eq!(
1981 shown,
1982 ["Token Report: USDC", "warning: partial data", "holders 42"]
1983 );
1984 assert!(captured.out().contains("holders 42"));
1986 let Action::Copy(copy) =
1988 state.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL))
1989 else {
1990 panic!("Ctrl-Y has nothing to copy")
1991 };
1992 assert_eq!(copy, "Token Report: USDC\nholders 42\n");
1993 }
1994
1995 #[tokio::test]
1996 async fn test_a_failed_suspended_command_shows_its_output_and_error() {
1997 let _lock = DIAG_LOCK.lock().await;
2000 let dir = tempfile::tempdir().unwrap();
2001 let mut state = journaled_state(&dir);
2002 let (mut host, _) = test_host(Vec::new(), Vec::new());
2003 let outside = Outside::new(host.suspended_output());
2004 let out = &outside.out;
2005 let failed = async {
2006 crate::outln!(out, "searching USDC")?;
2007 Err(ScopeError::Other("crawl blew up".into()))
2008 };
2009 let run = run_suspended(&mut host, "crawl USDC", &[], &outside, failed, &mut state);
2010 assert_eq!(within_limit(run).await, None);
2011 assert_ran_note(&state, "crawl USDC", "failed");
2012 let shown = lines_before_note(&state, "searching USDC").join("\n");
2013 let error = shown.find("✗ crawl blew up").expect(&shown);
2014 assert!(shown.find("searching USDC").unwrap() < error, "{shown}");
2015 let copy = state.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
2016 assert!(matches!(copy, Action::None), "no copy after a failure");
2017 }
2018
2019 #[tokio::test(start_paused = true)]
2020 async fn test_a_set_flag_without_a_signal_does_not_hang_the_tui() {
2021 let _lock = DIAG_LOCK.lock().await;
2024 let started = tokio::time::Instant::now();
2025 let failed = Err(ScopeError::IoError(io::Error::other("cancelled")));
2026 let (exit, state, _, dir) = prompt_stopped_by(None, failed).await;
2027 assert_eq!(exit, None);
2028 assert!(started.elapsed() <= PROMPT_SIGNAL_WAIT + Duration::from_millis(100));
2029 assert_ran_note(&state, "setup", "cancelled");
2030 assert_eq!(records(&dir)[0]["outcome"], "cancelled");
2031 }
2032
2033 #[tokio::test]
2034 async fn test_a_second_ctrl_c_does_not_end_the_session() {
2035 let _lock = DIAG_LOCK.lock().await;
2038 let dir = tempfile::tempdir().unwrap();
2039 let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
2040 let (mut host, captured) = test_host(script(&["setup --key nosuchkey", "ctx"]), Vec::new());
2041 host.suspend_signals = vec![Signal::Int, Signal::Int];
2042 let (res, state, host, _) = within_limit(drive_host(journal, host, captured)).await;
2043 assert_eq!(res.unwrap().code, 0);
2044 assert_eq!((host.suspends, host.resumes), (1, 1));
2045 assert_ran_note(&state, "setup --key nosuchkey", "cancelled");
2046 assert!(state.scrollback_text().contains("Current Context"));
2047 let rec = records(&dir);
2048 assert_eq!(rec.len(), 2, "{rec:?}");
2049 assert_eq!(rec[1]["input"], "ctx", "{rec:?}");
2050 }
2051
2052 #[tokio::test]
2053 async fn test_a_term_queued_after_a_cancel_still_ends_the_session() {
2054 let _lock = DIAG_LOCK.lock().await;
2056 let dir = tempfile::tempdir().unwrap();
2057 let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
2058 let (mut host, captured) = test_host(script(&["setup --key nosuchkey", "ctx"]), Vec::new());
2059 host.suspend_signals = vec![Signal::Int, Signal::Int, Signal::Term];
2060 let (res, _, host, _) = within_limit(drive_host(journal, host, captured)).await;
2061 assert_eq!(res.unwrap().code, 143);
2062 assert_eq!(host.resumes, 0);
2063 let rec = records(&dir);
2064 assert_eq!(rec[0]["outcome"], "cancelled", "{rec:?}");
2065 assert_eq!(rec[1]["outcome"], "signal:TERM", "{rec:?}");
2066 assert_eq!(rec[1]["route"], "suspend", "{rec:?}");
2067 }
2068
2069 #[tokio::test]
2070 async fn test_ending_signals_set_the_prompt_cancel_flag() {
2071 for (sigs, set) in [
2074 (vec![Signal::Tstp, Signal::Cont], false),
2075 (vec![Signal::Int], true),
2076 (vec![Signal::Term], true),
2077 (vec![Signal::Hup], true),
2078 (vec![Signal::Quit], true),
2079 ] {
2080 let flag = AtomicBool::new(false);
2081 cancel_prompts_on(futures::stream::iter(sigs.clone()), &flag).await;
2082 assert_eq!(flag.load(Ordering::SeqCst), set, "{sigs:?}");
2083 }
2084 }
2085
2086 struct BrokenTty;
2088
2089 impl io::Write for BrokenTty {
2090 fn write(&mut self, _: &[u8]) -> io::Result<usize> {
2091 Err(io::Error::other("tty gone"))
2092 }
2093 fn flush(&mut self) -> io::Result<()> {
2094 Ok(())
2095 }
2096 }
2097
2098 #[tokio::test]
2099 async fn test_terminal_errors_outside_the_tui_are_reported_after_it() {
2100 let _lock = DIAG_LOCK.lock().await;
2103 let dir = tempfile::tempdir().unwrap();
2104 let mut state = journaled_state(&dir);
2105 let (mut host, _) = test_host(Vec::new(), Vec::new());
2106 let outside = Outside::new(Output::from_writers(BrokenTty, BrokenTty));
2107 let failed = async { Err(ScopeError::Other("boom".into())) };
2108 let run = run_suspended(&mut host, "setup", &[], &outside, failed, &mut state);
2109 assert_eq!(within_limit(run).await, None);
2110 assert_ran_note(&state, "setup", "failed");
2111 let text = state.scrollback_text();
2112 assert_eq!(
2113 text.matches("Terminal I/O error while outside the TUI: tty gone")
2114 .count(),
2115 1,
2116 "the error text; the header is a note, and a failed note is dropped: {text}"
2117 );
2118 assert_eq!(records(&dir)[0]["outcome"], "error");
2119 }
2120
2121 #[tokio::test]
2122 async fn test_suspended_command_ignores_tstp_and_cont() {
2123 let _lock = DIAG_LOCK.lock().await;
2126 let (mut host, captured) = test_host(script(&["setup --key nosuchkey"]), Vec::new());
2127 host.suspend_signals = vec![Signal::Tstp, Signal::Cont];
2128 let (res, state, host, _) = drive_host(Journal::default(), host, captured).await;
2129 assert_eq!(res.unwrap().code, 0);
2130 assert_eq!((host.suspends, host.resumes), (1, 1));
2131 assert_eq!((host.stops, host.clears), (0, 0));
2132 assert_ran_note(&state, "setup --key nosuchkey", "ok");
2133 }
2134
2135 #[tokio::test]
2136 async fn test_event_stream_error_still_restores_once_and_saves() {
2137 let _lock = DIAG_LOCK.lock().await;
2140 let dir = tempfile::tempdir().unwrap();
2141 let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
2142 let mut events = script(&["ctx"]);
2143 events.push(Err(io::Error::other("tty gone")));
2144 let (res, _, host, _) = drive_with(journal, events, Vec::new()).await;
2145 assert!(res.unwrap_err().to_string().contains("tty gone"));
2146 assert_eq!(host.restores, 1);
2147 let history = std::fs::read_to_string(dir.path().join("h")).unwrap();
2148 assert!(history.contains("ctx"), "{history}");
2149 }
2150
2151 #[tokio::test]
2152 async fn test_normal_exit_restores_and_returns_code_zero() {
2153 let _lock = DIAG_LOCK.lock().await;
2154 let (res, _, host, _) = drive(script(&["exit"])).await;
2155 assert_eq!(res.unwrap().code, 0);
2156 assert_eq!(host.restores, 1);
2157 }
2158
2159 #[tokio::test]
2160 async fn test_loop_runs_session_and_pane_commands_then_exits() {
2161 let _lock = DIAG_LOCK.lock().await;
2162 let (res, state, host, _) =
2163 drive(script(&["chain base", "venues list", "exit", "never runs"])).await;
2164 res.unwrap();
2165 let text = state.scrollback_text();
2166 assert!(text.contains("Type `help` for commands"));
2167 assert!(text.contains("Chain pinned to: base"));
2168 assert!(text.contains("Available Venues"), "{}", text);
2169 assert!(text.contains("Done in"));
2170 assert!(!text.contains("never runs"), "nothing runs after exit");
2171 let buf = host.terminal.backend().buffer().clone();
2173 let screen: String = buf.content().iter().map(|c| c.symbol()).collect();
2174 assert!(screen.contains("scope:base>"));
2175 }
2176
2177 fn ctrl_y_event() -> io::Result<Event> {
2178 Ok(Event::Key(KeyEvent::new(
2179 KeyCode::Char('y'),
2180 KeyModifiers::CONTROL,
2181 )))
2182 }
2183
2184 #[tokio::test]
2185 async fn test_ctrl_y_sends_the_json_and_save_writes_it() {
2186 use base64::Engine as _;
2187 let _lock = DIAG_LOCK.lock().await;
2188 let dir = tempfile::tempdir().unwrap();
2189 let file = dir.path().join("one.json");
2190 let mut events = script(&[
2191 "venues list --format json",
2192 &format!("save '{}'", file.display()),
2195 ]);
2196 events.push(ctrl_y_event());
2197 let (res, state, host, _) = drive(events).await;
2198 res.unwrap();
2199 let body = std::fs::read_to_string(&file).unwrap();
2200 serde_json::from_str::<serde_json::Value>(&body).unwrap();
2201 let text = state.scrollback_text();
2202 assert!(text.contains("copied "), "{text}");
2203 let payload = host
2204 .raw
2205 .strip_prefix("\x1b]52;c;")
2206 .and_then(|r| r.strip_suffix('\x07'))
2207 .unwrap_or_else(|| panic!("not an OSC 52 sequence: {:?}", host.raw));
2208 let sent = base64::engine::general_purpose::STANDARD
2209 .decode(payload)
2210 .unwrap();
2211 assert_eq!(String::from_utf8(sent).unwrap(), body);
2212 }
2213
2214 #[tokio::test]
2215 async fn test_save_refuses_to_overwrite_then_force_replaces_and_both_are_journaled() {
2216 let _lock = DIAG_LOCK.lock().await;
2217 let dir = tempfile::tempdir().unwrap();
2218 let file = dir.path().join("two.json");
2219 std::fs::write(&file, "old").unwrap();
2220 let save = format!("save '{}'", file.display());
2222 let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
2223 let (res, state, _, _) = drive_with(
2224 journal,
2225 script(&[
2226 "venues list --format json",
2227 &save,
2228 &format!("{save} --force"),
2229 ]),
2230 Vec::new(),
2231 )
2232 .await;
2233 res.unwrap();
2234 let text = state.scrollback_text();
2235 assert!(text.contains("exists; add --force"), "{text}");
2236 assert!(text.contains("saved "), "{text}");
2237 let body = std::fs::read_to_string(&file).unwrap();
2238 assert_ne!(body, "old", "--force replaced the file");
2239 serde_json::from_str::<serde_json::Value>(&body).unwrap();
2240 let recs = records(&dir);
2241 let refused = recs.iter().find(|r| r["outcome"] == "refused").unwrap();
2242 assert_eq!(refused["input"], save);
2243 assert!(refused["error"].as_str().unwrap().contains("exists"));
2244 assert!(
2245 recs.iter()
2246 .any(|r| r["input"] == format!("{save} --force") && r["outcome"] == "session")
2247 );
2248 }
2249
2250 #[tokio::test]
2251 async fn test_ctrl_y_results_are_journaled() {
2252 let _lock = DIAG_LOCK.lock().await;
2253 let dir = tempfile::tempdir().unwrap();
2254 let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
2255 let mut events = vec![ctrl_y_event()];
2256 events.extend(script(&["venues list"]));
2257 events.push(ctrl_y_event());
2258 let (mut host, captured) = test_host(events, Vec::new());
2259 host.raw_fails = true;
2260 let (_, _, _, _) = drive_host(journal, host, captured).await;
2261 let recs = records(&dir);
2262 let ctrl_y: Vec<_> = recs.iter().filter(|r| r["input"] == "Ctrl-Y").collect();
2263 assert_eq!(ctrl_y.len(), 2, "{recs:?}");
2264 assert_eq!(ctrl_y[0]["outcome"], "refused");
2265 assert_eq!(ctrl_y[0]["error"], "nothing to copy yet");
2266 assert_eq!(ctrl_y[1]["outcome"], "error");
2267 assert!(ctrl_y[1]["error"].as_str().unwrap().contains("tty gone"));
2268 }
2269
2270 #[tokio::test]
2271 async fn test_a_failed_copy_is_noted() {
2272 let _lock = DIAG_LOCK.lock().await;
2273 let mut events = script(&["venues list"]);
2274 events.push(Ok(Event::Key(KeyEvent::new(
2275 KeyCode::Char('y'),
2276 KeyModifiers::CONTROL,
2277 ))));
2278 let (mut host, captured) = test_host(events, Vec::new());
2279 host.raw_fails = true;
2280 let (_, state, _, _) = drive_host(Journal::default(), host, captured).await;
2281 assert!(state.scrollback_text().contains("Could not copy: tty gone"));
2282 }
2283
2284 #[tokio::test]
2285 async fn test_watch_open_runs_the_grid_next_to_the_shell() {
2286 let _lock = DIAG_LOCK.lock().await;
2287 let dir = tempfile::tempdir().unwrap();
2288 let path = dir.path().join("tokens.yaml");
2289 std::fs::write(
2290 &path,
2291 "name: tokens\ninterval: 1h\ntargets:\n - kind: token\n id: \"0x1234567890123456789012345678901234567890\"\n chain: ethereum\n rules: [\"price_usd > 0\"]\n",
2292 )
2293 .unwrap();
2294 let line = format!(
2295 "watch open {}",
2296 path.display().to_string().replace('\\', "/")
2297 );
2298 let (res, mut state, host, _) = drive(script(&[&line])).await;
2299 res.unwrap();
2300 state.poll_watch();
2301 let g = state.grid.as_ref().expect("the grid opened");
2302 assert!(state.grid_shown);
2303 assert_eq!(g.list.name, "tokens");
2304 assert!(g.ready.is_some(), "the targets were resolved");
2305 let last = g.last.as_ref().expect("the first tick came");
2306 assert_eq!(
2307 last.snapshot.targets[0].status,
2308 scope::watch::TargetStatus::Passed
2309 );
2310 assert!(g.is_running(), "a 1h run goes on");
2311 assert!(
2312 screen_text(&host).contains("watchlist tokens"),
2313 "{}",
2314 screen_text(&host)
2315 );
2316 assert!(state.scrollback_text().contains("Watchlist tokens started"));
2317 }
2318
2319 #[tokio::test]
2320 async fn test_esc_in_the_grid_stops_the_run_and_writes_the_report() {
2321 let _lock = DIAG_LOCK.lock().await;
2322 let dir = tempfile::tempdir().unwrap();
2323 let path = dir.path().join("w.yaml");
2324 let report = dir.path().join("w.md");
2325 std::fs::write(
2326 &path,
2327 format!(
2328 "interval: 1h\noutputs:\n report: '{}'\ntargets:\n - kind: token\n id: \"0x1234567890123456789012345678901234567890\"\n rules: [\"price_usd > 0\"]\n",
2329 report.display()
2330 ),
2331 )
2332 .unwrap();
2333 let line = format!(
2334 "watch open {}",
2335 path.display().to_string().replace('\\', "/")
2336 );
2337 let (res, mut state, _, _) = drive(script(&[&line, "", "<esc>"])).await;
2339 res.unwrap();
2340 let g = state.grid.as_ref().unwrap();
2341 assert!(!g.is_running());
2342 let (footer, _) = g.ended.clone().unwrap();
2343 assert!(footer.starts_with("Stopped after"), "{footer}");
2344 assert!(g.last.is_some(), "the first tick landed before Esc");
2345 assert!(footer.contains("Report saved to"), "{footer}");
2346 assert!(
2347 std::fs::read_to_string(&report)
2348 .unwrap()
2349 .contains("# Watchlist Report")
2350 );
2351 state.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
2353 assert!(state.grid.is_none() && !state.grid_shown);
2354 assert!(
2355 state.scrollback_text().contains("watchlist w:"),
2356 "{}",
2357 state.scrollback_text()
2358 );
2359 }
2360
2361 const TOKEN: &str = "0x1234567890123456789012345678901234567890";
2362
2363 fn ctrl_key(c: char) -> io::Result<Event> {
2364 Ok(Event::Key(KeyEvent::new(
2365 KeyCode::Char(c),
2366 KeyModifiers::CONTROL,
2367 )))
2368 }
2369
2370 #[tokio::test]
2371 async fn test_monitor_opens_the_wizard_filled_from_the_arguments() {
2372 let _lock = DIAG_LOCK.lock().await;
2373 let (res, state, host, _) = drive(script(&[
2374 "monitor USDN --venue weex --every 30s --duration 1h --layout feed",
2375 ]))
2376 .await;
2377 res.unwrap();
2378 let w = state.wizard.as_ref().expect("the wizard opened");
2379 assert_eq!(w.list.interval, std::time::Duration::from_secs(30));
2380 let t = &w.list.targets[0];
2381 assert_eq!((t.id.as_str(), t.venue.as_deref()), ("USDN", Some("weex")));
2382 assert_eq!(t.preset, Some(scope::watch::Preset::MarketHealth));
2383 assert!(state.grid.is_none(), "nothing runs before Start");
2384 let screen = screen_text(&host);
2385 assert!(screen.contains("monitor wizard"), "{screen}");
2386 assert!(screen.contains("[x] market health"), "{screen}");
2387 assert!(screen.contains("--layout has no effect"), "{screen}");
2388 }
2389
2390 #[tokio::test]
2391 async fn test_ctrl_r_in_the_wizard_starts_the_grid() {
2392 let _lock = DIAG_LOCK.lock().await;
2393 let mut events = script(&[&format!("monitor {TOKEN} --duration 1h")]);
2394 events.push(ctrl_key('r'));
2395 let (res, mut state, _, _) = drive(events).await;
2396 res.unwrap();
2397 state.poll_watch();
2398 assert!(state.wizard.is_none());
2399 let g = state.grid.as_ref().expect("the run started");
2400 assert!(g.is_running());
2401 let names: Vec<String> = g.list.targets[0]
2402 .rules
2403 .iter()
2404 .map(ToString::to_string)
2405 .collect();
2406 assert_eq!(names, ["price_usd > 0", "volume_24h >= 0"]);
2407 }
2408
2409 #[tokio::test]
2410 async fn test_monitor_yes_starts_at_once_and_a_bad_argument_says_why() {
2411 let _lock = DIAG_LOCK.lock().await;
2412 let mut events = script(&[&format!("monitor {TOKEN} --yes")]);
2414 events.push(ctrl_key('g'));
2415 events.extend(script(&["monitor USDC --venue binance --every 0s"]));
2416 let (res, mut state, _, _) = drive(events).await;
2417 res.unwrap();
2418 state.poll_watch();
2419 assert!(state.wizard.is_none());
2420 assert!(state.grid.as_ref().is_some_and(|g| g.is_running()));
2421 assert!(
2422 state
2423 .scrollback_text()
2424 .contains("monitor: --every: Duration must be positive"),
2425 "{}",
2426 state.scrollback_text()
2427 );
2428 }
2429
2430 #[tokio::test]
2431 async fn test_scope_monitor_opens_the_tui_with_the_wizard() {
2432 let _lock = DIAG_LOCK.lock().await;
2433 let (mut host, _) = test_host(Vec::new(), Vec::new());
2434 let mut state = TuiState::new(
2435 SessionContext::default(),
2436 Journal::default(),
2437 vocab::Vocab::default(),
2438 );
2439 let cli = <crate::cli::Cli as clap::Parser>::try_parse_from([
2440 "scope", "monitor", "USDN", "--venue", "weex",
2441 ])
2442 .unwrap();
2443 state.start = Some(cli.command);
2444 let settings = settings_with(Box::new(MockClientFactory::new()));
2445 let reload = || Ok(settings_with(Box::new(MockClientFactory::new())));
2446 session(&mut host, &mut state, settings, &reload, |_| Ok(()))
2447 .await
2448 .unwrap();
2449 assert!(state.wizard.is_some(), "the wizard is open at the start");
2450 assert!(screen_text(&host).contains("monitor wizard"));
2451 }
2452
2453 struct NoisyDex(scope::chains::mocks::MockDexSource);
2456
2457 const NOISE: &str = "noisy dex warning";
2458
2459 #[async_trait::async_trait]
2460 impl scope::chains::DexDataSource for NoisyDex {
2461 async fn get_token_price(&self, c: &str, a: &str) -> Option<f64> {
2462 scope::notice!("{NOISE}");
2463 self.0.get_token_price(c, a).await
2464 }
2465 async fn get_native_token_price(&self, c: &str) -> Option<f64> {
2466 scope::notice!("{NOISE}");
2467 self.0.get_native_token_price(c).await
2468 }
2469 async fn get_token_data(
2470 &self,
2471 c: &str,
2472 a: &str,
2473 ) -> scope::Result<scope::chains::dex::DexTokenData> {
2474 scope::notice!("{NOISE}");
2475 self.0.get_token_data(c, a).await
2476 }
2477 async fn search_tokens(
2478 &self,
2479 q: &str,
2480 c: Option<&str>,
2481 ) -> scope::Result<Vec<scope::chains::dex::TokenSearchResult>> {
2482 scope::notice!("{NOISE}");
2483 self.0.search_tokens(q, c).await
2484 }
2485 }
2486
2487 struct NoisyFactory(MockClientFactory);
2488
2489 impl ChainClientFactory for NoisyFactory {
2490 fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
2491 scope::http::test_transport()
2492 }
2493 fn create_chain_client(
2494 &self,
2495 chain: &str,
2496 ) -> scope::Result<Box<dyn scope::chains::ChainClient>> {
2497 self.0.create_chain_client(chain)
2498 }
2499 fn create_dex_client(&self) -> Box<dyn scope::chains::DexDataSource> {
2500 Box::new(NoisyDex(scope::chains::mocks::MockDexSource::new()))
2501 }
2502 }
2503
2504 fn token_watchlist(dir: &tempfile::TempDir) -> String {
2506 let path = dir.path().join("noisy.yaml");
2507 std::fs::write(
2508 &path,
2509 format!("interval: 1h\ntargets:\n - kind: token\n id: \"{TOKEN}\"\n rules: [\"price_usd > 0\"]\n"),
2510 )
2511 .unwrap();
2512 format!(
2513 "watch open {}",
2514 path.display().to_string().replace('\\', "/")
2515 )
2516 }
2517
2518 #[tokio::test]
2519 async fn test_a_watchlist_warning_stays_out_of_the_shell_and_comes_with_the_grid() {
2520 let _lock = DIAG_LOCK.lock().await;
2521 let dir = tempfile::tempdir().unwrap();
2522 let mut events = script(&[&token_watchlist(&dir)]);
2523 events.push(ctrl_key('g'));
2525 events.extend(script(&["venues list"]));
2526 let (host, captured) = test_host(events, Vec::new());
2527 let (res, mut state, _, _) = drive_host_on(
2528 Journal::default(),
2529 host,
2530 captured,
2531 Box::new(NoisyFactory(MockClientFactory::new())),
2532 )
2533 .await;
2534 res.unwrap();
2535 state.poll_watch();
2536 assert!(
2537 state.grid.as_ref().is_some_and(|g| g.last.is_some()),
2538 "the watchlist ticked"
2539 );
2540 let shell = state.scrollback_text();
2541 assert!(shell.contains("binance"), "venues list ran: {shell}");
2542 assert!(
2543 !shell.contains(NOISE),
2544 "no watchlist warning in the shell: {shell}"
2545 );
2546 state.stop_watch();
2548 state.close_grid();
2549 assert!(state.scrollback_text().contains(NOISE));
2550 }
2551
2552 #[tokio::test]
2553 async fn test_a_setup_reload_says_the_running_watchlist_uses_the_old_settings() {
2554 let _lock = DIAG_LOCK.lock().await;
2555 let dir = tempfile::tempdir().unwrap();
2556 let mut events = script(&[&token_watchlist(&dir)]);
2557 events.push(ctrl_key('g'));
2558 events.extend(script(&["setup --key nosuchkey"]));
2559 let reload = || Ok(settings_with(Box::new(MockClientFactory::new())));
2560 let (host, captured) = test_host(events, Vec::new());
2561 let (res, state, host, _) = drive_host_reloading(
2562 Journal::default(),
2563 host,
2564 captured,
2565 settings_with(Box::new(MockClientFactory::new())),
2566 &reload,
2567 )
2568 .await;
2569 res.unwrap();
2570 let g = state.grid.as_ref().expect("the grid");
2571 assert!(g.stale, "the grid knows");
2572 assert!(state.scrollback_text().contains(grid::STALE_NOTE));
2573 assert!(
2574 screen_text(&host).contains("old settings"),
2575 "{}",
2576 screen_text(&host)
2577 );
2578 }
2579
2580 #[tokio::test]
2581 async fn test_ctrl_g_without_a_grid_says_how_to_start_one() {
2582 let _lock = DIAG_LOCK.lock().await;
2583 let (res, state, _, _) = drive(vec![Ok(Event::Key(KeyEvent::new(
2584 KeyCode::Char('g'),
2585 KeyModifiers::CONTROL,
2586 )))])
2587 .await;
2588 res.unwrap();
2589 assert!(state.scrollback_text().contains("No watchlist runs"));
2590 }
2591
2592 #[tokio::test]
2593 async fn test_loop_esc_cancels_a_running_repeat_command() {
2594 let _lock = DIAG_LOCK.lock().await;
2596 let (res, state, _, _) =
2597 drive_pending(script(&[PENDING_REPEAT, "<esc>"]), Vec::new()).await;
2598 res.unwrap();
2599 let text = state.scrollback_text();
2600 assert!(text.contains("Cancelled after"), "{}", text);
2601 assert!(state.running.is_none());
2602 }
2603
2604 #[tokio::test]
2605 async fn test_loop_suspend_mode_hands_over_the_terminal_and_records() {
2606 let _lock = DIAG_LOCK.lock().await;
2610 let (res, state, host, suspended) = drive(script(&["setup --key nosuchkey"])).await;
2611 res.unwrap();
2612 assert_eq!((host.suspends, host.resumes), (1, 1));
2613 assert!(
2614 suspended
2615 .out()
2616 .contains("── scope setup --key nosuchkey ──")
2617 );
2618 assert!(suspended.out().contains(
2619 "Running outside the TUI; it returns when the command ends (Ctrl-C cancels)."
2620 ));
2621 assert!(!suspended.out().contains("Press Enter"));
2623 assert_ran_note(&state, "setup --key nosuchkey", "ok");
2624 }
2625
2626 #[tokio::test]
2627 async fn test_loop_ignores_non_press_keys_and_ends_on_stream_end() {
2628 let _lock = DIAG_LOCK.lock().await;
2629 let mut events = vec![Ok(Event::Resize(80, 24))];
2630 let mut release = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
2631 release.kind = KeyEventKind::Release;
2632 release.state = KeyEventState::NONE;
2633 events.push(Ok(Event::Key(release)));
2634 let (res, state, _, _) = drive(events).await;
2635 res.unwrap();
2636 assert_eq!(state.input, "", "a key release must not type");
2637 }
2638
2639 #[tokio::test]
2640 async fn test_loop_ctrl_d_exits() {
2641 let _lock = DIAG_LOCK.lock().await;
2642 let (res, _, _, _) = drive(vec![Ok(Event::Key(KeyEvent::new(
2643 KeyCode::Char('d'),
2644 KeyModifiers::CONTROL,
2645 )))])
2646 .await;
2647 res.unwrap();
2648 }
2649
2650 #[tokio::test]
2651 async fn test_loop_returns_event_stream_errors() {
2652 let _lock = DIAG_LOCK.lock().await;
2654 let (res, _, _, _) = drive(vec![Err(io::Error::other("tty gone"))]).await;
2655 assert!(res.unwrap_err().to_string().contains("tty gone"));
2656 }
2657
2658 #[tokio::test]
2659 async fn test_library_warning_lands_in_the_running_command_output() {
2660 let _lock = DIAG_LOCK.lock().await;
2663 let mut s = TuiState::new(
2664 SessionContext::default(),
2665 Journal::default(),
2666 vocab::Vocab::default(),
2667 );
2668 let Plan::Run(inv) = s.submit("venues list", &Config::default()) else {
2669 panic!()
2670 };
2671 let _out = s.start("venues list", &inv);
2672 scope::notice!(" ⚠ library warning");
2673 s.finish(Ending::Finished(Ok(())));
2674 assert!(s.scrollback_text().contains("⚠ library warning"));
2675 }
2676
2677 #[cfg(unix)]
2678 #[tokio::test]
2679 async fn test_signal_stream_installs_all_handlers() {
2680 assert!(signal_stream().is_ok());
2683 }
2684}