Skip to content

Commit 6946bf9

Browse files
authored
Merge pull request #2502 from Tailoo/fix/2501-dotnet-test-failure-dedup
fix(dotnet): stop duplicating failures on failing test runs (#2501)
2 parents e3f60e9 + 5e7eab5 commit 6946bf9

1 file changed

Lines changed: 197 additions & 22 deletions

File tree

src/cmds/dotnet/dotnet_cmd.rs

Lines changed: 197 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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\nfail 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

Comments
 (0)