Skip to main content

testx/plugin/
script_adapter.rs

1//! Script-based custom adapter for user-defined test frameworks.
2//!
3//! Users can define custom adapters in testx.toml that run arbitrary commands
4//! and parse output in a standard format (JSON, JUnit XML, TAP, or line-based).
5
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9use crate::adapters::util::duration_from_secs_safe;
10use crate::adapters::{TestCase, TestError, TestRunResult, TestStatus, TestSuite};
11
12/// Output parser type for a script adapter.
13#[derive(Debug, Clone, PartialEq)]
14pub enum OutputParser {
15    /// Expects JSON matching TestRunResult schema
16    Json,
17    /// Expects JUnit XML output
18    Junit,
19    /// Expects TAP (Test Anything Protocol) output
20    Tap,
21    /// One test per line with status prefix
22    Lines,
23    /// Custom regex-based parser
24    Regex(RegexParserConfig),
25}
26
27/// Configuration for regex-based output parsing.
28#[derive(Debug, Clone, PartialEq)]
29pub struct RegexParserConfig {
30    /// Pattern to match a passing test line
31    pub pass_pattern: String,
32    /// Pattern to match a failing test line
33    pub fail_pattern: String,
34    /// Pattern to match a skipped test line
35    pub skip_pattern: Option<String>,
36    /// Capture group index for the test name (1-indexed)
37    pub name_group: usize,
38    /// Optional capture group for duration
39    pub duration_group: Option<usize>,
40}
41
42/// Definition of a custom script adapter from config.
43#[derive(Debug, Clone)]
44pub struct ScriptAdapterConfig {
45    /// Unique adapter name
46    pub name: String,
47    /// File whose presence triggers detection
48    pub detect_file: String,
49    /// Optional detect pattern (glob) for more specific detection
50    pub detect_pattern: Option<String>,
51    /// Command to run
52    pub command: String,
53    /// Default arguments
54    pub args: Vec<String>,
55    /// Output parser type
56    pub parser: OutputParser,
57    /// Working directory relative to project root (default: ".")
58    pub working_dir: Option<String>,
59    /// Environment variables to set
60    pub env: Vec<(String, String)>,
61}
62
63impl ScriptAdapterConfig {
64    /// Create a minimal script adapter config.
65    pub fn new(name: &str, detect_file: &str, command: &str) -> Self {
66        Self {
67            name: name.to_string(),
68            detect_file: detect_file.to_string(),
69            detect_pattern: None,
70            command: command.to_string(),
71            args: Vec::new(),
72            parser: OutputParser::Lines,
73            working_dir: None,
74            env: Vec::new(),
75        }
76    }
77
78    /// Set the output parser.
79    pub fn with_parser(mut self, parser: OutputParser) -> Self {
80        self.parser = parser;
81        self
82    }
83
84    /// Set default args.
85    pub fn with_args(mut self, args: Vec<String>) -> Self {
86        self.args = args;
87        self
88    }
89
90    /// Set working directory.
91    pub fn with_working_dir(mut self, dir: &str) -> Self {
92        self.working_dir = Some(dir.to_string());
93        self
94    }
95
96    /// Add an environment variable.
97    pub fn with_env(mut self, key: &str, value: &str) -> Self {
98        self.env.push((key.to_string(), value.to_string()));
99        self
100    }
101
102    /// Check if this adapter detects at the given project directory.
103    pub fn detect(&self, project_dir: &Path) -> bool {
104        let detect_path = project_dir.join(&self.detect_file);
105        if detect_path.exists() {
106            return true;
107        }
108
109        // Check detect_pattern if set
110        if let Some(ref pattern) = self.detect_pattern {
111            return glob_detect(project_dir, pattern);
112        }
113
114        false
115    }
116
117    /// Get the effective working directory.
118    pub fn effective_working_dir(&self, project_dir: &Path) -> PathBuf {
119        match &self.working_dir {
120            Some(dir) => project_dir.join(dir),
121            None => project_dir.to_path_buf(),
122        }
123    }
124
125    /// Build the command string with args.
126    pub fn full_command(&self) -> String {
127        let mut parts = vec![self.command.clone()];
128        parts.extend(self.args.clone());
129        parts.join(" ")
130    }
131}
132
133/// Simple glob detection — checks if any file matching the pattern exists.
134fn glob_detect(project_dir: &Path, pattern: &str) -> bool {
135    // Simple implementation: check common patterns
136    if pattern.contains('*') {
137        // For now, just check if the non-glob part exists as a directory
138        if let Some(base) = pattern.split('*').next() {
139            let base = base.trim_end_matches('/');
140            if !base.is_empty() {
141                return project_dir.join(base).exists();
142            }
143        }
144        // Fallback: try the pattern as-is
145        project_dir.join(pattern).exists()
146    } else {
147        project_dir.join(pattern).exists()
148    }
149}
150
151// ─── Output Parsers ─────────────────────────────────────────────────────
152
153/// Parse output from a script adapter using the configured parser.
154pub fn parse_script_output(
155    parser: &OutputParser,
156    stdout: &str,
157    stderr: &str,
158    exit_code: i32,
159) -> TestRunResult {
160    match parser {
161        OutputParser::Json => parse_json_output(stdout, stderr, exit_code),
162        OutputParser::Junit => parse_junit_output(stdout, exit_code),
163        OutputParser::Tap => parse_tap_output(stdout, exit_code),
164        OutputParser::Lines => parse_lines_output(stdout, exit_code),
165        OutputParser::Regex(config) => parse_regex_output(stdout, config, exit_code),
166    }
167}
168
169/// Parse JSON-formatted test output.
170fn parse_json_output(stdout: &str, _stderr: &str, exit_code: i32) -> TestRunResult {
171    // Try to parse as a TestRunResult JSON
172    if let Ok(result) = serde_json::from_str::<serde_json::Value>(stdout) {
173        let suites = parse_json_suites(&result);
174        if !suites.is_empty() {
175            return TestRunResult {
176                suites,
177                duration: Duration::ZERO,
178                raw_exit_code: exit_code,
179            };
180        }
181    }
182
183    // Fallback
184    fallback_result(stdout, exit_code, "json")
185}
186
187/// Extract test suites from a JSON value.
188fn parse_json_suites(value: &serde_json::Value) -> Vec<TestSuite> {
189    let mut suites = Vec::new();
190
191    // Handle {"suites": [...]} format
192    if let Some(arr) = value.get("suites").and_then(|v| v.as_array()) {
193        for suite_val in arr {
194            if let Some(suite) = parse_json_suite(suite_val) {
195                suites.push(suite);
196            }
197        }
198    }
199
200    // Handle {"tests": [...]} format (single suite)
201    if suites.is_empty()
202        && let Some(arr) = value.get("tests").and_then(|v| v.as_array())
203    {
204        let name = value
205            .get("name")
206            .and_then(|v| v.as_str())
207            .unwrap_or("tests");
208        let tests: Vec<TestCase> = arr.iter().filter_map(parse_json_test).collect();
209        if !tests.is_empty() {
210            suites.push(TestSuite {
211                name: name.to_string(),
212                tests,
213            });
214        }
215    }
216
217    // Handle [{"name": ..., "status": ...}, ...] format (flat array of tests)
218    if suites.is_empty()
219        && let Some(arr) = value.as_array()
220    {
221        let tests: Vec<TestCase> = arr.iter().filter_map(parse_json_test).collect();
222        if !tests.is_empty() {
223            suites.push(TestSuite {
224                name: "tests".to_string(),
225                tests,
226            });
227        }
228    }
229
230    suites
231}
232
233fn parse_json_suite(value: &serde_json::Value) -> Option<TestSuite> {
234    let name = value.get("name").and_then(|v| v.as_str())?;
235    let tests_arr = value.get("tests").and_then(|v| v.as_array())?;
236    let tests: Vec<TestCase> = tests_arr.iter().filter_map(parse_json_test).collect();
237    Some(TestSuite {
238        name: name.to_string(),
239        tests,
240    })
241}
242
243fn parse_json_test(value: &serde_json::Value) -> Option<TestCase> {
244    let name = value.get("name").and_then(|v| v.as_str())?;
245    let status_str = value.get("status").and_then(|v| v.as_str())?;
246
247    let status = match status_str.to_lowercase().as_str() {
248        "passed" | "pass" | "ok" | "success" => TestStatus::Passed,
249        "failed" | "fail" | "error" | "failure" => TestStatus::Failed,
250        "skipped" | "skip" | "pending" | "ignored" => TestStatus::Skipped,
251        _ => return None,
252    };
253
254    let duration = value
255        .get("duration")
256        .and_then(|v| v.as_f64())
257        .map(|ms| duration_from_secs_safe(ms / 1000.0))
258        .unwrap_or(Duration::ZERO);
259
260    let error = value.get("error").and_then(|v| {
261        let message = v.as_str().map(|s| s.to_string()).or_else(|| {
262            v.get("message")
263                .and_then(|m| m.as_str().map(|s| s.to_string()))
264        })?;
265        let location = v
266            .get("location")
267            .and_then(|l| l.as_str().map(|s| s.to_string()));
268        Some(TestError { message, location })
269    });
270
271    Some(TestCase {
272        name: name.to_string(),
273        status,
274        duration,
275        error,
276    })
277}
278
279/// Parse JUnit XML output.
280fn parse_junit_output(stdout: &str, exit_code: i32) -> TestRunResult {
281    let mut suites = Vec::new();
282
283    // Find all <testsuite> blocks
284    for line in stdout.lines() {
285        let trimmed = line.trim();
286        if trimmed.starts_with("<testsuite")
287            && !trimmed.starts_with("<testsuites")
288            && let Some(suite) = parse_junit_suite_tag(trimmed, stdout)
289        {
290            suites.push(suite);
291        }
292    }
293
294    // If no suites found, try to parse <testcase> elements directly
295    if suites.is_empty() {
296        let tests = parse_junit_testcases(stdout);
297        if !tests.is_empty() {
298            suites.push(TestSuite {
299                name: "tests".to_string(),
300                tests,
301            });
302        }
303    }
304
305    if suites.is_empty() {
306        return fallback_result(stdout, exit_code, "junit");
307    }
308
309    TestRunResult {
310        suites,
311        duration: Duration::ZERO,
312        raw_exit_code: exit_code,
313    }
314}
315
316fn parse_junit_suite_tag(tag: &str, full_output: &str) -> Option<TestSuite> {
317    let name = extract_xml_attr(tag, "name").unwrap_or_else(|| "tests".to_string());
318    let tests = parse_junit_testcases(full_output);
319    if tests.is_empty() {
320        return None;
321    }
322    Some(TestSuite { name, tests })
323}
324
325fn parse_junit_testcases(xml: &str) -> Vec<TestCase> {
326    let mut tests = Vec::new();
327    let lines: Vec<&str> = xml.lines().collect();
328
329    let mut i = 0;
330    while i < lines.len() {
331        let trimmed = lines[i].trim();
332        if trimmed.starts_with("<testcase") {
333            let name = extract_xml_attr(trimmed, "name").unwrap_or_else(|| "unknown".to_string());
334            let time = extract_xml_attr(trimmed, "time")
335                .and_then(|t| t.parse::<f64>().ok())
336                .map(duration_from_secs_safe)
337                .unwrap_or(Duration::ZERO);
338
339            // Check for failure/error/skipped in subsequent lines
340            let mut status = TestStatus::Passed;
341            let mut error = None;
342
343            if trimmed.ends_with("/>") {
344                // Self-closing, check for nested skipped/failure check
345                if trimmed.contains("<skipped") {
346                    status = TestStatus::Skipped;
347                }
348            } else {
349                // Look at following lines until </testcase>
350                let mut j = i + 1;
351                while j < lines.len() {
352                    let inner = lines[j].trim();
353                    if inner.starts_with("</testcase") {
354                        break;
355                    }
356                    if inner.starts_with("<failure") || inner.starts_with("<error") {
357                        status = TestStatus::Failed;
358                        let message = extract_xml_attr(inner, "message")
359                            .unwrap_or_else(|| "Test failed".to_string());
360                        error = Some(TestError {
361                            message,
362                            location: None,
363                        });
364                    }
365                    if inner.starts_with("<skipped") {
366                        status = TestStatus::Skipped;
367                    }
368                    j += 1;
369                }
370            }
371
372            tests.push(TestCase {
373                name,
374                status,
375                duration: time,
376                error,
377            });
378        }
379        i += 1;
380    }
381
382    tests
383}
384
385/// Extract an XML attribute value from an element tag.
386fn extract_xml_attr(tag: &str, attr: &str) -> Option<String> {
387    let search = format!("{attr}=\"");
388    let start = tag.find(&search)? + search.len();
389    let rest = &tag[start..];
390    let end = rest.find('"')?;
391    Some(rest[..end].to_string())
392}
393
394/// Parse TAP (Test Anything Protocol) output.
395fn parse_tap_output(stdout: &str, exit_code: i32) -> TestRunResult {
396    let mut tests = Vec::new();
397    let mut _plan_count = 0;
398
399    for line in stdout.lines() {
400        let trimmed = line.trim();
401
402        // Plan line: 1..N
403        if let Some(rest) = trimmed.strip_prefix("1..") {
404            if let Ok(n) = rest.parse::<usize>() {
405                _plan_count = n;
406            }
407            continue;
408        }
409
410        // ok N - description
411        if let Some(rest) = trimmed.strip_prefix("ok ") {
412            let (name, is_skip) = parse_tap_description(rest);
413            tests.push(TestCase {
414                name,
415                status: if is_skip {
416                    TestStatus::Skipped
417                } else {
418                    TestStatus::Passed
419                },
420                duration: Duration::ZERO,
421                error: None,
422            });
423            continue;
424        }
425
426        // not ok N - description
427        if let Some(rest) = trimmed.strip_prefix("not ok ") {
428            let (name, is_skip) = parse_tap_description(rest);
429            let is_todo = trimmed.contains("# TODO");
430            tests.push(TestCase {
431                name,
432                status: if is_skip || is_todo {
433                    TestStatus::Skipped
434                } else {
435                    TestStatus::Failed
436                },
437                duration: Duration::ZERO,
438                error: if !is_skip && !is_todo {
439                    Some(TestError {
440                        message: "Test failed".to_string(),
441                        location: None,
442                    })
443                } else {
444                    None
445                },
446            });
447        }
448    }
449
450    if tests.is_empty() {
451        return fallback_result(stdout, exit_code, "tap");
452    }
453
454    TestRunResult {
455        suites: vec![TestSuite {
456            name: "tests".to_string(),
457            tests,
458        }],
459        duration: Duration::ZERO,
460        raw_exit_code: exit_code,
461    }
462}
463
464/// Parse a TAP description, extracting the test name and directive.
465fn parse_tap_description(rest: &str) -> (String, bool) {
466    // Strip the test number
467    let after_num = rest
468        .find(|c: char| !c.is_ascii_digit())
469        .map(|i| rest[i..].trim_start())
470        .unwrap_or(rest);
471
472    // Strip leading " - "
473    let desc = after_num.strip_prefix("- ").unwrap_or(after_num);
474
475    // Check for # SKIP directive
476    let is_skip = desc.contains("# SKIP") || desc.contains("# skip");
477
478    // Remove directive from name
479    let name = if let Some(idx) = desc.find(" # ") {
480        desc[..idx].to_string()
481    } else {
482        desc.to_string()
483    };
484
485    (name, is_skip)
486}
487
488/// Parse line-based output (simplest format).
489///
490/// Expected format per line: `STATUS test_name` or `STATUS: test_name`
491/// STATUS can be: ok, pass, passed, fail, failed, error, skip, skipped, pending
492fn parse_lines_output(stdout: &str, exit_code: i32) -> TestRunResult {
493    let mut tests = Vec::new();
494
495    for line in stdout.lines() {
496        let trimmed = line.trim();
497        if trimmed.is_empty() {
498            continue;
499        }
500
501        if let Some(test) = parse_status_line(trimmed) {
502            tests.push(test);
503        }
504    }
505
506    if tests.is_empty() {
507        return fallback_result(stdout, exit_code, "lines");
508    }
509
510    TestRunResult {
511        suites: vec![TestSuite {
512            name: "tests".to_string(),
513            tests,
514        }],
515        duration: Duration::ZERO,
516        raw_exit_code: exit_code,
517    }
518}
519
520/// Parse a single status-prefixed line.
521fn parse_status_line(line: &str) -> Option<TestCase> {
522    let (status, rest) = parse_status_prefix(line)?;
523    let name = rest.trim().to_string();
524    if name.is_empty() {
525        return None;
526    }
527
528    let failed = status == TestStatus::Failed;
529    Some(TestCase {
530        name,
531        status,
532        duration: Duration::ZERO,
533        error: if failed {
534            Some(TestError {
535                message: "Test failed".into(),
536                location: None,
537            })
538        } else {
539            None
540        },
541    })
542}
543
544/// Try to extract a status prefix from a line.
545fn parse_status_prefix(line: &str) -> Option<(TestStatus, &str)> {
546    let patterns: &[(&str, TestStatus)] = &[
547        ("ok ", TestStatus::Passed),
548        ("pass ", TestStatus::Passed),
549        ("passed ", TestStatus::Passed),
550        ("PASS ", TestStatus::Passed),
551        ("PASSED ", TestStatus::Passed),
552        ("OK ", TestStatus::Passed),
553        ("✓ ", TestStatus::Passed),
554        ("✔ ", TestStatus::Passed),
555        ("fail ", TestStatus::Failed),
556        ("failed ", TestStatus::Failed),
557        ("error ", TestStatus::Failed),
558        ("FAIL ", TestStatus::Failed),
559        ("FAILED ", TestStatus::Failed),
560        ("ERROR ", TestStatus::Failed),
561        ("✗ ", TestStatus::Failed),
562        ("✘ ", TestStatus::Failed),
563        ("skip ", TestStatus::Skipped),
564        ("skipped ", TestStatus::Skipped),
565        ("pending ", TestStatus::Skipped),
566        ("SKIP ", TestStatus::Skipped),
567        ("SKIPPED ", TestStatus::Skipped),
568        ("PENDING ", TestStatus::Skipped),
569    ];
570
571    for (prefix, status) in patterns {
572        if let Some(rest) = line.strip_prefix(prefix) {
573            return Some((status.clone(), rest));
574        }
575    }
576
577    // Also try "status: name" format
578    let colon_patterns: &[(&str, TestStatus)] = &[
579        ("ok:", TestStatus::Passed),
580        ("pass:", TestStatus::Passed),
581        ("fail:", TestStatus::Failed),
582        ("error:", TestStatus::Failed),
583        ("skip:", TestStatus::Skipped),
584    ];
585
586    for (prefix, status) in colon_patterns {
587        if let Some(rest) = line.to_lowercase().strip_prefix(prefix) {
588            let idx = prefix.len();
589            let _ = rest; // use original line
590            return Some((status.clone(), line[idx..].trim_start()));
591        }
592    }
593
594    None
595}
596
597/// Parse output using custom regex patterns.
598fn parse_regex_output(stdout: &str, config: &RegexParserConfig, exit_code: i32) -> TestRunResult {
599    let mut tests = Vec::new();
600
601    for line in stdout.lines() {
602        let trimmed = line.trim();
603        if trimmed.is_empty() {
604            continue;
605        }
606
607        if let Some(test) =
608            try_regex_match(trimmed, &config.pass_pattern, TestStatus::Passed, config)
609        {
610            tests.push(test);
611        } else if let Some(test) =
612            try_regex_match(trimmed, &config.fail_pattern, TestStatus::Failed, config)
613        {
614            tests.push(test);
615        } else if let Some(ref skip_pattern) = config.skip_pattern
616            && let Some(test) = try_regex_match(trimmed, skip_pattern, TestStatus::Skipped, config)
617        {
618            tests.push(test);
619        }
620    }
621
622    if tests.is_empty() {
623        return fallback_result(stdout, exit_code, "regex");
624    }
625
626    TestRunResult {
627        suites: vec![TestSuite {
628            name: "tests".to_string(),
629            tests,
630        }],
631        duration: Duration::ZERO,
632        raw_exit_code: exit_code,
633    }
634}
635
636/// Try to match a line against a simple pattern with capture groups.
637///
638/// Pattern format uses `()` for capture groups and `.*` for wildcards.
639/// This is a simplified regex to avoid pulling in the regex crate.
640fn try_regex_match(
641    line: &str,
642    pattern: &str,
643    status: TestStatus,
644    config: &RegexParserConfig,
645) -> Option<TestCase> {
646    let captures = simple_pattern_match(pattern, line)?;
647
648    let name = captures.get(config.name_group.saturating_sub(1))?.clone();
649    if name.is_empty() {
650        return None;
651    }
652
653    let duration = config
654        .duration_group
655        .and_then(|g| captures.get(g.saturating_sub(1)))
656        .and_then(|d| d.parse::<f64>().ok())
657        .map(|ms| duration_from_secs_safe(ms / 1000.0))
658        .unwrap_or(Duration::ZERO);
659
660    Some(TestCase {
661        name,
662        status: status.clone(),
663        duration,
664        error: if status == TestStatus::Failed {
665            Some(TestError {
666                message: "Test failed".into(),
667                location: None,
668            })
669        } else {
670            None
671        },
672    })
673}
674
675/// Simple pattern matching with capture groups.
676///
677/// Supports: literal text, `(.*)` capture groups, `.*` wildcards.
678/// Returns captured groups as a Vec<String>.
679fn simple_pattern_match(pattern: &str, input: &str) -> Option<Vec<String>> {
680    let mut captures = Vec::new();
681    let mut pat_idx = 0;
682    let mut inp_idx = 0;
683    let pat_bytes = pattern.as_bytes();
684    let inp_bytes = input.as_bytes();
685
686    while pat_idx < pat_bytes.len() && inp_idx <= inp_bytes.len() {
687        if pat_idx + 4 <= pat_bytes.len() && &pat_bytes[pat_idx..pat_idx + 4] == b"(.*)" {
688            // Capture group: find the next literal after the group
689            pat_idx += 4;
690
691            // Find what comes after the capture group
692            let next_literal = find_next_literal(pattern, pat_idx);
693
694            match next_literal {
695                Some(lit) => {
696                    // Find the literal in the remaining input
697                    let remaining = &input[inp_idx..];
698                    if let Some(pos) = remaining.find(&lit) {
699                        captures.push(remaining[..pos].to_string());
700                        inp_idx += pos;
701                    } else {
702                        return None;
703                    }
704                }
705                None => {
706                    // Capture group at end of pattern, capture everything
707                    captures.push(input[inp_idx..].to_string());
708                    inp_idx = inp_bytes.len();
709                }
710            }
711        } else if pat_idx + 1 < pat_bytes.len()
712            && pat_bytes[pat_idx] == b'.'
713            && pat_bytes[pat_idx + 1] == b'*'
714        {
715            // Wildcard (non-capturing): skip to next literal
716            pat_idx += 2;
717            let next_literal = find_next_literal(pattern, pat_idx);
718            match next_literal {
719                Some(lit) => {
720                    let remaining = &input[inp_idx..];
721                    if let Some(pos) = remaining.find(&lit) {
722                        inp_idx += pos;
723                    } else {
724                        return None;
725                    }
726                }
727                None => {
728                    inp_idx = inp_bytes.len();
729                }
730            }
731        } else if inp_idx < inp_bytes.len() && pat_bytes[pat_idx] == inp_bytes[inp_idx] {
732            pat_idx += 1;
733            inp_idx += 1;
734        } else {
735            return None;
736        }
737    }
738
739    // Both pattern and input should be consumed
740    if pat_idx == pat_bytes.len() && inp_idx == inp_bytes.len() {
741        Some(captures)
742    } else {
743        None
744    }
745}
746
747/// Find the next literal string segment in a pattern after the given index.
748fn find_next_literal(pattern: &str, from: usize) -> Option<String> {
749    let rest = &pattern[from..];
750    if rest.is_empty() {
751        return None;
752    }
753
754    let mut lit = String::new();
755    let bytes = rest.as_bytes();
756    let mut i = 0;
757    while i < bytes.len() {
758        if i + 1 < bytes.len() && bytes[i] == b'.' && bytes[i + 1] == b'*' {
759            break;
760        }
761        if i + 4 <= bytes.len() && &bytes[i..i + 4] == b"(.*)" {
762            break;
763        }
764        lit.push(bytes[i] as char);
765        i += 1;
766    }
767
768    if lit.is_empty() { None } else { Some(lit) }
769}
770
771/// Generate a fallback result when parsing fails.
772fn fallback_result(stdout: &str, exit_code: i32, parser_name: &str) -> TestRunResult {
773    let status = if exit_code == 0 {
774        TestStatus::Passed
775    } else {
776        TestStatus::Failed
777    };
778
779    TestRunResult {
780        suites: vec![TestSuite {
781            name: format!("{parser_name}-output"),
782            tests: vec![TestCase {
783                name: format!("test run ({parser_name} parser)"),
784                status,
785                duration: Duration::ZERO,
786                error: if exit_code != 0 {
787                    Some(TestError {
788                        message: stdout.lines().next().unwrap_or("Test failed").to_string(),
789                        location: None,
790                    })
791                } else {
792                    None
793                },
794            }],
795        }],
796        duration: Duration::ZERO,
797        raw_exit_code: exit_code,
798    }
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804    use std::path::PathBuf;
805
806    // ─── ScriptAdapterConfig Tests ──────────────────────────────────────
807
808    #[test]
809    fn config_new() {
810        let config = ScriptAdapterConfig::new("mytest", "Makefile", "make test");
811        assert_eq!(config.name, "mytest");
812        assert_eq!(config.detect_file, "Makefile");
813        assert_eq!(config.command, "make test");
814        assert_eq!(config.parser, OutputParser::Lines);
815    }
816
817    #[test]
818    fn config_builder() {
819        let config = ScriptAdapterConfig::new("mytest", "Makefile", "make test")
820            .with_parser(OutputParser::Tap)
821            .with_args(vec!["--verbose".into()])
822            .with_working_dir("src")
823            .with_env("CI", "true");
824
825        assert_eq!(config.parser, OutputParser::Tap);
826        assert_eq!(config.args, vec!["--verbose"]);
827        assert_eq!(config.working_dir, Some("src".into()));
828        assert_eq!(config.env, vec![("CI".into(), "true".into())]);
829    }
830
831    #[test]
832    fn config_full_command() {
833        let config = ScriptAdapterConfig::new("test", "f", "make test")
834            .with_args(vec!["--verbose".into(), "--color".into()]);
835        assert_eq!(config.full_command(), "make test --verbose --color");
836    }
837
838    #[test]
839    fn config_effective_working_dir() {
840        let base = PathBuf::from("/project");
841
842        let config = ScriptAdapterConfig::new("test", "f", "cmd");
843        assert_eq!(
844            config.effective_working_dir(&base),
845            PathBuf::from("/project")
846        );
847
848        let config = config.with_working_dir("src");
849        assert_eq!(
850            config.effective_working_dir(&base),
851            PathBuf::from("/project/src")
852        );
853    }
854
855    // ─── TAP Parser Tests ───────────────────────────────────────────────
856
857    #[test]
858    fn parse_tap_basic() {
859        let output = "1..3\nok 1 - first test\nok 2 - second test\nnot ok 3 - third test\n";
860        let result = parse_tap_output(output, 1);
861        assert_eq!(result.total_tests(), 3);
862        assert_eq!(result.total_passed(), 2);
863        assert_eq!(result.total_failed(), 1);
864    }
865
866    #[test]
867    fn parse_tap_skip() {
868        let output = "1..2\nok 1 - test one\nok 2 - test two # SKIP not ready\n";
869        let result = parse_tap_output(output, 0);
870        assert_eq!(result.total_tests(), 2);
871        assert_eq!(result.total_passed(), 1);
872        assert_eq!(result.total_skipped(), 1);
873    }
874
875    #[test]
876    fn parse_tap_todo() {
877        let output = "1..1\nnot ok 1 - todo test # TODO implement later\n";
878        let result = parse_tap_output(output, 0);
879        assert_eq!(result.total_tests(), 1);
880        assert_eq!(result.total_skipped(), 1);
881    }
882
883    #[test]
884    fn parse_tap_empty() {
885        let result = parse_tap_output("", 0);
886        assert_eq!(result.total_tests(), 1); // fallback
887    }
888
889    #[test]
890    fn parse_tap_no_plan() {
891        let output = "ok 1 - works\nnot ok 2 - broken\n";
892        let result = parse_tap_output(output, 1);
893        assert_eq!(result.total_tests(), 2);
894    }
895
896    // ─── Lines Parser Tests ─────────────────────────────────────────────
897
898    #[test]
899    fn parse_lines_basic() {
900        let output = "ok test_one\nfail test_two\nskip test_three\n";
901        let result = parse_lines_output(output, 1);
902        assert_eq!(result.total_tests(), 3);
903        assert_eq!(result.total_passed(), 1);
904        assert_eq!(result.total_failed(), 1);
905        assert_eq!(result.total_skipped(), 1);
906    }
907
908    #[test]
909    fn parse_lines_uppercase() {
910        let output = "PASS test_one\nFAIL test_two\nSKIP test_three\n";
911        let result = parse_lines_output(output, 1);
912        assert_eq!(result.total_tests(), 3);
913    }
914
915    #[test]
916    fn parse_lines_unicode() {
917        let output = "✓ test_one\n✗ test_two\n";
918        let result = parse_lines_output(output, 1);
919        assert_eq!(result.total_tests(), 2);
920        assert_eq!(result.total_passed(), 1);
921        assert_eq!(result.total_failed(), 1);
922    }
923
924    #[test]
925    fn parse_lines_empty() {
926        let result = parse_lines_output("", 0);
927        assert_eq!(result.total_tests(), 1); // fallback
928    }
929
930    #[test]
931    fn parse_lines_ignores_non_matching() {
932        let output = "running tests...\nok test_one\nsome other output\nfail test_two\ndone";
933        let result = parse_lines_output(output, 1);
934        assert_eq!(result.total_tests(), 2);
935    }
936
937    // ─── JSON Parser Tests ──────────────────────────────────────────────
938
939    #[test]
940    fn parse_json_suites_format() {
941        let json = r#"{
942            "suites": [
943                {
944                    "name": "math",
945                    "tests": [
946                        {"name": "test_add", "status": "passed", "duration": 10},
947                        {"name": "test_sub", "status": "failed", "duration": 5}
948                    ]
949                }
950            ]
951        }"#;
952        let result = parse_json_output(json, "", 1);
953        assert_eq!(result.total_tests(), 2);
954        assert_eq!(result.total_passed(), 1);
955        assert_eq!(result.total_failed(), 1);
956    }
957
958    #[test]
959    fn parse_json_flat_tests() {
960        let json = r#"{"tests": [
961            {"name": "test1", "status": "pass"},
962            {"name": "test2", "status": "skip"}
963        ]}"#;
964        let result = parse_json_output(json, "", 0);
965        assert_eq!(result.total_tests(), 2);
966        assert_eq!(result.total_passed(), 1);
967        assert_eq!(result.total_skipped(), 1);
968    }
969
970    #[test]
971    fn parse_json_array_format() {
972        let json = r#"[
973            {"name": "test1", "status": "ok"},
974            {"name": "test2", "status": "error"}
975        ]"#;
976        let result = parse_json_output(json, "", 1);
977        assert_eq!(result.total_tests(), 2);
978    }
979
980    #[test]
981    fn parse_json_with_errors() {
982        let json = r#"{"tests": [
983            {"name": "test1", "status": "failed", "error": {"message": "expected 1 got 2", "location": "test.rs:10"}}
984        ]}"#;
985        let result = parse_json_output(json, "", 1);
986        assert_eq!(result.total_failed(), 1);
987        let test = &result.suites[0].tests[0];
988        assert!(test.error.is_some());
989        assert_eq!(test.error.as_ref().unwrap().message, "expected 1 got 2");
990    }
991
992    #[test]
993    fn parse_json_invalid() {
994        let result = parse_json_output("not json {{{", "", 1);
995        assert_eq!(result.total_tests(), 1); // fallback
996    }
997
998    // ─── JUnit XML Parser Tests ─────────────────────────────────────────
999
1000    #[test]
1001    fn parse_junit_basic() {
1002        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1003<testsuite name="math" tests="2" failures="1">
1004  <testcase name="test_add" classname="Math" time="0.01"/>
1005  <testcase name="test_div" classname="Math" time="0.02">
1006    <failure message="division by zero"/>
1007  </testcase>
1008</testsuite>"#;
1009        let result = parse_junit_output(xml, 1);
1010        assert_eq!(result.total_tests(), 2);
1011        assert_eq!(result.total_passed(), 1);
1012        assert_eq!(result.total_failed(), 1);
1013    }
1014
1015    #[test]
1016    fn parse_junit_skipped() {
1017        let xml = r#"<testsuite name="t" tests="1">
1018  <testcase name="test_skip" time="0.0">
1019    <skipped/>
1020  </testcase>
1021</testsuite>"#;
1022        let result = parse_junit_output(xml, 0);
1023        assert_eq!(result.total_skipped(), 1);
1024    }
1025
1026    #[test]
1027    fn parse_junit_empty() {
1028        let result = parse_junit_output("", 0);
1029        assert_eq!(result.total_tests(), 1); // fallback
1030    }
1031
1032    // ─── Regex Parser Tests ─────────────────────────────────────────────
1033
1034    #[test]
1035    fn parse_regex_basic() {
1036        let config = RegexParserConfig {
1037            pass_pattern: "PASS: (.*)".to_string(),
1038            fail_pattern: "FAIL: (.*)".to_string(),
1039            skip_pattern: None,
1040            name_group: 1,
1041            duration_group: None,
1042        };
1043        let output = "PASS: test_one\nFAIL: test_two\nsome output\n";
1044        let result = parse_regex_output(output, &config, 1);
1045        assert_eq!(result.total_tests(), 2);
1046        assert_eq!(result.total_passed(), 1);
1047        assert_eq!(result.total_failed(), 1);
1048    }
1049
1050    #[test]
1051    fn parse_regex_with_skip() {
1052        let config = RegexParserConfig {
1053            pass_pattern: "[OK] (.*)".to_string(),
1054            fail_pattern: "[ERR] (.*)".to_string(),
1055            skip_pattern: Some("[SKIP] (.*)".to_string()),
1056            name_group: 1,
1057            duration_group: None,
1058        };
1059        let output = "[OK] test_one\n[SKIP] test_two\n";
1060        let result = parse_regex_output(output, &config, 0);
1061        assert_eq!(result.total_tests(), 2);
1062    }
1063
1064    // ─── Simple Pattern Match Tests ─────────────────────────────────────
1065
1066    #[test]
1067    fn simple_match_literal() {
1068        let result = simple_pattern_match("hello world", "hello world");
1069        assert!(result.is_some());
1070        assert!(result.unwrap().is_empty());
1071    }
1072
1073    #[test]
1074    fn simple_match_capture() {
1075        let result = simple_pattern_match("PASS: (.*)", "PASS: test_one");
1076        assert!(result.is_some());
1077        assert_eq!(result.unwrap(), vec!["test_one"]);
1078    }
1079
1080    #[test]
1081    fn simple_match_multiple_captures() {
1082        let result = simple_pattern_match("(.*)=(.*)", "key=value");
1083        assert!(result.is_some());
1084        assert_eq!(result.unwrap(), vec!["key", "value"]);
1085    }
1086
1087    #[test]
1088    fn simple_match_wildcard() {
1089        let result = simple_pattern_match("hello .*!", "hello world!");
1090        assert!(result.is_some());
1091    }
1092
1093    #[test]
1094    fn simple_match_no_match() {
1095        let result = simple_pattern_match("hello", "world");
1096        assert!(result.is_none());
1097    }
1098
1099    #[test]
1100    fn simple_match_capture_with_context() {
1101        let result = simple_pattern_match("test (.*) in (.*)ms", "test add in 50ms");
1102        assert!(result.is_some());
1103        let caps = result.unwrap();
1104        assert_eq!(caps, vec!["add", "50"]);
1105    }
1106
1107    // ─── TAP Description Parsing ────────────────────────────────────────
1108
1109    #[test]
1110    fn tap_description_basic() {
1111        let (name, skip) = parse_tap_description("1 - my test");
1112        assert_eq!(name, "my test");
1113        assert!(!skip);
1114    }
1115
1116    #[test]
1117    fn tap_description_skip() {
1118        let (name, skip) = parse_tap_description("1 - my test # SKIP not implemented");
1119        assert_eq!(name, "my test");
1120        assert!(skip);
1121    }
1122
1123    #[test]
1124    fn tap_description_no_dash() {
1125        let (name, skip) = parse_tap_description("1 test name");
1126        assert_eq!(name, "test name");
1127        assert!(!skip);
1128    }
1129
1130    // ─── Status Line Parsing ────────────────────────────────────────────
1131
1132    #[test]
1133    fn status_line_pass() {
1134        let tc = parse_status_line("ok test_one").unwrap();
1135        assert_eq!(tc.name, "test_one");
1136        assert_eq!(tc.status, TestStatus::Passed);
1137    }
1138
1139    #[test]
1140    fn status_line_fail() {
1141        let tc = parse_status_line("fail test_two").unwrap();
1142        assert_eq!(tc.name, "test_two");
1143        assert_eq!(tc.status, TestStatus::Failed);
1144    }
1145
1146    #[test]
1147    fn status_line_skip() {
1148        let tc = parse_status_line("skip test_three").unwrap();
1149        assert_eq!(tc.name, "test_three");
1150        assert_eq!(tc.status, TestStatus::Skipped);
1151    }
1152
1153    #[test]
1154    fn status_line_no_match() {
1155        assert!(parse_status_line("some random text").is_none());
1156    }
1157
1158    #[test]
1159    fn status_line_empty_name() {
1160        assert!(parse_status_line("ok ").is_none());
1161    }
1162
1163    // ─── XML Attr Extraction ────────────────────────────────────────────
1164
1165    #[test]
1166    fn xml_attr_basic() {
1167        assert_eq!(
1168            extract_xml_attr(r#"<test name="hello" time="1.5">"#, "name"),
1169            Some("hello".into())
1170        );
1171    }
1172
1173    #[test]
1174    fn xml_attr_missing() {
1175        assert_eq!(extract_xml_attr("<test>", "name"), None);
1176    }
1177
1178    // ─── Fallback Result Tests ──────────────────────────────────────────
1179
1180    #[test]
1181    fn fallback_pass() {
1182        let result = fallback_result("all good", 0, "test");
1183        assert_eq!(result.total_passed(), 1);
1184        assert_eq!(result.raw_exit_code, 0);
1185    }
1186
1187    #[test]
1188    fn fallback_fail() {
1189        let result = fallback_result("something failed", 1, "test");
1190        assert_eq!(result.total_failed(), 1);
1191        assert!(result.suites[0].tests[0].error.is_some());
1192    }
1193
1194    // ─── Integration: parse_script_output ───────────────────────────────
1195
1196    #[test]
1197    fn script_output_delegates_to_tap() {
1198        let output = "1..2\nok 1 - a\nnot ok 2 - b\n";
1199        let result = parse_script_output(&OutputParser::Tap, output, "", 1);
1200        assert_eq!(result.total_tests(), 2);
1201    }
1202
1203    #[test]
1204    fn script_output_delegates_to_lines() {
1205        let output = "PASS test1\nFAIL test2\n";
1206        let result = parse_script_output(&OutputParser::Lines, output, "", 1);
1207        assert_eq!(result.total_tests(), 2);
1208    }
1209
1210    #[test]
1211    fn script_output_delegates_to_json() {
1212        let output = r#"[{"name": "t1", "status": "passed"}]"#;
1213        let result = parse_script_output(&OutputParser::Json, output, "", 0);
1214        assert_eq!(result.total_tests(), 1);
1215        assert_eq!(result.total_passed(), 1);
1216    }
1217}