termlens 0.11.0

Headless PTY test harness for CLI/TUI apps — spawn in a real PTY, assert on the rendered screen
Documentation
//! Retained scrollback: content that has scrolled off the top of the
//! screen stays assertable, and the bound on how much is pinned rather
//! than assumed.

use std::time::Duration;

use termlens::{Error, Key, Terminal};

mod common;

/// The `emit` fixture printing `count` numbered lines on a `rows`-row
/// screen, then parking so the terminal stays alive.
fn numbered(rows: u16, count: usize, scrollback: usize) -> termlens::Result<Terminal> {
    let lines: String = (1..=count).map(|i| format!("line-{i}\n")).collect();
    common::spawn_emit(
        Terminal::builder()
            .size(40, rows)
            .scrollback(scrollback)
            .timeout(Duration::from_secs(10)),
        &[&lines, "READY", "--wait"],
    )
}

#[test]
fn content_scrolled_off_the_top_is_still_assertable() -> termlens::Result<()> {
    let mut t = numbered(6, 40, 1000)?;
    t.wait_until(|s| s.contains("READY"))?;

    let s = t.screen();
    // Gone from the visible screen...
    assert!(
        !s.contains("line-1\n"),
        "line-1 should have scrolled off:\n{s}"
    );
    // ...and still there in the history.
    assert!(
        s.scrollback_text().contains("line-1\n"),
        "history:\n{}",
        s.scrollback_text()
    );
    assert!(s.full_text().contains("line-1\n"));
    assert!(s.full_text().contains("line-40"));
    assert!(s.scrollback_rows() >= 34, "rows: {}", s.scrollback_rows());

    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// The assertion an author writes when the application moves content
/// between the live region and native scrollback as it goes: "this
/// reached the terminal", without the test having to know which region it
/// currently sits in.
#[test]
fn full_text_answers_without_knowing_which_region_holds_it() -> termlens::Result<()> {
    // Ten lines on a twelve-row screen: everything is still visible.
    let mut t = numbered(12, 10, 1000)?;
    t.wait_until(|s| s.contains("READY"))?;
    let s = t.screen();
    assert_eq!(s.scrollback_rows(), 0, "nothing has scrolled yet");
    assert!(s.full_text().contains("line-1\n"));
    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());

    // Same assertion, same content, now past the top of a smaller screen.
    let mut t = numbered(4, 10, 1000)?;
    t.wait_until(|s| s.contains("READY"))?;
    let s = t.screen();
    assert!(s.scrollback_rows() > 0, "some rows must have scrolled");
    assert!(s.full_text().contains("line-1\n"));
    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// The bound is real: past it, the oldest rows are gone. This is the
/// limit that replaces "scrolled-off output is unrecoverable" — the
/// feature exists, and what it cannot do is stated.
#[test]
fn the_retention_bound_drops_the_oldest_rows() -> termlens::Result<()> {
    let mut t = numbered(4, 60, 10)?;
    t.wait_until(|s| s.contains("READY"))?;

    let s = t.screen();
    assert_eq!(s.scrollback_rows(), 10, "bounded at the configured length");
    assert!(
        !s.full_text().contains("line-1\n"),
        "line-1 is far past the bound:\n{}",
        s.full_text()
    );
    // The newest retained rows are there; the visible screen holds the rest.
    assert!(s.full_text().contains("line-60"));

    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

#[test]
fn retention_can_be_switched_off() -> termlens::Result<()> {
    let mut t = numbered(4, 20, 0)?;
    t.wait_until(|s| s.contains("READY"))?;

    let s = t.screen();
    assert_eq!(s.scrollback_rows(), 0);
    assert_eq!(s.scrollback_text(), "");
    assert_eq!(s.full_text(), s.text(), "full_text is then just the screen");
    assert!(!s.full_text().contains("line-1\n"));

    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// History is snapshot state, so it obeys snapshot rules — which makes it
/// usable in a wait predicate.
#[test]
fn history_is_observable_from_a_predicate() -> termlens::Result<()> {
    let mut t = common::spawn_emit(
        Terminal::builder()
            .size(40, 3)
            .scrollback(100)
            .timeout(Duration::from_secs(10)),
        &["committed-block\n", "a\nb\nc\nd\n", "--wait"],
    )?;

    // The block is asserted on *after* it has left the screen, from inside
    // the wait itself.
    t.wait_until(|s| s.scrollback_text().contains("committed-block"))?;
    assert!(!t.screen().contains("committed-block"));

    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// The most-copied line in the docs, on text that scrolled away in the same
/// burst: the wait can never succeed, and the screen in the error does not
/// show the text either — so it used to read as "the app never printed it".
/// The error now says that rows are off the top, and where they can be seen.
#[test]
fn a_wait_on_scrolled_off_text_names_the_history_in_its_error() -> termlens::Result<()> {
    let mut t = numbered(3, 10, 100)?;
    t.wait_until(|s| s.contains("READY"))?;
    let scrolled = t.screen().scrollback_rows();
    assert!(scrolled > 1, "the setup must have scrolled: {}", t.screen());

    // "line-1" is in history; `contains` reads the grid alone.
    let err = t
        .wait_until_for(|s| s.contains("line-1\n"), Duration::from_millis(300))
        .expect_err("the text is in history, not on the grid");
    assert!(matches!(err, Error::Timeout { .. }), "got: {err}");
    let msg = err.to_string();
    assert!(
        msg.contains(&format!("{scrolled} rows have scrolled off the top")),
        "{msg}"
    );
    assert!(msg.contains("full_text"), "the remedy is named: {msg}");
    // And the claim checks out against the very screen the error carries.
    assert!(err.screen().unwrap().full_text().contains("line-1\n"));

    // The EOF path carries the same note.
    t.send(Key::Enter)?;
    let err = t
        .wait_until(|s| s.contains("line-1\n"))
        .expect_err("the child has exited");
    assert!(matches!(err, Error::Eof { .. }), "got: {err}");
    assert!(err.to_string().contains("scrolled off the top"), "{err}");
    Ok(())
}

/// No history, no note: an application that owns its viewport is never told
/// about rows it does not have.
#[test]
fn a_wait_with_nothing_scrolled_carries_no_history_note() -> termlens::Result<()> {
    let mut t = numbered(12, 3, 100)?;
    t.wait_until(|s| s.contains("READY"))?;
    assert_eq!(t.screen().scrollback_rows(), 0);
    let err = t
        .wait_until_for(|s| s.contains("absent"), Duration::from_millis(200))
        .expect_err("never printed");
    assert!(!err.to_string().contains("scrolled off"), "{err}");
    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}