1use std::path::Path;
2use std::process::Command;
3use std::time::Duration;
4
5use anyhow::Result;
6
7use super::util::duration_from_secs_safe;
8use super::{
9 DetectionResult, TestAdapter, TestCase, TestError, TestRunResult, TestStatus, TestSuite,
10};
11
12pub struct DotnetAdapter;
13
14impl Default for DotnetAdapter {
15 fn default() -> Self {
16 Self::new()
17 }
18}
19
20impl DotnetAdapter {
21 pub fn new() -> Self {
22 Self
23 }
24
25 fn has_dotnet_project(project_dir: &Path) -> bool {
26 if let Ok(entries) = std::fs::read_dir(project_dir) {
27 for entry in entries.flatten() {
28 let name = entry.file_name();
29 let name = name.to_string_lossy();
30 if name.ends_with(".csproj") || name.ends_with(".fsproj") || name.ends_with(".sln")
31 {
32 return true;
33 }
34 }
35 }
36 false
37 }
38
39 fn detect_project_type(project_dir: &Path) -> &'static str {
40 if let Ok(entries) = std::fs::read_dir(project_dir) {
41 for entry in entries.flatten() {
42 let name = entry.file_name();
43 let name = name.to_string_lossy();
44 if name.ends_with(".fsproj") {
45 return "F#";
46 }
47 }
48 }
49 "C#"
50 }
51}
52
53impl TestAdapter for DotnetAdapter {
54 fn name(&self) -> &str {
55 "C#/.NET"
56 }
57
58 fn check_runner(&self) -> Option<String> {
59 if which::which("dotnet").is_err() {
60 return Some("dotnet not found. Install .NET SDK.".into());
61 }
62 None
63 }
64
65 fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
66 if !Self::has_dotnet_project(project_dir) {
67 return None;
68 }
69
70 let lang = Self::detect_project_type(project_dir);
71
72 Some(DetectionResult {
73 language: lang.into(),
74 framework: "dotnet test".into(),
75 confidence: 0.95,
76 })
77 }
78
79 fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
80 let mut cmd = Command::new("dotnet");
81 cmd.arg("test");
82 cmd.arg("--verbosity");
83 cmd.arg("normal");
84
85 for arg in extra_args {
86 cmd.arg(arg);
87 }
88
89 cmd.current_dir(project_dir);
90 Ok(cmd)
91 }
92
93 fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
94 let combined = format!("{}\n{}", stdout, stderr);
95
96 let mut suites = parse_dotnet_output(&combined, exit_code);
97
98 let failures = parse_dotnet_failures(&combined);
100 if !failures.is_empty() {
101 enrich_with_errors(&mut suites, &failures);
102 }
103
104 let duration = parse_dotnet_duration(&combined).unwrap_or(Duration::from_secs(0));
105
106 TestRunResult {
107 suites,
108 duration,
109 raw_exit_code: exit_code,
110 }
111 }
112}
113
114fn parse_dotnet_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
134 let mut tests = Vec::new();
135 let mut found_summary = false;
136
137 for line in output.lines() {
139 let trimmed = line.trim();
140
141 if trimmed.starts_with("Passed ")
142 || trimmed.starts_with("Failed ")
143 || trimmed.starts_with("Skipped ")
144 {
145 let (status, rest) = if let Some(rest) = trimmed.strip_prefix("Passed ") {
146 (TestStatus::Passed, rest)
147 } else if let Some(rest) = trimmed.strip_prefix("Failed ") {
148 (TestStatus::Failed, rest)
149 } else if let Some(rest) = trimmed.strip_prefix("Skipped ") {
150 (TestStatus::Skipped, rest)
151 } else {
152 continue;
153 };
154
155 let name = rest
157 .rfind('[')
158 .map(|i| rest[..i].trim())
159 .unwrap_or(rest)
160 .to_string();
161
162 let duration = if let Some(bracket_start) = rest.rfind('[') {
164 let dur_str = &rest[bracket_start + 1..rest.len().saturating_sub(1)];
165 parse_dotnet_test_duration(dur_str)
166 } else {
167 Duration::from_millis(0)
168 };
169
170 tests.push(TestCase {
171 name,
172 status,
173 duration,
174 error: None,
175 });
176 }
177 }
178
179 if tests.is_empty() {
181 let mut total = 0usize;
182 let mut passed = 0usize;
183 let mut failed = 0usize;
184 let mut skipped = 0usize;
185
186 for line in output.lines() {
187 let trimmed = line.trim();
188 if let Some(rest) = trimmed.strip_prefix("Total tests:") {
189 total = rest.trim().parse().unwrap_or(0);
190 found_summary = true;
191 } else if let Some(rest) = trimmed.strip_prefix("Passed:") {
192 passed = rest.trim().parse().unwrap_or(0);
193 } else if let Some(rest) = trimmed.strip_prefix("Failed:") {
194 failed = rest.trim().parse().unwrap_or(0);
195 } else if let Some(rest) = trimmed.strip_prefix("Skipped:") {
196 skipped = rest.trim().parse().unwrap_or(0);
197 }
198 }
199
200 if found_summary && total > 0 {
201 if passed == 0 && failed + skipped < total {
203 passed = total - failed - skipped;
204 }
205 for i in 0..passed {
206 tests.push(TestCase {
207 name: format!("test_{}", i + 1),
208 status: TestStatus::Passed,
209 duration: Duration::from_millis(0),
210 error: None,
211 });
212 }
213 for i in 0..failed {
214 tests.push(TestCase {
215 name: format!("failed_test_{}", i + 1),
216 status: TestStatus::Failed,
217 duration: Duration::from_millis(0),
218 error: None,
219 });
220 }
221 for i in 0..skipped {
222 tests.push(TestCase {
223 name: format!("skipped_test_{}", i + 1),
224 status: TestStatus::Skipped,
225 duration: Duration::from_millis(0),
226 error: None,
227 });
228 }
229 }
230 }
231
232 if tests.is_empty() {
233 tests.push(TestCase {
234 name: "test_suite".into(),
235 status: if exit_code == 0 {
236 TestStatus::Passed
237 } else {
238 TestStatus::Failed
239 },
240 duration: Duration::from_millis(0),
241 error: None,
242 });
243 }
244
245 vec![TestSuite {
246 name: "tests".into(),
247 tests,
248 }]
249}
250
251fn parse_dotnet_test_duration(dur_str: &str) -> Duration {
252 let clean = dur_str.trim().trim_start_matches("< ");
254 let parts: Vec<&str> = clean.split_whitespace().collect();
255 if parts.len() >= 2 {
256 let value: f64 = parts[0].parse().unwrap_or(0.0);
257 match parts[1] {
258 "ms" => duration_from_secs_safe(value / 1000.0),
259 "s" => duration_from_secs_safe(value),
260 _ => Duration::from_millis(0),
261 }
262 } else {
263 Duration::from_millis(0)
264 }
265}
266
267fn parse_dotnet_duration(output: &str) -> Option<Duration> {
268 for line in output.lines() {
270 let trimmed = line.trim();
271 if trimmed.starts_with("Total time:") || trimmed.starts_with("Duration:") {
272 let num_str: String = trimmed
273 .chars()
274 .filter(|c| c.is_ascii_digit() || *c == '.')
275 .collect();
276 if let Ok(secs) = num_str.parse::<f64>() {
277 return Some(duration_from_secs_safe(secs));
278 }
279 }
280 }
281 None
282}
283
284#[derive(Debug, Clone)]
286#[allow(dead_code)]
287struct DotnetFailure {
288 test_name: String,
290 message: String,
292 stack_trace: Option<String>,
294 location: Option<String>,
296}
297
298fn parse_dotnet_failures(output: &str) -> Vec<DotnetFailure> {
320 let mut failures = Vec::new();
321 let lines: Vec<&str> = output.lines().collect();
322 let mut i = 0;
323
324 while i < lines.len() {
325 let trimmed = lines[i].trim();
326
327 if trimmed.starts_with("Failed ") || trimmed.starts_with("X ") {
329 let rest = if let Some(r) = trimmed.strip_prefix("Failed ") {
330 r
331 } else if let Some(r) = trimmed.strip_prefix("X ") {
332 r
333 } else {
334 i += 1;
335 continue;
336 };
337
338 let test_name = rest
340 .rfind('[')
341 .map(|idx| rest[..idx].trim())
342 .unwrap_or(rest)
343 .to_string();
344
345 i += 1;
346
347 let mut message_lines = Vec::new();
349 let mut stack_lines = Vec::new();
350 let mut in_message = false;
351 let mut in_stack = false;
352
353 while i < lines.len() {
354 let line = lines[i].trim();
355
356 if line == "Error Message:" || line.starts_with("Error Message:") {
358 in_message = true;
359 in_stack = false;
360 i += 1;
361 continue;
362 }
363 if line == "Stack Trace:" || line.starts_with("Stack Trace:") {
364 in_message = false;
365 in_stack = true;
366 i += 1;
367 continue;
368 }
369
370 if line.starts_with("Passed ")
372 || line.starts_with("Failed ")
373 || line.starts_with("Skipped ")
374 || line.starts_with("X ")
375 || line.starts_with("Test Run")
376 || line.starts_with("Total tests:")
377 {
378 break;
379 }
380
381 if in_message && !line.is_empty() {
382 message_lines.push(line.to_string());
383 } else if in_stack && !line.is_empty() {
384 stack_lines.push(line.to_string());
385 }
386
387 i += 1;
388 }
389
390 let message = if message_lines.is_empty() {
391 "Test failed".to_string()
392 } else {
393 truncate_dotnet_message(&message_lines.join("\n"), 500)
394 };
395
396 let stack_trace = if stack_lines.is_empty() {
397 None
398 } else {
399 Some(
400 stack_lines
401 .iter()
402 .take(5)
403 .cloned()
404 .collect::<Vec<_>>()
405 .join("\n"),
406 )
407 };
408
409 let location = stack_lines.iter().find_map(|l| extract_dotnet_location(l));
410
411 failures.push(DotnetFailure {
412 test_name,
413 message,
414 stack_trace,
415 location,
416 });
417 continue;
418 }
419
420 i += 1;
421 }
422
423 failures
424}
425
426fn extract_dotnet_location(line: &str) -> Option<String> {
429 if let Some(in_idx) = line.find(" in ") {
431 let path_part = &line[in_idx + 4..];
432 let path = path_part.trim();
433 if !path.is_empty() {
434 return Some(path.to_string());
435 }
436 }
437 if (line.contains(".cs:") || line.contains(".fs:")) && line.contains("line ") {
439 return Some(line.trim().to_string());
440 }
441 None
442}
443
444fn truncate_dotnet_message(msg: &str, max_len: usize) -> String {
446 if msg.len() <= max_len {
447 msg.to_string()
448 } else {
449 format!("{}...", &msg[..max_len])
450 }
451}
452
453fn enrich_with_errors(suites: &mut [TestSuite], failures: &[DotnetFailure]) {
455 for suite in suites.iter_mut() {
456 for test in suite.tests.iter_mut() {
457 if test.status != TestStatus::Failed || test.error.is_some() {
458 continue;
459 }
460 if let Some(failure) = find_matching_dotnet_failure(&test.name, failures) {
461 test.error = Some(TestError {
462 message: failure.message.clone(),
463 location: failure.location.clone(),
464 });
465 }
466 }
467 }
468}
469
470fn find_matching_dotnet_failure<'a>(
472 test_name: &str,
473 failures: &'a [DotnetFailure],
474) -> Option<&'a DotnetFailure> {
475 for failure in failures {
476 if failure.test_name == test_name {
477 return Some(failure);
478 }
479 if failure.test_name.ends_with(test_name) || test_name.ends_with(&failure.test_name) {
481 return Some(failure);
482 }
483 }
484 if failures.len() == 1 {
485 return Some(&failures[0]);
486 }
487 None
488}
489
490pub fn parse_trx_report(project_dir: &Path) -> Vec<TestSuite> {
495 let results_dir = project_dir.join("TestResults");
496 if !results_dir.is_dir() {
497 return Vec::new();
498 }
499
500 let mut suites = Vec::new();
501
502 if let Ok(entries) = std::fs::read_dir(&results_dir) {
503 for entry in entries.flatten() {
504 let name = entry.file_name();
505 let name = name.to_string_lossy();
506 if name.ends_with(".trx")
507 && let Ok(content) = std::fs::read_to_string(entry.path())
508 {
509 let mut parsed = parse_trx_content(&content);
510 suites.append(&mut parsed);
511 }
512 }
513 }
514
515 suites
516}
517
518fn parse_trx_content(content: &str) -> Vec<TestSuite> {
538 let mut tests = Vec::new();
539
540 let mut search_from = 0;
542
543 while let Some(start) = content[search_from..].find("<UnitTestResult") {
544 let abs_start = search_from + start;
545
546 let end = if let Some(close) = content[abs_start..].find("</UnitTestResult>") {
548 abs_start + close + 17
549 } else if let Some(self_close) = content[abs_start..].find("/>") {
550 abs_start + self_close + 2
551 } else {
552 break;
553 };
554
555 let element = &content[abs_start..end];
556
557 let test_name = extract_trx_attr(element, "testName").unwrap_or_else(|| "unknown".into());
558 let outcome = extract_trx_attr(element, "outcome").unwrap_or_default();
559 let duration_str = extract_trx_attr(element, "duration").unwrap_or_default();
560
561 let status = match outcome.as_str() {
562 "Passed" => TestStatus::Passed,
563 "Failed" => TestStatus::Failed,
564 "NotExecuted" | "Inconclusive" => TestStatus::Skipped,
565 _ => TestStatus::Failed,
566 };
567
568 let duration = parse_trx_duration(&duration_str);
569
570 let error = if status == TestStatus::Failed {
571 let message =
572 extract_trx_error_message(element).unwrap_or_else(|| "Test failed".into());
573 let location =
574 extract_trx_stack_trace(element).and_then(|st| extract_dotnet_location(&st));
575 Some(TestError { message, location })
576 } else {
577 None
578 };
579
580 tests.push(TestCase {
581 name: test_name,
582 status,
583 duration,
584 error,
585 });
586
587 search_from = end;
588 }
589
590 if tests.is_empty() {
591 return Vec::new();
592 }
593
594 vec![TestSuite {
595 name: "tests".into(),
596 tests,
597 }]
598}
599
600fn extract_trx_attr(element: &str, attr: &str) -> Option<String> {
602 let pattern = format!("{}=\"", attr);
603 let start = element.find(&pattern)?;
604 let value_start = start + pattern.len();
605 let value_end = element[value_start..].find('"')?;
606 Some(element[value_start..value_start + value_end].to_string())
607}
608
609fn parse_trx_duration(s: &str) -> Duration {
611 let parts: Vec<&str> = s.split(':').collect();
612 if parts.len() == 3 {
613 let hours: f64 = parts[0].parse().unwrap_or(0.0);
614 let mins: f64 = parts[1].parse().unwrap_or(0.0);
615 let secs: f64 = parts[2].parse().unwrap_or(0.0);
616 duration_from_secs_safe(hours * 3600.0 + mins * 60.0 + secs)
617 } else {
618 Duration::from_millis(0)
619 }
620}
621
622fn extract_trx_error_message(element: &str) -> Option<String> {
624 let msg_start = element.find("<Message>")?;
625 let msg_end = element[msg_start..].find("</Message>")?;
626 let message = &element[msg_start + 9..msg_start + msg_end];
627 Some(message.trim().to_string())
628}
629
630fn extract_trx_stack_trace(element: &str) -> Option<String> {
632 let st_start = element.find("<StackTrace>")?;
633 let st_end = element[st_start..].find("</StackTrace>")?;
634 let trace = &element[st_start + 12..st_start + st_end];
635 Some(trace.trim().to_string())
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 #[test]
643 fn detect_csproj() {
644 let dir = tempfile::tempdir().unwrap();
645 std::fs::write(dir.path().join("MyApp.csproj"), "<Project/>").unwrap();
646 let adapter = DotnetAdapter::new();
647 let det = adapter.detect(dir.path()).unwrap();
648 assert_eq!(det.language, "C#");
649 assert_eq!(det.framework, "dotnet test");
650 }
651
652 #[test]
653 fn detect_fsproj() {
654 let dir = tempfile::tempdir().unwrap();
655 std::fs::write(dir.path().join("MyApp.fsproj"), "<Project/>").unwrap();
656 let adapter = DotnetAdapter::new();
657 let det = adapter.detect(dir.path()).unwrap();
658 assert_eq!(det.language, "F#");
659 }
660
661 #[test]
662 fn detect_sln() {
663 let dir = tempfile::tempdir().unwrap();
664 std::fs::write(dir.path().join("MyApp.sln"), "").unwrap();
665 let adapter = DotnetAdapter::new();
666 assert!(adapter.detect(dir.path()).is_some());
667 }
668
669 #[test]
670 fn detect_no_dotnet() {
671 let dir = tempfile::tempdir().unwrap();
672 let adapter = DotnetAdapter::new();
673 assert!(adapter.detect(dir.path()).is_none());
674 }
675
676 #[test]
677 fn parse_dotnet_detailed_output() {
678 let stdout = r#"
679Starting test execution, please wait...
680A total of 1 test files matched the specified pattern.
681
682 Passed test_add [2 ms]
683 Passed test_subtract [< 1 ms]
684 Failed test_divide [3 ms]
685
686Test Run Failed.
687Total tests: 3
688 Passed: 2
689 Failed: 1
690 Skipped: 0
691"#;
692 let adapter = DotnetAdapter::new();
693 let result = adapter.parse_output(stdout, "", 1);
694
695 assert_eq!(result.total_tests(), 3);
696 assert_eq!(result.total_passed(), 2);
697 assert_eq!(result.total_failed(), 1);
698 }
699
700 #[test]
701 fn parse_dotnet_all_pass() {
702 let stdout = r#"
703 Passed test_add [2 ms]
704 Passed test_subtract [1 ms]
705
706Test Run Successful.
707Total tests: 2
708 Passed: 2
709 Failed: 0
710 Skipped: 0
711"#;
712 let adapter = DotnetAdapter::new();
713 let result = adapter.parse_output(stdout, "", 0);
714
715 assert_eq!(result.total_tests(), 2);
716 assert_eq!(result.total_passed(), 2);
717 assert!(result.is_success());
718 }
719
720 #[test]
721 fn parse_dotnet_summary_only() {
722 let stdout = r#"
723Test Run Successful.
724Total tests: 5
725 Passed: 4
726 Failed: 0
727 Skipped: 1
728"#;
729 let adapter = DotnetAdapter::new();
730 let result = adapter.parse_output(stdout, "", 0);
731
732 assert_eq!(result.total_tests(), 5);
733 assert_eq!(result.total_passed(), 4);
734 assert_eq!(result.total_skipped(), 1);
735 }
736
737 #[test]
738 fn parse_dotnet_empty_output() {
739 let adapter = DotnetAdapter::new();
740 let result = adapter.parse_output("", "", 0);
741
742 assert_eq!(result.total_tests(), 1);
743 assert!(result.is_success());
744 }
745
746 #[test]
747 fn parse_test_duration_ms() {
748 assert_eq!(parse_dotnet_test_duration("2 ms"), Duration::from_millis(2));
749 }
750
751 #[test]
752 fn parse_test_duration_lt_ms() {
753 assert_eq!(
754 parse_dotnet_test_duration("< 1 ms"),
755 Duration::from_millis(1)
756 );
757 }
758
759 #[test]
760 fn parse_dotnet_failure_blocks() {
761 let output = r#"
762 Passed test_add [2 ms]
763 Failed test_divide [< 1 ms]
764 Error Message:
765 Assert.Equal() Failure
766 Expected: 4
767 Actual: 3
768 Stack Trace:
769 at MyApp.Tests.MathTest.TestDivide() in /tests/MathTest.cs:line 42
770
771Test Run Failed.
772Total tests: 2
773 Passed: 1
774 Failed: 1
775"#;
776 let failures = parse_dotnet_failures(output);
777 assert_eq!(failures.len(), 1);
778 assert_eq!(failures[0].test_name, "test_divide");
779 assert!(failures[0].message.contains("Assert.Equal"));
780 assert!(failures[0].location.is_some());
781 assert!(
782 failures[0]
783 .location
784 .as_ref()
785 .unwrap()
786 .contains("MathTest.cs:line 42")
787 );
788 }
789
790 #[test]
791 fn parse_dotnet_multiple_failures() {
792 let output = r#"
793 Failed test_a [1 ms]
794 Error Message:
795 Expected True but got False
796 Stack Trace:
797 at Tests.A() in /tests/Test.cs:line 10
798
799 Failed test_b [2 ms]
800 Error Message:
801 Null reference
802 Stack Trace:
803 at Tests.B() in /tests/Test.cs:line 20
804
805Test Run Failed.
806"#;
807 let failures = parse_dotnet_failures(output);
808 assert_eq!(failures.len(), 2);
809 assert_eq!(failures[0].test_name, "test_a");
810 assert_eq!(failures[1].test_name, "test_b");
811 }
812
813 #[test]
814 fn parse_dotnet_failure_no_stack() {
815 let output = r#"
816 Failed test_x [1 ms]
817 Error Message:
818 Something went wrong
819
820 Passed test_y [1 ms]
821"#;
822 let failures = parse_dotnet_failures(output);
823 assert_eq!(failures.len(), 1);
824 assert!(failures[0].stack_trace.is_none());
825 }
826
827 #[test]
828 fn extract_dotnet_location_test() {
829 assert_eq!(
830 extract_dotnet_location(
831 "at MyApp.Tests.MathTest.TestDivide() in /tests/MathTest.cs:line 42"
832 ),
833 Some("/tests/MathTest.cs:line 42".into())
834 );
835 assert!(extract_dotnet_location("no location here").is_none());
836 }
837
838 #[test]
839 fn enrich_with_errors_test() {
840 let mut suites = vec![TestSuite {
841 name: "tests".into(),
842 tests: vec![
843 TestCase {
844 name: "test_add".into(),
845 status: TestStatus::Passed,
846 duration: Duration::from_millis(0),
847 error: None,
848 },
849 TestCase {
850 name: "test_divide".into(),
851 status: TestStatus::Failed,
852 duration: Duration::from_millis(0),
853 error: None,
854 },
855 ],
856 }];
857 let failures = vec![DotnetFailure {
858 test_name: "test_divide".into(),
859 message: "Assert.Equal failure".into(),
860 stack_trace: Some("at Test.TestDivide() in /tests/Test.cs:line 42".into()),
861 location: Some("/tests/Test.cs:line 42".into()),
862 }];
863 enrich_with_errors(&mut suites, &failures);
864 assert!(suites[0].tests[0].error.is_none());
865 let err = suites[0].tests[1].error.as_ref().unwrap();
866 assert_eq!(err.message, "Assert.Equal failure");
867 assert!(err.location.as_ref().unwrap().contains("Test.cs:line 42"));
868 }
869
870 #[test]
871 fn truncate_dotnet_message_test() {
872 assert_eq!(truncate_dotnet_message("short", 100), "short");
873 let long = "m".repeat(600);
874 let truncated = truncate_dotnet_message(&long, 500);
875 assert!(truncated.ends_with("..."));
876 }
877
878 #[test]
879 fn parse_trx_basic() {
880 let content = r#"<?xml version="1.0" encoding="UTF-8"?>
881<TestRun>
882 <Results>
883 <UnitTestResult testName="TestAdd" outcome="Passed" duration="00:00:00.001">
884 </UnitTestResult>
885 <UnitTestResult testName="TestDiv" outcome="Failed" duration="00:00:00.002">
886 <Output>
887 <ErrorInfo>
888 <Message>Assert.Equal failure</Message>
889 <StackTrace>at Test.TestDiv() in /tests/Test.cs:line 42</StackTrace>
890 </ErrorInfo>
891 </Output>
892 </UnitTestResult>
893 </Results>
894</TestRun>"#;
895 let suites = parse_trx_content(content);
896 assert_eq!(suites.len(), 1);
897 assert_eq!(suites[0].tests.len(), 2);
898 assert_eq!(suites[0].tests[0].name, "TestAdd");
899 assert_eq!(suites[0].tests[0].status, TestStatus::Passed);
900 assert_eq!(suites[0].tests[1].name, "TestDiv");
901 assert_eq!(suites[0].tests[1].status, TestStatus::Failed);
902 assert!(suites[0].tests[1].error.is_some());
903 }
904
905 #[test]
906 fn parse_trx_skipped() {
907 let content = r#"<TestRun><Results>
908<UnitTestResult testName="TestSkip" outcome="NotExecuted" duration="00:00:00.000"/>
909</Results></TestRun>"#;
910 let suites = parse_trx_content(content);
911 assert_eq!(suites[0].tests[0].status, TestStatus::Skipped);
912 }
913
914 #[test]
915 fn parse_trx_duration_test() {
916 assert_eq!(
917 parse_trx_duration("00:00:01.500"),
918 Duration::from_millis(1500)
919 );
920 assert_eq!(parse_trx_duration("00:01:00.000"), Duration::from_secs(60));
921 }
922
923 #[test]
924 fn extract_trx_attr_test() {
925 assert_eq!(
926 extract_trx_attr(
927 r#"<UnitTestResult testName="TestAdd" outcome="Passed">"#,
928 "testName"
929 ),
930 Some("TestAdd".into())
931 );
932 assert_eq!(
933 extract_trx_attr(
934 r#"<UnitTestResult testName="TestAdd" outcome="Passed">"#,
935 "outcome"
936 ),
937 Some("Passed".into())
938 );
939 }
940
941 #[test]
942 fn extract_trx_error_message_test() {
943 let element =
944 "<Output><ErrorInfo><Message>Assert.Equal failure</Message></ErrorInfo></Output>";
945 assert_eq!(
946 extract_trx_error_message(element),
947 Some("Assert.Equal failure".into())
948 );
949 }
950
951 #[test]
952 fn extract_trx_stack_trace_test() {
953 let element = "<Output><ErrorInfo><StackTrace>at Test.Run() in Test.cs:line 10</StackTrace></ErrorInfo></Output>";
954 assert_eq!(
955 extract_trx_stack_trace(element),
956 Some("at Test.Run() in Test.cs:line 10".into())
957 );
958 }
959
960 #[test]
961 fn parse_dotnet_failure_integration() {
962 let stdout = r#"
963Starting test execution, please wait...
964
965 Passed test_add [2 ms]
966 Failed test_divide [< 1 ms]
967 Error Message:
968 Assert.Equal() Failure
969 Expected: 4
970 Actual: 3
971 Stack Trace:
972 at Tests.Divide() in /tests/MathTest.cs:line 42
973
974Test Run Failed.
975Total tests: 2
976 Passed: 1
977 Failed: 1
978"#;
979 let adapter = DotnetAdapter::new();
980 let result = adapter.parse_output(stdout, "", 1);
981
982 assert_eq!(result.total_tests(), 2);
983 assert_eq!(result.total_passed(), 1);
984 assert_eq!(result.total_failed(), 1);
985 let failed = result.suites[0]
986 .tests
987 .iter()
988 .find(|t| t.status == TestStatus::Failed)
989 .unwrap();
990 assert!(failed.error.is_some());
991 assert!(
992 failed
993 .error
994 .as_ref()
995 .unwrap()
996 .message
997 .contains("Assert.Equal")
998 );
999 }
1000}