#!/usr/bin/env python3 """ Automated tests for Ctrl+C and Ctrl+D using the cmux socket interface. Usage: python3 test_ctrl_socket.py Requirements: - cmux must be running with the socket controller enabled """ import json import os import sys import time import tempfile from pathlib import Path # Add the directory containing cmux.py to the path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from cmux import cmux, cmuxError class TestResult: def __init__(self, name: str): self.name = name self.passed = False self.message = "" def success(self, msg: str = ""): self.passed = True self.message = msg def failure(self, msg: str): self.passed = False self.message = msg def test_connection(client: cmux) -> TestResult: """Test that we can connect and ping the server""" result = TestResult("Connection") try: if client.ping(): result.success("Connected and received PONG") else: result.failure("Ping failed") except Exception as e: result.failure(str(e)) return result def test_ctrl_c(client: cmux) -> TestResult: """ Test Ctrl+C by: 1. Starting sleep command 2. Sending Ctrl+C 3. Verifying shell responds to next command """ result = TestResult("Ctrl+C (SIGINT)") marker = Path(tempfile.gettempdir()) / f"ghostty_ctrlc_{os.getpid()}" try: marker.unlink(missing_ok=True) # Start a long sleep client.send("sleep 30\n") time.sleep(0.8) # Send Ctrl+C to interrupt client.send_ctrl_c() time.sleep(0.8) # If Ctrl+C worked, shell should accept new command for attempt in range(3): client.send(f"touch {marker}\n") for _ in range(10): if marker.exists(): break time.sleep(0.2) if marker.exists(): break # try another Ctrl+C in case the process swallowed the signal client.send_ctrl_c() time.sleep(0.6) if marker.exists(): result.success("Ctrl+C interrupted sleep, shell responsive") marker.unlink(missing_ok=True) else: result.failure("Shell not responsive after Ctrl+C") except Exception as e: result.failure(f"Exception: {e}") marker.unlink(missing_ok=True) return result def test_ctrl_d(client: cmux) -> TestResult: """ Test Ctrl+D by: 1. Running cat command 2. Sending Ctrl+D 3. Verifying cat exits and next command runs """ result = TestResult("Ctrl+D (EOF)") marker = Path(tempfile.gettempdir()) / f"ghostty_ctrld_{os.getpid()}" try: marker.unlink(missing_ok=True) # Run cat (waits for input) client.send("cat\n") time.sleep(0.6) # Send Ctrl+D (EOF) client.send_ctrl_d() time.sleep(0.4) # If Ctrl+D worked, cat should exit and we can run another command client.send(f"touch {marker}\n") for _ in range(10): if marker.exists(): break time.sleep(0.2) if marker.exists(): result.success("Ctrl+D sent EOF, cat exited") marker.unlink(missing_ok=True) else: result.failure("cat did not exit after Ctrl+D") except Exception as e: result.failure(f"Exception: {e}") marker.unlink(missing_ok=True) return result def test_ctrl_c_python(client: cmux) -> TestResult: """ Test Ctrl+C with Python process """ result = TestResult("Ctrl+C in Python") marker = Path(tempfile.gettempdir()) / f"ghostty_pyctrlc_{os.getpid()}" try: marker.unlink(missing_ok=True) # Start Python that loops forever client.send("python3 -c 'import time; [time.sleep(1) for _ in iter(int, 1)]'\n") time.sleep(1.2) # Give Python time to start # Send Ctrl+C client.send_ctrl_c() time.sleep(0.6) # If Ctrl+C worked, shell should accept new command client.send(f"touch {marker}\n") for _ in range(10): if marker.exists(): break time.sleep(0.2) if marker.exists(): result.success("Ctrl+C interrupted Python process") marker.unlink(missing_ok=True) else: result.failure("Python not interrupted by Ctrl+C") except Exception as e: result.failure(f"Exception: {type(e).__name__}: {e}") marker.unlink(missing_ok=True) return result def test_environment_paths(client: cmux) -> TestResult: """ Verify that TERMINFO points to a real terminfo directory and that XDG_DATA_DIRS includes the app resources path (and defaults when unset). """ result = TestResult("Environment Paths") env_path = Path(tempfile.gettempdir()) / f"cmux_env_{os.getpid()}.json" env_path.unlink(missing_ok=True) try: command = ( "python3 -c 'import json,os;" f"open(\"{env_path}\",\"w\").write(" "json.dumps({" "\"TERMINFO\": os.environ.get(\"TERMINFO\", \"\")," "\"XDG_DATA_DIRS\": os.environ.get(\"XDG_DATA_DIRS\", \"\")," "}))'" ) for attempt in range(3): env_path.unlink(missing_ok=True) # Reset any partial prompt state (e.g., unmatched quotes) before retrying. client.send_ctrl_c() time.sleep(0.2) client.send(command + "\n") for _ in range(20): if env_path.exists(): break time.sleep(0.2) if env_path.exists(): break # Small backoff before retrying send in case the surface isn't ready yet. time.sleep(0.3 * (attempt + 1)) if not env_path.exists(): result.failure("Env dump file was not created") return result data = json.loads(env_path.read_text()) terminfo = data.get("TERMINFO", "") xdg_data_dirs = data.get("XDG_DATA_DIRS", "") if not terminfo: result.failure("TERMINFO is empty") return result terminfo_path = Path(terminfo) if not terminfo_path.exists(): result.failure(f"TERMINFO path does not exist: {terminfo}") return result xterm_entry = terminfo_path / "78" / "xterm-ghostty" if not xterm_entry.exists(): result.failure(f"Missing terminfo entry: {xterm_entry}") return result if not xdg_data_dirs: result.failure("XDG_DATA_DIRS is empty") return result xdg_entries = xdg_data_dirs.split(":") resources_dir = terminfo_path.parent if resources_dir.as_posix() not in xdg_entries: result.failure(f"XDG_DATA_DIRS missing resources path: {resources_dir}") return result if not os.environ.get("XDG_DATA_DIRS"): if "/usr/local/share" not in xdg_entries or "/usr/share" not in xdg_entries: result.failure( "XDG_DATA_DIRS missing standard defaults (/usr/local/share:/usr/share)" ) return result result.success("TERMINFO and XDG_DATA_DIRS paths look correct") env_path.unlink(missing_ok=True) return result except Exception as e: env_path.unlink(missing_ok=True) result.failure(f"Exception: {type(e).__name__}: {e}") return result def run_tests(): """Run all tests""" print("=" * 60) print("cmux Ctrl+C/D Automated Tests") print("=" * 60) print() socket_path = cmux().socket_path if not os.path.exists(socket_path): print(f"Error: Socket not found at {socket_path}") print("Please make sure cmux is running.") print("Tip: set CMUX_TAG= or CMUX_SOCKET_PATH= to target a tagged instance.") return 1 results = [] try: with cmux() as client: # Test connection print("Testing connection...") results.append(test_connection(client)) status = "✅" if results[-1].passed else "❌" print(f" {status} {results[-1].message}") print() if not results[-1].passed: return 1 # Test Ctrl+C print("Testing Ctrl+C (SIGINT)...") results.append(test_ctrl_c(client)) status = "✅" if results[-1].passed else "❌" print(f" {status} {results[-1].message}") print() time.sleep(0.5) # Test Ctrl+D print("Testing Ctrl+D (EOF)...") results.append(test_ctrl_d(client)) status = "✅" if results[-1].passed else "❌" print(f" {status} {results[-1].message}") print() time.sleep(0.5) # Test Ctrl+C in Python print("Testing Ctrl+C in Python process...") results.append(test_ctrl_c_python(client)) status = "✅" if results[-1].passed else "❌" print(f" {status} {results[-1].message}") print() time.sleep(0.5) # Test environment paths print("Testing TERMINFO/XDG_DATA_DIRS paths...") results.append(test_environment_paths(client)) status = "✅" if results[-1].passed else "❌" print(f" {status} {results[-1].message}") print() except cmuxError as e: print(f"Error: {e}") return 1 # Summary print("=" * 60) print("Test Results Summary") print("=" * 60) passed = sum(1 for r in results if r.passed) total = len(results) for r in results: status = "✅ PASS" if r.passed else "❌ FAIL" print(f" {r.name}: {status}") if not r.passed and r.message: print(f" {r.message}") print() print(f"Passed: {passed}/{total}") if passed == total: print("\n🎉 All tests passed!") return 0 else: print(f"\n⚠️ {total - passed} test(s) failed") return 1 if __name__ == "__main__": sys.exit(run_tests())