Skip to main content

testx/adapters/
dotnet.rs

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        // Enrich failed tests with error details from stack traces
99        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
114/// Parse dotnet test output.
115///
116/// Format:
117/// ```text
118/// Starting test execution, please wait...
119/// A total of 1 test files matched the specified pattern.
120///
121///   Passed test_add [< 1 ms]
122///   Passed test_subtract [2 ms]
123///   Failed test_divide [< 1 ms]
124///     Error Message:
125///       Assert.Equal() Failure
126///
127/// Test Run Successful.
128/// Total tests: 3
129///      Passed: 2
130///      Failed: 1
131///     Skipped: 0
132/// ```
133fn parse_dotnet_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
134    let mut tests = Vec::new();
135    let mut found_summary = false;
136
137    // Try detailed output first ("  Passed test_name [duration]")
138    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            // Name might have " [duration]" suffix
156            let name = rest
157                .rfind('[')
158                .map(|i| rest[..i].trim())
159                .unwrap_or(rest)
160                .to_string();
161
162            // Parse duration from "[2 ms]" or "[< 1 ms]"
163            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    // Fallback: parse summary section
180    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            // Use parsed counts; if passed wasn't explicitly listed, calculate it
202            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    // "2 ms", "< 1 ms", "1.5 s"
253    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    // "Total time: 1.234 Seconds" or "Duration: 1.234 s"
269    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/// A parsed failure from dotnet test output.
285#[derive(Debug, Clone)]
286#[allow(dead_code)]
287struct DotnetFailure {
288    /// Test name
289    test_name: String,
290    /// Error/assertion message
291    message: String,
292    /// Stack trace lines
293    stack_trace: Option<String>,
294    /// File location extracted from stack trace
295    location: Option<String>,
296}
297
298/// Parse dotnet test failure blocks.
299///
300/// Format:
301/// ```text
302///   Failed test_divide [< 1 ms]
303///   Error Message:
304///    Assert.Equal() Failure
305///    Expected: 4
306///    Actual:   3
307///   Stack Trace:
308///    at MyApp.Tests.MathTest.TestDivide() in /path/MathTest.cs:line 42
309/// ```
310///
311/// Or xUnit/NUnit format:
312/// ```text
313///   X test_divide [< 1 ms]
314///     Error Message:
315///       Assert.Equal() Failure
316///     Stack Trace:
317///       at MyApp.Tests.MathTest.TestDivide() in /tests/MathTest.cs:line 42
318/// ```
319fn 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        // Find "Failed test_name [duration]" lines
328        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            // Extract test name (before the duration bracket)
339            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            // Collect error message and stack trace
348            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                // Detect section headers
357                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                // Stop at next test result or empty context
371                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
426/// Extract file location from a .NET stack trace line.
427/// "at Namespace.Class.Method() in /path/File.cs:line 42"
428fn extract_dotnet_location(line: &str) -> Option<String> {
429    // Look for " in " followed by a path and ":line N"
430    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    // Direct file:line pattern
438    if (line.contains(".cs:") || line.contains(".fs:")) && line.contains("line ") {
439        return Some(line.trim().to_string());
440    }
441    None
442}
443
444/// Truncate a failure message.
445fn 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
453/// Enrich test cases with failure details.
454fn 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
470/// Find a matching failure for a test name.
471fn 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        // Partial match: test name might be namespace-qualified
480        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
490/// Parse dotnet test TRX report files.
491///
492/// TRX files are XML test result files generated by `dotnet test --logger trx`.
493/// Located at: TestResults/*.trx
494pub 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
518/// Parse TRX XML content.
519///
520/// TRX format (simplified):
521/// ```xml
522/// <TestRun>
523///   <Results>
524///     <UnitTestResult testName="TestAdd" outcome="Passed" duration="00:00:00.001">
525///     </UnitTestResult>
526///     <UnitTestResult testName="TestDiv" outcome="Failed" duration="00:00:00.002">
527///       <Output>
528///         <ErrorInfo>
529///           <Message>Assert.Equal failure</Message>
530///           <StackTrace>at Test.TestDiv() in Test.cs:line 42</StackTrace>
531///         </ErrorInfo>
532///       </Output>
533///     </UnitTestResult>
534///   </Results>
535/// </TestRun>
536/// ```
537fn parse_trx_content(content: &str) -> Vec<TestSuite> {
538    let mut tests = Vec::new();
539
540    // Find all <UnitTestResult> elements
541    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        // Find end of this element
547        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
600/// Extract an attribute from a TRX element.
601fn 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
609/// Parse TRX duration format: "00:00:00.001"
610fn 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
622/// Extract error message from TRX <ErrorInfo><Message> element.
623fn 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
630/// Extract stack trace from TRX <ErrorInfo><StackTrace> element.
631fn 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}