| #!/usr/bin/env -S python3 -B |
| # |
| # Copyright (c) 2024 Project CHIP Authors |
| # All rights reserved. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| |
| import datetime |
| import fnmatch |
| import glob as g |
| import json |
| import logging |
| import os |
| import re |
| import subprocess |
| import sys |
| import threading |
| import time |
| from dataclasses import asdict, dataclass, field |
| from pathlib import Path |
| from xml.dom.minidom import parseString |
| from xml.etree.ElementTree import Element, SubElement, tostring |
| |
| import click |
| import coloredlogs |
| import yaml |
| |
| log = logging.getLogger(__name__) |
| |
| |
| @dataclass |
| class TestResult: |
| name: str |
| status: str # "passed" | "failed" | "dry_run" |
| duration_seconds: float |
| error_message: str | None = None |
| |
| |
| @dataclass |
| class RunSummary: |
| run_timestamp: datetime.datetime |
| total_runs: int = 0 |
| passed: int = 0 |
| failed: int = 0 |
| results: list[TestResult] = field(default_factory=list) |
| |
| def record(self, name: str, status: str, duration: float, error_message: str | None = None) -> None: |
| self.results.append(TestResult(name=name, status=status, duration_seconds=round(duration, 3), error_message=error_message)) |
| if status == "passed": |
| self.passed += 1 |
| elif status == "failed": |
| self.failed += 1 |
| |
| def write_json(self, path: Path) -> None: |
| data = asdict(self) |
| data["run_timestamp"] = self.run_timestamp.isoformat() |
| path.parent.mkdir(parents=True, exist_ok=True) |
| path.write_text(json.dumps(data, indent=2)) |
| log.info("Test run summary written to %s", path) |
| |
| def write_junit_xml(self, suite_name: str, path: Path) -> None: |
| failures = sum(1 for r in self.results if r.status == "failed") |
| skipped = sum(1 for r in self.results if r.status == "dry_run") |
| total_time = sum(r.duration_seconds for r in self.results) |
| |
| suite = Element("testsuite", { |
| "name": suite_name, |
| "tests": str(len(self.results)), |
| "failures": str(failures), |
| "skipped": str(skipped), |
| "errors": "0", |
| "time": f"{total_time:.3f}", |
| "timestamp": self.run_timestamp.strftime("%Y-%m-%dT%H:%M:%S"), |
| }) |
| |
| for r in self.results: |
| tc = SubElement(suite, "testcase", { |
| "name": r.name, |
| "classname": suite_name, |
| "time": f"{r.duration_seconds:.3f}", |
| }) |
| if r.status == "failed": |
| failure = SubElement(tc, "failure", {"message": f"{r.name} failed"}) |
| if r.error_message: |
| failure.text = r.error_message |
| SubElement(tc, "system-out").text = r.error_message |
| elif r.status == "dry_run": |
| SubElement(tc, "skipped", {"message": "dry run"}) |
| |
| path.parent.mkdir(parents=True, exist_ok=True) |
| xml_str = parseString(tostring(suite, encoding="unicode")).toprettyxml(indent=" ") |
| path.write_text(xml_str) |
| log.info("JUnit XML written to %s", path) |
| |
| |
| def load_env_from_yaml(file_path): |
| """ |
| Load environment variables from the specified YAML file. |
| |
| The YAML file contains key-value pairs that define --app environment variables |
| required for the test scripts to run. These variables configurations needed during the test execution. |
| |
| This function reads the YAML file and sets the environment variables |
| in the current process's environment using os.environ. |
| |
| Args: |
| file_path (str): The path to the YAML file containing the environment variables. |
| """ |
| with open(file_path) as f: |
| for key, value in yaml.full_load(f).items(): |
| os.environ[key] = value |
| |
| |
| class VMFreezeWatchdog(threading.Thread): |
| """ |
| A background watchdog thread that detects VM freezes or severe CPU starvation. |
| |
| It periodically checks for clock drift by comparing the system wall clock (real time) |
| with the monotonic clock. |
| |
| Why this is needed: |
| The Matter SDK uses monotonic time for timers (like MRP). In virtualized environments, |
| if the VM freezes, the hardware clock on the host continues to run. On resume, the |
| guest kernel updates its clocks, causing a sudden jump in both monotonic and real time. |
| This "time compression" causes scheduled timers in the SDK to expire instantly, |
| leading to false test failures (e.g. MRP retry exhaustion). |
| |
| We cannot use a "paused" clock (like CPU time) in the SDK because the specification and |
| real-world interactions require physical time. Thus, we must detect these freezes at |
| the test runner level and retry the tests. |
| """ |
| |
| def __init__(self, check_interval_sec=2.0, threshold_sec=5.0): |
| """ |
| Args: |
| check_interval_sec (float): How often to check for drift. |
| threshold_sec (float): The minimum drift/jump in seconds to trigger freeze detection. |
| """ |
| super().__init__() |
| self.check_interval_sec = check_interval_sec |
| self.threshold_sec = threshold_sec |
| self.daemon = True |
| self._freeze_detected = False |
| self._lock = threading.Lock() |
| self._stop_event = threading.Event() |
| self.last_wall = time.time() |
| self.last_mono = time.monotonic() |
| |
| def check_for_freeze(self): |
| """Checks for clock drift/jump and updates the freeze detection flag.""" |
| with self._lock: |
| curr_wall = time.time() |
| curr_mono = time.monotonic() |
| |
| wall_delta = curr_wall - self.last_wall |
| mono_delta = curr_mono - self.last_mono |
| |
| # Check for two scenarios of VM clock behavior on resume: |
| # 1. Monotonic clock jumped: monotonic elapsed time is much larger than expected sleep time. |
| mono_jump = mono_delta > (self.check_interval_sec + self.threshold_sec) |
| |
| # 2. Monotonic clock paused but Wall clock jumped (synced via NTP/integration): |
| # drift between wall clock and monotonic clock is significant. |
| drift = wall_delta - mono_delta |
| wall_drift = drift > self.threshold_sec |
| |
| if mono_jump or wall_drift: |
| self._freeze_detected = True |
| log.warning( |
| "VMFreezeWatchdog: Potential VM freeze detected! " |
| "Mono jump: %s (delta: %.2fs), " |
| "Wall drift: %s (drift: %.2fs)", |
| mono_jump, mono_delta, wall_drift, drift |
| ) |
| |
| self.last_wall = curr_wall |
| self.last_mono = curr_mono |
| |
| def run(self): |
| # We use Event.wait() instead of time.sleep() to allow instant thread teardown on stop(). |
| while not self._stop_event.wait(self.check_interval_sec): |
| self.check_for_freeze() |
| |
| def is_freeze_detected(self): |
| """Returns True if a VM freeze has been detected since the last reset.""" |
| with self._lock: |
| return self._freeze_detected |
| |
| def reset_freeze_detected(self): |
| """Resets the freeze detection flag and updates baselines to current time.""" |
| with self._lock: |
| self._freeze_detected = False |
| self.last_wall = time.time() |
| self.last_mono = time.monotonic() |
| |
| def execute_with_retry(self, func, retries=3, delay_sec=10): |
| """ |
| Executes a function and retries it if a VM freeze is detected during execution. |
| |
| Args: |
| func (callable): The function to execute. |
| retries (int): Maximum number of retries. |
| delay_sec (float): Delay in seconds before retrying. |
| """ |
| attempt = 0 |
| while attempt <= retries: |
| attempt += 1 |
| self.reset_freeze_detected() |
| try: |
| func() |
| return |
| except Exception: |
| # Force a synchronous check to catch any freeze that just happened |
| # before the background watchdog thread had a chance to wake up and run. |
| self.check_for_freeze() |
| if self.is_freeze_detected() and attempt <= retries: |
| log.warning("VM freeze detected. Retrying in %d seconds (attempt %d/%d)...", |
| delay_sec, attempt, retries) |
| time.sleep(delay_sec) |
| continue |
| raise |
| |
| def stop(self): |
| """Stops the watchdog thread.""" |
| self._stop_event.set() |
| |
| |
| @click.group() |
| def main(): |
| pass |
| |
| |
| @main.command('run', help='Execute the Python certification tests.') |
| @click.option( |
| "--search-directory", |
| type=str, |
| default="src/python_testing", |
| help="Directory to search for Python scripts.", |
| ) |
| @click.option( |
| "--env-file", |
| type=str, |
| default="/tmp/test_env.yaml", |
| help="Path to the environment variables file.", |
| ) |
| @click.option( |
| "--keep-going", |
| is_flag=True, |
| help="Run ALL the tests, report a final status of what passed/failed.", |
| ) |
| @click.option( |
| "--dry-run", |
| is_flag=True, |
| help="If true, just print out what will be run but do not execute.", |
| ) |
| @click.option( |
| "--glob", |
| multiple=True, |
| help="Glob the tests to pick. Use `!` to negate the glob. Glob FILTERS out non-matching (i.e. you can use it to restrict more and more, but not to add)", |
| ) |
| @click.option( |
| "--regex", |
| multiple=True, |
| help="Regex the tests to pick. Use `!` to negate the expression. Expressions FILTERS out non-matching (i.e. you can use it to restrict more and more, but not to add)", |
| ) |
| @click.option( |
| "--nightly", |
| is_flag=True, |
| help="If set only run tests under the nightly section.", |
| ) |
| @click.option( |
| "--summary-file", |
| type=click.Path(dir_okay=False, path_type=Path), |
| default=None, |
| help="If provided, write a JSON test-run summary to this file at the end of the run.", |
| ) |
| @click.option( |
| "--junit-file", |
| type=click.Path(dir_okay=False, path_type=Path), |
| default=None, |
| help="If provided, write test results as JUnit XML to this file at the end of the run.", |
| ) |
| @click.option( |
| "--junit-suite-name", |
| type=str, |
| default="execute_python_tests script", |
| help="Name for the JUnit XML test suite (default: execute_python_tests script).", |
| ) |
| def cmd_run(search_directory, env_file, keep_going, dry_run: bool, glob: list[str], regex: list[str], nightly: bool, summary_file: Path | None, junit_file: Path | None, junit_suite_name: str): |
| chip_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) |
| |
| load_env_from_yaml(env_file) |
| |
| base_command = os.path.join(chip_root, "scripts/tests/run_python_test.py") |
| |
| with open(os.path.join(chip_root, "src/python_testing/test_metadata.yaml")) as f: |
| metadata = yaml.full_load(f) |
| excluded_patterns = {item["name"] for item in metadata["not_automated"]} |
| nightly_tests = {item["name"] for item in metadata["nightly"]} |
| |
| all_python_files = g.glob(os.path.join(search_directory, "*.py")) |
| |
| for pattern in glob: |
| if pattern.startswith('!'): |
| def match(p): return not fnmatch.fnmatch(p, f"*{pattern[1:]}*") |
| else: |
| def match(p): return fnmatch.fnmatch(p, f"*{pattern}*") |
| all_python_files = [path for path in all_python_files if match(path)] |
| |
| for pattern in regex: |
| if pattern.startswith('!'): |
| r = re.compile(pattern[1:]) |
| def match(p): return not r.search(p) |
| else: |
| r = re.compile(pattern) |
| def match(p): return r.search(p) is not None |
| all_python_files = [path for path in all_python_files if match(path)] |
| |
| # If nightly flag is set, only run tests listed under the nightly section. |
| # Otherwise, exclude both not_automated tests and nightly tests from the regular CI run |
| # (nightly tests are reserved for the nightly workflow). |
| if nightly and nightly_tests is not None: |
| python_files = [file for file in all_python_files if os.path.basename(file) in nightly_tests] |
| else: |
| python_files = [ |
| file for file in all_python_files |
| if os.path.basename(file) not in excluded_patterns |
| and os.path.basename(file) not in nightly_tests |
| ] |
| |
| if len(python_files) == 0: |
| # No files match |
| log.error("No tests to execute") |
| sys.exit(1) |
| |
| run_summary = RunSummary(run_timestamp=datetime.datetime.now(datetime.UTC)) |
| |
| watchdog = VMFreezeWatchdog() |
| watchdog.start() |
| |
| failed_scripts = [] |
| try: |
| for script in python_files: |
| test_start = time.monotonic() |
| try: |
| full_command = f"{base_command} --load-from-env {env_file} --script {script}" |
| if dry_run: |
| print(f"DRY-RUN(skip): {full_command}", flush=True) |
| run_summary.record(os.path.basename(script), "dry_run", time.monotonic() - test_start) |
| else: |
| def run_test(): |
| print(f"Running command: {full_command}", flush=True) |
| subprocess.run(full_command, shell=True, check=True) |
| |
| watchdog.execute_with_retry(run_test) |
| run_summary.record(os.path.basename(script), "passed", time.monotonic() - test_start) |
| except Exception as e: |
| run_summary.record(os.path.basename(script), "failed", time.monotonic() - test_start, error_message=str(e)) |
| if keep_going: |
| failed_scripts.append(script) |
| else: |
| raise |
| finally: |
| watchdog.stop() |
| run_summary.total_runs = len(run_summary.results) |
| if summary_file is not None: |
| run_summary.write_json(summary_file) |
| if junit_file is not None: |
| run_summary.write_junit_xml(junit_suite_name, junit_file) |
| |
| if failed_scripts: |
| log.error("FAILURES detected:") |
| for s in failed_scripts: |
| log.error(" - %s", s) |
| sys.exit(1) |
| |
| |
| @main.command('summarize', help='Pretty-print a JSON summary file produced by the "run" command.') |
| @click.option( |
| '--summary-file', |
| required=True, |
| type=click.Path(exists=True, dir_okay=False, path_type=Path), |
| help='Path to the JSON summary file to display.', |
| ) |
| @click.option( |
| '--top-slowest', |
| default=20, |
| show_default=True, |
| type=click.IntRange(min=1), |
| help='Number of slowest tests to include in the timing table.', |
| ) |
| @click.option( |
| '--compact-failures-file', |
| type=click.Path(dir_okay=False, path_type=Path), |
| default=None, |
| help='Path to output a compact, comma-separated list of failed test names.', |
| ) |
| def cmd_summarize(summary_file: Path, top_slowest: int, compact_failures_file: Path | None) -> None: |
| raw = json.loads(summary_file.read_text()) |
| results: list[dict] = raw.get("results", []) |
| |
| passed = raw.get("passed", 0) |
| failed = raw.get("failed", 0) |
| total = raw.get("total_runs", len(results)) |
| ts = raw.get("run_timestamp", "unknown") |
| |
| sep = "=" * 72 |
| |
| print(sep) |
| print(" PYTHON TEST RUN SUMMARY") |
| print(sep) |
| print(f" Run timestamp : {ts}") |
| print(f" Total runs : {total}") |
| print(f" Passed : {passed}") |
| print(f" Failed : {failed}") |
| if total: |
| print(f" Pass rate : {100 * passed / total:.1f}%") |
| print(sep) |
| |
| failed_results = [r for r in results if r["status"] == "failed"] |
| if failed_results: |
| print(f"\n FAILED TESTS ({len(failed_results)}):") |
| print(f" {'Test name':<60} {'Duration':>10}") |
| print(" " + "-" * 72) |
| for r in sorted(failed_results, key=lambda x: x["name"]): |
| print(f" {'✗ ' + r['name']:<60} {r['duration_seconds']:>9.2f}s") |
| else: |
| print("\n No failures recorded.") |
| |
| if compact_failures_file and failed_results: |
| existing = set() |
| if compact_failures_file.exists(): |
| content = compact_failures_file.read_text().strip() |
| if content: |
| existing = {n.strip() for n in content.split(",") if n.strip()} |
| new_names = [r["name"].removesuffix(".py") for r in failed_results] |
| all_names = sorted(existing.union(new_names)) |
| compact_failures_file.parent.mkdir(parents=True, exist_ok=True) |
| compact_failures_file.write_text(", ".join(all_names) + "\n") |
| |
| slowest = sorted( |
| [r for r in results if r["status"] != "dry_run"], |
| key=lambda x: -x["duration_seconds"] |
| )[:top_slowest] |
| |
| if slowest: |
| print(f"\n SLOWEST {top_slowest} TEST RUNS:") |
| print(f" {'Test name':<60} {'Status':<8} {'Duration':>10}") |
| print(" " + "-" * 82) |
| for r in slowest: |
| mark = "✓" if r["status"] == "passed" else "✗" |
| print(f" {mark + ' ' + r['name']:<60} {r['status']:<8} {r['duration_seconds']:>9.2f}s") |
| |
| print(sep) |
| |
| |
| if __name__ == "__main__": |
| coloredlogs.install(level="INFO") |
| main(auto_envvar_prefix="CHIP") |