Skip to main content

testx/adapters/
javascript.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::{DetectionResult, TestAdapter, TestCase, TestRunResult, TestStatus, TestSuite};
9
10/// Build a Command to run a JS tool via the detected package manager.
11/// npx: `npx <tool>`, bun: `bunx <tool>`, yarn/pnpm: `yarn <tool>` / `pnpm <tool>`
12fn 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        // yarn and pnpm can run local binaries directly
22        _ => {
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        // Check for vitest config files first (highest priority)
63        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        // Check for bun test
72        if project_dir.join("bunfig.toml").exists()
73            && (project_dir.join("bun.lockb").exists() || project_dir.join("bun.lock").exists())
74        {
75            // bun has a built-in test runner
76            if content.contains("\"bun:test\"") || !content.contains("\"jest\"") {
77                return Some("bun");
78            }
79        }
80
81        // Jest
82        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        // Mocha
92        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        // AVA
101        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        // Check for any common JS runner
120        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"); // non-watch mode
148            }
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            // Jest/Vitest suite header: "PASS src/utils.test.ts" or "FAIL src/utils.test.ts"
186            if trimmed.starts_with("PASS ") || trimmed.starts_with("FAIL ") {
187                // Flush previous suite
188                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            // Jest/Vitest/AVA test result lines
203            // Jest/Vitest: "✓ should work (5 ms)" / "✕ should fail"
204            // AVA: "✔ suite › test name" / "✘ [fail]: suite › test name Error"
205            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                // AVA failure format: "[fail]: suite › test Error msg" — strip "[fail]: " prefix
227                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            // Vitest format: "  ✓ module > test name 5ms"
249            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                // Strip AVA "[fail]: " prefix
271                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        // Flush last suite
295        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        // Fallback: parse summary line
308        if suites.is_empty() {
309            suites.push(parse_jest_summary(&combined, exit_code));
310        } else {
311            // If we parsed individual lines, but a summary line shows more tests,
312            // prefer the summary (this handles vitest default output where ✓ lines are
313            // file-level, not test-level)
314            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
332/// Parse "should work (5 ms)" → ("should work", Duration(5ms))
333fn 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        // Jest format: "Tests:  X passed, Y failed, Z total"
362        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        // Vitest format: "Tests  3575 passed (3575)" or "Tests  10 failed | 3565 passed (3575)"
402        if (trimmed.starts_with("Tests") || trimmed.starts_with("Tests "))
403            && !trimmed.contains(":")
404            && (trimmed.contains("passed") || trimmed.contains("failed"))
405        {
406            // Split by | for multi-status: "10 failed | 3565 passed (3575)"
407            let after_tests = trimmed.trim_start_matches("Tests").trim();
408            for segment in after_tests.split('|') {
409                let segment = segment.trim();
410                // Extract "N status" pairs
411                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        // AVA format: "30 tests failed" or "5 tests passed" or "2 known failures"
447        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
499/// Parse Jest/Vitest failure blocks to extract error messages per test.
500/// Jest shows errors like:
501/// ```text
502///   ● should multiply numbers
503///
504///     expect(received).toBe(expected)
505///
506///     Expected: 7
507///     Received: 6
508/// ```
509fn 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        // Match "● test name" or "● describe › test name"
517        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                    // Next failure block or summary section
531                    if l.starts_with('●')
532                        || l.starts_with("Test Suites:")
533                        || l.starts_with("Tests:")
534                    {
535                        break;
536                    }
537                    // Collect meaningful error lines (skip empty, skip code frame lines starting with |)
538                    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                    // Take first few lines to keep message concise
545                    let msg = error_lines
546                        .iter()
547                        .take(4)
548                        .cloned()
549                        .collect::<Vec<_>>()
550                        .join(" | ");
551                    // The test name in the ● block might be "describe › test name"
552                    // but in results it's just "test name". Store under last segment too.
553                    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        // Jest: "Time:  1.234 s" or "Time:  123 ms"
575        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        // Vitest: "Duration  30.18s (transform 24.34s, ...)"
590        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
612/// Strip ANSI escape codes from text. Handles CSI sequences like \x1b[32m.
613fn 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            // Skip CSI sequence: ESC [ ... (letter)
619            if let Some(next) = chars.next()
620                && next == '['
621            {
622                // Consume until a letter (A-Z, a-z) terminates the sequence
623                for c in chars.by_ref() {
624                    if c.is_ascii_alphabetic() {
625                        break;
626                    }
627                }
628            }
629            // else: non-CSI escape, skip the next char
630        } 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        // Verify error message was captured
669        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        // ✔ = U+2714, ✘ = U+2718 (different from Jest ✓/✕)
919        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        // Vitest: "Tests  3575 passed (3575)"
930        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}