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();
transcript.capture_output(
UserInput::command("echo \"Hello, world!\""),
&mut echo_command(),
)?;
assert_tracing_for_output_capture(&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(), 1);
let interaction = &parsed.interactions()[0];
assert_eq!(
*interaction.input(),
UserInput::command("echo \"Hello, world!\"")
);
assert_tracing_for_parsing(&tracing_storage.lock());
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"#));
}
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();
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))] 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!\"")
.with_init_timeout(Duration::from_secs(3))
.with_io_timeout(Duration::from_secs(1))
.with_lossy_utf8_decoder();
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());
}
}
#[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')));
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(())
}