diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index a60935711..050e67370 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -90,8 +90,8 @@ pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result { eprintln!("Running: dotnet {} ...", subcommand); } - let result = exec_capture(&mut cmd) - .with_context(|| format!("Failed to run dotnet {}", subcommand))?; + let result = + exec_capture(&mut cmd).with_context(|| format!("Failed to run dotnet {}", subcommand))?; let raw = format!("{}\n{}", result.stdout, result.stderr); @@ -131,8 +131,8 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res } let command_started_at = SystemTime::now(); - let result = exec_capture(&mut cmd) - .with_context(|| format!("Failed to run dotnet {}", subcommand))?; + let result = + exec_capture(&mut cmd).with_context(|| format!("Failed to run dotnet {}", subcommand))?; let raw = format!("{}\n{}", result.stdout, result.stderr); let command_success = result.success(); @@ -147,10 +147,8 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res } else { binlog::BuildSummary::default() }; - let raw_summary = normalize_build_summary( - binlog::parse_build_from_text(&raw), - command_success, - ); + let raw_summary = + normalize_build_summary(binlog::parse_build_from_text(&raw), command_success); let summary = merge_build_summaries(binlog_summary, raw_summary); format_build_output(&summary, &binlog_path) } @@ -179,10 +177,8 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res } else { binlog::BuildSummary::default() }; - let raw_diagnostics = normalize_build_summary( - binlog::parse_build_from_text(&raw), - command_success, - ); + let raw_diagnostics = + normalize_build_summary(binlog::parse_build_from_text(&raw), command_success); let test_build_summary = merge_build_summaries(binlog_diagnostics, raw_diagnostics); format_test_output( &summary, @@ -200,10 +196,8 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res } else { binlog::RestoreSummary::default() }; - let raw_summary = normalize_restore_summary( - binlog::parse_restore_from_text(&raw), - command_success, - ); + let raw_summary = + normalize_restore_summary(binlog::parse_restore_from_text(&raw), command_success); let summary = merge_restore_summaries(binlog_summary, raw_summary); let (raw_errors, raw_warnings) = binlog::parse_restore_issues_from_text(&raw); @@ -485,9 +479,9 @@ fn build_effective_dotnet_args( TestRunnerMode::Classic }; - // --nologo: skip for MtpNative — args pass directly to the MTP runtime which - // does not understand MSBuild/VSTest flags. - if runner_mode != TestRunnerMode::MtpNative && !has_nologo_arg(args) { + // --nologo: skip for MTP mode — when global.json enables MTP, args may + // pass directly to the MTP runtime which does not understand MSBuild/VSTest flags. + if runner_mode != TestRunnerMode::MtpAfterSeparator && !has_nologo_arg(args) { effective.push("-nologo".to_string()); } @@ -506,18 +500,12 @@ fn build_effective_dotnet_args( } effective.extend(args.iter().cloned()); } - TestRunnerMode::MtpNative => { - // In .NET 10 native MTP mode, --report-trx is a direct dotnet test flag. - // Modern MTP frameworks (TUnit 1.19.74+, MSTest, xUnit with MTP runner) - // include Microsoft.Testing.Extensions.TrxReport natively. - if !has_report_trx_arg(args) { - effective.push("--report-trx".to_string()); - } - effective.extend(args.iter().cloned()); - } - TestRunnerMode::MtpVsTestBridge => { - // In VsTestBridge mode (supported on .NET 9 SDK and earlier), --report-trx - // goes after the -- separator so it reaches the MTP runtime. + TestRunnerMode::MtpAfterSeparator => { + // MTP mode (global.json or project-file properties): inject --report-trx + // after the -- separator. Works for MSTest/TUnit which bundle TrxReport. + // For xUnit v3 without Microsoft.Testing.Extensions.TrxReport, this causes + // an "Unknown option" error — informative, tells user to add the package. + // See xunit/xunit#3456 for the package addition. if !has_report_trx_arg(args) { effective.extend(inject_report_trx_into_args(args)); } else { @@ -556,19 +544,18 @@ fn has_verbosity_arg(args: &[String]) -> bool { enum TestRunnerMode { /// Classic VSTest runner. Inject `--logger trx --results-directory`. Classic, - /// Native MTP runner (`UseMicrosoftTestingPlatformRunner`, `UseTestingPlatformRunner`, or - /// global.json MTP mode). `--logger trx` breaks the run; inject `--report-trx` directly. - MtpNative, - /// VSTest bridge for MTP (`TestingPlatformDotnetTestSupport=true`). `--logger trx` is - /// silently ignored; MTP args must come after `--`. Inject `-- --report-trx`. - MtpVsTestBridge, + /// MTP mode (from global.json or project-file properties like + /// `UseMicrosoftTestingPlatformRunner`, `TestingPlatformDotnetTestSupport`). + /// `--logger trx` is silently ignored or breaks the run; inject `-- --report-trx` + /// after the separator so it reaches the MTP runtime. + MtpAfterSeparator, } /// Which MTP-related property a single MSBuild file declares. #[derive(Debug, PartialEq)] enum MtpProjectKind { None, - VsTestBridge, // UseMicrosoftTestingPlatformRunner | UseTestingPlatformRunner | TestingPlatformDotnetTestSupport + MtpDetected, // UseMicrosoftTestingPlatformRunner | UseTestingPlatformRunner | TestingPlatformDotnetTestSupport } /// Scans a single MSBuild file (.csproj / .fsproj / .vbproj / Directory.Build.props) for @@ -588,8 +575,8 @@ fn scan_mtp_kind_in_file(path: &Path) -> MtpProjectKind { match reader.read_event_into(&mut buf) { Ok(Event::Start(e)) => { let name_lower = e.local_name().as_ref().to_ascii_lowercase(); - // All project-file MTP properties run in VSTest bridge mode and require - // MTP-specific args to come after `--`. Only global.json MTP mode is native. + // All project-file MTP properties lead to MtpAfterSeparator mode — + // `--report-trx` must come after `--` separator. inside_mtp_element = matches!( name_lower.as_slice(), b"usemicrosofttestingplatformrunner" @@ -601,7 +588,7 @@ fn scan_mtp_kind_in_file(path: &Path) -> MtpProjectKind { if inside_mtp_element { if let Ok(text) = e.unescape() { if text.trim().eq_ignore_ascii_case("true") { - return MtpProjectKind::VsTestBridge; + return MtpProjectKind::MtpDetected; } } } @@ -630,17 +617,15 @@ fn parse_global_json_mtp_mode(path: &Path) -> bool { .is_some_and(|r| r.eq_ignore_ascii_case("Microsoft.Testing.Platform")) } -/// Checks whether the `global.json` closest to the current directory enables the .NET 10 +/// Checks whether the `global.json` closest to `start_dir` enables the .NET 10 /// native MTP mode (`"test": { "runner": "Microsoft.Testing.Platform" }`). -fn is_global_json_mtp_mode() -> bool { - let Ok(mut dir) = std::env::current_dir() else { - return false; - }; +fn is_global_json_mtp_mode_from(start_dir: &Path) -> bool { + let mut dir = start_dir.to_path_buf(); loop { let path = dir.join("global.json"); if path.exists() { let is_mtp = parse_global_json_mtp_mode(&path); - return is_mtp; // stop at first global.json found, regardless of result + return is_mtp; } if !dir.pop() { break; @@ -649,17 +634,38 @@ fn is_global_json_mtp_mode() -> bool { false } + /// Detects which test runner mode the targeted project(s) use. /// -/// Priority order: global.json (MtpNative) > project-file/Directory.Build.props (MtpVsTestBridge) > Classic. -/// `global.json` MTP mode is checked first because it overrides all project-level properties. +/// Priority: global.json MTP > project-file/Directory.Build.props (MtpAfterSeparator) > Classic. +/// Both global.json MTP and project-file MTP properties use MtpAfterSeparator mode — inject +/// `-- --report-trx`. This works for MSTest/TUnit which bundle TrxReport, and for xUnit v3 +/// users who add `Microsoft.Testing.Extensions.TrxReport`. For xUnit v3 without the extension, +/// `--report-trx` causes an "Unknown option" error which is informative (tells user to add the +/// package). Parsing NuGet references to detect TrxReport availability is impractical due to +/// central package management and MSBuild inheritance. fn detect_test_runner_mode(args: &[String]) -> TestRunnerMode { - // global.json MTP mode takes overall precedence — when set, dotnet test runs MTP - // natively regardless of project file properties. - if is_global_json_mtp_mode() { - return TestRunnerMode::MtpNative; + let Ok(dir) = std::env::current_dir() else { + return TestRunnerMode::Classic; + }; + detect_test_runner_mode_from(args, &dir) +} + +fn detect_test_runner_mode_from(args: &[String], start_dir: &Path) -> TestRunnerMode { + if is_global_json_mtp_mode_from(start_dir) { + return TestRunnerMode::MtpAfterSeparator; } + let found = scan_project_files_for_mtp(args); + + if found == MtpProjectKind::MtpDetected { + TestRunnerMode::MtpAfterSeparator + } else { + TestRunnerMode::Classic + } +} + +fn scan_project_files_for_mtp(args: &[String]) -> MtpProjectKind { let project_extensions = ["csproj", "fsproj", "vbproj"]; let explicit_projects: Vec<&str> = args @@ -673,16 +679,13 @@ fn detect_test_runner_mode(args: &[String]) -> TestRunnerMode { }) .collect(); - let mut found = MtpProjectKind::None; - if !explicit_projects.is_empty() { for p in &explicit_projects { - if scan_mtp_kind_in_file(Path::new(p)) == MtpProjectKind::VsTestBridge { - found = MtpProjectKind::VsTestBridge; + if scan_mtp_kind_in_file(Path::new(p)) == MtpProjectKind::MtpDetected { + return MtpProjectKind::MtpDetected; } } } else { - // No explicit project — scan current directory. if let Ok(entries) = std::fs::read_dir(".") { for entry in entries.flatten() { let name = entry.file_name(); @@ -690,27 +693,23 @@ fn detect_test_runner_mode(args: &[String]) -> TestRunnerMode { if project_extensions .iter() .any(|ext| name_str.ends_with(&format!(".{ext}"))) - && scan_mtp_kind_in_file(&entry.path()) == MtpProjectKind::VsTestBridge + && scan_mtp_kind_in_file(&entry.path()) == MtpProjectKind::MtpDetected { - found = MtpProjectKind::VsTestBridge; + return MtpProjectKind::MtpDetected; } } } } - if found == MtpProjectKind::VsTestBridge { - return TestRunnerMode::MtpVsTestBridge; - } - // Walk up from current directory looking for Directory.Build.props. if let Ok(mut dir) = std::env::current_dir() { loop { let props = dir.join("Directory.Build.props"); if props.exists() { - if scan_mtp_kind_in_file(&props) == MtpProjectKind::VsTestBridge { - return TestRunnerMode::MtpVsTestBridge; + if scan_mtp_kind_in_file(&props) == MtpProjectKind::MtpDetected { + return MtpProjectKind::MtpDetected; } - break; // only read the first (closest) Directory.Build.props + break; } if !dir.pop() { break; @@ -718,7 +717,7 @@ fn detect_test_runner_mode(args: &[String]) -> TestRunnerMode { } } - TestRunnerMode::Classic + MtpProjectKind::None } fn has_nologo_arg(args: &[String]) -> bool { @@ -1695,7 +1694,7 @@ mod tests { ) .expect("write csproj"); - assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::VsTestBridge); + assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::MtpDetected); } #[test] @@ -1712,7 +1711,7 @@ mod tests { ) .expect("write csproj"); - assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::VsTestBridge); + assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::MtpDetected); } #[test] @@ -1766,7 +1765,7 @@ mod tests { ) .expect("write csproj"); - assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::VsTestBridge); + assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::MtpDetected); } #[test] @@ -1784,8 +1783,8 @@ mod tests { ) .expect("write csproj"); - // All project-file properties → VsTestBridge; only global.json gives MtpNative - assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::VsTestBridge); + // All project-file properties → MtpAfterSeparator; global.json → MtpAfterSeparator + assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::MtpDetected); } #[test] @@ -1805,13 +1804,13 @@ mod tests { let args = vec![csproj.display().to_string()]; assert_eq!( detect_test_runner_mode(&args), - TestRunnerMode::MtpVsTestBridge + TestRunnerMode::MtpAfterSeparator ); let binlog_path = Path::new("/tmp/test.binlog"); let injected = build_effective_dotnet_args("test", &args, binlog_path, None); - // MTP VsTestBridge → --report-trx injected after --, no VSTest --logger trx + // MTP MtpAfterSeparator → --report-trx injected after --, no VSTest --logger trx assert!(!injected.contains(&"--logger".to_string())); assert!(injected.contains(&"--report-trx".to_string())); assert!(injected.contains(&"--".to_string())); @@ -1834,21 +1833,21 @@ mod tests { let args = vec![csproj.display().to_string()]; assert_eq!( detect_test_runner_mode(&args), - TestRunnerMode::MtpVsTestBridge + TestRunnerMode::MtpAfterSeparator ); let binlog_path = Path::new("/tmp/test.binlog"); let injected = build_effective_dotnet_args("test", &args, binlog_path, None); - // --report-trx injected after --, --nologo supported in bridge mode + // --report-trx injected after --; -nologo is skipped for MTP mode assert!(!injected.contains(&"--logger".to_string())); assert!(injected.contains(&"--report-trx".to_string())); assert!(injected.contains(&"--".to_string())); - assert!(injected.contains(&"-nologo".to_string())); + assert!(!injected.contains(&"-nologo".to_string())); } #[test] - fn test_parse_global_json_mtp_mode_detects_mtp_native() { + fn test_parse_global_json_mtp_mode_detects_mtp_runner() { let temp_dir = tempfile::tempdir().expect("create temp dir"); let global_json = temp_dir.path().join("global.json"); fs::write( @@ -1877,13 +1876,13 @@ mod tests { let args = vec![csproj.display().to_string()]; assert_eq!( detect_test_runner_mode(&args), - TestRunnerMode::MtpVsTestBridge + TestRunnerMode::MtpAfterSeparator ); let binlog_path = Path::new("/tmp/test.binlog"); let injected = build_effective_dotnet_args("test", &args, binlog_path, None); - // VsTestBridge → inject -- --report-trx after user args + // MtpAfterSeparator → inject -- --report-trx after user args assert!(injected.contains(&"--".to_string())); assert!(injected.contains(&"--report-trx".to_string())); let sep_pos = injected.iter().position(|a| a == "--").unwrap(); @@ -1985,7 +1984,7 @@ mod tests { ) .expect("write Directory.Build.props"); - assert_eq!(scan_mtp_kind_in_file(&props), MtpProjectKind::VsTestBridge); + assert_eq!(scan_mtp_kind_in_file(&props), MtpProjectKind::MtpDetected); } #[test] @@ -2011,6 +2010,62 @@ mod tests { assert!(!parse_global_json_mtp_mode(&global_json)); } + #[test] + fn test_detect_test_runner_mode_global_json_returns_mtp_after_separator() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let global_json = temp_dir.path().join("global.json"); + fs::write( + &global_json, + r#"{"sdk":{"version":"10.0.100"},"test":{"runner":"Microsoft.Testing.Platform"}}"#, + ) + .expect("write global.json"); + + let args: Vec = vec![]; + assert_eq!( + detect_test_runner_mode_from(&args, temp_dir.path()), + TestRunnerMode::MtpAfterSeparator + ); + } + + #[test] + fn test_detect_test_runner_mode_global_json_priority_over_csproj() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let global_json = temp_dir.path().join("global.json"); + fs::write( + &global_json, + r#"{"test":{"runner":"Microsoft.Testing.Platform"}}"#, + ) + .expect("write global.json"); + + let csproj = temp_dir.path().join("Classic.Tests.csproj"); + fs::write( + &csproj, + r#" + + net9.0 + +"#, + ) + .expect("write csproj without MTP properties"); + + let args = vec![csproj.display().to_string()]; + assert_eq!( + detect_test_runner_mode_from(&args, temp_dir.path()), + TestRunnerMode::MtpAfterSeparator + ); + } + + #[test] + fn test_detect_test_runner_mode_no_global_json_falls_back_to_classic() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + + let args: Vec = vec![]; + assert_eq!( + detect_test_runner_mode_from(&args, temp_dir.path()), + TestRunnerMode::Classic + ); + } + #[test] fn test_merge_test_summary_from_trx_uses_primary_and_cleans_file() { let temp_dir = tempfile::tempdir().expect("create temp dir"); @@ -2304,4 +2359,97 @@ mod tests { assert!(!missing_file.exists()); } + + #[test] + fn test_global_json_mtp_without_project_properties_detection() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let global_json = temp_dir.path().join("global.json"); + fs::write( + &global_json, + r#"{"test":{"runner":"Microsoft.Testing.Platform"}}"#, + ) + .expect("write global.json"); + + let classic_csproj = temp_dir.path().join("Classic.Tests.csproj"); + fs::write( + &classic_csproj, + r#" + + net9.0 + +"#, + ) + .expect("write csproj"); + + // Verify global.json MTP detection works + assert!(parse_global_json_mtp_mode(&global_json)); + // Verify classic csproj has no MTP properties + assert_eq!(scan_mtp_kind_in_file(&classic_csproj), MtpProjectKind::None); + } + + #[test] + fn test_global_json_mtp_with_project_properties_detects_vstest_bridge() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let csproj = temp_dir.path().join("MTP.Tests.csproj"); + fs::write( + &csproj, + r#" + + true + +"#, + ) + .expect("write csproj"); + + let args = vec![csproj.display().to_string()]; + assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::MtpDetected); + assert!( + scan_project_files_for_mtp(&args) == MtpProjectKind::MtpDetected, + "Project with UseMicrosoftTestingPlatformRunner should have MTP properties" + ); + } + + #[test] + fn test_altinn_scenario_global_json_mtp_no_project_properties() { + // Simulates the Altinn/altinn-register scenario: + // global.json has MTP mode, but no project-file MTP properties. + // With MtpAfterSeparator, -- --report-trx is injected. Projects without + // Microsoft.Testing.Extensions.TrxReport will get "Unknown option" error, + // which is informative — tells user to add the package. + + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let global_json = temp_dir.path().join("global.json"); + fs::write( + &global_json, + r#"{"test":{"runner":"Microsoft.Testing.Platform"}}"#, + ) + .expect("write global.json"); + + // xUnit v3 project without UseMicrosoftTestingPlatformRunner + let xunit_csproj = temp_dir.path().join("Xunit.Tests.csproj"); + fs::write( + &xunit_csproj, + r#" + + net9.0 + true + + + + + + +"#, + ) + .expect("write csproj"); + + assert!(parse_global_json_mtp_mode(&global_json)); + assert_eq!(scan_mtp_kind_in_file(&xunit_csproj), MtpProjectKind::None); + + let args = vec![xunit_csproj.display().to_string()]; + assert!( + scan_project_files_for_mtp(&args) == MtpProjectKind::None, + "xUnit project without MTP properties should not be detected as MTP" + ); + } }