diff --git a/integration/test_hurl.py b/integration/test_hurl.py index 1b748d0b7..15a4c7967 100755 --- a/integration/test_hurl.py +++ b/integration/test_hurl.py @@ -122,7 +122,7 @@ def test(hurl_file: str): expected_pattern_lines = [parse_pattern(line) for line in expected_lines] actual_lines = actual.split("\n") if len(actual_lines) != len(expected_pattern_lines): - print(">>> error in stderr / mismatch in number of lines") + print(">>> error in stdout / mismatch in number of lines") print( f"actual: {len(actual_lines)} lines\nexpected: {len(expected_lines)} lines" ) diff --git a/packages/hurl/src/http/client.rs b/packages/hurl/src/http/client.rs index 91ca0f3c4..b049035df 100644 --- a/packages/hurl/src/http/client.rs +++ b/packages/hurl/src/http/client.rs @@ -208,9 +208,7 @@ impl Client { // Return all request headers (not one by one) easy::InfoType::HeaderOut => { let mut lines = split_lines(data); - if verbose { - logger.method_version_out(&lines[0]); - } + logger.debug_method_version_out(&lines[0]); // Extracts request headers from libcurl debug info. lines.pop().unwrap(); // Remove last empty line. diff --git a/packages/hurl/src/main.rs b/packages/hurl/src/main.rs index c5bfaf7b0..09d47ec42 100644 --- a/packages/hurl/src/main.rs +++ b/packages/hurl/src/main.rs @@ -15,6 +15,7 @@ * limitations under the License. * */ +use std::env; use std::io::prelude::*; use std::path::Path; use std::time::Instant; @@ -87,7 +88,8 @@ fn main() { ); } - let current_dir = std::env::current_dir(); + let progress_bar = cli_options.test && !verbose && !is_ci() && atty::is(Stream::Stderr); + let current_dir = env::current_dir(); let current_dir = unwrap_or_exit(current_dir, EXIT_ERROR_UNDEFINED, &base_logger); let current_dir = current_dir.as_path(); @@ -110,15 +112,15 @@ fn main() { let logger = builder .color(color) .verbose(verbose) + .test(cli_options.test) + .progress_bar(progress_bar) .filename(filename) .content(&content) .build() .unwrap(); - if cli_options.test { - let total = filenames.len(); - logger.test_running(current + 1, total); - } + let total = filenames.len(); + logger.test_running(current + 1, total); // We try to parse the text file to an HurlFile instance. let hurl_file = parser::parse_hurl_file(&content); @@ -132,9 +134,7 @@ fn main() { let hurl_result = execute(&hurl_file, filename, current_dir, &cli_options, &logger); let success = hurl_result.success; - if cli_options.test { - logger.test_completed(&hurl_result); - } + logger.test_completed(&hurl_result); // We can output the result, either the raw body or a structured JSON representation. let output_body = success @@ -448,3 +448,9 @@ fn get_summary(duration: u128, runs: &[Run]) -> String { s.push_str(format!("Duration: {duration} ms\n").as_str()); s } + +/// Whether or not this running in a Continuous Integration environment. +/// Code borrowed from +fn is_ci() -> bool { + env::var("CI").is_ok() || env::var("TF_BUILD").is_ok() +} diff --git a/packages/hurl/src/runner/hurl_file.rs b/packages/hurl/src/runner/hurl_file.rs index dabc1d31c..c9dc430ec 100644 --- a/packages/hurl/src/runner/hurl_file.rs +++ b/packages/hurl/src/runner/hurl_file.rs @@ -117,6 +117,8 @@ pub fn run( let logger = builder .color(logger.color) .verbose(entry_verbosity.is_some()) + .test(logger.test) + .progress_bar(entry_verbosity.is_none() && logger.progress_bar) .filename(logger.filename) .content(logger.content) .build() @@ -136,6 +138,8 @@ pub fn run( warn_deprecated(entry, &logger); + logger.test_progress(entry_index, n); + let options_result = entry::get_entry_options(entry, runner_options, &mut variables, &logger); let entry_result = match &options_result { @@ -185,13 +189,14 @@ pub fn run( // If we're going to retry the entry, we log error only in verbose. Otherwise, // we log error on stderr. for e in &entry_result.errors { + logger.test_erase_line(); if retry { logger.debug_error(e); } else { logger.error_rich(e); } } - entries.push(entry_result.clone()); + entries.push(entry_result); if let Some(post_entry) = runner_options.post_entry { let exit = post_entry(); @@ -219,6 +224,8 @@ pub fn run( retry_count = 1; } + logger.test_erase_line(); + let time_in_ms = start.elapsed().as_millis(); let cookies = http_client.get_cookie_storage(); let filename = filename.to_string(); diff --git a/packages/hurl/src/util/logger.rs b/packages/hurl/src/util/logger.rs index 6837cd8d5..a5d2768aa 100644 --- a/packages/hurl/src/util/logger.rs +++ b/packages/hurl/src/util/logger.rs @@ -24,56 +24,44 @@ use std::cmp::max; /// A simple logger to log app related event (start, high levels error, etc...). /// When we run an [`hurl_core::ast::HurlFile`], user has to provide a dedicated Hurl logger (see [`Logger`]). pub struct BaseLogger { - pub info: fn(&str), - pub debug: fn(&str), - pub warning: fn(&str), - pub error: fn(&str), + pub color: bool, + pub verbose: bool, } impl BaseLogger { pub fn new(color: bool, verbose: bool) -> BaseLogger { - match (color, verbose) { - (true, true) => BaseLogger { - info: log_info, - debug: log_debug, - warning: log_warning, - error: log_error, - }, - (false, true) => BaseLogger { - info: log_info, - debug: log_debug_no_color, - warning: log_warning_no_color, - error: log_error_no_color, - }, - (true, false) => BaseLogger { - info: log_info, - debug: |_| {}, - warning: log_warning, - error: log_error, - }, - (false, false) => BaseLogger { - info: log_info, - debug: |_| {}, - warning: log_warning_no_color, - error: log_error_no_color, - }, - } + BaseLogger { color, verbose } } pub fn info(&self, message: &str) { - (self.info)(message) + log_info(message) } pub fn debug(&self, message: &str) { - (self.debug)(message) + if !self.verbose { + return; + } + if self.color { + log_debug(message) + } else { + log_debug_no_color(message) + } } pub fn warning(&self, message: &str) { - (self.warning)(message) + if self.color { + log_warning(message) + } else { + log_warning_no_color(message) + } } pub fn error(&self, message: &str) { - (self.error)(message) + if self.color { + log_error(message) + } else { + log_error_no_color(message) + } } } @@ -81,22 +69,10 @@ impl BaseLogger { /// rich error for parsing and runtime errors. As the rich errors can display user content, /// this logger should have access to the content of the file being run. pub struct Logger<'a> { - info: fn(&str), - debug: fn(&str), - debug_curl: fn(&str), - debug_error: fn(&str, &str, &dyn Error), - debug_header_in: fn(&str, &str), - debug_header_out: fn(&str, &str), - debug_important: fn(&str), - debug_method_version_out: fn(&str), - debug_status_version_in: fn(&str), - warning: fn(&str), - error: fn(&str), - error_rich: fn(&str, &str, &dyn Error), - capture: fn(&str, &Value), - test_running: fn(&str, usize, usize), - test_completed: fn(result: &HurlResult), pub(crate) color: bool, + pub(crate) verbose: bool, + pub(crate) progress_bar: bool, + pub(crate) test: bool, pub(crate) filename: &'a str, pub(crate) content: &'a str, } @@ -105,6 +81,8 @@ pub struct Logger<'a> { pub struct LoggerBuilder<'a> { color: bool, verbose: bool, + progress_bar: bool, + test: bool, filename: Option<&'a str>, content: Option<&'a str>, } @@ -139,6 +117,18 @@ impl<'a> LoggerBuilder<'a> { self } + /// Sets progress bar. + pub fn progress_bar(&mut self, progress_bar: bool) -> &mut Self { + self.progress_bar = progress_bar; + self + } + + /// Sets test. + pub fn test(&mut self, test: bool) -> &mut Self { + self.test = test; + self + } + /// Creates a new logger. pub fn build(&self) -> Result { if self.filename.is_none() { @@ -148,151 +138,182 @@ impl<'a> LoggerBuilder<'a> { return Err("content is not set"); } - let logger = match (self.color, self.verbose) { - (true, true) => Logger { - info: log_info, - debug: log_debug, - debug_curl: log_debug_curl, - debug_error: log_debug_error, - debug_header_in: log_debug_header_in, - debug_header_out: log_debug_header_out, - debug_important: log_debug_important, - debug_method_version_out: log_debug_method_version_out, - debug_status_version_in: log_debug_status_version_in, - warning: log_warning, - error: log_error, - error_rich: log_error_rich, - capture: log_capture, - test_running: log_test_running, - test_completed: log_test_completed, - color: self.color, - filename: self.filename.unwrap(), - content: self.content.unwrap(), - }, - (false, true) => Logger { - info: log_info, - debug: log_debug_no_color, - debug_curl: log_debug_curl_no_color, - debug_error: log_debug_error_no_color, - debug_header_in: log_debug_header_in_no_color, - debug_header_out: log_debug_header_out_no_color, - debug_important: log_debug_no_color, - debug_method_version_out: log_debug_method_version_out_no_color, - debug_status_version_in: log_debug_status_version_in_no_color, - warning: log_warning_no_color, - error: log_error_no_color, - error_rich: log_error_rich_no_color, - capture: log_capture_no_color, - test_running: log_test_running_no_color, - test_completed: log_test_completed_no_color, - color: self.color, - filename: self.filename.unwrap(), - content: self.content.unwrap(), - }, - (true, false) => Logger { - info: log_info, - debug: |_| {}, - debug_curl: |_| {}, - debug_error: |_, _, _| {}, - debug_header_in: |_, _| {}, - debug_header_out: |_, _| {}, - debug_important: |_| {}, - debug_method_version_out: |_| {}, - debug_status_version_in: |_| {}, - warning: log_warning, - error: log_error, - error_rich: log_error_rich, - capture: |_, _| {}, - test_running: log_test_running, - test_completed: log_test_completed, - color: self.color, - filename: self.filename.unwrap(), - content: self.content.unwrap(), - }, - (false, false) => Logger { - info: log_info, - debug: |_| {}, - debug_curl: |_| {}, - debug_error: |_, _, _| {}, - debug_header_in: |_, _| {}, - debug_header_out: |_, _| {}, - debug_important: |_| {}, - debug_method_version_out: |_| {}, - debug_status_version_in: |_| {}, - warning: log_warning_no_color, - error: log_error_no_color, - error_rich: log_error_rich_no_color, - capture: |_, _| {}, - test_running: log_test_running_no_color, - test_completed: log_test_completed_no_color, - color: self.color, - filename: self.filename.unwrap(), - content: self.content.unwrap(), - }, - }; - Ok(logger) + Ok(Logger { + color: self.color, + verbose: self.verbose, + progress_bar: self.progress_bar, + test: self.test, + filename: self.filename.unwrap(), + content: self.content.unwrap(), + }) } } impl<'a> Logger<'a> { pub fn info(&self, message: &str) { - (self.info)(message) + log_info(message) } pub fn debug(&self, message: &str) { - (self.debug)(message) + if !self.verbose { + return; + } + if self.color { + log_debug(message) + } else { + log_debug_no_color(message) + } } pub fn debug_curl(&self, message: &str) { - (self.debug_curl)(message) + if !self.verbose { + return; + } + if self.color { + log_debug_curl(message) + } else { + log_debug_curl_no_color(message) + } } pub fn debug_error(&self, error: &dyn Error) { - (self.debug_error)(self.filename, self.content, error) + if !self.verbose { + return; + } + if self.color { + log_debug_error(self.filename, self.content, error) + } else { + log_debug_error_no_color(self.filename, self.content, error) + } } pub fn debug_header_in(&self, name: &str, value: &str) { - (self.debug_header_in)(name, value) + if !self.verbose { + return; + } + if self.color { + log_debug_header_in(name, value) + } else { + log_debug_header_in_no_color(name, value) + } } pub fn debug_header_out(&self, name: &str, value: &str) { - (self.debug_header_out)(name, value) + if !self.verbose { + return; + } + if self.color { + log_debug_header_out(name, value) + } else { + log_debug_header_out_no_color(name, value) + } } pub fn debug_important(&self, message: &str) { - (self.debug_important)(message) + if !self.verbose { + return; + } + if self.color { + log_debug_important(message) + } else { + log_debug_no_color(message) + } } pub fn debug_status_version_in(&self, line: &str) { - (self.debug_status_version_in)(line) + if !self.verbose { + return; + } + if self.color { + log_debug_status_version_in(line) + } else { + log_debug_status_version_in_no_color(line) + } } pub fn warning(&self, message: &str) { - (self.warning)(message) + if self.color { + log_warning(message) + } else { + log_warning_no_color(message) + } } pub fn error(&self, message: &str) { - (self.error)(message) + if self.color { + log_error(message) + } else { + log_error_no_color(message) + } } pub fn error_rich(&self, error: &dyn Error) { - (self.error_rich)(self.filename, self.content, error) + if self.color { + log_error_rich(self.filename, self.content, error) + } else { + log_error_rich_no_color(self.filename, self.content, error) + } } - pub fn method_version_out(&self, line: &str) { - (self.debug_method_version_out)(line) + pub fn debug_method_version_out(&self, line: &str) { + if !self.verbose { + return; + } + if self.color { + log_debug_method_version_out(line) + } else { + log_debug_method_version_out_no_color(line) + } } pub fn capture(&self, name: &str, value: &Value) { - (self.capture)(name, value) + if !self.verbose { + return; + } + if self.color { + log_capture(name, value) + } else { + log_capture_no_color(name, value) + } } pub fn test_running(&self, current: usize, total: usize) { - (self.test_running)(self.filename, current, total) + if !self.test { + return; + } + if self.color { + log_test_running(self.filename, current, total) + } else { + log_test_running_no_color(self.filename, current, total) + } + } + + pub fn test_progress(&self, entry_index: usize, count: usize) { + if !self.progress_bar { + return; + } + log_test_progress(entry_index, count) } pub fn test_completed(&self, result: &HurlResult) { - (self.test_completed)(result) + if !self.test { + return; + } + if self.color { + log_test_completed(result) + } else { + log_test_completed_no_color(result) + } + } + + pub fn test_erase_line(&self) { + if !self.progress_bar { + return; + } + // This is the "EL - Erase in Line" sequence. It clears from the cursor + // to the end of line. + // https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_sequences + eprint!("\x1B[K"); } } @@ -432,6 +453,26 @@ fn log_test_running_no_color(filename: &str, current: usize, total: usize) { eprintln!("{filename}: Running [{current}/{total}]") } +fn log_test_progress(entry_index: usize, count: usize) { + let progress = progress_string(entry_index, count); + eprint!(" {progress}\r"); +} + +/// Returns the progress string with the current entry at `entry_index`. +fn progress_string(entry_index: usize, count: usize) -> String { + const WIDTH: usize = 24; + // We report the number of entries already processed. + let progress = (entry_index - 1) as f64 / count as f64; + let col = (progress * WIDTH as f64) as usize; + let completed = if col > 0 { + "=".repeat(col) + } else { + "".to_string() + }; + let void = " ".repeat(WIDTH - col - 1); + format!("[{completed}>{void}] {entry_index}/{count}") +} + fn log_test_completed(result: &HurlResult) { let state = if result.success { "Success".green().bold() @@ -703,4 +744,24 @@ HTTP/1.0 200 |"# ) } + + #[rustfmt::skip] + #[test] + fn test_progress_string() { + // Progress strings with 20 entries: + assert_eq!(progress_string(1, 20), "[> ] 1/20"); + assert_eq!(progress_string(2, 20), "[=> ] 2/20"); + assert_eq!(progress_string(5, 20), "[====> ] 5/20"); + assert_eq!(progress_string(10, 20), "[==========> ] 10/20"); + assert_eq!(progress_string(15, 20), "[================> ] 15/20"); + assert_eq!(progress_string(20, 20), "[======================> ] 20/20"); + + // Progress strings with 3 entries: + assert_eq!(progress_string(1, 3), "[> ] 1/3"); + assert_eq!(progress_string(2, 3), "[========> ] 2/3"); + assert_eq!(progress_string(3, 3), "[================> ] 3/3"); + + // Progress strings with 1 entries: + assert_eq!(progress_string(1, 1), "[> ] 1/1"); + } }