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::{combined_output, duration_from_secs_safe};
8use super::{
9    ConfidenceScore, DetectionResult, TestAdapter, TestCase, TestRunResult, TestStatus, TestSuite,
10};
11
12/// Build a Command to run a JS tool via the detected package manager.
13/// npx: `npx <tool>`, bun: `bunx <tool>`, yarn/pnpm: `yarn <tool>` / `pnpm <tool>`
14fn 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        // yarn and pnpm can run local binaries directly
24        _ => {
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        // Check for vitest config files first (highest priority)
65        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        // Check for bun test
74        if project_dir.join("bunfig.toml").exists()
75            && (project_dir.join("bun.lockb").exists() || project_dir.join("bun.lock").exists())
76        {
77            // bun has a built-in test runner
78            if content.contains("\"bun:test\"") || !content.contains("\"jest\"") {
79                return Some("bun");
80            }
81        }
82
83        // Jest
84        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        // Mocha
94        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        // AVA
103        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        // Check for any common JS runner
122        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"); // non-watch mode
172                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            // Jest/Vitest suite header: "PASS src/utils.test.ts" or "FAIL src/utils.test.ts"
215            // Skip vitest verbose failure details like "FAIL  unit  file > describe > test"
216            if (trimmed.starts_with("PASS ") || trimmed.starts_with("FAIL "))
217                && !trimmed.contains(" > ")
218            {
219                // Flush previous suite
220                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            // Jest/Vitest/AVA test result lines
235            // Jest/Vitest: "✓ should work (5 ms)" / "✕ should fail"
236            // AVA: "✔ suite › test name" / "✘ [fail]: suite › test name Error"
237            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                // AVA failure format: "[fail]: suite › test Error msg" — strip "[fail]: " prefix
259                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            // Vitest format: "  ✓ module > test name 5ms"
281            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                // Strip AVA "[fail]: " prefix
303                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        // Flush last suite
327        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        // Fallback: parse summary line
340        if suites.is_empty() {
341            suites.push(parse_jest_summary(&combined, exit_code));
342        } else {
343            // If we parsed individual lines, but a summary line shows more tests,
344            // prefer the summary (this handles vitest default output where ✓ lines are
345            // file-level, not test-level). But keep any error details from individual parsing.
346            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                // Collect errors from individually parsed tests before replacing
351                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                // Re-attach errors to matching failed tests
358                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
382/// Parse "should work (5 ms)" or "should work 5ms" → ("should work", Duration(5ms))
383fn parse_jest_test_line(line: &str) -> (String, Duration) {
384    let trimmed = line.trim();
385    // Jest format: "should work (5 ms)"
386    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    // Vitest verbose format: "test name 21ms" or "test name 1.5s"
405    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        // Jest format: "Tests:  X passed, Y failed, Z total"
433        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        // Vitest format: "Tests  3575 passed (3575)" or "Tests  10 failed | 3565 passed (3575)"
473        if (trimmed.starts_with("Tests") || trimmed.starts_with("Tests "))
474            && !trimmed.contains(":")
475            && (trimmed.contains("passed") || trimmed.contains("failed"))
476        {
477            // Split by | for multi-status: "10 failed | 3565 passed (3575)"
478            let after_tests = trimmed.trim_start_matches("Tests").trim();
479            for segment in after_tests.split('|') {
480                let segment = segment.trim();
481                // Extract "N status" pairs
482                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        // AVA format: "30 tests failed" or "5 tests passed" or "2 known failures"
518        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
570/// Parse Jest/Vitest failure blocks to extract error messages per test.
571/// Jest shows errors like:
572/// ```text
573///   ● should multiply numbers
574///
575///     expect(received).toBe(expected)
576///
577///     Expected: 7
578///     Received: 6
579/// ```
580fn 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        // Match "● test name" or "● describe › test name"
588        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                    // Next failure block or summary section
602                    if l.starts_with('●')
603                        || l.starts_with("Test Suites:")
604                        || l.starts_with("Tests:")
605                    {
606                        break;
607                    }
608                    // Collect meaningful error lines (skip empty, skip code frame lines starting with |)
609                    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                    // Take first few lines to keep message concise
616                    let msg = error_lines
617                        .iter()
618                        .take(4)
619                        .cloned()
620                        .collect::<Vec<_>>()
621                        .join(" | ");
622                    // The test name in the ● block might be "describe › test name"
623                    // but in results it's just "test name". Store under last segment too.
624                    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        // Jest: "Time:  1.234 s" or "Time:  123 ms"
646        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        // Vitest: "Duration  30.18s (transform 24.34s, ...)"
661        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
683/// Strip ANSI escape codes from text. Handles CSI sequences like \x1b[32m.
684fn 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            // Skip CSI sequence: ESC [ ... (letter)
690            if let Some(next) = chars.next()
691                && next == '['
692            {
693                // Consume until a letter (A-Z, a-z) terminates the sequence
694                for c in chars.by_ref() {
695                    if c.is_ascii_alphabetic() {
696                        break;
697                    }
698                }
699            }
700            // else: non-CSI escape, skip the next char
701        } 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        // Verify error message was captured
740        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        // ✔ = U+2714, ✘ = U+2718 (different from Jest ✓/✕)
1008        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        // Vitest: "Tests  3575 passed (3575)"
1019        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        // Verify real test names, not synthetic test_N
1056        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        // Verify durations were parsed from vitest format (no parens)
1065        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}