1use crate::assert::hints::step_hints;
2use crate::assert::types::{FailureCategory, FileResult, RunResult, StepResult, TestResult};
3use crate::model::RedactionConfig;
4use crate::report::redaction::sanitize_assertion;
5use crate::report::RenderOptions;
6use colored::Colorize;
7
8pub fn render(result: &RunResult) -> String {
10 render_with_options(result, RenderOptions::default())
11}
12
13pub fn render_with_options(result: &RunResult, opts: RenderOptions) -> String {
15 let mut output = String::new();
16
17 for file_result in &result.file_results {
18 if opts.only_failed && file_result.passed {
19 continue;
20 }
21 render_file(&mut output, file_result, opts);
22 }
23
24 output.push_str(&render_summary(result));
25 output
26}
27
28pub fn render_summary(result: &RunResult) -> String {
30 let passed = result.passed_steps();
31 let failed = result.failed_steps();
32 let duration = result.duration_ms;
33
34 let mut out = String::from("\n");
35 if failed == 0 {
36 out.push_str(&format!(
37 " {} {} passed ({}ms)\n",
38 "Results:".bold(),
39 passed.to_string().green(),
40 duration
41 ));
42 } else {
43 out.push_str(&format!(
44 " {} {} passed, {} failed ({}ms)\n",
45 "Results:".bold(),
46 passed.to_string().green(),
47 failed.to_string().red(),
48 duration
49 ));
50 }
51 out
52}
53
54pub fn render_file_header(file_result: &FileResult) -> String {
56 render_file_header_parts(&file_result.file, &file_result.name)
57}
58
59pub fn render_file_header_parts(file_path: &str, file_name: &str) -> String {
62 let mut out = String::new();
63 out.push_str(&format!(
64 "\n {} Running {}\n\n",
65 "TARN".bold().white().on_blue(),
66 file_path.dimmed()
67 ));
68 out.push_str(&format!(" {} {}\n", "●".bold(), file_name.bold()));
69 out
70}
71
72pub fn render_setup_block(
74 setup_results: &[StepResult],
75 redaction: &RedactionConfig,
76 redacted_values: &[String],
77 opts: RenderOptions,
78) -> String {
79 let has_visible = setup_results
80 .iter()
81 .any(|s| !(opts.only_failed && s.passed));
82 if !has_visible {
83 return String::new();
84 }
85 let mut out = String::new();
86 out.push_str(&format!("\n {}\n", "Setup".dimmed()));
87 for step in setup_results {
88 if opts.only_failed && step.passed {
89 continue;
90 }
91 render_step_into(&mut out, step, redaction, redacted_values);
92 }
93 out
94}
95
96pub fn render_test_block(
98 test: &TestResult,
99 redaction: &RedactionConfig,
100 redacted_values: &[String],
101 opts: RenderOptions,
102) -> String {
103 if opts.only_failed && test.passed {
104 return String::new();
105 }
106 let mut out = String::new();
107 out.push('\n');
108 if let Some(ref desc) = test.description {
109 out.push_str(&format!(" {} — {}\n", test.name.bold(), desc.dimmed()));
110 } else {
111 out.push_str(&format!(" {}\n", test.name.bold()));
112 }
113 for step in &test.step_results {
114 if opts.only_failed && step.passed {
115 continue;
116 }
117 render_step_into(&mut out, step, redaction, redacted_values);
118 }
119 out
120}
121
122pub fn render_teardown_block(
124 teardown_results: &[StepResult],
125 redaction: &RedactionConfig,
126 redacted_values: &[String],
127 opts: RenderOptions,
128) -> String {
129 let has_visible = teardown_results
130 .iter()
131 .any(|s| !(opts.only_failed && s.passed));
132 if !has_visible {
133 return String::new();
134 }
135 let mut out = String::new();
136 out.push_str(&format!("\n {}\n", "Teardown".dimmed()));
137 for step in teardown_results {
138 if opts.only_failed && step.passed {
139 continue;
140 }
141 render_step_into(&mut out, step, redaction, redacted_values);
142 }
143 out
144}
145
146fn render_file(output: &mut String, file_result: &FileResult, opts: RenderOptions) {
147 output.push_str(&render_file_header(file_result));
148 output.push_str(&render_setup_block(
149 &file_result.setup_results,
150 &file_result.redaction,
151 &file_result.redacted_values,
152 opts,
153 ));
154 for test in &file_result.test_results {
155 output.push_str(&render_test_block(
156 test,
157 &file_result.redaction,
158 &file_result.redacted_values,
159 opts,
160 ));
161 }
162 output.push_str(&render_teardown_block(
163 &file_result.teardown_results,
164 &file_result.redaction,
165 &file_result.redacted_values,
166 opts,
167 ));
168}
169
170fn render_step_description_into(output: &mut String, step: &StepResult) {
178 if let Some(ref description) = step.description {
179 for line in description.lines() {
180 output.push_str(&format!(" {}\n", line.dimmed()));
181 }
182 }
183}
184
185fn render_step_into(
186 output: &mut String,
187 step: &StepResult,
188 redaction: &RedactionConfig,
189 redacted_values: &[String],
190) {
191 if step.passed {
192 output.push_str(&format!(
193 " {} {} ({}ms)\n",
194 "✓".green(),
195 step.name,
196 step.duration_ms
197 ));
198 render_step_description_into(output, step);
199 } else if matches!(
200 step.error_category,
201 Some(FailureCategory::SkippedDueToFailedCapture)
202 | Some(FailureCategory::SkippedDueToFailFast)
203 ) {
204 output.push_str(&format!(
207 " {} {} (skipped)\n",
208 "⊘".yellow(),
209 step.name.yellow(),
210 ));
211 render_step_description_into(output, step);
212 if let Some(reason) = step.assertion_results.iter().find(|a| !a.passed) {
213 let reason = sanitize_assertion(reason, redaction, redacted_values);
214 output.push_str(&format!(
215 " {} {}\n",
216 "└─".dimmed(),
217 reason.message.yellow()
218 ));
219 }
220 } else {
221 output.push_str(&format!(
222 " {} {} ({}ms)\n",
223 "✗".red(),
224 step.name.red(),
225 step.duration_ms
226 ));
227 render_step_description_into(output, step);
228 let failures = step.failures();
230 let hints = step_hints(step);
231 let failure_count = failures.len();
232 let hint_count = hints.len();
233 for (i, failure) in failures.iter().enumerate() {
234 let failure = sanitize_assertion(failure, redaction, redacted_values);
235 let is_last_line = i == failure_count - 1 && hint_count == 0;
238 let connector = if is_last_line { "└─" } else { "├─" };
239 output.push_str(&format!(
240 " {} {}\n",
241 connector.dimmed(),
242 failure.message.red()
243 ));
244 if let Some(diff) = &failure.diff {
245 for line in diff.lines() {
246 let colored = if line.starts_with("---") || line.starts_with("+++") {
247 line.bold().to_string()
248 } else if line.starts_with('+') {
249 line.green().to_string()
250 } else if line.starts_with('-') {
251 line.red().to_string()
252 } else {
253 line.dimmed().to_string()
254 };
255 output.push_str(&format!(" {}\n", colored));
256 }
257 }
258 }
259
260 for (i, hint) in hints.iter().enumerate() {
265 let is_last_line = i == hint_count - 1;
266 let connector = if is_last_line { "└─" } else { "├─" };
267 output.push_str(&format!(" {} {}\n", connector.dimmed(), hint.dimmed()));
268 }
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::assert::types::*;
276 use std::collections::HashMap;
277
278 fn make_run_result(passed: bool) -> RunResult {
279 RunResult {
280 duration_ms: 100,
281 file_results: vec![FileResult {
282 file: "test.tarn.yaml".into(),
283 name: "Test Suite".into(),
284 passed,
285 duration_ms: 100,
286 redaction: crate::model::RedactionConfig::default(),
287 redacted_values: vec![],
288 setup_results: vec![],
289 test_results: vec![TestResult {
290 name: "my_test".into(),
291 description: Some("A test".into()),
292 passed,
293 duration_ms: 100,
294 step_results: vec![StepResult {
295 name: "Check status".into(),
296 description: None,
297 debug: false,
298 passed,
299 duration_ms: 50,
300 assertion_results: if passed {
301 vec![AssertionResult::pass("status", "200", "200")]
302 } else {
303 vec![AssertionResult::fail(
304 "status",
305 "200",
306 "404",
307 "Expected HTTP status 200, got 404",
308 )]
309 },
310 request_info: None,
311 response_info: None,
312 error_category: None,
313 response_status: None,
314 response_summary: None,
315 captures_set: vec![],
316 location: None,
317 response_shape_mismatch: None,
318 }],
319 captures: HashMap::new(),
320 }],
321 teardown_results: vec![],
322 }],
323 }
324 }
325
326 #[test]
327 fn render_passing_test() {
328 let result = make_run_result(true);
329 let output = render(&result);
330 assert!(output.contains("Test Suite"));
331 assert!(output.contains("Check status"));
332 assert!(output.contains("1")); }
334
335 #[test]
336 fn render_failing_test() {
337 let result = make_run_result(false);
338 let output = render(&result);
339 assert!(output.contains("Check status"));
340 assert!(output.contains("Expected HTTP status 200, got 404"));
341 }
342
343 #[test]
344 fn render_with_setup_and_teardown() {
345 let result = RunResult {
346 duration_ms: 200,
347 file_results: vec![FileResult {
348 file: "test.tarn.yaml".into(),
349 name: "Suite".into(),
350 passed: true,
351 duration_ms: 200,
352 redaction: crate::model::RedactionConfig::default(),
353 redacted_values: vec![],
354 setup_results: vec![StepResult {
355 name: "Auth".into(),
356 description: None,
357 debug: false,
358 passed: true,
359 duration_ms: 50,
360 assertion_results: vec![],
361 request_info: None,
362 response_info: None,
363 error_category: None,
364 response_status: None,
365 response_summary: None,
366 captures_set: vec![],
367 location: None,
368 response_shape_mismatch: None,
369 }],
370 test_results: vec![],
371 teardown_results: vec![StepResult {
372 name: "Cleanup".into(),
373 description: None,
374 debug: false,
375 passed: true,
376 duration_ms: 30,
377 assertion_results: vec![],
378 request_info: None,
379 response_info: None,
380 error_category: None,
381 response_status: None,
382 response_summary: None,
383 captures_set: vec![],
384 location: None,
385 response_shape_mismatch: None,
386 }],
387 }],
388 };
389 let output = render(&result);
390 assert!(output.contains("Setup"));
391 assert!(output.contains("Auth"));
392 assert!(output.contains("Teardown"));
393 assert!(output.contains("Cleanup"));
394 }
395
396 #[test]
397 fn render_multiple_failures_shows_all() {
398 let result = RunResult {
399 duration_ms: 100,
400 file_results: vec![FileResult {
401 file: "test.tarn.yaml".into(),
402 name: "Suite".into(),
403 passed: false,
404 duration_ms: 100,
405 redaction: crate::model::RedactionConfig::default(),
406 redacted_values: vec![],
407 setup_results: vec![],
408 test_results: vec![TestResult {
409 name: "test".into(),
410 description: None,
411 passed: false,
412 duration_ms: 100,
413 step_results: vec![StepResult {
414 name: "step".into(),
415 description: None,
416 debug: false,
417 passed: false,
418 duration_ms: 50,
419 assertion_results: vec![
420 AssertionResult::fail("status", "200", "403", "status mismatch"),
421 AssertionResult::fail(
422 "body $.error",
423 "\"ok\"",
424 "\"forbidden\"",
425 "body mismatch",
426 ),
427 ],
428 request_info: None,
429 response_info: None,
430 error_category: None,
431 response_status: None,
432 response_summary: None,
433 captures_set: vec![],
434 location: None,
435 response_shape_mismatch: None,
436 }],
437 captures: HashMap::new(),
438 }],
439 teardown_results: vec![],
440 }],
441 };
442 let output = render(&result);
443 assert!(output.contains("status mismatch"));
444 assert!(output.contains("body mismatch"));
445 }
446
447 #[test]
448 fn render_whole_body_diff() {
449 let result = RunResult {
450 duration_ms: 100,
451 file_results: vec![FileResult {
452 file: "test.tarn.yaml".into(),
453 name: "Suite".into(),
454 passed: false,
455 duration_ms: 100,
456 redaction: crate::model::RedactionConfig::default(),
457 redacted_values: vec![],
458 setup_results: vec![],
459 test_results: vec![TestResult {
460 name: "test".into(),
461 description: None,
462 passed: false,
463 duration_ms: 100,
464 step_results: vec![StepResult {
465 name: "step".into(),
466 description: None,
467 debug: false,
468 passed: false,
469 duration_ms: 50,
470 assertion_results: vec![AssertionResult::fail_with_diff(
471 "body $",
472 "{\"name\":\"Alice\"}",
473 "{\"name\":\"Bob\"}",
474 "whole body mismatch",
475 "--- expected\n+++ actual\n- \"name\": \"Alice\"\n+ \"name\": \"Bob\"\n",
476 )],
477 request_info: None,
478 response_info: None,
479 error_category: None,
480 response_status: None,
481 response_summary: None,
482 captures_set: vec![],
483 location: None,
484 response_shape_mismatch: None,
485 }],
486 captures: HashMap::new(),
487 }],
488 teardown_results: vec![],
489 }],
490 };
491 let output = render(&result);
492 assert!(output.contains("whole body mismatch"));
493 assert!(output.contains("--- expected"));
494 assert!(output.contains("+++ actual"));
495 }
496
497 #[test]
498 fn route_ordering_hint_rendered_example_snapshot() {
499 colored::control::set_override(false);
503
504 let mut headers = HashMap::new();
505 headers.insert("Content-Type".into(), "application/json".into());
506
507 let run = RunResult {
508 duration_ms: 12,
509 file_results: vec![FileResult {
510 file: "orders.tarn.yaml".into(),
511 name: "Orders API".into(),
512 passed: false,
513 duration_ms: 12,
514 redaction: crate::model::RedactionConfig::default(),
515 redacted_values: vec![],
516 setup_results: vec![],
517 test_results: vec![TestResult {
518 name: "approve_order".into(),
519 description: None,
520 passed: false,
521 duration_ms: 12,
522 step_results: vec![StepResult {
523 name: "POST /orders/approve".into(),
524 description: None,
525 debug: false,
526 passed: false,
527 duration_ms: 12,
528 assertion_results: vec![AssertionResult::fail(
529 "status",
530 "201",
531 "400",
532 "Expected HTTP status 201, got 400",
533 )],
534 request_info: Some(RequestInfo {
535 method: "POST".into(),
536 url: "http://api.example.com/orders/approve".into(),
537 headers,
538 body: None,
539 multipart: None,
540 }),
541 response_info: Some(ResponseInfo {
542 status: 400,
543 headers: HashMap::new(),
544 body: Some(serde_json::json!({
545 "statusCode": 400,
546 "message": "Validation failed (uuid is expected)",
547 "error": "Bad Request"
548 })),
549 }),
550 error_category: Some(FailureCategory::AssertionFailed),
551 response_status: Some(400),
552 response_summary: None,
553 captures_set: vec![],
554 location: None,
555 response_shape_mismatch: None,
556 }],
557 captures: HashMap::new(),
558 }],
559 teardown_results: vec![],
560 }],
561 };
562
563 let output = render(&run);
564 assert!(output.contains("✗ POST /orders/approve (12ms)"));
567 assert!(output.contains("├─ Expected HTTP status 201, got 400"));
568 assert!(output.contains(
569 "└─ note: the server may have matched this path to a dynamic route (e.g. /foo/:id); check for route ordering conflicts (see docs/TROUBLESHOOTING.md#route-ordering)."
570 ));
571
572 colored::control::unset_override();
573 }
574
575 #[test]
576 fn render_emits_route_ordering_hint_when_body_signals_it() {
577 let mut headers = HashMap::new();
578 headers.insert("Content-Type".into(), "application/json".into());
579
580 let run = RunResult {
581 duration_ms: 5,
582 file_results: vec![FileResult {
583 file: "test.tarn.yaml".into(),
584 name: "Suite".into(),
585 passed: false,
586 duration_ms: 5,
587 redaction: crate::model::RedactionConfig::default(),
588 redacted_values: vec![],
589 setup_results: vec![],
590 test_results: vec![TestResult {
591 name: "approve_order".into(),
592 description: None,
593 passed: false,
594 duration_ms: 5,
595 step_results: vec![StepResult {
596 name: "POST /orders/approve".into(),
597 description: None,
598 debug: false,
599 passed: false,
600 duration_ms: 5,
601 assertion_results: vec![AssertionResult::fail(
602 "status",
603 "201",
604 "400",
605 "Expected HTTP status 201, got 400",
606 )],
607 request_info: Some(RequestInfo {
608 method: "POST".into(),
609 url: "http://api.example.com/orders/approve".into(),
610 headers,
611 body: None,
612 multipart: None,
613 }),
614 response_info: Some(ResponseInfo {
615 status: 400,
616 headers: HashMap::new(),
617 body: Some(serde_json::json!({
618 "statusCode": 400,
619 "message": "Validation failed (uuid is expected)",
620 "error": "Bad Request"
621 })),
622 }),
623 error_category: Some(FailureCategory::AssertionFailed),
624 response_status: Some(400),
625 response_summary: None,
626 captures_set: vec![],
627 location: None,
628 response_shape_mismatch: None,
629 }],
630 captures: HashMap::new(),
631 }],
632 teardown_results: vec![],
633 }],
634 };
635
636 let output = render(&run);
637 assert!(
638 output.contains("route ordering"),
639 "expected route-ordering hint in output, got:\n{}",
640 output
641 );
642 assert!(output.contains("docs/TROUBLESHOOTING.md#route-ordering"));
643 }
644
645 #[test]
646 fn render_does_not_emit_route_ordering_hint_without_signal() {
647 let run = RunResult {
648 duration_ms: 5,
649 file_results: vec![FileResult {
650 file: "test.tarn.yaml".into(),
651 name: "Suite".into(),
652 passed: false,
653 duration_ms: 5,
654 redaction: crate::model::RedactionConfig::default(),
655 redacted_values: vec![],
656 setup_results: vec![],
657 test_results: vec![TestResult {
658 name: "approve_order".into(),
659 description: None,
660 passed: false,
661 duration_ms: 5,
662 step_results: vec![StepResult {
663 name: "POST /orders/approve".into(),
664 description: None,
665 debug: false,
666 passed: false,
667 duration_ms: 5,
668 assertion_results: vec![AssertionResult::fail(
669 "status",
670 "201",
671 "400",
672 "Expected HTTP status 201, got 400",
673 )],
674 request_info: Some(RequestInfo {
675 method: "POST".into(),
676 url: "http://api.example.com/orders/approve".into(),
677 headers: HashMap::new(),
678 body: None,
679 multipart: None,
680 }),
681 response_info: Some(ResponseInfo {
682 status: 400,
683 headers: HashMap::new(),
684 body: Some(serde_json::json!({"message": "Insufficient funds"})),
685 }),
686 error_category: Some(FailureCategory::AssertionFailed),
687 response_status: Some(400),
688 response_summary: None,
689 captures_set: vec![],
690 location: None,
691 response_shape_mismatch: None,
692 }],
693 captures: HashMap::new(),
694 }],
695 teardown_results: vec![],
696 }],
697 };
698
699 let output = render(&run);
700 assert!(!output.contains("route ordering"));
701 assert!(!output.contains("docs/TROUBLESHOOTING.md#route-ordering"));
702 }
703
704 #[test]
705 fn render_redacts_secret_values_in_messages() {
706 let result = RunResult {
707 duration_ms: 10,
708 file_results: vec![FileResult {
709 file: "test.tarn.yaml".into(),
710 name: "Suite".into(),
711 passed: false,
712 duration_ms: 10,
713 redaction: crate::model::RedactionConfig {
714 replacement: "[hidden]".into(),
715 ..crate::model::RedactionConfig::default()
716 },
717 redacted_values: vec!["secret-token".into()],
718 setup_results: vec![],
719 test_results: vec![TestResult {
720 name: "test".into(),
721 description: None,
722 passed: false,
723 duration_ms: 10,
724 step_results: vec![StepResult {
725 name: "step".into(),
726 description: None,
727 debug: false,
728 passed: false,
729 duration_ms: 10,
730 assertion_results: vec![AssertionResult::fail(
731 "status",
732 "200",
733 "401",
734 "Expected secret-token to be accepted",
735 )],
736 request_info: None,
737 response_info: None,
738 error_category: None,
739 response_status: None,
740 response_summary: None,
741 captures_set: vec![],
742 location: None,
743 response_shape_mismatch: None,
744 }],
745 captures: HashMap::new(),
746 }],
747 teardown_results: vec![],
748 }],
749 };
750
751 let output = render(&result);
752 assert!(!output.contains("secret-token"));
753 assert!(output.contains("Expected [hidden] to be accepted"));
754 }
755
756 fn strip_ansi(input: &str) -> String {
765 let mut out = String::with_capacity(input.len());
766 let mut chars = input.chars();
767 while let Some(c) = chars.next() {
768 if c == '\x1b' {
769 if chars.next() != Some('[') {
771 continue;
772 }
773 for ch in chars.by_ref() {
774 if ch == 'm' {
775 break;
776 }
777 }
778 } else {
779 out.push(c);
780 }
781 }
782 out
783 }
784
785 fn run_with_single_step(passed: bool, description: Option<&str>) -> RunResult {
791 RunResult {
792 duration_ms: 10,
793 file_results: vec![FileResult {
794 file: "test.tarn.yaml".into(),
795 name: "Suite".into(),
796 passed,
797 duration_ms: 10,
798 redaction: crate::model::RedactionConfig::default(),
799 redacted_values: vec![],
800 setup_results: vec![],
801 test_results: vec![TestResult {
802 name: "group".into(),
803 description: None,
804 passed,
805 duration_ms: 10,
806 step_results: vec![StepResult {
807 name: "Step name".into(),
808 description: description.map(str::to_string),
809 debug: false,
810 passed,
811 duration_ms: 5,
812 assertion_results: if passed {
813 vec![AssertionResult::pass("status", "200", "200")]
814 } else {
815 vec![AssertionResult::fail(
816 "status",
817 "200",
818 "500",
819 "status mismatch",
820 )]
821 },
822 request_info: None,
823 response_info: None,
824 error_category: None,
825 response_status: None,
826 response_summary: None,
827 captures_set: vec![],
828 location: None,
829 response_shape_mismatch: None,
830 }],
831 captures: HashMap::new(),
832 }],
833 teardown_results: vec![],
834 }],
835 }
836 }
837
838 #[test]
839 fn human_renders_step_description_for_passing_step() {
840 let result = run_with_single_step(true, Some("Checks /health"));
843 let output = strip_ansi(&render(&result));
844 assert!(
845 output.contains("Step name"),
846 "step name must still render, got: {}",
847 output
848 );
849 assert!(
850 output.contains("Checks /health"),
851 "description must render beneath the step, got: {}",
852 output
853 );
854 let name_pos = output.find("Step name").unwrap();
856 let desc_pos = output.find("Checks /health").unwrap();
857 assert!(
858 desc_pos > name_pos,
859 "description must render below the step name, got name@{} desc@{}",
860 name_pos,
861 desc_pos
862 );
863 }
864
865 #[test]
866 fn human_renders_step_description_for_failing_step() {
867 let result = run_with_single_step(false, Some("Checks /health"));
870 let output = strip_ansi(&render(&result));
871 assert!(output.contains("Checks /health"));
872 assert!(output.contains("status mismatch"));
873 }
874
875 #[test]
876 fn human_omits_step_description_line_when_missing() {
877 let result = run_with_single_step(true, None);
881 let output = strip_ansi(&render(&result));
882 assert!(output.contains("Step name"));
883 assert!(!output.contains("Checks /health"));
884 }
885
886 #[test]
887 fn human_renders_multi_line_step_description_indented() {
888 let result = run_with_single_step(true, Some("First line\nSecond line"));
891 let output = strip_ansi(&render(&result));
892 assert!(output.contains("First line"));
893 assert!(output.contains("Second line"));
894 assert!(output.contains(" First line"));
897 assert!(output.contains(" Second line"));
898 }
899}