@@ -138,7 +138,7 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res
138138 let raw = format ! ( "{}\n {}" , result. stdout, result. stderr) ;
139139 let command_success = result. success ( ) ;
140140
141- let filtered = match subcommand {
141+ let ( filtered, needs_raw_fallback ) = match subcommand {
142142 "build" => {
143143 let binlog_summary = if should_expect_binlog && binlog_path. exists ( ) {
144144 normalize_build_summary (
@@ -151,7 +151,7 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res
151151 let raw_summary =
152152 normalize_build_summary ( binlog:: parse_build_from_text ( & raw ) , command_success) ;
153153 let summary = merge_build_summaries ( binlog_summary, raw_summary) ;
154- format_build_output ( & summary, & binlog_path)
154+ ( format_build_output ( & summary, & binlog_path) , true )
155155 }
156156 "test" => {
157157 // First try to parse from binlog/console output
@@ -181,11 +181,18 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res
181181 let raw_diagnostics =
182182 normalize_build_summary ( binlog:: parse_build_from_text ( & raw ) , command_success) ;
183183 let test_build_summary = merge_build_summaries ( binlog_diagnostics, raw_diagnostics) ;
184- format_test_output (
185- & summary,
186- & test_build_summary. errors ,
187- & test_build_summary. warnings ,
188- & binlog_path,
184+ // The `Failed Tests:` section already carries failure detail parsed from
185+ // TRX/console; skip the raw-stdout prepend when it would only duplicate it.
186+ // See issue #2501.
187+ let needs_raw = test_needs_raw_fallback ( & summary) ;
188+ (
189+ format_test_output (
190+ & summary,
191+ & test_build_summary. errors ,
192+ & test_build_summary. warnings ,
193+ & binlog_path,
194+ ) ,
195+ needs_raw,
189196 )
190197 }
191198 "restore" => {
@@ -203,24 +210,21 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res
203210
204211 let ( raw_errors, raw_warnings) = binlog:: parse_restore_issues_from_text ( & raw ) ;
205212
206- format_restore_output ( & summary, & raw_errors, & raw_warnings, & binlog_path)
213+ (
214+ format_restore_output ( & summary, & raw_errors, & raw_warnings, & binlog_path) ,
215+ true ,
216+ )
207217 }
208- _ => raw. clone ( ) ,
218+ _ => ( raw. clone ( ) , true ) ,
209219 } ;
210220
211- let output_to_print = if !command_success {
212- let stdout_trimmed = result. stdout . trim ( ) ;
213- let stderr_trimmed = result. stderr . trim ( ) ;
214- if !stdout_trimmed. is_empty ( ) {
215- format ! ( "{}\n \n {}" , stdout_trimmed, filtered)
216- } else if !stderr_trimmed. is_empty ( ) {
217- format ! ( "{}\n \n {}" , stderr_trimmed, filtered)
218- } else {
219- filtered
220- }
221- } else {
222- filtered
223- } ;
221+ let output_to_print = compose_failure_output (
222+ command_success,
223+ needs_raw_fallback,
224+ & result. stdout ,
225+ & result. stderr ,
226+ & filtered,
227+ ) ;
224228
225229 println ! ( "{}" , output_to_print) ;
226230
@@ -1076,6 +1080,53 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S
10761080 . join ( "\n " )
10771081}
10781082
1083+ /// Decides whether the raw stdout/stderr should be prepended ahead of the
1084+ /// filtered `dotnet test` summary on a failing run.
1085+ ///
1086+ /// On failure the orchestrator can prepend the raw command output as a safety
1087+ /// net. For `test`, the filtered `Failed Tests:` section already reproduces each
1088+ /// failure (name + message + clipped stack) parsed from TRX/console, so the
1089+ /// prepend would only duplicate every failure block — the source of the +65%
1090+ /// inflation in issue #2501.
1091+ ///
1092+ /// Keep the raw fallback only when the structured section can't stand on its own:
1093+ /// no failures were parsed, the parsed list is shorter than `summary.failed`
1094+ /// (some failures never made it into the section), or some parsed failure has no
1095+ /// detail (filter blind).
1096+ fn test_needs_raw_fallback ( summary : & binlog:: TestSummary ) -> bool {
1097+ summary. failed_tests . is_empty ( )
1098+ || summary. failed_tests . len ( ) < summary. failed
1099+ || summary. failed_tests . iter ( ) . any ( |t| t. details . is_empty ( ) )
1100+ }
1101+
1102+ /// Composes the final output for a (possibly failing) run: the filtered summary,
1103+ /// optionally prefixed with raw stdout/stderr as a fallback.
1104+ ///
1105+ /// On success, or when `needs_raw_fallback` is false, only the filtered summary
1106+ /// is emitted. Otherwise the raw stdout (or stderr if stdout is empty) is
1107+ /// prepended so nothing is lost when the filter couldn't capture the failure.
1108+ fn compose_failure_output (
1109+ command_success : bool ,
1110+ needs_raw_fallback : bool ,
1111+ stdout : & str ,
1112+ stderr : & str ,
1113+ filtered : & str ,
1114+ ) -> String {
1115+ if command_success || !needs_raw_fallback {
1116+ return filtered. to_string ( ) ;
1117+ }
1118+
1119+ let stdout_trimmed = stdout. trim ( ) ;
1120+ let stderr_trimmed = stderr. trim ( ) ;
1121+ if !stdout_trimmed. is_empty ( ) {
1122+ format ! ( "{}\n \n {}" , stdout_trimmed, filtered)
1123+ } else if !stderr_trimmed. is_empty ( ) {
1124+ format ! ( "{}\n \n {}" , stderr_trimmed, filtered)
1125+ } else {
1126+ filtered. to_string ( )
1127+ }
1128+ }
1129+
10791130/// Format the test summary for stdout.
10801131///
10811132/// `_binlog_path` is intentionally unused — the binlog is a temporary file
@@ -1405,6 +1456,130 @@ mod tests {
14051456 assert ! ( output. contains( "MyTests.ShouldFail" ) ) ;
14061457 }
14071458
1459+ // Regression tests for issue #2501: on failing test runs the raw stdout was
1460+ // prepended in front of the filtered `Failed Tests:` section, duplicating every
1461+ // failure block (+65% vs raw). `test_needs_raw_fallback` must suppress the raw
1462+ // prepend when the structured section already carries failure detail, while
1463+ // keeping it when the filter couldn't capture the failures.
1464+
1465+ #[ test]
1466+ fn test_needs_raw_fallback_false_when_failures_have_detail ( ) {
1467+ // Every reported failure was parsed and carries detail: the structured
1468+ // section stands alone, so the raw prepend is dropped (issue #2501).
1469+ let failed_tests: Vec < binlog:: FailedTest > = ( 0 ..5 )
1470+ . map ( |i| binlog:: FailedTest {
1471+ name : format ! ( "MyTests.Case{i}" ) ,
1472+ details : vec ! [ "Assert.True() Failure" . to_string( ) ] ,
1473+ } )
1474+ . collect ( ) ;
1475+ let summary = binlog:: TestSummary {
1476+ passed : 717 ,
1477+ failed : 5 ,
1478+ skipped : 0 ,
1479+ total : 722 ,
1480+ project_count : 1 ,
1481+ failed_tests,
1482+ duration_text : Some ( "2 s" . to_string ( ) ) ,
1483+ } ;
1484+ assert ! ( !test_needs_raw_fallback( & summary) ) ;
1485+ }
1486+
1487+ #[ test]
1488+ fn test_needs_raw_fallback_true_when_parsed_list_incomplete ( ) {
1489+ // summary.failed reports 5, but only 3 blocks were parsed (each with
1490+ // detail). The 2 missing failures live only in raw stdout — keep the
1491+ // fallback so they aren't silently dropped.
1492+ let summary = binlog:: TestSummary {
1493+ passed : 717 ,
1494+ failed : 5 ,
1495+ skipped : 0 ,
1496+ total : 722 ,
1497+ project_count : 1 ,
1498+ failed_tests : vec ! [
1499+ binlog:: FailedTest {
1500+ name: "MyTests.One" . to_string( ) ,
1501+ details: vec![ "Assert.True() Failure" . to_string( ) ] ,
1502+ } ,
1503+ binlog:: FailedTest {
1504+ name: "MyTests.Two" . to_string( ) ,
1505+ details: vec![ "Assert.Equal() Failure" . to_string( ) ] ,
1506+ } ,
1507+ binlog:: FailedTest {
1508+ name: "MyTests.Three" . to_string( ) ,
1509+ details: vec![ "Assert.Null() Failure" . to_string( ) ] ,
1510+ } ,
1511+ ] ,
1512+ duration_text : Some ( "2 s" . to_string ( ) ) ,
1513+ } ;
1514+ assert ! ( test_needs_raw_fallback( & summary) ) ;
1515+ }
1516+
1517+ #[ test]
1518+ fn test_needs_raw_fallback_true_when_no_failures_parsed ( ) {
1519+ // Build failure / crash: command failed but nothing landed in failed_tests.
1520+ let summary = binlog:: TestSummary {
1521+ failed : 1 ,
1522+ total : 1 ,
1523+ ..Default :: default ( )
1524+ } ;
1525+ assert ! ( test_needs_raw_fallback( & summary) ) ;
1526+ }
1527+
1528+ #[ test]
1529+ fn test_needs_raw_fallback_true_when_a_failure_lacks_detail ( ) {
1530+ // Self-closing <UnitTestResult> with no <ErrorInfo>: name only, no detail.
1531+ let summary = binlog:: TestSummary {
1532+ failed : 1 ,
1533+ total : 1 ,
1534+ failed_tests : vec ! [ binlog:: FailedTest {
1535+ name: "MyTests.NoDetail" . to_string( ) ,
1536+ details: Vec :: new( ) ,
1537+ } ] ,
1538+ ..Default :: default ( )
1539+ } ;
1540+ assert ! ( test_needs_raw_fallback( & summary) ) ;
1541+ }
1542+
1543+ #[ test]
1544+ fn test_compose_failure_output_drops_raw_when_no_fallback_needed ( ) {
1545+ // The raw stdout contains the inline failure; the filtered section also
1546+ // contains it. With needs_raw_fallback=false, the failure must appear once.
1547+ let raw_stdout = " failed MyTests.HasRestriction\n Assert.True() Failure" ;
1548+ let filtered =
1549+ "Failed Tests:\n MyTests.HasRestriction\n Assert.True() Failure\n \n fail dotnet test: 717 passed, 5 failed" ;
1550+ let output = compose_failure_output ( false , false , raw_stdout, "" , filtered) ;
1551+
1552+ assert_eq ! ( output, filtered) ;
1553+ assert_eq ! ( output. matches( "HasRestriction" ) . count( ) , 1 ) ;
1554+ }
1555+
1556+ #[ test]
1557+ fn test_compose_failure_output_prepends_raw_when_fallback_needed ( ) {
1558+ let raw_stdout = "Build FAILED.\n Program.cs(1,1): error CS1002" ;
1559+ let filtered = "fail dotnet test: 0 passed, 1 failed" ;
1560+ // command_success=false, needs_raw_fallback=true → raw is prepended.
1561+ let output = compose_failure_output ( false , true , raw_stdout, "" , filtered) ;
1562+
1563+ assert ! ( output. starts_with( "Build FAILED." ) ) ;
1564+ assert ! ( output. ends_with( filtered) ) ;
1565+ }
1566+
1567+ #[ test]
1568+ fn test_compose_failure_output_uses_stderr_when_stdout_empty ( ) {
1569+ let filtered = "fail dotnet test: 0 passed, 1 failed" ;
1570+ let output = compose_failure_output ( false , true , " " , "boom on stderr" , filtered) ;
1571+
1572+ assert ! ( output. starts_with( "boom on stderr" ) ) ;
1573+ assert ! ( output. ends_with( filtered) ) ;
1574+ }
1575+
1576+ #[ test]
1577+ fn test_compose_failure_output_returns_filtered_on_success ( ) {
1578+ let filtered = "ok dotnet test: 722 tests passed" ;
1579+ let output = compose_failure_output ( true , true , "ignored raw" , "ignored" , filtered) ;
1580+ assert_eq ! ( output, filtered) ;
1581+ }
1582+
14081583 #[ test]
14091584 fn test_format_test_output_surfaces_warnings ( ) {
14101585 let summary = binlog:: TestSummary {
0 commit comments