From ff4b03f233e19ec607a9c88dc1d71016aeaeed4b Mon Sep 17 00:00:00 2001 From: David Sherret Date: Mon, 6 Mar 2023 09:16:50 -0500 Subject: [PATCH] chore(tests): ability to capture stdout and stderr separately (#18035) This is to allow making assertions on stdout and stderr separately. --- cli/tests/integration/bench_tests.rs | 2 +- runtime/ops/process.rs | 5 +- test_util/src/builders.rs | 227 +++++++++++++++++++++------ 3 files changed, 184 insertions(+), 50 deletions(-) diff --git a/cli/tests/integration/bench_tests.rs b/cli/tests/integration/bench_tests.rs index 6da30857f3..b2cd38475d 100644 --- a/cli/tests/integration/bench_tests.rs +++ b/cli/tests/integration/bench_tests.rs @@ -196,7 +196,7 @@ fn recursive_permissions_pledge() { .run(); output.assert_exit_code(1); assert_contains!( - output.text(), + output.combined_output(), "pledge test permissions called before restoring previous pledge" ); } diff --git a/runtime/ops/process.rs b/runtime/ops/process.rs index ad14ef2e48..20db96af98 100644 --- a/runtime/ops/process.rs +++ b/runtime/ops/process.rs @@ -1,7 +1,6 @@ // Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. use super::check_unstable; -use super::signal; use crate::permissions::PermissionsContainer; use deno_core::error::AnyError; use deno_core::op; @@ -565,7 +564,7 @@ mod deprecated { #[cfg(unix)] pub fn kill(pid: i32, signal: &str) -> Result<(), AnyError> { - let signo = super::signal::signal_str_to_int(signal)?; + let signo = super::super::signal::signal_str_to_int(signal)?; use nix::sys::signal::kill as unix_kill; use nix::sys::signal::Signal; use nix::unistd::Pid; @@ -593,8 +592,8 @@ mod deprecated { } else if pid <= 0 { Err(type_error("Invalid pid")) } else { - // SAFETY: winapi call let handle = + // SAFETY: winapi call unsafe { OpenProcess(PROCESS_TERMINATE, FALSE, pid as DWORD) }; if handle.is_null() { diff --git a/test_util/src/builders.rs b/test_util/src/builders.rs index 6ccb45f50f..da331b62a1 100644 --- a/test_util/src/builders.rs +++ b/test_util/src/builders.rs @@ -168,6 +168,7 @@ impl TestContext { envs: Default::default(), env_clear: Default::default(), cwd: Default::default(), + split_output: false, context: self.clone(), } } @@ -181,6 +182,7 @@ pub struct TestCommandBuilder { envs: HashMap, env_clear: bool, cwd: Option, + split_output: bool, context: TestContext, } @@ -205,6 +207,15 @@ impl TestCommandBuilder { self } + /// Splits the output into stdout and stderr rather than having them combined. + pub fn split_output(&mut self) -> &mut Self { + // Note: it was previously attempted to capture stdout & stderr separately + // then forward the output to a combined pipe, but this was found to be + // too racy compared to providing the same combined pipe to both. + self.split_output = true; + self + } + pub fn env( &mut self, key: impl AsRef, @@ -227,6 +238,23 @@ impl TestCommandBuilder { } pub fn run(&self) -> TestCommandOutput { + fn read_pipe_to_string(mut pipe: os_pipe::PipeReader) -> String { + let mut output = String::new(); + pipe.read_to_string(&mut output).unwrap(); + output + } + + fn sanitize_output(text: String, args: &[String]) -> String { + let mut text = strip_ansi_codes(&text).to_string(); + // deno test's output capturing flushes with a zero-width space in order to + // synchronize the output pipes. Occassionally this zero width space + // might end up in the output so strip it from the output comparison here. + if args.first().map(|s| s.as_str()) == Some("test") { + text = text.replace('\u{200B}', ""); + } + text + } + let cwd = self.cwd.as_ref().or(self.context.cwd.as_ref()); let cwd = if self.context.use_temp_cwd { assert!(cwd.is_none()); @@ -256,7 +284,6 @@ impl TestCommandBuilder { arg.replace("$TESTDATA", &self.context.testdata_dir.to_string_lossy()) }) .collect::>(); - let (mut reader, writer) = pipe().unwrap(); let command_name = self .command_name .as_ref() @@ -284,9 +311,25 @@ impl TestCommandBuilder { }); command.current_dir(cwd); command.stdin(Stdio::piped()); - let writer_clone = writer.try_clone().unwrap(); - command.stderr(writer_clone); - command.stdout(writer); + + let (combined_reader, std_out_err_handle) = if self.split_output { + let (stdout_reader, stdout_writer) = pipe().unwrap(); + let (stderr_reader, stderr_writer) = pipe().unwrap(); + command.stdout(stdout_writer); + command.stderr(stderr_writer); + ( + None, + Some(( + std::thread::spawn(move || read_pipe_to_string(stdout_reader)), + std::thread::spawn(move || read_pipe_to_string(stderr_reader)), + )), + ) + } else { + let (combined_reader, combined_writer) = pipe().unwrap(); + command.stdout(combined_writer.try_clone().unwrap()); + command.stderr(combined_writer); + (Some(combined_reader), None) + }; let mut process = command.spawn().unwrap(); @@ -301,10 +344,16 @@ impl TestCommandBuilder { // and dropping it closes them. drop(command); - let mut actual = String::new(); - reader.read_to_string(&mut actual).unwrap(); + let combined = combined_reader + .map(|pipe| sanitize_output(read_pipe_to_string(pipe), &args)); - let status = process.wait().expect("failed to finish process"); + let status = process.wait().unwrap(); + let std_out_err = std_out_err_handle.map(|(stdout, stderr)| { + ( + sanitize_output(stdout.join().unwrap(), &args), + sanitize_output(stderr.join().unwrap(), &args), + ) + }); let exit_code = status.code(); #[cfg(unix)] let signal = { @@ -314,33 +363,30 @@ impl TestCommandBuilder { #[cfg(not(unix))] let signal = None; - actual = strip_ansi_codes(&actual).to_string(); - - // deno test's output capturing flushes with a zero-width space in order to - // synchronize the output pipes. Occassionally this zero width space - // might end up in the output so strip it from the output comparison here. - if args.first().map(|s| s.as_str()) == Some("test") { - actual = actual.replace('\u{200B}', ""); - } - TestCommandOutput { exit_code, signal, - text: actual, + combined, + std_out_err, testdata_dir: self.context.testdata_dir.clone(), asserted_exit_code: RefCell::new(false), - asserted_text: RefCell::new(false), + asserted_stdout: RefCell::new(false), + asserted_stderr: RefCell::new(false), + asserted_combined: RefCell::new(false), _test_context: self.context.clone(), } } } pub struct TestCommandOutput { - text: String, + combined: Option, + std_out_err: Option<(String, String)>, exit_code: Option, signal: Option, testdata_dir: PathBuf, - asserted_text: RefCell, + asserted_stdout: RefCell, + asserted_stderr: RefCell, + asserted_combined: RefCell, asserted_exit_code: RefCell, // keep alive for the duration of the output reference _test_context: TestContext, @@ -348,6 +394,17 @@ pub struct TestCommandOutput { impl Drop for TestCommandOutput { fn drop(&mut self) { + fn panic_unasserted_output(text: &str) { + println!("OUTPUT\n{text}\nOUTPUT"); + panic!( + concat!( + "The non-empty text of the command was not asserted at {}. ", + "Call `output.skip_output_check()` to skip if necessary.", + ), + failed_position() + ); + } + if std::thread::panicking() { return; } @@ -359,15 +416,20 @@ impl Drop for TestCommandOutput { failed_position(), ) } - if !*self.asserted_text.borrow() && !self.text.is_empty() { - println!("OUTPUT\n{}\nOUTPUT", self.text); - panic!( - concat!( - "The non-empty text of the command was not asserted. ", - "Call `output.skip_output_check()` to skip if necessary at {}.", - ), - failed_position() - ); + + // either the combined output needs to be asserted or both stdout and stderr + if let Some(combined) = &self.combined { + if !*self.asserted_combined.borrow() && !combined.is_empty() { + panic_unasserted_output(combined); + } + } + if let Some((stdout, stderr)) = &self.std_out_err { + if !*self.asserted_stdout.borrow() && !stdout.is_empty() { + panic_unasserted_output(stdout); + } + if !*self.asserted_stderr.borrow() && !stderr.is_empty() { + panic_unasserted_output(stderr); + } } } } @@ -378,7 +440,9 @@ impl TestCommandOutput { } pub fn skip_output_check(&self) { - *self.asserted_text.borrow_mut() = true; + *self.asserted_combined.borrow_mut() = true; + *self.asserted_stdout.borrow_mut() = true; + *self.asserted_stderr.borrow_mut() = true; } pub fn skip_exit_code_check(&self) { @@ -394,9 +458,30 @@ impl TestCommandOutput { self.signal } - pub fn text(&self) -> &str { + pub fn combined_output(&self) -> &str { self.skip_output_check(); - &self.text + self + .combined + .as_deref() + .expect("not available since .split_output() was called") + } + + pub fn stdout(&self) -> &str { + *self.asserted_stdout.borrow_mut() = true; + self + .std_out_err + .as_ref() + .map(|(stdout, _)| stdout.as_str()) + .expect("call .split_output() on the builder") + } + + pub fn stderr(&self) -> &str { + *self.asserted_stderr.borrow_mut() = true; + self + .std_out_err + .as_ref() + .map(|(_, stderr)| stderr.as_str()) + .expect("call .split_output() on the builder") } pub fn assert_exit_code(&self, expected_exit_code: i32) -> &Self { @@ -404,7 +489,7 @@ impl TestCommandOutput { if let Some(exit_code) = &actual_exit_code { if *exit_code != expected_exit_code { - println!("OUTPUT\n{}\nOUTPUT", self.text()); + self.print_output(); panic!( "bad exit code, expected: {:?}, actual: {:?} at {}", expected_exit_code, @@ -413,7 +498,7 @@ impl TestCommandOutput { ); } } else { - println!("OUTPUT\n{}\nOUTPUT", self.text()); + self.print_output(); if let Some(signal) = self.signal() { panic!( "process terminated by signal, expected exit code: {:?}, actual signal: {:?} at {}", @@ -433,29 +518,79 @@ impl TestCommandOutput { self } - pub fn assert_matches_text(&self, expected_text: impl AsRef) -> &Self { - let expected_text = expected_text.as_ref(); - let actual = self.text(); - - if !expected_text.contains("[WILDCARD]") { - assert_eq!(actual, expected_text, "at {}", failed_position()); - } else if !wildcard_match(expected_text, actual) { - println!("OUTPUT START\n{actual}\nOUTPUT END"); - println!("EXPECTED START\n{expected_text}\nEXPECTED END"); - panic!("pattern match failed at {}", failed_position()); + pub fn print_output(&self) { + if let Some(combined) = &self.combined { + println!("OUTPUT\n{combined}\nOUTPUT"); + } else if let Some((stdout, stderr)) = &self.std_out_err { + println!("STDOUT OUTPUT\n{stdout}\nSTDOUT OUTPUT"); + println!("STDERR OUTPUT\n{stderr}\nSTDERR OUTPUT"); } + } - self + pub fn assert_matches_text(&self, expected_text: impl AsRef) -> &Self { + self.inner_assert_matches_text(self.combined_output(), expected_text) } pub fn assert_matches_file(&self, file_path: impl AsRef) -> &Self { + self.inner_assert_matches_file(self.combined_output(), file_path) + } + + pub fn assert_stdout_matches_text( + &self, + expected_text: impl AsRef, + ) -> &Self { + self.inner_assert_matches_text(self.stdout(), expected_text) + } + + pub fn assert_stdout_matches_file( + &self, + file_path: impl AsRef, + ) -> &Self { + self.inner_assert_matches_file(self.stdout(), file_path) + } + + pub fn assert_stderr_matches_text( + &self, + expected_text: impl AsRef, + ) -> &Self { + self.inner_assert_matches_text(self.stderr(), expected_text) + } + + pub fn assert_stderrr_matches_file( + &self, + file_path: impl AsRef, + ) -> &Self { + self.inner_assert_matches_file(self.stderr(), file_path) + } + + fn inner_assert_matches_text( + &self, + actual: &str, + expected: impl AsRef, + ) -> &Self { + let expected = expected.as_ref(); + if !expected.contains("[WILDCARD]") { + assert_eq!(actual, expected, "at {}", failed_position()); + } else if !wildcard_match(expected, actual) { + println!("OUTPUT START\n{actual}\nOUTPUT END"); + println!("EXPECTED START\n{expected}\nEXPECTED END"); + panic!("pattern match failed at {}", failed_position()); + } + self + } + + fn inner_assert_matches_file( + &self, + actual: &str, + file_path: impl AsRef, + ) -> &Self { let output_path = self.testdata_dir().join(file_path); println!("output path {}", output_path.display()); let expected_text = std::fs::read_to_string(&output_path).unwrap_or_else(|err| { panic!("failed loading {}\n\n{err:#}", output_path.display()) }); - self.assert_matches_text(expected_text) + self.inner_assert_matches_text(actual, expected_text) } }