term-transcript 0.3.0

Snapshotting and snapshot testing for CLI / REPL applications
Documentation
//! Tests the full lifecycle of `Transcript`s.

use assert_matches::assert_matches;
use test_casing::{decorate, decorators::Retry, test_casing};
use tracing::{subscriber::DefaultGuard, Subscriber};
use tracing_capture::{CaptureLayer, CapturedSpan, SharedStorage, Storage};
use tracing_subscriber::{
    fmt::format::FmtSpan, layer::SubscriberExt, registry::LookupSpan, FmtSubscriber,
};

use std::{
    io,
    path::Path,
    process::{Command, Stdio},
    str::Utf8Error,
    time::Duration,
};

use term_transcript::{
    svg::{Template, TemplateOptions},
    ShellOptions, Transcript, UserInput,
};

fn create_fmt_subscriber() -> impl Subscriber + for<'a> LookupSpan<'a> {
    FmtSubscriber::builder()
        .pretty()
        .with_span_events(FmtSpan::CLOSE)
        .with_test_writer()
        .with_env_filter("term_transcript=debug")
        .finish()
}

fn enable_tracing() -> DefaultGuard {
    tracing::subscriber::set_default(create_fmt_subscriber())
}

fn enable_tracing_assertions() -> (DefaultGuard, SharedStorage) {
    let storage = SharedStorage::default();
    let subscriber = create_fmt_subscriber().with(CaptureLayer::new(&storage));
    let guard = tracing::subscriber::set_default(subscriber);
    (guard, storage)
}

#[cfg(unix)]
fn echo_command() -> Command {
    let mut command = Command::new("echo");
    command.arg("Hello, world!");
    command
}

#[cfg(windows)]
fn echo_command() -> Command {
    let mut command = Command::new("cmd");
    command.arg("/Q").arg("/C").arg("echo Hello, world!");
    command
}

#[test]
fn transcript_lifecycle() -> anyhow::Result<()> {
    let (_guard, tracing_storage) = enable_tracing_assertions();
    let mut transcript = Transcript::new();

    // 1. Capture output from a command.
    transcript.capture_output(
        UserInput::command("echo \"Hello, world!\""),
        &mut echo_command(),
    )?;
    assert_tracing_for_output_capture(&tracing_storage.lock());

    // 2. Render the transcript into SVG.
    let mut svg_buffer = vec![];
    Template::new(TemplateOptions::default()).render(&transcript, &mut svg_buffer)?;

    // 3. Parse SVG back to the transcript.
    let parsed = Transcript::from_svg(svg_buffer.as_slice())?;
    assert_eq!(parsed.interactions().len(), 1);
    let interaction = &parsed.interactions()[0];
    assert_eq!(
        *interaction.input(),
        UserInput::command("echo \"Hello, world!\"")
    );
    assert_tracing_for_parsing(&tracing_storage.lock());

    // 4. Compare output to the output in the original transcript.
    assert_eq!(
        interaction.output().plaintext(),
        transcript.interactions()[0].output().to_plaintext()?
    );
    assert_eq!(
        interaction.output().html(),
        transcript.interactions()[0].output().to_html()?
    );
    Ok(())
}

fn assert_tracing_for_output_capture(storage: &Storage) {
    let span = storage
        .root_spans()
        .find(|span| span.metadata().name() == "capture_output")
        .expect("`capture_output` span not found");
    assert!(span["command"].as_debug_str().is_some());
    assert_eq!(
        span["input.text"].as_debug_str(),
        Some(r#"echo "Hello, world!""#)
    );

    let output_event = span
        .events()
        .find(|event| event.message() == Some("read command output"))
        .expect("no output event");
    let output = output_event["output"].as_debug_str().unwrap();
    assert!(output.starts_with(r#""Hello, world"#));
    // ^ The output may have `\r\n` or `\n` ending depending on the OS, so we don't check it.
}

fn assert_tracing_for_parsing(storage: &Storage) {
    let span = storage
        .root_spans()
        .find(|span| span.metadata().name() == "from_svg")
        .expect("`from_svg` span not found");

    let interaction_event = span
        .events()
        .find(|event| event.message() == Some("parsed interaction"))
        .expect("new interaction event not found");
    assert!(interaction_event["interaction.input"]
        .is_debug(&UserInput::command(r#"echo "Hello, world!""#)));
    let output = interaction_event["interaction.output"]
        .as_debug_str()
        .unwrap();
    assert!(output.starts_with("\"Hello, world!"), "{output}");
}

const MUTE_OUTPUT_CASES: [&[bool]; 6] = [
    &[true],
    &[true, false],
    &[false, true],
    &[false, true, false],
    &[true, false, true],
    &[true, true, false, true],
];

#[test_casing(6, MUTE_OUTPUT_CASES)]
fn transcript_with_empty_output(mute_outputs: &[bool]) -> anyhow::Result<()> {
    #[cfg(unix)]
    const NULL_FILE: &str = "/dev/null";
    #[cfg(windows)]
    const NULL_FILE: &str = "NUL";

    let (_guard, tracing_storage) = enable_tracing_assertions();
    let inputs = mute_outputs.iter().map(|&mute| {
        if mute {
            UserInput::command(format!("echo \"Hello, world!\" > {NULL_FILE}"))
        } else {
            UserInput::command("echo \"Hello, world!\"")
        }
    });

    let mut shell_options = ShellOptions::default()
        .with_cargo_path()
        .with_io_timeout(Duration::from_millis(200));
    let transcript = Transcript::from_inputs(&mut shell_options, inputs)?;
    assert_tracing_for_transcript_from_inputs(&tracing_storage.lock());

    let mut svg_buffer = vec![];
    Template::new(TemplateOptions::default()).render(&transcript, &mut svg_buffer)?;
    let parsed = Transcript::from_svg(svg_buffer.as_slice())?;

    assert_eq!(parsed.interactions().len(), mute_outputs.len());

    for (interaction, &mute) in parsed.interactions().iter().zip(mute_outputs) {
        if mute {
            assert_eq!(interaction.output().plaintext(), "");
            assert_eq!(interaction.output().html(), "");
        } else {
            assert_ne!(interaction.output().plaintext(), "");
            assert_ne!(interaction.output().html(), "");
        }
    }
    Ok(())
}

fn assert_tracing_for_transcript_from_inputs(storage: &Storage) {
    let root_span = storage
        .root_spans()
        .find(|span| span.metadata().name() == "from_inputs")
        .expect("`from_inputs` span not found");
    assert!(root_span["options.io_timeout"].is_debug(&Duration::from_millis(200)));

    let spawn_shell_span = root_span
        .children()
        .find(|span| span.metadata().name() == "spawn_shell")
        .expect("`spawn_shell` span not found");
    let path_additions = spawn_shell_span["self.path_additions"]
        .as_debug_str()
        .unwrap();
    assert!(
        path_additions.starts_with('[') && path_additions.ends_with(']'),
        "{path_additions:?}"
    );

    root_span
        .children()
        .find(|span| span.metadata().name() == "push_init_commands")
        .expect("`push_init_commands` span not found");
    root_span
        .children()
        .find(|span| span.metadata().name() == "record_interaction")
        .expect("`record_interaction` spans not found");

    let written_lines = root_span.descendants().filter_map(|span| {
        if span.metadata().name() == "write_line" {
            span["line"].as_debug_str().map(str::to_owned)
        } else {
            None
        }
    });
    let written_lines: Vec<_> = written_lines.collect();
    assert!(
        written_lines
            .iter()
            .all(|line| line.starts_with("echo \"Hello, world!\"")),
        "{written_lines:?}"
    );
}

#[cfg(unix)]
#[test]
fn command_exit_status_in_sh() -> anyhow::Result<()> {
    let _guard = enable_tracing();
    let mut options = ShellOptions::sh();
    // ^ The error output is locale-specific and is not always UTF-8
    let inputs = [
        UserInput::command("echo \"Hello world!\""),
        UserInput::command("some-command-that-should-never-exist"),
    ];
    let transcript = Transcript::from_inputs(&mut options, inputs)?;

    let exit_status = transcript.interactions()[0].exit_status().unwrap();
    assert!(exit_status.is_success(), "{exit_status:?}");
    let exit_status = transcript.interactions()[1].exit_status().unwrap();
    assert!(!exit_status.is_success(), "{exit_status:?}");
    Ok(())
}

#[test]
#[decorate(Retry::times(3))] // PowerShell can be quite slow
fn command_exit_status_in_powershell() -> anyhow::Result<()> {
    fn powershell_exists() -> bool {
        let exit_status = Command::new("pwsh")
            .arg("-Help")
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
        matches!(exit_status, Ok(status) if status.success())
    }

    let (_guard, tracing_storage) = enable_tracing_assertions();
    if !powershell_exists() {
        println!("pwsh not found; exiting");
        return Ok(());
    }

    let mut options = ShellOptions::pwsh()
        .with_init_command("echo \"Hello world!\"")
        // ^ The first command executed by `pwsh` can take really long, so we warm up.
        .with_init_timeout(Duration::from_secs(3))
        .with_io_timeout(Duration::from_secs(1))
        .with_lossy_utf8_decoder();
    // ^ The error output is locale-specific and is not always UTF-8
    let inputs = [
        UserInput::command("echo \"Hello world!\""),
        UserInput::command("cargo what"),
    ];
    let transcript = Transcript::from_inputs(&mut options, inputs)?;

    let exit_status = transcript.interactions()[0].exit_status().unwrap();
    assert!(exit_status.is_success(), "{exit_status:?}");
    let exit_status = transcript.interactions()[1].exit_status().unwrap();
    assert!(!exit_status.is_success(), "{exit_status:?}");

    assert_tracing_for_powershell(&tracing_storage.lock());
    Ok(())
}

fn assert_tracing_for_powershell(storage: &Storage) {
    let echo_spans: Vec<_> = storage
        .all_spans()
        .filter(|span| span.metadata().name() == "read_echo")
        .collect();

    assert!(echo_spans
        .iter()
        .any(|span| span["input_line"].as_str() == Some("cargo what")));

    let received_line_events: Vec<_> = echo_spans
        .iter()
        .flat_map(CapturedSpan::events)
        .filter(|event| event.message() == Some("received line"))
        .collect();
    assert_eq!(received_line_events.len(), echo_spans.len());
    for event in &received_line_events {
        assert!(event["line_utf8"].as_str().is_some());
    }
}

/// The default `cmd` codepage can lead to non-UTF8 output for builtin commands
/// (e.g., `dir` may output non-breakable space in file sizes as 0xff).
/// Here, we test that the codepage is switched to UTF-8.
#[cfg(windows)]
#[test]
fn cmd_shell_with_non_utf8_output() {
    let _guard = enable_tracing();
    let input = UserInput::command(format!("dir {}", env!("CARGO_MANIFEST_DIR")));
    let transcript = Transcript::from_inputs(&mut ShellOptions::default(), vec![input]).unwrap();

    assert_eq!(transcript.interactions().len(), 1);
    let output = transcript.interactions()[0].output().as_ref();
    assert!(output.contains("LICENSE-APACHE"));
    assert!(!output.contains('\r'));
}

#[cfg(all(windows, feature = "portable-pty"))]
#[test]
fn cmd_shell_with_utf8_output_in_pty() {
    use term_transcript::PtyCommand;

    let _guard = enable_tracing();
    let input = UserInput::command(format!("dir {}", env!("CARGO_MANIFEST_DIR")));
    let mut options = ShellOptions::new(PtyCommand::default());
    let transcript = Transcript::from_inputs(&mut options, vec![input]).unwrap();

    assert_eq!(transcript.interactions().len(), 1);
    let output = transcript.interactions()[0].output().as_ref();
    assert!(output.contains("LICENSE-APACHE"));
    assert!(output.lines().all(|line| !line.ends_with('\r')));

    // Check that the captured output can be rendered.
    Template::new(TemplateOptions::default())
        .render(&transcript, &mut vec![])
        .unwrap();
}

#[test_casing(2, [false, true])]
fn non_utf8_shell_output(lossy: bool) -> anyhow::Result<()> {
    #[cfg(unix)]
    const CAT_COMMAND: &str = "cat";
    #[cfg(windows)]
    const CAT_COMMAND: &str = "type";

    let _guard = enable_tracing();
    let non_utf8_file = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("non-utf8.txt");
    let input = UserInput::command(format!(
        "{CAT_COMMAND} \"{}\"",
        non_utf8_file.to_string_lossy()
    ));

    let mut options = ShellOptions::default();
    if lossy {
        options = options.with_lossy_utf8_decoder();
    }

    let result = Transcript::from_inputs(&mut options, vec![input]);
    if lossy {
        let transcript = result.unwrap();
        let output = transcript.interactions()[0].output();
        assert!(output.to_plaintext()?.contains(char::REPLACEMENT_CHARACTER));
    } else {
        let err = result.unwrap_err();
        assert_matches!(err.kind(), io::ErrorKind::InvalidData);
        assert!(err.get_ref().unwrap().is::<Utf8Error>(), "{err:?}");
    }
    Ok(())
}