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};
8use super::{
9 ConfidenceScore, DetectionResult, TestAdapter, TestCase, TestRunResult, TestStatus, TestSuite,
10};
11
12fn build_js_runner_cmd(pkg_manager: &str, tool: &str) -> Command {
15 let mut cmd = Command::new(pkg_manager);
16 match pkg_manager {
17 "npx" => {
18 cmd.arg(tool);
19 }
20 "bun" => {
21 cmd.arg("x").arg(tool);
22 }
23 _ => {
25 cmd.arg(tool);
26 }
27 }
28 cmd
29}
30
31pub struct JavaScriptAdapter;
32
33impl Default for JavaScriptAdapter {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl JavaScriptAdapter {
40 pub fn new() -> Self {
41 Self
42 }
43
44 fn detect_package_manager(project_dir: &Path) -> &'static str {
45 if project_dir.join("bun.lockb").exists() || project_dir.join("bun.lock").exists() {
46 "bun"
47 } else if project_dir.join("pnpm-lock.yaml").exists() {
48 "pnpm"
49 } else if project_dir.join("yarn.lock").exists() {
50 "yarn"
51 } else {
52 "npx"
53 }
54 }
55
56 fn detect_framework(project_dir: &Path) -> Option<&'static str> {
57 let pkg_json = project_dir.join("package.json");
58 if !pkg_json.exists() {
59 return None;
60 }
61
62 let content = std::fs::read_to_string(&pkg_json).ok()?;
63
64 if project_dir.join("vitest.config.ts").exists()
66 || project_dir.join("vitest.config.js").exists()
67 || project_dir.join("vitest.config.mts").exists()
68 || content.contains("\"vitest\"")
69 {
70 return Some("vitest");
71 }
72
73 if project_dir.join("bunfig.toml").exists()
75 && (project_dir.join("bun.lockb").exists() || project_dir.join("bun.lock").exists())
76 {
77 if content.contains("\"bun:test\"") || !content.contains("\"jest\"") {
79 return Some("bun");
80 }
81 }
82
83 if project_dir.join("jest.config.ts").exists()
85 || project_dir.join("jest.config.js").exists()
86 || project_dir.join("jest.config.cjs").exists()
87 || project_dir.join("jest.config.mjs").exists()
88 || content.contains("\"jest\"")
89 {
90 return Some("jest");
91 }
92
93 if project_dir.join(".mocharc.yml").exists()
95 || project_dir.join(".mocharc.json").exists()
96 || project_dir.join(".mocharc.js").exists()
97 || content.contains("\"mocha\"")
98 {
99 return Some("mocha");
100 }
101
102 if project_dir.join("ava.config.js").exists()
104 || project_dir.join("ava.config.cjs").exists()
105 || project_dir.join("ava.config.mjs").exists()
106 || content.contains("\"ava\"")
107 {
108 return Some("ava");
109 }
110
111 None
112 }
113}
114
115impl TestAdapter for JavaScriptAdapter {
116 fn name(&self) -> &str {
117 "JavaScript/TypeScript"
118 }
119
120 fn check_runner(&self) -> Option<String> {
121 for runner in ["npx", "bun", "yarn", "pnpm"] {
123 if which::which(runner).is_ok() {
124 return None;
125 }
126 }
127 Some("node/npm".into())
128 }
129
130 fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
131 let framework = Self::detect_framework(project_dir)?;
132
133 let has_config = project_dir.join("vitest.config.ts").exists()
134 || project_dir.join("vitest.config.js").exists()
135 || project_dir.join("jest.config.ts").exists()
136 || project_dir.join("jest.config.js").exists()
137 || project_dir.join(".mocharc.yml").exists()
138 || project_dir.join(".mocharc.json").exists();
139 let has_lock = project_dir.join("package-lock.json").exists()
140 || project_dir.join("yarn.lock").exists()
141 || project_dir.join("pnpm-lock.yaml").exists()
142 || project_dir.join("bun.lockb").exists()
143 || project_dir.join("bun.lock").exists();
144 let has_runner = ["npx", "bun", "yarn", "pnpm"]
145 .iter()
146 .any(|r| which::which(r).is_ok());
147
148 let confidence = ConfidenceScore::base(0.50)
149 .signal(0.15, has_config)
150 .signal(0.10, project_dir.join("node_modules").is_dir())
151 .signal(0.10, has_lock)
152 .signal(0.07, has_runner)
153 .finish();
154
155 Some(DetectionResult {
156 language: "JavaScript".into(),
157 framework: framework.into(),
158 confidence,
159 })
160 }
161
162 fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
163 let framework = Self::detect_framework(project_dir).unwrap_or("jest");
164 let pkg_manager = Self::detect_package_manager(project_dir);
165
166 let mut cmd;
167
168 match framework {
169 "vitest" => {
170 cmd = build_js_runner_cmd(pkg_manager, "vitest");
171 cmd.arg("run"); cmd.args(["--reporter", "verbose"]);
173 }
174 "jest" => {
175 cmd = build_js_runner_cmd(pkg_manager, "jest");
176 }
177 "bun" => {
178 cmd = Command::new("bun");
179 cmd.arg("test");
180 }
181 "mocha" => {
182 cmd = build_js_runner_cmd(pkg_manager, "mocha");
183 }
184 "ava" => {
185 cmd = build_js_runner_cmd(pkg_manager, "ava");
186 }
187 _ => {
188 cmd = build_js_runner_cmd(pkg_manager, "jest");
189 }
190 }
191
192 for arg in extra_args {
193 cmd.arg(arg);
194 }
195
196 cmd.current_dir(project_dir);
197 Ok(cmd)
198 }
199
200 fn filter_args(&self, pattern: &str) -> Vec<String> {
201 vec!["-t".to_string(), pattern.to_string()]
202 }
203
204 fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
205 let combined = strip_ansi(&combined_output(stdout, stderr));
206 let failure_messages = parse_jest_failures(&combined);
207 let mut suites: Vec<TestSuite> = Vec::new();
208 let mut current_suite = String::new();
209 let mut current_tests: Vec<TestCase> = Vec::new();
210
211 for line in combined.lines() {
212 let trimmed = line.trim();
213
214 if (trimmed.starts_with("PASS ") || trimmed.starts_with("FAIL "))
217 && !trimmed.contains(" > ")
218 {
219 if !current_suite.is_empty() && !current_tests.is_empty() {
221 suites.push(TestSuite {
222 name: current_suite.clone(),
223 tests: std::mem::take(&mut current_tests),
224 });
225 }
226 current_suite = trimmed
227 .split_whitespace()
228 .nth(1)
229 .unwrap_or("tests")
230 .to_string();
231 continue;
232 }
233
234 if trimmed.starts_with('✓')
238 || trimmed.starts_with('✕')
239 || trimmed.starts_with('○')
240 || trimmed.starts_with("√")
241 || trimmed.starts_with("×")
242 || trimmed.starts_with('✔')
243 || trimmed.starts_with('✘')
244 {
245 let status = if trimmed.starts_with('✓')
246 || trimmed.starts_with("√")
247 || trimmed.starts_with('✔')
248 {
249 TestStatus::Passed
250 } else if trimmed.starts_with('○') {
251 TestStatus::Skipped
252 } else {
253 TestStatus::Failed
254 };
255
256 let rest = &trimmed[trimmed.char_indices().nth(1).map(|(i, _)| i).unwrap_or(1)..]
257 .trim_start();
258 let rest = rest.strip_prefix("[fail]: ").unwrap_or(rest);
260 let (name, duration) = parse_jest_test_line(rest);
261
262 let error = if status == TestStatus::Failed {
263 failure_messages.get(&name).map(|msg| super::TestError {
264 message: msg.clone(),
265 location: None,
266 })
267 } else {
268 None
269 };
270
271 current_tests.push(TestCase {
272 name,
273 status,
274 duration,
275 error,
276 });
277 continue;
278 }
279
280 if (trimmed.contains(" ✓ ")
282 || trimmed.contains(" ✕ ")
283 || trimmed.contains(" × ")
284 || trimmed.contains(" ✔ ")
285 || trimmed.contains(" ✘ "))
286 && !trimmed.starts_with("Test")
287 {
288 let status = if trimmed.contains(" ✓ ") || trimmed.contains(" ✔ ") {
289 TestStatus::Passed
290 } else {
291 TestStatus::Failed
292 };
293
294 let name = trimmed
295 .replace(" ✓ ", "")
296 .replace(" ✕ ", "")
297 .replace(" × ", "")
298 .replace(" ✔ ", "")
299 .replace(" ✘ ", "")
300 .trim()
301 .to_string();
302 let name = name
304 .strip_prefix("[fail]: ")
305 .map(|s| s.to_string())
306 .unwrap_or(name);
307
308 let error = if status == TestStatus::Failed {
309 failure_messages.get(&name).map(|msg| super::TestError {
310 message: msg.clone(),
311 location: None,
312 })
313 } else {
314 None
315 };
316
317 current_tests.push(TestCase {
318 name,
319 status,
320 duration: Duration::from_millis(0),
321 error,
322 });
323 }
324 }
325
326 if !current_tests.is_empty() {
328 let suite_name = if current_suite.is_empty() {
329 "tests".into()
330 } else {
331 current_suite
332 };
333 suites.push(TestSuite {
334 name: suite_name,
335 tests: current_tests,
336 });
337 }
338
339 if suites.is_empty() {
341 suites.push(parse_jest_summary(&combined, exit_code));
342 } else {
343 let summary = parse_jest_summary(&combined, exit_code);
347 let inline_total: usize = suites.iter().map(|s| s.tests.len()).sum();
348 let summary_total = summary.tests.len();
349 if summary_total > inline_total && summary_total > 1 {
350 let errors: Vec<(String, crate::adapters::TestError)> = suites
352 .iter()
353 .flat_map(|s| s.tests.iter())
354 .filter_map(|t| t.error.as_ref().map(|e| (t.name.clone(), e.clone())))
355 .collect();
356 suites = vec![summary];
357 if let Some(suite) = suites.first_mut() {
359 for (name, error) in errors {
360 if let Some(t) = suite
361 .tests
362 .iter_mut()
363 .find(|t| t.name == name && t.error.is_none())
364 {
365 t.error = Some(error);
366 }
367 }
368 }
369 }
370 }
371
372 let duration = parse_jest_duration(&combined).unwrap_or(Duration::from_secs(0));
373
374 TestRunResult {
375 suites,
376 duration,
377 raw_exit_code: exit_code,
378 }
379 }
380}
381
382fn parse_jest_test_line(line: &str) -> (String, Duration) {
384 let trimmed = line.trim();
385 if let Some(paren_start) = trimmed.rfind('(')
387 && let Some(paren_end) = trimmed.rfind(')')
388 {
389 let name = trimmed[..paren_start].trim().to_string();
390 let timing = &trimmed[paren_start + 1..paren_end];
391 let ms = timing
392 .replace("ms", "")
393 .replace("s", "")
394 .trim()
395 .parse::<f64>()
396 .unwrap_or(0.0);
397 let duration = if timing.contains("ms") {
398 Duration::from_millis(ms as u64)
399 } else {
400 duration_from_secs_safe(ms)
401 };
402 return (name, duration);
403 }
404 if let Some(last_space) = trimmed.rfind(' ') {
406 let last_word = &trimmed[last_space + 1..];
407 if let Some(ms_str) = last_word.strip_suffix("ms")
408 && let Ok(ms) = ms_str.parse::<f64>()
409 {
410 return (
411 trimmed[..last_space].trim().to_string(),
412 Duration::from_millis(ms as u64),
413 );
414 }
415 if let Some(s_str) = last_word.strip_suffix('s')
416 && let Ok(secs) = s_str.parse::<f64>()
417 {
418 return (
419 trimmed[..last_space].trim().to_string(),
420 duration_from_secs_safe(secs),
421 );
422 }
423 }
424 (trimmed.to_string(), Duration::from_millis(0))
425}
426
427fn parse_jest_summary(output: &str, exit_code: i32) -> TestSuite {
428 let mut tests = Vec::new();
429 for line in output.lines() {
430 let trimmed = line.trim();
431
432 if trimmed.contains("Tests:") && trimmed.contains("total") {
434 let after_label = trimmed.split("Tests:").nth(1).unwrap_or(trimmed);
435 for part in after_label.split(',') {
436 let part = part.trim();
437 if let Some(n) = part
438 .split_whitespace()
439 .next()
440 .and_then(|s| s.parse::<usize>().ok())
441 {
442 let status = if part.contains("passed") {
443 TestStatus::Passed
444 } else if part.contains("failed") {
445 TestStatus::Failed
446 } else if part.contains("skipped") || part.contains("todo") {
447 TestStatus::Skipped
448 } else {
449 continue;
450 };
451 for i in 0..n {
452 tests.push(TestCase {
453 name: format!(
454 "{}_{}",
455 if status == TestStatus::Passed {
456 "test"
457 } else {
458 "failed"
459 },
460 i + 1
461 ),
462 status: status.clone(),
463 duration: Duration::from_millis(0),
464 error: None,
465 });
466 }
467 }
468 }
469 continue;
470 }
471
472 if (trimmed.starts_with("Tests") || trimmed.starts_with("Tests "))
474 && !trimmed.contains(":")
475 && (trimmed.contains("passed") || trimmed.contains("failed"))
476 {
477 let after_tests = trimmed.trim_start_matches("Tests").trim();
479 for segment in after_tests.split('|') {
480 let segment = segment.trim();
481 let words: Vec<&str> = segment.split_whitespace().collect();
483 for w in words.windows(2) {
484 if let Ok(n) = w[0].parse::<usize>() {
485 let status_word = w[1].trim_end_matches(')');
486 let status = if status_word.contains("passed") {
487 TestStatus::Passed
488 } else if status_word.contains("failed") {
489 TestStatus::Failed
490 } else if status_word.contains("skipped") || status_word.contains("todo") {
491 TestStatus::Skipped
492 } else {
493 continue;
494 };
495 for i in 0..n {
496 tests.push(TestCase {
497 name: format!(
498 "{}_{}",
499 if status == TestStatus::Passed {
500 "test"
501 } else {
502 "failed"
503 },
504 tests.len() + i + 1
505 ),
506 status: status.clone(),
507 duration: Duration::from_millis(0),
508 error: None,
509 });
510 }
511 }
512 }
513 }
514 continue;
515 }
516
517 if (trimmed.contains("tests passed")
519 || trimmed.contains("tests failed")
520 || trimmed.contains("test passed")
521 || trimmed.contains("test failed"))
522 && let Some(n) = trimmed
523 .split_whitespace()
524 .next()
525 .and_then(|s| s.parse::<usize>().ok())
526 {
527 let status = if trimmed.contains("passed") {
528 TestStatus::Passed
529 } else {
530 TestStatus::Failed
531 };
532 for i in 0..n {
533 tests.push(TestCase {
534 name: format!(
535 "{}_{}",
536 if status == TestStatus::Passed {
537 "test"
538 } else {
539 "failed"
540 },
541 tests.len() + i + 1
542 ),
543 status: status.clone(),
544 duration: Duration::from_millis(0),
545 error: None,
546 });
547 }
548 }
549 }
550
551 if tests.is_empty() {
552 tests.push(TestCase {
553 name: "test_suite".into(),
554 status: if exit_code == 0 {
555 TestStatus::Passed
556 } else {
557 TestStatus::Failed
558 },
559 duration: Duration::from_millis(0),
560 error: None,
561 });
562 }
563
564 TestSuite {
565 name: "tests".into(),
566 tests,
567 }
568}
569
570fn parse_jest_failures(output: &str) -> std::collections::HashMap<String, String> {
581 let mut failures = std::collections::HashMap::new();
582 let lines: Vec<&str> = output.lines().collect();
583
584 let mut i = 0;
585 while i < lines.len() {
586 let trimmed = lines[i].trim();
587 if trimmed.starts_with('●') {
589 let test_name = trimmed[trimmed
590 .char_indices()
591 .nth(1)
592 .map(|(idx, _)| idx)
593 .unwrap_or(1)..]
594 .trim()
595 .to_string();
596 if !test_name.is_empty() {
597 let mut error_lines = Vec::new();
598 i += 1;
599 while i < lines.len() {
600 let l = lines[i].trim();
601 if l.starts_with('●')
603 || l.starts_with("Test Suites:")
604 || l.starts_with("Tests:")
605 {
606 break;
607 }
608 if !l.is_empty() && !l.starts_with('|') && !l.starts_with("at ") {
610 error_lines.push(l.to_string());
611 }
612 i += 1;
613 }
614 if !error_lines.is_empty() {
615 let msg = error_lines
617 .iter()
618 .take(4)
619 .cloned()
620 .collect::<Vec<_>>()
621 .join(" | ");
622 let short_name = test_name
625 .split(" › ")
626 .last()
627 .unwrap_or(&test_name)
628 .to_string();
629 failures.insert(test_name.clone(), msg.clone());
630 if short_name != test_name {
631 failures.insert(short_name, msg);
632 }
633 }
634 continue;
635 }
636 }
637 i += 1;
638 }
639 failures
640}
641
642fn parse_jest_duration(output: &str) -> Option<Duration> {
643 for line in output.lines() {
644 let trimmed = line.trim();
645 if trimmed.contains("Time:") {
647 let parts: Vec<&str> = trimmed.split_whitespace().collect();
648 for (i, part) in parts.iter().enumerate() {
649 if let Ok(n) = part.parse::<f64>()
650 && let Some(unit) = parts.get(i + 1)
651 {
652 if unit.starts_with('s') {
653 return Some(duration_from_secs_safe(n));
654 } else if unit.starts_with("ms") {
655 return Some(Duration::from_millis(n as u64));
656 }
657 }
658 }
659 }
660 if trimmed.starts_with("Duration")
662 && !trimmed.contains(":")
663 && let Some(dur_str) = trimmed
664 .strip_prefix("Duration")
665 .and_then(|s| s.split_whitespace().next())
666 {
667 if let Some(secs) = dur_str
668 .strip_suffix('s')
669 .and_then(|s| s.parse::<f64>().ok())
670 {
671 return Some(duration_from_secs_safe(secs));
672 } else if let Some(ms) = dur_str
673 .strip_suffix("ms")
674 .and_then(|s| s.parse::<f64>().ok())
675 {
676 return Some(Duration::from_millis(ms as u64));
677 }
678 }
679 }
680 None
681}
682
683fn strip_ansi(s: &str) -> String {
685 let mut out = String::with_capacity(s.len());
686 let mut chars = s.chars();
687 while let Some(ch) = chars.next() {
688 if ch == '\x1b' {
689 if let Some(next) = chars.next()
691 && next == '['
692 {
693 for c in chars.by_ref() {
695 if c.is_ascii_alphabetic() {
696 break;
697 }
698 }
699 }
700 } else {
702 out.push(ch);
703 }
704 }
705 out
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 #[test]
713 fn parse_jest_verbose_output() {
714 let stdout = r#"
715PASS src/utils.test.ts
716 ✓ should add numbers (3 ms)
717 ✓ should subtract numbers (1 ms)
718 ✕ should multiply numbers (2 ms)
719
720 ● should multiply numbers
721
722 expect(received).toBe(expected)
723
724 Expected: 7
725 Received: 6
726
727Test Suites: 1 passed, 1 total
728Tests: 2 passed, 1 failed, 3 total
729Time: 1.234 s
730"#;
731 let adapter = JavaScriptAdapter::new();
732 let result = adapter.parse_output(stdout, "", 1);
733
734 assert_eq!(result.total_tests(), 3);
735 assert_eq!(result.total_passed(), 2);
736 assert_eq!(result.total_failed(), 1);
737 assert!(!result.is_success());
738
739 let failed = &result.suites[0].failures();
741 assert_eq!(failed.len(), 1);
742 assert!(failed[0].error.is_some());
743 assert!(
744 failed[0]
745 .error
746 .as_ref()
747 .unwrap()
748 .message
749 .contains("expect(received).toBe(expected)")
750 );
751 }
752
753 #[test]
754 fn parse_jest_all_pass() {
755 let stdout = r#"
756PASS src/math.test.ts
757 ✓ test_one (5 ms)
758 ✓ test_two (2 ms)
759
760Tests: 2 passed, 2 total
761Time: 0.456 s
762"#;
763 let adapter = JavaScriptAdapter::new();
764 let result = adapter.parse_output(stdout, "", 0);
765
766 assert_eq!(result.total_passed(), 2);
767 assert!(result.is_success());
768 assert_eq!(result.duration, Duration::from_millis(456));
769 }
770
771 #[test]
772 fn parse_jest_summary_fallback() {
773 let stdout = "Tests: 5 passed, 2 failed, 7 total\nTime: 3.21 s\n";
774 let adapter = JavaScriptAdapter::new();
775 let result = adapter.parse_output(stdout, "", 1);
776
777 assert_eq!(result.total_passed(), 5);
778 assert_eq!(result.total_failed(), 2);
779 }
780
781 #[test]
782 fn parse_jest_test_line_with_duration() {
783 let (name, dur) = parse_jest_test_line(" should add numbers (5 ms)");
784 assert_eq!(name, "should add numbers");
785 assert_eq!(dur, Duration::from_millis(5));
786 }
787
788 #[test]
789 fn parse_jest_test_line_no_duration() {
790 let (name, dur) = parse_jest_test_line(" should add numbers");
791 assert_eq!(name, "should add numbers");
792 assert_eq!(dur, Duration::from_millis(0));
793 }
794
795 #[test]
796 fn parse_vitest_test_line_duration_ms() {
797 let (name, dur) =
798 parse_jest_test_line("tests/unit/core/AxiosError.test.js > core > test name 21ms");
799 assert_eq!(
800 name,
801 "tests/unit/core/AxiosError.test.js > core > test name"
802 );
803 assert_eq!(dur, Duration::from_millis(21));
804 }
805
806 #[test]
807 fn parse_vitest_test_line_duration_zero() {
808 let (name, dur) = parse_jest_test_line("file.test.ts > should work 0ms");
809 assert_eq!(name, "file.test.ts > should work");
810 assert_eq!(dur, Duration::from_millis(0));
811 }
812
813 #[test]
814 fn parse_jest_duration_seconds() {
815 assert_eq!(
816 parse_jest_duration("Time: 1.234 s"),
817 Some(Duration::from_millis(1234))
818 );
819 }
820
821 #[test]
822 fn parse_jest_duration_ms() {
823 assert_eq!(
824 parse_jest_duration("Time: 456 ms"),
825 Some(Duration::from_millis(456))
826 );
827 }
828
829 #[test]
830 fn detect_vitest_project() {
831 let dir = tempfile::tempdir().unwrap();
832 std::fs::write(
833 dir.path().join("package.json"),
834 r#"{"devDependencies":{"vitest":"^1.0"}}"#,
835 )
836 .unwrap();
837 std::fs::write(dir.path().join("vitest.config.ts"), "export default {}").unwrap();
838 let adapter = JavaScriptAdapter::new();
839 let det = adapter.detect(dir.path()).unwrap();
840 assert_eq!(det.framework, "vitest");
841 }
842
843 #[test]
844 fn detect_jest_project() {
845 let dir = tempfile::tempdir().unwrap();
846 std::fs::write(
847 dir.path().join("package.json"),
848 r#"{"devDependencies":{"jest":"^29"}}"#,
849 )
850 .unwrap();
851 std::fs::write(dir.path().join("jest.config.js"), "module.exports = {}").unwrap();
852 let adapter = JavaScriptAdapter::new();
853 let det = adapter.detect(dir.path()).unwrap();
854 assert_eq!(det.framework, "jest");
855 }
856
857 #[test]
858 fn detect_no_js() {
859 let dir = tempfile::tempdir().unwrap();
860 std::fs::write(dir.path().join("main.py"), "print('hello')\n").unwrap();
861 let adapter = JavaScriptAdapter::new();
862 assert!(adapter.detect(dir.path()).is_none());
863 }
864
865 #[test]
866 fn detect_bun_package_manager() {
867 let dir = tempfile::tempdir().unwrap();
868 std::fs::write(dir.path().join("bun.lockb"), "").unwrap();
869 assert_eq!(JavaScriptAdapter::detect_package_manager(dir.path()), "bun");
870 }
871
872 #[test]
873 fn detect_pnpm_package_manager() {
874 let dir = tempfile::tempdir().unwrap();
875 std::fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
876 assert_eq!(
877 JavaScriptAdapter::detect_package_manager(dir.path()),
878 "pnpm"
879 );
880 }
881
882 #[test]
883 fn parse_jest_empty_output() {
884 let adapter = JavaScriptAdapter::new();
885 let result = adapter.parse_output("", "", 0);
886
887 assert_eq!(result.total_tests(), 1);
888 assert!(result.is_success());
889 }
890
891 #[test]
892 fn parse_jest_with_describe_blocks() {
893 let stdout = r#"
894PASS src/math.test.ts
895 Math operations
896 ✓ should add (2 ms)
897 ✓ should subtract (1 ms)
898 String operations
899 ✕ should uppercase (3 ms)
900
901 ● String operations › should uppercase
902
903 expect(received).toBe(expected)
904
905Tests: 2 passed, 1 failed, 3 total
906Time: 0.789 s
907"#;
908 let adapter = JavaScriptAdapter::new();
909 let result = adapter.parse_output(stdout, "", 1);
910
911 assert_eq!(result.total_tests(), 3);
912 assert_eq!(result.total_passed(), 2);
913 assert_eq!(result.total_failed(), 1);
914 }
915
916 #[test]
917 fn parse_jest_multiple_suites() {
918 let stdout = r#"
919PASS src/a.test.ts
920 ✓ test_a1 (1 ms)
921 ✓ test_a2 (1 ms)
922FAIL src/b.test.ts
923 ✓ test_b1 (1 ms)
924 ✕ test_b2 (5 ms)
925
926Tests: 3 passed, 1 failed, 4 total
927Time: 1.0 s
928"#;
929 let adapter = JavaScriptAdapter::new();
930 let result = adapter.parse_output(stdout, "", 1);
931
932 assert_eq!(result.total_tests(), 4);
933 assert_eq!(result.suites.len(), 2);
934 assert_eq!(result.suites[0].name, "src/a.test.ts");
935 assert_eq!(result.suites[1].name, "src/b.test.ts");
936 }
937
938 #[test]
939 fn parse_jest_skipped_tests() {
940 let stdout = r#"
941PASS src/utils.test.ts
942 ✓ should work (2 ms)
943 ○ skipped test
944
945Tests: 1 passed, 1 skipped, 2 total
946Time: 0.5 s
947"#;
948 let adapter = JavaScriptAdapter::new();
949 let result = adapter.parse_output(stdout, "", 0);
950
951 assert_eq!(result.total_passed(), 1);
952 assert_eq!(result.total_skipped(), 1);
953 assert!(result.is_success());
954 }
955
956 #[test]
957 fn detect_yarn_package_manager() {
958 let dir = tempfile::tempdir().unwrap();
959 std::fs::write(dir.path().join("yarn.lock"), "").unwrap();
960 assert_eq!(
961 JavaScriptAdapter::detect_package_manager(dir.path()),
962 "yarn"
963 );
964 }
965
966 #[test]
967 fn detect_npx_default() {
968 let dir = tempfile::tempdir().unwrap();
969 assert_eq!(JavaScriptAdapter::detect_package_manager(dir.path()), "npx");
970 }
971
972 #[test]
973 fn detect_mocha_project() {
974 let dir = tempfile::tempdir().unwrap();
975 std::fs::write(
976 dir.path().join("package.json"),
977 r#"{"devDependencies":{"mocha":"^10"}}"#,
978 )
979 .unwrap();
980 std::fs::write(dir.path().join(".mocharc.yml"), "").unwrap();
981 let adapter = JavaScriptAdapter::new();
982 let det = adapter.detect(dir.path()).unwrap();
983 assert_eq!(det.framework, "mocha");
984 }
985
986 #[test]
987 fn detect_no_framework_without_package_json() {
988 let dir = tempfile::tempdir().unwrap();
989 std::fs::write(dir.path().join("index.js"), "console.log('hi')").unwrap();
990 let adapter = JavaScriptAdapter::new();
991 assert!(adapter.detect(dir.path()).is_none());
992 }
993
994 #[test]
995 fn parse_ava_output() {
996 let stdout = " ✔ body-size › returns 0 for null\n ✔ body-size › returns correct size\n ✘ [fail]: browser › request fails Rejected promise\n\n 1 test failed\n";
997 let adapter = JavaScriptAdapter::new();
998 let result = adapter.parse_output(stdout, "", 1);
999
1000 assert_eq!(result.total_passed(), 2);
1001 assert_eq!(result.total_failed(), 1);
1002 assert_eq!(result.total_tests(), 3);
1003 }
1004
1005 #[test]
1006 fn parse_ava_checkmark_chars() {
1007 let stdout = "✔ test_one\n✘ test_two\n";
1009 let adapter = JavaScriptAdapter::new();
1010 let result = adapter.parse_output(stdout, "", 1);
1011
1012 assert_eq!(result.total_passed(), 1);
1013 assert_eq!(result.total_failed(), 1);
1014 }
1015
1016 #[test]
1017 fn parse_vitest_summary_format() {
1018 let stdout = " Test Files 323 passed (323)\n Tests 3575 passed (3575)\n Start at 12:24:03\n Duration 30.18s\n";
1020 let adapter = JavaScriptAdapter::new();
1021 let result = adapter.parse_output(stdout, "", 0);
1022
1023 assert_eq!(result.total_passed(), 3575);
1024 assert!(result.is_success());
1025 }
1026
1027 #[test]
1028 fn parse_vitest_mixed_summary() {
1029 let stdout = " Tests 10 failed | 3565 passed (3575)\n Duration 30.18s\n";
1030 let adapter = JavaScriptAdapter::new();
1031 let result = adapter.parse_output(stdout, "", 1);
1032
1033 assert_eq!(result.total_passed(), 3565);
1034 assert_eq!(result.total_failed(), 10);
1035 assert_eq!(result.total_tests(), 3575);
1036 }
1037
1038 #[test]
1039 fn parse_vitest_verbose_output() {
1040 let stdout = r#"
1041 ✓ unit tests/unit/core/AxiosError.test.js > core::AxiosError > creates an error 21ms
1042 ✓ unit tests/unit/core/AxiosError.test.js > core::AxiosError > serializes to JSON safely 4ms
1043 × unit tests/unit/adapters/http.test.js > supports http > should handle EPIPE
1044
1045 Tests 1 failed | 2 passed (3)
1046 Duration 5.00s
1047"#;
1048 let adapter = JavaScriptAdapter::new();
1049 let result = adapter.parse_output(stdout, "", 1);
1050
1051 assert_eq!(result.total_tests(), 3);
1052 assert_eq!(result.total_passed(), 2);
1053 assert_eq!(result.total_failed(), 1);
1054
1055 let names: Vec<&str> = result.suites[0]
1057 .tests
1058 .iter()
1059 .map(|t| t.name.as_str())
1060 .collect();
1061 assert!(names[0].contains("creates an error"));
1062 assert!(names[1].contains("serializes to JSON safely"));
1063
1064 assert_eq!(
1066 result.suites[0].tests[0].duration,
1067 Duration::from_millis(21)
1068 );
1069 assert_eq!(result.suites[0].tests[1].duration, Duration::from_millis(4));
1070 }
1071
1072 #[test]
1073 fn parse_vitest_duration_format() {
1074 assert_eq!(
1075 parse_jest_duration(" Duration 30.18s (transform 24.34s, setup 16.70s)"),
1076 Some(Duration::from_millis(30180))
1077 );
1078 }
1079
1080 #[test]
1081 fn parse_ava_summary_fallback() {
1082 let stdout = " 30 tests failed\n 2 known failures\n";
1083 let adapter = JavaScriptAdapter::new();
1084 let result = adapter.parse_output(stdout, "", 1);
1085
1086 assert_eq!(result.total_failed(), 30);
1087 }
1088
1089 #[test]
1090 fn detect_ava_project() {
1091 let dir = tempfile::tempdir().unwrap();
1092 std::fs::write(
1093 dir.path().join("package.json"),
1094 r#"{"devDependencies":{"ava":"^6"}}"#,
1095 )
1096 .unwrap();
1097 let adapter = JavaScriptAdapter::new();
1098 let det = adapter.detect(dir.path()).unwrap();
1099 assert_eq!(det.framework, "ava");
1100 }
1101}