|
@@ -0,0 +1,446 @@
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+
|
|
|
|
|
+import argparse
|
|
|
|
|
+
|
|
|
|
|
+import glob
|
|
|
|
|
+import importlib.util
|
|
|
|
|
+import logging
|
|
|
|
|
+import re
|
|
|
|
|
+import sys
|
|
|
|
|
+import time
|
|
|
|
|
+import os
|
|
|
|
|
+import asyncio
|
|
|
|
|
+
|
|
|
|
|
+from enum import StrEnum
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+from typing import List, Optional, Tuple
|
|
|
|
|
+from abc import ABC, abstractmethod
|
|
|
|
|
+from dataclasses import dataclass, field
|
|
|
|
|
+
|
|
|
|
|
+import httpx
|
|
|
|
|
+from tabulate import tabulate
|
|
|
|
|
+from termcolor import colored
|
|
|
|
|
+
|
|
|
|
|
+from .backend import BackendConfig, DummyBackend
|
|
|
|
|
+from .proxy import ProxyConfig, ProxyManager
|
|
|
|
|
+
|
|
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestStatus(StrEnum):
|
|
|
|
|
+ PASS = "green"
|
|
|
|
|
+ FAIL = "yellow"
|
|
|
|
|
+ ERROR = "red"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass
|
|
|
|
|
+class TestResult:
|
|
|
|
|
+ """Result of a single test execution"""
|
|
|
|
|
+ test_id: str
|
|
|
|
|
+ description: str
|
|
|
|
|
+ status: TestStatus
|
|
|
|
|
+ duration: float
|
|
|
|
|
+ error_messages: Optional[List[str]] = field(default_factory=list)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class BaseProxyTest(ABC):
|
|
|
|
|
+ """Base class for all HTTP tests
|
|
|
|
|
+
|
|
|
|
|
+ All test classes must inherit from this class and eventually
|
|
|
|
|
+ override the required parameters.
|
|
|
|
|
+
|
|
|
|
|
+ Attributes:
|
|
|
|
|
+ description (str): Description for the test that will be printed in overall summary
|
|
|
|
|
+ url (str): URL to run the test against (default: 'http://localhost:4242/')
|
|
|
|
|
+ method (str): HTTP method to use (default: GET)
|
|
|
|
|
+ headers (dict): A dictionary of headers that will be sent by the client
|
|
|
|
|
+ while performing the request.
|
|
|
|
|
+ body (str): A string that will be sent as request body (if applicable)
|
|
|
|
|
+ expected_status (int): The HTTP status that will be compared with the received
|
|
|
|
|
+ one. The test fails if doesnt' match (default: 200)
|
|
|
|
|
+ expected_headers (dict): A dictionary (header_name: header_value) of headers
|
|
|
|
|
+ that needs to be present in the response. The test fails if the header_name
|
|
|
|
|
+ is not present in the response or if header_value doesn't match.
|
|
|
|
|
+ expected_body_pattern (str): A regex used to match the body content. The test
|
|
|
|
|
+ fails if the match isn't found.
|
|
|
|
|
+ expected_header_patterns (dict): a dictionary (header_name: header_value_pattern)
|
|
|
|
|
+ that will be checked against received headers. If header_name is not present
|
|
|
|
|
+ in the response headers or header_value_pattern doesn't match the header
|
|
|
|
|
+ content, the test will fail.
|
|
|
|
|
+ forbidden_client_headers (list): A list of header names that needs to be *absent*
|
|
|
|
|
+ from the header list sent to the client. If any of the header name is found in
|
|
|
|
|
+ the response the test will fail.
|
|
|
|
|
+ expected_backend_headers (list): A list of headers that needs to be present in the
|
|
|
|
|
+ request sent to the backend. This is used to check if the reverse proxy deletes
|
|
|
|
|
+ one or more headers before forwarding the request to the backend. The test will
|
|
|
|
|
+ fail if one or more header are not present.
|
|
|
|
|
+ forbidden_backend_headers (list): Similar to the above parameter but the test will
|
|
|
|
|
+ fail instead if any of the header is found among the headers received by the
|
|
|
|
|
+ backend.
|
|
|
|
|
+ backend_header_patterns (dict): A dictionary (header_name: header_value_pattern)
|
|
|
|
|
+ that will be checked against the headers received by the backend. If the header
|
|
|
|
|
+ name is not present in the backend headers or header_value_pattern doesn't match
|
|
|
|
|
+ the header value received by the backend, the test will fail.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.test_id = self.__class__.__name__
|
|
|
|
|
+ self.description = getattr(self, 'description',
|
|
|
|
|
+ 'No description provided')
|
|
|
|
|
+ self.url = getattr(self, 'url', 'http://localhost:4242/')
|
|
|
|
|
+ self.method = getattr(self, 'method', 'GET')
|
|
|
|
|
+ self.headers = getattr(self, 'headers', {})
|
|
|
|
|
+ self.body = getattr(self, 'body', '')
|
|
|
|
|
+
|
|
|
|
|
+ # Test configuration
|
|
|
|
|
+ self.backend_config = getattr(self, 'backend_config', BackendConfig())
|
|
|
|
|
+ self.proxy_config = getattr(self, 'proxy_config', ProxyConfig())
|
|
|
|
|
+
|
|
|
|
|
+ # Validation rules
|
|
|
|
|
+ self.expected_status = getattr(self, 'expected_status', 200)
|
|
|
|
|
+ self.expected_headers = getattr(self, 'expected_headers', {})
|
|
|
|
|
+ self.expected_body_pattern = getattr(self, 'expected_body_pattern', None)
|
|
|
|
|
+ self.expected_header_patterns = getattr(self, 'expected_header_patterns', {})
|
|
|
|
|
+ self.forbidden_client_headers = getattr(self, 'forbidden_client_headers', [])
|
|
|
|
|
+ self.expected_backend_headers = getattr(self, 'expected_backend_headers', [])
|
|
|
|
|
+ self.forbidden_backend_headers = getattr(self, 'forbidden_backend_headers', [])
|
|
|
|
|
+ self.backend_header_patterns = getattr(self, 'backend_header_patterns', {})
|
|
|
|
|
+
|
|
|
|
|
+ # Runtime data
|
|
|
|
|
+ self.backend = None
|
|
|
|
|
+ self.proxy = None
|
|
|
|
|
+ self.response = None
|
|
|
|
|
+ self.response_headers = {}
|
|
|
|
|
+ self.response_body = ""
|
|
|
|
|
+
|
|
|
|
|
+ def setup(self):
|
|
|
|
|
+ logger.debug("Setting up test environment")
|
|
|
|
|
+ # Start backend
|
|
|
|
|
+ logger.debug(f"Instantiating backend with configuration: {self.backend_config}")
|
|
|
|
|
+ self.backend = DummyBackend(self.backend_config)
|
|
|
|
|
+ logger.debug("Starting backend")
|
|
|
|
|
+ self.backend.start()
|
|
|
|
|
+ time.sleep(999)
|
|
|
|
|
+
|
|
|
|
|
+ # Start proxy
|
|
|
|
|
+ logger.debug(f"Instantiating reverse proxy with configuration: {self.proxy_config}")
|
|
|
|
|
+ self.proxy = ProxyManager(self.proxy_config)
|
|
|
|
|
+ logger.debug(f"Starting reverse proxy with configuration: {self.backend_config}")
|
|
|
|
|
+ self.proxy.start(self.backend_config)
|
|
|
|
|
+ logger.debug("Sleeping for 0.1s before proceeding")
|
|
|
|
|
+ time.sleep(0.1)
|
|
|
|
|
+
|
|
|
|
|
+ def teardown(self):
|
|
|
|
|
+ logger.debug("Cleaning up test environment")
|
|
|
|
|
+ if self.backend:
|
|
|
|
|
+ logger.debug("Stopping backend")
|
|
|
|
|
+ asyncio.run(self.backend.stop())
|
|
|
|
|
+ if self.proxy:
|
|
|
|
|
+ logger.debug("Stopping reverse proxy")
|
|
|
|
|
+ self.proxy.stop()
|
|
|
|
|
+
|
|
|
|
|
+ def make_request(self):
|
|
|
|
|
+ """Make HTTP request through the proxy"""
|
|
|
|
|
+
|
|
|
|
|
+ request = httpx.Request(
|
|
|
|
|
+ method=self.method,
|
|
|
|
|
+ url=self.url,
|
|
|
|
|
+ headers=self.headers,
|
|
|
|
|
+ content=self.body)
|
|
|
|
|
+ logger.debug(f"Performing HTTP request: {request}")
|
|
|
|
|
+ with httpx.Client(http2=True) as client:
|
|
|
|
|
+ response = client.send(request=request)
|
|
|
|
|
+ logger.debug(f"Response: {response}")
|
|
|
|
|
+ self.response = response
|
|
|
|
|
+
|
|
|
|
|
+ return response
|
|
|
|
|
+
|
|
|
|
|
+ def validate_response(self) -> Tuple[TestStatus, Optional[List[str]]]:
|
|
|
|
|
+ """Validate the HTTP response"""
|
|
|
|
|
+ validation_errors = []
|
|
|
|
|
+ # Check status code
|
|
|
|
|
+ if self.response.status_code != self.expected_status:
|
|
|
|
|
+ logger.info(f"Expected status {self.expected_status}, got {self.response.status_code}")
|
|
|
|
|
+ validation_errors.append(
|
|
|
|
|
+ f"Expected status {self.expected_status}, got {self.response.status_code}")
|
|
|
|
|
+
|
|
|
|
|
+ # Check expected headers
|
|
|
|
|
+ for header, expected_value in self.expected_headers.items():
|
|
|
|
|
+ if header not in self.response_headers:
|
|
|
|
|
+ logger.info(f"Expected header '{header}' not found in response")
|
|
|
|
|
+ validation_errors.append(f"Expected header '{header}' not found in response")
|
|
|
|
|
+ else:
|
|
|
|
|
+ received_value = self.response_headers[header]
|
|
|
|
|
+ if received_value != expected_value:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ f"Header '{header}' expected '{expected_value}', got '{received_value}'")
|
|
|
|
|
+ validation_errors.append(
|
|
|
|
|
+ f"Header '{header}' expected '{expected_value}', got '{received_value}'")
|
|
|
|
|
+
|
|
|
|
|
+ # Check header patterns
|
|
|
|
|
+ for header, pattern in self.expected_header_patterns.items():
|
|
|
|
|
+ if header not in self.response_headers:
|
|
|
|
|
+ logger.info(f"Header '{header}' for pattern matching not found")
|
|
|
|
|
+ validation_errors.append(f"Header '{header}' for pattern matching not found")
|
|
|
|
|
+
|
|
|
|
|
+ if not re.match(pattern, self.response_headers[header]):
|
|
|
|
|
+ logger.info(f"Header '{header}' value doesn't match pattern '{pattern}'")
|
|
|
|
|
+ validation_errors.append(
|
|
|
|
|
+ f"Header '{header}' value doesn't match pattern '{pattern}'")
|
|
|
|
|
+
|
|
|
|
|
+ # Check forbidden client headers
|
|
|
|
|
+ for header in self.forbidden_client_headers:
|
|
|
|
|
+ if header in self.response_headers:
|
|
|
|
|
+ logger.info(f"Forbidden header '{header}' found in client response")
|
|
|
|
|
+ validation_errors.append(f"Forbidden header '{header}' found in client response")
|
|
|
|
|
+
|
|
|
|
|
+ # Check body pattern
|
|
|
|
|
+ if self.expected_body_pattern and not re.search(
|
|
|
|
|
+ self.expected_body_pattern, self.response_body):
|
|
|
|
|
+ logger.info(f"Response body doesn't match pattern '{self.expected_body_pattern}'")
|
|
|
|
|
+ validation_errors.append(
|
|
|
|
|
+ f"Response body doesn't match pattern '{self.expected_body_pattern}'")
|
|
|
|
|
+
|
|
|
|
|
+ # Check backend headers
|
|
|
|
|
+ for header in self.expected_backend_headers:
|
|
|
|
|
+ if header not in self.backend.received_headers:
|
|
|
|
|
+ logger.info(f"Expected header {header} in backend headers")
|
|
|
|
|
+ validation_errors.append(f"Expected header {header} in backend headers")
|
|
|
|
|
+
|
|
|
|
|
+ # Check backend headers that shouldn't be there
|
|
|
|
|
+ for header in self.forbidden_backend_headers:
|
|
|
|
|
+ if header in self.backend.received_headers:
|
|
|
|
|
+ logger.info(f"Forbidden header '{header}' found in backend request")
|
|
|
|
|
+ validation_errors.append(f"Forbidden header '{header}' found in backend request")
|
|
|
|
|
+
|
|
|
|
|
+ # Check backend header patterns
|
|
|
|
|
+ for header, pattern in self.backend_header_patterns.items():
|
|
|
|
|
+ if header not in self.backend.received_headers:
|
|
|
|
|
+ logger.info(f"Expected backend header '{header}' not found")
|
|
|
|
|
+ validation_errors.append(f"Expected backend header '{header}' not found")
|
|
|
|
|
+
|
|
|
|
|
+ if not re.match(pattern, self.backend.received_headers[header]):
|
|
|
|
|
+ logger.info(f"Backend header '{header}' doesn't match pattern '{pattern}'")
|
|
|
|
|
+ validation_errors.append(
|
|
|
|
|
+ f"Backend header '{header}' doesn't match pattern '{pattern}'")
|
|
|
|
|
+
|
|
|
|
|
+ logger.debug("All checks done")
|
|
|
|
|
+ if validation_errors:
|
|
|
|
|
+ return TestStatus.FAIL, validation_errors
|
|
|
|
|
+ return TestStatus.PASS, None
|
|
|
|
|
+
|
|
|
|
|
+ #@abstractmethod
|
|
|
|
|
+ def run_test(self) -> bool:
|
|
|
|
|
+ """Run the actual test logic. Must be implemented by subclasses"""
|
|
|
|
|
+ self.make_request()
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ def execute(self) -> TestResult:
|
|
|
|
|
+ """Execute the complete test"""
|
|
|
|
|
+ start_time = time.time()
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.setup()
|
|
|
|
|
+ success = self.run_test()
|
|
|
|
|
+
|
|
|
|
|
+ if success:
|
|
|
|
|
+ logger.debug("Validating response...")
|
|
|
|
|
+ # Validate response if request was made
|
|
|
|
|
+ if self.response:
|
|
|
|
|
+ validation_status, errors = self.validate_response()
|
|
|
|
|
+ return TestResult(
|
|
|
|
|
+ self.test_id,
|
|
|
|
|
+ self.description,
|
|
|
|
|
+ validation_status,
|
|
|
|
|
+ time.time() - start_time,
|
|
|
|
|
+ errors
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ return TestResult(
|
|
|
|
|
+ self.test_id,
|
|
|
|
|
+ self.description,
|
|
|
|
|
+ TestStatus.ERROR,
|
|
|
|
|
+ time.time() - start_time,
|
|
|
|
|
+ ["Test logic failed"]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.exception(f"Test {self.test_id} failed with exception")
|
|
|
|
|
+ return TestResult(
|
|
|
|
|
+ self.test_id,
|
|
|
|
|
+ self.description,
|
|
|
|
|
+ TestStatus.ERROR,
|
|
|
|
|
+ time.time() - start_time,
|
|
|
|
|
+ [str(e)]
|
|
|
|
|
+ )
|
|
|
|
|
+ finally:
|
|
|
|
|
+ self.teardown()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestRunner:
|
|
|
|
|
+ """Main test runner"""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.results = []
|
|
|
|
|
+
|
|
|
|
|
+ def run_all_tests(self, tests: List[BaseProxyTest]) -> TestResult:
|
|
|
|
|
+ """Run all tests"""
|
|
|
|
|
+ logger.debug("Running all tests")
|
|
|
|
|
+ for test in tests:
|
|
|
|
|
+ logger.info(colored(f"Running test: {test.test_id}", "blue"))
|
|
|
|
|
+ result = test.execute()
|
|
|
|
|
+ logger.debug(f"Test result for {test.test_id}: {result}")
|
|
|
|
|
+ self.results.append(result)
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+ def print_summary(self):
|
|
|
|
|
+
|
|
|
|
|
+ total_tests = len(self.results)
|
|
|
|
|
+ passed_tests = sum(1 for r in self.results if r.status == TestStatus.PASS)
|
|
|
|
|
+ failed_tests = total_tests - passed_tests
|
|
|
|
|
+ total_time = sum(r.duration for r in self.results)
|
|
|
|
|
+
|
|
|
|
|
+ print("TEST EXECUTION SUMMARY")
|
|
|
|
|
+ print()
|
|
|
|
|
+ for result in self.results:
|
|
|
|
|
+ if result.status != TestStatus.PASS:
|
|
|
|
|
+ print(f"Test {colored(result.test_id, 'blue')} failed with following errors:")
|
|
|
|
|
+ for msg in result.error_messages:
|
|
|
|
|
+ print(colored(f"\t{msg}", result.status.value))
|
|
|
|
|
+ print()
|
|
|
|
|
+
|
|
|
|
|
+ result_table = [["Status", "Name", "Description", "Duration"]]
|
|
|
|
|
+
|
|
|
|
|
+ for result in self.results:
|
|
|
|
|
+ status_color = colored(result.status.name, result.status.value)
|
|
|
|
|
+
|
|
|
|
|
+ result_table.append([status_color,
|
|
|
|
|
+ result.test_id,
|
|
|
|
|
+ result.description,
|
|
|
|
|
+ result.duration])
|
|
|
|
|
+
|
|
|
|
|
+ print(tabulate(result_table, headers="firstrow"))
|
|
|
|
|
+ print()
|
|
|
|
|
+ print(tabulate([["Total tests", "Passed", "Failed", "Total duration"],
|
|
|
|
|
+ [total_tests, passed_tests, failed_tests, f"{total_time:.2f}"]]))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def discover_tests(paths: List[str]) -> List[BaseProxyTest]:
|
|
|
|
|
+ """Discover all test classes in the passed paths and returns
|
|
|
|
|
+ a list of tests ready to be run
|
|
|
|
|
+ """
|
|
|
|
|
+ all_files = []
|
|
|
|
|
+ tests = []
|
|
|
|
|
+
|
|
|
|
|
+ for p in paths:
|
|
|
|
|
+ test_path = Path(p)
|
|
|
|
|
+
|
|
|
|
|
+ if not test_path.exists():
|
|
|
|
|
+ logger.error(f"{test_path} does not exists")
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ logger.debug(f"Searching for test files in {test_path}")
|
|
|
|
|
+ if test_path.is_dir():
|
|
|
|
|
+ all_files.extend(test_path.glob("*.py"))
|
|
|
|
|
+ elif test_path.is_file():
|
|
|
|
|
+ if test_path.suffix == ".py":
|
|
|
|
|
+ all_files.append(test_path)
|
|
|
|
|
+ elif '*' in test_path or '?' in test_path or '[' in test_path:
|
|
|
|
|
+ globbed_files = glob.glob(test_path)
|
|
|
|
|
+ for f in globbed_files:
|
|
|
|
|
+ if os.path.isfile(f) and f.endswith(".py"):
|
|
|
|
|
+ all_files.append(f)
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.error(f"Cannot find test files in {test_path}")
|
|
|
|
|
+ raise RuntimeError(f"Cannot find test files in {test_path}")
|
|
|
|
|
+
|
|
|
|
|
+ if not all_files:
|
|
|
|
|
+ logger.error(f"No test file to import from {paths}")
|
|
|
|
|
+ raise RuntimeError(f"Not test files to import from {paths}")
|
|
|
|
|
+
|
|
|
|
|
+ for test_file in all_files:
|
|
|
|
|
+ try:
|
|
|
|
|
+ spec = importlib.util.spec_from_file_location(
|
|
|
|
|
+ f"test_{test_file.stem}",
|
|
|
|
|
+ test_file,
|
|
|
|
|
+ )
|
|
|
|
|
+ module = importlib.util.module_from_spec(spec)
|
|
|
|
|
+ spec.loader.exec_module(module)
|
|
|
|
|
+
|
|
|
|
|
+ # Find all test classes
|
|
|
|
|
+ for attr_name in dir(module):
|
|
|
|
|
+ attr = getattr(module, attr_name)
|
|
|
|
|
+ if (isinstance(attr, type) and
|
|
|
|
|
+ issubclass(attr, BaseProxyTest) and
|
|
|
|
|
+ attr != BaseProxyTest):
|
|
|
|
|
+ tests.append(attr())
|
|
|
|
|
+ logger.debug(f"Test classes: {tests}")
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error(f"Failed to load test file {test_file}: {e}")
|
|
|
|
|
+
|
|
|
|
|
+ return tests
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def setup_logging(level):
|
|
|
|
|
+ """Configure logging"""
|
|
|
|
|
+ logging.basicConfig(
|
|
|
|
|
+ level=level,
|
|
|
|
|
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+def main():
|
|
|
|
|
+ parser = argparse.ArgumentParser(description="Reverse roxy test tool")
|
|
|
|
|
+ parser.add_argument(
|
|
|
|
|
+ '--log-level', '-l',
|
|
|
|
|
+ type=str.upper,
|
|
|
|
|
+ choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
|
|
|
|
|
+ default='INFO', help='Set logging level (Default: INFO)')
|
|
|
|
|
+ parser.add_argument(
|
|
|
|
|
+ 'paths',
|
|
|
|
|
+ nargs='+',
|
|
|
|
|
+ help='Directories, file patterns, or individual Python files (.py)')
|
|
|
|
|
+ args = parser.parse_args()
|
|
|
|
|
+
|
|
|
|
|
+ log_level = getattr(logging, args.log_level.upper())
|
|
|
|
|
+ setup_logging(log_level)
|
|
|
|
|
+ logger = logging.getLogger("httphound")
|
|
|
|
|
+
|
|
|
|
|
+ logger.info("Starting httphound")
|
|
|
|
|
+ # a little Ascii art only in debug mode
|
|
|
|
|
+
|
|
|
|
|
+ hound = '''
|
|
|
|
|
+ __
|
|
|
|
|
+ \ ______/ V`-,
|
|
|
|
|
+ } /~~
|
|
|
|
|
+ /_)^ --,r'
|
|
|
|
|
+|b |b
|
|
|
|
|
+'''
|
|
|
|
|
+ logger.debug(hound)
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ tests = discover_tests(args.paths)
|
|
|
|
|
+ logger.info(f"Discovered {len(tests)} tests in {args.paths}")
|
|
|
|
|
+
|
|
|
|
|
+ if not tests:
|
|
|
|
|
+ logger.info("No tests found")
|
|
|
|
|
+ sys.exit(0)
|
|
|
|
|
+
|
|
|
|
|
+ # Run tests
|
|
|
|
|
+ runner = TestRunner()
|
|
|
|
|
+ runner.run_all_tests(tests)
|
|
|
|
|
+
|
|
|
|
|
+ # Print summary
|
|
|
|
|
+ runner.print_summary()
|
|
|
|
|
+
|
|
|
|
|
+ # Exit with error code if any tests failed
|
|
|
|
|
+ failed_count = sum(1 for r in runner.results if not r.status == TestStatus.PASS)
|
|
|
|
|
+ if failed_count > 0:
|
|
|
|
|
+ raise RuntimeError(f"{failed_count} over {len(runner.results)} test failed")
|
|
|
|
|
+
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error(f"Test execution failed: {e}")
|
|
|
|
|
+ sys.exit(1)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def start():
|
|
|
|
|
+ main()
|