1use std::path::Path;
2use std::process::Command;
3use std::time::Duration;
4
5use anyhow::Result;
6
7use super::util::{combined_output, duration_from_secs_safe, has_marker_in_subdirs, truncate};
8use super::{
9 ConfidenceScore, DetectionResult, TestAdapter, TestCase, TestError, TestRunResult, TestStatus,
10 TestSuite,
11};
12
13pub struct DotnetAdapter;
14
15impl Default for DotnetAdapter {
16 fn default() -> Self {
17 Self::new()
18 }
19}
20
21impl DotnetAdapter {
22 pub fn new() -> Self {
23 Self
24 }
25
26 fn has_dotnet_project(project_dir: &Path) -> bool {
27 if let Ok(entries) = std::fs::read_dir(project_dir) {
29 for entry in entries.flatten() {
30 let name = entry.file_name();
31 let name = name.to_string_lossy();
32 if name.ends_with(".csproj") || name.ends_with(".fsproj") || name.ends_with(".sln")
33 {
34 return true;
35 }
36 }
37 }
38 has_marker_in_subdirs(project_dir, 2, |name| {
40 name.ends_with(".csproj") || name.ends_with(".fsproj") || name.ends_with(".sln")
41 })
42 }
43
44 fn detect_project_type(project_dir: &Path) -> &'static str {
45 if let Ok(entries) = std::fs::read_dir(project_dir) {
46 for entry in entries.flatten() {
47 let name = entry.file_name();
48 let name = name.to_string_lossy();
49 if name.ends_with(".fsproj") {
50 return "F#";
51 }
52 }
53 }
54 if has_marker_in_subdirs(project_dir, 2, |name| name.ends_with(".fsproj")) {
56 return "F#";
57 }
58 "C#"
59 }
60
61 fn find_project_file(project_dir: &Path) -> Option<std::path::PathBuf> {
66 if let Ok(entries) = std::fs::read_dir(project_dir) {
68 for entry in entries.flatten() {
69 let name = entry.file_name();
70 let name_str = name.to_string_lossy();
71 if name_str.ends_with(".sln")
72 || name_str.ends_with(".csproj")
73 || name_str.ends_with(".fsproj")
74 {
75 return None;
76 }
77 }
78 }
79
80 let mut best_sln: Option<std::path::PathBuf> = None;
82 let mut best_proj: Option<std::path::PathBuf> = None;
83 Self::scan_for_project_files(project_dir, 2, &mut best_sln, &mut best_proj);
84 best_sln.or(best_proj)
85 }
86
87 fn scan_for_project_files(
88 dir: &Path,
89 depth: u8,
90 best_sln: &mut Option<std::path::PathBuf>,
91 best_proj: &mut Option<std::path::PathBuf>,
92 ) {
93 if best_sln.is_some() {
94 return;
95 }
96 let entries = match std::fs::read_dir(dir) {
97 Ok(e) => e,
98 Err(_) => return,
99 };
100 for entry in entries.flatten() {
101 let name = entry.file_name();
102 let name_str = name.to_string_lossy();
103 let path = entry.path();
104 if path.is_file() {
105 if name_str.ends_with(".sln") {
106 *best_sln = Some(path);
107 return; }
109 if name_str.ends_with(".csproj") || name_str.ends_with(".fsproj") {
110 let is_test_proj = name_str.contains("Test") || name_str.contains("test");
112 if is_test_proj || best_proj.is_none() {
113 *best_proj = Some(path);
114 }
115 }
116 } else if depth > 0 && path.is_dir() && !name_str.starts_with('.') {
117 let skip = matches!(
118 name_str.as_ref(),
119 "node_modules" | "vendor" | "target" | "bin" | "obj" | "packages"
120 );
121 if !skip {
122 Self::scan_for_project_files(&path, depth - 1, best_sln, best_proj);
123 }
124 }
125 }
126 }
127}
128
129impl TestAdapter for DotnetAdapter {
130 fn name(&self) -> &str {
131 "C#/.NET"
132 }
133
134 fn check_runner(&self) -> Option<String> {
135 if which::which("dotnet").is_err() {
136 return Some("dotnet not found. Install .NET SDK.".into());
137 }
138 None
139 }
140
141 fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
142 if !Self::has_dotnet_project(project_dir) {
143 return None;
144 }
145
146 let lang = Self::detect_project_type(project_dir);
147
148 let has_sln = std::fs::read_dir(project_dir)
150 .ok()
151 .map(|entries| {
152 entries
153 .flatten()
154 .any(|e| e.file_name().to_string_lossy().ends_with(".sln"))
155 })
156 .unwrap_or(false)
157 || has_marker_in_subdirs(project_dir, 2, |name| name.ends_with(".sln"));
158
159 let has_build_artifacts = project_dir.join("obj").is_dir()
161 || project_dir.join("bin").is_dir()
162 || has_marker_in_subdirs(project_dir, 2, |name| name == "obj" || name == "bin");
163
164 let confidence = ConfidenceScore::base(0.50)
165 .signal(0.15, has_sln)
166 .signal(0.10, has_build_artifacts)
167 .signal(0.15, which::which("dotnet").is_ok())
168 .finish();
169
170 Some(DetectionResult {
171 language: lang.into(),
172 framework: "dotnet test".into(),
173 confidence,
174 })
175 }
176
177 fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
178 let mut cmd = Command::new("dotnet");
179 cmd.arg("test");
180
181 if let Some(project_file) = Self::find_project_file(project_dir) {
183 cmd.arg(&project_file);
184 }
185
186 cmd.arg("--verbosity");
187 cmd.arg("normal");
188
189 for arg in extra_args {
190 cmd.arg(arg);
191 }
192
193 cmd.current_dir(project_dir);
194 Ok(cmd)
195 }
196
197 fn filter_args(&self, pattern: &str) -> Vec<String> {
198 vec!["--filter".to_string(), pattern.to_string()]
199 }
200
201 fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
202 let combined = combined_output(stdout, stderr);
203
204 let mut suites = parse_dotnet_output(&combined, exit_code);
205
206 let failures = parse_dotnet_failures(&combined);
208 if !failures.is_empty() {
209 enrich_with_errors(&mut suites, &failures);
210 }
211
212 let duration = parse_dotnet_duration(&combined).unwrap_or(Duration::from_secs(0));
213
214 TestRunResult {
215 suites,
216 duration,
217 raw_exit_code: exit_code,
218 }
219 }
220}
221
222fn is_dotnet_test_result_line(trimmed: &str) -> bool {
255 let rest = if let Some(r) = trimmed.strip_prefix("Passed ") {
256 r
257 } else if let Some(r) = trimmed.strip_prefix("Failed ") {
258 r
259 } else if let Some(r) = trimmed.strip_prefix("Skipped ") {
260 r
261 } else {
262 return false;
263 };
264
265 if rest.ends_with(']') && rest.contains('[') {
267 return true;
268 }
269
270 let name_part = rest.split_whitespace().next().unwrap_or("");
272 if name_part.contains('.') && !name_part.starts_with('.') {
273 return true;
274 }
275
276 false
277}
278
279fn parse_dotnet_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
280 let mut tests = Vec::new();
281 let mut found_summary = false;
282
283 for line in output.lines() {
285 let trimmed = line.trim();
286
287 if !is_dotnet_test_result_line(trimmed) {
288 } else if trimmed.starts_with("Passed ")
290 || trimmed.starts_with("Failed ")
291 || trimmed.starts_with("Skipped ")
292 {
293 let (status, rest) = if let Some(rest) = trimmed.strip_prefix("Passed ") {
294 (TestStatus::Passed, rest)
295 } else if let Some(rest) = trimmed.strip_prefix("Failed ") {
296 (TestStatus::Failed, rest)
297 } else if let Some(rest) = trimmed.strip_prefix("Skipped ") {
298 (TestStatus::Skipped, rest)
299 } else {
300 continue;
301 };
302
303 let name = rest
305 .rfind('[')
306 .map(|i| rest[..i].trim())
307 .unwrap_or(rest)
308 .to_string();
309
310 let duration = if let Some(bracket_start) = rest.rfind('[') {
312 let dur_str = &rest[bracket_start + 1..rest.len().saturating_sub(1)];
313 parse_dotnet_test_duration(dur_str)
314 } else {
315 Duration::from_millis(0)
316 };
317
318 tests.push(TestCase {
319 name,
320 status,
321 duration,
322 error: None,
323 });
324 }
325 }
326
327 if tests.is_empty() {
329 let mut total = 0usize;
330 let mut passed = 0usize;
331 let mut failed = 0usize;
332 let mut skipped = 0usize;
333
334 for line in output.lines() {
335 let trimmed = line.trim();
336 if let Some(rest) = trimmed.strip_prefix("Total tests:") {
337 total = rest.trim().parse().unwrap_or(0);
338 found_summary = true;
339 } else if let Some(rest) = trimmed.strip_prefix("Passed:") {
340 passed = rest.trim().parse().unwrap_or(0);
341 } else if let Some(rest) = trimmed.strip_prefix("Failed:") {
342 failed = rest.trim().parse().unwrap_or(0);
343 } else if let Some(rest) = trimmed.strip_prefix("Skipped:") {
344 skipped = rest.trim().parse().unwrap_or(0);
345 }
346 }
347
348 if found_summary && total > 0 {
349 if passed == 0 && failed + skipped < total {
351 passed = total - failed - skipped;
352 }
353 for i in 0..passed {
354 tests.push(TestCase {
355 name: format!("test_{}", i + 1),
356 status: TestStatus::Passed,
357 duration: Duration::from_millis(0),
358 error: None,
359 });
360 }
361 for i in 0..failed {
362 tests.push(TestCase {
363 name: format!("failed_test_{}", i + 1),
364 status: TestStatus::Failed,
365 duration: Duration::from_millis(0),
366 error: None,
367 });
368 }
369 for i in 0..skipped {
370 tests.push(TestCase {
371 name: format!("skipped_test_{}", i + 1),
372 status: TestStatus::Skipped,
373 duration: Duration::from_millis(0),
374 error: None,
375 });
376 }
377 }
378 }
379
380 if tests.is_empty() {
381 tests.push(TestCase {
382 name: "test_suite".into(),
383 status: if exit_code == 0 {
384 TestStatus::Passed
385 } else {
386 TestStatus::Failed
387 },
388 duration: Duration::from_millis(0),
389 error: None,
390 });
391 }
392
393 vec![TestSuite {
394 name: "tests".into(),
395 tests,
396 }]
397}
398
399fn parse_dotnet_test_duration(dur_str: &str) -> Duration {
400 let clean = dur_str.trim().trim_start_matches("< ");
402 let parts: Vec<&str> = clean.split_whitespace().collect();
403 if parts.len() >= 2 {
404 let value: f64 = parts[0].parse().unwrap_or(0.0);
405 match parts[1] {
406 "ms" => duration_from_secs_safe(value / 1000.0),
407 "s" => duration_from_secs_safe(value),
408 _ => Duration::from_millis(0),
409 }
410 } else {
411 Duration::from_millis(0)
412 }
413}
414
415fn parse_dotnet_duration(output: &str) -> Option<Duration> {
416 for line in output.lines() {
418 let trimmed = line.trim();
419 if trimmed.starts_with("Total time:") || trimmed.starts_with("Duration:") {
420 let num_str: String = trimmed
421 .chars()
422 .filter(|c| c.is_ascii_digit() || *c == '.')
423 .collect();
424 if let Ok(secs) = num_str.parse::<f64>() {
425 return Some(duration_from_secs_safe(secs));
426 }
427 }
428 }
429 None
430}
431
432#[derive(Debug, Clone)]
434#[allow(dead_code)]
435struct DotnetFailure {
436 test_name: String,
438 message: String,
440 stack_trace: Option<String>,
442 location: Option<String>,
444}
445
446fn parse_dotnet_failures(output: &str) -> Vec<DotnetFailure> {
468 let mut failures = Vec::new();
469 let lines: Vec<&str> = output.lines().collect();
470 let mut i = 0;
471
472 while i < lines.len() {
473 let trimmed = lines[i].trim();
474
475 let is_failed_test = if trimmed.starts_with("Failed ") {
477 is_dotnet_test_result_line(trimmed)
478 } else {
479 trimmed.starts_with("X ")
480 };
481 if is_failed_test {
482 let rest = if let Some(r) = trimmed.strip_prefix("Failed ") {
483 r
484 } else if let Some(r) = trimmed.strip_prefix("X ") {
485 r
486 } else {
487 i += 1;
488 continue;
489 };
490
491 let test_name = rest
493 .rfind('[')
494 .map(|idx| rest[..idx].trim())
495 .unwrap_or(rest)
496 .to_string();
497
498 i += 1;
499
500 let mut message_lines = Vec::new();
502 let mut stack_lines = Vec::new();
503 let mut in_message = false;
504 let mut in_stack = false;
505
506 while i < lines.len() {
507 let line = lines[i].trim();
508
509 if line == "Error Message:" || line.starts_with("Error Message:") {
511 in_message = true;
512 in_stack = false;
513 i += 1;
514 continue;
515 }
516 if line == "Stack Trace:" || line.starts_with("Stack Trace:") {
517 in_message = false;
518 in_stack = true;
519 i += 1;
520 continue;
521 }
522
523 if is_dotnet_test_result_line(line)
525 || line.starts_with("X ")
526 || line.starts_with("Test Run")
527 || line.starts_with("Total tests:")
528 {
529 break;
530 }
531
532 if in_message && !line.is_empty() {
533 message_lines.push(line.to_string());
534 } else if in_stack && !line.is_empty() {
535 stack_lines.push(line.to_string());
536 }
537
538 i += 1;
539 }
540
541 let message = if message_lines.is_empty() {
542 "Test failed".to_string()
543 } else {
544 truncate(&message_lines.join("\n"), 500)
545 };
546
547 let stack_trace = if stack_lines.is_empty() {
548 None
549 } else {
550 Some(
551 stack_lines
552 .iter()
553 .take(5)
554 .cloned()
555 .collect::<Vec<_>>()
556 .join("\n"),
557 )
558 };
559
560 let location = stack_lines.iter().find_map(|l| extract_dotnet_location(l));
561
562 failures.push(DotnetFailure {
563 test_name,
564 message,
565 stack_trace,
566 location,
567 });
568 continue;
569 }
570
571 i += 1;
572 }
573
574 failures
575}
576
577fn extract_dotnet_location(line: &str) -> Option<String> {
580 if let Some(in_idx) = line.find(" in ") {
582 let path_part = &line[in_idx + 4..];
583 let path = path_part.trim();
584 if !path.is_empty() {
585 return Some(path.to_string());
586 }
587 }
588 if (line.contains(".cs:") || line.contains(".fs:")) && line.contains("line ") {
590 return Some(line.trim().to_string());
591 }
592 None
593}
594
595fn enrich_with_errors(suites: &mut [TestSuite], failures: &[DotnetFailure]) {
597 for suite in suites.iter_mut() {
598 for test in suite.tests.iter_mut() {
599 if test.status != TestStatus::Failed || test.error.is_some() {
600 continue;
601 }
602 if let Some(failure) = find_matching_dotnet_failure(&test.name, failures) {
603 test.error = Some(TestError {
604 message: failure.message.clone(),
605 location: failure.location.clone(),
606 });
607 }
608 }
609 }
610}
611
612fn find_matching_dotnet_failure<'a>(
614 test_name: &str,
615 failures: &'a [DotnetFailure],
616) -> Option<&'a DotnetFailure> {
617 for failure in failures {
618 if failure.test_name == test_name {
619 return Some(failure);
620 }
621 if failure.test_name.ends_with(test_name) || test_name.ends_with(&failure.test_name) {
623 return Some(failure);
624 }
625 }
626 if failures.len() == 1 {
627 return Some(&failures[0]);
628 }
629 None
630}
631
632pub fn parse_trx_report(project_dir: &Path) -> Vec<TestSuite> {
637 let results_dir = project_dir.join("TestResults");
638 if !results_dir.is_dir() {
639 return Vec::new();
640 }
641
642 let mut suites = Vec::new();
643
644 if let Ok(entries) = std::fs::read_dir(&results_dir) {
645 for entry in entries.flatten() {
646 let name = entry.file_name();
647 let name = name.to_string_lossy();
648 if name.ends_with(".trx")
649 && let Ok(content) = std::fs::read_to_string(entry.path())
650 {
651 let mut parsed = parse_trx_content(&content);
652 suites.append(&mut parsed);
653 }
654 }
655 }
656
657 suites
658}
659
660fn parse_trx_content(content: &str) -> Vec<TestSuite> {
680 let mut tests = Vec::new();
681
682 let mut search_from = 0;
684
685 while let Some(start) = content[search_from..].find("<UnitTestResult") {
686 let abs_start = search_from + start;
687
688 let end = if let Some(close) = content[abs_start..].find("</UnitTestResult>") {
690 abs_start + close + 17
691 } else if let Some(self_close) = content[abs_start..].find("/>") {
692 abs_start + self_close + 2
693 } else {
694 break;
695 };
696
697 let element = &content[abs_start..end];
698
699 let test_name = extract_trx_attr(element, "testName").unwrap_or_else(|| "unknown".into());
700 let outcome = extract_trx_attr(element, "outcome").unwrap_or_default();
701 let duration_str = extract_trx_attr(element, "duration").unwrap_or_default();
702
703 let status = match outcome.as_str() {
704 "Passed" => TestStatus::Passed,
705 "Failed" => TestStatus::Failed,
706 "NotExecuted" | "Inconclusive" => TestStatus::Skipped,
707 _ => TestStatus::Failed,
708 };
709
710 let duration = parse_trx_duration(&duration_str);
711
712 let error = if status == TestStatus::Failed {
713 let message =
714 extract_trx_error_message(element).unwrap_or_else(|| "Test failed".into());
715 let location =
716 extract_trx_stack_trace(element).and_then(|st| extract_dotnet_location(&st));
717 Some(TestError { message, location })
718 } else {
719 None
720 };
721
722 tests.push(TestCase {
723 name: test_name,
724 status,
725 duration,
726 error,
727 });
728
729 search_from = end;
730 }
731
732 if tests.is_empty() {
733 return Vec::new();
734 }
735
736 vec![TestSuite {
737 name: "tests".into(),
738 tests,
739 }]
740}
741
742fn extract_trx_attr(element: &str, attr: &str) -> Option<String> {
744 let pattern = format!("{}=\"", attr);
745 let start = element.find(&pattern)?;
746 let value_start = start + pattern.len();
747 let value_end = element[value_start..].find('"')?;
748 Some(element[value_start..value_start + value_end].to_string())
749}
750
751fn parse_trx_duration(s: &str) -> Duration {
753 let parts: Vec<&str> = s.split(':').collect();
754 if parts.len() == 3 {
755 let hours: f64 = parts[0].parse().unwrap_or(0.0);
756 let mins: f64 = parts[1].parse().unwrap_or(0.0);
757 let secs: f64 = parts[2].parse().unwrap_or(0.0);
758 duration_from_secs_safe(hours * 3600.0 + mins * 60.0 + secs)
759 } else {
760 Duration::from_millis(0)
761 }
762}
763
764fn extract_trx_error_message(element: &str) -> Option<String> {
766 let msg_start = element.find("<Message>")?;
767 let msg_end = element[msg_start..].find("</Message>")?;
768 let message = &element[msg_start + 9..msg_start + msg_end];
769 Some(message.trim().to_string())
770}
771
772fn extract_trx_stack_trace(element: &str) -> Option<String> {
774 let st_start = element.find("<StackTrace>")?;
775 let st_end = element[st_start..].find("</StackTrace>")?;
776 let trace = &element[st_start + 12..st_start + st_end];
777 Some(trace.trim().to_string())
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 #[test]
785 fn detect_csproj() {
786 let dir = tempfile::tempdir().unwrap();
787 std::fs::write(dir.path().join("MyApp.csproj"), "<Project/>").unwrap();
788 let adapter = DotnetAdapter::new();
789 let det = adapter.detect(dir.path()).unwrap();
790 assert_eq!(det.language, "C#");
791 assert_eq!(det.framework, "dotnet test");
792 }
793
794 #[test]
795 fn detect_fsproj() {
796 let dir = tempfile::tempdir().unwrap();
797 std::fs::write(dir.path().join("MyApp.fsproj"), "<Project/>").unwrap();
798 let adapter = DotnetAdapter::new();
799 let det = adapter.detect(dir.path()).unwrap();
800 assert_eq!(det.language, "F#");
801 }
802
803 #[test]
804 fn detect_sln() {
805 let dir = tempfile::tempdir().unwrap();
806 std::fs::write(dir.path().join("MyApp.sln"), "").unwrap();
807 let adapter = DotnetAdapter::new();
808 assert!(adapter.detect(dir.path()).is_some());
809 }
810
811 #[test]
812 fn detect_no_dotnet() {
813 let dir = tempfile::tempdir().unwrap();
814 let adapter = DotnetAdapter::new();
815 assert!(adapter.detect(dir.path()).is_none());
816 }
817
818 #[test]
819 fn parse_dotnet_detailed_output() {
820 let stdout = r#"
821Starting test execution, please wait...
822A total of 1 test files matched the specified pattern.
823
824 Passed test_add [2 ms]
825 Passed test_subtract [< 1 ms]
826 Failed test_divide [3 ms]
827
828Test Run Failed.
829Total tests: 3
830 Passed: 2
831 Failed: 1
832 Skipped: 0
833"#;
834 let adapter = DotnetAdapter::new();
835 let result = adapter.parse_output(stdout, "", 1);
836
837 assert_eq!(result.total_tests(), 3);
838 assert_eq!(result.total_passed(), 2);
839 assert_eq!(result.total_failed(), 1);
840 }
841
842 #[test]
843 fn parse_dotnet_all_pass() {
844 let stdout = r#"
845 Passed test_add [2 ms]
846 Passed test_subtract [1 ms]
847
848Test Run Successful.
849Total tests: 2
850 Passed: 2
851 Failed: 0
852 Skipped: 0
853"#;
854 let adapter = DotnetAdapter::new();
855 let result = adapter.parse_output(stdout, "", 0);
856
857 assert_eq!(result.total_tests(), 2);
858 assert_eq!(result.total_passed(), 2);
859 assert!(result.is_success());
860 }
861
862 #[test]
863 fn parse_dotnet_summary_only() {
864 let stdout = r#"
865Test Run Successful.
866Total tests: 5
867 Passed: 4
868 Failed: 0
869 Skipped: 1
870"#;
871 let adapter = DotnetAdapter::new();
872 let result = adapter.parse_output(stdout, "", 0);
873
874 assert_eq!(result.total_tests(), 5);
875 assert_eq!(result.total_passed(), 4);
876 assert_eq!(result.total_skipped(), 1);
877 }
878
879 #[test]
880 fn parse_dotnet_empty_output() {
881 let adapter = DotnetAdapter::new();
882 let result = adapter.parse_output("", "", 0);
883
884 assert_eq!(result.total_tests(), 1);
885 assert!(result.is_success());
886 }
887
888 #[test]
889 fn parse_test_duration_ms() {
890 assert_eq!(parse_dotnet_test_duration("2 ms"), Duration::from_millis(2));
891 }
892
893 #[test]
894 fn parse_test_duration_lt_ms() {
895 assert_eq!(
896 parse_dotnet_test_duration("< 1 ms"),
897 Duration::from_millis(1)
898 );
899 }
900
901 #[test]
902 fn parse_dotnet_failure_blocks() {
903 let output = r#"
904 Passed test_add [2 ms]
905 Failed test_divide [< 1 ms]
906 Error Message:
907 Assert.Equal() Failure
908 Expected: 4
909 Actual: 3
910 Stack Trace:
911 at MyApp.Tests.MathTest.TestDivide() in /tests/MathTest.cs:line 42
912
913Test Run Failed.
914Total tests: 2
915 Passed: 1
916 Failed: 1
917"#;
918 let failures = parse_dotnet_failures(output);
919 assert_eq!(failures.len(), 1);
920 assert_eq!(failures[0].test_name, "test_divide");
921 assert!(failures[0].message.contains("Assert.Equal"));
922 assert!(failures[0].location.is_some());
923 assert!(
924 failures[0]
925 .location
926 .as_ref()
927 .unwrap()
928 .contains("MathTest.cs:line 42")
929 );
930 }
931
932 #[test]
933 fn parse_dotnet_multiple_failures() {
934 let output = r#"
935 Failed test_a [1 ms]
936 Error Message:
937 Expected True but got False
938 Stack Trace:
939 at Tests.A() in /tests/Test.cs:line 10
940
941 Failed test_b [2 ms]
942 Error Message:
943 Null reference
944 Stack Trace:
945 at Tests.B() in /tests/Test.cs:line 20
946
947Test Run Failed.
948"#;
949 let failures = parse_dotnet_failures(output);
950 assert_eq!(failures.len(), 2);
951 assert_eq!(failures[0].test_name, "test_a");
952 assert_eq!(failures[1].test_name, "test_b");
953 }
954
955 #[test]
956 fn parse_dotnet_failure_no_stack() {
957 let output = r#"
958 Failed test_x [1 ms]
959 Error Message:
960 Something went wrong
961
962 Passed test_y [1 ms]
963"#;
964 let failures = parse_dotnet_failures(output);
965 assert_eq!(failures.len(), 1);
966 assert!(failures[0].stack_trace.is_none());
967 }
968
969 #[test]
970 fn extract_dotnet_location_test() {
971 assert_eq!(
972 extract_dotnet_location(
973 "at MyApp.Tests.MathTest.TestDivide() in /tests/MathTest.cs:line 42"
974 ),
975 Some("/tests/MathTest.cs:line 42".into())
976 );
977 assert!(extract_dotnet_location("no location here").is_none());
978 }
979
980 #[test]
981 fn enrich_with_errors_test() {
982 let mut suites = vec![TestSuite {
983 name: "tests".into(),
984 tests: vec![
985 TestCase {
986 name: "test_add".into(),
987 status: TestStatus::Passed,
988 duration: Duration::from_millis(0),
989 error: None,
990 },
991 TestCase {
992 name: "test_divide".into(),
993 status: TestStatus::Failed,
994 duration: Duration::from_millis(0),
995 error: None,
996 },
997 ],
998 }];
999 let failures = vec![DotnetFailure {
1000 test_name: "test_divide".into(),
1001 message: "Assert.Equal failure".into(),
1002 stack_trace: Some("at Test.TestDivide() in /tests/Test.cs:line 42".into()),
1003 location: Some("/tests/Test.cs:line 42".into()),
1004 }];
1005 enrich_with_errors(&mut suites, &failures);
1006 assert!(suites[0].tests[0].error.is_none());
1007 let err = suites[0].tests[1].error.as_ref().unwrap();
1008 assert_eq!(err.message, "Assert.Equal failure");
1009 assert!(err.location.as_ref().unwrap().contains("Test.cs:line 42"));
1010 }
1011
1012 #[test]
1013 fn truncate_test() {
1014 assert_eq!(truncate("short", 100), "short");
1015 let long = "m".repeat(600);
1016 let truncated = truncate(&long, 500);
1017 assert!(truncated.ends_with("..."));
1018 }
1019
1020 #[test]
1021 fn parse_trx_basic() {
1022 let content = r#"<?xml version="1.0" encoding="UTF-8"?>
1023<TestRun>
1024 <Results>
1025 <UnitTestResult testName="TestAdd" outcome="Passed" duration="00:00:00.001">
1026 </UnitTestResult>
1027 <UnitTestResult testName="TestDiv" outcome="Failed" duration="00:00:00.002">
1028 <Output>
1029 <ErrorInfo>
1030 <Message>Assert.Equal failure</Message>
1031 <StackTrace>at Test.TestDiv() in /tests/Test.cs:line 42</StackTrace>
1032 </ErrorInfo>
1033 </Output>
1034 </UnitTestResult>
1035 </Results>
1036</TestRun>"#;
1037 let suites = parse_trx_content(content);
1038 assert_eq!(suites.len(), 1);
1039 assert_eq!(suites[0].tests.len(), 2);
1040 assert_eq!(suites[0].tests[0].name, "TestAdd");
1041 assert_eq!(suites[0].tests[0].status, TestStatus::Passed);
1042 assert_eq!(suites[0].tests[1].name, "TestDiv");
1043 assert_eq!(suites[0].tests[1].status, TestStatus::Failed);
1044 assert!(suites[0].tests[1].error.is_some());
1045 }
1046
1047 #[test]
1048 fn parse_trx_skipped() {
1049 let content = r#"<TestRun><Results>
1050<UnitTestResult testName="TestSkip" outcome="NotExecuted" duration="00:00:00.000"/>
1051</Results></TestRun>"#;
1052 let suites = parse_trx_content(content);
1053 assert_eq!(suites[0].tests[0].status, TestStatus::Skipped);
1054 }
1055
1056 #[test]
1057 fn parse_trx_duration_test() {
1058 assert_eq!(
1059 parse_trx_duration("00:00:01.500"),
1060 Duration::from_millis(1500)
1061 );
1062 assert_eq!(parse_trx_duration("00:01:00.000"), Duration::from_secs(60));
1063 }
1064
1065 #[test]
1066 fn extract_trx_attr_test() {
1067 assert_eq!(
1068 extract_trx_attr(
1069 r#"<UnitTestResult testName="TestAdd" outcome="Passed">"#,
1070 "testName"
1071 ),
1072 Some("TestAdd".into())
1073 );
1074 assert_eq!(
1075 extract_trx_attr(
1076 r#"<UnitTestResult testName="TestAdd" outcome="Passed">"#,
1077 "outcome"
1078 ),
1079 Some("Passed".into())
1080 );
1081 }
1082
1083 #[test]
1084 fn extract_trx_error_message_test() {
1085 let element =
1086 "<Output><ErrorInfo><Message>Assert.Equal failure</Message></ErrorInfo></Output>";
1087 assert_eq!(
1088 extract_trx_error_message(element),
1089 Some("Assert.Equal failure".into())
1090 );
1091 }
1092
1093 #[test]
1094 fn extract_trx_stack_trace_test() {
1095 let element = "<Output><ErrorInfo><StackTrace>at Test.Run() in Test.cs:line 10</StackTrace></ErrorInfo></Output>";
1096 assert_eq!(
1097 extract_trx_stack_trace(element),
1098 Some("at Test.Run() in Test.cs:line 10".into())
1099 );
1100 }
1101
1102 #[test]
1103 fn parse_dotnet_failure_integration() {
1104 let stdout = r#"
1105Starting test execution, please wait...
1106
1107 Passed test_add [2 ms]
1108 Failed test_divide [< 1 ms]
1109 Error Message:
1110 Assert.Equal() Failure
1111 Expected: 4
1112 Actual: 3
1113 Stack Trace:
1114 at Tests.Divide() in /tests/MathTest.cs:line 42
1115
1116Test Run Failed.
1117Total tests: 2
1118 Passed: 1
1119 Failed: 1
1120"#;
1121 let adapter = DotnetAdapter::new();
1122 let result = adapter.parse_output(stdout, "", 1);
1123
1124 assert_eq!(result.total_tests(), 2);
1125 assert_eq!(result.total_passed(), 1);
1126 assert_eq!(result.total_failed(), 1);
1127 let failed = result.suites[0]
1128 .tests
1129 .iter()
1130 .find(|t| t.status == TestStatus::Failed)
1131 .unwrap();
1132 assert!(failed.error.is_some());
1133 assert!(
1134 failed
1135 .error
1136 .as_ref()
1137 .unwrap()
1138 .message
1139 .contains("Assert.Equal")
1140 );
1141 }
1142}