From c8b86e2ee3ff82e520afd617d66b2274523897dd Mon Sep 17 00:00:00 2001 From: Alex Ostrovski Date: Fri, 15 May 2026 12:35:58 +0300 Subject: [PATCH 1/5] Sketch testing captured snapshots --- crates/term-transcript-cli/src/main.rs | 4 +- crates/term-transcript-cli/tests/e2e.rs | 2 +- .../term-transcript/src/test/config_impl.rs | 223 +++++++++--------- crates/term-transcript/src/test/mod.rs | 58 ++++- crates/term-transcript/src/test/tests.rs | 7 +- e2e-tests/rainbow/tests/integration.rs | 29 ++- 6 files changed, 195 insertions(+), 128 deletions(-) diff --git a/crates/term-transcript-cli/src/main.rs b/crates/term-transcript-cli/src/main.rs index 01249bbc..f9087169 100644 --- a/crates/term-transcript-cli/src/main.rs +++ b/crates/term-transcript-cli/src/main.rs @@ -15,7 +15,7 @@ use anyhow::Context; use clap::{Parser, Subcommand, ValueEnum}; use styled_str::StyledString; use term_transcript::{ - Transcript, UserInput, + ShellOptions, Transcript, UserInput, test::{MatchKind, TestConfig, TestOutputConfig, TestStats}, traits::SpawnShell, }; @@ -203,7 +203,7 @@ impl Command { fn process_file( svg_path: &Path, - test_config: &mut TestConfig, + test_config: &mut TestConfig>, ) -> anyhow::Result { let svg = BufReader::new(File::open(svg_path)?); let transcript = Transcript::from_svg(svg)?; diff --git a/crates/term-transcript-cli/tests/e2e.rs b/crates/term-transcript-cli/tests/e2e.rs index 20988793..1181796f 100644 --- a/crates/term-transcript-cli/tests/e2e.rs +++ b/crates/term-transcript-cli/tests/e2e.rs @@ -25,7 +25,7 @@ fn svg_snapshot(name: &str) -> PathBuf { // ANCHOR: config // Executes commands in a temporary dir, with paths to the `term-transcript` binary and // the `rainbow` script added to PATH. -fn test_config() -> (TestConfig, TempDir) { +fn test_config() -> (TestConfig>, TempDir) { let temp_dir = tempdir().expect("cannot create temporary directory"); let rainbow_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../e2e-tests/rainbow/bin"); diff --git a/crates/term-transcript/src/test/config_impl.rs b/crates/term-transcript/src/test/config_impl.rs index 6e6a408c..38b0a627 100644 --- a/crates/term-transcript/src/test/config_impl.rs +++ b/crates/term-transcript/src/test/config_impl.rs @@ -12,10 +12,10 @@ use anstyle::{Ansi256Color, AnsiColor, Color, Style}; use styled_str::{StyleDiff, TextDiff}; use super::{ - MatchKind, TestConfig, TestOutputConfig, TestStats, + MatchKind, TestCommand, TestConfig, TestOutputConfig, TestStats, utils::{ChoiceWriter, IndentingWriter, PrintlnWriter}, }; -use crate::{Interaction, Transcript, UserInput, traits::SpawnShell}; +use crate::{Interaction, ShellOptions, Transcript, UserInput, traits::SpawnShell}; const SUCCESS: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightGreen))); const ERROR: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightRed))); @@ -100,56 +100,9 @@ pub fn compare_transcripts( Ok(stats) } -impl TestConfig { - /// Tests a snapshot at the specified path with the provided inputs. - /// - /// If the path is relative, it is resolved relative to the current working dir, - /// which in the case of tests is the root directory of the including crate (i.e., the dir - /// where the crate manifest is located). You may specify an absolute path - /// using env vars that Cargo sets during build, such as [`env!("CARGO_MANIFEST_DIR")`]. - /// - /// Similar to other kinds of snapshot testing, a new snapshot will be generated if - /// there is no existing snapshot or there are mismatches between inputs or outputs - /// in the original and reproduced transcripts. This new snapshot will have the same path - /// as the original snapshot, but with the `.new.svg` extension. As an example, - /// if the snapshot at `snapshots/help.svg` is tested, the new snapshot will be saved at - /// `snapshots/help.new.svg`. - /// - /// Generation of new snapshots will only happen if the `svg` crate feature is enabled - /// (which it is by default), and if the [update mode](Self::with_update_mode()) - /// is not [`UpdateMode::Never`], either because it was set explicitly or - /// [inferred] from the execution environment. - /// - /// The snapshot template can be customized via [`Self::with_template()`]. - /// - /// # Panics - /// - /// - Panics if there is no snapshot at the specified path, or if the path points - /// to a directory. - /// - Panics if an error occurs during reproducing the transcript or processing - /// its output. - /// - Panics if there are mismatches between inputs or outputs in the original and reproduced - /// transcripts. - /// - /// [`env!("CARGO_MANIFEST_DIR")`]: https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates - /// [`UpdateMode::Never`]: crate::test::UpdateMode::Never - /// [inferred]: crate::test::UpdateMode::from_env() - #[cfg_attr( - feature = "tracing", - tracing::instrument(skip_all, fields(snapshot_path, inputs)) - )] - pub fn test>( - &mut self, - snapshot_path: impl AsRef, - inputs: impl IntoIterator, - ) { - let inputs: Vec<_> = inputs.into_iter().map(Into::into).collect(); - let snapshot_path = snapshot_path.as_ref(); - #[cfg(feature = "tracing")] - tracing::Span::current() - .record("snapshot_path", tracing::field::debug(snapshot_path)) - .record("inputs", tracing::field::debug(&inputs)); - +impl TestConfig { + #[cfg_attr(feature = "tracing", tracing::instrument(name = "test", skip(self)))] + fn test_inner(&mut self, snapshot_path: &Path, input: Cmd::Inputs) { if snapshot_path.is_file() { #[cfg(feature = "tracing")] tracing::debug!(snapshot_path.is_file = true); @@ -164,7 +117,7 @@ impl TestConfig snapshot_path.display() ); }); - self.compare_and_test_transcript(snapshot_path, &transcript, &inputs); + self.compare_and_test_transcript(snapshot_path, &transcript, input); } else if snapshot_path.exists() { panic!( "Snapshot path `{}` exists, but is not a file", @@ -174,8 +127,7 @@ impl TestConfig #[cfg(feature = "tracing")] tracing::debug!(snapshot_path.is_file = false); - let new_snapshot_message = - self.create_and_write_new_snapshot(snapshot_path, inputs.into_iter()); + let new_snapshot_message = self.create_and_write_new_snapshot(snapshot_path, input); panic!( "Snapshot `{}` is missing\n{new_snapshot_message}", snapshot_path.display() @@ -185,23 +137,24 @@ impl TestConfig #[cfg_attr( feature = "tracing", - tracing::instrument(level = "debug", skip(self, transcript)) + tracing::instrument(level = "debug", skip(self, parsed)) )] fn compare_and_test_transcript( &mut self, snapshot_path: &Path, - transcript: &Transcript, - expected_inputs: &[UserInput], + parsed: &Transcript, + input: Cmd::Inputs, ) { - let actual_inputs: Vec<_> = transcript + let actual_inputs: Vec<_> = parsed .interactions() .iter() .map(Interaction::input) .collect(); + let expected_inputs = Cmd::extract_inputs(&input); - if !actual_inputs.iter().copied().eq(expected_inputs) { - let new_snapshot_message = - self.create_and_write_new_snapshot(snapshot_path, expected_inputs.iter().cloned()); + if !actual_inputs.iter().copied().eq(expected_inputs.as_ref()) { + let expected_inputs = expected_inputs.into_owned(); + let new_snapshot_message = self.create_and_write_new_snapshot(snapshot_path, input); panic!( "Unexpected user inputs in parsed snapshot: expected {expected_inputs:?}, \ got {actual_inputs:?}\n{new_snapshot_message}" @@ -209,7 +162,7 @@ impl TestConfig } let (stats, reproduced) = self - .test_transcript_for_stats(transcript) + .test_transcript_with_input(parsed, input) .unwrap_or_else(|err| panic!("{err}")); if stats.errors(self.match_kind) > 0 { let new_snapshot_message = self.write_new_snapshot(snapshot_path, &reproduced); @@ -217,20 +170,51 @@ impl TestConfig } } - #[cfg(feature = "svg")] - #[cfg_attr( - feature = "tracing", - tracing::instrument(level = "debug", skip(self, inputs)) - )] - fn create_and_write_new_snapshot( + fn test_transcript_with_input( &mut self, - path: &Path, - inputs: impl Iterator, - ) -> String { - let mut reproduced = Transcript::from_inputs(&mut self.shell_options, inputs) - .unwrap_or_else(|err| { - panic!("Cannot create a snapshot `{}`: {err}", path.display()); - }); + parsed: &Transcript, + input: Cmd::Inputs, + ) -> io::Result<(TestStats, Transcript)> { + if self.output == TestOutputConfig::Quiet { + self.test_transcript_inner(&mut io::sink(), parsed, input) + } else { + let choice = if self.color_choice == ColorChoice::Auto { + AutoStream::choice(&io::stdout()) + } else { + self.color_choice + }; + // We cannot create an `AutoStream` here because it would require `PrintlnWriter` to implement `anstream::RawStream`, + // which is a sealed trait. + let mut out = ChoiceWriter::new(PrintlnWriter::default(), choice); + self.test_transcript_inner(&mut out, parsed, input) + } + } + + pub(super) fn test_transcript_inner( + &mut self, + out: &mut impl Write, + parsed: &Transcript, + input: Cmd::Inputs, + ) -> io::Result<(TestStats, Transcript)> { + let mut reproduced = self.command.reproduce(input)?; + (self.transform)(&mut reproduced); + + let stats = compare_transcripts( + out, + parsed, + &reproduced, + self.match_kind, + self.output == TestOutputConfig::Verbose, + )?; + Ok((stats, reproduced)) + } + + #[cfg(feature = "svg")] + #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self)))] + fn create_and_write_new_snapshot(&mut self, path: &Path, inputs: Cmd::Inputs) -> String { + let mut reproduced = self.command.reproduce(inputs).unwrap_or_else(|err| { + panic!("Cannot create a snapshot `{}`: {err}", path.display()); + }); (self.transform)(&mut reproduced); self.write_new_snapshot(path, &reproduced) } @@ -286,6 +270,58 @@ impl TestConfig env!("CARGO_PKG_NAME") ) } +} + +impl TestConfig<(), F> { + /// FIXME + pub fn test_captured(&mut self, snapshot_path: impl AsRef, captured: Transcript) { + self.test_inner(snapshot_path.as_ref(), captured); + } +} + +impl TestConfig, F> { + /// Tests a snapshot at the specified path with the provided inputs. + /// + /// If the path is relative, it is resolved relative to the current working dir, + /// which in the case of tests is the root directory of the including crate (i.e., the dir + /// where the crate manifest is located). You may specify an absolute path + /// using env vars that Cargo sets during build, such as [`env!("CARGO_MANIFEST_DIR")`]. + /// + /// Similar to other kinds of snapshot testing, a new snapshot will be generated if + /// there is no existing snapshot or there are mismatches between inputs or outputs + /// in the original and reproduced transcripts. This new snapshot will have the same path + /// as the original snapshot, but with the `.new.svg` extension. As an example, + /// if the snapshot at `snapshots/help.svg` is tested, the new snapshot will be saved at + /// `snapshots/help.new.svg`. + /// + /// Generation of new snapshots will only happen if the `svg` crate feature is enabled + /// (which it is by default), and if the [update mode](Self::with_update_mode()) + /// is not [`UpdateMode::Never`], either because it was set explicitly or + /// [inferred] from the execution environment. + /// + /// The snapshot template can be customized via [`Self::with_template()`]. + /// + /// # Panics + /// + /// - Panics if there is no snapshot at the specified path, or if the path points + /// to a directory. + /// - Panics if an error occurs during reproducing the transcript or processing + /// its output. + /// - Panics if there are mismatches between inputs or outputs in the original and reproduced + /// transcripts. + /// + /// [`env!("CARGO_MANIFEST_DIR")`]: https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates + /// [`UpdateMode::Never`]: crate::test::UpdateMode::Never + /// [inferred]: crate::test::UpdateMode::from_env() + pub fn test>( + &mut self, + snapshot_path: impl AsRef, + inputs: impl IntoIterator, + ) { + let input: Vec<_> = inputs.into_iter().map(Into::into).collect(); + let snapshot_path = snapshot_path.as_ref(); + self.test_inner(snapshot_path, input); + } /// Tests the `transcript`. This is a lower-level alternative to [`Self::test()`]. /// @@ -313,41 +349,12 @@ impl TestConfig pub fn test_transcript_for_stats( &mut self, transcript: &Transcript, - ) -> io::Result<(TestStats, Transcript)> { - if self.output == TestOutputConfig::Quiet { - self.test_transcript_inner(&mut io::sink(), transcript) - } else { - let choice = if self.color_choice == ColorChoice::Auto { - AutoStream::choice(&io::stdout()) - } else { - self.color_choice - }; - // We cannot create an `AutoStream` here because it would require `PrintlnWriter` to implement `anstream::RawStream`, - // which is a sealed trait. - let mut out = ChoiceWriter::new(PrintlnWriter::default(), choice); - self.test_transcript_inner(&mut out, transcript) - } - } - - pub(super) fn test_transcript_inner( - &mut self, - out: &mut impl Write, - transcript: &Transcript, ) -> io::Result<(TestStats, Transcript)> { let inputs = transcript .interactions() .iter() - .map(|interaction| interaction.input().clone()); - let mut reproduced = Transcript::from_inputs(&mut self.shell_options, inputs)?; - (self.transform)(&mut reproduced); - - let stats = compare_transcripts( - out, - transcript, - &reproduced, - self.match_kind, - self.output == TestOutputConfig::Verbose, - )?; - Ok((stats, reproduced)) + .map(|interaction| interaction.input().clone()) + .collect(); + self.test_transcript_with_input(transcript, inputs) } } diff --git a/crates/term-transcript/src/test/mod.rs b/crates/term-transcript/src/test/mod.rs index 4b2bfd54..13b86826 100644 --- a/crates/term-transcript/src/test/mod.rs +++ b/crates/term-transcript/src/test/mod.rs @@ -53,7 +53,7 @@ //! # } //! ``` -use std::process::Command; +use std::{borrow::Cow, fmt, io, process::Command}; #[cfg(feature = "svg")] use std::{env, ffi::OsStr}; @@ -65,7 +65,7 @@ pub use self::{ }; #[cfg(feature = "svg")] use crate::svg::Template; -use crate::{ShellOptions, Transcript, traits::SpawnShell}; +use crate::{ShellOptions, Transcript, UserInput, traits::SpawnShell}; mod config_impl; mod parser; @@ -151,14 +151,54 @@ impl UpdateMode { } } +/// FIXME +pub trait TestCommand { + #[doc(hidden)] // implementation detail + type Inputs: fmt::Debug; + + #[doc(hidden)] // implementation detail + fn extract_inputs(inputs: &Self::Inputs) -> Cow<'_, [UserInput]>; + + #[doc(hidden)] // implementation detail + fn reproduce(&mut self, inputs: Self::Inputs) -> io::Result; +} + +impl TestCommand for ShellOptions { + type Inputs = Vec; + + fn extract_inputs(inputs: &Self::Inputs) -> Cow<'_, [UserInput]> { + Cow::Borrowed(inputs) + } + + fn reproduce(&mut self, inputs: Self::Inputs) -> io::Result { + Transcript::from_inputs(self, inputs) + } +} + +impl TestCommand for () { + type Inputs = Transcript; + + fn extract_inputs(inputs: &Self::Inputs) -> Cow<'_, [UserInput]> { + let inputs = inputs + .interactions() + .iter() + .map(|interaction| interaction.input().clone()); + Cow::Owned(inputs.collect()) + } + + fn reproduce(&mut self, inputs: Self::Inputs) -> io::Result { + Ok(inputs) + } +} + /// Testing configuration. /// /// # Examples /// /// See the [module docs](crate::test) for the examples of usage. #[derive(Debug)] -pub struct TestConfig { - shell_options: ShellOptions, +pub struct TestConfig, F = fn(&mut Transcript)> { + command: Cmd, match_kind: MatchKind, output: TestOutputConfig, color_choice: ColorChoice, @@ -169,16 +209,16 @@ pub struct TestConfig { transform: F, } -impl TestConfig { +impl TestConfig { /// Creates a new config. /// /// # Panics /// /// - Panics if the `svg` crate feature is enabled and the `TERM_TRANSCRIPT_UPDATE` variable /// is set to an incorrect value. See [`UpdateMode::from_env()`] for more details. - pub fn new(shell_options: ShellOptions) -> Self { + pub fn new(command: Cmd) -> Self { Self { - shell_options, + command, match_kind: MatchKind::TextOnly, output: TestOutputConfig::Normal, color_choice: ColorChoice::Auto, @@ -198,7 +238,7 @@ impl TestConfig { F: FnMut(&mut Transcript), { TestConfig { - shell_options: self.shell_options, + command: self.command, match_kind: self.match_kind, output: self.output, color_choice: self.color_choice, @@ -211,7 +251,7 @@ impl TestConfig { } } -impl TestConfig { +impl TestConfig { /// Sets the matching kind applied. #[must_use] pub fn with_match_kind(mut self, kind: MatchKind) -> Self { diff --git a/crates/term-transcript/src/test/tests.rs b/crates/term-transcript/src/test/tests.rs index 11520350..4c2bd8c5 100644 --- a/crates/term-transcript/src/test/tests.rs +++ b/crates/term-transcript/src/test/tests.rs @@ -32,8 +32,13 @@ fn test_negative_snapshot_testing(test_config: &mut TestConfig) -> anyhow::Resul Template::default().render(&transcript, &mut svg_buffer)?; let parsed = Transcript::from_svg(svg_buffer.as_slice())?; + let inputs = parsed + .interactions() + .iter() + .map(|interaction| interaction.input().clone()) + .collect(); let mut out = StripStream::new(vec![]); - let (stats, _) = test_config.test_transcript_inner(&mut out, &parsed)?; + let (stats, _) = test_config.test_transcript_inner(&mut out, &parsed, inputs)?; assert_eq!(stats.errors(MatchKind::TextOnly), 1); String::from_utf8(out.into_inner()).map_err(Into::into) } diff --git a/e2e-tests/rainbow/tests/integration.rs b/e2e-tests/rainbow/tests/integration.rs index 07d67475..fb7287fa 100644 --- a/e2e-tests/rainbow/tests/integration.rs +++ b/e2e-tests/rainbow/tests/integration.rs @@ -9,11 +9,12 @@ use std::{ }; use handlebars::Template as HandlebarsTemplate; +use styled_str::StyledString; use tempfile::tempdir; #[cfg(feature = "portable-pty")] use term_transcript::PtyCommand; use term_transcript::{ - ExitStatus, ShellOptions, Transcript, UserInput, + ExitStatus, Interaction, ShellOptions, Transcript, UserInput, svg::{NamedPalette, Template, TemplateOptions, ValidTemplateOptions}, test::{MatchKind, TestConfig, TestOutputConfig, UpdateMode, compare_transcripts}, }; @@ -61,7 +62,7 @@ fn aliased_snapshot_path() -> &'static Path { fn main_snapshot_can_be_rendered(pure_svg: bool) -> anyhow::Result<()> { let mut shell_options = ShellOptions::default().with_additional_path(rainbow_dir()); let mut transcript = - Transcript::from_inputs(&mut shell_options, vec![UserInput::command("rainbow")])?; + Transcript::from_inputs(&mut shell_options, [UserInput::command("rainbow")])?; // Patch the exit status for cross-platform compatibility. transcript.interactions_mut()[0].set_exit_status(Some(ExitStatus(0))); @@ -112,8 +113,7 @@ fn main_snapshot_can_be_rendered(pure_svg: bool) -> anyhow::Result<()> { #[test] fn snapshot_with_custom_template() -> anyhow::Result<()> { let mut shell_options = ShellOptions::default().with_additional_path(rainbow_dir()); - let transcript = - Transcript::from_inputs(&mut shell_options, vec![UserInput::command("rainbow")])?; + let transcript = Transcript::from_inputs(&mut shell_options, [UserInput::command("rainbow")])?; let template = read_custom_template()?; let template_options = TemplateOptions { @@ -137,8 +137,7 @@ fn snapshot_with_custom_template() -> anyhow::Result<()> { fn main_snapshot_can_be_rendered_from_pty(pure_svg: bool) -> anyhow::Result<()> { let mut shell_options = ShellOptions::new(PtyCommand::default()).with_additional_path(rainbow_dir()); - let transcript = - Transcript::from_inputs(&mut shell_options, vec![UserInput::command("rainbow")])?; + let transcript = Transcript::from_inputs(&mut shell_options, [UserInput::command("rainbow")])?; let template = if pure_svg { Template::pure_svg(ValidTemplateOptions::default()) } else { @@ -156,7 +155,7 @@ fn snapshot_with_long_lines_can_be_rendered_from_pty(pure_svg: bool) -> anyhow:: ShellOptions::new(PtyCommand::default()).with_additional_path(rainbow_dir()); let transcript = Transcript::from_inputs( &mut shell_options, - vec![UserInput::command("rainbow --long-lines")], + [UserInput::command("rainbow --long-lines")], )?; let interaction = &transcript.interactions()[0]; @@ -194,6 +193,22 @@ fn snapshot_testing(pure_svg: bool) { config.test(main_snapshot_path(), ["rainbow"]); } +#[test_casing(2, [false, true])] +#[decorate(TRACING)] +fn snapshot_testing_with_captured_input(pure_svg: bool) { + let mut config = TestConfig::new(()); + if pure_svg { + config = config.with_template(Template::pure_svg(ValidTemplateOptions::default())); + } + + let output = fs::read_to_string(rainbow_dir().join("rainbow.out")).unwrap(); + let interaction = Interaction::new("rainbow", StyledString::from_ansi(&output).unwrap()) + .with_exit_status(ExitStatus(0)); + let mut transcript = Transcript::default(); + transcript.add_existing_interaction(interaction); + config.test_captured(main_snapshot_path(), transcript); +} + #[cfg(feature = "portable-pty")] #[test_casing(2, [false, true])] #[decorate(TRACING)] From 5f23d8e2bf044fe095066480e4d4343076622bd8 Mon Sep 17 00:00:00 2001 From: Alex Ostrovski Date: Fri, 15 May 2026 13:01:24 +0300 Subject: [PATCH 2/5] Document testing captured snapshots --- .../term-transcript/src/test/config_impl.rs | 5 +++- crates/term-transcript/src/test/mod.rs | 30 ++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/term-transcript/src/test/config_impl.rs b/crates/term-transcript/src/test/config_impl.rs index 38b0a627..ae26c8c9 100644 --- a/crates/term-transcript/src/test/config_impl.rs +++ b/crates/term-transcript/src/test/config_impl.rs @@ -273,7 +273,10 @@ impl TestConfig { } impl TestConfig<(), F> { - /// FIXME + /// Tests a snapshot at the specified path against the provided captured snapshot. + /// + /// This method is similar to [`TestConfig::test()`], but it allows to fully customize to how the snapshot + /// is reproduced for the test. pub fn test_captured(&mut self, snapshot_path: impl AsRef, captured: Transcript) { self.test_inner(snapshot_path.as_ref(), captured); } diff --git a/crates/term-transcript/src/test/mod.rs b/crates/term-transcript/src/test/mod.rs index 13b86826..a08b9ae9 100644 --- a/crates/term-transcript/src/test/mod.rs +++ b/crates/term-transcript/src/test/mod.rs @@ -27,6 +27,8 @@ //! } //! ``` //! +//! ## Lower-level testing +//! //! Use [`TestConfig::test_transcript()`] for more complex scenarios or increased control: //! //! ``` @@ -52,6 +54,23 @@ //! # Ok(()) //! # } //! ``` +//! +//! ## Testing with custom capture logic +//! +//! Use [`TestConfig::test_captured()`] if you need to customize snapshot capture logic. +//! +//! ```no_run +//! use term_transcript::{test::TestConfig, Transcript, UserInput}; +//! +//! #[test] +//! fn captured_snapshot() { +//! let mut captured = Transcript::default(); +//! let test_output = "result: [[bold green!]]OK[[/]]".parse().unwrap(); +//! captured.add_interaction(UserInput::command("test").hide(), test_output); +//! TestConfig::new(()) +//! .test_captured("tests/__snapshots__/test.svg", captured); +//! } +//! ``` use std::{borrow::Cow, fmt, io, process::Command}; #[cfg(feature = "svg")] @@ -151,7 +170,16 @@ impl UpdateMode { } } -/// FIXME +/// Command executed during snapshot testing in [`TestConfig`] to reproduce a snapshot. +/// +/// Two provided implementations are: +/// +/// - [`ShellOptions`], which reproduces snapshots based on [`UserInput`]s and the provided shell. +/// Used in [`TestConfig::test()`] and related lower-level methods. +/// - `()`, which requires a captured [`Transcript`], i.e., delegates reproduction to the user code. +/// Used in [`TestConfig::test_captured()`]. +/// +/// The contents of this trait are implementation details. pub trait TestCommand { #[doc(hidden)] // implementation detail type Inputs: fmt::Debug; From 3d849fd29aff45165b2da19215c356001e7bd48c Mon Sep 17 00:00:00 2001 From: Alex Ostrovski Date: Fri, 15 May 2026 13:05:45 +0300 Subject: [PATCH 3/5] Implement iter traits for `Transcript` --- crates/term-transcript/src/types.rs | 14 ++++++++++++++ e2e-tests/rainbow/tests/integration.rs | 4 +--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/term-transcript/src/types.rs b/crates/term-transcript/src/types.rs index 82a432be..3d40d046 100644 --- a/crates/term-transcript/src/types.rs +++ b/crates/term-transcript/src/types.rs @@ -85,6 +85,20 @@ impl Transcript { } } +impl FromIterator for Transcript { + fn from_iter>(iter: I) -> Self { + Self { + interactions: iter.into_iter().collect(), + } + } +} + +impl Extend for Transcript { + fn extend>(&mut self, iter: I) { + self.interactions.extend(iter); + } +} + /// Portable, platform-independent version of [`ExitStatus`] from the standard library. /// /// # Capturing `ExitStatus` diff --git a/e2e-tests/rainbow/tests/integration.rs b/e2e-tests/rainbow/tests/integration.rs index fb7287fa..2bff17be 100644 --- a/e2e-tests/rainbow/tests/integration.rs +++ b/e2e-tests/rainbow/tests/integration.rs @@ -204,9 +204,7 @@ fn snapshot_testing_with_captured_input(pure_svg: bool) { let output = fs::read_to_string(rainbow_dir().join("rainbow.out")).unwrap(); let interaction = Interaction::new("rainbow", StyledString::from_ansi(&output).unwrap()) .with_exit_status(ExitStatus(0)); - let mut transcript = Transcript::default(); - transcript.add_existing_interaction(interaction); - config.test_captured(main_snapshot_path(), transcript); + config.test_captured(main_snapshot_path(), Transcript::from_iter([interaction])); } #[cfg(feature = "portable-pty")] From dda1d170052425fe8ea54e46326827036507315e Mon Sep 17 00:00:00 2001 From: Alex Ostrovski Date: Tue, 9 Jun 2026 08:31:14 +0300 Subject: [PATCH 4/5] Update changelog --- crates/term-transcript/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/term-transcript/CHANGELOG.md b/crates/term-transcript/CHANGELOG.md index 2d3e8625..ce36de43 100644 --- a/crates/term-transcript/CHANGELOG.md +++ b/crates/term-transcript/CHANGELOG.md @@ -5,6 +5,10 @@ The project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Added + +- Add ability to test captured CLI transcripts. + ### Changed - Bump minimum supported Rust version to 1.86. From 945d6d78bb7db8b8ee75db091682796b648d7971 Mon Sep 17 00:00:00 2001 From: Alex Ostrovski Date: Tue, 9 Jun 2026 08:40:22 +0300 Subject: [PATCH 5/5] Fix conditional compilation --- crates/term-transcript/src/test/config_impl.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/term-transcript/src/test/config_impl.rs b/crates/term-transcript/src/test/config_impl.rs index ae26c8c9..7557b1cb 100644 --- a/crates/term-transcript/src/test/config_impl.rs +++ b/crates/term-transcript/src/test/config_impl.rs @@ -260,11 +260,7 @@ impl TestConfig { #[cfg(not(feature = "svg"))] #[allow(clippy::unused_self)] // necessary for uniformity - fn create_and_write_new_snapshot( - &mut self, - _: &Path, - _: impl Iterator, - ) -> String { + fn create_and_write_new_snapshot(&mut self, _: &Path, _: Cmd::Inputs) -> String { format!( "Not writing a new snapshot since `{}/svg` feature is not enabled", env!("CARGO_PKG_NAME")