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 JavaAdapter;
13
14impl Default for JavaAdapter {
15 fn default() -> Self {
16 Self::new()
17 }
18}
19
20impl JavaAdapter {
21 pub fn new() -> Self {
22 Self
23 }
24
25 fn detect_build_tool(project_dir: &Path) -> Option<&'static str> {
27 if project_dir.join("build.gradle.kts").exists()
29 || project_dir.join("build.gradle").exists()
30 {
31 return Some("gradle");
32 }
33 if project_dir.join("pom.xml").exists() {
34 return Some("maven");
35 }
36 None
37 }
38
39 fn has_gradle_wrapper(project_dir: &Path) -> bool {
41 project_dir.join("gradlew").exists()
42 }
43}
44
45impl TestAdapter for JavaAdapter {
46 fn name(&self) -> &str {
47 "Java/Kotlin"
48 }
49
50 fn check_runner(&self) -> Option<String> {
51 None
53 }
54
55 fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
56 let build_tool = Self::detect_build_tool(project_dir)?;
57
58 let framework = match build_tool {
59 "gradle" => {
60 if project_dir.join("build.gradle.kts").exists() {
61 "gradle (kotlin dsl)"
62 } else {
63 "gradle"
64 }
65 }
66 "maven" => "maven surefire",
67 _ => "unknown",
68 };
69
70 Some(DetectionResult {
71 language: "Java".into(),
72 framework: framework.into(),
73 confidence: 0.95,
74 })
75 }
76
77 fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
78 let build_tool = Self::detect_build_tool(project_dir).unwrap_or("maven");
79
80 let mut cmd;
81
82 match build_tool {
83 "gradle" => {
84 if Self::has_gradle_wrapper(project_dir) {
85 cmd = Command::new("./gradlew");
86 } else {
87 cmd = Command::new("gradle");
88 }
89 cmd.arg("test");
90 }
91 _ => {
92 cmd = Command::new("mvn");
94 cmd.arg("test");
95 cmd.arg("-B"); }
97 }
98
99 for arg in extra_args {
100 cmd.arg(arg);
101 }
102
103 cmd.current_dir(project_dir);
104 Ok(cmd)
105 }
106
107 fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
108 let combined = format!("{}\n{}", stdout, stderr);
109
110 let mut suites = if combined.contains("Tests run:") {
112 parse_maven_output(&combined, exit_code)
113 } else {
114 parse_gradle_output(&combined, exit_code)
115 };
116
117 let failures = parse_java_failures(&combined);
119 if !failures.is_empty() {
120 enrich_with_errors(&mut suites, &failures);
121 }
122
123 let duration = parse_java_duration(&combined).unwrap_or(Duration::from_secs(0));
124
125 TestRunResult {
126 suites,
127 duration,
128 raw_exit_code: exit_code,
129 }
130 }
131}
132
133fn parse_maven_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
148 let mut suites = Vec::new();
149 let mut current_suite = String::new();
150
151 for line in output.lines() {
152 let trimmed = line.trim();
153 let clean = trimmed
155 .strip_prefix("[INFO] ")
156 .or_else(|| trimmed.strip_prefix("[ERROR] "))
157 .unwrap_or(trimmed)
158 .trim();
159
160 if let Some(rest) = clean.strip_prefix("Running ") {
162 current_suite = rest.trim().to_string();
163 continue;
164 }
165
166 if clean.starts_with("Tests run:")
168 && !current_suite.is_empty()
169 && let Some(suite) = parse_surefire_result_line(clean, ¤t_suite)
170 {
171 suites.push(suite);
172 }
173 }
175
176 if suites.len() > 1 {
180 let mut seen = std::collections::HashSet::new();
182 suites.retain(|s| seen.insert(s.name.clone()));
183 }
184
185 if suites.is_empty() {
186 let status = if exit_code == 0 {
187 TestStatus::Passed
188 } else {
189 TestStatus::Failed
190 };
191 suites.push(TestSuite {
192 name: "tests".into(),
193 tests: vec![TestCase {
194 name: "test_suite".into(),
195 status,
196 duration: Duration::from_millis(0),
197 error: None,
198 }],
199 });
200 }
201
202 suites
203}
204
205fn parse_surefire_result_line(line: &str, suite_name: &str) -> Option<TestSuite> {
206 let mut total = 0usize;
208 let mut failures = 0usize;
209 let mut errors = 0usize;
210 let mut skipped = 0usize;
211
212 for part in line.split(',') {
213 let part = part.trim();
214 if let Some(rest) = part.strip_prefix("Tests run:") {
215 total = rest.trim().parse().unwrap_or(0);
216 } else if let Some(rest) = part.strip_prefix("Failures:") {
217 failures = rest.trim().parse().unwrap_or(0);
218 } else if let Some(rest) = part.strip_prefix("Errors:") {
219 errors = rest.trim().parse().unwrap_or(0);
220 } else if let Some(rest) = part.strip_prefix("Skipped:") {
221 skipped = rest.trim().parse().unwrap_or(0);
222 }
223 }
224
225 if total == 0 && failures == 0 {
226 return None;
227 }
228
229 let failed = failures + errors;
230 let passed = total.saturating_sub(failed + skipped);
231
232 let mut tests = Vec::new();
233 for i in 0..passed {
234 tests.push(TestCase {
235 name: format!("test_{}", i + 1),
236 status: TestStatus::Passed,
237 duration: Duration::from_millis(0),
238 error: None,
239 });
240 }
241 for i in 0..failed {
242 tests.push(TestCase {
243 name: format!("failed_test_{}", i + 1),
244 status: TestStatus::Failed,
245 duration: Duration::from_millis(0),
246 error: None,
247 });
248 }
249 for i in 0..skipped {
250 tests.push(TestCase {
251 name: format!("skipped_test_{}", i + 1),
252 status: TestStatus::Skipped,
253 duration: Duration::from_millis(0),
254 error: None,
255 });
256 }
257
258 Some(TestSuite {
259 name: suite_name.to_string(),
260 tests,
261 })
262}
263
264fn parse_gradle_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
276 let mut suites_map: std::collections::HashMap<String, Vec<TestCase>> =
277 std::collections::HashMap::new();
278
279 for line in output.lines() {
280 let trimmed = line.trim();
281
282 if trimmed.contains(" > ")
284 && (trimmed.ends_with("PASSED")
285 || trimmed.ends_with("FAILED")
286 || trimmed.ends_with("SKIPPED"))
287 {
288 let status = if trimmed.ends_with("PASSED") {
289 TestStatus::Passed
290 } else if trimmed.ends_with("FAILED") {
291 TestStatus::Failed
292 } else {
293 TestStatus::Skipped
294 };
295
296 if let Some(arrow_idx) = trimmed.find(" > ") {
297 let suite_name = trimmed[..arrow_idx].trim().to_string();
298 let rest = &trimmed[arrow_idx + 3..];
299 let test_name = rest
301 .rsplit_once(' ')
302 .map(|(name, _)| name.trim())
303 .unwrap_or(rest)
304 .to_string();
305
306 suites_map.entry(suite_name).or_default().push(TestCase {
307 name: test_name,
308 status,
309 duration: Duration::from_millis(0),
310 error: None,
311 });
312 }
313 }
314 }
315
316 let mut suites: Vec<TestSuite> = suites_map
317 .into_iter()
318 .map(|(name, tests)| TestSuite { name, tests })
319 .collect();
320 suites.sort_by(|a, b| a.name.cmp(&b.name));
321
322 if suites.is_empty() {
324 suites.push(parse_gradle_summary(output, exit_code));
325 }
326
327 suites
328}
329
330fn parse_gradle_summary(output: &str, exit_code: i32) -> TestSuite {
331 let mut passed = 0usize;
332 let mut failed = 0usize;
333
334 for line in output.lines() {
335 let trimmed = line.trim();
336 if trimmed.contains("tests completed") {
338 for part in trimmed.split(',') {
339 let part = part.trim();
340 if part.contains("completed")
341 && let Some(n) = part.split_whitespace().next().and_then(|s| s.parse().ok())
342 {
343 passed = n;
344 }
345 if part.contains("failed")
346 && let Some(n) = part.split_whitespace().next().and_then(|s| s.parse().ok())
347 {
348 failed = n;
349 passed = passed.saturating_sub(failed);
350 }
351 }
352 }
353 }
354
355 let mut tests = Vec::new();
356 for i in 0..passed {
357 tests.push(TestCase {
358 name: format!("test_{}", i + 1),
359 status: TestStatus::Passed,
360 duration: Duration::from_millis(0),
361 error: None,
362 });
363 }
364 for i in 0..failed {
365 tests.push(TestCase {
366 name: format!("failed_test_{}", i + 1),
367 status: TestStatus::Failed,
368 duration: Duration::from_millis(0),
369 error: None,
370 });
371 }
372
373 if tests.is_empty() {
374 tests.push(TestCase {
375 name: "test_suite".into(),
376 status: if exit_code == 0 {
377 TestStatus::Passed
378 } else {
379 TestStatus::Failed
380 },
381 duration: Duration::from_millis(0),
382 error: None,
383 });
384 }
385
386 TestSuite {
387 name: "tests".into(),
388 tests,
389 }
390}
391
392fn parse_java_duration(output: &str) -> Option<Duration> {
393 for line in output.lines() {
395 if let Some(idx) = line.find("Time elapsed:") {
396 let after = &line[idx + 13..];
397 let num_str: String = after
398 .trim()
399 .chars()
400 .take_while(|c| c.is_ascii_digit() || *c == '.')
401 .collect();
402 if let Ok(secs) = num_str.parse::<f64>() {
403 return Some(duration_from_secs_safe(secs));
404 }
405 }
406 if (line.contains("BUILD SUCCESSFUL") || line.contains("BUILD FAILED"))
408 && line.contains(" in ")
409 && let Some(idx) = line.rfind(" in ")
410 {
411 let after = &line[idx + 4..];
412 let num_str: String = after
413 .trim()
414 .chars()
415 .take_while(|c| c.is_ascii_digit() || *c == '.')
416 .collect();
417 if let Ok(secs) = num_str.parse::<f64>() {
418 return Some(duration_from_secs_safe(secs));
419 }
420 }
421 }
422 None
423}
424
425#[derive(Debug, Clone)]
427struct JavaTestFailure {
428 test_name: String,
430 method_name: String,
432 message: String,
434 stack_trace: Option<String>,
436}
437
438fn parse_java_failures(output: &str) -> Vec<JavaTestFailure> {
463 let failures = Vec::new();
464
465 let maven_failures = parse_maven_failed_tests_section(output);
467 if !maven_failures.is_empty() {
468 return maven_failures;
469 }
470
471 let gradle_failures = parse_gradle_failures(output);
473 if !gradle_failures.is_empty() {
474 return gradle_failures;
475 }
476
477 let error_failures = parse_maven_error_failures(output);
479 if !error_failures.is_empty() {
480 return error_failures;
481 }
482
483 failures
484}
485
486fn parse_maven_failed_tests_section(output: &str) -> Vec<JavaTestFailure> {
488 let mut failures = Vec::new();
489 let lines: Vec<&str> = output.lines().collect();
490 let mut i = 0;
491 let mut in_section = false;
492
493 while i < lines.len() {
494 let trimmed = lines[i].trim();
495 let clean = strip_maven_prefix(trimmed);
496
497 if clean == "Failed tests:" || clean == "Tests in error:" {
498 in_section = true;
499 i += 1;
500 continue;
501 }
502
503 if in_section {
504 if clean.is_empty()
506 || clean.starts_with("Tests run:")
507 || clean == "Failed tests:"
508 || clean == "Tests in error:"
509 {
510 if clean == "Failed tests:" || clean == "Tests in error:" {
511 continue;
512 }
513 in_section = false;
514 i += 1;
515 continue;
516 }
517
518 if let Some(failure) = parse_maven_failure_line(clean) {
520 failures.push(failure);
521 }
522 }
523
524 i += 1;
525 }
526
527 failures
528}
529
530fn parse_maven_failure_line(line: &str) -> Option<JavaTestFailure> {
533 let paren_open = line.find('(')?;
535 let paren_close = line.find(')')?;
536 if paren_close <= paren_open {
537 return None;
538 }
539
540 let method_name = line[..paren_open].trim().to_string();
541 let _class_name = &line[paren_open + 1..paren_close];
542 let test_name = line[..paren_close + 1].trim().to_string();
543
544 let message = if paren_close + 1 < line.len() {
545 let rest = &line[paren_close + 1..];
546 rest.strip_prefix(':')
547 .or_else(|| rest.strip_prefix(": "))
548 .unwrap_or(rest)
549 .trim()
550 .to_string()
551 } else {
552 String::new()
553 };
554
555 Some(JavaTestFailure {
556 test_name,
557 method_name,
558 message,
559 stack_trace: None,
560 })
561}
562
563fn parse_gradle_failures(output: &str) -> Vec<JavaTestFailure> {
570 let mut failures = Vec::new();
571 let lines: Vec<&str> = output.lines().collect();
572 let mut i = 0;
573
574 while i < lines.len() {
575 let trimmed = lines[i].trim();
576
577 if trimmed.contains(" > ") && trimmed.ends_with("FAILED") {
579 let arrow_idx = trimmed.find(" > ").unwrap();
580 let class_name = &trimmed[..arrow_idx];
581 let rest = &trimmed[arrow_idx + 3..];
582 let method_name = rest
583 .strip_suffix(" FAILED")
584 .unwrap_or(rest)
585 .trim()
586 .to_string();
587
588 let test_name = format!("{}.{}", class_name, method_name);
589
590 let mut message_lines = Vec::new();
592 let mut stack_lines = Vec::new();
593 i += 1;
594
595 while i < lines.len() {
596 let line = lines[i];
597 if !line.starts_with(" ") && !line.starts_with('\t') {
598 break;
599 }
600 let content = line.trim();
601 if content.starts_with("at ") {
602 stack_lines.push(content.to_string());
603 } else if !content.is_empty() {
604 message_lines.push(content.to_string());
605 }
606 i += 1;
607 }
608
609 let message = message_lines.join("\n");
610 let stack_trace = if stack_lines.is_empty() {
611 None
612 } else {
613 Some(
614 stack_lines
615 .into_iter()
616 .take(5)
617 .collect::<Vec<_>>()
618 .join("\n"),
619 )
620 };
621
622 failures.push(JavaTestFailure {
623 test_name,
624 method_name,
625 message: truncate_java_message(&message, 500),
626 stack_trace,
627 });
628 continue;
629 }
630
631 i += 1;
632 }
633
634 failures
635}
636
637fn parse_maven_error_failures(output: &str) -> Vec<JavaTestFailure> {
640 let mut failures = Vec::new();
641 let lines: Vec<&str> = output.lines().collect();
642 let mut in_failures = false;
643
644 for line in &lines {
645 let trimmed = line.trim();
646 let clean = strip_maven_prefix(trimmed);
647
648 if clean == "Failures:" || clean == "Errors:" {
649 in_failures = true;
650 continue;
651 }
652
653 if in_failures && !clean.is_empty() {
654 if clean.contains('.') && (clean.contains(':') || clean.contains(' ')) {
656 let parts: Vec<&str> = clean.splitn(2, ' ').collect();
657 if !parts.is_empty() {
658 let test_ref = parts[0];
659 let message = if parts.len() > 1 {
660 parts[1].to_string()
661 } else {
662 String::new()
663 };
664
665 let method_name = test_ref
667 .split('.')
668 .next_back()
669 .unwrap_or(test_ref)
670 .split(':')
671 .next()
672 .unwrap_or(test_ref)
673 .to_string();
674
675 failures.push(JavaTestFailure {
676 test_name: test_ref.to_string(),
677 method_name,
678 message: truncate_java_message(&message, 500),
679 stack_trace: None,
680 });
681 }
682 } else if clean.starts_with("Tests run:") || clean.starts_with("[") {
683 in_failures = false;
684 }
685 }
686 }
687
688 failures
689}
690
691fn strip_maven_prefix(line: &str) -> &str {
693 line.strip_prefix("[INFO] ")
694 .or_else(|| line.strip_prefix("[ERROR] "))
695 .or_else(|| line.strip_prefix("[WARNING] "))
696 .unwrap_or(line)
697 .trim()
698}
699
700fn enrich_with_errors(suites: &mut [TestSuite], failures: &[JavaTestFailure]) {
702 for suite in suites.iter_mut() {
703 for test in suite.tests.iter_mut() {
704 if test.status != TestStatus::Failed || test.error.is_some() {
705 continue;
706 }
707 if let Some(failure) = find_matching_java_failure(&test.name, &suite.name, failures) {
708 let location = failure
709 .stack_trace
710 .as_ref()
711 .and_then(|st| st.lines().next())
712 .map(|s| s.to_string());
713 test.error = Some(TestError {
714 message: failure.message.clone(),
715 location,
716 });
717 }
718 }
719 }
720}
721
722fn find_matching_java_failure<'a>(
724 test_name: &str,
725 suite_name: &str,
726 failures: &'a [JavaTestFailure],
727) -> Option<&'a JavaTestFailure> {
728 for failure in failures {
729 if test_name == failure.method_name {
731 return Some(failure);
732 }
733 if failure.test_name.ends_with(&format!(".{}", test_name)) {
735 return Some(failure);
736 }
737 if failure.test_name.contains(test_name) {
739 return Some(failure);
740 }
741 if failure.test_name.contains(suite_name) && failure.method_name == test_name {
743 return Some(failure);
744 }
745 }
746 if failures.len() == 1 && test_name.starts_with("failed_test_") {
748 return Some(&failures[0]);
749 }
750 None
751}
752
753fn truncate_java_message(msg: &str, max_len: usize) -> String {
755 if msg.len() <= max_len {
756 msg.to_string()
757 } else {
758 format!("{}...", &msg[..max_len])
759 }
760}
761
762pub fn parse_surefire_xml(project_dir: &Path) -> Vec<TestSuite> {
767 let report_dirs = [
768 project_dir.join("target/surefire-reports"),
769 project_dir.join("build/test-results/test"),
770 project_dir.join("build/test-results"),
771 ];
772
773 let mut suites = Vec::new();
774
775 for dir in &report_dirs {
776 if !dir.is_dir() {
777 continue;
778 }
779 if let Ok(entries) = std::fs::read_dir(dir) {
780 for entry in entries.flatten() {
781 let name = entry.file_name();
782 let name = name.to_string_lossy();
783 if name.starts_with("TEST-")
784 && name.ends_with(".xml")
785 && let Ok(content) = std::fs::read_to_string(entry.path())
786 && let Some(suite) = parse_single_surefire_xml(&content)
787 {
788 suites.push(suite);
789 }
790 }
791 }
792 }
793
794 suites
795}
796
797fn parse_single_surefire_xml(content: &str) -> Option<TestSuite> {
813 let suite_name = extract_xml_attr(content, "testsuite", "name")?;
815
816 let mut tests = Vec::new();
817
818 let mut search_from = 0;
820 while let Some(tc_start) = content[search_from..].find("<testcase") {
821 let absolute_start = search_from + tc_start;
822
823 let tc_content_start = absolute_start + 9; let (tc_end, is_self_closing) =
826 if let Some(self_close) = find_self_closing_end(content, tc_content_start) {
827 (self_close, true)
828 } else if let Some(close) = content[tc_content_start..].find("</testcase>") {
829 (tc_content_start + close + 11, false)
830 } else {
831 break;
832 };
833
834 let tc_text = &content[absolute_start..tc_end];
835
836 let name =
837 extract_xml_attr(tc_text, "testcase", "name").unwrap_or_else(|| "unknown".into());
838 let time_str = extract_xml_attr(tc_text, "testcase", "time").unwrap_or_default();
839 let duration = time_str
840 .parse::<f64>()
841 .map(duration_from_secs_safe)
842 .unwrap_or(Duration::from_millis(0));
843
844 let (status, error) = if is_self_closing {
845 (TestStatus::Passed, None)
847 } else if tc_text.contains("<failure") {
848 let msg = extract_xml_attr(tc_text, "failure", "message")
849 .unwrap_or_else(|| "Test failed".into());
850 let error_type = extract_xml_attr(tc_text, "failure", "type");
851 let location = error_type.map(|t| format!("type: {}", t));
852 (
853 TestStatus::Failed,
854 Some(TestError {
855 message: xml_unescape(&msg),
856 location,
857 }),
858 )
859 } else if tc_text.contains("<error") {
860 let msg = extract_xml_attr(tc_text, "error", "message")
861 .unwrap_or_else(|| "Test error".into());
862 (
863 TestStatus::Failed,
864 Some(TestError {
865 message: xml_unescape(&msg),
866 location: None,
867 }),
868 )
869 } else if tc_text.contains("<skipped") {
870 (TestStatus::Skipped, None)
871 } else {
872 (TestStatus::Passed, None)
873 };
874
875 tests.push(TestCase {
876 name,
877 status,
878 duration,
879 error,
880 });
881
882 search_from = tc_end;
883 }
884
885 if tests.is_empty() {
886 return None;
887 }
888
889 Some(TestSuite {
890 name: suite_name,
891 tests,
892 })
893}
894
895fn find_self_closing_end(content: &str, from: usize) -> Option<usize> {
899 let remaining = &content[from..];
900 let first_close = remaining.find('>')?;
902 if first_close > 0 && remaining.as_bytes()[first_close - 1] == b'/' {
904 Some(from + first_close + 1)
905 } else {
906 None
907 }
908}
909
910fn extract_xml_attr(content: &str, tag: &str, attr: &str) -> Option<String> {
913 let tag_start = content.find(&format!("<{}", tag))?;
914 let tag_content = &content[tag_start..];
915 let tag_end = tag_content.find('>')?.min(tag_content.len());
916 let tag_text = &tag_content[..tag_end];
917
918 let attr_pattern = format!("{}=\"", attr);
919 let attr_start = tag_text.find(&attr_pattern)?;
920 let value_start = attr_start + attr_pattern.len();
921 let value_end = tag_text[value_start..].find('"')?;
922 Some(tag_text[value_start..value_start + value_end].to_string())
923}
924
925fn xml_unescape(s: &str) -> String {
927 s.replace("<", "<")
928 .replace(">", ">")
929 .replace("&", "&")
930 .replace(""", "\"")
931 .replace("'", "'")
932}
933
934#[cfg(test)]
935mod tests {
936 use super::*;
937
938 #[test]
939 fn detect_maven_project() {
940 let dir = tempfile::tempdir().unwrap();
941 std::fs::write(
942 dir.path().join("pom.xml"),
943 "<project><modelVersion>4.0.0</modelVersion></project>",
944 )
945 .unwrap();
946 let adapter = JavaAdapter::new();
947 let det = adapter.detect(dir.path()).unwrap();
948 assert_eq!(det.language, "Java");
949 assert_eq!(det.framework, "maven surefire");
950 }
951
952 #[test]
953 fn detect_gradle_project() {
954 let dir = tempfile::tempdir().unwrap();
955 std::fs::write(dir.path().join("build.gradle"), "apply plugin: 'java'\n").unwrap();
956 let adapter = JavaAdapter::new();
957 let det = adapter.detect(dir.path()).unwrap();
958 assert_eq!(det.language, "Java");
959 assert_eq!(det.framework, "gradle");
960 }
961
962 #[test]
963 fn detect_gradle_kts_project() {
964 let dir = tempfile::tempdir().unwrap();
965 std::fs::write(dir.path().join("build.gradle.kts"), "plugins { java }\n").unwrap();
966 let adapter = JavaAdapter::new();
967 let det = adapter.detect(dir.path()).unwrap();
968 assert_eq!(det.framework, "gradle (kotlin dsl)");
969 }
970
971 #[test]
972 fn detect_no_java() {
973 let dir = tempfile::tempdir().unwrap();
974 let adapter = JavaAdapter::new();
975 assert!(adapter.detect(dir.path()).is_none());
976 }
977
978 #[test]
979 fn parse_maven_surefire_output() {
980 let stdout = r#"
981[INFO] -------------------------------------------------------
982[INFO] T E S T S
983[INFO] -------------------------------------------------------
984[INFO] Running com.example.AppTest
985[INFO] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s
986[INFO]
987[INFO] Results:
988[INFO]
989[INFO] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
990[INFO]
991[INFO] BUILD FAILURE
992"#;
993 let adapter = JavaAdapter::new();
994 let result = adapter.parse_output(stdout, "", 1);
995
996 assert_eq!(result.total_tests(), 3);
997 assert_eq!(result.total_passed(), 2);
998 assert_eq!(result.total_failed(), 1);
999 assert!(!result.is_success());
1000 }
1001
1002 #[test]
1003 fn parse_maven_all_pass() {
1004 let stdout = r#"
1005[INFO] Running com.example.MathTest
1006[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.12 s
1007[INFO] BUILD SUCCESS
1008"#;
1009 let adapter = JavaAdapter::new();
1010 let result = adapter.parse_output(stdout, "", 0);
1011
1012 assert_eq!(result.total_tests(), 5);
1013 assert_eq!(result.total_passed(), 5);
1014 assert!(result.is_success());
1015 }
1016
1017 #[test]
1018 fn parse_maven_with_skipped() {
1019 let stdout = r#"
1020[INFO] Running com.example.AppTest
1021[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 0.03 s
1022"#;
1023 let adapter = JavaAdapter::new();
1024 let result = adapter.parse_output(stdout, "", 0);
1025
1026 assert_eq!(result.total_passed(), 2);
1027 assert_eq!(result.total_skipped(), 2);
1028 assert!(result.is_success());
1029 }
1030
1031 #[test]
1032 fn parse_maven_with_errors() {
1033 let stdout = r#"
1034[INFO] Running com.example.AppTest
1035[INFO] Tests run: 3, Failures: 0, Errors: 2, Skipped: 0, Time elapsed: 0.01 s
1036"#;
1037 let adapter = JavaAdapter::new();
1038 let result = adapter.parse_output(stdout, "", 1);
1039
1040 assert_eq!(result.total_failed(), 2);
1041 assert!(!result.is_success());
1042 }
1043
1044 #[test]
1045 fn parse_gradle_test_output() {
1046 let stdout = r#"
1047> Task :test
1048
1049com.example.AppTest > testAdd PASSED
1050com.example.AppTest > testSubtract PASSED
1051com.example.AppTest > testDivide FAILED
1052
10533 tests completed, 1 failed
1054
1055BUILD FAILED in 2s
1056"#;
1057 let adapter = JavaAdapter::new();
1058 let result = adapter.parse_output(stdout, "", 1);
1059
1060 assert_eq!(result.total_tests(), 3);
1061 assert_eq!(result.total_passed(), 2);
1062 assert_eq!(result.total_failed(), 1);
1063 assert!(!result.is_success());
1064 }
1065
1066 #[test]
1067 fn parse_gradle_all_pass() {
1068 let stdout = r#"
1069> Task :test
1070
1071com.example.MathTest > testAdd PASSED
1072com.example.MathTest > testMultiply PASSED
1073
1074BUILD SUCCESSFUL in 3s
1075"#;
1076 let adapter = JavaAdapter::new();
1077 let result = adapter.parse_output(stdout, "", 0);
1078
1079 assert_eq!(result.total_tests(), 2);
1080 assert_eq!(result.total_passed(), 2);
1081 assert!(result.is_success());
1082 }
1083
1084 #[test]
1085 fn parse_gradle_multiple_suites() {
1086 let stdout = r#"
1087com.example.MathTest > testAdd PASSED
1088com.example.StringTest > testUpper PASSED
1089com.example.StringTest > testLower FAILED
1090
10913 tests completed, 1 failed
1092"#;
1093 let adapter = JavaAdapter::new();
1094 let result = adapter.parse_output(stdout, "", 1);
1095
1096 assert_eq!(result.suites.len(), 2);
1097 assert_eq!(result.total_tests(), 3);
1098 }
1099
1100 #[test]
1101 fn parse_java_empty_output() {
1102 let adapter = JavaAdapter::new();
1103 let result = adapter.parse_output("", "", 0);
1104
1105 assert_eq!(result.total_tests(), 1);
1106 assert!(result.is_success());
1107 }
1108
1109 #[test]
1110 fn parse_java_duration_maven() {
1111 assert_eq!(
1112 parse_java_duration(
1113 "[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.23 s"
1114 ),
1115 Some(Duration::from_millis(1230))
1116 );
1117 }
1118
1119 #[test]
1120 fn parse_java_duration_gradle() {
1121 assert_eq!(
1122 parse_java_duration("BUILD SUCCESSFUL in 5s"),
1123 Some(Duration::from_secs(5))
1124 );
1125 }
1126
1127 #[test]
1128 fn gradle_wrapper_detection() {
1129 let dir = tempfile::tempdir().unwrap();
1130 std::fs::write(dir.path().join("build.gradle"), "").unwrap();
1131 assert!(!JavaAdapter::has_gradle_wrapper(dir.path()));
1132 std::fs::write(dir.path().join("gradlew"), "#!/bin/bash\n").unwrap();
1133 assert!(JavaAdapter::has_gradle_wrapper(dir.path()));
1134 }
1135
1136 #[test]
1137 fn parse_maven_failed_tests_section_test() {
1138 let output = r#"
1139[ERROR] Failed tests:
1140[ERROR] testDivide(com.example.MathTest): expected:<4> but was:<3>
1141[ERROR] testModulo(com.example.MathTest): ArithmeticException
1142[ERROR]
1143[ERROR] Tests run: 5, Failures: 2, Errors: 0, Skipped: 0
1144"#;
1145 let failures = parse_maven_failed_tests_section(output);
1146 assert_eq!(failures.len(), 2);
1147 assert_eq!(failures[0].method_name, "testDivide");
1148 assert!(failures[0].message.contains("expected:<4>"));
1149 assert_eq!(failures[1].method_name, "testModulo");
1150 }
1151
1152 #[test]
1153 fn parse_maven_failure_line_test() {
1154 let failure =
1155 parse_maven_failure_line("testAdd(com.example.MathTest): expected 5 got 4").unwrap();
1156 assert_eq!(failure.method_name, "testAdd");
1157 assert_eq!(failure.test_name, "testAdd(com.example.MathTest)");
1158 assert_eq!(failure.message, "expected 5 got 4");
1159 }
1160
1161 #[test]
1162 fn parse_maven_failure_line_no_message() {
1163 let failure = parse_maven_failure_line("testAdd(com.example.MathTest)").unwrap();
1164 assert_eq!(failure.method_name, "testAdd");
1165 assert!(failure.message.is_empty());
1166 }
1167
1168 #[test]
1169 fn parse_gradle_failure_blocks() {
1170 let output = r#"
1171> Task :test
1172
1173com.example.AppTest > testAdd PASSED
1174com.example.AppTest > testDivide FAILED
1175 org.opentest4j.AssertionFailedError: expected: <4> but was: <3>
1176 at com.example.AppTest.testDivide(AppTest.java:42)
1177
11783 tests completed, 1 failed
1179"#;
1180 let failures = parse_gradle_failures(output);
1181 assert_eq!(failures.len(), 1);
1182 assert_eq!(failures[0].method_name, "testDivide");
1183 assert!(failures[0].message.contains("AssertionFailedError"));
1184 assert!(failures[0].stack_trace.is_some());
1185 }
1186
1187 #[test]
1188 fn parse_gradle_multiple_failures() {
1189 let output = r#"
1190com.example.Test > methodA FAILED
1191 java.lang.RuntimeException: boom
1192com.example.Test > methodB FAILED
1193 java.lang.NullPointerException
1194 at com.example.Test.methodB(Test.java:10)
1195"#;
1196 let failures = parse_gradle_failures(output);
1197 assert_eq!(failures.len(), 2);
1198 assert_eq!(failures[0].method_name, "methodA");
1199 assert_eq!(failures[1].method_name, "methodB");
1200 }
1201
1202 #[test]
1203 fn parse_maven_error_failures_test() {
1204 let output = r#"
1205[ERROR] Failures:
1206[ERROR] AppTest.testDivide:42 expected:<4> but was:<3>
1207[ERROR]
1208"#;
1209 let failures = parse_maven_error_failures(output);
1210 assert_eq!(failures.len(), 1);
1211 assert_eq!(failures[0].method_name, "testDivide");
1212 }
1213
1214 #[test]
1215 fn strip_maven_prefix_test() {
1216 assert_eq!(strip_maven_prefix("[INFO] Hello"), "Hello");
1217 assert_eq!(strip_maven_prefix("[ERROR] Fail"), "Fail");
1218 assert_eq!(strip_maven_prefix("[WARNING] Warn"), "Warn");
1219 assert_eq!(strip_maven_prefix("No prefix"), "No prefix");
1220 }
1221
1222 #[test]
1223 fn enrich_with_errors_test() {
1224 let mut suites = vec![TestSuite {
1225 name: "com.example.AppTest".into(),
1226 tests: vec![
1227 TestCase {
1228 name: "testAdd".into(),
1229 status: TestStatus::Passed,
1230 duration: Duration::from_millis(0),
1231 error: None,
1232 },
1233 TestCase {
1234 name: "testDivide".into(),
1235 status: TestStatus::Failed,
1236 duration: Duration::from_millis(0),
1237 error: None,
1238 },
1239 ],
1240 }];
1241 let failures = vec![JavaTestFailure {
1242 test_name: "com.example.AppTest.testDivide".into(),
1243 method_name: "testDivide".into(),
1244 message: "expected 4 got 3".into(),
1245 stack_trace: Some("at com.example.AppTest.testDivide(AppTest.java:42)".into()),
1246 }];
1247 enrich_with_errors(&mut suites, &failures);
1248 assert!(suites[0].tests[0].error.is_none());
1249 let err = suites[0].tests[1].error.as_ref().unwrap();
1250 assert_eq!(err.message, "expected 4 got 3");
1251 assert!(err.location.is_some());
1252 }
1253
1254 #[test]
1255 fn truncate_java_message_test() {
1256 assert_eq!(truncate_java_message("short", 100), "short");
1257 let long = "x".repeat(600);
1258 let truncated = truncate_java_message(&long, 500);
1259 assert!(truncated.ends_with("..."));
1260 assert_eq!(truncated.len(), 503);
1261 }
1262
1263 #[test]
1264 fn parse_surefire_xml_basic() {
1265 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1266<testsuite name="com.example.MathTest" tests="3" failures="1" errors="0" skipped="0" time="0.05">
1267 <testcase name="testAdd" classname="com.example.MathTest" time="0.01"/>
1268 <testcase name="testSub" classname="com.example.MathTest" time="0.02"/>
1269 <testcase name="testDiv" classname="com.example.MathTest" time="0.02">
1270 <failure message="expected:<4> but was:<3>" type="AssertionError">
1271 stack trace here
1272 </failure>
1273 </testcase>
1274</testsuite>"#;
1275 let suite = parse_single_surefire_xml(xml).unwrap();
1276 assert_eq!(suite.name, "com.example.MathTest");
1277 assert_eq!(suite.tests.len(), 3);
1278 assert_eq!(suite.tests[0].status, TestStatus::Passed);
1279 assert_eq!(suite.tests[0].name, "testAdd");
1280 assert_eq!(suite.tests[2].status, TestStatus::Failed);
1281 let err = suite.tests[2].error.as_ref().unwrap();
1282 assert!(err.message.contains("expected:<4>"));
1283 }
1284
1285 #[test]
1286 fn parse_surefire_xml_with_skipped() {
1287 let xml = r#"<testsuite name="Test" tests="2" failures="0" errors="0" skipped="1" time="0.01">
1288 <testcase name="testA" classname="Test" time="0.005"/>
1289 <testcase name="testB" classname="Test" time="0.005">
1290 <skipped/>
1291 </testcase>
1292</testsuite>"#;
1293 let suite = parse_single_surefire_xml(xml).unwrap();
1294 assert_eq!(suite.tests.len(), 2);
1295 assert_eq!(suite.tests[0].status, TestStatus::Passed);
1296 assert_eq!(suite.tests[1].status, TestStatus::Skipped);
1297 }
1298
1299 #[test]
1300 fn parse_surefire_xml_with_error() {
1301 let xml = r#"<testsuite name="Test" tests="1" failures="0" errors="1" time="0.01">
1302 <testcase name="testBroken" classname="Test" time="0.001">
1303 <error message="NullPointerException" type="java.lang.NullPointerException">
1304 at Test.testBroken(Test.java:5)
1305 </error>
1306 </testcase>
1307</testsuite>"#;
1308 let suite = parse_single_surefire_xml(xml).unwrap();
1309 assert_eq!(suite.tests[0].status, TestStatus::Failed);
1310 assert!(
1311 suite.tests[0]
1312 .error
1313 .as_ref()
1314 .unwrap()
1315 .message
1316 .contains("NullPointerException")
1317 );
1318 }
1319
1320 #[test]
1321 fn parse_surefire_xml_empty() {
1322 assert!(parse_single_surefire_xml("<testsuite name=\"Test\"></testsuite>").is_none());
1323 }
1324
1325 #[test]
1326 fn extract_xml_attr_test() {
1327 assert_eq!(
1328 extract_xml_attr(r#"<tag name="value">"#, "tag", "name"),
1329 Some("value".into())
1330 );
1331 assert_eq!(
1332 extract_xml_attr(r#"<tag foo="bar" baz="qux">"#, "tag", "baz"),
1333 Some("qux".into())
1334 );
1335 assert!(extract_xml_attr(r#"<tag>"#, "tag", "name").is_none());
1336 }
1337
1338 #[test]
1339 fn xml_unescape_test() {
1340 assert_eq!(
1341 xml_unescape("expected:<4> but was:<3>"),
1342 "expected:<4> but was:<3>"
1343 );
1344 assert_eq!(xml_unescape("&"'"), "&\"'");
1345 }
1346
1347 #[test]
1348 fn parse_java_failures_integration() {
1349 let output = r#"
1350[INFO] -------------------------------------------------------
1351[INFO] T E S T S
1352[INFO] -------------------------------------------------------
1353[INFO] Running com.example.AppTest
1354[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
1355
1356Failed tests:
1357 testDivide(com.example.AppTest): expected:<4> but was:<3>
1358
1359[INFO] BUILD FAILURE
1360"#;
1361 let adapter = JavaAdapter::new();
1362 let result = adapter.parse_output(output, "", 1);
1363 assert_eq!(result.total_tests(), 3);
1364 assert_eq!(result.total_failed(), 1);
1365 }
1366}