Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """Test runner + coverage matrix logger for Marionette. | |
| Auto-detects OS, prompts for robot model, runs unit and E2E tests, | |
| logs results to test_results.json, exports static/test_coverage.json, | |
| and prints the coverage matrix with a test catalog summary. | |
| Usage: | |
| cd marionette | |
| python tests/run_tests.py # unit + E2E | |
| python tests/run_tests.py --hardware # also run hardware tests (local) | |
| python tests/run_tests.py --on-robot # run hardware tests on robot via SSH | |
| """ | |
| import argparse | |
| import json | |
| import platform | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| RESULTS_FILE = Path(__file__).parent / "test_results.json" | |
| TESTS_DIR = Path(__file__).parent | |
| MARIONETTE_DIR = TESTS_DIR.parent | |
| STATIC_DIR = MARIONETTE_DIR / "marionette" / "static" | |
| WEB_COVERAGE_FILE = STATIC_DIR / "test_coverage.json" | |
| ROBOT_MODELS = ["None", "Lite", "Wireless"] | |
| BROWSERS = ["chromium", "firefox", "webkit"] | |
| # Human-readable descriptions for each test class. | |
| # Keep in sync with the actual test classes in test_api.py, test_ui.py, | |
| # and test_hardware.py. | |
| TEST_CLASS_DESCRIPTIONS: dict[str, tuple[str, str]] = { | |
| # (tier, description) | |
| "TestSlugify": ("unit", "Move label slug generation"), | |
| "TestStateEndpoint": ("unit", "GET /api/state shape and initial values"), | |
| "TestStartingUpMode": ("unit", "Starting-up mode rejects commands"), | |
| "TestRecordEndpoint": ("unit", "POST /api/record validation and state transitions"), | |
| "TestPlayEndpoint": ("unit", "POST /api/play validation"), | |
| "TestStopEndpoints": ("unit", "POST /api/play/stop and /api/record/stop"), | |
| "TestMoveDelete": ("unit", "DELETE /api/moves/:id and file cleanup"), | |
| "TestDatasets": ("unit", "Dataset create, select, root change, origin"), | |
| "TestRegistryPersistence": ("unit", "Registry file survival across restarts"), | |
| "TestMovesRefresh": ("unit", "Move listing and metadata"), | |
| "TestExperiments": ("unit", "Feature toggles and experimental settings"), | |
| "TestHfAutoLogin": ("unit", "HF auto-login detection and caching"), | |
| "TestSensorData": ("unit", "Sensor data dummy endpoint"), | |
| "TestUploadAudio": ("unit", "POST /api/upload-audio validation and integration"), | |
| "TestMotionModelEndpoint": ("unit", "POST /api/motion-model enable, set, persist"), | |
| "TestCommunityDatasets": ("unit", "Community dataset listing and download validation"), | |
| "TestCorruptData": ("unit", "Malformed JSON and corrupt registry recovery"), | |
| "TestConcurrentStateChanges": ("unit", "Concurrent operations rejected when busy"), | |
| "TestDurationEdgeCases": ("unit", "Duration boundary validation (gt=0.5, le=300)"), | |
| "TestSyncDatasetExtended": ("unit", "Sync endpoint edge cases and validation"), | |
| "TestMicAgcConfig": ("unit", "Mic AGC disable/restore with mock USB device"), | |
| "TestApiContracts": ("unit", "API response shapes (refactoring protection)"), | |
| # E2E | |
| "TestPageLoad": ("e2e", "Page loads and main sections visible"), | |
| "TestIdleState": ("e2e", "Idle state display and controls"), | |
| "TestSimplifiedForm": ("e2e", "Simplified form layout"), | |
| "TestFormValidation": ("e2e", "Form fields and HTML5 validation"), | |
| "TestRecordingSubmission": ("e2e", "Recording submission changes mode"), | |
| "TestDatasetUI": ("e2e", "Dataset UI elements visible"), | |
| "TestRecordingLifecycle": ("e2e", "Recording submit, stop, and injected move visibility"), | |
| "TestPlaybackLifecycle": ("e2e", "Play button, queued playback, and move metadata"), | |
| "TestDeleteMove": ("e2e", "Delete button, confirm/cancel dialog handling"), | |
| "TestCreateDataset": ("e2e", "New dataset form show/hide/create/validate"), | |
| "TestSwitchDataset": ("e2e", "Dataset switching and dropdown population"), | |
| "TestAudioSourceRadios": ("e2e", "Audio source radio buttons and upload area toggle"), | |
| "TestSettingsPanel": ("e2e", "Settings expand, experimental toggle, root display"), | |
| "TestFormEdgeCases": ("e2e", "Empty/long/special labels and localStorage persistence"), | |
| "TestCommunitySection": ("e2e", "Community datasets section expand and fetch button"), | |
| "TestHfUploadSection": ("e2e", "HF username field and sync button presence"), | |
| # Hardware | |
| "TestHardwareStartup": ("hardware", "Robot reaches idle after startup"), | |
| "TestHardwareRecording": ("hardware", "Record and verify motion capture"), | |
| "TestHardwarePlayback": ("hardware", "Playback and delete (silent)"), | |
| "TestFullPipeline": ("hardware", "Full record β verify files β replay β delete lifecycle"), | |
| "TestMotionAccuracy": ("hardware", "Synthetic playback accuracy β reference vs observed poses"), | |
| "TestMultiDuration": ("hardware", "Recording and playback across 1s/3s/5s/10s durations"), | |
| "TestPerformance": ("hardware", "Startup, recording, and playback latency benchmarks"), | |
| "TestHardwareAudio": ("hardware", "Audio recording and playback (may skip on mic issues)"), | |
| "TestExistingRecordingPlayback": ("hardware", "Play back existing audio and silent recordings"), | |
| "TestRecordingRoundTrip": ("hardware", "Record β playback fidelity and timing verification"), | |
| "TestAntennaAndBodyYaw": ("hardware", "Verify antenna and body_yaw data in recordings"), | |
| "TestPlaybackAntennas": ("hardware", "Synthetic antenna oscillation playback"), | |
| "TestStopDuringPlayback": ("hardware", "Stop mid-playback and during goto-start-pose"), | |
| "TestStopDuringRecording": ("hardware", "Stop mid-recording, verify partial save"), | |
| "TestMultipleRecordPlayCycles": ("hardware", "5 back-to-back record/play cycles"), | |
| "TestPlaybackWithCorruptFile": ("hardware", "Deleted/corrupt JSON during playback"), | |
| } | |
| def detect_os() -> str: | |
| system = platform.system() | |
| if system == "Linux": | |
| return "Linux" | |
| elif system == "Darwin": | |
| return "macOS" | |
| elif system == "Windows": | |
| return "Windows" | |
| return system | |
| def prompt_robot_model() -> str: | |
| print("\nWhich robot model is connected?") | |
| for i, model in enumerate(ROBOT_MODELS): | |
| print(f" {i}) {model}") | |
| while True: | |
| choice = input(f"Enter 0-{len(ROBOT_MODELS) - 1} [0]: ").strip() | |
| if choice == "": | |
| return ROBOT_MODELS[0] | |
| try: | |
| idx = int(choice) | |
| if 0 <= idx < len(ROBOT_MODELS): | |
| return ROBOT_MODELS[idx] | |
| except ValueError: | |
| pass | |
| print("Invalid choice, try again.") | |
| def detect_browsers() -> list[str]: | |
| """Return list of browsers that Playwright has installed.""" | |
| available = [] | |
| for browser in BROWSERS: | |
| result = subprocess.run( | |
| [ | |
| sys.executable, "-c", | |
| f"from playwright.sync_api import sync_playwright; " | |
| f"p = sync_playwright().start(); " | |
| f"b = p.{browser}.launch(); b.close(); p.stop()", | |
| ], | |
| capture_output=True, | |
| timeout=30, | |
| ) | |
| if result.returncode == 0: | |
| available.append(browser) | |
| return available | |
| def run_pytest(args: list[str], label: str) -> dict: | |
| """Run pytest with verbose output and JSON report, return result summary.""" | |
| with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: | |
| json_path = f.name | |
| cmd = [ | |
| sys.executable, "-m", "pytest", | |
| "--json-report", f"--json-report-file={json_path}", | |
| "-v", | |
| *args, | |
| ] | |
| print(f"\n{'β' * 60}") | |
| print(f"Running: {label}") | |
| print(f"Command: {' '.join(cmd)}") | |
| print(f"{'β' * 60}") | |
| result = subprocess.run(cmd, cwd=str(MARIONETTE_DIR)) | |
| # Parse JSON report | |
| report_path = Path(json_path) | |
| report = None | |
| try: | |
| report = json.loads(report_path.read_text()) | |
| summary = report.get("summary", {}) | |
| passed = summary.get("passed", 0) | |
| failed = summary.get("failed", 0) | |
| errors = summary.get("error", 0) | |
| total = summary.get("total", 0) | |
| except (json.JSONDecodeError, FileNotFoundError): | |
| passed = 0 | |
| failed = 0 | |
| errors = 1 | |
| total = 0 | |
| finally: | |
| report_path.unlink(missing_ok=True) | |
| status = "pass" if (failed == 0 and errors == 0 and total > 0) else "fail" | |
| return { | |
| "passed": passed, | |
| "failed": failed, | |
| "errors": errors, | |
| "total": total, | |
| "status": status, | |
| "returncode": result.returncode, | |
| "report": report, | |
| } | |
| def extract_class_counts(report: dict | None) -> dict[str, int]: | |
| """Extract per-class test counts from a pytest-json-report report.""" | |
| counts: dict[str, int] = {} | |
| if not report: | |
| return counts | |
| for test in report.get("tests", []): | |
| node_id = test.get("nodeid", "") | |
| # nodeid looks like "tests/test_api.py::TestSlugify::test_simple_lowercase" | |
| parts = node_id.split("::") | |
| if len(parts) >= 2: | |
| class_name = parts[1] | |
| counts[class_name] = counts.get(class_name, 0) + 1 | |
| return counts | |
| def check_json_report_plugin() -> bool: | |
| """Check if pytest-json-report is available.""" | |
| result = subprocess.run( | |
| [sys.executable, "-m", "pytest", "--co", "--json-report", "-q"], | |
| capture_output=True, | |
| cwd=str(MARIONETTE_DIR), | |
| ) | |
| return result.returncode == 0 | |
| def load_results() -> list[dict]: | |
| if RESULTS_FILE.exists(): | |
| return json.loads(RESULTS_FILE.read_text()) | |
| return [] | |
| def save_results(results: list[dict]) -> None: | |
| RESULTS_FILE.write_text(json.dumps(results, indent=2) + "\n") | |
| def result_fields(result: dict) -> dict: | |
| """Extract storable fields from a run result (excludes the full report).""" | |
| return { | |
| "passed": result["passed"], | |
| "failed": result["failed"], | |
| "errors": result["errors"], | |
| "total": result["total"], | |
| "status": result["status"], | |
| "returncode": result["returncode"], | |
| } | |
| def build_test_catalog(all_class_counts: dict[str, int]) -> list[dict]: | |
| """Build a test catalog from observed class counts + known descriptions.""" | |
| catalog = [] | |
| for class_name, count in sorted(all_class_counts.items()): | |
| tier, description = TEST_CLASS_DESCRIPTIONS.get( | |
| class_name, ("unknown", class_name) | |
| ) | |
| catalog.append({ | |
| "class": class_name, | |
| "count": count, | |
| "tier": tier, | |
| "description": description, | |
| }) | |
| return catalog | |
| def print_test_catalog(catalog: list[dict]) -> None: | |
| """Print a grouped test catalog summary.""" | |
| if not catalog: | |
| return | |
| print("Test Catalog\n") | |
| # Group by tier | |
| tiers: dict[str, list[dict]] = {} | |
| for entry in catalog: | |
| tiers.setdefault(entry["tier"], []).append(entry) | |
| tier_order = ["unit", "e2e", "hardware", "unknown"] | |
| tier_labels = { | |
| "unit": "Unit Tests", | |
| "e2e": "E2E Browser Tests", | |
| "hardware": "Hardware Tests", | |
| "unknown": "Other", | |
| } | |
| for tier in tier_order: | |
| entries = tiers.get(tier) | |
| if not entries: | |
| continue | |
| total = sum(e["count"] for e in entries) | |
| label = tier_labels.get(tier, tier) | |
| print(f" {label} β {total} tests") | |
| for e in entries: | |
| print(f" {e['class']} ({e['count']}){' ' * max(1, 28 - len(e['class']) - len(str(e['count'])))} {e['description']}") | |
| print() | |
| def export_web_coverage( | |
| results: list[dict], | |
| catalog: list[dict], | |
| ) -> None: | |
| """Write static/test_coverage.json for the web UI.""" | |
| # Build matrix entries (latest result per combination) | |
| latest: dict[tuple, dict] = {} | |
| for r in results: | |
| key = (r["os"], r["robot"], r["suite"], r.get("browser")) | |
| existing = latest.get(key) | |
| if existing is None or r["timestamp"] >= existing["timestamp"]: | |
| latest[key] = r | |
| matrix = [] | |
| for key, r in sorted(latest.items()): | |
| dt = datetime.fromisoformat(r["timestamp"]) | |
| matrix.append({ | |
| "os": r["os"], | |
| "robot": r["robot"], | |
| "suite": r["suite"], | |
| "browser": r.get("browser"), | |
| "status": r["status"], | |
| "passed": r["passed"], | |
| "failed": r.get("failed", 0), | |
| "total": r["total"], | |
| "date": dt.strftime("%b %d"), | |
| }) | |
| payload = { | |
| "generated_at": datetime.now(timezone.utc).isoformat(), | |
| "matrix": matrix, | |
| "test_catalog": catalog, | |
| } | |
| STATIC_DIR.mkdir(parents=True, exist_ok=True) | |
| WEB_COVERAGE_FILE.write_text(json.dumps(payload, indent=2) + "\n") | |
| print(f"Web coverage exported to {WEB_COVERAGE_FILE}") | |
| def _print_matrix(results: list[dict]) -> None: | |
| """Print coverage matrix from results.""" | |
| os_list = ["Linux", "macOS", "Windows"] | |
| robot_list = ["None", "Lite", "Wireless"] | |
| suites = [ | |
| ("unit", None, "Unit Tests"), | |
| ("e2e", "chromium", "Chromium"), | |
| ("e2e", "firefox", "Firefox"), | |
| ("e2e", "webkit", "WebKit"), | |
| ("hardware", None, "Hardware"), | |
| ] | |
| # Build lookup: (os, robot, suite, browser) -> latest result | |
| latest: dict[tuple, dict] = {} | |
| for r in results: | |
| key = (r["os"], r["robot"], r["suite"], r.get("browser")) | |
| existing = latest.get(key) | |
| if existing is None or r["timestamp"] >= existing["timestamp"]: | |
| latest[key] = r | |
| # Only show columns that have at least one result | |
| active_suites = [ | |
| s for s in suites | |
| if any( | |
| latest.get((os_name, robot, s[0], s[1])) | |
| for os_name in os_list | |
| for robot in robot_list | |
| ) | |
| ] | |
| if not active_suites: | |
| active_suites = suites[:4] # Show defaults even if empty | |
| col_w = 11 | |
| label_w = 13 | |
| print("Coverage Matrix (latest result per combination)\n") | |
| header = f"{'':>{label_w}} \u2502 " + " \u2502 ".join( | |
| f"{s[2]:^{col_w}}" for s in active_suites | |
| ) | |
| print(header) | |
| sep = "\u2500" * label_w + "\u2500\u253c\u2500" + "\u2500\u253c\u2500".join( | |
| "\u2500" * (col_w + 1) for _ in active_suites | |
| ) | |
| print(sep) | |
| for os_name in os_list: | |
| for robot in robot_list: | |
| combo = f"{os_name}/{robot[:4] + '.' if len(robot) > 4 else robot}" | |
| cells = [] | |
| for suite, browser, _ in active_suites: | |
| key = (os_name, robot, suite, browser) | |
| r = latest.get(key) | |
| if r is None: | |
| cells.append(f"{'β':^{col_w}}") | |
| else: | |
| dt = datetime.fromisoformat(r["timestamp"]) | |
| date_str = dt.strftime("%b %d") | |
| icon = "\u2705" if r["status"] == "pass" else "\u274c" | |
| cells.append(f"{icon + ' ' + date_str:^{col_w}}") | |
| row = f"{combo:>{label_w}} \u2502 " + " \u2502 ".join(cells) | |
| print(row) | |
| print() | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description="Run Marionette tests and log results.") | |
| parser.add_argument( | |
| "--hardware", action="store_true", | |
| help="Also run hardware integration tests (requires connected robot)", | |
| ) | |
| parser.add_argument( | |
| "--on-robot", action="store_true", | |
| help="Run hardware tests on the robot via SSH (delegates to run_on_robot.py)", | |
| ) | |
| parser.add_argument( | |
| "--host", default="reachy-mini.local", | |
| help="Robot hostname for --on-robot (default: reachy-mini.local)", | |
| ) | |
| parser.add_argument( | |
| "--user", default="pollen", | |
| help="SSH user for --on-robot (default: pollen)", | |
| ) | |
| args = parser.parse_args() | |
| # Delegate to run_on_robot.py if --on-robot | |
| if args.on_robot: | |
| run_on_robot = TESTS_DIR / "run_on_robot.py" | |
| cmd = [sys.executable, str(run_on_robot), "--host", args.host, "--user", args.user] | |
| sys.exit(subprocess.run(cmd).returncode) | |
| os_name = detect_os() | |
| print(f"Detected OS: {os_name}") | |
| robot = prompt_robot_model() | |
| print(f"Robot model: {robot}") | |
| # Check for pytest-json-report | |
| if not check_json_report_plugin(): | |
| print("\npytest-json-report is required but not installed.") | |
| print("Install it with: pip install pytest-json-report") | |
| sys.exit(1) | |
| now = datetime.now(timezone.utc).isoformat() | |
| combo = f"{os_name}/{robot}" | |
| all_results = load_results() | |
| runs: list[dict] = [] | |
| all_class_counts: dict[str, int] = {} | |
| # 1. Unit tests | |
| print(f"\n{'β' * 60}") | |
| print("UNIT TESTS") | |
| print(f"{'β' * 60}") | |
| unit = run_pytest(["tests/test_api.py"], "Unit tests") | |
| entry = { | |
| "timestamp": now, | |
| "os": os_name, | |
| "robot": robot, | |
| "suite": "unit", | |
| "browser": None, | |
| **result_fields(unit), | |
| } | |
| all_results.append(entry) | |
| runs.append(entry) | |
| all_class_counts.update(extract_class_counts(unit.get("report"))) | |
| # 2. E2E tests per browser | |
| print(f"\n{'β' * 60}") | |
| print("E2E BROWSER TESTS") | |
| print(f"{'β' * 60}") | |
| available = detect_browsers() | |
| if not available: | |
| print("\nNo Playwright browsers found. Skipping E2E tests.") | |
| print("Install browsers with: playwright install --with-deps chromium firefox") | |
| else: | |
| print(f"Available browsers: {', '.join(available)}") | |
| for browser in available: | |
| e2e = run_pytest( | |
| ["tests/e2e", "--browser", browser], | |
| f"E2E β {browser}", | |
| ) | |
| entry = { | |
| "timestamp": now, | |
| "os": os_name, | |
| "robot": robot, | |
| "suite": "e2e", | |
| "browser": browser, | |
| **result_fields(e2e), | |
| } | |
| all_results.append(entry) | |
| runs.append(entry) | |
| # E2E class counts only need to be captured once (same tests across browsers) | |
| if not any(k.startswith("TestPage") for k in all_class_counts): | |
| all_class_counts.update(extract_class_counts(e2e.get("report"))) | |
| # 3. Hardware tests (optional) | |
| if args.hardware: | |
| hw_test_file = TESTS_DIR / "test_hardware.py" | |
| if hw_test_file.exists(): | |
| print(f"\n{'β' * 60}") | |
| print("HARDWARE TESTS") | |
| print(f"{'β' * 60}") | |
| hw = run_pytest( | |
| ["tests/test_hardware.py", "-m", "hardware"], | |
| "Hardware integration tests", | |
| ) | |
| entry = { | |
| "timestamp": now, | |
| "os": os_name, | |
| "robot": robot, | |
| "suite": "hardware", | |
| "browser": None, | |
| **result_fields(hw), | |
| } | |
| all_results.append(entry) | |
| runs.append(entry) | |
| all_class_counts.update(extract_class_counts(hw.get("report"))) | |
| else: | |
| print("\nNo hardware test file found. Skipping.") | |
| # Save results | |
| save_results(all_results) | |
| print(f"\nResults saved to {RESULTS_FILE}") | |
| # Print run summary | |
| print(f"\n{'β' * 60}") | |
| print(f"SUMMARY β {combo}") | |
| print(f"{'β' * 60}") | |
| for r in runs: | |
| suite_label = r["suite"] | |
| if r.get("browser"): | |
| suite_label += f" ({r['browser']})" | |
| icon = "\u2705" if r["status"] == "pass" else "\u274c" | |
| print(f" {icon} {suite_label}: {r['passed']}/{r['total']} passed") | |
| # Print test catalog | |
| catalog = build_test_catalog(all_class_counts) | |
| print() | |
| print_test_catalog(catalog) | |
| # Print matrix | |
| _print_matrix(all_results) | |
| # Export web coverage | |
| export_web_coverage(all_results, catalog) | |
| if __name__ == "__main__": | |
| main() | |