quvyta-framework 0.1.34

A Rust framework for building terminal applications
//! A session attached to a program in another process, against a session that runs it here.

use std::io::{self, Cursor, PipeWriter, Write};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use crate::prelude::*;
use crate::runtime::Harness;
use crate::widgets::{Terminal, TerminalChange, TerminalSession};

use super::super::terminal_session::lock;
use super::TerminalCarrier;

/// Generous: the tests run next to many others on a busy machine.
const PATIENCE: Duration = Duration::from_secs(30);

/// What a carrier was asked to do.
#[derive(Debug, Default)]
struct Asked {
    written: Vec<u8>,
    sizes: Vec<(u16, u16)>,
    ended: usize,
}

/// A carrier that writes down what it is asked and says the program ended with `code`.
#[derive(Clone, Default)]
struct Recording {
    asked: Arc<Mutex<Asked>>,
    code: Option<u32>,
}

impl TerminalCarrier for Recording {
    fn write(&mut self, bytes: &[u8]) -> io::Result<()> {
        lock(&self.asked).written.extend_from_slice(bytes);
        Ok(())
    }

    fn resize(&mut self, columns: u16, rows: u16) -> io::Result<()> {
        lock(&self.asked).sizes.push((columns, rows));
        Ok(())
    }

    fn end(&mut self) {
        lock(&self.asked).ended += 1;
    }

    fn exit_code(&mut self) -> Option<u32> {
        self.code
    }
}

/// Waits until the screen of `session` shows `text`.
fn wait_for(session: &TerminalSession, text: &str) {
    let watch = session.watch();
    let started = Instant::now();
    while !session.parser().screen().contents().contains(text) {
        let _ = watch.next_change_within(Duration::from_millis(200));
        assert!(started.elapsed() < PATIENCE, "{text:?} never came:\n{}", session.parser().screen().contents());
    }
}

/// Waits until the program of `session` has been quiet for a while, so nothing it wrote is still on
/// its way to the screen.
fn wait_quiet(session: &TerminalSession) {
    let started = Instant::now();
    while session.last_output().elapsed() < Duration::from_millis(300) {
        std::thread::sleep(Duration::from_millis(50));
        assert!(started.elapsed() < PATIENCE, "the program never went quiet");
    }
}

/// Everything a screen shows and remembers, scrollback included, in the bytes vt100 writes for
/// it, with the cursor and the input modes: two screens that give the same are drawn alike.
fn everything(session: &TerminalSession) -> Vec<Vec<u8>> {
    let parser = session.parser();
    let mut screen = parser.screen().clone();
    let columns = screen.size().1;
    let mut out = vec![
        screen.contents_formatted(),
        screen.input_mode_formatted(),
        screen.cursor_state_formatted(),
        vec![u8::from(screen.alternate_screen())],
        format!("{:?} {:?}", parser.callbacks().keyboard.pushed(false), parser.callbacks().keyboard.pushed(true))
            .into_bytes(),
    ];
    screen.set_scrollback(usize::MAX);
    for back in (1..=screen.scrollback()).rev() {
        screen.set_scrollback(back);
        out.push(screen.rows_formatted(0, columns).next().unwrap_or_default());
    }
    out
}

/// A program that fills more than a screen, colours a word, turns modes on, and waits; a line
/// takes it to the alternate screen, and another one back.
const PROGRAM: &str = "i=1; while [ $i -le 30 ]; do printf 'line %s\\n' $i; i=$((i+1)); done; \
     printf '\\033[31mred\\033[m \\033[1mbold\\033[m'; \
     printf '\\033[?2004h\\033[?1000h\\033[?1006h\\033[?1004h\\033[>1u'; printf ' ready'; read a; \
     printf '\\033[?1049h\\033[2;3Halt screen\\033[>3u'; read b; \
     printf '\\033[?1049lback'; read c";

#[test]
fn a_snapshot_and_the_bytes_after_it_draw_what_the_program_draws() {
    // The process that keeps the program: what the program writes is relayed to the screen that
    // attaches, from the moment of its snapshot on.
    let relay: Arc<Mutex<Option<PipeWriter>>> = Arc::default();
    let tee = Arc::clone(&relay);
    let real = TerminalSession::builder("/bin/sh")
        .args(["-c", PROGRAM])
        .folder(Path::new("/"))
        .size(40, 10)
        .scrollback(100)
        .tee(move |bytes| {
            if let Some(relay) = lock(&tee).as_mut() {
                relay.write_all(bytes).expect("the attached screen reads");
            }
        })
        .spawn()
        .expect("pty");
    wait_for(&real, "ready");
    wait_quiet(&real);

    let (output, mut relayed) = io::pipe().expect("a pipe");
    {
        // The relay starts with the snapshot, and the program writes nothing in between.
        let mut relay = lock(&relay);
        relayed.write_all(&real.snapshot()).expect("the attached screen reads");
        *relay = Some(relayed);
    }
    let attached = TerminalSession::attach(output, Recording::default()).size(40, 10).scrollback(100).start();
    wait_for(&attached, "ready");
    assert_eq!(everything(&attached), everything(&real), "the same screen, scrollback, cursor and modes");

    // On to the alternate screen, which the snapshot did not see: the bytes that follow it draw it.
    real.write(b"\r").expect("the program reads");
    wait_for(&real, "alt screen");
    wait_for(&attached, "alt screen");
    wait_quiet(&real);
    assert_eq!(everything(&attached), everything(&real), "the alternate screen too");

    real.kill();
}

#[test]
fn a_snapshot_taken_on_the_alternate_screen_keeps_the_screen_beneath_it() {
    let relay: Arc<Mutex<Option<PipeWriter>>> = Arc::default();
    let tee = Arc::clone(&relay);
    let real = TerminalSession::builder("/bin/sh")
        .args(["-c", PROGRAM])
        .folder(Path::new("/"))
        .size(40, 10)
        .scrollback(100)
        .tee(move |bytes| {
            if let Some(relay) = lock(&tee).as_mut() {
                relay.write_all(bytes).expect("the attached screen reads");
            }
        })
        .spawn()
        .expect("pty");
    wait_for(&real, "ready");
    real.write(b"\r").expect("the program reads");
    wait_for(&real, "alt screen");
    wait_quiet(&real);

    let (output, mut relayed) = io::pipe().expect("a pipe");
    {
        let mut relay = lock(&relay);
        relayed.write_all(&real.snapshot()).expect("the attached screen reads");
        *relay = Some(relayed);
    }
    let attached = TerminalSession::attach(output, Recording::default()).size(40, 10).scrollback(100).start();
    wait_for(&attached, "alt screen");
    assert_eq!(everything(&attached), everything(&real), "the alternate screen as the program drew it");

    // Leaving it shows the screen beneath as it was, which only the snapshot carried.
    real.write(b"\r").expect("the program reads");
    wait_for(&real, "back");
    wait_for(&attached, "back");
    wait_quiet(&real);
    assert!(attached.parser().screen().contents().contains("bold"), "{}", attached.parser().screen().contents());
    assert_eq!(everything(&attached), everything(&real), "the screen beneath, its scrollback and its modes");

    real.kill();
}

/// A screen with an attached terminal on it, as a person sees it.
struct Screen {
    session: TerminalSession,
}

impl App for Screen {
    type Msg = ();
    fn update(&mut self, (): ()) -> Command<()> {
        Command::none()
    }
    fn view(&self, ui: &mut View<'_, ()>) {
        ui.add(Terminal::new(&self.session)).fill().id("terminal");
    }
}

#[test]
fn what_a_person_types_and_the_size_the_screen_takes_reach_the_carrier() {
    let (output, mut program) = io::pipe().expect("a pipe");
    let carrier = Recording::default();
    let asked = Arc::clone(&carrier.asked);
    let session = TerminalSession::attached(output, carrier);
    program.write_all(b"$ ").expect("the screen reads");
    wait_for(&session, "$");

    let mut h = Harness::new(Screen { session: session.clone() }, 50, 12);
    h.click_text("$").type_text("ls").press("enter");
    assert_eq!(lock(&asked).written, b"ls\r", "the keys went to the program through the carrier");

    // The widget asks for its own size, and the watch hands it on off the drawing thread.
    let watch = session.watch();
    let started = Instant::now();
    while lock(&asked).sizes.is_empty() {
        let _ = watch.next_change_within(Duration::from_millis(100));
        assert!(started.elapsed() < PATIENCE, "the size never reached the carrier");
    }
    assert_eq!(lock(&asked).sizes, [(50, 12)], "the screen's size, columns first");
    assert_eq!(lock(&asked).ended, 0);
}

#[test]
fn a_programs_question_is_left_to_the_session_it_runs_in() {
    // Where is the cursor, which terminal is this, which keyboard flags: a session here would
    // answer each of them, and the session where the program runs already has.
    let (output, mut program) = io::pipe().expect("a pipe");
    let carrier = Recording::default();
    let asked = Arc::clone(&carrier.asked);
    let session = TerminalSession::attached(output, carrier);
    program.write_all(b"\x1b[6n\x1b[c\x1b[?u\x1b]11;?\x1b\\asked").expect("the screen reads");
    wait_for(&session, "asked");
    assert!(lock(&asked).written.is_empty(), "nothing was written back: {:?}", lock(&asked).written);
}

#[test]
fn the_end_of_the_output_is_the_end_of_the_program_with_the_code_the_carrier_gives() {
    let carrier = Recording { code: Some(3), ..Recording::default() };
    let session = TerminalSession::attached(Cursor::new(b"bye".to_vec()), carrier);
    let watch = session.watch();
    let started = Instant::now();
    loop {
        match watch.next_change_within(Duration::from_millis(200)) {
            Some(TerminalChange::Exited(code)) => {
                assert_eq!(code, Some(3), "the code the carrier was told");
                break;
            }
            _ => assert!(started.elapsed() < PATIENCE, "the end never came"),
        }
    }
    assert!(session.parser().screen().contents().contains("bye"), "what it wrote before is drawn");
    assert!(session.write(b"x").is_err(), "a program that ended takes no keys");
}

#[test]
fn letting_the_screen_go_leaves_the_program_running_and_kill_ends_it() {
    let (output, _program) = io::pipe().expect("a pipe");
    let carrier = Recording::default();
    let asked = Arc::clone(&carrier.asked);
    let session = TerminalSession::attached(output, carrier);
    let kept = session.clone();
    drop(session);
    assert_eq!(lock(&asked).ended, 0, "a screen that goes is not a program that ends");

    kept.terminate(Duration::from_secs(1));
    assert_eq!(lock(&asked).ended, 1, "terminate asks the carrier, since the program is not here");
    assert_eq!(kept.pid(), None, "and there is no process id to signal");
    kept.kill();
    assert_eq!(lock(&asked).ended, 2);
    drop(kept);
    assert_eq!(lock(&asked).ended, 2, "and the last handle going asks nothing more");
}

#[test]
fn a_listener_hears_every_byte_the_program_writes_before_it_is_drawn() {
    let heard: Arc<Mutex<Vec<u8>>> = Arc::default();
    let into = Arc::clone(&heard);
    let session = TerminalSession::builder("/bin/sh")
        .args(["-c", "printf 'one\\033[31mtwo\\033[m'; read a"])
        .folder(Path::new("/"))
        .tee(move |bytes| lock(&into).extend_from_slice(bytes))
        .spawn()
        .expect("pty");
    wait_for(&session, "onetwo");
    let heard = String::from_utf8_lossy(&lock(&heard)).into_owned();
    assert!(heard.contains("one\x1b[31mtwo\x1b[m"), "the raw bytes, escapes and all: {heard:?}");
    session.kill();
}

#[test]
fn a_session_no_widget_shows_takes_the_size_a_screen_asks_for() {
    // The process that keeps the program has no widget to size the session; a screen that
    // attaches says how big it is, and the program and the screen kept for it both follow.
    let real = TerminalSession::builder("/bin/sh")
        .args(["-c", "printf 'ready'; read a; stty size; read b"])
        .folder(Path::new("/"))
        .spawn()
        .expect("pty");
    wait_for(&real, "ready");
    real.resize(91, 17);
    assert_eq!(real.size(), (91, 17), "the screen kept here");
    real.write(b"\r").expect("the program reads");
    wait_for(&real, "17 91");
    real.kill();

    let (output, _program) = io::pipe().expect("a pipe");
    let carrier = Recording::default();
    let asked = Arc::clone(&carrier.asked);
    let attached = TerminalSession::attached(output, carrier);
    attached.resize(70, 20);
    assert_eq!(attached.size(), (70, 20));
    assert_eq!(lock(&asked).sizes, [(70, 20)], "the carrier is told, columns first");
}