use crate::assert::types::{FileResult, RunResult, TestResult};
use crate::report::failure::{
group_failures, primary_failure_message, request_arrow_response, skip_cascade_summary,
truncate_string, CAPTURE_VALUE_CAP, COMPACT_MESSAGE_CAP,
};
use crate::report::RenderOptions;
use colored::Colorize;
pub fn render(result: &RunResult) -> String {
render_with_options(result, RenderOptions::default())
}
pub fn render_with_options(result: &RunResult, opts: RenderOptions) -> String {
if opts.no_color {
colored::control::set_override(false);
} else {
colored::control::unset_override();
}
let mut out = String::new();
out.push_str(&render_header(result));
out.push('\n');
for file in &result.file_results {
if opts.only_failed && file.passed {
continue;
}
render_file(&mut out, file, opts);
}
let groups = group_failures(result);
if !groups.is_empty() {
out.push('\n');
out.push_str(&render_group_summary(&groups));
out.push('\n');
}
colored::control::unset_override();
out
}
fn render_header(result: &RunResult) -> String {
let files = result.total_files();
let tests: usize = result
.file_results
.iter()
.map(|f| f.test_results.len())
.sum();
let steps_total = result.total_steps();
let steps_passed = result.passed_steps();
let seconds = duration_seconds(result.duration_ms);
format!(
"tarn: {} file{}, {} test{}, {}/{} steps passed, {}s",
files,
plural(files),
tests,
plural(tests),
steps_passed,
steps_total,
seconds
)
}
fn render_file(out: &mut String, file: &FileResult, opts: RenderOptions) {
let total = file.total_steps();
let passed = file.passed_steps();
if file.passed {
out.push_str(&format!(
"{} {} ({}/{})\n",
"✓".green(),
file.file,
passed,
total
));
} else {
out.push_str(&format!(
"{} {} ({}/{})\n",
"✗".red(),
file.file.bold(),
passed,
total
));
render_file_failures(out, file, opts);
}
}
fn render_file_failures(out: &mut String, file: &FileResult, opts: RenderOptions) {
for step in &file.setup_results {
if step.passed {
continue;
}
let message = primary_failure_message(
step,
&file.redaction,
&file.redacted_values,
COMPACT_MESSAGE_CAP,
);
out.push_str(&format!(
" {} <setup> — {} — {}\n",
"FAIL:".red().bold(),
step.name,
message
));
out.push_str(&format!(
" {}\n",
request_arrow_response(step, &file.redaction, &file.redacted_values).dimmed()
));
}
for test in &file.test_results {
if test.passed && !opts.verbose {
continue;
}
render_test_failures(out, file, test, opts);
}
for step in &file.teardown_results {
if step.passed {
continue;
}
let message = primary_failure_message(
step,
&file.redaction,
&file.redacted_values,
COMPACT_MESSAGE_CAP,
);
out.push_str(&format!(
" {} <teardown> — {} — {}\n",
"FAIL:".red().bold(),
step.name,
message
));
out.push_str(&format!(
" {}\n",
request_arrow_response(step, &file.redaction, &file.redacted_values).dimmed()
));
}
}
fn render_test_failures(
out: &mut String,
file: &FileResult,
test: &TestResult,
opts: RenderOptions,
) {
let mut printed_anything = false;
for step in &test.step_results {
if step.passed {
continue;
}
let message = primary_failure_message(
step,
&file.redaction,
&file.redacted_values,
COMPACT_MESSAGE_CAP,
);
out.push_str(&format!(
" {} {} — {} — {}\n",
"FAIL:".red().bold(),
test.name,
step.name,
message
));
out.push_str(&format!(
" {}\n",
request_arrow_response(step, &file.redaction, &file.redacted_values).dimmed()
));
printed_anything = true;
}
for (capture, count) in skip_cascade_summary(test) {
out.push_str(&format!(
" {} {} step{} (depended on failed capture '{}')\n",
"skipped:".yellow(),
count,
plural(count),
capture
));
printed_anything = true;
}
if opts.verbose && !test.captures.is_empty() {
if printed_anything || test.passed {
out.push_str(&format!(" {} ({})\n", "captures".dimmed(), test.name));
let mut keys: Vec<&String> = test.captures.keys().collect();
keys.sort();
for key in keys {
let rendered =
serde_json::to_string(&test.captures[key]).unwrap_or_else(|_| "null".into());
out.push_str(&format!(
" {} = {}\n",
key,
truncate_string(&rendered, CAPTURE_VALUE_CAP)
));
}
}
}
}
fn render_group_summary(groups: &[(String, usize)]) -> String {
groups
.iter()
.map(|(label, count)| format!("{}: {}", label, count))
.collect::<Vec<_>>()
.join(" | ")
}
fn duration_seconds(ms: u64) -> String {
let seconds = ms as f64 / 1000.0;
if seconds >= 10.0 {
format!("{:.0}", seconds)
} else {
format!("{:.1}", seconds)
}
}
fn plural(n: usize) -> &'static str {
if n == 1 {
""
} else {
"s"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assert::types::*;
use crate::model::RedactionConfig;
use serde_json::json;
use std::collections::HashMap;
fn strip_ansi(s: &str) -> String {
let re = regex::Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").unwrap();
re.replace_all(s, "").into_owned()
}
fn base_passed_step(name: &str) -> StepResult {
StepResult {
name: name.into(),
description: None,
debug: false,
passed: true,
duration_ms: 20,
assertion_results: vec![AssertionResult::pass("status", "200", "200")],
request_info: None,
response_info: None,
error_category: None,
response_status: Some(200),
response_summary: None,
captures_set: vec![],
location: None,
response_shape_mismatch: None,
}
}
fn failing_step(name: &str, status: u16) -> StepResult {
StepResult {
name: name.into(),
description: None,
debug: false,
passed: false,
duration_ms: 30,
assertion_results: vec![AssertionResult::fail(
"status",
"200",
status.to_string(),
format!("Expected 200, got {}", status),
)],
request_info: Some(RequestInfo {
method: "GET".into(),
url: "/foo".into(),
headers: HashMap::new(),
body: None,
multipart: None,
}),
response_info: Some(ResponseInfo {
status,
headers: HashMap::new(),
body: Some(json!({"err": "boom"})),
}),
error_category: Some(FailureCategory::AssertionFailed),
response_status: Some(status),
response_summary: None,
captures_set: vec![],
location: None,
response_shape_mismatch: None,
}
}
fn build_run(files: Vec<FileResult>) -> RunResult {
let duration_ms = files.iter().map(|f| f.duration_ms).sum();
RunResult {
file_results: files,
duration_ms,
}
}
fn file_with(name: &str, passed: bool, steps: Vec<StepResult>) -> FileResult {
let total_steps = steps.len();
let passed_steps = steps.iter().filter(|s| s.passed).count();
let _ = total_steps;
let _ = passed_steps;
FileResult {
file: name.into(),
name: name.into(),
passed,
duration_ms: 10,
redaction: RedactionConfig::default(),
redacted_values: vec![],
setup_results: vec![],
test_results: vec![TestResult {
name: "t".into(),
description: None,
passed,
duration_ms: 10,
step_results: steps,
captures: HashMap::new(),
}],
teardown_results: vec![],
}
}
#[test]
fn header_has_counts_and_duration() {
let run = build_run(vec![file_with(
"a.tarn.yaml",
true,
vec![base_passed_step("s1"), base_passed_step("s2")],
)]);
let out = strip_ansi(&render_with_options(
&run,
RenderOptions {
no_color: true,
..RenderOptions::default()
},
));
assert!(out.starts_with("tarn: 1 file, 1 test, 2/2 steps passed,"));
}
#[test]
fn passing_file_renders_checkmark_line() {
let run = build_run(vec![file_with(
"a.tarn.yaml",
true,
vec![base_passed_step("s1")],
)]);
let out = strip_ansi(&render_with_options(
&run,
RenderOptions {
no_color: true,
..RenderOptions::default()
},
));
assert!(out.contains("\u{2713} a.tarn.yaml (1/1)"));
}
#[test]
fn only_failed_hides_passing_files() {
let run = build_run(vec![
file_with("a.tarn.yaml", true, vec![base_passed_step("s1")]),
file_with("b.tarn.yaml", false, vec![failing_step("bad", 500)]),
]);
let out = strip_ansi(&render_with_options(
&run,
RenderOptions {
only_failed: true,
no_color: true,
..RenderOptions::default()
},
));
assert!(!out.contains("a.tarn.yaml"));
assert!(out.contains("b.tarn.yaml"));
}
#[test]
fn failed_file_shows_fail_line_and_arrow() {
let run = build_run(vec![file_with(
"b.tarn.yaml",
false,
vec![failing_step("bad", 500)],
)]);
let out = strip_ansi(&render_with_options(
&run,
RenderOptions {
no_color: true,
..RenderOptions::default()
},
));
assert!(out.contains("\u{2717} b.tarn.yaml (0/1)"));
assert!(out.contains("FAIL: t \u{2014} bad \u{2014} Expected 200, got 500"));
assert!(out.contains("GET /foo -> 500"));
}
#[test]
fn group_summary_lists_categories_by_count() {
let run = build_run(vec![file_with(
"b.tarn.yaml",
false,
vec![
failing_step("a", 500),
failing_step("b", 500),
failing_step("c", 404),
],
)]);
let out = strip_ansi(&render_with_options(
&run,
RenderOptions {
no_color: true,
..RenderOptions::default()
},
));
assert!(out.contains("HTTP 500: 2 | HTTP 404: 1"));
}
#[test]
fn verbose_shows_captures_for_test() {
let mut tr = TestResult {
name: "cap_test".into(),
description: None,
passed: false,
duration_ms: 10,
step_results: vec![failing_step("login", 500)],
captures: HashMap::new(),
};
tr.captures.insert("token".into(), json!("abc"));
tr.captures.insert("user_id".into(), json!(42));
let file = FileResult {
file: "b.tarn.yaml".into(),
name: "b".into(),
passed: false,
duration_ms: 10,
redaction: RedactionConfig::default(),
redacted_values: vec![],
setup_results: vec![],
test_results: vec![tr],
teardown_results: vec![],
};
let run = build_run(vec![file]);
let out = strip_ansi(&render_with_options(
&run,
RenderOptions {
verbose: true,
no_color: true,
..RenderOptions::default()
},
));
assert!(out.contains("captures (cap_test)"));
assert!(out.contains("token = \"abc\""));
assert!(out.contains("user_id = 42"));
}
#[test]
fn long_capture_value_is_truncated() {
let mut tr = TestResult {
name: "cap".into(),
description: None,
passed: false,
duration_ms: 10,
step_results: vec![failing_step("login", 500)],
captures: HashMap::new(),
};
tr.captures.insert("payload".into(), json!("a".repeat(200)));
let file = FileResult {
file: "b.tarn.yaml".into(),
name: "b".into(),
passed: false,
duration_ms: 10,
redaction: RedactionConfig::default(),
redacted_values: vec![],
setup_results: vec![],
test_results: vec![tr],
teardown_results: vec![],
};
let run = build_run(vec![file]);
let out = strip_ansi(&render_with_options(
&run,
RenderOptions {
verbose: true,
no_color: true,
..RenderOptions::default()
},
));
assert!(out.contains("payload = "));
assert!(out.contains("..."));
assert!(!out.contains(&"a".repeat(150)));
}
}