Skip to main content

perl_tdd_support/tdd/
test_runner.rs

1use crate::ast::{Node, NodeKind};
2use serde_json::{Value, json};
3use std::path::Path;
4use std::process::{Command, Stdio};
5
6/// Test item representing a test that can be run
7#[derive(Debug, Clone)]
8pub struct TestItem {
9    /// Unique identifier for the test (typically URI::function_name)
10    pub id: String,
11    /// Human-readable display name for the test
12    pub label: String,
13    /// File URI where the test is located
14    pub uri: String,
15    /// Source location range of the test
16    pub range: TestRange,
17    /// Classification of this test item
18    pub kind: TestKind,
19    /// Nested test items (e.g., functions within a file)
20    pub children: Vec<TestItem>,
21}
22
23/// Source location range for a test item
24#[derive(Debug, Clone)]
25pub struct TestRange {
26    /// Zero-based starting line number
27    pub start_line: u32,
28    /// Zero-based starting character offset within the line
29    pub start_character: u32,
30    /// Zero-based ending line number
31    pub end_line: u32,
32    /// Zero-based ending character offset within the line
33    pub end_character: u32,
34}
35
36/// Classification of test items
37#[derive(Debug, Clone, PartialEq)]
38pub enum TestKind {
39    /// A test file (e.g., .t file)
40    File,
41    /// A test suite containing multiple tests
42    Suite,
43    /// An individual test function or assertion
44    Test,
45}
46
47/// Test result after running a test
48#[derive(Debug, Clone)]
49pub struct TestResult {
50    /// Identifier of the test that was run
51    pub test_id: String,
52    /// Outcome status of the test execution
53    pub status: TestStatus,
54    /// Optional diagnostic message (e.g., error details)
55    pub message: Option<String>,
56    /// Execution time in milliseconds
57    pub duration: Option<u64>,
58}
59
60/// Outcome status of a test execution
61#[derive(Debug, Clone, PartialEq)]
62pub enum TestStatus {
63    /// Test passed successfully
64    Passed,
65    /// Test failed (assertion not met)
66    Failed,
67    /// Test was skipped (not executed)
68    Skipped,
69    /// Test encountered an error (could not run)
70    Errored,
71}
72
73impl TestStatus {
74    /// Convert to string for JSON serialization
75    pub fn as_str(&self) -> &'static str {
76        match self {
77            TestStatus::Passed => "passed",
78            TestStatus::Failed => "failed",
79            TestStatus::Skipped => "skipped",
80            TestStatus::Errored => "errored",
81        }
82    }
83}
84
85/// Test Runner for Perl tests
86pub struct TestRunner {
87    /// Source code content of the test file
88    source: String,
89    /// URI of the test file being analyzed
90    uri: String,
91}
92
93impl TestRunner {
94    /// Creates a new test runner for the given source code and file URI.
95    pub fn new(source: String, uri: String) -> Self {
96        Self { source, uri }
97    }
98
99    /// Discover tests in the AST
100    pub fn discover_tests(&self, ast: &Node) -> Vec<TestItem> {
101        let mut tests = Vec::new();
102
103        // Find test functions
104        let mut test_functions = Vec::new();
105        self.find_test_functions_only(ast, &mut test_functions);
106
107        // Check if this is a test file
108        if self.is_test_file(&self.uri) {
109            // Create a file-level test item
110            let file_item = TestItem {
111                id: self.uri.clone(),
112                label: Path::new(&self.uri)
113                    .file_name()
114                    .and_then(|s| s.to_str())
115                    .unwrap_or("test")
116                    .to_string(),
117                uri: self.uri.clone(),
118                range: self.get_file_range(),
119                kind: TestKind::File,
120                children: test_functions,
121            };
122
123            tests.push(file_item);
124        } else {
125            // Return individual test functions
126            tests.extend(test_functions);
127        }
128
129        tests
130    }
131
132    /// Check if a file is a test file
133    fn is_test_file(&self, uri: &str) -> bool {
134        let path = Path::new(uri);
135        let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
136
137        // Common Perl test file patterns
138        file_name.ends_with(".t")
139            || file_name.ends_with("_test.pl")
140            || file_name.ends_with("Test.pl")
141            || file_name.starts_with("test_")
142            || path.components().any(|c| c.as_os_str() == "t" || c.as_os_str() == "tests")
143    }
144
145    /// Find test functions in the AST
146    #[allow(dead_code)]
147    fn find_test_functions(&self, node: &Node) -> Vec<TestItem> {
148        let mut tests = Vec::new();
149        self.visit_node_for_tests(node, &mut tests);
150        tests
151    }
152
153    /// Find only test functions (not assertions)
154    fn find_test_functions_only(&self, node: &Node, tests: &mut Vec<TestItem>) {
155        match &node.kind {
156            NodeKind::Program { statements } => {
157                for stmt in statements {
158                    self.find_test_functions_only(stmt, tests);
159                }
160            }
161
162            NodeKind::Block { statements } => {
163                for stmt in statements {
164                    self.find_test_functions_only(stmt, tests);
165                }
166            }
167
168            NodeKind::Subroutine { name, .. } => {
169                if let Some(func_name) = name {
170                    if self.is_test_function(func_name) {
171                        let test_item = TestItem {
172                            id: format!("{}::{}", self.uri, func_name),
173                            label: func_name.clone(),
174                            uri: self.uri.clone(),
175                            range: self.node_to_range(node),
176                            kind: TestKind::Test,
177                            children: vec![],
178                        };
179                        tests.push(test_item);
180                    }
181                }
182            }
183
184            _ => {
185                // Visit children
186                self.visit_children_for_test_functions(node, tests);
187            }
188        }
189    }
190
191    /// Visit children nodes for test functions only
192    fn visit_children_for_test_functions(&self, node: &Node, tests: &mut Vec<TestItem>) {
193        match &node.kind {
194            NodeKind::If { then_branch, elsif_branches, else_branch, .. } => {
195                self.find_test_functions_only(then_branch, tests);
196                for (_, body) in elsif_branches {
197                    self.find_test_functions_only(body, tests);
198                }
199                if let Some(else_b) = else_branch {
200                    self.find_test_functions_only(else_b, tests);
201                }
202            }
203            NodeKind::While { body, .. } => {
204                self.find_test_functions_only(body, tests);
205            }
206            NodeKind::For { body, .. } => {
207                self.find_test_functions_only(body, tests);
208            }
209            NodeKind::Foreach { body, .. } => {
210                self.find_test_functions_only(body, tests);
211            }
212            _ => {}
213        }
214    }
215
216    /// Visit nodes looking for test functions
217    #[allow(dead_code)]
218    fn visit_node_for_tests(&self, node: &Node, tests: &mut Vec<TestItem>) {
219        match &node.kind {
220            NodeKind::Program { statements } => {
221                for stmt in statements {
222                    self.visit_node_for_tests(stmt, tests);
223                }
224            }
225
226            NodeKind::Block { statements } => {
227                for stmt in statements {
228                    self.visit_node_for_tests(stmt, tests);
229                }
230            }
231
232            NodeKind::Subroutine { name, body, .. } => {
233                if let Some(func_name) = name {
234                    if self.is_test_function(func_name) {
235                        let test_item = TestItem {
236                            id: format!("{}::{}", self.uri, func_name),
237                            label: func_name.clone(),
238                            uri: self.uri.clone(),
239                            range: self.node_to_range(node),
240                            kind: TestKind::Test,
241                            children: vec![],
242                        };
243                        tests.push(test_item);
244                    }
245                }
246
247                // Still visit the body for nested tests
248                self.visit_node_for_tests(body, tests);
249            }
250
251            // Look for Test::More style tests
252            NodeKind::FunctionCall { name, args } => {
253                if self.is_test_assertion(name) {
254                    // Extract test description if available
255                    let description = self.extract_test_description(args);
256                    let label = description.unwrap_or_else(|| name.clone());
257
258                    let test_item = TestItem {
259                        id: format!("{}::{}::{}", self.uri, name, node.location.start),
260                        label,
261                        uri: self.uri.clone(),
262                        range: self.node_to_range(node),
263                        kind: TestKind::Test,
264                        children: vec![],
265                    };
266                    tests.push(test_item);
267                }
268
269                // Visit arguments
270                for arg in args {
271                    self.visit_node_for_tests(arg, tests);
272                }
273            }
274
275            _ => {
276                // Visit children
277                self.visit_children_for_tests(node, tests);
278            }
279        }
280    }
281
282    /// Check if a function name indicates a test
283    fn is_test_function(&self, name: &str) -> bool {
284        name.starts_with("test_")
285            || name.ends_with("_test")
286            || name.starts_with("Test")
287            || name.ends_with("Test")
288            || name == "test"
289    }
290
291    /// Check if a function call is a test assertion
292    #[allow(dead_code)]
293    fn is_test_assertion(&self, name: &str) -> bool {
294        // Test::More assertions
295        matches!(
296            name,
297            "ok" | "is"
298                | "isnt"
299                | "like"
300                | "unlike"
301                | "is_deeply"
302                | "cmp_ok"
303                | "can_ok"
304                | "isa_ok"
305                | "pass"
306                | "fail"
307                | "dies_ok"
308                | "lives_ok"
309                | "throws_ok"
310                | "lives_and"
311        )
312    }
313
314    /// Extract test description from arguments
315    #[allow(dead_code)]
316    fn extract_test_description(&self, args: &[Node]) -> Option<String> {
317        // Usually the last argument is the description
318        args.last().and_then(|arg| match &arg.kind {
319            NodeKind::String { value, .. } => Some(value.clone()),
320            _ => None,
321        })
322    }
323
324    /// Visit children nodes for tests
325    #[allow(dead_code)]
326    fn visit_children_for_tests(&self, node: &Node, tests: &mut Vec<TestItem>) {
327        match &node.kind {
328            NodeKind::If { condition, then_branch, elsif_branches, else_branch, .. } => {
329                self.visit_node_for_tests(condition, tests);
330                self.visit_node_for_tests(then_branch, tests);
331                for (cond, body) in elsif_branches {
332                    self.visit_node_for_tests(cond, tests);
333                    self.visit_node_for_tests(body, tests);
334                }
335                if let Some(else_b) = else_branch {
336                    self.visit_node_for_tests(else_b, tests);
337                }
338            }
339            NodeKind::While { condition, body, .. } => {
340                self.visit_node_for_tests(condition, tests);
341                self.visit_node_for_tests(body, tests);
342            }
343            NodeKind::For { init, condition, update, body, .. } => {
344                if let Some(i) = init {
345                    self.visit_node_for_tests(i, tests);
346                }
347                if let Some(c) = condition {
348                    self.visit_node_for_tests(c, tests);
349                }
350                if let Some(u) = update {
351                    self.visit_node_for_tests(u, tests);
352                }
353                self.visit_node_for_tests(body, tests);
354            }
355            NodeKind::Foreach { variable, list, body, continue_block } => {
356                self.visit_node_for_tests(variable, tests);
357                self.visit_node_for_tests(list, tests);
358                self.visit_node_for_tests(body, tests);
359                if let Some(cb) = continue_block {
360                    self.visit_node_for_tests(cb, tests);
361                }
362            }
363            _ => {}
364        }
365    }
366
367    /// Convert node to test range
368    fn node_to_range(&self, node: &Node) -> TestRange {
369        let (start_line, start_char) = self.offset_to_position(node.location.start);
370        let (end_line, end_char) = self.offset_to_position(node.location.end);
371
372        TestRange { start_line, start_character: start_char, end_line, end_character: end_char }
373    }
374
375    /// Get range for entire file
376    fn get_file_range(&self) -> TestRange {
377        let lines: Vec<&str> = self.source.lines().collect();
378        let last_line = lines.len().saturating_sub(1) as u32;
379        let last_char = lines.last().map(|l| l.len() as u32).unwrap_or(0);
380
381        TestRange {
382            start_line: 0,
383            start_character: 0,
384            end_line: last_line,
385            end_character: last_char,
386        }
387    }
388
389    /// Convert byte offset to line/character position
390    fn offset_to_position(&self, offset: usize) -> (u32, u32) {
391        let mut line = 0;
392        let mut col = 0;
393
394        for (i, ch) in self.source.chars().enumerate() {
395            if i >= offset {
396                break;
397            }
398            if ch == '\n' {
399                line += 1;
400                col = 0;
401            } else {
402                col += 1;
403            }
404        }
405
406        (line, col)
407    }
408
409    /// Run a test and return results
410    pub fn run_test(&self, test_id: &str) -> Vec<TestResult> {
411        let mut results = Vec::new();
412
413        // Extract file path from URI
414        let file_path = test_id.split("::").next().unwrap_or(test_id);
415        let file_path = file_path.strip_prefix("file://").unwrap_or(file_path);
416
417        // Determine how to run the test
418        if file_path.ends_with(".t") {
419            // Run as a test file
420            results.extend(self.run_test_file(file_path));
421        } else {
422            // Run as a Perl script with prove or perl
423            results.extend(self.run_perl_test(file_path));
424        }
425
426        results
427    }
428
429    /// Create a hermetic `Command` for the Perl interpreter.
430    ///
431    /// Strips the entire parent environment and passes through only:
432    /// - `PATH` — needed for interpreter self-resolution and fork helpers
433    /// - `SYSTEMROOT` (Windows only) — required for process spawning on Windows
434    ///
435    /// This prevents ambient `PERL5LIB`, `PERL5OPT`, `HOME`, and `local::lib`
436    /// state from silently altering TDD fixture behaviour. Call sites that need
437    /// extra env vars (e.g. `PERL5LIB` for `-Ilib`) must add them explicitly
438    /// after calling this function — do not bypass with plain `Command::new`.
439    ///
440    /// Implements the hermeticity contract for issue #8689 / #8551.
441    fn hermetic_perl_command(&self, perl_binary: &str) -> Command {
442        let mut cmd = Command::new(perl_binary);
443        cmd.env_clear();
444        if let Some(path_val) = std::env::var_os("PATH") {
445            cmd.env("PATH", path_val);
446        }
447        #[cfg(windows)]
448        if let Some(systemroot) = std::env::var_os("SYSTEMROOT") {
449            cmd.env("SYSTEMROOT", systemroot);
450        }
451        cmd
452    }
453
454    /// Run a .t test file
455    fn run_test_file(&self, file_path: &str) -> Vec<TestResult> {
456        let start_time = std::time::Instant::now();
457
458        // SECURITY: For prove, filenames starting with '-' can be interpreted as flags.
459        // prove does not support '--' to separate files from options in all versions/modes.
460        // Prepend ./ to ensure it is treated as a file path if it starts with -.
461        // Absolute paths (starting with /) are safe.
462        let safe_prove_path = if file_path.starts_with('-') {
463            format!("./{}", file_path)
464        } else {
465            file_path.to_string()
466        };
467
468        // Try to run with prove first, fall back to perl.
469        // The direct `perl` fallback is hermetic below; the ambient `prove`
470        // seam remains out of scope for this TDD fixture hardening slice.
471        let output = Command::new("prove")
472            .arg("-v")
473            .arg(&safe_prove_path)
474            .stdout(Stdio::piped())
475            .stderr(Stdio::piped())
476            .output();
477
478        let output = match output {
479            Ok(out) => out,
480            Err(_) => {
481                // Fall back to running with perl.
482                // hermetic_perl_command strips ambient env (PERL5LIB, PERL5OPT, etc.)
483                // so test fixtures cannot be silently altered by the caller's env.
484                // SECURITY: Use -- to separate options from script file.
485                match self
486                    .hermetic_perl_command("perl")
487                    .arg("--")
488                    .arg(file_path)
489                    .stdout(Stdio::piped())
490                    .stderr(Stdio::piped())
491                    .output()
492                {
493                    Ok(out) => out,
494                    Err(e) => {
495                        return vec![TestResult {
496                            test_id: file_path.to_string(),
497                            status: TestStatus::Errored,
498                            message: Some(format!("Failed to run test: {}", e)),
499                            duration: Some(start_time.elapsed().as_millis() as u64),
500                        }];
501                    }
502                }
503            }
504        };
505
506        let duration = start_time.elapsed().as_millis() as u64;
507
508        // Parse TAP output
509        self.parse_tap_output(
510            &String::from_utf8_lossy(&output.stdout),
511            &String::from_utf8_lossy(&output.stderr),
512            output.status.success(),
513            duration,
514            file_path,
515        )
516    }
517
518    /// Run a Perl script as a test
519    fn run_perl_test(&self, file_path: &str) -> Vec<TestResult> {
520        let start_time = std::time::Instant::now();
521
522        // SECURITY: Use -- to separate options from script file.
523        // hermetic_perl_command strips ambient env so fixtures cannot be affected
524        // by caller's PERL5LIB, PERL5OPT, HOME, or local::lib settings.
525        let output = match self
526            .hermetic_perl_command("perl")
527            .arg("-Ilib")
528            .arg("--")
529            .arg(file_path)
530            .stdout(Stdio::piped())
531            .stderr(Stdio::piped())
532            .output()
533        {
534            Ok(out) => out,
535            Err(e) => {
536                return vec![TestResult {
537                    test_id: file_path.to_string(),
538                    status: TestStatus::Errored,
539                    message: Some(format!("Failed to run test: {}", e)),
540                    duration: Some(start_time.elapsed().as_millis() as u64),
541                }];
542            }
543        };
544
545        let duration = start_time.elapsed().as_millis() as u64;
546        let stdout = String::from_utf8_lossy(&output.stdout);
547        let stderr = String::from_utf8_lossy(&output.stderr);
548
549        vec![TestResult {
550            test_id: file_path.to_string(),
551            status: if output.status.success() { TestStatus::Passed } else { TestStatus::Failed },
552            message: if !stderr.is_empty() {
553                Some(stderr.to_string())
554            } else if !stdout.is_empty() {
555                Some(stdout.to_string())
556            } else {
557                None
558            },
559            duration: Some(duration),
560        }]
561    }
562
563    /// Parse TAP (Test Anything Protocol) output
564    fn parse_tap_output(
565        &self,
566        stdout: &str,
567        stderr: &str,
568        success: bool,
569        duration: u64,
570        test_id: &str,
571    ) -> Vec<TestResult> {
572        let mut results = Vec::new();
573        let mut _test_count = 0;
574
575        // Parse TAP output line by line
576        for line in stdout.lines() {
577            if line.starts_with("ok ") {
578                _test_count += 1;
579                let test_name = line.splitn(3, ' ').nth(2).unwrap_or("test");
580                results.push(TestResult {
581                    test_id: format!("{}::{}", test_id, test_name),
582                    status: TestStatus::Passed,
583                    message: None,
584                    duration: None,
585                });
586            } else if line.starts_with("not ok ") {
587                _test_count += 1;
588                let test_name = line.splitn(3, ' ').nth(2).unwrap_or("test");
589                results.push(TestResult {
590                    test_id: format!("{}::{}", test_id, test_name),
591                    status: TestStatus::Failed,
592                    message: Some(line.to_string()),
593                    duration: None,
594                });
595            }
596        }
597
598        // If no individual test results, create one for the whole file
599        if results.is_empty() {
600            results.push(TestResult {
601                test_id: test_id.to_string(),
602                status: if success { TestStatus::Passed } else { TestStatus::Failed },
603                message: if !stderr.is_empty() { Some(stderr.to_string()) } else { None },
604                duration: Some(duration),
605            });
606        }
607
608        results
609    }
610}
611
612/// Convert TestItem to JSON for LSP
613impl TestItem {
614    /// Serializes this test item to a JSON value for LSP communication.
615    pub fn to_json(&self) -> Value {
616        json!({
617            "id": self.id,
618            "label": self.label,
619            "uri": self.uri,
620            "range": {
621                "start": {
622                    "line": self.range.start_line,
623                    "character": self.range.start_character
624                },
625                "end": {
626                    "line": self.range.end_line,
627                    "character": self.range.end_character
628                }
629            },
630            "canResolveChildren": !self.children.is_empty(),
631            "children": self.children.iter().map(|c| c.to_json()).collect::<Vec<_>>()
632        })
633    }
634}
635
636/// Convert TestResult to JSON for LSP
637impl TestResult {
638    /// Serializes this test result to a JSON value for LSP communication.
639    pub fn to_json(&self) -> Value {
640        let mut result = json!({
641            "testId": self.test_id,
642            "state": match self.status {
643                TestStatus::Passed => "passed",
644                TestStatus::Failed => "failed",
645                TestStatus::Skipped => "skipped",
646                TestStatus::Errored => "errored",
647            }
648        });
649
650        if let Some(message) = &self.message {
651            result["message"] = json!({
652                "message": message
653            });
654        }
655
656        if let Some(duration) = self.duration {
657            result["duration"] = json!(duration);
658        }
659
660        result
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use crate::SourceLocation;
668    use crate::parser::Parser;
669    use std::sync::{LazyLock, Mutex};
670
671    static ENV_MUTEX: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
672
673    #[test]
674    fn test_discover_test_functions() {
675        let code = r#"
676sub test_basic {
677    ok(1, "Basic test");
678}
679
680sub helper_function {
681    # Not a test
682}
683
684sub test_another_thing {
685    is($result, 42, "The answer");
686}
687"#;
688
689        let mut parser = Parser::new(code);
690        if let Ok(ast) = parser.parse() {
691            let runner = TestRunner::new(code.to_string(), "file:///test.pl".to_string());
692            let tests = runner.discover_tests(&ast);
693
694            // Debug: print tests found
695            eprintln!("Found {} tests", tests.len());
696            for test in &tests {
697                eprintln!("Test: {} (kind: {:?})", test.label, test.kind);
698                for child in &test.children {
699                    eprintln!("  Child: {}", child.label);
700                }
701            }
702
703            // Should find at least 1 test (file or functions)
704            assert!(!tests.is_empty());
705
706            // Should have found test functions
707            let test_functions: Vec<&str> = tests
708                .iter()
709                .filter(|t| t.kind == TestKind::Test && t.label.starts_with("test_"))
710                .map(|t| t.label.as_str())
711                .collect();
712
713            eprintln!("Test functions: {:?}", test_functions);
714            assert!(test_functions.contains(&"test_basic"));
715            assert!(test_functions.contains(&"test_another_thing"));
716        }
717    }
718
719    #[test]
720    fn test_discover_test_assertions() {
721        let code = r#"
722use Test::More;
723
724ok(1, "First test");
725is($x, 5, "X should be 5");
726like($string, qr/pattern/, "String matches");
727
728done_testing();
729"#;
730
731        let mut parser = Parser::new(code);
732        if let Ok(ast) = parser.parse() {
733            let runner = TestRunner::new(code.to_string(), "file:///test.t".to_string());
734            let tests = runner.discover_tests(&ast);
735
736            // Should find test file with assertions
737            assert!(!tests.is_empty());
738
739            // Should have discovered individual assertions
740            let all_tests: Vec<&TestItem> = tests
741                .iter()
742                .flat_map(|t| {
743                    let mut items = vec![t];
744                    items.extend(&t.children);
745                    items
746                })
747                .collect();
748
749            // Debug: print all tests
750            eprintln!("All tests found:");
751            for test in &all_tests {
752                eprintln!("  Test: {} (kind: {:?})", test.label, test.kind);
753            }
754
755            // Should have found the test file
756            assert!(!tests.is_empty());
757            assert_eq!(tests[0].kind, TestKind::File);
758        }
759    }
760
761    #[test]
762    fn test_is_test_file() {
763        let runner = TestRunner::new("".to_string(), "".to_string());
764
765        assert!(runner.is_test_file("file:///t/basic.t"));
766        assert!(runner.is_test_file("file:///tests/foo_test.pl"));
767        assert!(runner.is_test_file("file:///MyTest.pl"));
768        assert!(runner.is_test_file("file:///test_something.pl"));
769
770        assert!(!runner.is_test_file("file:///lib/Module.pm"));
771        assert!(!runner.is_test_file("file:///script.pl"));
772    }
773
774    #[test]
775    fn test_status_strings_cover_all_variants() -> Result<(), Box<dyn std::error::Error>> {
776        assert_eq!(TestStatus::Passed.as_str(), "passed");
777        assert_eq!(TestStatus::Failed.as_str(), "failed");
778        assert_eq!(TestStatus::Skipped.as_str(), "skipped");
779        assert_eq!(TestStatus::Errored.as_str(), "errored");
780        Ok(())
781    }
782
783    #[test]
784    fn test_item_json_includes_ranges_and_children() -> Result<(), Box<dyn std::error::Error>> {
785        let child = TestItem {
786            id: "file:///suite.t::child".to_string(),
787            label: "child".to_string(),
788            uri: "file:///suite.t".to_string(),
789            range: TestRange { start_line: 3, start_character: 4, end_line: 3, end_character: 16 },
790            kind: TestKind::Test,
791            children: vec![],
792        };
793        let item = TestItem {
794            id: "file:///suite.t".to_string(),
795            label: "suite.t".to_string(),
796            uri: "file:///suite.t".to_string(),
797            range: TestRange { start_line: 0, start_character: 0, end_line: 5, end_character: 1 },
798            kind: TestKind::File,
799            children: vec![child],
800        };
801
802        let json = item.to_json();
803
804        assert_eq!(json["id"], "file:///suite.t");
805        assert_eq!(json["label"], "suite.t");
806        assert_eq!(json["range"]["end"]["line"], 5);
807        assert_eq!(json["canResolveChildren"], true);
808        assert_eq!(json["children"][0]["id"], "file:///suite.t::child");
809        assert_eq!(json["children"][0]["range"]["start"]["character"], 4);
810        Ok(())
811    }
812
813    #[test]
814    fn test_result_json_covers_message_and_duration() -> Result<(), Box<dyn std::error::Error>> {
815        let result = TestResult {
816            test_id: "file:///suite.t::case".to_string(),
817            status: TestStatus::Errored,
818            message: Some("boom".to_string()),
819            duration: Some(42),
820        };
821
822        let json = result.to_json();
823
824        assert_eq!(json["testId"], "file:///suite.t::case");
825        assert_eq!(json["state"], "errored");
826        assert_eq!(json["message"]["message"], "boom");
827        assert_eq!(json["duration"], 42);
828        Ok(())
829    }
830
831    #[test]
832    fn tap_parser_reports_individual_passes_and_failures() -> Result<(), Box<dyn std::error::Error>>
833    {
834        let runner = TestRunner::new(String::new(), String::new());
835
836        let results = runner.parse_tap_output(
837            "1..3
838ok 1 - loads
839not ok 2 - rejects invalid input
840ok 3
841",
842            "ignored when individual TAP records exist",
843            false,
844            99,
845            "t/sample.t",
846        );
847
848        assert_eq!(results.len(), 3);
849        assert_eq!(results[0].test_id, "t/sample.t::- loads");
850        assert_eq!(results[0].status, TestStatus::Passed);
851        assert_eq!(results[1].test_id, "t/sample.t::2 - rejects invalid input");
852        assert_eq!(results[1].status, TestStatus::Failed);
853        assert_eq!(results[1].message.as_deref(), Some("not ok 2 - rejects invalid input"));
854        assert_eq!(results[2].test_id, "t/sample.t::test");
855        assert_eq!(results[2].duration, None);
856        Ok(())
857    }
858
859    #[test]
860    fn tap_parser_falls_back_to_file_result_with_stderr() -> Result<(), Box<dyn std::error::Error>>
861    {
862        let runner = TestRunner::new(String::new(), String::new());
863
864        let results = runner.parse_tap_output("", "syntax error", false, 17, "script.pl");
865
866        assert_eq!(results.len(), 1);
867        assert_eq!(results[0].test_id, "script.pl");
868        assert_eq!(results[0].status, TestStatus::Failed);
869        assert_eq!(results[0].message.as_deref(), Some("syntax error"));
870        assert_eq!(results[0].duration, Some(17));
871        Ok(())
872    }
873
874    #[test]
875    fn discover_tests_nests_file_children_and_computes_ranges()
876    -> Result<(), Box<dyn std::error::Error>> {
877        let source = "use Test::More;
878sub test_nested {
879    ok(1);
880}
881";
882        let body = node(NodeKind::Block { statements: vec![] }, 36, 46);
883        let subroutine = node(
884            NodeKind::Subroutine {
885                name: Some("test_nested".to_string()),
886                name_span: Some(SourceLocation { start: 20, end: 31 }),
887                prototype: None,
888                signature: None,
889                attributes: vec![],
890                body: Box::new(body),
891            },
892            16,
893            46,
894        );
895        let ast = node(NodeKind::Program { statements: vec![subroutine] }, 0, source.len());
896        let runner = TestRunner::new(source.to_string(), "file:///project/t/sample.t".to_string());
897
898        let tests = runner.discover_tests(&ast);
899
900        assert_eq!(tests.len(), 1);
901        assert_eq!(tests[0].kind, TestKind::File);
902        assert_eq!(tests[0].label, "sample.t");
903        assert_eq!(tests[0].range.end_line, 3);
904        assert_eq!(tests[0].range.end_character, 1);
905        assert_eq!(tests[0].children.len(), 1);
906        assert_eq!(tests[0].children[0].label, "test_nested");
907        assert_eq!(tests[0].children[0].range.start_line, 1);
908        assert_eq!(tests[0].children[0].range.start_character, 0);
909        assert_eq!(tests[0].children[0].range.end_line, 3);
910        assert_eq!(tests[0].children[0].range.end_character, 1);
911        Ok(())
912    }
913
914    #[test]
915    fn visit_children_for_tests_walks_if_with_keyword_metadata()
916    -> Result<(), Box<dyn std::error::Error>> {
917        let runner = TestRunner::new("is($got, $want);".to_string(), "file:///suite.t".to_string());
918        let string = |value: &str, start| {
919            node(
920                NodeKind::String { value: value.to_string(), interpolated: false },
921                start,
922                start + value.len() + 2,
923            )
924        };
925        let call = |name: &str, start| {
926            node(
927                NodeKind::FunctionCall {
928                    name: name.to_string(),
929                    args: vec![string("case", start + name.len() + 1)],
930                },
931                start,
932                start + name.len() + 8,
933            )
934        };
935        let node = node(
936            NodeKind::If {
937                condition: Box::new(node(NodeKind::Number { value: "1".to_string() }, 0, 1)),
938                then_branch: Box::new(call("is", 2)),
939                elsif_branches: vec![],
940                else_branch: Some(Box::new(call("ok", 12))),
941                keyword: Some("unless".to_string()),
942            },
943            0,
944            20,
945        );
946        let mut tests = Vec::new();
947
948        runner.visit_children_for_tests(&node, &mut tests);
949
950        assert_eq!(tests.len(), 2);
951        Ok(())
952    }
953
954    #[test]
955    fn assertion_discovery_uses_string_description_or_call_name()
956    -> Result<(), Box<dyn std::error::Error>> {
957        let source = "ok($value, 'truthy');
958pass();
959";
960        let described = node(
961            NodeKind::FunctionCall {
962                name: "ok".to_string(),
963                args: vec![
964                    node(
965                        NodeKind::Variable { sigil: "$".to_string(), name: "value".to_string() },
966                        3,
967                        9,
968                    ),
969                    node(
970                        NodeKind::String { value: "truthy".to_string(), interpolated: false },
971                        11,
972                        19,
973                    ),
974                ],
975            },
976            0,
977            20,
978        );
979        let unnamed =
980            node(NodeKind::FunctionCall { name: "pass".to_string(), args: vec![] }, 21, 27);
981        let ast = node(NodeKind::Program { statements: vec![described, unnamed] }, 0, source.len());
982        let runner =
983            TestRunner::new(source.to_string(), "file:///project/lib/Module.pm".to_string());
984
985        let tests = runner.find_test_functions(&ast);
986
987        assert_eq!(tests.len(), 2);
988        assert_eq!(tests[0].label, "truthy");
989        assert_eq!(tests[0].id, "file:///project/lib/Module.pm::ok::0");
990        assert_eq!(tests[1].label, "pass");
991        assert_eq!(tests[1].id, "file:///project/lib/Module.pm::pass::21");
992        Ok(())
993    }
994
995    fn node(kind: NodeKind, start: usize, end: usize) -> Node {
996        Node::new(kind, SourceLocation { start, end })
997    }
998
999    // ── Hermeticity tests for hermetic_perl_command (#8689) ──────────────────
1000
1001    /// `hermetic_perl_command` produces a command with an empty env except PATH.
1002    ///
1003    /// We cannot inspect `Command`'s env map on stable Rust, so we verify
1004    /// indirectly by running the Perl subprocess and asserting PERL5LIB and
1005    /// PERL5OPT are absent from its environment.
1006    ///
1007    /// Skipped automatically when Perl is not on PATH.
1008    #[test]
1009    // SAFETY-LINT: this test intentionally mutates process env under ENV_MUTEX.
1010    #[allow(unsafe_code)]
1011    fn hermetic_perl_command_strips_perl5lib() {
1012        let perl = which_perl();
1013        let Some(perl) = perl else { return };
1014        let Ok(_env_guard) = ENV_MUTEX.lock() else { return };
1015
1016        let runner = TestRunner::new("".to_string(), "".to_string());
1017
1018        // Temporarily set a poisoned PERL5LIB in the parent process.
1019        let poison = "/hermetic-test-poison-perl5lib";
1020        // SAFETY: ENV_MUTEX serializes test-local environment mutation.
1021        unsafe { std::env::set_var("PERL5LIB", poison) };
1022        let mut cmd = runner.hermetic_perl_command(&perl);
1023        cmd.args(["-e", "print $ENV{PERL5LIB} // 'UNSET'"]);
1024        cmd.stdout(std::process::Stdio::piped());
1025        cmd.stderr(std::process::Stdio::piped());
1026        let out = cmd.output();
1027        // SAFETY: ENV_MUTEX serializes test-local environment mutation.
1028        unsafe { std::env::remove_var("PERL5LIB") };
1029
1030        let out = match out {
1031            Ok(o) => o,
1032            Err(_) => return, // Perl not found; skip
1033        };
1034        let stdout = String::from_utf8_lossy(&out.stdout);
1035        assert_eq!(
1036            stdout.trim(),
1037            "UNSET",
1038            "PERL5LIB must be stripped by hermetic_perl_command; got: {stdout:?}",
1039        );
1040    }
1041
1042    /// `hermetic_perl_command` strips PERL5OPT — ambient runtime injection
1043    /// must not reach TDD fixture subprocesses.
1044    #[test]
1045    // SAFETY-LINT: this test intentionally mutates process env under ENV_MUTEX.
1046    #[allow(unsafe_code)]
1047    fn hermetic_perl_command_strips_perl5opt() {
1048        let perl = which_perl();
1049        let Some(perl) = perl else { return };
1050        let Ok(_env_guard) = ENV_MUTEX.lock() else { return };
1051
1052        let runner = TestRunner::new("".to_string(), "".to_string());
1053
1054        // SAFETY: ENV_MUTEX serializes test-local environment mutation.
1055        unsafe { std::env::set_var("PERL5OPT", "-Mstrict") };
1056        let mut cmd = runner.hermetic_perl_command(&perl);
1057        cmd.args(["-e", "print $ENV{PERL5OPT} // 'UNSET'"]);
1058        cmd.stdout(std::process::Stdio::piped());
1059        cmd.stderr(std::process::Stdio::piped());
1060        let out = cmd.output();
1061        // SAFETY: ENV_MUTEX serializes test-local environment mutation.
1062        unsafe { std::env::remove_var("PERL5OPT") };
1063
1064        let out = match out {
1065            Ok(o) => o,
1066            Err(_) => return,
1067        };
1068        let stdout = String::from_utf8_lossy(&out.stdout);
1069        assert_eq!(
1070            stdout.trim(),
1071            "UNSET",
1072            "PERL5OPT must be stripped by hermetic_perl_command; got: {stdout:?}",
1073        );
1074    }
1075
1076    /// `hermetic_perl_command` preserves PATH so the interpreter can resolve
1077    /// its own helpers.
1078    #[test]
1079    fn hermetic_perl_command_preserves_path() {
1080        let runner = TestRunner::new("".to_string(), "".to_string());
1081        // We only check the struct-level behaviour here: PATH should come from
1082        // the parent env if it is set. We just confirm the function is callable
1083        // and returns a usable Command without panicking.
1084        let _ = runner.hermetic_perl_command("perl");
1085    }
1086
1087    /// Resolve the `perl` binary on PATH for subprocess tests.
1088    /// Returns `None` when Perl is not found (test is auto-skipped).
1089    fn which_perl() -> Option<String> {
1090        let path_env = std::env::var_os("PATH")?;
1091        for dir in std::env::split_paths(&path_env) {
1092            let candidate = dir.join("perl");
1093            if candidate.is_file() {
1094                return Some(candidate.to_string_lossy().into_owned());
1095            }
1096            // Windows: also try perl.exe
1097            let candidate_exe = dir.join("perl.exe");
1098            if candidate_exe.is_file() {
1099                return Some(candidate_exe.to_string_lossy().into_owned());
1100            }
1101        }
1102        None
1103    }
1104}