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