""" JavaScript Test Runner Provides integration between Python test suite and JavaScript tests. Allows running JavaScript tests from Python and collecting results. """ import json import subprocess import sys from pathlib import Path from typing import Dict, List, Optional, Union, Any from dataclasses import dataclass import tempfile import os @dataclass class JSTestResult: """Results from running JavaScript tests.""" success: bool tests_passed: int tests_failed: int tests_total: int failures: List[Dict[str, Any]] coverage: Optional[Dict[str, Any]] = None duration: float = 0.0 stdout: str = "" stderr: str = "" class JavaScriptTestRunner: """ Runs JavaScript tests via Node.js and Jest, integrating with Python test suite. """ def __init__(self, capability_root: Optional[Path] = None): """ Initialize the JavaScript test runner. Args: capability_root: Root directory of the testdrive-jsui capability. If None, attempts to auto-discover. """ self.capability_root = capability_root or self._find_capability_root() self.js_dir = self.capability_root / "js" self.package_json = self.capability_root / "package.json" if not self.package_json.exists(): raise ValueError(f"package.json not found at {self.package_json}") def _find_capability_root(self) -> Path: """Auto-discover the capability root directory.""" current = Path(__file__).parent while current.parent != current: if (current / "package.json").exists(): return current current = current.parent # Fallback: look for capabilities directory structure current = Path(__file__).parent while current.parent != current: capabilities_dir = current / "capabilities" / "testdrive-jsui" if capabilities_dir.exists(): return capabilities_dir current = current.parent raise ValueError("Could not find testdrive-jsui capability root") def check_node_environment(self) -> bool: """ Check if Node.js and npm are available. Returns: True if Node.js environment is ready, False otherwise. """ try: # Check Node.js subprocess.run( ["node", "--version"], capture_output=True, check=True, cwd=self.capability_root ) # Check npm subprocess.run( ["npm", "--version"], capture_output=True, check=True, cwd=self.capability_root ) return True except (subprocess.CalledProcessError, FileNotFoundError): return False def install_dependencies(self) -> bool: """ Install JavaScript dependencies via npm. Returns: True if installation succeeded, False otherwise. """ try: result = subprocess.run( ["npm", "install"], capture_output=True, text=True, check=True, cwd=self.capability_root ) return result.returncode == 0 except subprocess.CalledProcessError: return False def run_js_tests( self, test_patterns: Optional[List[str]] = None, coverage: bool = False, verbose: bool = False ) -> JSTestResult: """ Run JavaScript tests via Jest. Args: test_patterns: Specific test files or patterns to run coverage: Whether to collect coverage information verbose: Whether to run in verbose mode Returns: JSTestResult containing test execution results """ if not self.check_node_environment(): return JSTestResult( success=False, tests_passed=0, tests_failed=1, tests_total=1, failures=[{"message": "Node.js environment not available"}], stderr="Node.js or npm not found" ) # Build Jest command cmd = ["npm", "test", "--"] if coverage: cmd.append("--coverage") if verbose: cmd.append("--verbose") # Add JSON reporter for parsing results cmd.extend(["--json", "--outputFile", "test-results.json"]) if test_patterns: cmd.extend(test_patterns) try: # Run the tests result = subprocess.run( cmd, capture_output=True, text=True, cwd=self.capability_root, timeout=300 # 5 minute timeout ) # Parse JSON results test_results_file = self.capability_root / "test-results.json" if test_results_file.exists(): try: with open(test_results_file, 'r') as f: jest_results = json.load(f) # Clean up results file test_results_file.unlink() return self._parse_jest_results(jest_results, result) except (json.JSONDecodeError, KeyError) as e: return JSTestResult( success=False, tests_passed=0, tests_failed=1, tests_total=1, failures=[{"message": f"Failed to parse test results: {e}"}], stdout=result.stdout, stderr=result.stderr ) else: # Fallback: parse from stdout/stderr return self._parse_output_results(result) except subprocess.TimeoutExpired: return JSTestResult( success=False, tests_passed=0, tests_failed=1, tests_total=1, failures=[{"message": "Test execution timed out"}], stderr="Test execution exceeded 5 minute timeout" ) except Exception as e: return JSTestResult( success=False, tests_passed=0, tests_failed=1, tests_total=1, failures=[{"message": f"Test execution failed: {e}"}], stderr=str(e) ) def _parse_jest_results(self, jest_results: Dict, process_result) -> JSTestResult: """Parse Jest JSON results into JSTestResult.""" success = jest_results.get("success", False) # Extract test counts num_passed_tests = jest_results.get("numPassedTests", 0) num_failed_tests = jest_results.get("numFailedTests", 0) num_total_tests = jest_results.get("numTotalTests", 0) # Extract failures failures = [] test_results = jest_results.get("testResults", []) for test_file in test_results: for assertion in test_file.get("assertionResults", []): if assertion.get("status") == "failed": failures.append({ "title": assertion.get("title", "Unknown test"), "message": assertion.get("failureMessages", ["Unknown failure"])[0], "file": test_file.get("name", "Unknown file") }) return JSTestResult( success=success, tests_passed=num_passed_tests, tests_failed=num_failed_tests, tests_total=num_total_tests, failures=failures, duration=jest_results.get("startTime", 0), stdout=process_result.stdout, stderr=process_result.stderr ) def _parse_output_results(self, process_result) -> JSTestResult: """Fallback: parse results from stdout/stderr.""" success = process_result.returncode == 0 stdout = process_result.stdout # Simple parsing for basic stats tests_passed = 0 tests_failed = 0 if "passed" in stdout: try: # Look for pattern like "5 passed" import re passed_match = re.search(r"(\d+)\s+passed", stdout) if passed_match: tests_passed = int(passed_match.group(1)) failed_match = re.search(r"(\d+)\s+failed", stdout) if failed_match: tests_failed = int(failed_match.group(1)) except (ValueError, AttributeError): pass return JSTestResult( success=success, tests_passed=tests_passed, tests_failed=tests_failed, tests_total=tests_passed + tests_failed, failures=[{"message": process_result.stderr}] if not success else [], stdout=stdout, stderr=process_result.stderr ) def run_specific_test(self, test_file: str) -> JSTestResult: """ Run a specific JavaScript test file. Args: test_file: Path to the test file relative to js/tests/ Returns: JSTestResult containing test execution results """ return self.run_js_tests(test_patterns=[test_file]) def list_available_tests(self) -> List[str]: """ List all available JavaScript test files. Returns: List of test file paths """ tests_dir = self.js_dir / "tests" if not tests_dir.exists(): return [] test_files = [] for test_file in tests_dir.glob("**/*.js"): if test_file.name.startswith("test-") or test_file.name.endswith(".test.js"): test_files.append(str(test_file.relative_to(tests_dir))) return sorted(test_files) def get_test_info(self) -> Dict[str, Any]: """ Get information about the JavaScript test environment. Returns: Dictionary with test environment information """ return { "capability_root": str(self.capability_root), "js_directory": str(self.js_dir), "node_available": self.check_node_environment(), "available_tests": self.list_available_tests(), "package_json_exists": self.package_json.exists(), }