#!/usr/bin/env python3 """ CPU usage tests for notification scenarios. Tests that CPU usage stays reasonable when: 1. Notifications arrive 2. Notifications popover is opened and closed 3. Multiple notifications arrive in sequence Usage: python3 tests/test_cpu_notifications.py Requires cmux to be running with socket control enabled. """ from __future__ import annotations import subprocess import sys import time import os from typing import List, Optional sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from cmux import cmux, cmuxError # Maximum acceptable CPU usage during idle (after notifications) MAX_IDLE_CPU_PERCENT = 20.0 # Maximum acceptable CPU usage right after notification burst MAX_POST_NOTIFICATION_CPU_PERCENT = 30.0 # How long to wait for app to settle (seconds) SETTLE_TIME = 2.0 # Duration to monitor CPU (seconds) MONITOR_DURATION = 3.0 def get_cmux_pid() -> Optional[int]: """Get the PID of the running cmux process.""" result = subprocess.run( ["pgrep", "-f", r"cmux\.app/Contents/MacOS/cmux$"], capture_output=True, text=True, ) if result.returncode != 0: # Try DEV build result = subprocess.run( ["pgrep", "-f", r"cmux DEV\.app/Contents/MacOS/cmux"], capture_output=True, text=True, ) if result.returncode != 0: return None pids = result.stdout.strip().split("\n") return int(pids[0]) if pids and pids[0] else None def get_cpu_usage(pid: int) -> float: """Get current CPU usage percentage for a process.""" result = subprocess.run( ["ps", "-p", str(pid), "-o", "%cpu="], capture_output=True, text=True, ) if result.returncode != 0: return 0.0 try: return float(result.stdout.strip()) except ValueError: return 0.0 def monitor_cpu(pid: int, duration: float, interval: float = 0.5) -> List[float]: """Monitor CPU usage over a period.""" readings = [] start = time.time() while time.time() - start < duration: readings.append(get_cpu_usage(pid)) time.sleep(interval) return readings def test_cpu_after_notification_burst(client: cmux, pid: int) -> tuple[bool, str]: """ Test that CPU returns to normal after a burst of notifications. """ # Clear any existing notifications try: client.clear_notifications() except cmuxError: pass time.sleep(0.5) # Send a burst of notifications for i in range(5): try: client.notify(f"Test notification {i+1}") except cmuxError: pass time.sleep(0.1) # Wait for processing time.sleep(1.0) # Monitor CPU readings = monitor_cpu(pid, MONITOR_DURATION) avg_cpu = sum(readings) / len(readings) if readings else 0 # Clean up try: client.clear_notifications() except cmuxError: pass if avg_cpu > MAX_POST_NOTIFICATION_CPU_PERCENT: return False, f"CPU {avg_cpu:.1f}% exceeds {MAX_POST_NOTIFICATION_CPU_PERCENT}% after notification burst" return True, f"CPU {avg_cpu:.1f}% is acceptable after notification burst" def test_cpu_after_popover_close(client: cmux, pid: int) -> tuple[bool, str]: """ Test that CPU returns to normal after opening and closing the notifications popover. This tests that the popover's SwiftUI view is properly cleaned up when closed. """ # Create some notifications first try: client.clear_notifications() except cmuxError: pass for i in range(3): try: client.notify(f"Popover test {i+1}") except cmuxError: pass time.sleep(0.1) time.sleep(0.5) # Toggle the popover via our debug socket shortcut simulator (doesn't require Accessibility). # Default: Cmd+Shift+I (Show Notifications). try: client.simulate_shortcut("cmd+shift+i") except Exception: # Keep this test best-effort; if shortcut simulation is unavailable, fall back to osascript. subprocess.run([ "osascript", "-e", 'tell application "System Events" to keystroke "i" using {command down, shift down}' ], capture_output=True) time.sleep(0.5) # Close it try: client.simulate_shortcut("cmd+shift+i") except Exception: subprocess.run([ "osascript", "-e", 'tell application "System Events" to keystroke "i" using {command down, shift down}' ], capture_output=True) time.sleep(1.0) # Monitor CPU - should be low now readings = monitor_cpu(pid, MONITOR_DURATION) avg_cpu = sum(readings) / len(readings) if readings else 0 # Clean up try: client.clear_notifications() except cmuxError: pass if avg_cpu > MAX_IDLE_CPU_PERCENT: return False, f"CPU {avg_cpu:.1f}% exceeds {MAX_IDLE_CPU_PERCENT}% after closing popover" return True, f"CPU {avg_cpu:.1f}% is acceptable after closing popover" def test_cpu_idle_with_notifications(client: cmux, pid: int) -> tuple[bool, str]: """ Test that CPU stays low when notifications exist but popover is closed. """ # Create notifications try: client.clear_notifications() except cmuxError: pass for i in range(3): try: client.notify(f"Idle test {i+1}") except cmuxError: pass time.sleep(0.2) # Wait for things to settle time.sleep(SETTLE_TIME) # Monitor CPU readings = monitor_cpu(pid, MONITOR_DURATION) avg_cpu = sum(readings) / len(readings) if readings else 0 # Clean up try: client.clear_notifications() except cmuxError: pass if avg_cpu > MAX_IDLE_CPU_PERCENT: return False, f"CPU {avg_cpu:.1f}% exceeds {MAX_IDLE_CPU_PERCENT}% with notifications pending" return True, f"CPU {avg_cpu:.1f}% is acceptable with notifications pending" def main(): print("=" * 60) print("cmux Notification CPU Tests") print("=" * 60) pid = get_cmux_pid() if pid is None: print("\n❌ SKIP: cmux is not running") return 0 print(f"\nFound cmux process: PID {pid}") # Try to connect to the socket socket_paths = ["/tmp/cmux.sock", "/tmp/cmux-debug.sock"] client = None for socket_path in socket_paths: if os.path.exists(socket_path): try: client = cmux(socket_path) client.connect() print(f"Connected to {socket_path}") break except cmuxError: continue if client is None: print(f"\n❌ SKIP: Could not connect to cmux socket") return 0 results = [] print("\nRunning tests...") # Test 1: CPU after notification burst print("\n[1/3] Testing CPU after notification burst...") passed, msg = test_cpu_after_notification_burst(client, pid) results.append(("CPU after notification burst", passed, msg)) print(f" {'✓' if passed else '✗'} {msg}") time.sleep(1) # Test 2: CPU after popover close print("\n[2/3] Testing CPU after popover open/close...") passed, msg = test_cpu_after_popover_close(client, pid) results.append(("CPU after popover close", passed, msg)) print(f" {'✓' if passed else '✗'} {msg}") time.sleep(1) # Test 3: CPU idle with pending notifications print("\n[3/3] Testing CPU idle with pending notifications...") passed, msg = test_cpu_idle_with_notifications(client, pid) results.append(("CPU idle with notifications", passed, msg)) print(f" {'✓' if passed else '✗'} {msg}") client.close() # Summary print("\n" + "=" * 60) print("Results:") all_passed = True for name, passed, msg in results: status = "PASS" if passed else "FAIL" print(f" {status}: {name}") if not passed: all_passed = False if all_passed: print("\n✅ All notification CPU tests passed!") return 0 else: print("\n❌ Some tests failed") return 1 if __name__ == "__main__": sys.exit(main())