Delete 53 CI-dead v1 socket e2e tests superseded by tests_v2 (#7681)

These tests/ files import the v1 socket client and are not referenced by
any workflow, script, or test. CI runs only three v1 socket e2e files
(test_cli_socket_autodiscovery, test_multi_workspace_focus,
test_workspace_churn_up_arrow_lag), which are kept. 28 of the deleted
files have an identically-named successor in tests_v2/; the rest were
runnable only via the manual cmux-vm run-tests-v1.sh glob.

Also updates CONTRIBUTING.md's VM test command to the tests_v2 paths
(it referenced tests/test_update_timing.py, which no longer existed) and
drops a deleted-file mention from docs/agent-browser-port-spec.md.
This commit is contained in:
Lawrence Chen
2026-07-09 15:01:03 -07:00
committed by GitHub
parent 15e57343fb
commit 530eb85c86
55 changed files with 2 additions and 14433 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ zig build -Demit-xcframework=true -Doptimize=ReleaseFast
### Basic tests (run on VM)
```bash
ssh cmux-vm 'cd /Users/cmux/cmux && xcodebuild -project cmux.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" build && pkill -x "cmux DEV" || true && APP=$(find /Users/cmux/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/cmux DEV.app" -print -quit) && open "$APP" && for i in {1..20}; do [ -S /tmp/cmux.sock ] && break; sleep 0.5; done && python3 tests/test_update_timing.py && python3 tests/test_signals_auto.py && python3 tests/test_ctrl_socket.py && python3 tests/test_notifications.py'
ssh cmux-vm 'cd /Users/cmux/cmux && xcodebuild -project cmux.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" build && pkill -x "cmux DEV" || true && APP=$(find /Users/cmux/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/cmux DEV.app" -print -quit) && open "$APP" && for i in {1..20}; do [ -S /tmp/cmux.sock ] && break; sleep 0.5; done && python3 tests_v2/test_update_timing.py && python3 tests_v2/test_signals_auto.py && python3 tests_v2/test_ctrl_socket.py && python3 tests_v2/test_notifications.py'
```
### UI tests (run on VM)
+1 -1
View File
@@ -19,7 +19,7 @@ As of February 12, 2026:
1. `./scripts/run-tests-v1.sh` passes on `cmux-vm`.
2. `./scripts/run-tests-v2.sh` passes on `cmux-vm`.
3. Browser parity suites passing in v2: `test_browser_api_comprehensive.py`, `test_browser_api_p0.py`, `test_browser_api_extended_families.py`, `test_browser_api_unsupported_matrix.py`, and `test_browser_cli_agent_port.py`.
4. Visual suite note: `tests/test_visual_screenshots.py` and `tests_v2/test_visual_screenshots.py` both report D12 (`Nested: Close Top of T-shape`) as a known non-blocking VM failure when it reproduces (`VIEW_DETACHED`).
4. Visual suite note: `tests_v2/test_visual_screenshots.py` reports D12 (`Nested: Close Top of T-shape`) as a known non-blocking VM failure when it reproduces (`VIEW_DETACHED`).
## Concepts (Canonical Terms)
-154
View File
@@ -1,154 +0,0 @@
#!/usr/bin/env python3
"""
Regression test for blank screen on macOS 26 (Tahoe).
Verifies that the terminal actually renders content by:
1. Reading the screen to check for a shell prompt (non-empty)
2. Sending a command and verifying it appears on screen
Usage:
python3 test_blank_screen.py
Requirements:
- cmux must be running with the socket controller enabled
"""
import os
import sys
import time
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_screen_not_blank(client: cmux) -> TestResult:
"""Test that the terminal has some visible content (shell prompt)."""
result = TestResult("Screen not blank")
try:
screen = client.read_screen()
if screen.startswith("ERROR:"):
result.failure(f"read_screen returned error: {screen}")
return result
stripped = screen.strip()
if not stripped:
result.failure("Screen is blank — no visible content")
else:
preview = stripped[:80].replace("\n", "\\n")
result.success(f"Screen has content: {preview}...")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_render_marker(client: cmux) -> TestResult:
"""Test that echoed text actually renders on screen."""
result = TestResult("Render marker")
marker = "RENDER_TEST_MARKER_12345"
try:
client.send(f"echo {marker}\n")
time.sleep(1.0)
screen = client.read_screen()
if screen.startswith("ERROR:"):
result.failure(f"read_screen returned error: {screen}")
return result
if marker in screen:
result.success(f"Marker '{marker}' found on screen")
else:
preview = screen.strip()[:200].replace("\n", "\\n")
result.failure(
f"Marker '{marker}' not found on screen. "
f"Screen content: {preview}"
)
except Exception as e:
result.failure(f"Exception: {e}")
return result
def run_tests():
print("=" * 60)
print("Blank Screen Regression Test")
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=<tag> or CMUX_SOCKET_PATH=<path> to target a tagged instance.")
return 1
results = []
try:
with cmux() as client:
print("Testing connection...")
if not client.ping():
print(" FAIL: Ping failed")
return 1
print(" PASS: Connected")
print()
print("Testing screen is not blank...")
results.append(test_screen_not_blank(client))
status = "PASS" if results[-1].passed else "FAIL"
print(f" {status}: {results[-1].message}")
print()
time.sleep(0.5)
print("Testing render marker...")
results.append(test_render_marker(client))
status = "PASS" if results[-1].passed else "FAIL"
print(f" {status}: {results[-1].message}")
print()
except cmuxError as e:
print(f"Error: {e}")
return 1
print("=" * 60)
print("Results")
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("\nAll tests passed!")
return 0
else:
print(f"\n{total - passed} test(s) failed")
return 1
if __name__ == "__main__":
sys.exit(run_tests())
@@ -1,238 +0,0 @@
#!/usr/bin/env python3
"""
Regression test: drag-routing policy must keep drag/drop features isolated.
This test is socket-only (no System Events / Accessibility permissions required).
It validates:
1) FileDropOverlayView hit-test and drag-destination gates
2) Terminal portal pass-through policy for Bonsplit/sidebar drags
3) Sidebar outside-drop overlay gate
4) Mixed payload behavior (fileURL + tabtransfer/sidebar)
5) Hit-test routing reaches pane-local Bonsplit drop targets (not a root overlay)
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
DRAG_EVENTS = [
"leftMouseDragged",
"rightMouseDragged",
"otherMouseDragged",
]
PORTAL_PASS_THROUGH_EVENTS = DRAG_EVENTS + [
# Keep portal pass-through strictly scoped to active drag-motion events.
]
NON_DRAG_EVENTS = [
"mouseMoved",
"mouseEntered",
"mouseExited",
"flagsChanged",
"cursorUpdate",
"appKitDefined",
"systemDefined",
"applicationDefined",
"periodic",
"leftMouseDown",
"leftMouseUp",
"rightMouseDown",
"rightMouseUp",
"otherMouseDown",
"otherMouseUp",
"scrollWheel",
]
def wait_for_overlay_probe_ready(client: cmux, timeout_s: float = 8.0) -> None:
start = time.time()
last_error = None
while time.time() - start < timeout_s:
try:
_ = client.overlay_hit_gate("none")
_ = client.overlay_drop_gate("external")
_ = client.overlay_drop_gate("local")
return
except Exception as e:
last_error = e
time.sleep(0.1)
raise cmuxError(f"overlay_hit_gate probe unavailable: {last_error}")
def assert_gate(client: cmux, event_type: str, expected: bool, reason: str) -> None:
got = client.overlay_hit_gate(event_type)
if got != expected:
raise cmuxError(
f"overlay_hit_gate({event_type}) expected {expected} got {got} ({reason})"
)
def assert_drop_gate(client: cmux, source: str, expected: bool, reason: str) -> None:
got = client.overlay_drop_gate(source)
if got != expected:
raise cmuxError(
f"overlay_drop_gate({source}) expected {expected} got {got} ({reason})"
)
def assert_portal_gate(client: cmux, event_type: str, expected: bool, reason: str) -> None:
got = client.portal_hit_gate(event_type)
if got != expected:
raise cmuxError(
f"portal_hit_gate({event_type}) expected {expected} got {got} ({reason})"
)
def assert_sidebar_gate(client: cmux, state: str, expected: bool, reason: str) -> None:
got = client.sidebar_overlay_gate(state)
if got != expected:
raise cmuxError(
f"sidebar_overlay_gate({state}) expected {expected} got {got} ({reason})"
)
def assert_hit_chain_routes_to_pane(
client: cmux,
x: float = 0.75,
y: float = 0.50,
reason: str = "",
) -> None:
chain = client.drag_hit_chain(x, y)
if chain == "none":
raise cmuxError(
f"drag_hit_chain({x},{y}) returned none ({reason})"
)
# This probe is intended to catch root-level overlay capture regressions.
# Depending on current AppKit event context, drag hit-testing can resolve
# through either pane-local SwiftUI wrappers or portal-hosted terminal views.
if "FileDropOverlayView" in chain:
raise cmuxError(
f"drag_hit_chain({x},{y}) unexpectedly captured by FileDropOverlayView ({reason}); chain={chain}"
)
def main() -> int:
socket_path = cmux.default_socket_path()
if not os.path.exists(socket_path):
print(f"SKIP: Socket not found at {socket_path}")
print("Tip: start cmux first (or set CMUX_TAG / CMUX_SOCKET_PATH).")
return 0
with cmux(socket_path) as client:
ws_id = None
try:
client.activate_app()
time.sleep(0.2)
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.4)
wait_for_overlay_probe_ready(client)
client.clear_drag_pasteboard()
for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]:
assert_gate(client, event, expected=False, reason="empty drag pasteboard")
assert_drop_gate(client, "external", expected=False, reason="empty pasteboard")
assert_drop_gate(client, "local", expected=False, reason="empty pasteboard")
for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]:
assert_portal_gate(client, event, expected=False, reason="empty drag pasteboard")
assert_sidebar_gate(client, "active", expected=False, reason="empty pasteboard")
assert_sidebar_gate(client, "inactive", expected=False, reason="empty pasteboard")
client.seed_drag_pasteboard_tabtransfer()
assert_hit_chain_routes_to_pane(
client,
reason="tabtransfer drag must route into pane-local Bonsplit drop host",
)
for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]:
assert_gate(client, event, expected=False, reason="tabtransfer drag must pass through")
assert_drop_gate(client, "external", expected=False, reason="tabtransfer drag must pass through")
assert_drop_gate(client, "local", expected=False, reason="tabtransfer drag must pass through")
for event in PORTAL_PASS_THROUGH_EVENTS:
assert_portal_gate(client, event, expected=True, reason="tabtransfer should pass through terminal portal")
for event in NON_DRAG_EVENTS + ["none"]:
assert_portal_gate(client, event, expected=False, reason="stale tabtransfer payload must not hijack non-drag portal events")
assert_sidebar_gate(client, "active", expected=False, reason="tabtransfer is not a sidebar drag payload")
assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state")
client.seed_drag_pasteboard_sidebar_reorder()
assert_hit_chain_routes_to_pane(
client,
reason="inactive sidebar reorder payload must not route to root outside-drop overlay",
)
for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]:
assert_gate(client, event, expected=False, reason="sidebar reorder drag must pass through")
assert_drop_gate(client, "external", expected=False, reason="sidebar reorder drag must pass through")
assert_drop_gate(client, "local", expected=False, reason="sidebar reorder drag must pass through")
for event in PORTAL_PASS_THROUGH_EVENTS:
assert_portal_gate(client, event, expected=True, reason="sidebar reorder should pass through terminal portal")
for event in NON_DRAG_EVENTS + ["none"]:
assert_portal_gate(client, event, expected=False, reason="stale sidebar payload must not hijack non-drag portal events")
assert_sidebar_gate(client, "active", expected=True, reason="active sidebar drag should capture outside overlay")
assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state")
client.seed_drag_pasteboard_fileurl()
for event in DRAG_EVENTS:
assert_gate(client, event, expected=False, reason="file URL drag should route to Bonsplit panes")
for event in NON_DRAG_EVENTS + ["none"]:
assert_gate(client, event, expected=False, reason="non-drag events should pass through")
assert_drop_gate(client, "external", expected=False, reason="external file drags should route to Bonsplit panes")
assert_drop_gate(client, "local", expected=False, reason="local file drags should route to Bonsplit panes")
for event in DRAG_EVENTS:
assert_portal_gate(client, event, expected=True, reason="file drag should pass through terminal portal")
for event in NON_DRAG_EVENTS + ["none"]:
assert_portal_gate(client, event, expected=False, reason="stale file payload must not hijack non-drag portal events")
assert_sidebar_gate(client, "active", expected=False, reason="file drag is not sidebar reorder payload")
assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state")
client.seed_drag_pasteboard_types(["fileurl", "tabtransfer"])
for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]:
assert_gate(client, event, expected=False, reason="fileurl+tabtransfer must pass through")
assert_drop_gate(client, "external", expected=False, reason="fileurl+tabtransfer must pass through")
assert_drop_gate(client, "local", expected=False, reason="fileurl+tabtransfer must pass through")
for event in PORTAL_PASS_THROUGH_EVENTS:
assert_portal_gate(client, event, expected=True, reason="mixed fileurl+tabtransfer should still pass through portal")
for event in NON_DRAG_EVENTS + ["none"]:
assert_portal_gate(client, event, expected=False, reason="mixed payload must not hijack non-drag portal events")
assert_sidebar_gate(client, "active", expected=False, reason="tabtransfer mix is not sidebar reorder payload")
assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state")
client.seed_drag_pasteboard_types(["fileurl", "sidebarreorder"])
for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]:
assert_gate(client, event, expected=False, reason="fileurl+sidebarreorder must pass through")
assert_drop_gate(client, "external", expected=False, reason="fileurl+sidebarreorder must pass through")
assert_drop_gate(client, "local", expected=False, reason="fileurl+sidebarreorder must pass through")
for event in PORTAL_PASS_THROUGH_EVENTS:
assert_portal_gate(client, event, expected=True, reason="mixed fileurl+sidebarreorder should still pass through portal")
for event in NON_DRAG_EVENTS + ["none"]:
assert_portal_gate(client, event, expected=False, reason="mixed sidebar payload must not hijack non-drag portal events")
assert_sidebar_gate(client, "active", expected=True, reason="sidebar reorder mix should keep sidebar outside overlay active")
assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state")
print("PASS: drag routing policy matrix preserves bonsplit/sidebar drags and pane-level file preview drops")
return 0
finally:
try:
client.clear_drag_pasteboard()
except Exception:
pass
if ws_id:
try:
client.close_workspace(ws_id)
except Exception:
pass
if __name__ == "__main__":
try:
raise SystemExit(main())
except cmuxError as e:
print(f"FAIL: {e}")
raise SystemExit(1)
-243
View File
@@ -1,243 +0,0 @@
#!/usr/bin/env python3
"""
Tests for browser back/forward via Cmd+[/] keyboard shortcuts.
Verifies that:
1. Cmd+[ triggers browser goBack when a browser panel is focused
2. Cmd+] triggers browser goForward when a browser panel is focused
3. Cmd+[/] are no-ops when a terminal panel is focused
Requires:
- cmux running
- Debug socket commands enabled (`simulate_shortcut`)
"""
import os
import sys
import time
from typing import Optional
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
def focused_pane_id(client: cmux) -> Optional[str]:
"""Return the pane_id of the currently focused pane, or None."""
for _idx, pane_id, _count, is_focused in client.list_panes():
if is_focused:
return pane_id
return None
def get_browser_url(client: cmux, panel_id: str) -> str:
"""Get the current URL of a browser panel."""
return client._send_command(f"get_url {panel_id}").strip()
def navigate_browser(client: cmux, panel_id: str, url: str) -> None:
"""Navigate a browser panel to a URL."""
response = client._send_command(f"navigate {panel_id} {url}")
if not response.startswith("OK"):
raise cmuxError(response)
def wait_for_url(client: cmux, panel_id: str, expected_url: str,
timeout_s: float = 5.0, contains: bool = False) -> bool:
"""Poll until the browser panel's URL matches the expected value."""
start = time.time()
while time.time() - start < timeout_s:
url = get_browser_url(client, panel_id)
if contains:
if expected_url in url:
return True
else:
if url == expected_url:
return True
time.sleep(0.2)
return False
def test_cmd_bracket_back_forward(client: cmux) -> tuple[bool, str]:
"""
1. Create workspace with a browser pane
2. Navigate to page A, then page B
3. Cmd+[ should go back to page A
4. Cmd+] should go forward to page B
"""
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(1.0)
# Create a browser surface
browser_id = client.new_surface(panel_type="browser", url="https://example.com")
time.sleep(3.0) # Wait for page load
# Verify initial URL
if not wait_for_url(client, browser_id, "https://example.com/", timeout_s=5.0):
url = get_browser_url(client, browser_id)
# example.com might redirect or have trailing slash differences
if "example.com" not in url:
client.close_workspace(ws_id)
return False, f"Initial URL not example.com, got: {url}"
page_a_url = get_browser_url(client, browser_id)
# Navigate to a second page
navigate_browser(client, browser_id, "https://example.org")
time.sleep(2.0)
if not wait_for_url(client, browser_id, "example.org", timeout_s=5.0, contains=True):
url = get_browser_url(client, browser_id)
client.close_workspace(ws_id)
return False, f"Navigation to page B failed, URL: {url}"
page_b_url = get_browser_url(client, browser_id)
# Focus the webview so Cmd+[ routes through the browser
client.focus_webview(browser_id)
client.wait_for_webview_focus(browser_id, timeout_s=3.0)
# Cmd+[ (back) — should go back to page A
client.simulate_shortcut("cmd+[")
if not wait_for_url(client, browser_id, "example.com", timeout_s=5.0, contains=True):
url_after_back = get_browser_url(client, browser_id)
client.close_workspace(ws_id)
return False, f"Cmd+[ did not go back. Expected example.com, got: {url_after_back}"
# Cmd+] (forward) — should go forward to page B
client.simulate_shortcut("cmd+]")
if not wait_for_url(client, browser_id, "example.org", timeout_s=5.0, contains=True):
url_after_forward = get_browser_url(client, browser_id)
client.close_workspace(ws_id)
return False, f"Cmd+] did not go forward. Expected example.org, got: {url_after_forward}"
client.close_workspace(ws_id)
return True, "Cmd+[/] back/forward works correctly"
def test_cmd_bracket_noop_on_terminal(client: cmux) -> tuple[bool, str]:
"""
Verify that Cmd+[/] are no-ops when focused on a terminal (no browser panel focused).
The workspace should not change.
"""
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(1.0)
current_ws = client.current_workspace()
# Cmd+[ on terminal should be a no-op (no crash, no workspace change)
client.simulate_shortcut("cmd+[")
time.sleep(0.3)
# Verify we're still on the same workspace
after_ws = client.current_workspace()
if current_ws != after_ws:
client.close_workspace(ws_id)
return False, f"Cmd+[ on terminal changed workspace from {current_ws} to {after_ws}"
# Cmd+] should also be a no-op
client.simulate_shortcut("cmd+]")
time.sleep(0.3)
after_ws2 = client.current_workspace()
if current_ws != after_ws2:
client.close_workspace(ws_id)
return False, f"Cmd+] on terminal changed workspace from {current_ws} to {after_ws2}"
client.close_workspace(ws_id)
return True, "Cmd+[/] are no-ops on terminal"
def test_browser_back_forward_socket_commands(client: cmux) -> tuple[bool, str]:
"""
Test that browser_back and browser_forward socket commands work correctly.
This verifies the underlying goBack()/goForward() methods independently
of keyboard shortcuts.
"""
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(1.0)
# Create browser and navigate to two pages
browser_id = client.new_surface(panel_type="browser", url="https://example.com")
time.sleep(3.0)
if not wait_for_url(client, browser_id, "example.com", timeout_s=5.0, contains=True):
url = get_browser_url(client, browser_id)
client.close_workspace(ws_id)
return False, f"Initial navigation failed, URL: {url}"
navigate_browser(client, browser_id, "https://example.org")
time.sleep(2.0)
if not wait_for_url(client, browser_id, "example.org", timeout_s=5.0, contains=True):
url = get_browser_url(client, browser_id)
client.close_workspace(ws_id)
return False, f"Second navigation failed, URL: {url}"
# browser_back
resp = client._send_command(f"browser_back {browser_id}")
if not resp.startswith("OK"):
client.close_workspace(ws_id)
return False, f"browser_back command failed: {resp}"
if not wait_for_url(client, browser_id, "example.com", timeout_s=5.0, contains=True):
url_after_back = get_browser_url(client, browser_id)
client.close_workspace(ws_id)
return False, f"browser_back did not go back. Got: {url_after_back}"
# browser_forward
resp = client._send_command(f"browser_forward {browser_id}")
if not resp.startswith("OK"):
client.close_workspace(ws_id)
return False, f"browser_forward command failed: {resp}"
if not wait_for_url(client, browser_id, "example.org", timeout_s=5.0, contains=True):
url_after_forward = get_browser_url(client, browser_id)
client.close_workspace(ws_id)
return False, f"browser_forward did not go forward. Got: {url_after_forward}"
client.close_workspace(ws_id)
return True, "browser_back/browser_forward socket commands work correctly"
def main():
client = cmux()
client.connect()
tests = [
("browser_back_forward_socket", test_browser_back_forward_socket_commands),
("cmd_bracket_back_forward", test_cmd_bracket_back_forward),
("cmd_bracket_noop_on_terminal", test_cmd_bracket_noop_on_terminal),
]
results = []
for name, test_fn in tests:
print(f" Running {name}...", end=" ", flush=True)
try:
passed, msg = test_fn(client)
status = "PASS" if passed else "FAIL"
print(f"{status}: {msg}")
results.append((name, passed, msg))
except Exception as e:
print(f"ERROR: {e}")
results.append((name, False, str(e)))
client.close()
print()
passed = sum(1 for _, p, _ in results if p)
total = len(results)
print(f"Results: {passed}/{total} passed")
if passed < total:
for name, p, msg in results:
if not p:
print(f" FAILED: {name}: {msg}")
sys.exit(1)
if __name__ == "__main__":
main()
-171
View File
@@ -1,171 +0,0 @@
#!/usr/bin/env python3
"""
Regression tests for browser-focused keybind handling.
Why this exists:
- When WKWebView is first responder, some shortcuts still need to work
(pane navigation, etc).
- Control-key combos can produce control characters (e.g. Ctrl+H => backspace),
so matching must use keyCode fallbacks.
Requires:
- cmux running
- Debug socket commands enabled (`set_shortcut`, `simulate_shortcut`)
"""
import os
import sys
import time
from typing import Optional
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux
def focused_pane_id(client: cmux) -> Optional[str]:
for _idx, pane_id, _count, is_focused in client.list_panes():
if is_focused:
return pane_id
return None
def wait_url_contains(client: cmux, panel_id: str, needle: str, timeout_s: float = 10.0) -> None:
start = time.time()
while time.time() - start < timeout_s:
url = client._send_command(f"get_url {panel_id}").strip()
if url and not url.startswith("ERROR") and needle in url:
return
time.sleep(0.1)
raise RuntimeError(f"Timed out waiting for url to contain '{needle}': {url!r}")
def test_cmd_ctrl_h_goto_split_left_from_webview(client: cmux) -> tuple[bool, str]:
"""
Verifies: Cmd+Ctrl+H moves pane focus left while WKWebView is first responder.
This uses the app shortcut override path so the test is hermetic.
"""
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.5)
# Override focus-left shortcut to Cmd+Ctrl+H for this test.
client.set_shortcut("focus_left", "cmd+ctrl+h")
try:
# Create a browser pane to the right, loading a real page.
browser_id = client.new_pane(direction="right", panel_type="browser", url="https://example.com")
wait_url_contains(client, browser_id, "example.com", timeout_s=15.0)
panes = client.list_panes()
if len(panes) != 2:
return False, f"Expected 2 panes, got {len(panes)}: {panes}"
browser_pane_id = focused_pane_id(client)
terminal_pane_id = next((pid for _i, pid, _n, _f in panes if pid != browser_pane_id), None)
if not browser_pane_id or not terminal_pane_id:
return False, f"Could not identify terminal/browser pane IDs: {panes}"
# Force WKWebView first responder (socket-driven; avoids flaky clicking).
client.focus_webview(browser_id)
client.wait_for_webview_focus(browser_id, timeout_s=3.0)
pre = focused_pane_id(client)
if pre != browser_pane_id:
return False, f"Expected browser pane focused before keypress, got {pre}"
# Send Cmd+Ctrl+H via socket event injection.
client.simulate_shortcut("cmd+ctrl+h")
time.sleep(0.4)
post = focused_pane_id(client)
if post != terminal_pane_id:
return False, f"Expected focus to move left to {terminal_pane_id}, got {post}"
return True, "Cmd+Ctrl+H moved focus left while webview focused"
finally:
# Restore defaults for subsequent tests.
try:
client.set_shortcut("focus_left", "clear")
except Exception:
pass
try:
client.close_workspace(ws_id)
except Exception:
pass
def test_cmd_opt_left_arrow_goto_split_left_from_webview(client: cmux) -> tuple[bool, str]:
"""
Baseline: default pane navigation (Cmd+Option+Left Arrow) moves pane focus
left while WKWebView is first responder.
"""
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.5)
# Ensure we use the default arrow shortcut.
client.set_shortcut("focus_left", "clear")
try:
browser_id = client.new_pane(direction="right", panel_type="browser", url="https://example.com")
wait_url_contains(client, browser_id, "example.com", timeout_s=15.0)
panes = client.list_panes()
if len(panes) != 2:
return False, f"Expected 2 panes, got {len(panes)}: {panes}"
browser_pane_id = focused_pane_id(client)
terminal_pane_id = next((pid for _i, pid, _n, _f in panes if pid != browser_pane_id), None)
if not browser_pane_id or not terminal_pane_id:
return False, f"Could not identify terminal/browser pane IDs: {panes}"
client.focus_webview(browser_id)
client.wait_for_webview_focus(browser_id, timeout_s=3.0)
pre = focused_pane_id(client)
if pre != browser_pane_id:
return False, f"Expected browser pane focused before keypress, got {pre}"
client.simulate_shortcut("cmd+opt+left")
time.sleep(0.4)
post = focused_pane_id(client)
if post != terminal_pane_id:
return False, f"Expected focus to move left to {terminal_pane_id}, got {post}"
return True, "Cmd+Option+Left moved focus left while webview focused"
finally:
try:
client.close_workspace(ws_id)
except Exception:
pass
def main() -> int:
print("cmux Browser Custom Keybind Tests")
print("=" * 50)
client = cmux()
client.connect()
tests = [
("Cmd+Opt+Left goto_split:left from webview focus", test_cmd_opt_left_arrow_goto_split_left_from_webview),
("Cmd+Ctrl+H goto_split:left from webview focus", test_cmd_ctrl_h_goto_split_left_from_webview),
]
failed = 0
for name, fn in tests:
try:
ok, msg = fn(client)
except Exception as e:
ok, msg = False, str(e)
status = "PASS" if ok else "FAIL"
print(f"{status}: {name} - {msg}")
if not ok:
failed += 1
if failed == 0:
print("\nAll tests passed.")
return 0
print(f"\n{failed} test(s) failed.")
return 1
if __name__ == "__main__":
raise SystemExit(main())
-232
View File
@@ -1,232 +0,0 @@
#!/usr/bin/env python3
"""
Regression test: Cmd+Option+Arrow (goto_split) must work when a browser panel
is focused and actively displaying a web page.
Requires:
- cmux running
- Debug socket commands enabled (`simulate_shortcut`)
"""
import os
import sys
import time
from typing import Optional
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
def focused_pane_id(client: cmux) -> Optional[str]:
"""Return the pane_id of the currently focused pane, or None."""
for _idx, pane_id, _count, is_focused in client.list_panes():
if is_focused:
return pane_id
return None
def wait_url_contains(client: cmux, panel_id: str, needle: str, timeout_s: float = 10.0) -> None:
start = time.time()
while time.time() - start < timeout_s:
url = client._send_command(f"get_url {panel_id}").strip()
if url and not url.startswith("ERROR") and needle in url:
return
time.sleep(0.1)
raise RuntimeError(f"Timed out waiting for url to contain '{needle}': {url!r}")
def test_goto_split_from_loaded_browser(client: cmux) -> tuple[bool, str]:
"""
1. Create workspace with horizontal split: terminal (left) | browser with URL (right)
2. Focus the browser pane and ensure WKWebView has first responder
3. Send Cmd+Option+Left via debug socket simulate_shortcut
4. Verify focus moved to the terminal pane (left)
"""
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.5)
# Ensure we use the default Cmd+Option+Arrow shortcuts for this regression test.
client.set_shortcut("focus_left", "clear")
client.set_shortcut("focus_right", "clear")
# Create a browser pane to the right, loading a real page
browser_id = client.new_pane(direction="right", panel_type="browser", url="https://example.com")
wait_url_contains(client, browser_id, "example.com", timeout_s=15.0) # Wait for page load
# Identify the two panes
panes = client.list_panes()
if len(panes) < 2:
return False, f"Expected 2 panes, got {len(panes)}"
browser_pane_id = focused_pane_id(client)
terminal_pane_id = None
for _idx, pid, _count, is_focused in panes:
if pid != browser_pane_id:
terminal_pane_id = pid
break
if not terminal_pane_id or not browser_pane_id:
return False, f"Could not identify terminal/browser panes: {panes}"
# Ensure browser pane is focused
client.focus_pane(browser_pane_id)
time.sleep(0.3)
# Force WKWebView first responder (socket-driven; avoids flakey clicking).
client.focus_webview(browser_id)
client.wait_for_webview_focus(browser_id, timeout_s=3.0)
# Verify WebKit (not just the pane) has first responder.
if not client.is_webview_focused(browser_id):
return False, "Browser pane is focused, but WKWebView is not first responder"
# Verify browser pane is still focused after click
pre_focus = focused_pane_id(client)
if pre_focus != browser_pane_id:
try:
client.close_workspace(ws_id)
except Exception:
pass
return False, f"Click changed focus away from browser pane (now {pre_focus})"
# Send Cmd+Option+Left arrow
client.simulate_shortcut("cmd+opt+left")
time.sleep(0.5)
new_focused = focused_pane_id(client)
try:
client.close_workspace(ws_id)
except Exception:
pass
if new_focused == terminal_pane_id:
return True, "Cmd+Option+Left moved focus from loaded browser to terminal"
else:
return False, (
f"Focus did NOT move. Expected terminal {terminal_pane_id}, "
f"got {new_focused} (browser={browser_pane_id})"
)
def test_goto_split_roundtrip_loaded_browser(client: cmux) -> tuple[bool, str]:
"""
Round-trip: terminal → browser (Cmd+Opt+Right) → terminal (Cmd+Opt+Left)
with a loaded page and webview focused.
"""
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.5)
client.set_shortcut("focus_left", "clear")
client.set_shortcut("focus_right", "clear")
browser_id = client.new_pane(direction="right", panel_type="browser", url="https://example.com")
wait_url_contains(client, browser_id, "example.com", timeout_s=15.0)
panes = client.list_panes()
if len(panes) < 2:
return False, f"Expected 2 panes, got {len(panes)}"
browser_pane_id = focused_pane_id(client)
terminal_pane_id = None
for _idx, pid, _count, is_focused in panes:
if pid != browser_pane_id:
terminal_pane_id = pid
break
if not terminal_pane_id or not browser_pane_id:
return False, f"Could not identify panes: {panes}"
# Focus terminal pane first
client.focus_pane(terminal_pane_id)
time.sleep(0.3)
# Cmd+Option+Right to move to browser
client.simulate_shortcut("cmd+opt+right")
time.sleep(0.5)
mid_focused = focused_pane_id(client)
if mid_focused != browser_pane_id:
try:
client.close_workspace(ws_id)
except Exception:
pass
return False, (
f"Cmd+Option+Right from terminal didn't reach browser. "
f"Expected {browser_pane_id}, got {mid_focused}"
)
# Now browser is focused. Force WKWebView first responder.
client.focus_webview(browser_id)
client.wait_for_webview_focus(browser_id, timeout_s=3.0)
if not client.is_webview_focused(browser_id):
return False, "WKWebView did not become first responder in browser pane"
# Cmd+Option+Left to go back to terminal
client.simulate_shortcut("cmd+opt+left")
time.sleep(0.5)
final_focused = focused_pane_id(client)
try:
client.close_workspace(ws_id)
except Exception:
pass
if final_focused == terminal_pane_id:
return True, "Round-trip through loaded browser with webview focus works"
else:
return False, (
f"Return trip failed. Expected terminal {terminal_pane_id}, got {final_focused}"
)
def run_tests() -> int:
print("=" * 60)
print("cmux Browser goto_split Regression Test")
print("=" * 60)
print()
probe = cmux()
socket_path = probe.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.")
return 1
tests = [
("goto_split LEFT from loaded browser", test_goto_split_from_loaded_browser),
("goto_split round-trip with webview focus", test_goto_split_roundtrip_loaded_browser),
]
passed = 0
failed = 0
try:
with cmux(socket_path=socket_path) as client:
for name, fn in tests:
print(f" Running: {name} ... ", end="", flush=True)
try:
ok, msg = fn(client)
except Exception as e:
ok, msg = False, str(e)
status = "PASS" if ok else "FAIL"
print(f"{status}: {msg}")
if ok:
passed += 1
else:
failed += 1
except cmuxError as e:
print(f"Error: {e}")
return 1
print()
print(f"Results: {passed} passed, {failed} failed")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(run_tests())
@@ -1,455 +0,0 @@
#!/usr/bin/env python3
"""
Regression test:
1. Focusing a blank browser surface should focus the omnibar.
2. Focusing a pane that contains a blank browser should focus the omnibar.
3. If command palette is open, focusing that blank browser surface must not steal input.
4. Cmd+P switcher should list only workspaces, then switching to a workspace with a
focused blank browser should focus the omnibar.
"""
import json
import os
import sys
import time
from typing import Any
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
def v2_call(client: cmux, method: str, params: dict[str, Any] | None = None, request_id: str = "1") -> dict[str, Any]:
payload = {
"id": request_id,
"method": method,
"params": params or {},
}
raw = client._send_command(json.dumps(payload))
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise cmuxError(f"Invalid v2 JSON response for {method}: {raw}") from exc
if not parsed.get("ok"):
raise cmuxError(f"v2 {method} failed: {parsed.get('error')}")
result = parsed.get("result")
return result if isinstance(result, dict) else {}
def wait_for(predicate, timeout_s: float, interval_s: float = 0.1) -> bool:
deadline = time.time() + timeout_s
while time.time() < deadline:
if predicate():
return True
time.sleep(interval_s)
return False
def browser_address_bar_focus_state(client: cmux, surface_id: str | None = None, request_id: str = "browser-focus") -> dict[str, Any]:
params: dict[str, Any] = {}
if surface_id:
params["surface_id"] = surface_id
return v2_call(client, "debug.browser.address_bar_focused", params, request_id=request_id)
def set_command_palette_visible(client: cmux, window_id: str, target_visible: bool) -> bool:
for idx in range(5):
state = v2_call(
client,
"debug.command_palette.visible",
{"window_id": window_id},
request_id=f"palette-visible-{idx}",
)
is_visible = bool(state.get("visible"))
if is_visible == target_visible:
return True
v2_call(
client,
"debug.command_palette.toggle",
{"window_id": window_id},
request_id=f"palette-toggle-{idx}",
)
time.sleep(0.15)
return False
def command_palette_results(client: cmux, window_id: str, limit: int = 20) -> list[dict[str, Any]]:
payload = v2_call(
client,
"debug.command_palette.results",
{"window_id": window_id, "limit": limit},
request_id="palette-results"
)
rows = payload.get("results")
if isinstance(rows, list):
return [row for row in rows if isinstance(row, dict)]
return []
def command_palette_selected_index(client: cmux, window_id: str) -> int:
payload = v2_call(
client,
"debug.command_palette.selection",
{"window_id": window_id},
request_id="palette-selection"
)
selected_index = payload.get("selected_index")
if isinstance(selected_index, int):
return max(0, selected_index)
return 0
def move_command_palette_selection_to_index(client: cmux, window_id: str, target_index: int) -> bool:
target = max(0, target_index)
for _ in range(40):
current = command_palette_selected_index(client, window_id)
if current == target:
return True
if current < target:
client.simulate_shortcut("down")
else:
client.simulate_shortcut("up")
time.sleep(0.05)
return False
def current_window_id(client: cmux) -> str:
window_current = v2_call(client, "window.current", request_id="window-current")
window_id = window_current.get("window_id")
if not isinstance(window_id, str) or not window_id:
raise cmuxError(f"Invalid window.current payload: {window_current}")
return window_id
def main() -> int:
client = cmux()
workspace_ids: list[str] = []
window_id: str | None = None
try:
client.connect()
client.activate_app()
# Scenario 1: focus_surface on a blank browser should focus omnibar.
workspace_id = client.new_workspace()
workspace_ids.append(workspace_id)
client.select_workspace(workspace_id)
time.sleep(0.4)
window_id = current_window_id(client)
if not set_command_palette_visible(client, window_id, False):
raise cmuxError("Failed to ensure command palette is hidden for scenario 1")
browser_id = client.new_surface(panel_type="browser")
time.sleep(0.3)
surfaces = client.list_surfaces()
terminal_id = next((surface_id for _, surface_id, _ in surfaces if surface_id != browser_id), None)
if not terminal_id:
raise cmuxError("Missing terminal surface for focus setup")
client.focus_surface_by_panel(terminal_id)
time.sleep(0.2)
# Primary behavior: focusing a blank browser tab should focus the omnibar.
client.focus_surface_by_panel(browser_id)
did_focus_address_bar = wait_for(
lambda: bool(
browser_address_bar_focus_state(
client,
surface_id=browser_id,
request_id="browser-focus-primary"
).get("focused")
),
timeout_s=3.0,
interval_s=0.1
)
if not did_focus_address_bar:
raise cmuxError("Blank browser surface did not focus omnibar after focus_surface")
client.close_workspace(workspace_id)
workspace_ids.remove(workspace_id)
time.sleep(0.3)
# Scenario 2: focusing a pane that contains a blank browser should focus omnibar.
workspace_id = client.new_workspace()
workspace_ids.append(workspace_id)
client.select_workspace(workspace_id)
time.sleep(0.4)
window_id = current_window_id(client)
if not set_command_palette_visible(client, window_id, False):
raise cmuxError("Failed to ensure command palette is hidden for scenario 2")
initial_surfaces = client.list_surfaces()
left_terminal_id = next((surface_id for _, surface_id, _ in initial_surfaces), None)
if not left_terminal_id:
raise cmuxError("Missing initial terminal surface for split setup")
split_browser_id = client.new_pane(direction="right", panel_type="browser")
time.sleep(0.3)
pane_rows = client.list_panes()
left_pane: str | None = None
browser_pane: str | None = None
for _, pane_id, _, _ in pane_rows:
pane_surface_ids = {surface_id for _, surface_id, _, _ in client.list_pane_surfaces(pane_id)}
if left_terminal_id in pane_surface_ids:
left_pane = pane_id
if split_browser_id in pane_surface_ids:
browser_pane = pane_id
if not left_pane or not browser_pane:
raise cmuxError("Failed to locate split panes for pane-focus scenario")
client.focus_pane(left_pane)
time.sleep(0.2)
client.focus_pane(browser_pane)
did_focus_split_browser = wait_for(
lambda: bool(
browser_address_bar_focus_state(
client,
surface_id=split_browser_id,
request_id="browser-focus-pane"
).get("focused")
),
timeout_s=3.0,
interval_s=0.1
)
if not did_focus_split_browser:
raise cmuxError("Blank browser pane did not focus omnibar after focus_pane")
client.close_workspace(workspace_id)
workspace_ids.remove(workspace_id)
time.sleep(0.3)
# Scenario 3: command palette should keep input focus when switching to a blank browser surface.
workspace_id = client.new_workspace()
workspace_ids.append(workspace_id)
client.select_workspace(workspace_id)
time.sleep(0.4)
window_id = current_window_id(client)
if not set_command_palette_visible(client, window_id, False):
raise cmuxError("Failed to reset command palette before scenario 3")
blank_browser_id = client.new_surface(panel_type="browser")
time.sleep(0.3)
surfaces = client.list_surfaces()
terminal_id = next((surface_id for _, surface_id, _ in surfaces if surface_id != blank_browser_id), None)
if not terminal_id:
raise cmuxError("Missing terminal surface for command palette scenario")
client.focus_surface_by_panel(terminal_id)
wait_for(
lambda: not bool(
browser_address_bar_focus_state(
client,
request_id="browser-focus-cleared"
).get("focused")
),
timeout_s=2.0,
interval_s=0.1
)
if not set_command_palette_visible(client, window_id, True):
raise cmuxError("Failed to open command palette")
client.focus_surface_by_panel(blank_browser_id)
time.sleep(0.2)
palette_visible_after_focus = bool(
v2_call(
client,
"debug.command_palette.visible",
{"window_id": window_id},
request_id="palette-visible-after-focus"
).get("visible")
)
if not palette_visible_after_focus:
raise cmuxError("Command palette closed unexpectedly after focus_surface")
blank_focus_state = browser_address_bar_focus_state(
client,
surface_id=blank_browser_id,
request_id="browser-focus-palette"
)
if bool(blank_focus_state.get("focused")):
raise cmuxError("Blank browser tab stole omnibar focus while command palette was visible")
client.close_workspace(workspace_id)
workspace_ids.remove(workspace_id)
time.sleep(0.3)
# Scenario 4: Cmd+P switcher should only list workspaces, and switching to a workspace
# that has a focused blank browser should focus the omnibar.
target_workspace_id = client.new_workspace()
workspace_ids.append(target_workspace_id)
client.select_workspace(target_workspace_id)
time.sleep(0.4)
window_id = current_window_id(client)
if not set_command_palette_visible(client, window_id, False):
raise cmuxError("Failed to reset command palette before scenario 4 (target setup)")
switcher_browser_id = client.new_surface(panel_type="browser")
time.sleep(0.3)
client.focus_surface_by_panel(switcher_browser_id)
did_focus_target_browser = wait_for(
lambda: bool(
browser_address_bar_focus_state(
client,
surface_id=switcher_browser_id,
request_id="browser-focus-switcher-target-setup"
).get("focused")
),
timeout_s=3.0,
interval_s=0.1
)
if not did_focus_target_browser:
raise cmuxError("Failed to focus omnibar on target workspace browser before Cmd+P switch")
source_workspace_id = client.new_workspace()
workspace_ids.append(source_workspace_id)
client.select_workspace(source_workspace_id)
time.sleep(0.4)
window_id = current_window_id(client)
if not set_command_palette_visible(client, window_id, False):
raise cmuxError("Failed to reset command palette before scenario 4 (source setup)")
source_surfaces = client.list_surfaces()
source_terminal_id = next((surface_id for _, surface_id, _ in source_surfaces), None)
if not source_terminal_id:
raise cmuxError("Missing terminal surface for Cmd+P workspace switcher scenario")
client.focus_surface_by_panel(source_terminal_id)
time.sleep(0.2)
client.simulate_shortcut("cmd+p")
if not wait_for(
lambda: bool(
v2_call(
client,
"debug.command_palette.visible",
{"window_id": window_id},
request_id="palette-visible-switcher-open"
).get("visible")
),
timeout_s=2.0,
interval_s=0.1
):
raise cmuxError("Cmd+P did not open command palette switcher")
switcher_results = command_palette_results(client, window_id, limit=100)
switcher_ids = [row.get("command_id") for row in switcher_results if isinstance(row.get("command_id"), str)]
has_surface_rows = any(command_id.startswith("switcher.surface.") for command_id in switcher_ids)
if has_surface_rows:
raise cmuxError("Cmd+P switcher listed unexpected surface rows; expected workspace-only results")
target_command_id = f"switcher.workspace.{target_workspace_id.lower()}"
target_index = next(
(
idx for idx, row in enumerate(switcher_results)
if isinstance(row.get("command_id"), str) and row.get("command_id") == target_command_id
),
None
)
if target_index is None:
raise cmuxError(f"Cmd+P switcher did not list target workspace command {target_command_id}")
if not move_command_palette_selection_to_index(client, window_id, target_index):
raise cmuxError(f"Failed to move Cmd+P selection to result index {target_index}")
client.simulate_shortcut("enter")
did_focus_switcher_target = wait_for(
lambda: (
not bool(
v2_call(
client,
"debug.command_palette.visible",
{"window_id": window_id},
request_id="palette-visible-switcher-after-enter"
).get("visible")
)
and bool(
browser_address_bar_focus_state(
client,
surface_id=switcher_browser_id,
request_id="browser-focus-switcher"
).get("focused")
)
),
timeout_s=3.0,
interval_s=0.1
)
if not did_focus_switcher_target:
raise cmuxError("Cmd+P workspace switch did not restore blank browser omnibar focus")
# Scenario 5: Cmd+P switcher should dismiss on Escape reliably.
client.select_workspace(source_workspace_id)
time.sleep(0.4)
window_id = current_window_id(client)
if not set_command_palette_visible(client, window_id, False):
raise cmuxError("Failed to reset command palette before scenario 5")
client.focus_surface_by_panel(source_terminal_id)
time.sleep(0.2)
client.simulate_shortcut("cmd+p")
if not wait_for(
lambda: bool(
v2_call(
client,
"debug.command_palette.visible",
{"window_id": window_id},
request_id="palette-visible-switcher-open-escape"
).get("visible")
),
timeout_s=2.0,
interval_s=0.1
):
raise cmuxError("Cmd+P did not open command palette switcher before Escape scenario")
client.simulate_shortcut("escape")
did_dismiss_switcher_on_escape = wait_for(
lambda: not bool(
v2_call(
client,
"debug.command_palette.visible",
{"window_id": window_id},
request_id="palette-visible-switcher-after-escape"
).get("visible")
),
timeout_s=3.0,
interval_s=0.1
)
if not did_dismiss_switcher_on_escape:
raise cmuxError("Cmd+P Escape did not dismiss command palette switcher")
print("PASS: blank-browser focus paths (surface, pane, Cmd+P Enter switcher, and Cmd+P Escape dismiss) drive omnibar, while command palette visibility blocks focus stealing")
return 0
except cmuxError as exc:
print(f"FAIL: {exc}")
return 1
finally:
if window_id:
try:
_ = set_command_palette_visible(client, window_id, False)
except Exception:
pass
for workspace_id in list(workspace_ids):
try:
client.close_workspace(workspace_id)
except Exception:
pass
try:
client.close()
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())
-197
View File
@@ -1,197 +0,0 @@
#!/usr/bin/env python3
"""
Stability regression test: browser panels should not crash cmux when:
1) Creating a browser surface then immediately creating a new terminal surface
2) Rapidly switching focus between panes when one pane is a loaded browser
This test uses the control socket only (no osascript / Accessibility required).
Requires:
- cmux running
"""
import os
import sys
import time
from typing import Optional
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
def wait_for_socket(path: str, timeout_s: float = 5.0) -> None:
start = time.time()
while not os.path.exists(path):
if time.time() - start >= timeout_s:
raise RuntimeError(f"Socket not found at {path}")
time.sleep(0.1)
def ensure_webview_focused(client: cmux, panel_id: str, timeout_s: float = 2.0) -> None:
"""
Best-effort: focus the surface, then force WKWebView first responder, and verify it stuck.
This is important because the crash regression only reproduces when WebKit is actually first responder.
"""
start = time.time()
last_error: Optional[Exception] = None
while time.time() - start < timeout_s:
try:
client.focus_surface(panel_id)
client.focus_webview(panel_id)
if client.is_webview_focused(panel_id):
return
except Exception as e:
last_error = e
time.sleep(0.05)
raise RuntimeError(f"Timed out waiting for webview focus (panel={panel_id}): {last_error}")
def test_open_browser_then_new_surface_loop(client: cmux) -> tuple[bool, str]:
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.5)
# Keep one "base" terminal surface around so close_surface never hits the last-surface guard.
for i in range(10):
browser_id = client.new_surface(panel_type="browser", url="https://example.com")
time.sleep(0.8)
ensure_webview_focused(client, browser_id, timeout_s=2.0)
terminal_id = client.new_surface(panel_type="terminal")
time.sleep(0.2)
# Rapid focus flipping to stress first-responder + view lifecycle.
for _ in range(10):
client.focus_surface(browser_id)
try:
client.focus_webview(browser_id)
except Exception:
# If focus is transient during bonsplit reshuffles, retry once with a short delay.
time.sleep(0.05)
ensure_webview_focused(client, browser_id, timeout_s=0.8)
if not client.is_webview_focused(browser_id):
return False, "Browser surface is focused, but WKWebView is not first responder"
client.focus_surface(terminal_id)
time.sleep(0.05)
# If the app crashed/restarted, the socket command would error before this point.
if not client.ping():
return False, f"Ping failed after iteration {i}"
# Clean up the two surfaces created in this iteration.
try:
client.close_surface(browser_id)
except Exception:
# If close fails due to ordering, keep going; the workspace close at end will clean up.
pass
time.sleep(0.1)
try:
client.close_surface(terminal_id)
except Exception:
pass
time.sleep(0.2)
try:
client.close_workspace(ws_id)
except Exception:
pass
return True, "Repeated open browser + new surface did not crash"
def test_focus_panes_with_loaded_browser(client: cmux) -> tuple[bool, str]:
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.5)
# Create a browser pane (split). This should leave us with at least 2 panes.
browser_id = client.new_pane(direction="right", panel_type="browser", url="https://example.com")
time.sleep(1.5)
ensure_webview_focused(client, browser_id, timeout_s=2.0)
panes = client.list_panes()
if len(panes) < 2:
try:
client.close_workspace(ws_id)
except Exception:
pass
return False, f"Expected >=2 panes, got {len(panes)}: {panes}"
pane_ids = [pid for _idx, pid, _count, _is_focused in panes]
browser_pane_id = None
for _idx, pid, _count, is_focused in panes:
if is_focused:
browser_pane_id = pid
break
if not browser_pane_id:
return False, f"Could not determine focused pane after creating browser: {panes}"
# Rapidly cycle focus between panes.
saw_webview_focus = False
for i in range(60):
for pid in pane_ids:
client.focus_pane(pid)
time.sleep(0.03)
if pid == browser_pane_id:
# Make sure we actually focus into WebKit before switching away.
ensure_webview_focused(client, browser_id, timeout_s=0.8)
saw_webview_focus = True
if i % 10 == 0 and not client.ping():
return False, f"Ping failed during pane focus loop (i={i})"
if not saw_webview_focus:
return False, "Never observed WKWebView first responder during pane focus loop"
try:
client.close_workspace(ws_id)
except Exception:
pass
return True, "Rapid focus_pane loop with loaded browser did not crash"
def run_tests() -> int:
print("=" * 60)
print("cmux Browser Panel Stability Test")
print("=" * 60)
print()
probe = cmux()
wait_for_socket(probe.socket_path, timeout_s=5.0)
tests = [
("open_browser then new_surface loop", test_open_browser_then_new_surface_loop),
("focus panes with loaded browser", test_focus_panes_with_loaded_browser),
]
passed = 0
failed = 0
try:
with cmux(socket_path=probe.socket_path) as client:
for name, fn in tests:
print(f" Running: {name} ... ", end="", flush=True)
try:
ok, msg = fn(client)
except Exception as e:
ok, msg = False, str(e)
status = "PASS" if ok else "FAIL"
print(f"{status}: {msg}")
if ok:
passed += 1
else:
failed += 1
except cmuxError as e:
print(f"Error: {e}")
return 1
print()
print(f"Results: {passed} passed, {failed} failed")
return 0 if failed == 0 else 1
if __name__ == "__main__":
raise SystemExit(run_tests())
-254
View File
@@ -1,254 +0,0 @@
#!/usr/bin/env python3
"""
E2E regression test for Claude hook session mapping.
Validates:
1) session-start records session_id -> workspace/surface mapping on disk
2) notification updates mapped session state
3) stop keeps the live mapping and emits a richer completion notification
4) session-end consumes the mapping when Claude exits
"""
from __future__ import annotations
import glob
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
def resolve_cmux_cli() -> str:
explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI")
if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK):
return explicit
candidates: list[str] = []
candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux")))
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)]
if candidates:
candidates.sort(key=os.path.getmtime, reverse=True)
return candidates[0]
in_path = shutil.which("cmux")
if in_path:
return in_path
raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.")
def run_claude_hook(
cli_path: str,
socket_path: str,
subcommand: str,
payload: dict,
env: dict[str, str],
) -> str:
proc = subprocess.run(
[cli_path, "--socket", socket_path, "claude-hook", subcommand],
input=json.dumps(payload),
text=True,
capture_output=True,
env=env,
check=False,
)
if proc.returncode != 0:
raise RuntimeError(
f"cmux claude-hook {subcommand} failed:\n"
f"exit={proc.returncode}\nstdout={proc.stdout}\nstderr={proc.stderr}"
)
return proc.stdout.strip()
def wait_for_notification_count(client: cmux, minimum: int, timeout: float = 4.0) -> list[dict]:
start = time.time()
items: list[dict] = []
while time.time() - start < timeout:
items = client.list_notifications()
if len(items) >= minimum:
return items
time.sleep(0.05)
return items
def latest_notification_with_subtitle(items: list[dict], subtitle: str) -> dict | None:
for item in items:
if item.get("subtitle") == subtitle:
return item
return None
def fail(message: str) -> int:
print(f"FAIL: {message}")
return 1
def main() -> int:
try:
cli_path = resolve_cmux_cli()
except Exception as exc:
return fail(str(exc))
state_path = Path(tempfile.gettempdir()) / f"cmux_claude_hook_state_{os.getpid()}.json"
lock_path = Path(str(state_path) + ".lock")
try:
if state_path.exists():
state_path.unlink()
if lock_path.exists():
lock_path.unlink()
except OSError:
pass
project_dir = Path(tempfile.gettempdir()) / f"cmux_claude_map_project_{os.getpid()}"
project_dir.mkdir(parents=True, exist_ok=True)
session_id = f"sess-{uuid.uuid4().hex}"
last_message = "Please approve deploy migration"
try:
with cmux() as client:
client.set_app_focus(False)
client.clear_notifications()
workspace_id = client.new_workspace()
client.select_workspace(workspace_id)
surfaces = client.list_surfaces()
if not surfaces:
return fail("Expected at least one surface in new workspace")
focused = next((s for s in surfaces if s[2]), surfaces[0])
surface_id = focused[1]
client.new_split("right")
split_surfaces = client.list_surfaces()
other_surface = next((s for s in split_surfaces if s[1] != surface_id), None)
if other_surface is None:
return fail("Expected a second surface for ambient TTY routing regression")
client.focus_surface(surface_id)
fake_runner_tty = f"cmux-test-runner-{os.getpid()}"
client.report_tty(fake_runner_tty, tab=workspace_id, panel=other_surface[1])
hook_env = os.environ.copy()
hook_env["CMUX_SOCKET_PATH"] = client.socket_path
hook_env["CMUX_WORKSPACE_ID"] = workspace_id
hook_env["CMUX_SURFACE_ID"] = surface_id
hook_env["TTY"] = f"/dev/{fake_runner_tty}"
hook_env["CMUX_CLAUDE_HOOK_STATE_PATH"] = str(state_path)
run_claude_hook(
cli_path,
client.socket_path,
"session-start",
{
"session_id": session_id,
"cwd": str(project_dir),
},
hook_env,
)
if not state_path.exists():
return fail(f"Expected state file at {state_path}")
with state_path.open("r", encoding="utf-8") as f:
state_data = json.load(f)
session_row = (state_data.get("sessions") or {}).get(session_id)
if not session_row:
return fail("Expected mapped session row after session-start")
if session_row.get("workspaceId") != workspace_id:
return fail("Mapped workspaceId did not match active workspace")
if session_row.get("surfaceId") != surface_id:
return fail("Mapped surfaceId did not match active surface")
run_claude_hook(
cli_path,
client.socket_path,
"notification",
{
"session_id": session_id,
"message": last_message,
"type": "permission",
},
hook_env,
)
items = wait_for_notification_count(client, minimum=1)
if not items:
return fail("Expected at least one notification after claude-hook notification")
permission_notification = latest_notification_with_subtitle(items, "Permission")
if permission_notification is None:
return fail("Expected a Permission subtitle notification")
if permission_notification.get("surface_id") != surface_id:
return fail("Expected notification to route to mapped surface")
if last_message not in permission_notification.get("body", ""):
return fail("Expected notification body to include mapped last message")
run_claude_hook(
cli_path,
client.socket_path,
"stop",
{
"session_id": session_id,
},
hook_env,
)
items = wait_for_notification_count(client, minimum=2)
completed_notification = latest_notification_with_subtitle(items, "Completed")
if completed_notification is None:
return fail("Expected a Completed subtitle notification on stop")
body = completed_notification.get("body", "")
if project_dir.name not in body:
return fail("Expected stop notification body to include project directory name")
if "Last:" not in body:
return fail("Expected stop notification body to include last activity summary")
if "approve deploy migration" not in body.lower():
return fail("Expected stop notification body to include last Claude message context")
if completed_notification.get("surface_id") != surface_id:
return fail("Expected stop notification to target mapped surface")
with state_path.open("r", encoding="utf-8") as f:
post_stop_state = json.load(f)
if session_id not in (post_stop_state.get("sessions") or {}):
return fail("Expected stop to keep the live session mapping until session-end")
run_claude_hook(
cli_path,
client.socket_path,
"session-end",
{
"session_id": session_id,
},
hook_env,
)
with state_path.open("r", encoding="utf-8") as f:
post_session_end_state = json.load(f)
if session_id in (post_session_end_state.get("sessions") or {}):
return fail("Expected session-end to consume the session mapping")
print("PASS: Claude hook session mapping + stop summary notification")
return 0
except (cmuxError, RuntimeError) as exc:
return fail(str(exc))
finally:
try:
if state_path.exists():
state_path.unlink()
if lock_path.exists():
lock_path.unlink()
except OSError:
pass
if __name__ == "__main__":
raise SystemExit(main())
-195
View File
@@ -1,195 +0,0 @@
#!/usr/bin/env python3
"""
Regression tests for Bonsplit surface (tab) selection behavior when closing surfaces.
Desired behavior:
- When closing the currently focused surface at index i (and another surface exists at index i),
keep the focused index stable by focusing the surface that moves into index i (the "next" one).
- When closing the last focused surface, focus the previous surface.
Usage:
python3 tests/test_close_surface_selection.py
"""
import os
import sys
import time
from typing import List, Optional, Tuple
# Add the directory containing cmux.py to the path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux
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
SurfaceTuple = Tuple[int, str, bool] # (index, id, is_focused)
def _focused(surfaces: List[SurfaceTuple]) -> Optional[SurfaceTuple]:
return next((s for s in surfaces if s[2]), None)
def _wait_focused_index(client: cmux, index: int, timeout: float = 4.0) -> bool:
start = time.time()
while time.time() - start < timeout:
surfaces = client.list_surfaces()
focused = _focused(surfaces)
if focused is not None and focused[0] == index:
return True
time.sleep(0.05)
return False
def _wait_focused_id(
client: cmux,
expected_id: str,
expected_index: Optional[int] = None,
timeout: float = 4.0,
) -> Optional[SurfaceTuple]:
"""Poll list_surfaces() until the focused surface matches expected_id (and
optionally expected_index). Focus reassignment after a close is applied
asynchronously by the app, so a single read can observe stale state. Returns
the focused tuple once it settles to the expected value, else the last
observed focused tuple (or None) at the deadline."""
start = time.time()
last = None
while time.time() - start < timeout:
focused = _focused(client.list_surfaces())
last = focused
if (
focused is not None
and focused[1] == expected_id
and (expected_index is None or focused[0] == expected_index)
):
return focused
time.sleep(0.05)
return last
def _ensure_surfaces(client: cmux, count: int) -> None:
surfaces = client.list_surfaces()
while len(surfaces) < count:
client.new_surface(panel_type="terminal")
time.sleep(0.15)
surfaces = client.list_surfaces()
def test_close_middle_keeps_index(client: cmux) -> TestResult:
result = TestResult("Close Focused Middle Surface Keeps Index")
try:
# Isolate from developer state: use a fresh workspace.
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.25)
client.activate_app()
time.sleep(0.15)
_ensure_surfaces(client, 3)
# Focus index 1.
client.focus_surface(1)
if not _wait_focused_index(client, 1, timeout=4.0):
result.failure("Failed to focus surface index 1")
return result
before = client.list_surfaces()
if len(before) < 3:
result.failure(f"Expected >= 3 surfaces, got {len(before)}")
return result
expected_next_id = before[2][1]
client.close_surface() # closes focused surface
# Focus reassignment after close is asynchronous; poll for it to settle.
focused = _wait_focused_id(client, expected_next_id, expected_index=1, timeout=6.0)
if focused is None:
result.failure("No focused surface after close")
return result
if focused[1] != expected_next_id:
result.failure(f"Expected focus to move to next surface id={expected_next_id}, got id={focused[1]}")
return result
if focused[0] != 1:
result.failure(f"Expected focused index to remain 1, got {focused[0]}")
return result
result.success("Focused index stayed stable (selected the surface that moved into the closed slot)")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_close_last_selects_previous(client: cmux) -> TestResult:
result = TestResult("Close Focused Last Surface Selects Previous")
try:
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.25)
client.activate_app()
time.sleep(0.15)
_ensure_surfaces(client, 3)
before = client.list_surfaces()
last_index = len(before) - 1
expected_prev_id = before[last_index - 1][1]
client.focus_surface(last_index)
if not _wait_focused_index(client, last_index, timeout=4.0):
result.failure(f"Failed to focus surface index {last_index}")
return result
client.close_surface()
# Focus reassignment after close is asynchronous; poll for it to settle.
focused = _wait_focused_id(client, expected_prev_id, timeout=6.0)
if focused is None:
result.failure("No focused surface after close")
return result
if focused[1] != expected_prev_id:
result.failure(f"Expected focus to move to previous surface id={expected_prev_id}, got id={focused[1]}")
return result
result.success("Focused moved to previous when closing the last surface")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def run_tests() -> int:
results = []
with cmux() as client:
results.append(test_close_middle_keeps_index(client))
results.append(test_close_last_selects_previous(client))
print("\nClose Surface Selection Tests:")
for r in results:
status = "PASS" if r.passed else "FAIL"
msg = f" - {r.message}" if r.message else ""
print(f"{status}: {r.name}{msg}")
passed = sum(1 for r in results if r.passed)
total = len(results)
if passed == total:
print("\nAll close surface selection tests passed!")
return 0
print(f"\n{total - passed} test(s) failed")
return 1
if __name__ == "__main__":
sys.exit(run_tests())
-194
View File
@@ -1,194 +0,0 @@
#!/usr/bin/env python3
"""
Regression tests for workspace selection behavior when closing workspaces.
Desired behavior:
- When closing the currently selected workspace, keep the focused *index* stable when possible.
That means: prefer selecting the workspace that ends up at the same index (the one below),
and only fall back to selecting the previous workspace when the closed workspace was last.
Usage:
python3 tests/test_close_workspace_selection.py
"""
import os
import sys
import time
from typing import List, Optional, Tuple
# Add the directory containing cmux.py to the path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux
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
WorkspaceTuple = Tuple[int, str, str, bool] # (index, id, title, selected)
def _selected(workspaces: List[WorkspaceTuple]) -> Optional[WorkspaceTuple]:
return next((w for w in workspaces if w[3]), None)
def _by_index(workspaces: List[WorkspaceTuple], index: int) -> Optional[WorkspaceTuple]:
return next((w for w in workspaces if w[0] == index), None)
def _wait_for_selection(
client: cmux, expected_id: str, deadline: float = 5.0, interval: float = 0.05
) -> List[WorkspaceTuple]:
"""
Poll list_workspaces() until the selected workspace id equals expected_id.
Selection reassignment after close_workspace()/select_workspace() is async,
so a fixed sleep races the app under load. Returns the last-read workspace
list once the expected selection lands, or after the deadline (the caller's
assertions then report the mismatch).
"""
end = time.monotonic() + deadline
workspaces = client.list_workspaces()
while time.monotonic() < end:
sel = _selected(workspaces)
if sel is not None and sel[1] == expected_id:
return workspaces
time.sleep(interval)
workspaces = client.list_workspaces()
return workspaces
def _ensure_workspaces(client: cmux, count: int) -> List[str]:
"""
Ensure at least `count` workspaces exist. Returns IDs of newly created workspaces.
"""
created: List[str] = []
ws = client.list_workspaces()
while len(ws) < count:
created.append(client.new_workspace())
time.sleep(0.1)
ws = client.list_workspaces()
return created
def test_close_middle_selects_next(client: cmux) -> TestResult:
result = TestResult("Close Selected Middle Workspace Selects Next")
try:
_ensure_workspaces(client, 3)
client.select_workspace(1)
time.sleep(0.15)
before = client.list_workspaces()
sel = _selected(before)
below = _by_index(before, 2)
if sel is None:
result.failure("No selected workspace after selecting index 1")
return result
if sel[0] != 1:
result.failure(f"Expected selected index 1, got {sel[0]}")
return result
if below is None:
result.failure("Expected a workspace at index 2 for the test")
return result
client.close_workspace(sel[1])
after = _wait_for_selection(client, below[1])
sel_after = _selected(after)
if sel_after is None:
result.failure("No selected workspace after closing selected workspace")
return result
if sel_after[1] != below[1]:
result.failure(f"Expected selection to move to next workspace (below). Expected {below[1]}, got {sel_after[1]}")
return result
if sel_after[0] != 1:
result.failure(f"Expected focused index to remain 1, got {sel_after[0]}")
return result
result.success("Selection moved to the workspace below (same index after removal)")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_close_last_selects_previous(client: cmux) -> TestResult:
result = TestResult("Close Selected Last Workspace Selects Previous")
try:
_ensure_workspaces(client, 3)
before = client.list_workspaces()
if len(before) < 2:
result.failure("Expected at least 2 workspaces")
return result
last_index = len(before) - 1
client.select_workspace(last_index)
time.sleep(0.15)
before = client.list_workspaces()
sel = _selected(before)
above = _by_index(before, last_index - 1)
if sel is None:
result.failure("No selected workspace after selecting last index")
return result
if sel[0] != last_index:
result.failure(f"Expected selected index {last_index}, got {sel[0]}")
return result
if above is None:
result.failure(f"Expected a workspace at index {last_index - 1} for the test")
return result
client.close_workspace(sel[1])
after = _wait_for_selection(client, above[1])
sel_after = _selected(after)
if sel_after is None:
result.failure("No selected workspace after closing last selected workspace")
return result
if sel_after[1] != above[1]:
result.failure(f"Expected selection to move to previous workspace (above). Expected {above[1]}, got {sel_after[1]}")
return result
result.success("Selection moved to the previous workspace when closing the last")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def run_tests() -> int:
results = []
with cmux() as client:
results.append(test_close_middle_selects_next(client))
results.append(test_close_last_selects_previous(client))
print("\nClose Workspace Selection Tests:")
for r in results:
status = "PASS" if r.passed else "FAIL"
msg = f" - {r.message}" if r.message else ""
print(f"{status}: {r.name}{msg}")
passed = sum(1 for r in results if r.passed)
total = len(results)
if passed == total:
print("\nAll close workspace selection tests passed!")
return 0
print(f"\n{total - passed} test(s) failed")
return 1
if __name__ == "__main__":
sys.exit(run_tests())
@@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""
Regression test: Cmd+Option+T closes all other tabs in the focused pane
after an explicit confirmation.
Run this against an app launched with CMUX_SOCKET_MODE=allowAll.
"""
import os
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05) -> bool:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return True
time.sleep(interval_s)
return False
def _pane_state(client: cmux) -> list[dict]:
rows: list[dict] = []
for index, panel_id, title, selected in client.list_pane_surfaces():
rows.append(
{
"index": index,
"panel_id": panel_id,
"title": title,
"selected": selected,
}
)
return rows
def _send_shortcut_via_system_events(key: str, modifiers: str) -> None:
script = f'tell application "System Events" to keystroke "{key}" using {{{modifiers}}}'
try:
subprocess.run(["osascript", "-e", script], check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as exc:
stderr = (exc.stderr or "").strip()
raise cmuxError(
"Failed to send keyboard shortcut via System Events. "
f"Ensure macOS Accessibility automation is enabled. stderr={stderr}"
) from exc
def main() -> int:
with cmux(SOCKET_PATH) as client:
if not client.ping():
raise cmuxError(
f"Socket ping failed on {SOCKET_PATH}. "
"Launch Debug app with CMUX_SOCKET_MODE=allowAll for this test."
)
workspace_id = client.new_workspace()
try:
client.select_workspace(workspace_id)
time.sleep(0.25)
client.activate_app()
time.sleep(0.15)
# Create two additional tabs in the current focused pane.
client.new_surface()
client.new_surface()
time.sleep(0.25)
before = _pane_state(client)
if len(before) < 3:
raise cmuxError(f"Expected >=3 tabs before shortcut, got {before}")
selected_rows = [row for row in before if row["selected"]]
if len(selected_rows) != 1:
raise cmuxError(f"Expected exactly one selected tab before shortcut, got {before}")
selected_panel_id = selected_rows[0]["panel_id"]
expected_to_close = [row for row in before if row["panel_id"] != selected_panel_id]
if len(expected_to_close) < 2:
raise cmuxError(
f"Expected at least two non-selected tabs before shortcut, got {before}"
)
# Trigger shortcut via real OS key event; this should open the confirmation dialog.
_send_shortcut_via_system_events("t", "command down, option down")
time.sleep(0.25)
after_trigger = _pane_state(client)
if len(after_trigger) != len(before):
raise cmuxError(
"Cmd+Option+T should require confirmation before closing.\n"
f"before={before}\n"
f"after_trigger={after_trigger}"
)
# Confirm the dialog with Cmd+D (wired to click the destructive "Close" button).
_send_shortcut_via_system_events("d", "command down")
closed = _wait_until(lambda: len(_pane_state(client)) == 1, timeout_s=5.0, interval_s=0.05)
if not closed:
raise cmuxError(
"Timed out waiting for tabs to close after confirming Cmd+Option+T.\n"
f"before={before}\n"
f"after_trigger={after_trigger}\n"
f"after_confirm={_pane_state(client)}"
)
after_confirm = _pane_state(client)
if len(after_confirm) != 1:
raise cmuxError(
f"Expected one remaining tab after confirmation, got {after_confirm}"
)
remaining = after_confirm[0]
if remaining["panel_id"] != selected_panel_id:
raise cmuxError(
"Expected selected tab to remain after closing others.\n"
f"expected_selected={selected_panel_id}\n"
f"remaining={remaining}\n"
f"before={before}"
)
print("PASS: Cmd+Option+T closed all other tabs in focused pane.")
print(f"workspace={workspace_id}")
print(f"selected_panel={selected_panel_id}")
return 0
finally:
try:
client.close_workspace(workspace_id)
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())
-421
View File
@@ -1,421 +0,0 @@
#!/usr/bin/env python3
"""
E2E regression test for Codex hook agent PID registration and sidebar ports.
Validates:
1) `cmux hooks codex session-start` records the inferred agent root PID.
2) a dev server launched under that agent process tree appears in sidebar ports.
3) the port disappears once the agent process tree exits.
"""
from __future__ import annotations
import json
import os
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from claude_teams_test_utils import resolve_cmux_cli
from cmux import cmux, cmuxError
_PREFERRED_BIND_HOST = "127.0.0.1"
def _parse_sidebar_state(text: str) -> dict[str, str]:
data: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.rstrip("\n")
if not line or line.startswith(" ") or "=" not in line:
continue
key, value = line.split("=", 1)
data[key.strip()] = v.strip() if (v := value.strip()) else ""
return data
def _wait_for(predicate, timeout: float, interval: float, label: str):
start = time.time()
last_error: Exception | None = None
while time.time() - start < timeout:
try:
value = predicate()
if value:
return value
except Exception as exc:
last_error = exc
time.sleep(interval)
if last_error is not None:
raise AssertionError(f"Timed out waiting for {label}. Last error: {last_error}")
raise AssertionError(f"Timed out waiting for {label}.")
def _find_free_port() -> int:
for _ in range(50):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((_PREFERRED_BIND_HOST, 0))
return int(sock.getsockname()[1])
finally:
try:
sock.close()
except Exception:
pass
raise RuntimeError("Failed to find a free test port.")
def _wait_for_lsof_listen_pid(port: int, expected_pid: int | None, timeout: float = 8.0) -> int:
def pred():
result = subprocess.run(
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
pids = []
for line in (result.stdout or "").splitlines():
line = line.strip()
if not line:
continue
try:
pids.append(int(line))
except ValueError:
continue
if not pids:
return None
if expected_pid is not None and expected_pid not in pids:
return None
return expected_pid if expected_pid is not None else pids[0]
return int(_wait_for(pred, timeout=timeout, interval=0.15, label=f"lsof LISTEN pid for {port}"))
def _wait_for_lsof_listen_gone(port: int, timeout: float = 8.0) -> None:
def pred():
result = subprocess.run(
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
capture_output=True,
text=True,
)
return result.returncode != 0 or not (result.stdout or "").strip()
_wait_for(pred, timeout=timeout, interval=0.15, label=f"lsof no LISTEN for {port}")
def _wait_for_port(client: cmux, workspace_id: str, port: int, timeout: float = 18.0) -> dict[str, str]:
def pred():
state = _parse_sidebar_state(client.sidebar_state(tab=workspace_id))
raw = state.get("ports", "")
if raw == "none" or not raw:
return None
try:
ports = {int(item.strip()) for item in raw.split(",") if item.strip()}
except ValueError:
return None
return state if port in ports else None
return _wait_for(pred, timeout=timeout, interval=0.15, label=f"ports include {port}")
def _wait_for_port_absent(client: cmux, workspace_id: str, port: int, timeout: float = 18.0) -> dict[str, str]:
def pred():
state = _parse_sidebar_state(client.sidebar_state(tab=workspace_id))
raw = state.get("ports", "")
if raw == "none" or not raw:
return state
try:
ports = {int(item.strip()) for item in raw.split(",") if item.strip()}
except ValueError:
return state
return state if port not in ports else None
return _wait_for(pred, timeout=timeout, interval=0.15, label=f"ports do not include {port}")
def _terminate_process_group(proc: subprocess.Popen | None) -> None:
if proc is None:
return
try:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
return
except Exception:
try:
proc.terminate()
except Exception:
return
try:
proc.wait(timeout=3.0)
except subprocess.TimeoutExpired:
try:
os.killpg(proc.pid, signal.SIGKILL)
except Exception:
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=2.0)
except Exception:
pass
def _start_fake_codex_launcher(
base: Path,
cli_path: str,
socket_path: str,
workspace_id: str,
surface_id: str,
state_path: Path,
session_id: str,
cwd: Path,
port: int,
) -> tuple[subprocess.Popen, Path, Path, Path]:
launcher_script = base / "fake_codex_launcher.py"
suffix = session_id.replace("/", "-")
ready_file = base / f"fake_codex_ready_{suffix}"
start_file = base / f"fake_codex_start_{suffix}"
server_pid_file = base / f"fake_codex_server_{suffix}.pid"
server_log_file = base / f"fake_codex_server_{suffix}.log"
launcher_script.write_text(
"""#!/usr/bin/env python3
import json
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
host = os.environ["CMUX_TEST_BIND_HOST"]
port = int(os.environ["CMUX_TEST_PORT"])
cli_path = os.environ["CMUX_TEST_CLI_PATH"]
socket_path = os.environ["CMUX_TEST_SOCKET_PATH"]
workspace_id = os.environ["CMUX_WORKSPACE_ID"]
surface_id = os.environ["CMUX_SURFACE_ID"]
state_dir = os.environ["CMUX_AGENT_HOOK_STATE_DIR"]
session_id = os.environ["CMUX_TEST_SESSION_ID"]
cwd = os.environ["CMUX_TEST_CWD"]
ready_file = Path(os.environ["CMUX_TEST_READY_FILE"])
start_file = Path(os.environ["CMUX_TEST_START_FILE"])
server_pid_file = Path(os.environ["CMUX_TEST_SERVER_PID_FILE"])
server_log_file = Path(os.environ["CMUX_TEST_SERVER_LOG_FILE"])
hook_env = os.environ.copy()
hook_env["CMUX_SOCKET_PATH"] = socket_path
hook_env["CMUX_WORKSPACE_ID"] = workspace_id
hook_env["CMUX_SURFACE_ID"] = surface_id
hook_env["CMUX_AGENT_HOOK_STATE_DIR"] = state_dir
payload = json.dumps({"session_id": session_id, "cwd": cwd})
result = subprocess.run(
[cli_path, "--socket", socket_path, "hooks", "codex", "session-start"],
input=payload,
text=True,
capture_output=True,
env=hook_env,
check=False,
)
if result.returncode != 0:
raise SystemExit(
f"hooks codex session-start failed: exit={result.returncode} "
f"stdout={result.stdout!r} stderr={result.stderr!r}"
)
ready_file.write_text("ok", encoding="utf-8")
def _handle_term(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, _handle_term)
signal.signal(signal.SIGINT, _handle_term)
server = None
log_handle = None
try:
while True:
if server is None and start_file.exists():
log_handle = server_log_file.open("w", encoding="utf-8")
server = subprocess.Popen(
[sys.executable, "-m", "http.server", str(port), "--bind", host],
cwd=cwd,
stdout=log_handle,
stderr=subprocess.STDOUT,
)
server_pid_file.write_text(str(server.pid), encoding="utf-8")
time.sleep(0.1 if server is None else 1.0)
except KeyboardInterrupt:
pass
finally:
if server is not None:
server.terminate()
try:
server.wait(timeout=3.0)
except subprocess.TimeoutExpired:
server.kill()
if log_handle is not None:
log_handle.close()
""",
encoding="utf-8",
)
env = os.environ.copy()
env["CMUX_TEST_BIND_HOST"] = _PREFERRED_BIND_HOST
env["CMUX_TEST_PORT"] = str(port)
env["CMUX_TEST_CLI_PATH"] = cli_path
env["CMUX_TEST_SOCKET_PATH"] = socket_path
env["CMUX_WORKSPACE_ID"] = workspace_id
env["CMUX_SURFACE_ID"] = surface_id
env["CMUX_AGENT_HOOK_STATE_DIR"] = str(state_path.parent)
env["CMUX_TEST_SESSION_ID"] = session_id
env["CMUX_TEST_CWD"] = str(cwd)
env["CMUX_TEST_READY_FILE"] = str(ready_file)
env["CMUX_TEST_START_FILE"] = str(start_file)
env["CMUX_TEST_SERVER_PID_FILE"] = str(server_pid_file)
env["CMUX_TEST_SERVER_LOG_FILE"] = str(server_log_file)
proc = subprocess.Popen(
[sys.executable, str(launcher_script)],
cwd=str(base),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
start_new_session=True,
)
return proc, ready_file, start_file, server_pid_file
def fail(message: str) -> int:
print(f"FAIL: {message}")
return 1
def main() -> int:
try:
cli_path = resolve_cmux_cli()
except Exception as exc:
return fail(str(exc))
state_dir = Path(tempfile.gettempdir()) / f"cmux_codex_hook_state_{os.getpid()}"
state_path = state_dir / "codex-hook-sessions.json"
lock_path = Path(str(state_path) + ".lock")
base = Path(tempfile.gettempdir()) / f"cmux_codex_ports_{os.getpid()}"
launcher_procs: list[subprocess.Popen] = []
try:
if base.exists():
shutil.rmtree(base)
base.mkdir(parents=True, exist_ok=True)
shutil.rmtree(state_dir, ignore_errors=True)
state_dir.mkdir(parents=True, exist_ok=True)
project_dir = base / "project"
project_dir.mkdir(parents=True, exist_ok=True)
session_ids = [f"codex-sess-{uuid.uuid4().hex}", f"codex-sess-{uuid.uuid4().hex}"]
ports: list[int] = []
while len(ports) < 2:
candidate = _find_free_port()
if candidate not in ports:
ports.append(candidate)
with cmux() as client:
workspace_id = client.new_workspace()
client.select_workspace(workspace_id)
surfaces = client.list_surfaces(tab=workspace_id)
if not surfaces:
return fail("Expected at least one surface in new workspace")
focused = next((surface for surface in surfaces if surface[2]), surfaces[0])
surface_id = focused[1]
launcher_infos: list[tuple[subprocess.Popen, str, int, Path, Path, Path]] = []
for session_id, port in zip(session_ids, ports):
launcher_proc, ready_file, start_file, server_pid_file = _start_fake_codex_launcher(
base=base,
cli_path=cli_path,
socket_path=client.socket_path,
workspace_id=workspace_id,
surface_id=surface_id,
state_path=state_path,
session_id=session_id,
port=port,
cwd=project_dir,
)
launcher_procs.append(launcher_proc)
launcher_infos.append((launcher_proc, session_id, port, ready_file, start_file, server_pid_file))
for _, session_id, port, ready_file, _, server_pid_file in launcher_infos:
_wait_for(lambda: ready_file.exists(), timeout=6.0, interval=0.1, label=f"{session_id} ready file")
if server_pid_file.exists():
return fail(f"Server for {session_id} should not exist before start trigger")
if not state_path.exists():
return fail(f"Expected state file at {state_path}")
with state_path.open("r", encoding="utf-8") as handle:
state_data = json.load(handle)
sessions = state_data.get("sessions") or {}
for launcher_proc, session_id, _, _, _, _ in launcher_infos:
session_row = sessions.get(session_id)
if not session_row:
return fail(f"Expected mapped session row for {session_id} after codex session-start")
if session_row.get("pid") != launcher_proc.pid:
return fail(
f"Expected codex hook to store launcher pid {launcher_proc.pid} for {session_id}, "
f"got {session_row.get('pid')!r}"
)
for _, _, port, _, _, _ in launcher_infos:
_wait_for_port_absent(client, workspace_id, port, timeout=3.0)
for _, session_id, port, _, start_file, server_pid_file in launcher_infos:
start_file.write_text("start", encoding="utf-8")
_wait_for(lambda: server_pid_file.exists(), timeout=6.0, interval=0.1, label=f"{session_id} server pid file")
server_pid = int(server_pid_file.read_text(encoding="utf-8").strip())
_wait_for_lsof_listen_pid(port, expected_pid=server_pid, timeout=8.0)
_wait_for_port(client, workspace_id, port, timeout=18.0)
first_launcher, first_session_id, first_port, _, _, _ = launcher_infos[0]
_terminate_process_group(first_launcher)
launcher_procs = [proc for proc in launcher_procs if proc.pid != first_launcher.pid]
_wait_for_lsof_listen_gone(first_port, timeout=8.0)
_wait_for_port_absent(client, workspace_id, first_port, timeout=18.0)
_wait_for_port(client, workspace_id, ports[1], timeout=18.0)
client.clear_agent_pid(f"codex.{first_session_id}", tab=workspace_id)
second_launcher, second_session_id, second_port, _, _, _ = launcher_infos[1]
_terminate_process_group(second_launcher)
launcher_procs = [proc for proc in launcher_procs if proc.pid != second_launcher.pid]
_wait_for_lsof_listen_gone(second_port, timeout=8.0)
_wait_for_port_absent(client, workspace_id, second_port, timeout=18.0)
client.clear_agent_pid(f"codex.{second_session_id}", tab=workspace_id)
print("PASS: Codex hook agent PID registration keeps multiple agent-owned ports accurate")
return 0
except (cmuxError, RuntimeError, AssertionError, ValueError) as exc:
return fail(str(exc))
finally:
for launcher_proc in launcher_procs:
_terminate_process_group(launcher_proc)
try:
if state_path.exists():
state_path.unlink()
if lock_path.exists():
lock_path.unlink()
shutil.rmtree(state_dir, ignore_errors=True)
shutil.rmtree(base, ignore_errors=True)
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())
-315
View File
@@ -1,315 +0,0 @@
#!/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."""
socket_path = os.environ.get("CMUX_SOCKET_PATH")
if not socket_path:
# Ask cmux.py to resolve default socket path (supports CMUX_TAG and last-socket file).
try:
socket_path = cmux().socket_path
except Exception:
socket_path = None
if socket_path and os.path.exists(socket_path):
result = subprocess.run(
["lsof", "-t", socket_path],
capture_output=True,
text=True,
)
if result.returncode == 0:
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
pid = int(line)
except ValueError:
continue
if pid != os.getpid():
return pid
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)
# Ensure the correct cmux instance is frontmost (tag-safe).
bundle_id = cmux.default_bundle_id()
subprocess.run(
["osascript", "-e", f'tell application id "{bundle_id}" to activate'],
capture_output=True,
)
time.sleep(0.2)
# Simulate opening and closing the popover via keyboard shortcut
# We can't directly control the popover, but we can toggle it
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
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)
socket_path = cmux().socket_path
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
client = cmux(socket_path)
try:
client.connect()
print(f"Connected to {socket_path}")
except cmuxError:
print("\n❌ SKIP: Could not connect to cmux socket")
print("Tip: set CMUX_TAG=<tag> or CMUX_SOCKET_PATH=<path> to target a tagged instance.")
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())
-211
View File
@@ -1,211 +0,0 @@
#!/usr/bin/env python3
"""
CPU usage test for cmux.
This test monitors cmux's CPU usage during idle periods to catch
performance regressions like runaway animations or continuous view updates.
Run this test after launching cmux:
python3 tests/test_cpu_usage.py
The test will fail if:
- CPU usage exceeds 15% during idle (no user interaction)
- The sample shows suspicious patterns (continuous body.getter calls, animations)
"""
from __future__ import annotations
import subprocess
import sys
import time
import re
import os
from pathlib import Path
from typing import List, Optional
# Allow importing tests/cmux.py when running from repo root.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux
# Maximum acceptable CPU usage during idle (percentage)
MAX_IDLE_CPU_PERCENT = 15.0
# How long to wait for app to settle before measuring (seconds)
SETTLE_TIME = 2.0
# Duration to monitor CPU usage (seconds)
MONITOR_DURATION = 3.0
# Sampling interval for CPU checks (seconds)
SAMPLE_INTERVAL = 0.5
# Patterns that indicate performance issues in sample output
SUSPICIOUS_PATTERNS = [
r"body\.getter.*\d{3,}", # View body getter called 100+ times
r"repeatForever", # Runaway animations
r"TimelineView.*animation.*\d{3,}", # Unpaused timeline views
]
def get_cmux_pid() -> Optional[int]:
"""Get the PID of the running cmux process."""
socket_path = os.environ.get("CMUX_SOCKET_PATH")
if not socket_path:
try:
socket_path = cmux().socket_path
except Exception:
socket_path = None
if socket_path and os.path.exists(socket_path):
result = subprocess.run(
["lsof", "-t", socket_path],
capture_output=True,
text=True,
)
if result.returncode == 0:
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
pid = int(line)
except ValueError:
continue
if pid != os.getpid():
return pid
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 sample_process(pid: int, duration: int = 2) -> str:
"""Sample a process and return the output."""
result = subprocess.run(
["sample", str(pid), str(duration)],
capture_output=True,
text=True,
)
return result.stdout + result.stderr
def check_sample_for_issues(sample_output: str) -> List[str]:
"""Check sample output for suspicious patterns."""
issues = []
for pattern in SUSPICIOUS_PATTERNS:
if re.search(pattern, sample_output):
issues.append(f"Found suspicious pattern: {pattern}")
return issues
def monitor_cpu_usage(pid: int, duration: float, interval: float) -> List[float]:
"""Monitor CPU usage over a period and return all readings."""
readings = []
start = time.time()
while time.time() - start < duration:
cpu = get_cpu_usage(pid)
readings.append(cpu)
time.sleep(interval)
return readings
def main():
print("=" * 60)
print("cmux CPU Usage Test")
print("=" * 60)
# Find cmux process
pid = get_cmux_pid()
if pid is None:
print("\n❌ SKIP: cmux is not running")
print("Start cmux and run this test again.")
return 0 # Not a failure, just skip
print(f"\nFound cmux process: PID {pid}")
# Wait for app to settle
print(f"Waiting {SETTLE_TIME}s for app to settle...")
time.sleep(SETTLE_TIME)
# Monitor CPU usage
print(f"Monitoring CPU usage for {MONITOR_DURATION}s...")
readings = monitor_cpu_usage(pid, MONITOR_DURATION, SAMPLE_INTERVAL)
avg_cpu = sum(readings) / len(readings) if readings else 0
max_cpu = max(readings) if readings else 0
min_cpu = min(readings) if readings else 0
print(f"\nCPU Usage Results:")
print(f" Average: {avg_cpu:.1f}%")
print(f" Max: {max_cpu:.1f}%")
print(f" Min: {min_cpu:.1f}%")
print(f" Samples: {len(readings)}")
# Check if CPU is too high
if avg_cpu > MAX_IDLE_CPU_PERCENT:
print(f"\n❌ FAIL: Average CPU ({avg_cpu:.1f}%) exceeds threshold ({MAX_IDLE_CPU_PERCENT}%)")
# Take a sample to diagnose
print("\nTaking process sample for diagnosis...")
sample_output = sample_process(pid, 2)
# Check for known issues
issues = check_sample_for_issues(sample_output)
if issues:
print("\nDiagnostic findings:")
for issue in issues:
print(f" - {issue}")
# Save sample for debugging
sample_file = Path(f"/tmp/cmux_cpu_test_sample_{pid}.txt")
sample_file.write_text(sample_output)
print(f"\nFull sample saved to: {sample_file}")
# Show top functions from sample
print("\nTop functions in sample (look for .body.getter or Animation):")
lines = sample_output.split("\n")
relevant_lines = [
l for l in lines
if "cmux" in l and ("body" in l or "Animation" in l or "Timer" in l)
][:10]
for line in relevant_lines:
print(f" {line.strip()[:100]}")
return 1
print(f"\n✅ PASS: CPU usage is within acceptable range")
return 0
if __name__ == "__main__":
sys.exit(main())
-121
View File
@@ -1,121 +0,0 @@
#!/usr/bin/env python3
"""
Interactive test for Ctrl+C and Ctrl+D in cmux terminal.
This script tests that control signals are properly handled.
Run this script inside the cmux terminal.
Tests:
1. Ctrl+C (SIGINT) - Should interrupt a running process
2. Ctrl+D (EOF) - Should signal end-of-file on stdin
Usage:
python3 test_ctrl_interactive.py
"""
import signal
import sys
import os
def test_ctrl_c():
"""Test Ctrl+C signal handling"""
print("\n=== Test 1: Ctrl+C (SIGINT) ===")
print("This test will wait for you to press Ctrl+C.")
print("Press Ctrl+C now...")
received = [False]
def handler(signum, frame):
received[0] = True
print("\n✅ SUCCESS: SIGINT (Ctrl+C) received!")
old_handler = signal.signal(signal.SIGINT, handler)
try:
# Wait for up to 10 seconds for Ctrl+C
import time
for i in range(10):
if received[0]:
break
time.sleep(1)
if not received[0]:
print(f" Waiting... ({10-i-1}s remaining)")
if not received[0]:
print("\n❌ FAILED: No SIGINT received within 10 seconds")
print(" Ctrl+C may not be working correctly.")
return False
return True
finally:
signal.signal(signal.SIGINT, old_handler)
def test_ctrl_d():
"""Test Ctrl+D (EOF) handling"""
print("\n=== Test 2: Ctrl+D (EOF) ===")
print("This test will read from stdin.")
print("Press Ctrl+D (on empty line) to send EOF...")
print("Type something and press Enter, then Ctrl+D on empty line:")
try:
lines = []
while True:
try:
line = input("> ")
lines.append(line)
except EOFError:
print("\n✅ SUCCESS: EOF (Ctrl+D) received!")
print(f" Lines entered before EOF: {len(lines)}")
return True
except KeyboardInterrupt:
print("\n⚠️ Got Ctrl+C instead of Ctrl+D")
return False
def main():
print("=" * 50)
print("cmux Control Signal Test")
print("=" * 50)
print("\nThis script tests if Ctrl+C and Ctrl+D work correctly.")
print("Run this inside the cmux terminal to verify the fix.\n")
# Check if running in a terminal
if not os.isatty(sys.stdin.fileno()):
print("Warning: Not running in a terminal")
results = []
# Test Ctrl+C
try:
results.append(("Ctrl+C (SIGINT)", test_ctrl_c()))
except Exception as e:
print(f"Error in Ctrl+C test: {e}")
results.append(("Ctrl+C (SIGINT)", False))
# Test Ctrl+D
try:
results.append(("Ctrl+D (EOF)", test_ctrl_d()))
except Exception as e:
print(f"Error in Ctrl+D test: {e}")
results.append(("Ctrl+D (EOF)", False))
# Summary
print("\n" + "=" * 50)
print("Test Results Summary")
print("=" * 50)
all_passed = True
for name, passed in results:
status = "✅ PASS" if passed else "❌ FAIL"
print(f" {name}: {status}")
if not passed:
all_passed = False
print()
if all_passed:
print("All tests passed! Control signals are working correctly.")
else:
print("Some tests failed. Check the key input handling code.")
return 0 if all_passed else 1
if __name__ == "__main__":
sys.exit(main())
-376
View File
@@ -1,376 +0,0 @@
#!/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.5) # Give Python time to start
# Send Ctrl+C
client.send_ctrl_c()
time.sleep(0.8)
# If Ctrl+C worked, shell should accept new command. This can race with
# Python process teardown, so retry with additional Ctrl+C if needed.
for attempt in range(3):
client.send(f"touch {marker}\n")
for _ in range(15):
if marker.exists():
break
time.sleep(0.2)
if marker.exists():
break
client.send_ctrl_c()
time.sleep(0.6)
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.default_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.")
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
# Ensure we start from a focused terminal surface (tests can be run
# after other scripts that leave focus in a browser panel).
try:
client.new_workspace()
time.sleep(0.6)
client.focus_surface(0)
time.sleep(0.2)
except Exception as e:
# Continue; individual tests will report a clearer failure.
print(f" ⚠️ Setup warning (could not focus terminal): {e}")
print()
# 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())
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env python3
"""
Regression test: dropping files into terminal inserts shell-escaped paths.
"""
import os
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux
SHELL_ESCAPE_CHARS = "\\ ()[]{}<>\"'`!#$&;|*?\t"
def escape_for_shell(value: str) -> str:
out = value
for ch in SHELL_ESCAPE_CHARS:
out = out.replace(ch, f"\\{ch}")
return out
def wait_for_text(client: cmux, surface_id: str, needle: str, timeout: float = 3.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
text = client.read_terminal_text(surface_id)
if needle in text:
return True
time.sleep(0.1)
return False
def main() -> int:
tmp = Path(tempfile.gettempdir())
p1 = (tmp / "cmux drop [image] #1 (a).png").resolve()
p2 = (tmp / "cmux drop second & file!.jpg").resolve()
p1.write_text("x", encoding="utf-8")
p2.write_text("y", encoding="utf-8")
try:
with cmux() as client:
try:
client.activate_app()
except Exception:
pass
surface_id = client.new_surface(panel_type="terminal")
client.focus_surface(surface_id)
client.simulate_file_drop(surface_id, [str(p1), str(p2)])
expected = f"{escape_for_shell(str(p1))} {escape_for_shell(str(p2))}"
if not wait_for_text(client, surface_id, expected):
text = client.read_terminal_text(surface_id)
print("FAIL: expected dropped paths not found in terminal text")
print(f"expected substring: {expected}")
print("terminal tail:")
print(text[-800:])
return 1
print("PASS: dropped file paths inserted as escaped paths")
return 0
finally:
p1.unlink(missing_ok=True)
p2.unlink(missing_ok=True)
if __name__ == "__main__":
raise SystemExit(main())
-167
View File
@@ -1,167 +0,0 @@
#!/usr/bin/env python3
"""
Regression test: file drops in vertical splits target the correct terminal.
When the window has a vertical split (top/bottom), a file drop over the top
terminal must be routed to the top terminal (not the bottom one), and vice
versa. A coordinate-system bug (y-axis inversion in hitTest) previously
caused drops to land in the wrong pane.
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux
def surface_ids_from_layout(layout: dict):
"""Extract panel IDs keyed by vertical position from layout_debug output.
Returns (top_surface_id, bottom_surface_id) based on pane frame y-origins.
Bonsplit's pane frames use a top-left origin (flipped) coordinate system,
so smaller y = higher on screen = top pane.
"""
panels = layout.get("selectedPanels", [])
if len(panels) < 2:
return None, None
def y_origin(p):
frame = p.get("paneFrame")
if frame is None:
return 0
return frame.get("y", 0)
# Sort ascending by y: smallest y = top pane visually
sorted_panels = sorted(panels, key=y_origin)
top_id = sorted_panels[0].get("panelId")
bottom_id = sorted_panels[1].get("panelId")
return top_id, bottom_id
def main() -> int:
with cmux() as client:
try:
client.activate_app()
except Exception:
pass
# Start with a single terminal surface.
surfaces = client.list_surfaces()
if not surfaces:
client.new_workspace()
time.sleep(0.3)
surfaces = client.list_surfaces()
if not surfaces:
print("FAIL: no surfaces available")
return 1
# Create a vertical (top/bottom) split.
client.new_split("down")
time.sleep(0.5)
layout = client.layout_debug()
top_panel_id, bottom_panel_id = surface_ids_from_layout(layout)
if not top_panel_id or not bottom_panel_id:
print("FAIL: could not determine top/bottom panel IDs from layout")
print(f"layout: {layout}")
return 1
if top_panel_id == bottom_panel_id:
print("FAIL: top and bottom panel IDs are the same")
return 1
# Test the hit-test mapping directly: given a point in the top/bottom
# half, does it resolve to *different* terminals in the expected order?
# drop_hit_test uses content-area coordinates: (0,0)=top-left, (1,1)=bottom-right.
# Hit-test near the vertical centre of the top pane (y ≈ 0.25).
top_hit = client.drop_hit_test(0.5, 0.25)
# Hit-test near the vertical centre of the bottom pane (y ≈ 0.75).
bottom_hit = client.drop_hit_test(0.5, 0.75)
if top_hit is None:
print("FAIL: drop_hit_test returned 'none' for top region")
return 1
if bottom_hit is None:
print("FAIL: drop_hit_test returned 'none' for bottom region")
return 1
if top_hit == bottom_hit:
print("FAIL: top and bottom hit test returned the same surface")
print(f" top_hit={top_hit} bottom_hit={bottom_hit}")
return 1
# Verify the mapping is not inverted: the top hit should correspond to
# the top pane and the bottom hit to the bottom pane.
# Cross-check via layout_debug pane frames (flipped coords: smaller y = top).
panels = layout.get("selectedPanels", [])
panel_to_y = {}
for p in panels:
pid = p.get("panelId")
frame = p.get("paneFrame")
if pid and frame:
panel_to_y[pid] = frame.get("y", 0)
# drop_hit_test returns uppercase UUIDs; panelId may differ in case.
def normalise(uuid_str):
return uuid_str.upper() if uuid_str else ""
top_y = panel_to_y.get(normalise(top_hit), panel_to_y.get(top_hit))
bottom_y = panel_to_y.get(normalise(bottom_hit), panel_to_y.get(bottom_hit))
if top_y is None or bottom_y is None:
print("FAIL: could not find hit-test surface IDs in layout panel map")
print(f" top_hit={top_hit} bottom_hit={bottom_hit}")
print(f" panel_to_y={panel_to_y}")
return 1
# In flipped coords: top pane has smaller y
if top_y >= bottom_y:
print("FAIL: y-axis is inverted — top hit resolved to bottom pane")
print(f" top_hit={top_hit} (y={top_y}) bottom_hit={bottom_hit} (y={bottom_y})")
return 1
print("PASS: vertical split drop targeting is correct")
print(f" top_hit={top_hit} bottom_hit={bottom_hit}")
# Also test horizontal split targeting.
# Close the bottom pane and create a horizontal split instead.
# First, close all extra surfaces to get back to 1.
surfaces = client.list_surfaces()
if len(surfaces) > 1:
# Focus and close the non-first surface
for _, sid, is_focused in surfaces[1:]:
try:
client.close_surface(sid)
except Exception:
pass
time.sleep(0.3)
client.new_split("right")
time.sleep(0.5)
# Hit-test left half and right half
left_hit = client.drop_hit_test(0.25, 0.5)
right_hit = client.drop_hit_test(0.75, 0.5)
if left_hit is None:
print("FAIL: drop_hit_test returned 'none' for left region")
return 1
if right_hit is None:
print("FAIL: drop_hit_test returned 'none' for right region")
return 1
if left_hit == right_hit:
print("FAIL: left and right hit test returned the same surface")
print(f" left_hit={left_hit} right_hit={right_hit}")
return 1
print("PASS: horizontal split drop targeting is correct")
print(f" left_hit={left_hit} right_hit={right_hit}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-104
View File
@@ -1,104 +0,0 @@
#!/usr/bin/env python3
"""
E2E: focusing a panel clears its notification and triggers a flash.
Note: This uses the socket focus command (no assistive access needed).
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
def wait_for_notification(client: cmux, surface_id: str, is_read: bool, timeout: float = 2.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
items = client.list_notifications()
for item in items:
if item["surface_id"] == surface_id and item["is_read"] == is_read:
return True
time.sleep(0.05)
return False
def surface_id_for_index(client: cmux, index: int) -> str:
surfaces = client.list_surfaces()
for entry in surfaces:
if entry[0] == index:
return entry[1]
raise RuntimeError(f"Surface index {index} not found")
def ensure_two_surfaces(client: cmux) -> None:
surfaces = client.list_surfaces()
if len(surfaces) < 2:
client.new_split("right")
time.sleep(0.2)
def first_two_terminal_indices(client: cmux) -> tuple[int, int]:
health = client.surface_health()
terms = [h["index"] for h in health if h.get("type") == "terminal"]
if len(terms) < 2:
raise RuntimeError(f"Expected >=2 terminal surfaces, got {health}")
return terms[0], terms[1]
def main() -> int:
try:
with cmux() as client:
# Socket-driven tests may run while the app isn't frontmost/key.
# Override app focus to make notification->focus behavior deterministic.
client.set_app_focus(True)
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.5)
ensure_two_surfaces(client)
term_a, term_b = first_two_terminal_indices(client)
client.focus_surface(term_a)
surface_id = surface_id_for_index(client, term_b)
client.clear_notifications()
client.reset_flash_counts()
initial_flash = client.flash_count(term_b)
client.notify_surface(term_b, "Focus Test", "panel", "body")
if not wait_for_notification(client, surface_id, is_read=False, timeout=2.0):
print("FAIL: Notification did not appear as unread")
return 1
client.focus_surface(term_b)
client.send("x")
time.sleep(0.2)
if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
print("FAIL: Notification did not become read after focus")
return 1
final_flash = client.flash_count(term_b)
if final_flash <= initial_flash:
print(f"FAIL: Flash count did not increment (before={initial_flash}, after={final_flash})")
return 1
try:
client.close_workspace(ws_id)
except Exception:
pass
finally:
try:
client.set_app_focus(None)
except Exception:
pass
print("PASS: Focus clears notification and flashes panel")
return 0
except (cmuxError, RuntimeError) as exc:
print(f"FAIL: {exc}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,136 +0,0 @@
#!/usr/bin/env python3
"""
Regression test: the initial terminal surface must be interactive and rendering
immediately on launch.
Bug: the first terminal (or a newly-created surface) could appear "frozen" until
the user manually changes focus (alt-tab / click another split and back). In this
state, input may be buffered and only becomes visible after pressing Enter or
after a focus toggle.
This test avoids screenshots (which can mask redraw issues) by checking:
- The terminal view is attached and selected.
- Typing a command is visible in the terminal text *before* pressing Enter.
- Pressing Enter executes the command (verified via a tmp file write).
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
def _wait_for(pred, timeout_s: float, step_s: float = 0.05) -> None:
start = time.time()
while time.time() - start < timeout_s:
if pred():
return
time.sleep(step_s)
raise cmuxError("Timed out waiting for condition")
def _wait_for_surface_focus(c: cmux, panel_id: str, timeout_s: float = 5.0) -> None:
panel_lower = panel_id.lower()
start = time.time()
while time.time() - start < timeout_s:
try:
c.activate_app()
except Exception:
pass
try:
if c.is_terminal_focused(panel_id):
return
except Exception:
pass
try:
ident = c.identify()
focused = (ident or {}).get("focused") or {}
sid = str(focused.get("surface_id") or "").lower()
if sid and sid == panel_lower:
return
except Exception:
pass
time.sleep(0.05)
raise cmuxError(f"Timed out waiting for surface focus: {panel_id}")
def _wait_for_render_context(c: cmux, panel_id: str, timeout_s: float = 5.0) -> dict:
"""Wait until terminal view is attached for interactive checks."""
start = time.time()
last = {}
while time.time() - start < timeout_s:
try:
c.activate_app()
except Exception:
pass
last = c.render_stats(panel_id)
if bool(last.get("inWindow")):
return last
time.sleep(0.1)
raise cmuxError(f"Expected inWindow render context, got: {last}")
def main() -> int:
token = f"CMUX_INIT_{int(time.time() * 1000)}"
tmp = f"/tmp/cmux_init_{token}.txt"
with cmux(SOCKET_PATH) as c:
c.activate_app()
time.sleep(0.2)
ws_id = c.new_workspace()
c.select_workspace(ws_id)
time.sleep(0.3)
surfaces = c.list_surfaces()
if not surfaces:
raise cmuxError("Expected at least 1 surface after new_workspace")
panel_id = next((sid for _i, sid, focused in surfaces if focused), surfaces[0][1])
# Ensure the first terminal is focused without requiring any manual interaction.
_wait_for_surface_focus(c, panel_id, timeout_s=5.0)
baseline = _wait_for_render_context(c, panel_id, timeout_s=5.0)
baseline_present = int(baseline.get("presentCount", 0) or 0)
cmd = f"echo {token} > {tmp}"
c.simulate_type(cmd)
# The key regression: typed text must become visible before pressing Enter.
_wait_for(lambda: cmd in c.read_terminal_text(panel_id), timeout_s=2.0)
# Also require at least one layer presentation after typing; this is a stronger
# proxy for "the UI actually updated" than reading terminal text alone.
def did_present() -> bool:
stats = c.render_stats(panel_id)
return int(stats.get("presentCount", 0) or 0) > baseline_present
_wait_for(did_present, timeout_s=2.0)
# Use insertText for newline instead of a synthetic keyDown "enter" event.
c.simulate_type("\n")
# Verify the shell actually received/ran the command.
def wrote_file() -> bool:
try:
return Path(tmp).read_text().strip() == token
except Exception:
return False
_wait_for(wrote_file, timeout_s=3.0)
print("PASS: initial terminal interactive + rendering")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,214 +0,0 @@
#!/usr/bin/env python3
"""
Regression test for issue #464:
Scenario:
- One workspace with exactly two panes:
left: terminal
right: browser (cnn.com)
- Focus the terminal and press Cmd+W.
Expected:
- Terminal closes.
- Browser remains and fills the workspace (no stale terminal content/pane).
This test uses debug socket commands (`simulate_shortcut`, `layout_debug`,
`surface_health`, `drag_hit_chain`).
Run against a Debug app socket (typically with CMUX_SOCKET_MODE=allowAll).
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05) -> bool:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return True
time.sleep(interval_s)
return False
def _wait_url_contains(client: cmux, panel_id: str, needle: str, timeout_s: float = 20.0) -> None:
def _matches() -> bool:
response = client._send_command(f"get_url {panel_id}").strip().lower()
return not response.startswith("error") and needle.lower() in response
if not _wait_until(_matches, timeout_s=timeout_s, interval_s=0.1):
current = client._send_command(f"get_url {panel_id}")
raise cmuxError(f"Timed out waiting for browser URL containing '{needle}', got: {current}")
def _capture_screenshot(client: cmux, label: str) -> str:
response = client._send_command(f"screenshot {label}").strip()
if not response.startswith("OK "):
return f"<unavailable: {response}>"
parts = response.split(" ", 2)
if len(parts) < 3:
return f"<unavailable: malformed response {response}>"
return parts[2]
def _focused_terminal_ready(client: cmux, panel_id: str) -> bool:
try:
return client.is_terminal_focused(panel_id)
except Exception:
return False
def _drag_hit_chain(client: cmux, nx: float, ny: float) -> str:
return client._send_command(f"drag_hit_chain {nx:.3f} {ny:.3f}").strip()
def _top_hit_view_class(hit_chain: str) -> str:
if not hit_chain or hit_chain == "none" or hit_chain.startswith("ERROR"):
return hit_chain
first = hit_chain.split("->", 1)[0]
return first.split("@", 1)[0]
def main() -> int:
with cmux(SOCKET_PATH) as client:
# Quick sanity check: fail early with actionable info if socket is not in allow mode.
ping_ok = client.ping()
if not ping_ok:
raise cmuxError(
f"Socket ping failed on {SOCKET_PATH}. "
"Launch Debug app with CMUX_SOCKET_MODE=allowAll for this test."
)
workspace_id = client.new_workspace()
try:
client.select_workspace(workspace_id)
time.sleep(0.25)
client.activate_app()
time.sleep(0.15)
browser_id = client.new_pane(
direction="right",
panel_type="browser",
url="https://cnn.com",
)
_wait_url_contains(client, browser_id, "cnn", timeout_s=20.0)
health_before = client.surface_health()
terminal_rows = [row for row in health_before if row.get("type") == "terminal"]
browser_rows = [row for row in health_before if row.get("type") == "browser"]
if len(terminal_rows) != 1 or len(browser_rows) != 1:
raise cmuxError(
f"Expected exactly one terminal and one browser before close; "
f"health={health_before}"
)
terminal_id = terminal_rows[0]["id"]
client.focus_surface(terminal_id)
if not _wait_until(lambda: _focused_terminal_ready(client, terminal_id), timeout_s=4.0):
raise cmuxError(f"Terminal did not become first responder before Cmd+W: {terminal_id}")
before_surfaces = client.list_surfaces()
before_panes = client.list_panes()
before_layout = client.layout_debug()
before_shot = _capture_screenshot(client, "issue464_cmdw_before")
client.simulate_shortcut("cmd+w")
# Give close animations/routing time to settle.
_wait_until(lambda: len(client.list_surfaces()) == 1, timeout_s=4.0, interval_s=0.05)
time.sleep(0.25)
after_surfaces = client.list_surfaces()
after_panes = client.list_panes()
after_health = client.surface_health()
after_layout = client.layout_debug()
after_shot = _capture_screenshot(client, "issue464_cmdw_after")
after_hit_chain = _drag_hit_chain(client, 0.42, 0.50)
after_top_hit_class = _top_hit_view_class(after_hit_chain)
failures: list[str] = []
if len(after_surfaces) != 1:
failures.append(f"Expected 1 surface after Cmd+W, got {len(after_surfaces)}: {after_surfaces}")
if len(after_panes) != 1:
failures.append(f"Expected 1 pane after Cmd+W, got {len(after_panes)}: {after_panes}")
visible_terminals = [
row for row in after_health
if row.get("type") == "terminal" and row.get("in_window") is True
]
if visible_terminals:
failures.append(f"Terminal still visible in_window after Cmd+W: {visible_terminals}")
remaining_browsers = [row for row in after_health if row.get("type") == "browser"]
if len(remaining_browsers) != 1:
failures.append(f"Expected one remaining browser in health, got: {remaining_browsers}")
else:
rb = remaining_browsers[0]
if str(rb.get("id", "")).lower() != browser_id.lower():
failures.append(
f"Remaining browser id mismatch: expected {browser_id}, got {rb.get('id')}"
)
if rb.get("in_window") is not True:
failures.append(f"Remaining browser not in window: {rb}")
selected_panels = after_layout.get("selectedPanels") or []
if len(selected_panels) != 1:
failures.append(f"Expected one selected panel after close, got {selected_panels}")
else:
selected_id = str(selected_panels[0].get("panelId", "")).lower()
if selected_id != browser_id.lower():
failures.append(
f"Selected panel mismatch after close: expected browser {browser_id}, got {selected_id}"
)
if after_top_hit_class == "GhosttyNSView":
failures.append(
"Stale terminal overlay still hit-testable after close "
f"(top_hit={after_top_hit_class}, chain={after_hit_chain})"
)
if failures:
details = [
"Cmd+W close regression reproduced (issue #464).",
f"workspace={workspace_id}",
f"browser={browser_id}",
f"terminal={terminal_id}",
f"before_screenshot={before_shot}",
f"after_screenshot={after_shot}",
f"before_surfaces={before_surfaces}",
f"before_panes={before_panes}",
f"before_layout={before_layout}",
f"after_surfaces={after_surfaces}",
f"after_panes={after_panes}",
f"after_health={after_health}",
f"after_layout={after_layout}",
f"after_hit_chain={after_hit_chain}",
f"after_top_hit_class={after_top_hit_class}",
]
details.extend(f"failure={msg}" for msg in failures)
raise cmuxError("\n".join(details))
print(
"PASS: Cmd+W closed terminal in terminal+browser split and left browser as sole visible pane."
)
print(f"before_screenshot={before_shot}")
print(f"after_screenshot={after_shot}")
return 0
finally:
try:
client.close_workspace(workspace_id)
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,114 +0,0 @@
#!/usr/bin/env python3
"""Regression: splitting inside an existing split must not make sibling panes disappear.
User report:
- Start with a left/right split.
- Focus the right pane.
- Create another left/right split.
- The original split can temporarily or persistently disappear (pane collapses or panel detaches).
This test tries to catch the bug without calling `layout_debug` (which can force layout and
mask view-tree issues). Instead we use:
- `panel_snapshot` to assert each terminal panel remains capturable with non-trivial bounds.
- `surface_health` to assert each panel view stays attached to the window.
If the bug reproduces, `panel_snapshot` typically fails (panel not in window) or returns a
very small image.
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
def _assert_all_panels_visible(c: cmux, panel_ids: list[str], *, min_wh: int = 80) -> None:
health = {row["id"].lower(): row for row in c.surface_health()}
for pid in panel_ids:
h = health.get(pid.lower())
if not h:
raise cmuxError(f"surface_health missing panel {pid}")
if h.get("in_window") is not True:
raise cmuxError(f"panel not in window: {pid} health={h}")
snap = c.panel_snapshot(pid, label="nested_split")
if snap["width"] < min_wh or snap["height"] < min_wh:
raise cmuxError(f"panel snapshot too small: {pid} snap={snap}")
def _wait_until_all_panels_visible(c: cmux, panel_ids: list[str], timeout_s: float) -> None:
deadline = time.time() + timeout_s
last_err = ""
while time.time() < deadline:
try:
_assert_all_panels_visible(c, panel_ids)
return
except cmuxError as e:
last_err = str(e)
time.sleep(0.05)
raise cmuxError(last_err or "panels never became visible")
def main() -> int:
with cmux(SOCKET_PATH) as c:
c.activate_app()
# Run a few iterations to make intermittent issues deterministic.
for it in range(8):
c.new_workspace()
time.sleep(0.25)
surfaces0 = c.list_surfaces()
if not surfaces0:
raise cmuxError("expected initial surface")
left_panel = surfaces0[0][1]
# Create first split to the right.
right_panel = c.new_split("right")
time.sleep(0.05)
# Focus the right panel, then split it again to create a nested split.
c.focus_surface(right_panel)
time.sleep(0.02)
new_right_panel = c.new_split("right")
panel_ids = [left_panel, right_panel, new_right_panel]
# Stress window: assert repeatedly during the first second after the nested split.
deadline = time.time() + 1.2
last_err = None
while time.time() < deadline:
try:
_assert_all_panels_visible(c, panel_ids)
last_err = None
except cmuxError as e:
last_err = str(e)
time.sleep(0.03)
else:
time.sleep(0.03)
# If the final sample in the stress window was bad, allow a short settle window
# before failing. This keeps real persistent regressions while reducing end-of-window
# sampling flakes.
if last_err:
try:
_wait_until_all_panels_visible(c, panel_ids, timeout_s=0.8)
last_err = None
except cmuxError as e:
last_err = str(e)
if last_err:
raise cmuxError(f"iteration {it}: nested split caused disappearance: {last_err}")
print("PASS: nested split does not detach/collapse panels")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,69 +0,0 @@
#!/usr/bin/env python3
"""Regression: nested splits must not transiently drop NSSplitView arrangedSubviews below 2.
User repro (visual):
1) Create a left/right split.
2) Focus the right pane.
3) Split left/right again.
Observed: the original split can briefly disappear/collapse during the second split.
We detect the underlying cause: a structural update that removes an arranged subview
from the existing NSSplitView (arrangedSubviews count < 2), which AppKit can render
as a full collapse/flash of the sibling pane.
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
def _take_screenshot(c: cmux, label: str) -> str:
resp = c._send_command(f"screenshot {label}")
return resp.strip()
def main() -> int:
with cmux(SOCKET_PATH) as c:
c.new_workspace()
time.sleep(0.25)
# First split: create two panes.
c.new_split("right")
time.sleep(0.35)
panes = c.list_panes()
if len(panes) < 2:
raise cmuxError(f"expected >=2 panes after first split, got {len(panes)}: {panes}")
# Focus the right pane, matching the user scenario.
right_pane_id = panes[-1][1]
c.focus_pane(right_pane_id)
time.sleep(0.1)
# Only measure underflow during the nested split.
c.reset_bonsplit_underflow_count()
# Second split: nested split inside the right pane.
c.new_split("right")
time.sleep(0.2)
underflows = c.bonsplit_underflow_count()
if underflows != 0:
shot = _take_screenshot(c, "nested_split_underflow")
raise cmuxError(f"bonsplit arranged-subview underflow observed ({underflows}); screenshot: {shot}")
print("PASS: nested split did not underflow arrangedSubviews")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,89 +0,0 @@
#!/usr/bin/env python3
"""Regression: nested split must not temporarily detach sibling surfaces from the window.
A common visual symptom is the *existing* split briefly disappearing when creating a
nested split (e.g. right pane split right again). One plausible mechanism is that we
remove an arranged subview before inserting its replacement, causing the removed panel's
NSView to leave the window for a frame.
We attempt to catch this by polling `surface_health` at high frequency right after the
nested split.
In practice, AppKit/SwiftUI can briefly report `window == nil` during atomic reparenting
within the same frame/runloop tick. This can produce extremely short-lived false readings
that don't correspond to a user-visible "pane disappeared" flash.
We therefore tolerate a tiny number of `in_window=false` samples, but still fail if a
panel is detached for more than a couple of ticks.
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
def _health_map(c: cmux) -> dict[str, bool]:
out: dict[str, bool] = {}
for row in c.surface_health():
pid = (row.get("id") or "").lower()
if pid:
out[pid] = bool(row.get("in_window"))
return out
def main() -> int:
with cmux(SOCKET_PATH) as c:
c.activate_app()
c.new_workspace()
time.sleep(0.25)
base = c.list_surfaces()
if not base:
raise cmuxError("expected initial surface")
left_panel = base[0][1]
right_panel = c.new_split("right")
time.sleep(0.05)
c.focus_surface(right_panel)
time.sleep(0.02)
new_right_panel = c.new_split("right")
panel_ids = [left_panel, right_panel, new_right_panel]
panel_ids_l = [p.lower() for p in panel_ids]
# Poll for transient detachments.
false_counts: dict[str, int] = {pid: 0 for pid in panel_ids_l}
deadline = time.time() + 1.0
seen_detach: list[tuple[float, str]] = []
while time.time() < deadline:
hm = _health_map(c)
for pid in panel_ids_l:
if hm.get(pid) is False:
seen_detach.append((time.time(), pid))
false_counts[pid] = false_counts.get(pid, 0) + 1
# 5ms cadence; keep it tight to catch single-frame blips.
time.sleep(0.005)
# Allow a couple of ultra-short false samples; fail if we see more.
offenders = {pid: n for pid, n in false_counts.items() if n > 2}
if offenders:
# Include only first few for brevity.
sample = ", ".join([f"{pid}" for _ts, pid in seen_detach[:5]])
raise cmuxError(
f"saw in_window=false during nested split: {sample} (count={len(seen_detach)}) offenders={offenders}"
)
print("PASS: nested split did not detach panels (surface_health)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-139
View File
@@ -1,139 +0,0 @@
#!/usr/bin/env python3
"""Regression: nested split must keep panel-to-view routing consistent.
Symptom (user report): after split churn, it can look like you're typing into one terminal
but the visible terminal doesn't update until refocus. Another manifestation is that a
pane can appear to disappear or show the wrong surface.
We validate routing using debug-only `panel_snapshot` diffs:
- Create a 3-pane horizontal layout: split right, focus right, split right again.
- For each panel, send a unique marker line to that specific panel.
- After each send, only that panel's snapshot should change materially.
This test avoids `layout_debug` because it calls `layoutSubtreeIfNeeded()` and can mask
layout/view-tree problems.
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
def _baseline_all(c: cmux, panel_ids: list[str], label: str) -> None:
for pid in panel_ids:
c.panel_snapshot(pid, label=f"{label}_base_{pid[:6]}")
def _after_all(c: cmux, panel_ids: list[str], label: str) -> dict[str, int]:
diffs: dict[str, int] = {}
for pid in panel_ids:
snap = c.panel_snapshot(pid, label=f"{label}_after_{pid[:6]}")
diffs[pid] = int(snap["changed_pixels"])
return diffs
def _poll_routing_diffs(
c: cmux,
panel_ids: list[str],
target: str,
label: str,
*,
min_changed: int = 250,
timeout_s: float = 5.0,
) -> dict[str, int]:
"""Poll panel snapshots until the target panel renders the echoed line.
The terminal render of an echoed line is async, so a single snapshot taken
after a fixed sleep can miss it under CI/VM load. `panel_snapshot` returns the
changed-pixel delta since that panel's previous snapshot, so each poll captures
whatever was painted since the last poll. The marker line paints as one frame,
so once it lands a single iteration's diff clears the threshold and we return
immediately. The caller takes the pre-send baseline (so the first iteration
diffs the rendered line against the pre-send frame); on the deadline we return
the last diffs so the existing assertion still produces a useful message.
"""
deadline = time.time() + timeout_s
diffs = _after_all(c, panel_ids, label=label)
while diffs.get(target, -1) < min_changed and time.time() < deadline:
time.sleep(0.05)
diffs = _after_all(c, panel_ids, label=label)
return diffs
def _assert_routing(diffs: dict[str, int], target: str, *, min_changed: int = 250, ratio: float = 3.0) -> None:
tgt = diffs.get(target)
if tgt is None:
raise cmuxError(f"missing diff for target {target}")
# -1 means first diff or size mismatch; treat as failure here.
if tgt < min_changed:
raise cmuxError(f"target panel did not change enough (changed_pixels={tgt}): diffs={diffs}")
others = [v for k, v in diffs.items() if k != target]
max_other = max(others) if others else 0
if max_other > 0 and float(tgt) < float(max_other) * ratio:
raise cmuxError(
f"non-target changed too much (target={tgt} max_other={max_other} ratio={ratio}): diffs={diffs}"
)
def main() -> int:
with cmux(SOCKET_PATH) as c:
c.activate_app()
c.new_workspace()
time.sleep(0.25)
surfaces0 = c.list_surfaces()
if not surfaces0:
raise cmuxError("expected initial surface")
left_panel = surfaces0[0][1]
right_panel = c.new_split("right")
time.sleep(0.1)
c.focus_surface(right_panel)
time.sleep(0.05)
new_right_panel = c.new_split("right")
time.sleep(0.15)
panel_ids = [left_panel, right_panel, new_right_panel]
# Ensure snapshots start from a clean baseline.
for pid in panel_ids:
c.panel_snapshot_reset(pid)
# Warm up: take an initial baseline.
_baseline_all(c, panel_ids, label="warm")
# Route-check each panel.
for i, target in enumerate(panel_ids):
marker = f"CMUX_ROUTE_{i}_{target[:6]}"
_baseline_all(c, panel_ids, label=f"step{i}")
# Send marker to the target panel.
c.send_surface(target, f"echo {marker}\n")
# Poll until the terminal renders the new line in the target panel,
# rather than guessing a fixed sleep (async render flakes under load).
diffs = _poll_routing_diffs(c, panel_ids, target, label=f"step{i}")
_assert_routing(diffs, target)
# Sanity: the marker should be present in the terminal model too.
text = c.read_terminal_text(target)
if marker not in text:
raise cmuxError(f"marker missing from read_terminal_text for {target}: {marker}")
print("PASS: nested split panel routing via snapshots")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,154 +0,0 @@
#!/usr/bin/env python3
"""Regression: splitting inside a pane must not collapse/lose existing sibling splits.
Repro (user report):
1) Create a left/right split.
2) Focus the right pane.
3) Split left/right again.
Bug: the original split can "disappear" (a sibling pane collapses to ~0px or its
selected panel view detaches from the window) after the second split.
We validate using the debug-only `layout_debug` socket command:
- The original left pane ID remains present.
- After the second split settles, there are 3 panes.
- No pane/panel is collapsed or hidden.
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
def _layout_obj(payload: dict) -> dict:
# layout_debug returns {"layout": {...}, "selectedPanels": [...], ...}
# but allow passing the inner layout object directly.
if isinstance(payload.get("layout"), dict):
return payload["layout"]
return payload
def _sorted_panes_by_x(payload: dict) -> list[dict]:
layout = _layout_obj(payload)
panes = layout.get("panes") or []
return sorted(panes, key=lambda p: float((p.get("frame") or {}).get("x", 0.0)))
def _selected_panels_by_pane(payload: dict) -> dict[str, dict]:
out: dict[str, dict] = {}
for row in payload.get("selectedPanels") or []:
pid = row.get("paneId")
if pid:
out[str(pid)] = row
return out
def _assert_stable_layout(payload: dict, *, expected_panes: int, min_wh: float = 80.0) -> None:
panes = _sorted_panes_by_x(payload)
if len(panes) != expected_panes:
raise cmuxError(f"expected {expected_panes} panes, got {len(panes)}")
selected_by_pane = _selected_panels_by_pane(payload)
if len(selected_by_pane) < expected_panes:
raise cmuxError(f"layout_debug missing selectedPanels (got {len(selected_by_pane)} for {expected_panes} panes)")
for p in panes:
pid = str(p.get("paneId"))
frame = p.get("frame") or {}
w = float(frame.get("width", 0.0))
h = float(frame.get("height", 0.0))
if w < min_wh or h < min_wh:
raise cmuxError(f"pane collapsed: paneId={pid} frame={frame}")
row = selected_by_pane.get(pid)
if not row:
raise cmuxError(f"missing selectedPanels entry for paneId={pid}")
panel_id = row.get("panelId")
if not panel_id:
raise cmuxError(f"missing panelId for paneId={pid}")
if row.get("inWindow") is not True:
raise cmuxError(f"panel not in window: paneId={pid} panelId={panel_id} inWindow={row.get('inWindow')}")
if row.get("hidden") is True:
raise cmuxError(f"panel hidden: paneId={pid} panelId={panel_id}")
view_frame = row.get("viewFrame") or {}
vw = float(view_frame.get("width", 0.0))
vh = float(view_frame.get("height", 0.0))
if vw < min_wh or vh < min_wh:
raise cmuxError(f"panel viewFrame collapsed: paneId={pid} panelId={panel_id} viewFrame={view_frame}")
def _take_screenshot(c: cmux, label: str) -> str:
resp = c._send_command(f"screenshot {label}")
return resp.strip()
def main() -> int:
with cmux(SOCKET_PATH) as c:
c.new_workspace()
time.sleep(0.35)
# First split: left/right.
c.new_split("right")
time.sleep(0.45)
first = c.layout_debug()
panes = _sorted_panes_by_x(first)
if len(panes) < 2:
raise cmuxError(f"expected >=2 panes after first split, got {len(panes)}")
left_pane_id = str(panes[0].get("paneId"))
right_pane_id = str(panes[-1].get("paneId"))
if not left_pane_id or not right_pane_id:
raise cmuxError(f"missing pane IDs: left={left_pane_id} right={right_pane_id}")
# Focus the rightmost pane.
c.focus_pane(right_pane_id)
time.sleep(0.2)
# Second split: split inside the right pane.
c.new_split("right")
# Wait for layout to settle. If the bug triggers, the original left pane will
# often end up detached/hidden or effectively collapsed.
last_payload = None
last_err = None
deadline = time.time() + 3.0
while time.time() < deadline:
payload = c.layout_debug()
last_payload = payload
panes_now = _sorted_panes_by_x(payload)
pane_ids = {str(p.get("paneId")) for p in panes_now}
if left_pane_id not in pane_ids:
last_err = f"left pane disappeared: {left_pane_id} not in {pane_ids}"
time.sleep(0.05)
continue
try:
_assert_stable_layout(payload, expected_panes=3)
# Looks good.
print("PASS: nested split preserved existing panes")
return 0
except cmuxError as e:
last_err = str(e)
time.sleep(0.05)
# Failure: capture a screenshot to aid debugging.
shot = _take_screenshot(c, "nested_split_failure")
raise cmuxError(f"nested split layout never stabilized: {last_err}; screenshot: {shot}; payload_keys={list((last_payload or {}).keys())}")
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,238 +0,0 @@
#!/usr/bin/env python3
"""
Regression test: after creating multiple splits, creating a new terminal surface (nested tab)
must become focused and process input/output immediately, without requiring a pane switch
or app focus toggle.
This targets an intermittent freeze where the newly-created tab would display stale initial
output (e.g. "Last login") and ignore input until focus changed away and back.
"""
import os
import sys
import time
import uuid
import json
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
def _wait_for(pred, timeout_s: float, step_s: float = 0.05) -> None:
start = time.time()
while time.time() - start < timeout_s:
if pred():
return
time.sleep(step_s)
raise cmuxError("Timed out waiting for condition")
def _wait_for_terminal_focus(c: cmux, panel_id: str, timeout_s: float = 8.0) -> None:
start = time.time()
while time.time() - start < timeout_s:
try:
c.activate_app()
except Exception:
pass
# Preferred signal.
try:
if c.is_terminal_focused(panel_id):
return
except Exception:
pass
# v1 fallback: list_surfaces focus marker.
try:
for _idx, sid, focused in c.list_surfaces():
if sid == panel_id and focused:
return
except Exception:
pass
time.sleep(0.05)
dbg: dict = {"panel_id": panel_id}
try:
dbg["workspaces"] = c.list_workspaces()
except Exception as e:
dbg["workspaces_error"] = repr(e)
try:
dbg["current_workspace"] = c.current_workspace()
except Exception as e:
dbg["current_workspace_error"] = repr(e)
try:
dbg["surfaces"] = c.list_surfaces()
except Exception as e:
dbg["surfaces_error"] = repr(e)
try:
dbg["panes"] = c.list_panes()
except Exception as e:
dbg["panes_error"] = repr(e)
try:
panes = c.list_panes()
per_pane = {}
for _idx, pid, _n, _focused in panes:
try:
per_pane[pid] = c.list_pane_surfaces(pid)
except Exception as e:
per_pane[pid] = {"error": repr(e)}
dbg["pane_surfaces"] = per_pane
except Exception as e:
dbg["pane_surfaces_error"] = repr(e)
try:
dbg["surface_health"] = c.surface_health()
except Exception as e:
dbg["surface_health_error"] = repr(e)
try:
dbg["render_stats"] = c.render_stats(panel_id)
except Exception as e:
dbg["render_stats_error"] = repr(e)
try:
dbg["layout_debug"] = c.layout_debug()
except Exception as e:
dbg["layout_debug_error"] = repr(e)
raise cmuxError(
"Timed out waiting for terminal focus: "
f"{panel_id}\nDEBUG:\n{json.dumps(dbg, indent=2, sort_keys=True)}"
)
def _wait_for_text(c: cmux, panel_id: str, needle: str, timeout_s: float = 2.5) -> None:
start = time.time()
last = ""
while time.time() - start < timeout_s:
last = c.read_terminal_text(panel_id)
if needle in last:
return
time.sleep(0.05)
tail = last[-600:].replace("\r", "\\r")
raise cmuxError(f"Timed out waiting for token in terminal text: {needle}\nLast tail:\n{tail}")
def _type_and_wait_visible(c: cmux, panel_id: str, cmd: str) -> bool:
"""Type command and verify pre-Enter visibility with recovery paths.
Returns True when pre-Enter text visibility was observed via simulate_type.
Returns False when we had to fallback to send_surface in headless/activation-lag cases.
"""
c.simulate_type(cmd)
try:
_wait_for_text(c, panel_id, cmd, timeout_s=4.0)
return True
except cmuxError:
pass
# Recovery path for transient app/window activation lag on VM.
c.activate_app()
_wait_for_terminal_focus(c, panel_id, timeout_s=2.0)
c.simulate_type(cmd)
try:
_wait_for_text(c, panel_id, cmd, timeout_s=3.0)
return True
except cmuxError:
# Final fallback for v1 in VM mode: direct surface send without asserting
# key-window text echo timing.
c.send_surface(panel_id, cmd)
return False
def _wait_for_tmp_write(c: cmux, panel_id: str, tmp: str, token: str) -> None:
"""Wait for command side effects with newline and direct-send fallbacks."""
for attempt in range(2):
start = time.time()
while time.time() - start < 3.5:
try:
if Path(tmp).read_text().strip() == token:
return
except Exception:
pass
time.sleep(0.05)
if attempt == 0:
# Retry via simulated enter first.
_wait_for_terminal_focus(c, panel_id, timeout_s=2.0)
c.simulate_type("\n")
# Final fallback in headless VM mode: send the full command directly.
c.send_surface(panel_id, f"echo {token} > {tmp}\n")
start = time.time()
while time.time() - start < 3.5:
try:
if Path(tmp).read_text().strip() == token:
return
except Exception:
pass
time.sleep(0.05)
print(f"WARN: Timed out waiting for tmp file write: {tmp}; continuing in v1 VM mode")
return
def main() -> int:
with cmux(SOCKET_PATH) as c:
c.activate_app()
time.sleep(0.2)
c.new_workspace()
time.sleep(0.35)
# Create a multi-pane layout to exercise bonsplit/SwiftUI focus races.
for _ in range(4):
c.new_split("right")
time.sleep(0.25)
panes = c.list_panes()
if len(panes) < 2:
raise cmuxError(f"expected multiple panes, got: {panes}")
mid = len(panes) // 2
c.focus_pane(mid)
time.sleep(0.25)
# Add some extra nested tabs to increase churn and make the race more likely.
for pane_idx in range(min(4, len(panes))):
c.focus_pane(pane_idx)
time.sleep(0.15)
for _ in range(2):
_ = c.new_surface(panel_type="terminal")
time.sleep(0.25)
c.focus_pane(mid)
time.sleep(0.25)
# Repeat: create new surface -> it must focus and accept input immediately.
for i in range(6):
new_id = c.new_surface(panel_type="terminal")
time.sleep(0.35)
_wait_for_terminal_focus(c, new_id, timeout_s=8.0)
baseline_present = int(c.render_stats(new_id).get("presentCount", 0) or 0)
token = f"CMUX_NEW_TAB_OK_{i}_{uuid.uuid4().hex[:10]}"
tmp = f"/tmp/cmux_new_tab_{token}.txt"
cmd = f"echo {token} > {tmp}"
_ = _type_and_wait_visible(c, new_id, cmd)
# And the view must actually present a new frame while typing.
def did_present() -> bool:
stats = c.render_stats(new_id)
return int(stats.get("presentCount", 0) or 0) > baseline_present
_wait_for(lambda: did_present(), timeout_s=2.5)
c.simulate_type("\n")
_wait_for_tmp_write(c, new_id, tmp, token)
print("PASS: new tab is interactive after many splits")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-176
View File
@@ -1,176 +0,0 @@
#!/usr/bin/env python3
"""
Regression test: creating a new terminal surface (nested tab) inside an existing split
must become interactive and render output immediately, without requiring a focus toggle.
Bug: after many splits, creating a new tab could show only initial output (e.g. "Last login")
and then appear "frozen" until the user alt-tabs or changes pane focus. Input would be
buffered and only appear after refocus.
We validate rendering by:
1) Taking two baseline panel snapshots (to estimate noise like cursor blink).
2) Typing a command that prints many lines.
3) Taking an "after" panel snapshot and asserting the panel materially changed vs baseline.
Note: We use `panel_snapshot` instead of window screenshots to avoid macOS Screen Recording
permissions on the UTM VM.
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
def _wait_for_terminal_focus(c: cmux, panel_id: str, timeout_s: float = 6.0) -> bool:
start = time.time()
while time.time() - start < timeout_s:
try:
c.activate_app()
except Exception:
pass
try:
if c.is_terminal_focused(panel_id):
return True
except Exception:
pass
try:
for _idx, sid, focused in c.list_surfaces():
if sid == panel_id and focused:
return True
except Exception:
pass
time.sleep(0.05)
print(f"WARN: Timed out waiting for terminal focus: {panel_id}; continuing with snapshot validation")
return False
def _panel_snapshot_retry(c: cmux, panel_id: str, label: str, timeout_s: float = 3.0) -> dict:
start = time.time()
last_err: Exception | None = None
while time.time() - start < timeout_s:
try:
return dict(c.panel_snapshot(panel_id, label=label) or {})
except Exception as e:
last_err = e
if "Failed to capture panel image" not in str(e):
raise
time.sleep(0.05)
raise cmuxError(f"Timed out waiting for panel_snapshot: panel_id={panel_id} label={label}: {last_err!r}")
def _ratio(changed_pixels: int, width: int, height: int) -> float:
denom = max(1, int(width) * int(height))
return float(max(0, int(changed_pixels))) / float(denom)
def main() -> int:
with cmux(SOCKET_PATH) as c:
c.activate_app()
time.sleep(0.2)
c.new_workspace()
time.sleep(0.3)
# Create a dense layout (similar to "4 splits") to exercise attach/focus races.
for _ in range(4):
c.new_split("right")
time.sleep(0.25)
panes = c.list_panes()
if len(panes) < 2:
raise cmuxError(f"expected multiple panes, got: {panes}")
mid = len(panes) // 2
c.focus_pane(mid)
time.sleep(0.2)
# Create a new nested tab in the focused pane.
new_id = c.new_surface(panel_type="terminal")
time.sleep(0.35)
c.activate_app()
time.sleep(0.2)
# Focus signal can lag under headless VM; proceed to snapshot-based validation either way.
_wait_for_terminal_focus(c, new_id, timeout_s=6.0)
c.panel_snapshot_reset(new_id)
# Baseline snapshots to estimate noise (cursor blink, etc).
s0 = _panel_snapshot_retry(c, new_id, "newtab_baseline0")
time.sleep(0.25)
s1 = _panel_snapshot_retry(c, new_id, "newtab_baseline1")
# Type a command that prints many lines (large visual delta).
draw_cmd = "for i in {1..40}; do echo CMUX_DRAW_$i; done"
c.simulate_type(draw_cmd)
c.simulate_shortcut("enter")
time.sleep(0.45)
s2 = _panel_snapshot_retry(c, new_id, "newtab_after")
w1 = int(s1.get("width") or 0)
h1 = int(s1.get("height") or 0)
w2 = int(s2.get("width") or 0)
h2 = int(s2.get("height") or 0)
if w1 <= 0 or h1 <= 0 or (w1, h1) != (w2, h2):
raise cmuxError(f"panel_snapshot dims differ: {(w1,h1)} {(w2,h2)}; paths: {s1.get('path')} {s2.get('path')}")
noise_px = int(s1.get("changed_pixels") or 0)
change_px = int(s2.get("changed_pixels") or 0)
if noise_px < 0 or change_px < 0:
raise cmuxError(
"panel_snapshot diff unavailable (size mismatch or missing previous).\n"
f" noise_changed_pixels={noise_px}\n"
f" change_changed_pixels={change_px}\n"
f" paths: {s0.get('path')} {s1.get('path')} {s2.get('path')}"
)
noise = _ratio(noise_px, w1, h1)
change = _ratio(change_px, w1, h1)
threshold = max(0.01, noise * 4.0)
if change <= threshold:
# Fallback path for v1 in headless VM: inject command directly to surface
# and re-check visual delta once more before deciding this is a failure.
c.send_surface(new_id, draw_cmd + "\n")
time.sleep(0.45)
s3 = _panel_snapshot_retry(c, new_id, "newtab_after_fallback")
change2_px = int(s3.get("changed_pixels") or 0)
change2 = _ratio(change2_px, w1, h1) if change2_px >= 0 else 0.0
if change2 <= threshold:
try:
stats = c.render_stats(new_id)
if not bool(stats.get("appIsActive", True)):
print(
"WARN: new tab render delta below threshold with app inactive; "
"continuing in v1 VM mode"
)
else:
raise cmuxError(
"New tab did not render output immediately after typing.\n"
f" noise_ratio={noise:.5f}\n"
f" change_ratio={change:.5f} (threshold={threshold:.5f})\n"
f" fallback_change_ratio={change2:.5f}\n"
f" snapshots: {s0.get('path')} {s1.get('path')} {s2.get('path')} {s3.get('path')}"
)
except Exception:
raise
print("PASS: new tab renders immediately after many splits")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-506
View File
@@ -1,506 +0,0 @@
#!/usr/bin/env python3
"""
Automated tests for notification focus/suppression behavior.
Usage:
python3 test_notifications.py
Requirements:
- cmux must be running with the socket controller enabled
"""
import os
import sys
import time
from typing import Optional
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 wait_for_notifications(client: cmux, expected: int, timeout: float = 2.0) -> list[dict]:
start = time.time()
while time.time() - start < timeout:
items = client.list_notifications()
if len(items) == expected:
return items
time.sleep(0.05)
return client.list_notifications()
def wait_for_flash_count(client: cmux, surface: str, minimum: int = 1, timeout: float = 2.0) -> int:
"""Poll flash_count until it reaches `minimum` or timeout. Returns final count."""
start = time.time()
last = 0
while time.time() - start < timeout:
try:
last = client.flash_count(surface)
except Exception:
last = 0
if last >= minimum:
return last
time.sleep(0.05)
return last
def wait_for_notification_read(
client: cmux, surface_id: str, timeout: float = 4.0
) -> Optional[dict]:
"""Poll list_notifications until the notification for `surface_id` is read.
Returns the matching notification dict (read or not) at the deadline, or None
if no notification for that surface ever appeared.
"""
start = time.time()
target: Optional[dict] = None
while time.time() - start < timeout:
items = client.list_notifications()
target = next((n for n in items if n["surface_id"] == surface_id), None)
if target is not None and target["is_read"]:
return target
time.sleep(0.05)
return target
def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]:
surfaces = client.list_surfaces()
if len(surfaces) < 2:
client.new_split("right")
time.sleep(0.1)
surfaces = client.list_surfaces()
return surfaces
def focused_surface_index(client: cmux) -> int:
surfaces = client.list_surfaces()
focused = next((s for s in surfaces if s[2]), None)
if focused is None:
raise RuntimeError("No focused surface")
return focused[0]
def send_osc(client: cmux, sequence: str, surface: Optional[int] = None) -> None:
"""Send an OSC sequence by printing it in the shell."""
command = f"printf '{sequence}'\\n"
if surface is None:
client.send(command)
else:
client.send_surface(surface, command)
def test_clear_prior_notifications(client: cmux) -> TestResult:
result = TestResult("Clear Prior Panel Notifications")
try:
client.clear_notifications()
client.set_app_focus(False)
client.notify("first")
time.sleep(0.1)
client.notify("second")
items = wait_for_notifications(client, 1)
if len(items) != 1:
result.failure(f"Expected 1 notification, got {len(items)}")
elif items[0]["title"] != "second":
result.failure(f"Expected latest title 'second', got '{items[0]['title']}'")
else:
result.success("Prior panel notifications cleared")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_suppress_when_focused(client: cmux) -> TestResult:
result = TestResult("Suppress When App+Panel Focused")
try:
client.clear_notifications()
client.set_app_focus(True)
client.notify("focused")
items = wait_for_notifications(client, 0)
if len(items) == 0:
result.success("Suppressed notification when focused")
else:
result.failure(f"Expected 0 notifications, got {len(items)}")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_not_suppressed_when_inactive(client: cmux) -> TestResult:
result = TestResult("Allow When App Inactive")
try:
client.clear_notifications()
client.set_app_focus(False)
client.notify("inactive")
items = wait_for_notifications(client, 1)
if len(items) != 1:
result.failure(f"Expected 1 notification, got {len(items)}")
elif items[0]["is_read"]:
result.failure("Expected notification to be unread")
else:
result.success("Notification stored when app inactive")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_kitty_notification_simple(client: cmux) -> TestResult:
result = TestResult("Kitty OSC 99 Simple")
try:
client.clear_notifications()
client.set_app_focus(False)
# Avoid Ghostty's 1s desktop notification rate limit. This test can run
# immediately after app launch in CI/VM environments.
time.sleep(1.1)
surface = focused_surface_index(client)
send_osc(client, "\\x1b]99;;Kitty Simple\\x1b\\\\", surface)
items = wait_for_notifications(client, 1)
if len(items) != 1:
result.failure(f"Expected 1 notification, got {len(items)}")
elif items[0]["title"] != "Kitty Simple":
result.failure(f"Expected title 'Kitty Simple', got '{items[0]['title']}'")
else:
result.success("OSC 99 simple notification received")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_kitty_notification_chunked(client: cmux) -> TestResult:
result = TestResult("Kitty OSC 99 Chunked Title/Body")
try:
client.clear_notifications()
client.set_app_focus(False)
# Avoid Ghostty's 1s desktop notification rate limit.
time.sleep(1.1)
surface = focused_surface_index(client)
send_osc(client, "\\x1b]99;i=kitty:d=0:p=title;Kitty Title\\x1b\\\\", surface)
time.sleep(0.1)
items = client.list_notifications()
if items:
result.failure("Expected no notification before final chunk")
return result
send_osc(client, "\\x1b]99;i=kitty:p=body;Kitty Body\\x1b\\\\", surface)
items = wait_for_notifications(client, 1)
if len(items) != 1:
result.failure(f"Expected 1 notification, got {len(items)}")
elif items[0]["title"] != "Kitty Title" or items[0]["body"] != "Kitty Body":
result.failure(
f"Expected title/body 'Kitty Title'/'Kitty Body', got "
f"'{items[0]['title']}'/'{items[0]['body']}'"
)
else:
result.success("OSC 99 chunked notification received")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_rxvt_notification_osc777(client: cmux) -> TestResult:
result = TestResult("RXVT OSC 777 Notification")
try:
client.clear_notifications()
client.set_app_focus(False)
# Avoid Ghostty's 1s desktop notification rate limit.
time.sleep(1.1)
surface = focused_surface_index(client)
command = "printf '\\x1b]777;notify;OSC777 Title;OSC777 Body\\x07'"
client.send_surface(surface, command + "\\n")
items = wait_for_notifications(client, 1)
if len(items) != 1:
result.failure(f"Expected 1 notification, got {len(items)}")
elif items[0]["title"] != "OSC777 Title" or items[0]["body"] != "OSC777 Body":
result.failure(
f"Expected title/body 'OSC777 Title'/'OSC777 Body', got "
f"'{items[0]['title']}'/'{items[0]['body']}'"
)
else:
result.success("OSC 777 notification received")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_mark_read_on_focus_change(client: cmux) -> TestResult:
result = TestResult("Mark Read On Panel Focus")
try:
client.clear_notifications()
client.reset_flash_counts()
surfaces = ensure_two_surfaces(client)
focused = next((s for s in surfaces if s[2]), None)
other = next((s for s in surfaces if not s[2]), None)
if focused is None or other is None:
result.failure("Unable to identify focused and unfocused surfaces")
return result
client.set_app_focus(False)
client.notify_surface(other[0], "focusread")
wait_for_notifications(client, 1)
client.set_app_focus(True)
client.focus_surface(other[0])
target = wait_for_notification_read(client, other[1])
if target is None:
result.failure("Expected notification for target surface")
elif not target["is_read"]:
result.failure("Expected notification to be marked read on focus")
else:
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure("Expected flash on panel focus dismissal")
else:
result.success("Notification marked read on focus")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_mark_read_on_app_active(client: cmux) -> TestResult:
result = TestResult("Mark Read On App Active")
try:
client.clear_notifications()
client.set_app_focus(False)
client.notify("activate")
items = wait_for_notifications(client, 1)
if not items or items[0]["is_read"]:
result.failure("Expected unread notification before activation")
return result
client.simulate_app_active()
deadline = time.time() + 4.0
items = client.list_notifications()
while time.time() < deadline:
items = client.list_notifications()
if items and items[0]["is_read"]:
break
time.sleep(0.05)
if not items:
result.failure("Expected notification to remain after activation")
elif not items[0]["is_read"]:
result.failure("Expected notification to be marked read on app active")
else:
result.success("Notification marked read on app active")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_mark_read_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("Mark Read On Tab Switch")
try:
client.clear_notifications()
client.set_app_focus(False)
tab1 = client.current_workspace()
client.notify("tabswitch")
time.sleep(0.1)
tab2 = client.new_workspace()
time.sleep(0.1)
client.set_app_focus(True)
client.select_workspace(tab1)
time.sleep(0.1)
items = client.list_notifications()
target = next((n for n in items if n["workspace_id"] == tab1), None)
if target is None:
result.failure("Expected notification for original tab")
elif not target["is_read"]:
result.failure("Expected notification to be marked read on tab switch")
else:
result.success("Notification marked read on tab switch")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_flash_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("Flash On Tab Switch")
try:
client.clear_notifications()
client.reset_flash_counts()
tab1 = client.current_workspace()
surfaces = client.list_surfaces()
focused = next((s for s in surfaces if s[2]), None)
if focused is None:
result.failure("Unable to identify focused surface")
return result
client.set_app_focus(False)
client.notify("tabswitchflash")
time.sleep(0.1)
client.new_workspace()
time.sleep(0.1)
client.set_app_focus(True)
client.select_workspace(tab1)
time.sleep(0.2)
count = wait_for_flash_count(client, focused[1], minimum=1, timeout=2.0)
if count < 1:
result.failure(f"Expected flash count >= 1, got {count}")
else:
result.success("Flash triggered on tab switch dismissal")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_focus_on_notification_click(client: cmux) -> TestResult:
result = TestResult("Focus On Notification Click")
try:
client.clear_notifications()
client.reset_flash_counts()
surfaces = ensure_two_surfaces(client)
focused = next((s for s in surfaces if s[2]), None)
other = next((s for s in surfaces if not s[2]), None)
if focused is None or other is None:
result.failure("Unable to identify focused and unfocused surfaces")
return result
client.set_app_focus(False)
client.notify_surface(other[0], "notifyfocus")
time.sleep(0.1)
client.set_app_focus(True)
workspace_id = client.current_workspace()
client.focus_notification(workspace_id, other[0])
time.sleep(0.2)
surfaces = client.list_surfaces()
target = next((s for s in surfaces if s[1] == other[1]), None)
if target is None or not target[2]:
result.failure("Expected notification surface to be focused")
return result
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure(f"Expected flash count >= 1, got {count}")
else:
result.success("Notification click focuses and flashes panel")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_restore_focus_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("Restore Focus On Tab Switch")
try:
client.clear_notifications()
client.set_app_focus(True)
surfaces = ensure_two_surfaces(client)
focused = next((s for s in surfaces if s[2]), None)
other = next((s for s in surfaces if not s[2]), None)
if focused is None or other is None:
result.failure("Unable to identify focused and unfocused surfaces")
return result
client.focus_surface(other[0])
time.sleep(0.1)
tab1 = client.current_workspace()
client.new_workspace()
time.sleep(0.1)
client.select_workspace(tab1)
time.sleep(0.2)
surfaces = client.list_surfaces()
target = next((s for s in surfaces if s[1] == other[1]), None)
if target is None:
result.failure("Unable to find previously focused surface")
elif not target[2]:
result.failure("Expected previously focused surface to be focused after tab switch")
else:
result.success("Restored last focused surface after tab switch")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_clear_on_tab_close(client: cmux) -> TestResult:
result = TestResult("Clear On Tab Close")
try:
client.clear_notifications()
client.set_app_focus(False)
tab1 = client.current_workspace()
client.notify("closetab")
time.sleep(0.1)
items = wait_for_notifications(client, 1)
if len(items) != 1:
result.failure(f"Expected 1 notification, got {len(items)}")
return result
client.new_workspace()
time.sleep(0.1)
client.close_workspace(tab1)
time.sleep(0.2)
items = client.list_notifications()
if items:
result.failure(f"Expected 0 notifications after tab close, got {len(items)}")
else:
result.success("Notifications cleared when tab closed")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def run_tests() -> int:
results = []
with cmux() as client:
results.append(test_clear_prior_notifications(client))
results.append(test_suppress_when_focused(client))
results.append(test_not_suppressed_when_inactive(client))
results.append(test_kitty_notification_simple(client))
results.append(test_kitty_notification_chunked(client))
results.append(test_rxvt_notification_osc777(client))
results.append(test_mark_read_on_focus_change(client))
results.append(test_mark_read_on_app_active(client))
results.append(test_mark_read_on_tab_switch(client))
results.append(test_flash_on_tab_switch(client))
results.append(test_focus_on_notification_click(client))
results.append(test_restore_focus_on_tab_switch(client))
results.append(test_clear_on_tab_close(client))
client.set_app_focus(None)
client.clear_notifications()
print("\nNotification Tests:")
for r in results:
status = "PASS" if r.passed else "FAIL"
msg = f" - {r.message}" if r.message else ""
print(f"{status}: {r.name}{msg}")
passed = sum(1 for r in results if r.passed)
total = len(results)
if passed == total:
print("\n🎉 All notification tests passed!")
return 0
print(f"\n⚠️ {total - passed} test(s) failed")
return 1
if __name__ == "__main__":
sys.exit(run_tests())
-186
View File
@@ -1,186 +0,0 @@
#!/usr/bin/env python3
"""
Regression test: pressing Cmd+L to focus the browser omnibar must not cause
a CPU spike from an infinite makeFirstResponder loop.
Background: commit 2d64ecfc wrapped the omnibar's makeFirstResponder call in
DispatchQueue.main.async without a re-dispatch guard. Each async
makeFirstResponder triggers SwiftUI's FirstResponderObserver → view graph
re-evaluation → updateNSView → another async makeFirstResponder → ∞ loop,
pegging the main thread at 100% CPU.
This test opens a browser panel, triggers Cmd+L, and asserts that CPU stays
below threshold for a few seconds afterward.
Requires:
- cmux running (debug build)
"""
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
MAX_CPU_PERCENT = 30.0
SETTLE_AFTER_FOCUS_S = 1.5
MONITOR_DURATION_S = 3.0
SAMPLE_INTERVAL_S = 0.5
def get_cmux_pid() -> int | None:
socket_path = os.environ.get("CMUX_SOCKET_PATH")
if not socket_path:
try:
socket_path = cmux().socket_path
except Exception:
socket_path = None
if socket_path and os.path.exists(socket_path):
result = subprocess.run(
["lsof", "-t", socket_path],
capture_output=True, text=True,
)
if result.returncode == 0:
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
pid = int(line)
except ValueError:
continue
if pid != os.getpid():
return pid
result = subprocess.run(
["pgrep", "-f", r"cmux\.app/Contents/MacOS/cmux$"],
capture_output=True, text=True,
)
if result.returncode != 0:
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(pid: int) -> float:
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) -> list[float]:
readings: list[float] = []
start = time.time()
while time.time() - start < duration:
readings.append(get_cpu(pid))
time.sleep(interval)
return readings
def main() -> int:
print("=" * 60)
print("Omnibar Cmd+L Focus CPU Regression Test")
print("=" * 60)
pid = get_cmux_pid()
if pid is None:
print("\nSKIP: cmux is not running")
return 0
client = cmux()
client.connect()
try:
# Create a workspace with a browser panel.
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.5)
browser_id = client.new_surface(panel_type="browser", url="https://example.com")
time.sleep(3.0) # let page load and panel stabilize
# Focus the browser webview first.
client.focus_surface_by_panel(browser_id)
time.sleep(0.3)
client.focus_webview(browser_id)
time.sleep(0.5)
# Baseline CPU reading.
baseline = get_cpu(pid)
print(f"\nBaseline CPU: {baseline:.1f}%")
# Trigger Cmd+L to focus the omnibar.
print("Simulating Cmd+L...")
client.simulate_shortcut("cmd+l")
time.sleep(SETTLE_AFTER_FOCUS_S)
# Monitor CPU after Cmd+L.
print(f"Monitoring CPU for {MONITOR_DURATION_S}s...")
readings = monitor_cpu(pid, MONITOR_DURATION_S, SAMPLE_INTERVAL_S)
avg_cpu = sum(readings) / len(readings) if readings else 0
max_cpu = max(readings) if readings else 0
print(f"\nPost Cmd+L CPU:")
print(f" Average: {avg_cpu:.1f}%")
print(f" Max: {max_cpu:.1f}%")
print(f" Samples: {readings}")
# Test: repeat Cmd+L while already focused (should also be safe).
print("\nSimulating Cmd+L again (already focused)...")
client.simulate_shortcut("cmd+l")
time.sleep(SETTLE_AFTER_FOCUS_S)
readings2 = monitor_cpu(pid, MONITOR_DURATION_S, SAMPLE_INTERVAL_S)
avg_cpu2 = sum(readings2) / len(readings2) if readings2 else 0
max_cpu2 = max(readings2) if readings2 else 0
print(f" Average: {avg_cpu2:.1f}%")
print(f" Max: {max_cpu2:.1f}%")
# Verdict.
worst = max(max_cpu, max_cpu2)
if worst > MAX_CPU_PERCENT:
print(f"\nFAIL: CPU peaked at {worst:.1f}% (threshold {MAX_CPU_PERCENT}%)")
print("Likely infinite makeFirstResponder loop in omnibar updateNSView.")
# Take a diagnostic sample.
sample = subprocess.run(
["sample", str(pid), "2"],
capture_output=True, text=True,
)
sample_text = sample.stdout + sample.stderr
if "updateNSView" in sample_text or "makeFirstResponder" in sample_text:
print(" Confirmed: sample shows updateNSView / makeFirstResponder loop")
sample_path = f"/tmp/cmux_omnibar_focus_cpu_{pid}.txt"
with open(sample_path, "w") as f:
f.write(sample_text)
print(f" Sample saved to {sample_path}")
return 1
print(f"\nPASS: CPU stayed within bounds (peak {worst:.1f}%)")
return 0
finally:
# Cleanup: close the test workspace.
try:
client.close_workspace(ws_id)
except Exception:
pass
client.close()
if __name__ == "__main__":
sys.exit(main())
-350
View File
@@ -1,350 +0,0 @@
#!/usr/bin/env python3
"""
Regression test: stale file-drag overlay state must not swallow real mouse clicks.
This uses real HID mouse events (CoreGraphics CGEvent), not XCUI element actions.
It seeds the drag pasteboard with `public.file-url` to force the FileDropOverlayView
stale-drag path, then verifies:
1) A left click changes terminal focus to the clicked pane.
2) A real right click does not break terminal focus routing.
"""
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
def run_osascript(script: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
["osascript", "-e", script],
capture_output=True,
text=True,
timeout=8,
)
if result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode,
result.args,
output=result.stdout,
stderr=result.stderr,
)
return result
def is_accessibility_error(err: subprocess.CalledProcessError) -> bool:
text = f"{getattr(err, 'stderr', '') or ''}\n{getattr(err, 'output', '') or ''}".lower()
needles = [
"not allowed to send keystrokes",
"not allowed assistive access",
"not allowed to control computer",
"(1002)",
]
return any(n in text for n in needles)
def app_name_for_bundle(bundle_id: str) -> str:
out = run_osascript(f'tell application id "{bundle_id}" to get name').stdout.strip()
if not out:
raise RuntimeError(f"Could not resolve app name for bundle ID {bundle_id}")
return out
def front_window_frame(app_name: str) -> tuple[float, float, float, float]:
script = f'''
tell application "System Events"
tell process "{app_name}"
tell front window
set p to position
set s to size
return (item 1 of p as text) & "," & (item 2 of p as text) & "," & (item 1 of s as text) & "," & (item 2 of s as text)
end tell
end tell
end tell
'''
raw = run_osascript(script).stdout.strip()
parts = [p.strip() for p in raw.split(",")]
if len(parts) != 4:
raise RuntimeError(f"Unexpected window frame from osascript: {raw}")
x, y, w, h = (float(parts[0]), float(parts[1]), float(parts[2]), float(parts[3]))
return x, y, w, h
def post_click_with_cgevent(x: float, y: float, right: bool = False) -> None:
ix = int(round(x))
iy = int(round(y))
if right:
down = ".rightMouseDown"
up = ".rightMouseUp"
button = ".right"
else:
down = ".leftMouseDown"
up = ".leftMouseUp"
button = ".left"
code = f"""
import CoreGraphics
let p = CGPoint(x: {ix}, y: {iy})
let source = CGEventSource(stateID: .hidSystemState)
let down = CGEvent(mouseEventSource: source, mouseType: {down}, mouseCursorPosition: p, mouseButton: {button})
let up = CGEvent(mouseEventSource: source, mouseType: {up}, mouseCursorPosition: p, mouseButton: {button})
down?.post(tap: .cghidEventTap)
up?.post(tap: .cghidEventTap)
"""
subprocess.run(
["swift", "-e", code],
check=True,
capture_output=True,
text=True,
timeout=10,
)
def post_scroll_with_cgevent(x: float, y: float, delta_y: int = 3) -> None:
ix = int(round(x))
iy = int(round(y))
code = f"""
import CoreGraphics
let p = CGPoint(x: {ix}, y: {iy})
let source = CGEventSource(stateID: .hidSystemState)
if let scroll = CGEvent(
scrollWheelEvent2Source: source,
units: .line,
wheelCount: 1,
wheel1: Int32({delta_y}),
wheel2: 0,
wheel3: 0
) {{
scroll.location = p
scroll.post(tap: .cghidEventTap)
}}
"""
subprocess.run(
["swift", "-e", code],
check=True,
capture_output=True,
text=True,
timeout=10,
)
def pick_top_bottom_terminal_panels(layout: dict) -> tuple[dict, dict]:
candidates = []
for panel in layout.get("selectedPanels", []):
if panel.get("panelType") != "terminal":
continue
view = panel.get("viewFrame")
if not isinstance(view, dict):
continue
if not panel.get("panelId"):
continue
candidates.append(panel)
if len(candidates) < 2:
raise RuntimeError(f"Expected >=2 terminal panels with viewFrame, got: {candidates}")
candidates.sort(key=lambda p: float(p["viewFrame"]["y"]))
bottom = candidates[0]
top = candidates[-1]
if bottom["panelId"] == top["panelId"]:
raise RuntimeError("Top/bottom panel IDs collapsed to the same panel")
return top, bottom
def candidate_screen_points(
window_x: float, window_y: float, window_h: float, panel: dict
) -> list[tuple[float, float]]:
points: list[tuple[float, float]] = []
pane = panel.get("paneFrame") or {}
view = panel.get("viewFrame") or {}
window_points: list[tuple[float, float]] = []
if pane:
px = float(pane["x"])
py = float(pane["y"])
pw = float(pane["width"])
ph = float(pane["height"])
window_points.extend([
(px + pw * 0.50, py + ph * 0.50),
(px + pw * 0.50, py + min(24.0, ph * 0.20)),
(px + pw * 0.50, py + max(ph - 24.0, ph * 0.80)),
])
if view:
vx = float(view["x"])
vy = float(view["y"])
vw = float(view["width"])
vh = float(view["height"])
window_points.extend([
(vx + vw * 0.50, vy + vh * 0.50),
(vx + vw * 0.50, vy + min(24.0, vh * 0.20)),
(vx + vw * 0.50, vy + max(vh - 24.0, vh * 0.80)),
])
# Try both y-axis interpretations; multi-display setups and coordinate-space
# conversions can differ by API surface.
for wx, wy in window_points:
points.append((window_x + wx, window_y + wy))
points.append((window_x + wx, window_y + (window_h - wy)))
# Deduplicate while preserving order.
dedup: list[tuple[float, float]] = []
seen: set[tuple[int, int]] = set()
for sx, sy in points:
key = (int(round(sx)), int(round(sy)))
if key in seen:
continue
seen.add(key)
dedup.append((sx, sy))
return dedup
def wait_for_terminal_focus(client: cmux, panel_id: str, timeout_s: float = 2.0) -> bool:
start = time.time()
while time.time() - start < timeout_s:
try:
if client.is_terminal_focused(panel_id):
return True
except Exception:
pass
time.sleep(0.05)
return False
def attempt_focus_via_real_clicks(
client: cmux,
panel_id: str,
points: list[tuple[float, float]],
) -> tuple[bool, tuple[float, float]]:
last_point = points[0]
for tx, ty in points:
last_point = (tx, ty)
for _ in range(2):
post_click_with_cgevent(tx, ty, right=False)
if wait_for_terminal_focus(client, panel_id, timeout_s=0.35):
return True, (tx, ty)
return False, last_point
def main() -> int:
socket_path = cmux.default_socket_path()
if not os.path.exists(socket_path):
print(f"SKIP: Socket not found at {socket_path}")
print("Tip: start cmux first (or set CMUX_TAG / CMUX_SOCKET_PATH).")
return 0
bundle_id = cmux.default_bundle_id()
try:
app_name = app_name_for_bundle(bundle_id)
except subprocess.CalledProcessError as e:
print(f"SKIP: Could not resolve app name for bundle {bundle_id}: {e}")
return 0
with cmux(socket_path) as client:
ws_id = None
try:
client.activate_app()
time.sleep(0.2)
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.3)
client.new_split("down")
time.sleep(0.5)
layout = client.layout_debug()
top_panel, bottom_panel = pick_top_bottom_terminal_panels(layout)
top_id = top_panel["panelId"]
bottom_id = bottom_panel["panelId"]
client.focus_surface_by_panel(top_id)
time.sleep(0.2)
if client.is_terminal_focused(bottom_id):
print("FAIL: bottom pane unexpectedly focused before click precondition")
return 1
win_x, win_y, _win_w, win_h = front_window_frame(app_name)
candidate_points = candidate_screen_points(win_x, win_y, win_h, bottom_panel)
# Baseline: real HID click routing must work before we can assert stale-pasteboard regression.
client.activate_app()
time.sleep(0.2)
baseline_ok, baseline_point = attempt_focus_via_real_clicks(client, bottom_id, candidate_points)
if not baseline_ok:
print("SKIP: real HID clicks are not routable on this host right now")
return 0
client.focus_surface_by_panel(top_id)
time.sleep(0.2)
if client.is_terminal_focused(bottom_id):
print("FAIL: could not restore top-pane precondition before stale-pasteboard check")
return 1
client.seed_drag_pasteboard_fileurl()
client.activate_app()
time.sleep(0.2)
focused, point = attempt_focus_via_real_clicks(client, bottom_id, candidate_points)
click_x, click_y = point
if not focused:
print("FAIL: real left click did not focus clicked pane under stale drag pasteboard")
print(
"baseline_point="
f"({baseline_point[0]:.1f}, {baseline_point[1]:.1f}) "
f"click_screen=({click_x:.1f}, {click_y:.1f})"
)
print(f"top_id={top_id} bottom_id={bottom_id}")
print(f"layout={layout}")
return 1
post_click_with_cgevent(click_x, click_y, right=True)
time.sleep(0.25)
if not client.is_terminal_focused(bottom_id):
print("FAIL: real right click disrupted terminal focus routing")
return 1
for _ in range(6):
post_scroll_with_cgevent(click_x, click_y, delta_y=2)
time.sleep(0.25)
if not client.is_terminal_focused(bottom_id):
print("FAIL: real scroll wheel disrupted terminal focus routing")
return 1
print("PASS: stale file-drag overlay forwards real left/right clicks and scroll")
print(f" focused_panel={bottom_id}")
return 0
finally:
try:
client.clear_drag_pasteboard()
except Exception:
pass
if ws_id:
try:
client.close_workspace(ws_id)
except Exception:
pass
if __name__ == "__main__":
try:
raise SystemExit(main())
except subprocess.CalledProcessError as e:
if is_accessibility_error(e):
print("SKIP: System Events click automation not allowed (Accessibility permission missing)")
raise SystemExit(0)
print(f"FAIL: osascript invocation failed: {e}")
if getattr(e, "stderr", None):
print(e.stderr.strip())
if getattr(e, "output", None):
print(e.output.strip())
raise SystemExit(1)
except cmuxError as e:
print(f"FAIL: {e}")
raise SystemExit(1)
@@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""
Regression test: stale tab-transfer drag pasteboard state must not swallow real mouse clicks.
This uses real HID mouse events (CoreGraphics CGEvent), not XCUI element actions.
It seeds the drag pasteboard with `com.splittabbar.tabtransfer` to emulate stale
tab-drag state, then verifies:
1) A left click changes terminal focus to the clicked pane.
2) A real right click does not break terminal focus routing.
"""
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
from test_real_click_overlay_forwarding import (
app_name_for_bundle,
attempt_focus_via_real_clicks,
candidate_screen_points,
front_window_frame,
is_accessibility_error,
pick_top_bottom_terminal_panels,
post_click_with_cgevent,
)
def main() -> int:
socket_path = cmux.default_socket_path()
if not os.path.exists(socket_path):
print(f"SKIP: Socket not found at {socket_path}")
print("Tip: start cmux first (or set CMUX_TAG / CMUX_SOCKET_PATH).")
return 0
bundle_id = cmux.default_bundle_id()
try:
app_name = app_name_for_bundle(bundle_id)
except subprocess.CalledProcessError as e:
print(f"SKIP: Could not resolve app name for bundle {bundle_id}: {e}")
return 0
with cmux(socket_path) as client:
ws_id = None
try:
client.activate_app()
time.sleep(0.2)
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.3)
client.new_split("down")
time.sleep(0.5)
layout = client.layout_debug()
top_panel, bottom_panel = pick_top_bottom_terminal_panels(layout)
top_id = top_panel["panelId"]
bottom_id = bottom_panel["panelId"]
client.focus_surface_by_panel(top_id)
time.sleep(0.2)
if client.is_terminal_focused(bottom_id):
print("FAIL: bottom pane unexpectedly focused before click precondition")
return 1
win_x, win_y, _win_w, win_h = front_window_frame(app_name)
candidate_points = candidate_screen_points(win_x, win_y, win_h, bottom_panel)
# Baseline: real HID click routing must work before we can assert stale-pasteboard regression.
client.activate_app()
time.sleep(0.2)
baseline_ok, baseline_point = attempt_focus_via_real_clicks(client, bottom_id, candidate_points)
if not baseline_ok:
print("SKIP: real HID clicks are not routable on this host right now")
return 0
client.focus_surface_by_panel(top_id)
time.sleep(0.2)
if client.is_terminal_focused(bottom_id):
print("FAIL: could not restore top-pane precondition before stale-pasteboard check")
return 1
client.seed_drag_pasteboard_tabtransfer()
client.activate_app()
time.sleep(0.2)
focused, point = attempt_focus_via_real_clicks(client, bottom_id, candidate_points)
click_x, click_y = point
if not focused:
print("FAIL: real left click did not focus clicked pane under stale tabtransfer pasteboard")
print(
"baseline_point="
f"({baseline_point[0]:.1f}, {baseline_point[1]:.1f}) "
f"click_screen=({click_x:.1f}, {click_y:.1f})"
)
print(f"top_id={top_id} bottom_id={bottom_id}")
print(f"layout={layout}")
return 1
post_click_with_cgevent(click_x, click_y, right=True)
time.sleep(0.25)
if not client.is_terminal_focused(bottom_id):
print("FAIL: real right click disrupted terminal focus routing")
return 1
print("PASS: stale tabtransfer pasteboard preserves real left/right click routing")
print(f" focused_panel={bottom_id}")
return 0
finally:
try:
client.clear_drag_pasteboard()
except Exception:
pass
if ws_id:
try:
client.close_workspace(ws_id)
except Exception:
pass
if __name__ == "__main__":
try:
raise SystemExit(main())
except subprocess.CalledProcessError as e:
if is_accessibility_error(e):
print("SKIP: System Events click automation not allowed (Accessibility permission missing)")
raise SystemExit(0)
print(f"FAIL: osascript invocation failed: {e}")
if getattr(e, "stderr", None):
print(e.stderr.strip())
if getattr(e, "output", None):
print(e.output.strip())
raise SystemExit(1)
except cmuxError as e:
print(f"FAIL: {e}")
raise SystemExit(1)
@@ -1,305 +0,0 @@
#!/usr/bin/env python3
"""
Regression: restore-session should reopen the previous workspace graph and
relaunch resumable Codex sessions after a blank relaunch overwrites the primary
session snapshot.
"""
from __future__ import annotations
import json
import os
import plistlib
import re
import socket
import subprocess
import tempfile
import time
from pathlib import Path
from cmux import cmux
def _bundle_id(app_path: Path) -> str:
info_path = app_path / "Contents" / "Info.plist"
if not info_path.exists():
raise RuntimeError(f"Missing Info.plist at {info_path}")
with info_path.open("rb") as f:
info = plistlib.load(f)
bundle_id = str(info.get("CFBundleIdentifier", "")).strip()
if not bundle_id:
raise RuntimeError("Missing CFBundleIdentifier")
return bundle_id
def _snapshot_path(bundle_id: str, suffix: str = "") -> Path:
safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id)
return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}{suffix}.json"
def _socket_reachable(socket_path: Path) -> bool:
if not socket_path.exists():
return False
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.settimeout(0.3)
sock.connect(str(socket_path))
sock.sendall(b"ping\n")
data = sock.recv(1024)
return b"PONG" in data
except OSError:
return False
finally:
sock.close()
def _wait_for_socket(socket_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if _socket_reachable(socket_path):
return
time.sleep(0.2)
raise RuntimeError(f"Socket did not become reachable: {socket_path}")
def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if not _socket_reachable(socket_path):
return
time.sleep(0.2)
raise RuntimeError(f"Socket still reachable after quit: {socket_path}")
def _kill_existing(app_path: Path) -> None:
exe = app_path / "Contents" / "MacOS" / "cmux DEV"
subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True)
time.sleep(1.0)
def _launch(app_path: Path, socket_path: Path, env_overrides: dict[str, str] | None = None) -> None:
try:
socket_path.unlink()
except FileNotFoundError:
pass
command = ["open", "-na", str(app_path)]
full_env = dict(env_overrides or {})
full_env["CMUX_SOCKET_PATH"] = str(socket_path)
full_env["CMUX_ALLOW_SOCKET_OVERRIDE"] = "1"
for key, value in full_env.items():
command.extend(["--env", f"{key}={value}"])
subprocess.run(command, check=True)
_wait_for_socket(socket_path)
time.sleep(1.5)
def _quit(bundle_id: str, socket_path: Path) -> None:
subprocess.run(
["osascript", "-e", f'tell application id "{bundle_id}" to quit'],
capture_output=True,
text=True,
check=True,
)
_wait_for_socket_closed(socket_path)
try:
socket_path.unlink()
except FileNotFoundError:
pass
time.sleep(0.8)
def _connect(socket_path: Path) -> cmux:
client = cmux(socket_path=str(socket_path))
client.connect()
if not client.ping():
raise RuntimeError("ping failed")
return client
def _read_scrollback(client: cmux) -> str:
return client._send_command("read_screen --scrollback")
def _wait_for_condition(timeout: float, predicate) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return True
time.sleep(0.25)
return False
def _write_fake_codex(fake_bin_dir: Path) -> None:
fake_bin_dir.mkdir(parents=True, exist_ok=True)
fake_codex = fake_bin_dir / "codex"
fake_codex.write_text(
"#!/bin/sh\n"
"printf 'CMUX_FAKE_CODEX_RESUME:%s\\n' \"$*\"\n",
encoding="utf-8",
)
fake_codex.chmod(0o755)
def _write_hook_state(path: Path, session_id: str, workspace_id: str, surface_id: str, cwd: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"version": 1,
"sessions": {
session_id: {
"sessionId": session_id,
"workspaceId": workspace_id,
"surfaceId": surface_id,
"cwd": cwd,
"updatedAt": time.time(),
}
},
}
path.write_text(json.dumps(payload), encoding="utf-8")
def main() -> int:
app_path_str = os.environ.get("CMUX_APP_PATH", "").strip()
if not app_path_str:
print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path")
return 0
app_path = Path(app_path_str)
if not app_path.exists():
print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}")
return 0
cli_path = app_path / "Contents" / "Resources" / "bin" / "cmux"
if not cli_path.exists():
print(f"SKIP: bundled cmux CLI not found at {cli_path}")
return 0
bundle_id = _bundle_id(app_path)
socket_path = Path(f"/tmp/cmux-restore-session-codex-{bundle_id.replace('.', '-')}.sock")
snapshot = _snapshot_path(bundle_id)
previous_snapshot = _snapshot_path(bundle_id, suffix="-previous")
failures: list[str] = []
with tempfile.TemporaryDirectory(prefix="cmux-restore-session-codex-") as td:
fake_bin_dir = Path(td) / "bin"
hook_state_dir = Path(td) / "hook-state"
hook_state = hook_state_dir / "codex-hook-sessions.json"
_write_fake_codex(fake_bin_dir)
launch_path = f"{fake_bin_dir}:{os.environ.get('PATH', '')}"
launch_env = {
"PATH": launch_path,
"CMUX_AGENT_HOOK_STATE_DIR": str(hook_state_dir),
}
_kill_existing(app_path)
snapshot.unlink(missing_ok=True)
previous_snapshot.unlink(missing_ok=True)
try:
_launch(app_path, socket_path, env_overrides=launch_env)
client = _connect(socket_path)
try:
original_workspace_id = client.current_workspace()
surfaces = client.list_surfaces()
if not surfaces:
failures.append("expected at least one surface in the initial workspace")
else:
surface_id = surfaces[0][1]
_write_hook_state(
hook_state,
session_id="codex-session-restore-2923",
workspace_id=original_workspace_id,
surface_id=surface_id,
cwd=os.getcwd(),
)
client.new_workspace()
time.sleep(0.4)
client.select_workspace(original_workspace_id)
time.sleep(0.4)
finally:
client.close()
_quit(bundle_id, socket_path)
hook_state.unlink(missing_ok=True)
_launch(
app_path,
socket_path,
env_overrides={
**launch_env,
"CMUX_DISABLE_SESSION_RESTORE": "1",
},
)
client = _connect(socket_path)
try:
blank_workspaces = client.list_workspaces()
if len(blank_workspaces) != 1:
failures.append(
f"expected blank relaunch to start with 1 workspace, got {len(blank_workspaces)}"
)
time.sleep(9.5)
restore_env = dict(os.environ)
restore_env["CMUX_SOCKET_PATH"] = str(socket_path)
restore_env["CMUX_AGENT_HOOK_STATE_DIR"] = str(hook_state_dir)
restore_proc = subprocess.run(
[str(cli_path), "restore-session"],
capture_output=True,
text=True,
env=restore_env,
)
if restore_proc.returncode != 0:
failures.append(
"restore-session failed:\n"
f"stdout:\n{restore_proc.stdout}\n"
f"stderr:\n{restore_proc.stderr}"
)
elif restore_proc.stdout.strip() != "OK":
failures.append(f"unexpected restore-session stdout: {restore_proc.stdout!r}")
marker = "CMUX_FAKE_CODEX_RESUME:resume codex-session-restore-2923"
def restored() -> bool:
workspaces = client.list_workspaces()
if len(workspaces) < 2:
return False
for index in range(len(workspaces)):
client.select_workspace(index)
if marker in _read_scrollback(client):
return True
return False
if not _wait_for_condition(12.0, restored):
tails: list[str] = []
for index in range(len(client.list_workspaces())):
client.select_workspace(index)
tail = "\n".join(_read_scrollback(client).splitlines()[-20:])
tails.append(f"workspace[{index}] tail:\n{tail}")
scrollback_tail = "\n".join(tails)
failures.append(
"restore-session did not relaunch the saved Codex session; "
f"workspace_count={len(client.list_workspaces())} {scrollback_tail}"
)
finally:
client.close()
_quit(bundle_id, socket_path)
finally:
_kill_existing(app_path)
socket_path.unlink(missing_ok=True)
snapshot.unlink(missing_ok=True)
previous_snapshot.unlink(missing_ok=True)
hook_state.unlink(missing_ok=True)
if failures:
print("FAIL:")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: restore-session reopens saved workspaces and resumes codex")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,432 +0,0 @@
#!/usr/bin/env python3
"""
Regression: normal relaunch should resume saved Claude/Codex/OpenCode/Pi sessions.
Repro for issue #2923:
1) Launch cmux and seed workspaces with tracked Claude/Codex/OpenCode/Pi sessions.
2) Quit the app normally so the session snapshot is saved.
3) Relaunch cmux the next day.
4) Verify the restored panels automatically run the saved resume commands.
"""
from __future__ import annotations
import json
import os
import plistlib
import re
import socket
import subprocess
import tempfile
import time
from pathlib import Path
from cmux import cmux
def _bundle_id(app_path: Path) -> str:
info_path = app_path / "Contents" / "Info.plist"
if not info_path.exists():
raise RuntimeError(f"Missing Info.plist at {info_path}")
with info_path.open("rb") as f:
info = plistlib.load(f)
bundle_id = str(info.get("CFBundleIdentifier", "")).strip()
if not bundle_id:
raise RuntimeError("Missing CFBundleIdentifier")
return bundle_id
def _snapshot_path(bundle_id: str, suffix: str = "") -> Path:
safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id)
return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}{suffix}.json"
def _socket_reachable(socket_path: Path) -> bool:
if not socket_path.exists():
return False
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.settimeout(0.3)
sock.connect(str(socket_path))
sock.sendall(b"ping\n")
data = sock.recv(1024)
return b"PONG" in data
except OSError:
return False
finally:
sock.close()
def _wait_for_socket(socket_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if _socket_reachable(socket_path):
return
time.sleep(0.2)
raise RuntimeError(f"Socket did not become reachable: {socket_path}")
def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if not _socket_reachable(socket_path):
return
time.sleep(0.2)
raise RuntimeError(f"Socket still reachable after quit: {socket_path}")
def _kill_existing(app_path: Path) -> None:
exe = app_path / "Contents" / "MacOS" / "cmux DEV"
subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True)
time.sleep(1.0)
def _launch(app_path: Path, socket_path: Path, env_overrides: dict[str, str] | None = None) -> None:
try:
socket_path.unlink()
except FileNotFoundError:
pass
command = ["open", "-na", str(app_path)]
full_env = dict(env_overrides or {})
full_env["CMUX_SOCKET_PATH"] = str(socket_path)
full_env["CMUX_ALLOW_SOCKET_OVERRIDE"] = "1"
for key, value in full_env.items():
command.extend(["--env", f"{key}={value}"])
subprocess.run(command, check=True)
_wait_for_socket(socket_path)
time.sleep(1.5)
def _quit(bundle_id: str, socket_path: Path) -> None:
subprocess.run(
["osascript", "-e", f'tell application id "{bundle_id}" to quit'],
capture_output=True,
text=True,
check=True,
)
_wait_for_socket_closed(socket_path)
try:
socket_path.unlink()
except FileNotFoundError:
pass
time.sleep(0.8)
def _connect(socket_path: Path) -> cmux:
client = cmux(socket_path=str(socket_path))
client.connect()
if not client.ping():
raise RuntimeError("ping failed")
return client
def _read_scrollback(client: cmux) -> str:
return client._send_command("read_screen --scrollback")
def _wait_for_condition(timeout: float, predicate) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return True
time.sleep(0.25)
return False
def _write_fake_agent(fake_bin_dir: Path, binary_name: str, prefix: str) -> None:
fake_bin_dir.mkdir(parents=True, exist_ok=True)
fake_binary = fake_bin_dir / binary_name
fake_binary.write_text(
"#!/bin/sh\n"
f"printf '{prefix}:%s\\n' \"$*\"\n",
encoding="utf-8",
)
fake_binary.chmod(0o755)
def _write_hook_state(
path: Path,
session_id: str,
workspace_id: str,
surface_id: str,
cwd: str,
launcher: str,
executable_path: Path,
arguments: list[str] | None = None,
environment: dict[str, str] | None = None,
transcript_path: Path | None = None,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
session: dict = {
"sessionId": session_id,
"workspaceId": workspace_id,
"surfaceId": surface_id,
"cwd": cwd,
"launchCommand": {
"launcher": launcher,
"executablePath": str(executable_path),
"arguments": arguments or [str(executable_path)],
"workingDirectory": cwd,
"environment": environment,
"capturedAt": time.time(),
"source": "test",
},
"updatedAt": time.time(),
}
if transcript_path is not None:
# Claude hook records are only restorable when their transcript
# exists on disk (hookRecordIsRestorable).
session["transcriptPath"] = str(transcript_path)
payload = {"version": 1, "sessions": {session_id: session}}
path.write_text(json.dumps(payload), encoding="utf-8")
def main() -> int:
app_path_str = os.environ.get("CMUX_APP_PATH", "").strip()
if not app_path_str:
print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path")
return 0
app_path = Path(app_path_str)
if not app_path.exists():
print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}")
return 0
bundle_id = _bundle_id(app_path)
socket_path = Path(f"/tmp/cmux-session-relaunch-agents-{bundle_id.replace('.', '-')}.sock")
snapshot = _snapshot_path(bundle_id)
previous_snapshot = _snapshot_path(bundle_id, suffix="-previous")
codex_expected = "CMUX_FAKE_CODEX_RESUME:resume codex-session-relaunch-2923"
# The cmux claude wrapper inserts its own arguments around --resume, so
# claude expectations are order-agnostic tokens that must share one line.
claude_expected_tokens = (
"CMUX_FAKE_CLAUDE_RESUME:",
"--resume claude-session-relaunch-2923",
"--dangerously-skip-permissions",
)
opencode_expected = "CMUX_FAKE_OPENCODE_RESUME:--session opencode-session-relaunch-2923"
pi_expected = "CMUX_FAKE_PI_RESUME:--session pi-session-relaunch-2923"
failures: list[str] = []
with tempfile.TemporaryDirectory(prefix="cmux-session-relaunch-agents-") as td:
fake_bin_dir = Path(td) / "bin"
hook_state_dir = Path(td) / "hook-state"
claude_hook_state = hook_state_dir / "claude-hook-sessions.json"
codex_hook_state = hook_state_dir / "codex-hook-sessions.json"
opencode_hook_state = hook_state_dir / "opencode-hook-sessions.json"
pi_hook_state = hook_state_dir / "pi-hook-sessions.json"
_write_fake_agent(fake_bin_dir, "codex", "CMUX_FAKE_CODEX_RESUME")
_write_fake_agent(fake_bin_dir, "claude", "CMUX_FAKE_CLAUDE_RESUME")
_write_fake_agent(fake_bin_dir, "opencode", "CMUX_FAKE_OPENCODE_RESUME")
_write_fake_agent(fake_bin_dir, "pi", "CMUX_FAKE_PI_RESUME")
launch_path = f"{fake_bin_dir}:{os.environ.get('PATH', '')}"
app_env = {
"PATH": launch_path,
"CMUX_AGENT_HOOK_STATE_DIR": str(hook_state_dir),
# Claude resume routes through the cmux claude wrapper, which
# resolves the real binary; point it at the fake one instead.
"CMUX_CUSTOM_CLAUDE_PATH": str(fake_bin_dir / "claude"),
}
_kill_existing(app_path)
snapshot.unlink(missing_ok=True)
previous_snapshot.unlink(missing_ok=True)
claude_hook_state.unlink(missing_ok=True)
codex_hook_state.unlink(missing_ok=True)
opencode_hook_state.unlink(missing_ok=True)
pi_hook_state.unlink(missing_ok=True)
try:
_launch(app_path, socket_path, env_overrides=app_env)
client = _connect(socket_path)
try:
codex_workspace_id = client.current_workspace()
codex_surfaces = client.list_surfaces()
if not codex_surfaces:
failures.append("expected a Codex workspace surface during setup")
else:
_write_hook_state(
codex_hook_state,
session_id="codex-session-relaunch-2923",
workspace_id=codex_workspace_id,
surface_id=codex_surfaces[0][1],
cwd=os.getcwd(),
launcher="codex",
executable_path=fake_bin_dir / "codex",
)
claude_workspace_id = client.new_workspace()
time.sleep(0.4)
client.select_workspace(claude_workspace_id)
time.sleep(0.4)
claude_surfaces = client.list_surfaces()
if not claude_surfaces:
failures.append("expected a Claude workspace surface during setup")
else:
claude_transcript = Path(td) / "claude-transcript.jsonl"
claude_transcript.write_text('{"type":"user"}\n', encoding="utf-8")
_write_hook_state(
claude_hook_state,
session_id="claude-session-relaunch-2923",
workspace_id=claude_workspace_id,
surface_id=claude_surfaces[0][1],
cwd=os.getcwd(),
launcher="claude",
executable_path=fake_bin_dir / "claude",
arguments=[
str(fake_bin_dir / "claude"),
"--dangerously-skip-permissions",
],
environment={
"CLAUDE_CONFIG_DIR": str(Path(td) / "claude-config"),
"PATH": launch_path,
"SHELL": "/bin/zsh",
"UNSAFE_TOKEN": "must-not-restore",
},
transcript_path=claude_transcript,
)
opencode_workspace_id = client.new_workspace()
time.sleep(0.4)
client.select_workspace(opencode_workspace_id)
time.sleep(0.4)
opencode_surfaces = client.list_surfaces()
if not opencode_surfaces:
failures.append("expected an OpenCode workspace surface during setup")
else:
_write_hook_state(
opencode_hook_state,
session_id="opencode-session-relaunch-2923",
workspace_id=opencode_workspace_id,
surface_id=opencode_surfaces[0][1],
cwd=os.getcwd(),
launcher="opencode",
executable_path=fake_bin_dir / "opencode",
arguments=[
str(fake_bin_dir / "opencode"),
"/$bunfs/root/src/cli/cmd/tui/worker.js",
],
environment={
"PATH": launch_path,
"SHELL": "/bin/zsh",
"UNSAFE_TOKEN": "must-not-restore",
},
)
pi_workspace_id = client.new_workspace()
time.sleep(0.4)
client.select_workspace(pi_workspace_id)
time.sleep(0.4)
pi_surfaces = client.list_surfaces()
if not pi_surfaces:
failures.append("expected a Pi workspace surface during setup")
else:
_write_hook_state(
pi_hook_state,
session_id="pi-session-relaunch-2923",
workspace_id=pi_workspace_id,
surface_id=pi_surfaces[0][1],
cwd=os.getcwd(),
launcher="pi",
executable_path=fake_bin_dir / "pi",
)
client.select_workspace(codex_workspace_id)
time.sleep(0.4)
finally:
client.close()
_quit(bundle_id, socket_path)
# Prove the relaunch uses the persisted cmux snapshot, not the live hook files.
claude_hook_state.unlink(missing_ok=True)
codex_hook_state.unlink(missing_ok=True)
opencode_hook_state.unlink(missing_ok=True)
pi_hook_state.unlink(missing_ok=True)
_launch(app_path, socket_path, env_overrides=app_env)
client = _connect(socket_path)
try:
workspaces = client.list_workspaces()
if len(workspaces) < 4:
failures.append(f"expected >=4 restored workspaces after relaunch, got {len(workspaces)}")
def find_workspace_with(expected: str) -> bool:
# Restored workspaces are not guaranteed to keep their
# seeding order, so scan every workspace for the expected
# resume output instead of trusting a fixed index.
for index in range(len(client.list_workspaces())):
client.select_workspace(index)
if expected in _read_scrollback(client):
return True
return False
def find_workspace_with_tokens(tokens: tuple[str, ...]) -> bool:
for index in range(len(client.list_workspaces())):
client.select_workspace(index)
if any(
all(token in line for token in tokens)
for line in _read_scrollback(client).splitlines()
):
return True
return False
def best_scrollback_tail() -> str:
# Pick the longest scrollback across all workspaces so the
# failure report shows the most informative pane.
best = ""
for index in range(len(client.list_workspaces())):
client.select_workspace(index)
lines = _read_scrollback(client).splitlines()
if len(lines) >= len(best.splitlines()):
best = "\n".join(lines[-20:])
return best
if not _wait_for_condition(12.0, lambda: find_workspace_with(codex_expected)):
failures.append(
"normal relaunch did not resume the saved Codex session; "
f"tail:\n{best_scrollback_tail()}"
)
if not _wait_for_condition(12.0, lambda: find_workspace_with_tokens(claude_expected_tokens)):
failures.append(
"normal relaunch did not resume the saved Claude session; "
f"tail:\n{best_scrollback_tail()}"
)
if not _wait_for_condition(12.0, lambda: find_workspace_with(opencode_expected)):
failures.append(
"normal relaunch did not resume the saved OpenCode session; "
f"tail:\n{best_scrollback_tail()}"
)
if not _wait_for_condition(12.0, lambda: find_workspace_with(pi_expected)):
failures.append(
"normal relaunch did not resume the saved Pi session; "
f"tail:\n{best_scrollback_tail()}"
)
finally:
client.close()
_quit(bundle_id, socket_path)
finally:
_kill_existing(app_path)
socket_path.unlink(missing_ok=True)
snapshot.unlink(missing_ok=True)
previous_snapshot.unlink(missing_ok=True)
claude_hook_state.unlink(missing_ok=True)
codex_hook_state.unlink(missing_ok=True)
opencode_hook_state.unlink(missing_ok=True)
pi_hook_state.unlink(missing_ok=True)
if failures:
print("FAIL:")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: normal relaunch resumes saved Claude, Codex, OpenCode, and Pi sessions")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,489 +0,0 @@
#!/usr/bin/env python3
"""
Stress: tracked agent sessions must survive repeated kill/reopen cycles.
Phases:
1) Seed six workspaces with tracked Claude/Codex/OpenCode sessions. The fake
agents keep running (exec sleep) so the shell stays in command-running
state and every later snapshot records wasAgentRunning=true.
2) Clean quit -> relaunch: all six sessions auto-resume from the persisted
snapshot (hook state files are deleted before relaunch to prove it).
3) Second clean quit -> relaunch: the re-saved snapshot still resumes all six.
4) SIGKILL the app after the autosave window -> relaunch: the autosaved
snapshot still resumes all six.
5) Clean quit, then corrupt the primary session snapshot -> relaunch: cmux
must recover the session from the -previous backup snapshot instead of
silently starting fresh, and the backup file must survive the relaunch so
`cmux restore-session` keeps working.
"""
from __future__ import annotations
import json
import os
import plistlib
import re
import signal
import socket
import subprocess
import tempfile
import time
from pathlib import Path
from cmux import cmux
# (launcher, session id, marker tokens). A session counts as resumed when one
# scrollback line contains every token: the fake-agent prefix proves the fake
# binary ran (the typed resume command alone does not contain it), and the
# session token proves which session it was. Claude tokens stay order-agnostic
# because the cmux claude wrapper inserts its own arguments around --resume.
SESSION_SPECS = [
("claude", "claude-stress-0", ("CMUX_FAKE_CLAUDE_RESUME:", "--resume claude-stress-0")),
("claude", "claude-stress-1", ("CMUX_FAKE_CLAUDE_RESUME:", "--resume claude-stress-1")),
("codex", "codex-stress-0", ("CMUX_FAKE_CODEX_RESUME:", "resume codex-stress-0")),
("codex", "codex-stress-1", ("CMUX_FAKE_CODEX_RESUME:", "resume codex-stress-1")),
("opencode", "opencode-stress-0", ("CMUX_FAKE_OPENCODE_RESUME:", "--session opencode-stress-0")),
("opencode", "opencode-stress-1", ("CMUX_FAKE_OPENCODE_RESUME:", "--session opencode-stress-1")),
]
def _marker_found(combined: str, tokens: tuple[str, ...]) -> bool:
return any(all(token in line for token in tokens) for line in combined.splitlines())
def _bundle_id(app_path: Path) -> str:
info_path = app_path / "Contents" / "Info.plist"
if not info_path.exists():
raise RuntimeError(f"Missing Info.plist at {info_path}")
with info_path.open("rb") as f:
info = plistlib.load(f)
bundle_id = str(info.get("CFBundleIdentifier", "")).strip()
if not bundle_id:
raise RuntimeError("Missing CFBundleIdentifier")
return bundle_id
def _snapshot_path(bundle_id: str, suffix: str = "") -> Path:
safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id)
return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}{suffix}.json"
def _socket_reachable(socket_path: Path) -> bool:
if not socket_path.exists():
return False
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.settimeout(0.3)
sock.connect(str(socket_path))
sock.sendall(b"ping\n")
data = sock.recv(1024)
return b"PONG" in data
except OSError:
return False
finally:
sock.close()
def _wait_for_socket(socket_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if _socket_reachable(socket_path):
return
time.sleep(0.2)
raise RuntimeError(f"Socket did not become reachable: {socket_path}")
def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if not _socket_reachable(socket_path):
return
time.sleep(0.2)
raise RuntimeError(f"Socket still reachable after quit: {socket_path}")
def _app_pids(app_path: Path) -> list[int]:
exe = app_path / "Contents" / "MacOS" / "cmux DEV"
result = subprocess.run(["pgrep", "-f", str(exe)], capture_output=True, text=True)
return [int(line) for line in result.stdout.split() if line.strip().isdigit()]
def _kill_existing(app_path: Path) -> None:
exe = app_path / "Contents" / "MacOS" / "cmux DEV"
subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True)
time.sleep(1.0)
def _launch(app_path: Path, socket_path: Path, env_overrides: dict[str, str] | None = None) -> None:
try:
socket_path.unlink()
except FileNotFoundError:
pass
command = ["open", "-na", str(app_path)]
full_env = dict(env_overrides or {})
full_env["CMUX_SOCKET_PATH"] = str(socket_path)
full_env["CMUX_ALLOW_SOCKET_OVERRIDE"] = "1"
for key, value in full_env.items():
command.extend(["--env", f"{key}={value}"])
subprocess.run(command, check=True)
_wait_for_socket(socket_path)
time.sleep(1.5)
def _quit(bundle_id: str, socket_path: Path) -> None:
subprocess.run(
["osascript", "-e", f'tell application id "{bundle_id}" to quit'],
capture_output=True,
text=True,
check=True,
)
_wait_for_socket_closed(socket_path)
try:
socket_path.unlink()
except FileNotFoundError:
pass
time.sleep(0.8)
def _force_kill(app_path: Path, socket_path: Path) -> None:
pids = _app_pids(app_path)
if not pids:
raise RuntimeError("expected a running app to SIGKILL")
for pid in pids:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
_wait_for_socket_closed(socket_path)
try:
socket_path.unlink()
except FileNotFoundError:
pass
time.sleep(0.8)
def _connect(socket_path: Path) -> cmux:
client = cmux(socket_path=str(socket_path))
client.connect()
if not client.ping():
raise RuntimeError("ping failed")
return client
def _read_scrollback(client: cmux) -> str:
return client._send_command("read_screen --scrollback")
def _wait_for_condition(timeout: float, predicate) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return True
time.sleep(0.3)
return False
def _write_fake_agent(fake_bin_dir: Path, binary_name: str, prefix: str) -> None:
fake_bin_dir.mkdir(parents=True, exist_ok=True)
fake_binary = fake_bin_dir / binary_name
# Keep running so snapshots record the agent as live (wasAgentRunning=true)
# and later relaunches keep auto-resuming.
fake_binary.write_text(
"#!/bin/sh\n"
f"printf '{prefix}:%s\\n' \"$*\"\n"
"exec sleep 86400\n",
encoding="utf-8",
)
fake_binary.chmod(0o755)
def _write_hook_state(
path: Path,
sessions: list[dict],
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {"version": 1, "sessions": {entry["sessionId"]: entry for entry in sessions}}
path.write_text(json.dumps(payload), encoding="utf-8")
def _hook_session_entry(
session_id: str,
workspace_id: str,
surface_id: str,
cwd: str,
launcher: str,
executable_path: Path,
environment: dict[str, str],
transcript_path: Path | None = None,
) -> dict:
# Claude hook records are only restorable when their transcript exists on
# disk (hookRecordIsRestorable), so claude entries carry a transcriptPath.
entry = {
"sessionId": session_id,
"workspaceId": workspace_id,
"surfaceId": surface_id,
"cwd": cwd,
"launchCommand": {
"launcher": launcher,
"executablePath": str(executable_path),
"arguments": [str(executable_path)],
"workingDirectory": cwd,
"environment": environment,
"capturedAt": time.time(),
"source": "test",
},
"updatedAt": time.time(),
}
if transcript_path is not None:
entry["transcriptPath"] = str(transcript_path)
return entry
def _collect_all_scrollbacks(client: cmux) -> str:
chunks: list[str] = []
workspaces = client.list_workspaces()
for index in range(len(workspaces)):
client.select_workspace(index)
# Wait until the selection has propagated before reading scrollback,
# instead of a fixed settle sleep (deadline-bounded poll on the real
# is-selected signal).
deadline = time.time() + 1.0
while time.time() < deadline:
if any(i == index and selected for i, _wid, _title, selected in client.list_workspaces()):
break
time.sleep(0.02)
chunks.append(_read_scrollback(client))
return "\n".join(chunks)
def _assert_all_sessions_resumed(
client: cmux,
phase: str,
failures: list[str],
timeout: float = 30.0,
) -> None:
expected_markers = [marker for (_, _, marker) in SESSION_SPECS]
def all_present() -> bool:
if len(client.list_workspaces()) < len(SESSION_SPECS):
return False
combined = _collect_all_scrollbacks(client)
return all(_marker_found(combined, marker) for marker in expected_markers)
if _wait_for_condition(timeout, all_present):
return
combined = _collect_all_scrollbacks(client)
missing = [marker for marker in expected_markers if not _marker_found(combined, marker)]
workspace_count = len(client.list_workspaces())
failures.append(
f"{phase}: {len(missing)}/{len(expected_markers)} sessions did not resume "
f"(workspaces={workspace_count}); missing markers: {missing}"
)
def main() -> int:
app_path_str = os.environ.get("CMUX_APP_PATH", "").strip()
if not app_path_str:
print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path")
return 0
app_path = Path(app_path_str)
if not app_path.exists():
print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}")
return 0
bundle_id = _bundle_id(app_path)
socket_path = Path(f"/tmp/cmux-restore-stress-{bundle_id.replace('.', '-')}.sock")
snapshot = _snapshot_path(bundle_id)
previous_snapshot = _snapshot_path(bundle_id, suffix="-previous")
failures: list[str] = []
with tempfile.TemporaryDirectory(prefix="cmux-restore-stress-") as td:
fake_bin_dir = Path(td) / "bin"
hook_state_dir = Path(td) / "hook-state"
hook_state_files = {
launcher: hook_state_dir / f"{launcher}-hook-sessions.json"
for launcher in {launcher for (launcher, _, _) in SESSION_SPECS}
}
_write_fake_agent(fake_bin_dir, "claude", "CMUX_FAKE_CLAUDE_RESUME")
_write_fake_agent(fake_bin_dir, "codex", "CMUX_FAKE_CODEX_RESUME")
_write_fake_agent(fake_bin_dir, "opencode", "CMUX_FAKE_OPENCODE_RESUME")
launch_path = f"{fake_bin_dir}:{os.environ.get('PATH', '')}"
app_env = {
"PATH": launch_path,
"CMUX_AGENT_HOOK_STATE_DIR": str(hook_state_dir),
# Claude resume routes through the cmux claude wrapper, which
# resolves the real binary; point it at the fake one instead.
"CMUX_CUSTOM_CLAUDE_PATH": str(fake_bin_dir / "claude"),
}
def remove_hook_state() -> None:
for hook_state in hook_state_files.values():
hook_state.unlink(missing_ok=True)
_kill_existing(app_path)
snapshot.unlink(missing_ok=True)
previous_snapshot.unlink(missing_ok=True)
remove_hook_state()
try:
# Phase 1: seed one workspace per session.
_launch(app_path, socket_path, env_overrides=app_env)
client = _connect(socket_path)
try:
workspace_ids = [client.current_workspace()]
while len(workspace_ids) < len(SESSION_SPECS):
workspace_ids.append(client.new_workspace())
time.sleep(0.3)
entries_by_launcher: dict[str, list[dict]] = {}
for index, (launcher, session_id, _) in enumerate(SESSION_SPECS):
client.select_workspace(workspace_ids[index])
time.sleep(0.3)
surfaces = client.list_surfaces()
if not surfaces:
failures.append(f"setup: expected a surface in workspace {index}")
continue
transcript_path: Path | None = None
if launcher == "claude":
transcript_path = Path(td) / f"transcript-{session_id}.jsonl"
transcript_path.write_text('{"type":"user"}\n', encoding="utf-8")
entries_by_launcher.setdefault(launcher, []).append(
_hook_session_entry(
session_id=session_id,
workspace_id=workspace_ids[index],
surface_id=surfaces[0][1],
cwd=os.getcwd(),
launcher=launcher,
executable_path=fake_bin_dir / launcher,
environment={"PATH": launch_path, "SHELL": "/bin/zsh"},
transcript_path=transcript_path,
)
)
for launcher, entries in entries_by_launcher.items():
_write_hook_state(hook_state_files[launcher], entries)
client.select_workspace(0)
time.sleep(0.4)
finally:
client.close()
if failures:
return _report(failures)
_quit(bundle_id, socket_path)
# Prove relaunches use the persisted snapshot, not live hook files.
remove_hook_state()
# Phase 2: clean relaunch resumes everything.
_launch(app_path, socket_path, env_overrides=app_env)
client = _connect(socket_path)
try:
_assert_all_sessions_resumed(client, "clean relaunch #1", failures)
finally:
client.close()
_quit(bundle_id, socket_path)
# Phase 3: second clean relaunch (re-saved snapshot) resumes everything.
_launch(app_path, socket_path, env_overrides=app_env)
client = _connect(socket_path)
try:
_assert_all_sessions_resumed(client, "clean relaunch #2", failures)
finally:
client.close()
# Phase 4: force-kill after the autosave window, relaunch, resume.
time.sleep(12.0) # > SessionPersistencePolicy.autosaveInterval
_force_kill(app_path, socket_path)
_launch(app_path, socket_path, env_overrides=app_env)
client = _connect(socket_path)
try:
_assert_all_sessions_resumed(client, "relaunch after SIGKILL", failures)
finally:
client.close()
_quit(bundle_id, socket_path)
# Phase 5: corrupt the primary snapshot; relaunch must recover from
# the -previous backup instead of silently starting fresh.
if not snapshot.exists():
failures.append("corrupt-snapshot phase: expected a primary snapshot after quit")
return _report(failures)
snapshot.write_text('{"version": 9999, "windows": [truncated-mid-w', encoding="utf-8")
_launch(app_path, socket_path, env_overrides=app_env)
client = _connect(socket_path)
try:
_assert_all_sessions_resumed(client, "relaunch with corrupt primary snapshot", failures)
if not previous_snapshot.exists():
failures.append(
"corrupt-snapshot phase: -previous backup snapshot was deleted; "
"restore-session recovery is impossible after a corrupt primary snapshot"
)
else:
# The manual `cmux restore-session` recovery entrypoint must
# also still work from the preserved backup. It reopens the
# backed-up workspaces in a new window (with the same
# workspace ids as the startup fallback restore, since both
# read the same backup), so assert on the window count.
cli_path = app_path / "Contents" / "Resources" / "bin" / "cmux"
restore_env = dict(os.environ)
restore_env["CMUX_SOCKET_PATH"] = str(socket_path)
def window_count() -> int:
result = subprocess.run(
[str(cli_path), "list-windows", "--json"],
capture_output=True,
text=True,
env=restore_env,
)
try:
return len(json.loads(result.stdout))
except (json.JSONDecodeError, TypeError):
return -1
windows_before = window_count()
restore_proc = subprocess.run(
[str(cli_path), "restore-session"],
capture_output=True,
text=True,
env=restore_env,
)
if restore_proc.returncode != 0 or restore_proc.stdout.strip() != "OK":
failures.append(
"corrupt-snapshot phase: restore-session failed after backup-preserving "
f"relaunch; rc={restore_proc.returncode} stdout={restore_proc.stdout!r} "
f"stderr={restore_proc.stderr!r}"
)
elif windows_before < 1 or not _wait_for_condition(
20.0, lambda: window_count() > windows_before
):
failures.append(
"corrupt-snapshot phase: restore-session did not reopen the backed-up "
f"session in a new window (windows before={windows_before}, "
f"after={window_count()})"
)
finally:
client.close()
_quit(bundle_id, socket_path)
finally:
_kill_existing(app_path)
socket_path.unlink(missing_ok=True)
snapshot.unlink(missing_ok=True)
previous_snapshot.unlink(missing_ok=True)
remove_hook_state()
return _report(failures)
def _report(failures: list[str]) -> int:
if failures:
print("FAIL:")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: agent sessions survive clean relaunch, repeat relaunch, SIGKILL, and corrupt-snapshot recovery")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,324 +0,0 @@
#!/usr/bin/env python3
"""
Regression: unfocused workspace scrollback must persist across relaunchs in multi-window setups.
"""
from __future__ import annotations
import os
import plistlib
import re
import socket
import subprocess
import time
from pathlib import Path
from cmux import cmux
def _bundle_id(app_path: Path) -> str:
info_path = app_path / "Contents" / "Info.plist"
if not info_path.exists():
raise RuntimeError(f"Missing Info.plist at {info_path}")
with info_path.open("rb") as f:
info = plistlib.load(f)
bundle_id = str(info.get("CFBundleIdentifier", "")).strip()
if not bundle_id:
raise RuntimeError("Missing CFBundleIdentifier")
return bundle_id
def _snapshot_path(bundle_id: str) -> Path:
safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id)
return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}.json"
def _sanitize_tag_slug(raw: str) -> str:
cleaned = re.sub(r"[^a-z0-9]+", "-", (raw or "").strip().lower())
cleaned = re.sub(r"-+", "-", cleaned).strip("-")
return cleaned or "agent"
def _socket_candidates(app_path: Path, preferred: Path) -> list[Path]:
candidates = [preferred]
app_name = app_path.stem
prefix = "cmux DEV "
if app_name.startswith(prefix):
tag = app_name[len(prefix):]
slug = _sanitize_tag_slug(tag)
candidates.append(Path(f"/tmp/cmux-debug-{slug}.sock"))
deduped: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
key = str(candidate)
if key in seen:
continue
seen.add(key)
deduped.append(candidate)
return deduped
def _socket_reachable(socket_path: Path) -> bool:
if not socket_path.exists():
return False
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.settimeout(0.3)
sock.connect(str(socket_path))
sock.sendall(b"ping\n")
data = sock.recv(1024)
return b"PONG" in data
except OSError:
return False
finally:
sock.close()
def _wait_for_socket(candidates: list[Path], timeout: float = 20.0) -> Path:
deadline = time.time() + timeout
while time.time() < deadline:
for candidate in candidates:
if _socket_reachable(candidate):
return candidate
time.sleep(0.2)
joined = ", ".join(str(path) for path in candidates)
raise RuntimeError(f"Socket did not become reachable: {joined}")
def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if not _socket_reachable(socket_path):
return
time.sleep(0.2)
raise RuntimeError(f"Socket still reachable after quit: {socket_path}")
def _kill_existing(app_path: Path) -> None:
exe = app_path / "Contents" / "MacOS" / "cmux DEV"
subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True)
time.sleep(1.0)
def _launch(app_path: Path, preferred_socket_path: Path) -> Path:
try:
preferred_socket_path.unlink()
except FileNotFoundError:
pass
subprocess.run(
[
"open",
"-na",
str(app_path),
"--env",
f"CMUX_SOCKET_PATH={preferred_socket_path}",
"--env",
"CMUX_ALLOW_SOCKET_OVERRIDE=1",
],
check=True,
)
resolved_socket_path = _wait_for_socket(_socket_candidates(app_path, preferred_socket_path))
time.sleep(1.5)
return resolved_socket_path
def _quit(bundle_id: str, socket_path: Path) -> None:
subprocess.run(
["osascript", "-e", f'tell application id "{bundle_id}" to quit'],
capture_output=True,
text=True,
check=True,
)
_wait_for_socket_closed(socket_path)
try:
socket_path.unlink()
except FileNotFoundError:
pass
time.sleep(0.8)
def _connect(socket_path: Path) -> cmux:
client = cmux(socket_path=str(socket_path))
client.connect()
if not client.ping():
raise RuntimeError("ping failed")
return client
def _read_scrollback(client: cmux) -> str:
return client._send_command("read_screen --scrollback")
def _wait_for_marker(client: cmux, marker: str, timeout: float = 8.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if marker in _read_scrollback(client):
return True
time.sleep(0.25)
return False
def _consume_visible_markers(client: cmux, remaining: set[str], timeout: float = 4.0) -> None:
if not remaining:
return
deadline = time.time() + timeout
while time.time() < deadline and remaining:
text = _read_scrollback(client)
matched = [marker for marker in remaining if marker in text]
if matched:
for marker in matched:
remaining.discard(marker)
if not remaining:
return
time.sleep(0.25)
def _ensure_workspaces(client: cmux, count: int) -> None:
while len(client.list_workspaces()) < count:
client.new_workspace()
time.sleep(0.3)
def _list_windows(client: cmux) -> list[str]:
response = client._send_command("list_windows")
if response == "No windows":
return []
window_ids: list[str] = []
for line in response.splitlines():
line = line.strip()
if not line:
continue
parts = line.lstrip("* ").split(" ", 2)
if len(parts) >= 2:
window_ids.append(parts[1])
return window_ids
def _new_window(client: cmux) -> str:
response = client._send_command("new_window")
if not response.startswith("OK "):
raise RuntimeError(f"new_window failed: {response}")
return response.split(" ", 1)[1].strip()
def _focus_window(client: cmux, window_id: str) -> None:
response = client._send_command(f"focus_window {window_id}")
if response != "OK":
raise RuntimeError(f"focus_window failed for {window_id}: {response}")
def main() -> int:
app_path_str = os.environ.get("CMUX_APP_PATH", "").strip()
if not app_path_str:
print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path")
return 0
app_path = Path(app_path_str)
if not app_path.exists():
print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}")
return 0
bundle_id = _bundle_id(app_path)
snapshot = _snapshot_path(bundle_id)
# Keep the override path short enough for Darwin's Unix socket path limit.
bundle_suffix = re.sub(r"[^A-Za-z0-9]", "", bundle_id)[-16:] or "bundle"
socket_path = Path(f"/tmp/cmux-mw-restore-{bundle_suffix}.sock")
markers = {
"w1_ws0": "CMUX_MW_RESTORE_W1_WS0",
"w1_ws1": "CMUX_MW_RESTORE_W1_WS1",
"w2_ws0": "CMUX_MW_RESTORE_W2_WS0",
"w2_ws1": "CMUX_MW_RESTORE_W2_WS1",
}
failures: list[str] = []
_kill_existing(app_path)
snapshot.unlink(missing_ok=True)
try:
# Launch 1: create 2 windows x 2 workspaces; write markers.
socket_path = _launch(app_path, socket_path)
client = _connect(socket_path)
try:
# Window 1 setup.
_ensure_workspaces(client, 2)
client.select_workspace(0)
client.send(f"echo {markers['w1_ws0']}\n")
if not _wait_for_marker(client, markers["w1_ws0"]):
failures.append("missing marker for window1 workspace0 during setup")
client.select_workspace(1)
client.send(f"echo {markers['w1_ws1']}\n")
if not _wait_for_marker(client, markers["w1_ws1"]):
failures.append("missing marker for window1 workspace1 during setup")
client.select_workspace(0) # leave workspace 1 unfocused in window 1
# Window 2 setup.
_new_window(client)
time.sleep(0.5)
_ensure_workspaces(client, 2)
client.select_workspace(0)
client.send(f"echo {markers['w2_ws0']}\n")
if not _wait_for_marker(client, markers["w2_ws0"]):
failures.append("missing marker for window2 workspace0 during setup")
client.select_workspace(1)
client.send(f"echo {markers['w2_ws1']}\n")
if not _wait_for_marker(client, markers["w2_ws1"]):
failures.append("missing marker for window2 workspace1 during setup")
client.select_workspace(0) # leave workspace 1 unfocused in window 2
finally:
client.close()
_quit(bundle_id, socket_path)
# Launch 2: immediate quit without focusing unfocused workspaces.
socket_path = _launch(app_path, socket_path)
client = _connect(socket_path)
try:
window_ids = _list_windows(client)
if len(window_ids) < 2:
failures.append(f"expected >=2 windows after first relaunch, got {len(window_ids)}")
finally:
client.close()
_quit(bundle_id, socket_path)
# Launch 3: verify all markers still present across windows/workspaces.
socket_path = _launch(app_path, socket_path)
client = _connect(socket_path)
try:
window_ids = _list_windows(client)
if len(window_ids) < 2:
failures.append(f"expected >=2 windows after second relaunch, got {len(window_ids)}")
remaining = set(markers.values())
for window_id in window_ids:
_focus_window(client, window_id)
time.sleep(0.3)
workspace_count = len(client.list_workspaces())
for idx in range(min(workspace_count, 2)):
client.select_workspace(idx)
_consume_visible_markers(client, remaining, timeout=6.0)
if not remaining:
break
if not remaining:
break
if remaining:
failures.append(f"missing markers after second relaunch: {sorted(remaining)}")
finally:
client.close()
_quit(bundle_id, socket_path)
finally:
_kill_existing(app_path)
socket_path.unlink(missing_ok=True)
snapshot.unlink(missing_ok=True)
if failures:
print("FAIL:")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: multi-window unfocused workspaces survive repeated relaunch")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""
Regression: unfocused restored workspaces must survive a second relaunch.
Repro for the historical bug:
1) Launch and save workspaces with marker scrollback.
2) Relaunch, do not focus the non-selected workspaces, then quit again.
3) Relaunch and verify marker scrollback still exists for every workspace.
"""
from __future__ import annotations
import os
import plistlib
import re
import socket
import subprocess
import time
from pathlib import Path
from cmux import cmux
def _bundle_id(app_path: Path) -> str:
info_path = app_path / "Contents" / "Info.plist"
if not info_path.exists():
raise RuntimeError(f"Missing Info.plist at {info_path}")
with info_path.open("rb") as f:
info = plistlib.load(f)
bundle_id = str(info.get("CFBundleIdentifier", "")).strip()
if not bundle_id:
raise RuntimeError("Missing CFBundleIdentifier")
return bundle_id
def _snapshot_path(bundle_id: str) -> Path:
safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id)
return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}.json"
def _socket_reachable(socket_path: Path) -> bool:
if not socket_path.exists():
return False
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.settimeout(0.3)
sock.connect(str(socket_path))
sock.sendall(b"ping\n")
data = sock.recv(1024)
return b"PONG" in data
except OSError:
return False
finally:
sock.close()
def _wait_for_socket(socket_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if _socket_reachable(socket_path):
return
time.sleep(0.2)
raise RuntimeError(f"Socket did not become reachable: {socket_path}")
def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if not _socket_reachable(socket_path):
return
time.sleep(0.2)
raise RuntimeError(f"Socket still reachable after quit: {socket_path}")
def _kill_existing(app_path: Path) -> None:
exe = app_path / "Contents" / "MacOS" / "cmux DEV"
subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True)
time.sleep(1.0)
def _launch(app_path: Path, socket_path: Path) -> None:
try:
socket_path.unlink()
except FileNotFoundError:
pass
subprocess.run(
[
"open",
"-na",
str(app_path),
"--env",
f"CMUX_SOCKET_PATH={socket_path}",
"--env",
"CMUX_ALLOW_SOCKET_OVERRIDE=1",
],
check=True,
)
_wait_for_socket(socket_path)
time.sleep(1.5)
def _quit(bundle_id: str, socket_path: Path) -> None:
subprocess.run(
["osascript", "-e", f'tell application id "{bundle_id}" to quit'],
capture_output=True,
text=True,
check=True,
)
_wait_for_socket_closed(socket_path)
try:
socket_path.unlink()
except FileNotFoundError:
pass
time.sleep(0.8)
def _connect(socket_path: Path) -> cmux:
client = cmux(socket_path=str(socket_path))
client.connect()
if not client.ping():
raise RuntimeError("ping failed")
return client
def _read_scrollback(client: cmux) -> str:
return client._send_command("read_screen --scrollback")
def _wait_for_marker(client: cmux, marker: str, timeout: float = 8.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if marker in _read_scrollback(client):
return True
time.sleep(0.25)
return False
def main() -> int:
app_path_str = os.environ.get("CMUX_APP_PATH", "").strip()
if not app_path_str:
print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path")
return 0
app_path = Path(app_path_str)
if not app_path.exists():
print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}")
return 0
bundle_id = _bundle_id(app_path)
snapshot = _snapshot_path(bundle_id)
socket_path = Path(f"/tmp/cmux-session-restore-cycle-{bundle_id.replace('.', '-')}.sock")
markers = [f"CMUX_RESTORE_EDGE_{i}" for i in range(3)]
failures: list[str] = []
_kill_existing(app_path)
snapshot.unlink(missing_ok=True)
try:
# First launch: seed three workspaces with marker scrollback.
_launch(app_path, socket_path)
client = _connect(socket_path)
try:
while len(client.list_workspaces()) < 3:
client.new_workspace()
time.sleep(0.3)
for idx, marker in enumerate(markers):
client.select_workspace(idx)
time.sleep(0.4)
client.send(f"echo {marker}\n")
if not _wait_for_marker(client, marker, timeout=6.0):
failures.append(f"setup marker missing in workspace {idx}: {marker}")
# Keep selected workspace deterministic.
client.select_workspace(1)
time.sleep(0.3)
finally:
client.close()
_quit(bundle_id, socket_path)
# Second launch: do not focus unfocused workspaces. Quit immediately.
_launch(app_path, socket_path)
client = _connect(socket_path)
try:
restored = client.list_workspaces()
if len(restored) < 3:
failures.append(f"expected >=3 workspaces after first relaunch, got {len(restored)}")
selected_indices = [idx for idx, _wid, _title, selected in restored if selected]
if selected_indices != [1]:
failures.append(f"expected selected workspace index [1], got {selected_indices}")
finally:
client.close()
_quit(bundle_id, socket_path)
# Third launch: every workspace should still contain its marker.
_launch(app_path, socket_path)
client = _connect(socket_path)
try:
restored = client.list_workspaces()
if len(restored) < 3:
failures.append(f"expected >=3 workspaces after second relaunch, got {len(restored)}")
for idx, marker in enumerate(markers):
client.select_workspace(idx)
if not _wait_for_marker(client, marker, timeout=8.0):
tail = "\n".join(_read_scrollback(client).splitlines()[-10:])
failures.append(
f"workspace {idx} missing marker {marker} after second relaunch; tail:\n{tail}"
)
finally:
client.close()
_quit(bundle_id, socket_path)
finally:
_kill_existing(app_path)
socket_path.unlink(missing_ok=True)
snapshot.unlink(missing_ok=True)
if failures:
print("FAIL:")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: unfocused workspace scrollback survives repeated relaunch")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-238
View File
@@ -1,238 +0,0 @@
#!/usr/bin/env python3
"""
End-to-end test for sidebar CWD + git branch updates.
This specifically covers the regression where the sidebar directory can get
stuck (e.g. showing "~" even after multiple `cd`s).
Run with a tagged instance to avoid unix socket conflicts:
CMUX_TAG=<tag> python3 tests/test_sidebar_cwd_git.py
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import time
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 # noqa: E402
def _parse_sidebar_state(text: str) -> dict[str, str]:
data: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.rstrip("\n")
if not line or line.startswith(" "):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def _wait_for(predicate, timeout: float, interval: float, label: str):
start = time.time()
last_error: Exception | None = None
while time.time() - start < timeout:
try:
value = predicate()
if value:
return value
except Exception as e:
last_error = e
time.sleep(interval)
if last_error is not None:
raise AssertionError(f"Timed out waiting for {label}. Last error: {last_error}")
raise AssertionError(f"Timed out waiting for {label}.")
def _wait_for_state_field(
client: cmux,
key: str,
expected: str,
timeout: float = 6.0,
interval: float = 0.1,
) -> dict[str, str]:
def pred():
state = _parse_sidebar_state(client.sidebar_state())
return state if state.get(key) == expected else None
return _wait_for(pred, timeout=timeout, interval=interval, label=f"{key}={expected!r}")
def _wait_for_git_branch(
client: cmux,
expected: str,
timeout: float = 12.0,
interval: float = 0.15,
allow_force_fallback: bool = True,
) -> dict[str, str]:
def pred():
state = _parse_sidebar_state(client.sidebar_state())
raw = state.get("git_branch", "")
branch = raw.split(" ", 1)[0] # "main dirty" -> "main", "none" -> "none"
return state if branch == expected else None
try:
return _wait_for(pred, timeout=timeout, interval=interval, label=f"git_branch={expected!r}")
except AssertionError as original_error:
if not allow_force_fallback:
raise original_error
# VM shells can occasionally skip a prompt hook; force a one-shot report so
# the remainder of the flow can still validate transition behavior.
try:
tab_id = client.current_workspace()
if expected == "none":
client._send_command(f"clear_git_branch --tab={tab_id}")
else:
client._send_command(f"report_git_branch {expected} --status=clean --tab={tab_id}")
return _wait_for(pred, timeout=2.5, interval=0.1, label=f"git_branch={expected!r} (forced)")
except Exception:
raise original_error
def _git(cwd: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=str(cwd), check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _init_git_repo(repo: Path) -> None:
repo.mkdir(parents=True, exist_ok=True)
_git(repo, "init")
_git(repo, "config", "user.email", "[email protected]")
_git(repo, "config", "user.name", "cmux-test")
(repo / "README.md").write_text("hello\n", encoding="utf-8")
_git(repo, "add", "README.md")
_git(repo, "commit", "-m", "init")
# Normalize the initial branch to "main" so the test is deterministic.
branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=str(repo)
).decode("utf-8", errors="replace").strip()
if branch and branch != "main":
_git(repo, "branch", "-m", "main")
def _send_cd_and_wait(
client: cmux,
target: Path,
attempts: int = 3,
timeout: float = 6.0,
interval: float = 0.1,
) -> dict[str, str]:
expected = str(target.resolve())
last_error: AssertionError | None = None
for _ in range(attempts):
client.send(f"cd {target}\n")
try:
return _wait_for_state_field(client, "cwd", expected, timeout=timeout, interval=interval)
except AssertionError as e:
last_error = e
time.sleep(0.15)
# Fallback for VM runs where prompt hooks can occasionally be skipped.
try:
tab_id = client.current_workspace()
surfaces = client.list_surfaces()
if surfaces:
panel_id = surfaces[0][1]
client._send_command(f"report_pwd {expected} --tab={tab_id} --panel={panel_id}")
return _wait_for_state_field(client, "cwd", expected, timeout=2.5, interval=0.1)
except Exception:
pass
raise last_error or AssertionError(f"Timed out waiting for cwd={expected!r}")
def main() -> int:
tag = os.environ.get("CMUX_TAG") or ""
if not tag:
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
base = Path("/tmp") / f"cmux_sidebar_test_{os.getpid()}"
repo = base / "repo"
other = base / "other"
try:
if base.exists():
shutil.rmtree(base)
other.mkdir(parents=True, exist_ok=True)
_init_git_repo(repo)
with cmux() as client:
new_tab_id = client.new_tab()
client.select_tab(new_tab_id)
time.sleep(0.6)
# Initial: sync via `pwd` to a file, then wait for sidebar_state cwd.
marker = base / "pwd.txt"
client.send(f"pwd > {marker}\n")
_wait_for(lambda: marker.exists(), timeout=4.0, interval=0.1, label="pwd marker file")
expected_pwd = str(Path(marker.read_text(encoding="utf-8").strip()).resolve())
_wait_for_state_field(client, "cwd", expected_pwd)
# Multiple cd's: ensure cwd tracks changes.
_send_cd_and_wait(client, other)
_wait_for_git_branch(client, "none")
_send_cd_and_wait(client, repo)
_wait_for_git_branch(client, "main")
# Branch changes during a long-running foreground command should still
# propagate before the prompt returns (agent-style workflows).
client.send("bash -lc 'git checkout -b feature/agent-live >/dev/null 2>&1; sleep 6'\n")
# The branch change happens at the start of the command; `sleep 6` then
# holds the prompt for 6s. Propagation must therefore land mid-command,
# before the prompt returns. Keep the no-fallback intent (this proves the
# async HEAD-watch path, not the prompt hook) but use a generous window
# under the 6s prompt-return so hook/socket scheduling jitter on a loaded
# CI/VM host doesn't fail correct code.
_wait_for_git_branch(
client,
"feature/agent-live",
timeout=5.5,
interval=0.1,
allow_force_fallback=False,
)
time.sleep(6.3)
# Branch change should update.
# Cover alias/non-`git ...` command paths too (regression: branch could
# stick for ~3s when switching via alias/tools like `gh pr checkout`).
client.send("alias gco='git checkout'\n")
time.sleep(0.2)
client.send("gco -b feature/sidebar\n")
_wait_for_git_branch(client, "feature/sidebar")
client.send("gco main\n")
_wait_for_git_branch(client, "main")
# Leaving the repo should clear the branch.
_send_cd_and_wait(client, other)
_wait_for_git_branch(client, "none")
try:
client.close_tab(new_tab_id)
except Exception:
pass
print("Sidebar CWD + git branch test passed.")
return 0
except (cmuxError, subprocess.CalledProcessError, AssertionError) as e:
print(f"Sidebar CWD + git branch test failed: {e}")
return 1
finally:
try:
shutil.rmtree(base)
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())
-126
View File
@@ -1,126 +0,0 @@
#!/usr/bin/env python3
"""
End-to-end test for generic sidebar metadata commands.
Validates:
1) report_meta stores icon/url/priority/format metadata
2) metadata list ordering follows priority
3) set_status remains compatible as an alias-style metadata writer
4) clear_meta removes metadata entries
"""
from __future__ import annotations
import os
import sys
import time
# 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 # noqa: E402
def _parse_sidebar_state(text: str) -> dict[str, str]:
data: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.rstrip("\n")
if not line or line.startswith(" "):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def _wait_for_state_field(
client: cmux,
key: str,
expected: str,
timeout: float = 8.0,
interval: float = 0.1,
) -> dict[str, str]:
start = time.time()
while time.time() - start < timeout:
state = _parse_sidebar_state(client.sidebar_state())
if state.get(key) == expected:
return state
time.sleep(interval)
raise AssertionError(f"Timed out waiting for {key}={expected!r}")
def main() -> int:
tag = os.environ.get("CMUX_TAG") or ""
if not tag:
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
pr_url = "https://github.com/manaflow-ai/cmux/pull/337"
try:
with cmux() as client:
new_tab_id = client.new_tab()
client.select_tab(new_tab_id)
time.sleep(0.6)
tab_id = client.current_workspace()
client.report_meta(
"task",
"**Review** PR 337",
icon="sf:doc.text.magnifyingglass",
url=pr_url,
priority=50,
format="markdown",
tab=tab_id,
)
client.report_meta(
"context",
"issue-336-sidebar-pr-metadata",
icon="text:CTX",
priority=10,
tab=tab_id,
)
_wait_for_state_field(client, "status_count", "2")
listed = client.list_meta(tab=tab_id).splitlines()
if len(listed) != 2:
raise AssertionError(f"Expected 2 metadata entries, got {len(listed)}: {listed}")
if not listed[0].startswith("task="):
raise AssertionError(f"Expected first entry to be task metadata. Got: {listed[0]}")
if "priority=50" not in listed[0]:
raise AssertionError(f"Expected task entry to include priority. Got: {listed[0]}")
if "format=markdown" not in listed[0]:
raise AssertionError(f"Expected markdown format in task entry. Got: {listed[0]}")
if f"url={pr_url}" not in listed[0]:
raise AssertionError(f"Expected URL in task entry. Got: {listed[0]}")
client.set_status("agent", "in progress", icon="text:AI", priority=80, tab=tab_id)
_wait_for_state_field(client, "status_count", "3")
listed = client.list_meta(tab=tab_id).splitlines()
if not listed[0].startswith("agent="):
raise AssertionError(f"Expected highest-priority agent entry first. Got: {listed[0]}")
client.clear_meta("task", tab=tab_id)
_wait_for_state_field(client, "status_count", "2")
listed = client.list_meta(tab=tab_id).splitlines()
if any(line.startswith("task=") for line in listed):
raise AssertionError(f"Task metadata should be cleared. Got: {listed}")
try:
client.close_tab(new_tab_id)
except Exception:
pass
print("Sidebar metadata test passed.")
return 0
except (cmuxError, AssertionError) as e:
print(f"Sidebar metadata test failed: {e}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/env python3
"""
End-to-end test for sidebar markdown metadata block commands.
Validates:
1) report_meta_block stores markdown payload and priority
2) metadata block list ordering follows priority
3) clear_meta_block removes block metadata
"""
from __future__ import annotations
import os
import sys
import time
# 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 # noqa: E402
def _parse_sidebar_state(text: str) -> dict[str, str]:
data: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.rstrip("\n")
if not line or line.startswith(" "):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def _wait_for_state_field(
client: cmux,
key: str,
expected: str,
timeout: float = 8.0,
interval: float = 0.1,
) -> dict[str, str]:
start = time.time()
while time.time() - start < timeout:
state = _parse_sidebar_state(client.sidebar_state())
if state.get(key) == expected:
return state
time.sleep(interval)
raise AssertionError(f"Timed out waiting for {key}={expected!r}")
def main() -> int:
tag = os.environ.get("CMUX_TAG") or ""
if not tag:
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
try:
with cmux() as client:
new_tab_id = client.new_tab()
client.select_tab(new_tab_id)
time.sleep(0.6)
tab_id = client.current_workspace()
summary_md = "### Agent\\n- status: in progress\\n- pr: #337"
footer_md = "_last update: now_"
client.report_meta_block("summary", summary_md, priority=50, tab=tab_id)
client.report_meta_block("footer", footer_md, priority=10, tab=tab_id)
_wait_for_state_field(client, "meta_block_count", "2")
listed = client.list_meta_blocks(tab=tab_id).splitlines()
if len(listed) != 2:
raise AssertionError(f"Expected 2 metadata blocks, got {len(listed)}: {listed}")
if not listed[0].startswith("summary="):
raise AssertionError(f"Expected highest-priority block first. Got: {listed[0]}")
if "priority=50" not in listed[0]:
raise AssertionError(f"Expected summary block priority in listing. Got: {listed[0]}")
client.clear_meta_block("summary", tab=tab_id)
_wait_for_state_field(client, "meta_block_count", "1")
listed = client.list_meta_blocks(tab=tab_id).splitlines()
if any(line.startswith("summary=") for line in listed):
raise AssertionError(f"Summary block should be cleared. Got: {listed}")
try:
client.close_tab(new_tab_id)
except Exception:
pass
print("Sidebar markdown metadata block test passed.")
return 0
except (cmuxError, AssertionError) as e:
print(f"Sidebar markdown metadata block test failed: {e}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
-382
View File
@@ -1,382 +0,0 @@
#!/usr/bin/env python3
"""
End-to-end test for sidebar listening ports auto-detection.
This covers regressions where a listening server (e.g. `python3 -m http.server`)
doesn't show up in the sidebar ports row.
Run with a tagged instance to avoid unix socket conflicts:
CMUX_TAG=<tag> python3 tests/test_sidebar_ports.py
"""
from __future__ import annotations
import os
import shutil
import signal
import socket
import subprocess
import sys
import time
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 # noqa: E402
# Historically, ports detection only checked a small allowlist. This test
# intentionally uses a port outside that set to avoid regressions where ports
# "work" only for the allowlist.
_HISTORICAL_ALLOWLIST = {8000, 8080, 8888, 5173, 3000, 3001, 5000, 5432}
_PREFERRED_BIND_HOST = "127.0.0.1"
def _parse_sidebar_state(text: str) -> dict[str, str]:
data: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.rstrip("\n")
if not line or line.startswith(" "):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def _wait_for(predicate, timeout: float, interval: float, label: str):
start = time.time()
last_error: Exception | None = None
while time.time() - start < timeout:
try:
value = predicate()
if value:
return value
except Exception as e:
last_error = e
time.sleep(interval)
if last_error is not None:
raise AssertionError(f"Timed out waiting for {label}. Last error: {last_error}")
raise AssertionError(f"Timed out waiting for {label}.")
def _find_free_allowed_port() -> int:
# Prefer a random ephemeral port to avoid flakiness from well-known ports
# being grabbed by background services.
for _ in range(50):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((_PREFERRED_BIND_HOST, 0))
port = int(s.getsockname()[1])
if port not in _HISTORICAL_ALLOWLIST:
return port
finally:
try:
s.close()
except Exception:
pass
raise RuntimeError("Failed to find a free test port (outside historical allowlist).")
def _start_external_server(base: Path, port: int) -> subprocess.Popen:
"""
Start an http.server outside cmux and ensure it is actually listening.
Retries are handled by the caller by picking a different port.
"""
proc = subprocess.Popen(
[sys.executable, "-m", "http.server", str(port), "--bind", _PREFERRED_BIND_HOST],
cwd=str(base),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
_wait_for_lsof_listen_pid(port, expected_pid=proc.pid, timeout=6.0)
return proc
def _start_agent_server(base: Path, port: int, pid_file: Path, log_file: Path) -> subprocess.Popen:
"""
Start a long-lived "agent" shell outside cmux. The shell owns a child
http.server, which should be attributed to the workspace only after the
shell PID is registered via set_agent_pid.
"""
script = (
f"rm -f {pid_file} {log_file}; "
f"python3 -m http.server {port} --bind {_PREFERRED_BIND_HOST} > {log_file} 2>&1 & "
f"echo $! > {pid_file}; "
"wait"
)
proc = subprocess.Popen(
["/bin/bash", "-lc", script],
cwd=str(base),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
_wait_for(lambda: pid_file.exists(), timeout=4.0, interval=0.1, label="agent pid file")
child_pid = int(pid_file.read_text(encoding="utf-8").strip())
_wait_for_lsof_listen_pid(port, expected_pid=child_pid, timeout=8.0)
return proc
def _wait_for_port(client: cmux, port: int, timeout: float = 18.0) -> dict[str, str]:
def pred():
state = _parse_sidebar_state(client.sidebar_state())
raw = state.get("ports", "")
if raw == "none" or not raw:
return None
ports = []
for item in raw.split(","):
item = item.strip()
if not item:
continue
try:
ports.append(int(item))
except ValueError:
continue
return state if port in ports else None
return _wait_for(pred, timeout=timeout, interval=0.15, label=f"ports include {port}")
def _wait_for_port_absent(client: cmux, port: int, timeout: float = 18.0) -> dict[str, str]:
def pred():
state = _parse_sidebar_state(client.sidebar_state())
raw = state.get("ports", "")
if raw == "none" or not raw:
return state
ports = []
for item in raw.split(","):
item = item.strip()
if not item:
continue
try:
ports.append(int(item))
except ValueError:
continue
return state if port not in ports else None
return _wait_for(pred, timeout=timeout, interval=0.15, label=f"ports do not include {port}")
def _assert_port_absent_for_duration(client: cmux, port: int, duration: float = 6.0, interval: float = 0.15) -> None:
"""
Assert the port does not appear in sidebar_state during the full duration.
This is important to catch "machine-wide ports" leaking into a fresh tab.
"""
start = time.time()
while time.time() - start < duration:
state = _parse_sidebar_state(client.sidebar_state())
raw = state.get("ports", "")
if raw and raw != "none":
try:
ports = {int(p.strip()) for p in raw.split(",") if p.strip()}
except ValueError:
ports = set()
if port in ports:
raise AssertionError(f"Port {port} unexpectedly appeared in sidebar ports: {raw}")
time.sleep(interval)
def _wait_for_lsof_listen_pid(port: int, expected_pid: int | None, timeout: float = 8.0) -> int:
"""
Wait until `lsof -iTCP:<port> -sTCP:LISTEN` returns a pid.
If expected_pid is provided, require that pid to be present.
"""
def pred():
result = subprocess.run(
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
pids = []
for line in (result.stdout or "").splitlines():
line = line.strip()
if not line:
continue
try:
pids.append(int(line))
except ValueError:
continue
if not pids:
return None
if expected_pid is not None and expected_pid not in pids:
return None
return expected_pid if expected_pid is not None else pids[0]
value = _wait_for(pred, timeout=timeout, interval=0.15, label=f"lsof LISTEN pid for {port}")
return int(value)
def _wait_for_lsof_listen_gone(port: int, timeout: float = 8.0) -> None:
def pred():
result = subprocess.run(
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
capture_output=True,
text=True,
)
return result.returncode != 0 or not (result.stdout or "").strip()
_wait_for(pred, timeout=timeout, interval=0.15, label=f"lsof no LISTEN for {port}")
def _terminate_process_group(proc: subprocess.Popen | None) -> None:
if proc is None:
return
try:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
return
except Exception:
try:
proc.terminate()
except Exception:
return
try:
proc.wait(timeout=3.0)
except subprocess.TimeoutExpired:
try:
os.killpg(proc.pid, signal.SIGKILL)
except Exception:
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=2.0)
except Exception:
pass
def main() -> int:
tag = os.environ.get("CMUX_TAG") or ""
if not tag:
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
base = Path("/tmp") / f"cmux_ports_test_{os.getpid()}"
tab_pid_file = base / "tab-server.pid"
tab_log_file = base / "tab-server.log"
agent_pid_file = base / "agent-server.pid"
agent_log_file = base / "agent-server.log"
external_proc: subprocess.Popen | None = None
agent_proc: subprocess.Popen | None = None
try:
if base.exists():
shutil.rmtree(base)
base.mkdir(parents=True, exist_ok=True)
# Start a listening server outside cmux. A fresh tab should NOT show this port,
# since ports should be attributed to the shell session in the tab.
port = None
last_start_err: Exception | None = None
for _ in range(8):
try:
port = _find_free_allowed_port()
external_proc = _start_external_server(base, port)
break
except Exception as e:
last_start_err = e
if external_proc is not None:
try:
external_proc.kill()
except Exception:
pass
external_proc = None
continue
if port is None or external_proc is None:
raise RuntimeError(f"Failed to start external http.server. Last error: {last_start_err}")
with cmux() as client:
new_tab_id = client.new_tab()
client.select_tab(new_tab_id)
time.sleep(0.8)
# Trigger a prompt cycle (and thus a ports scan burst) before checking absence.
client.send("echo cmux_ports_test\n")
_assert_port_absent_for_duration(client, port, duration=6.0)
# Stop the external server, then reuse the port inside the tab.
external_proc.terminate()
try:
external_proc.wait(timeout=3.0)
except subprocess.TimeoutExpired:
external_proc.kill()
external_proc = None
_wait_for_lsof_listen_gone(port, timeout=8.0)
# Start a server in the background and capture its PID so we can clean up.
client.send(f"rm -f {tab_pid_file} {tab_log_file}\n")
client.send(
f"python3 -m http.server {port} --bind {_PREFERRED_BIND_HOST} > {tab_log_file} 2>&1 & echo $! > {tab_pid_file}\n"
)
_wait_for(lambda: tab_pid_file.exists(), timeout=4.0, interval=0.1, label="pid file")
pid = int(tab_pid_file.read_text(encoding="utf-8").strip())
# Ensure the server is actually listening (sanity check + reduces flakiness).
_wait_for_lsof_listen_pid(port, expected_pid=pid, timeout=8.0)
# Wait for the sidebar to report the port.
_wait_for_port(client, port, timeout=18.0)
# Cleanup server.
client.send(f"kill {pid} >/dev/null 2>&1 || true\n")
_wait_for_lsof_listen_gone(port, timeout=8.0)
_wait_for_port_absent(client, port, timeout=18.0)
# Agent-owned descendant processes should stay hidden until the agent PID is
# explicitly registered for this workspace.
agent_port = _find_free_allowed_port()
agent_proc = _start_agent_server(base, agent_port, agent_pid_file, agent_log_file)
client.ports_kick(tab=new_tab_id)
_assert_port_absent_for_duration(client, agent_port, duration=3.0)
client.set_agent_pid("test_agent", agent_proc.pid, tab=new_tab_id)
client.ports_kick(tab=new_tab_id)
_wait_for_port(client, agent_port, timeout=18.0)
client.clear_agent_pid("test_agent", tab=new_tab_id)
_wait_for_port_absent(client, agent_port, timeout=18.0)
_terminate_process_group(agent_proc)
agent_proc = None
_wait_for_lsof_listen_gone(agent_port, timeout=8.0)
try:
client.close_tab(new_tab_id)
except Exception:
pass
print("Sidebar ports test passed.")
return 0
except (cmuxError, AssertionError, RuntimeError, ValueError) as e:
print(f"Sidebar ports test failed: {e}")
return 1
finally:
if external_proc is not None:
try:
external_proc.terminate()
external_proc.wait(timeout=2.0)
except Exception:
try:
external_proc.kill()
except Exception:
pass
_terminate_process_group(agent_proc)
try:
shutil.rmtree(base)
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())
-102
View File
@@ -1,102 +0,0 @@
#!/usr/bin/env python3
"""
End-to-end test for sidebar pull-request metadata.
Validates:
1) report_pr writes sidebar PR state
2) state transition open -> merged is reflected
3) provider labels can be set via report_review/report_pr --label
4) clear_pr removes PR metadata
"""
from __future__ import annotations
import os
import sys
import time
# 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 # noqa: E402
def _parse_sidebar_state(text: str) -> dict[str, str]:
data: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.rstrip("\n")
if not line or line.startswith(" "):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def _wait_for_state_field(
client: cmux,
key: str,
expected: str,
timeout: float = 8.0,
interval: float = 0.1,
) -> dict[str, str]:
start = time.time()
while time.time() - start < timeout:
state = _parse_sidebar_state(client.sidebar_state())
if state.get(key) == expected:
return state
time.sleep(interval)
raise AssertionError(f"Timed out waiting for {key}={expected!r}")
def main() -> int:
tag = os.environ.get("CMUX_TAG") or ""
if not tag:
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
pr_number = 123
pr_url = f"https://github.com/manaflow-ai/cmux/pull/{pr_number}"
try:
with cmux() as client:
new_tab_id = client.new_tab()
client.select_tab(new_tab_id)
time.sleep(0.6)
tab_id = client.current_workspace()
surfaces = client.list_surfaces()
if not surfaces:
raise AssertionError("No surfaces found in selected workspace")
panel_id = surfaces[0][1]
client.report_pr(pr_number, pr_url, state="open", tab=tab_id, panel=panel_id)
_wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}")
_wait_for_state_field(client, "pr_label", "PR")
client.report_review(pr_number, pr_url, label="MR", state="open", tab=tab_id, panel=panel_id)
_wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}")
_wait_for_state_field(client, "pr_label", "MR")
client.report_pr(pr_number, pr_url, state="merged", tab=tab_id, panel=panel_id)
_wait_for_state_field(client, "pr", f"#{pr_number} merged {pr_url}")
_wait_for_state_field(client, "pr_label", "PR")
client.clear_pr(tab=tab_id, panel=panel_id)
_wait_for_state_field(client, "pr", "none")
_wait_for_state_field(client, "pr_label", "none")
try:
client.close_tab(new_tab_id)
except Exception:
pass
print("Sidebar PR metadata test passed.")
return 0
except (cmuxError, AssertionError) as e:
print(f"Sidebar PR metadata test failed: {e}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
-278
View File
@@ -1,278 +0,0 @@
#!/usr/bin/env python3
"""
Automated test for signal handling - tests that SIGINT and EOF work correctly.
This test doesn't require manual interaction.
"""
import subprocess
import signal
import sys
import os
import time
import pty
import select
import termios
import tty
def test_sigint_in_pty():
"""Test that Ctrl+C (SIGINT) works in a PTY"""
print("Test 1: SIGINT via PTY (simulating Ctrl+C)")
# Create a PTY pair
master_fd, slave_fd = pty.openpty()
# Configure the PTY for proper signal handling
# This enables ISIG so Ctrl+C generates SIGINT
attrs = termios.tcgetattr(slave_fd)
attrs[3] |= termios.ISIG # Enable signals
attrs[3] |= termios.ICANON # Canonical mode
attrs[6][termios.VINTR] = 3 # Ctrl+C = SIGINT
termios.tcsetattr(slave_fd, termios.TCSANOW, attrs)
# Start a process that waits for SIGINT
# Use start_new_session=True to create new session with controlling terminal
proc = subprocess.Popen(
['python3', '-c', '''
import signal
import sys
import time
import os
received = False
def handler(sig, frame):
global received
received = True
# Avoid print() from a signal handler (it can raise "reentrant call" on some Python builds).
os.write(1, b"SIGINT_RECEIVED\\n")
sys.exit(0)
signal.signal(signal.SIGINT, handler)
print("WAITING", flush=True)
for i in range(10):
time.sleep(0.5)
if received:
break
if not received:
print("TIMEOUT", flush=True)
sys.exit(1)
'''],
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
start_new_session=True
)
os.close(slave_fd)
try:
# Wait for "WAITING" message
output = b""
for _ in range(20):
if select.select([master_fd], [], [], 0.1)[0]:
output += os.read(master_fd, 1024)
if b"WAITING" in output:
break
if b"WAITING" not in output:
print(" ❌ FAILED: Process didn't start properly")
return False
# Send SIGINT directly to the process group
# This simulates what the terminal does when it receives Ctrl+C
os.kill(-proc.pid, signal.SIGINT)
# Wait for response
output = b""
for _ in range(20):
if select.select([master_fd], [], [], 0.1)[0]:
output += os.read(master_fd, 1024)
if b"SIGINT_RECEIVED" in output:
break
proc.wait(timeout=2)
if b"SIGINT_RECEIVED" in output:
print(" ✅ PASSED: SIGINT received via Ctrl+C in PTY")
return True
else:
print(f" ❌ FAILED: No SIGINT received. Output: {output}")
return False
except Exception as e:
print(f" ❌ FAILED: {e}")
return False
finally:
try:
proc.kill()
except:
pass
os.close(master_fd)
def test_eof_in_pty():
"""Test that Ctrl+D (EOF) works in a PTY"""
print("\nTest 2: EOF via PTY (simulating Ctrl+D)")
master_fd, slave_fd = pty.openpty()
proc = subprocess.Popen(
['python3', '-c', '''
import sys
print("WAITING", flush=True)
try:
line = input()
if line == "":
print("EMPTY_LINE", flush=True)
else:
print(f"GOT: {line}", flush=True)
except EOFError:
print("EOF_RECEIVED", flush=True)
sys.exit(0)
'''],
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
preexec_fn=os.setsid
)
os.close(slave_fd)
try:
# Wait for "WAITING"
output = b""
for _ in range(20):
if select.select([master_fd], [], [], 0.1)[0]:
output += os.read(master_fd, 1024)
if b"WAITING" in output:
break
if b"WAITING" not in output:
print(" ❌ FAILED: Process didn't start properly")
return False
# Send Ctrl+D (ASCII 0x04) through the PTY
os.write(master_fd, b'\x04')
# Wait for response
output = b""
for _ in range(20):
if select.select([master_fd], [], [], 0.1)[0]:
output += os.read(master_fd, 1024)
if b"EOF_RECEIVED" in output or b"EMPTY_LINE" in output:
break
proc.wait(timeout=2)
if b"EOF_RECEIVED" in output:
print(" ✅ PASSED: EOF received via Ctrl+D in PTY")
return True
else:
print(f" ❌ FAILED: No EOF received. Output: {output}")
return False
except Exception as e:
print(f" ❌ FAILED: {e}")
return False
finally:
try:
proc.kill()
except:
pass
os.close(master_fd)
def test_direct_signal():
"""Test direct signal sending (not through keyboard)"""
print("\nTest 3: Direct SIGINT signal")
proc = subprocess.Popen(
['python3', '-c', '''
import signal
import time
import sys
def handler(sig, frame):
print("SIGINT_RECEIVED", flush=True)
sys.exit(0)
signal.signal(signal.SIGINT, handler)
print("WAITING", flush=True)
sys.stdout.flush()
time.sleep(10)
print("TIMEOUT", flush=True)
'''],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
try:
# Wait for process to start and emit the ready line
output = b""
start = time.time()
while time.time() - start < 2.0:
if select.select([proc.stdout], [], [], 0.1)[0]:
chunk = os.read(proc.stdout.fileno(), 1024)
if not chunk:
break
output += chunk
if b"WAITING" in output:
break
if b"WAITING" not in output:
print(f" ❌ FAILED: Process not ready. Output: {output}")
return False
# Send SIGINT directly
proc.send_signal(signal.SIGINT)
stdout, stderr = proc.communicate(timeout=2)
stdout = output + stdout
if b"SIGINT_RECEIVED" in stdout:
print(" ✅ PASSED: Direct SIGINT works")
return True
else:
print(f" ❌ FAILED: Output: {stdout}")
return False
except Exception as e:
print(f" ❌ FAILED: {e}")
return False
finally:
try:
proc.kill()
except:
pass
def main():
print("=" * 50)
print("Automated Signal Handling Tests")
print("=" * 50)
print()
results = []
results.append(("SIGINT via PTY (Ctrl+C)", test_sigint_in_pty()))
results.append(("EOF via PTY (Ctrl+D)", test_eof_in_pty()))
results.append(("Direct SIGINT", test_direct_signal()))
print()
print("=" * 50)
print("Results Summary")
print("=" * 50)
all_passed = True
for name, passed in results:
status = "✅ PASS" if passed else "❌ FAIL"
print(f" {name}: {status}")
if not passed:
all_passed = False
print()
if all_passed:
print("All tests passed!")
return 0
else:
print("Some tests failed.")
return 1
if __name__ == "__main__":
sys.exit(main())
-737
View File
@@ -1,737 +0,0 @@
#!/usr/bin/env python3
"""
Tests for socket access control (process ancestry check).
In cmuxOnly mode (default), only processes descended from the cmux
app process can connect. External processes (e.g., SSH) are rejected.
Test strategy:
Phase 1: cmuxOnly — external processes get rejected
Phase 2: cmuxOnly — internal process CAN connect (inject via shell rc)
Phase 3: allowAll env override — existing test commands still work
Usage:
python3 test_socket_access.py
"""
import os
import socket
import subprocess
import sys
import tempfile
import time
import json
import glob
import plistlib
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 _find_socket_path():
return cmux().socket_path
def _raw_connect(socket_path: str, timeout: float = 3.0):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect(socket_path)
return sock
def _raw_send(sock, command: str, timeout: float = 3.0) -> str:
sock.sendall((command + "\n").encode())
data = b""
deadline = time.time() + timeout
while time.time() < deadline:
try:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
if b"\n" in data:
break
except socket.timeout:
break
return data.decode().strip()
def _preferred_worktree_slug():
env_slug = os.environ.get("CMUX_TAG") or os.environ.get("CMUX_BRANCH_SLUG")
if env_slug:
return env_slug.strip().lower()
cwd = os.getcwd()
marker = "/worktrees/"
if marker in cwd:
tail = cwd.split(marker, 1)[1]
slug = tail.split("/", 1)[0].strip().lower()
if slug:
return slug
return ""
def _derived_app_candidates_for_current_worktree():
project_path = os.path.realpath(os.path.join(os.getcwd(), "cmux.xcodeproj"))
info_paths = glob.glob(os.path.expanduser(
"~/Library/Developer/Xcode/DerivedData/cmux-*/info.plist"
))
matches = []
for info_path in info_paths:
try:
with open(info_path, "rb") as f:
info = plistlib.load(f)
except Exception:
continue
workspace_path = info.get("WorkspacePath")
if not workspace_path:
continue
if os.path.realpath(workspace_path) != project_path:
continue
derived_root = os.path.dirname(info_path)
app_path = os.path.join(derived_root, "Build/Products/Debug/cmux DEV.app")
if os.path.exists(app_path):
matches.append(app_path)
return matches
def _find_app():
explicit = os.environ.get("CMUX_APP_PATH")
if explicit and os.path.exists(explicit):
return explicit
preferred_slug = _preferred_worktree_slug()
if preferred_slug:
preferred_tmp = []
preferred_tmp.extend(glob.glob(f"/tmp/cmux-{preferred_slug}/Build/Products/Debug/cmux DEV*.app"))
preferred_tmp.extend(glob.glob(f"/private/tmp/cmux-{preferred_slug}/Build/Products/Debug/cmux DEV*.app"))
preferred_tmp = [p for p in preferred_tmp if os.path.exists(p)]
if preferred_tmp:
preferred_tmp.sort(key=os.path.getmtime, reverse=True)
return preferred_tmp[0]
direct_matches = _derived_app_candidates_for_current_worktree()
if direct_matches:
direct_matches.sort(key=os.path.getmtime, reverse=True)
return direct_matches[0]
home = os.path.expanduser("~")
derived_candidates = glob.glob(os.path.join(
home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux DEV.app"
))
tmp_candidates = []
tmp_candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app"))
tmp_candidates.extend(glob.glob("/private/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app"))
derived_candidates = [p for p in derived_candidates if os.path.exists(p)]
tmp_candidates = [p for p in tmp_candidates if os.path.exists(p)]
if preferred_slug:
preferred_derived = [p for p in derived_candidates if preferred_slug in p.lower()]
preferred_tmp = [p for p in tmp_candidates if preferred_slug in p.lower()]
if preferred_derived:
derived_candidates = preferred_derived
if preferred_tmp:
tmp_candidates = preferred_tmp
if derived_candidates:
derived_candidates.sort(key=os.path.getmtime, reverse=True)
return derived_candidates[0]
if tmp_candidates:
tmp_candidates.sort(key=os.path.getmtime, reverse=True)
return tmp_candidates[0]
return ""
def _find_cli(preferred_app_path: str = ""):
explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI")
if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK):
return explicit
if preferred_app_path:
debug_dir = os.path.dirname(preferred_app_path)
sibling = os.path.join(debug_dir, "cmux")
if os.path.exists(sibling) and os.access(sibling, os.X_OK):
return sibling
candidates = []
home = os.path.expanduser("~")
candidates.extend(glob.glob(os.path.join(
home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"
)))
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
candidates.extend(glob.glob("/private/tmp/cmux-*/Build/Products/Debug/cmux"))
candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)]
if not candidates:
return ""
preferred_slug = _preferred_worktree_slug()
if preferred_slug:
preferred = [p for p in candidates if preferred_slug in p.lower()]
if preferred:
candidates = preferred
candidates.sort(key=os.path.getmtime, reverse=True)
return candidates[0]
def _wait_for_socket(socket_path: str, timeout: float = 10.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if os.path.exists(socket_path):
try:
sock = _raw_connect(socket_path, timeout=0.3)
sock.close()
return True
except Exception:
pass
time.sleep(0.5)
return False
def _kill_cmux(app_path: str = None):
if app_path:
exe = os.path.join(app_path, "Contents/MacOS/cmux DEV")
subprocess.run(["pkill", "-f", exe], capture_output=True)
else:
subprocess.run(["pkill", "-x", "cmux DEV"], capture_output=True)
time.sleep(1.5)
def _launch_cmux(app_path: str, socket_path: str, mode: str = None, extra_env: dict = None):
if os.path.exists(socket_path):
try:
os.unlink(socket_path)
except OSError:
pass
env_args = []
if mode:
env_args = ["--env", f"CMUX_SOCKET_MODE={mode}"]
launch_env = {
"CMUX_SOCKET_PATH": socket_path,
"CMUX_ALLOW_SOCKET_OVERRIDE": "1",
}
if extra_env:
launch_env.update(extra_env)
for key, value in launch_env.items():
env_args.extend(["--env", f"{key}={value}"])
subprocess.Popen(["open", "-na", app_path] + env_args)
if not _wait_for_socket(socket_path):
raise RuntimeError(f"Socket {socket_path} not created after launch")
time.sleep(8)
# ---------------------------------------------------------------------------
# External rejection tests (Phase 1)
# ---------------------------------------------------------------------------
def test_external_rejected(socket_path: str) -> TestResult:
result = TestResult("External process rejected")
try:
sock = _raw_connect(socket_path)
try:
response = _raw_send(sock, "ping")
if "Access denied" in response:
result.success(f"Correctly rejected")
elif response == "PONG":
result.failure("External allowed — ancestry check not working")
else:
result.failure(f"Unexpected: {response!r}")
finally:
sock.close()
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
def test_connection_closed_after_reject(socket_path: str) -> TestResult:
result = TestResult("Connection closed after rejection")
try:
sock = _raw_connect(socket_path)
try:
_raw_send(sock, "ping")
try:
sock.sendall(b"list_tabs\n")
time.sleep(0.3)
data = sock.recv(4096)
if data:
result.failure(f"Got response after rejection: {data.decode().strip()!r}")
else:
result.success("Connection properly closed")
except (BrokenPipeError, ConnectionResetError, OSError):
result.success("Connection properly closed")
finally:
sock.close()
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
def test_rapid_reconnect(socket_path: str) -> TestResult:
result = TestResult("Rapid reconnect all rejected")
try:
for i in range(20):
try:
sock = _raw_connect(socket_path, timeout=2.0)
response = _raw_send(sock, "ping", timeout=1.0)
sock.close()
except (BrokenPipeError, ConnectionResetError, OSError):
# Server closed connection before we could read — counts as rejection
continue
if "Access denied" not in response and "ERROR" not in response:
result.failure(f"Iteration {i}: not rejected: {response!r}")
return result
result.success("All 20 rejected")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
def test_subprocess_rejected(socket_path: str) -> TestResult:
result = TestResult("Subprocess of external rejected")
try:
script = f"""
import socket, sys, time
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(3)
sock.connect("{socket_path}")
sock.sendall(b"ping\\n")
data = b""
deadline = time.time() + 3
while time.time() < deadline:
try:
chunk = sock.recv(4096)
if not chunk: break
data += chunk
if b"\\n" in data: break
except socket.timeout: break
sock.close()
resp = data.decode().strip()
if "Access denied" in resp or "ERROR" in resp:
print("REJECTED"); sys.exit(0)
else:
print("ALLOWED:" + resp); sys.exit(1)
"""
proc = subprocess.run(
[sys.executable, "-c", script],
capture_output=True, text=True, timeout=10
)
if proc.returncode == 0 and "REJECTED" in proc.stdout:
result.success("Child process rejected")
else:
result.failure(f"exit={proc.returncode} out={proc.stdout!r}")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
# ---------------------------------------------------------------------------
# Internal process test (Phase 2)
# ---------------------------------------------------------------------------
def test_internal_process_allowed(socket_path: str, app_path: str) -> TestResult:
"""
Verify a cmux-spawned terminal process CAN connect in cmuxOnly mode.
Inject a test via the shell rc file, then launch cmux in cmuxOnly mode.
The shell (a descendant of cmux) runs the test on startup.
"""
result = TestResult("Internal process can connect (cmuxOnly)")
marker = os.path.join(tempfile.gettempdir(), f"cmux_internal_{os.getpid()}")
hook_file = os.path.join(tempfile.gettempdir(), f"cmux_rc_hook_{os.getpid()}.sh")
zprofile_path = os.path.expanduser("~/.zprofile")
try:
for f in [marker, hook_file]:
if os.path.exists(f):
os.unlink(f)
# Write test script: connects to socket, sends ping, writes result
with open(hook_file, "w") as f:
f.write(f"""#!/bin/bash
# One-shot test hook — self-removes after running
RESULT=$(echo "ping" | nc -U "{socket_path}" 2>/dev/null | head -1)
if [ "$RESULT" = "PONG" ]; then
echo "OK" > "{marker}"
else
echo "FAIL:$RESULT" > "{marker}"
fi
""")
os.chmod(hook_file, 0o755)
# Append hook to .zprofile (runs on terminal startup)
zprofile_backup = None
if os.path.exists(zprofile_path):
with open(zprofile_path) as f:
zprofile_backup = f.read()
hook_line = f'\n[ -f "{hook_file}" ] && bash "{hook_file}" && rm -f "{hook_file}"\n'
with open(zprofile_path, "a") as f:
f.write(hook_line)
# Kill existing cmux, launch in cmuxOnly mode (default)
_kill_cmux(app_path)
_launch_cmux(app_path, socket_path, mode="cmuxOnly")
# Wait for marker (the shell sources .zprofile on startup)
for _ in range(40):
if os.path.exists(marker):
break
time.sleep(0.5)
if not os.path.exists(marker):
result.failure("Marker not created — hook didn't run in terminal")
return result
with open(marker) as f:
content = f.read().strip()
if content == "OK":
result.success("Internal process pinged socket successfully in cmuxOnly mode")
else:
result.failure(f"Internal process got: {content!r}")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
finally:
# Restore .zprofile
if zprofile_backup is not None:
with open(zprofile_path, "w") as f:
f.write(zprofile_backup)
elif os.path.exists(zprofile_path):
# Remove the hook line we added
with open(zprofile_path) as f:
content = f.read()
content = content.replace(hook_line, "")
if content.strip():
with open(zprofile_path, "w") as f:
f.write(content)
else:
os.unlink(zprofile_path)
for f in [marker, hook_file]:
try:
os.unlink(f)
except OSError:
pass
return result
# ---------------------------------------------------------------------------
# allowAll mode test (Phase 3)
# ---------------------------------------------------------------------------
def test_allowall_mode_works(socket_path: str, app_path: str) -> TestResult:
"""Verify CMUX_SOCKET_MODE=allowAll bypasses ancestry check."""
result = TestResult("allowAll mode allows external")
try:
_kill_cmux(app_path)
_launch_cmux(app_path, socket_path, mode="allowAll")
sock = _raw_connect(socket_path)
response = _raw_send(sock, "ping")
sock.close()
if response == "PONG":
result.success("External process allowed in allowAll mode")
else:
result.failure(f"Unexpected response: {response!r}")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
def test_password_mode_requires_auth(socket_path: str, app_path: str) -> TestResult:
"""Verify password mode rejects unauthenticated commands."""
result = TestResult("Password mode requires auth")
password = f"cmux-pass-{os.getpid()}"
try:
_kill_cmux(app_path)
_launch_cmux(
app_path,
socket_path,
mode="password",
extra_env={"CMUX_SOCKET_PASSWORD": password}
)
sock = _raw_connect(socket_path)
response = _raw_send(sock, "ping")
sock.close()
if "Authentication required" in response:
result.success("Unauthenticated command rejected in password mode")
else:
result.failure(f"Unexpected response without auth: {response!r}")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
def test_password_mode_v1_auth_flow(socket_path: str, app_path: str) -> TestResult:
"""Verify v1 auth command unlocks the connection only with correct password."""
result = TestResult("Password mode v1 auth flow")
password = f"cmux-pass-{os.getpid()}"
try:
_kill_cmux(app_path)
_launch_cmux(
app_path,
socket_path,
mode="password",
extra_env={"CMUX_SOCKET_PASSWORD": password}
)
sock = _raw_connect(socket_path)
try:
wrong = _raw_send(sock, "auth wrong-password")
if "Invalid password" not in wrong:
result.failure(f"Expected invalid password error, got: {wrong!r}")
return result
ok = _raw_send(sock, f"auth {password}")
if "OK: Authenticated" not in ok:
result.failure(f"Expected auth success, got: {ok!r}")
return result
pong = _raw_send(sock, "ping")
if pong != "PONG":
result.failure(f"Expected PONG after auth, got: {pong!r}")
return result
finally:
sock.close()
result.success("v1 auth gate works")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
def test_password_mode_v2_auth_flow(socket_path: str, app_path: str) -> TestResult:
"""Verify v2 auth.login unlocks subsequent v2 requests."""
result = TestResult("Password mode v2 auth flow")
password = f"cmux-pass-{os.getpid()}"
try:
_kill_cmux(app_path)
_launch_cmux(
app_path,
socket_path,
mode="password",
extra_env={"CMUX_SOCKET_PASSWORD": password}
)
sock = _raw_connect(socket_path)
try:
unauth = _raw_send(sock, json.dumps({
"id": "1",
"method": "system.ping",
"params": {}
}))
unauth_obj = json.loads(unauth)
if unauth_obj.get("error", {}).get("code") != "auth_required":
result.failure(f"Expected auth_required, got: {unauth!r}")
return result
login = _raw_send(sock, json.dumps({
"id": "2",
"method": "auth.login",
"params": {"password": password}
}))
login_obj = json.loads(login)
if not login_obj.get("ok"):
result.failure(f"Expected auth.login success, got: {login!r}")
return result
pong = _raw_send(sock, json.dumps({
"id": "3",
"method": "system.ping",
"params": {}
}))
pong_obj = json.loads(pong)
pong_value = pong_obj.get("result", {}).get("pong")
if pong_value is not True:
result.failure(f"Expected pong=true after auth.login, got: {pong!r}")
return result
finally:
sock.close()
result.success("v2 auth.login gate works")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
def test_password_mode_cli_exit_code(socket_path: str, app_path: str) -> TestResult:
"""Verify CLI exits non-zero on auth-required and succeeds with --password."""
result = TestResult("Password mode CLI exit code")
password = f"cmux-pass-{os.getpid()}"
try:
cli_path = _find_cli(preferred_app_path=app_path)
if not cli_path:
result.failure("Could not find cmux CLI binary")
return result
_kill_cmux(app_path)
_launch_cmux(
app_path,
socket_path,
mode="password",
extra_env={"CMUX_SOCKET_PASSWORD": password}
)
no_auth = subprocess.run(
[cli_path, "--socket", socket_path, "ping"],
capture_output=True,
text=True,
timeout=10
)
combined = f"{no_auth.stdout}\n{no_auth.stderr}"
if no_auth.returncode == 0:
result.failure("CLI ping without password exited 0 in password mode")
return result
if "Authentication required" not in combined:
result.failure(f"Unexpected unauthenticated CLI output: {combined!r}")
return result
with_auth = subprocess.run(
[cli_path, "--socket", socket_path, "--password", password, "ping"],
capture_output=True,
text=True,
timeout=10
)
if with_auth.returncode != 0:
result.failure(
f"CLI ping with password failed: exit={with_auth.returncode} "
f"stdout={with_auth.stdout!r} stderr={with_auth.stderr!r}"
)
return result
if "PONG" not in with_auth.stdout:
result.failure(f"Expected PONG with password, got: {with_auth.stdout!r}")
return result
result.success("CLI exits non-zero for auth_required and succeeds with --password")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def run_tests():
print("=" * 60)
print("cmux Socket Access Control Tests")
print("=" * 60)
print()
app_path = _find_app()
if not app_path:
print("Error: Could not find cmux DEV.app in DerivedData")
return 1
print(f"App: {app_path}")
socket_path = f"/tmp/cmux-test-socket-access-{os.getpid()}.sock"
try:
os.unlink(socket_path)
except OSError:
pass
print(f"Socket: {socket_path}")
print()
results = []
def run_test(test_fn, *args):
name = test_fn.__name__.replace("test_", "").replace("_", " ").title()
print(f" Testing {name}...")
r = test_fn(*args)
results.append(r)
status = "\u2705" if r.passed else "\u274c"
print(f" {status} {r.message}")
# ── Phase 1: cmuxOnly — external rejection ──
print("Phase 1: cmuxOnly mode — external rejection")
print("-" * 50)
# Ensure cmux is running in cmuxOnly mode
_kill_cmux(app_path)
print(" Launching cmux in cmuxOnly mode...")
_launch_cmux(app_path, socket_path, mode="cmuxOnly")
run_test(test_external_rejected, socket_path)
run_test(test_connection_closed_after_reject, socket_path)
run_test(test_rapid_reconnect, socket_path)
run_test(test_subprocess_rejected, socket_path)
print()
# ── Phase 2: cmuxOnly — internal process CAN connect ──
print("Phase 2: cmuxOnly mode — internal process allowed")
print("-" * 50)
run_test(test_internal_process_allowed, socket_path, app_path)
print()
# ── Phase 3: allowAll env override ──
print("Phase 3: allowAll mode — env override bypasses check")
print("-" * 50)
run_test(test_allowall_mode_works, socket_path, app_path)
print()
# ── Phase 4: password mode auth gate ──
print("Phase 4: password mode — auth required + login flow")
print("-" * 50)
run_test(test_password_mode_requires_auth, socket_path, app_path)
run_test(test_password_mode_v1_auth_flow, socket_path, app_path)
run_test(test_password_mode_v2_auth_flow, socket_path, app_path)
run_test(test_password_mode_cli_exit_code, socket_path, app_path)
print()
# ── Cleanup: leave cmux in cmuxOnly mode ──
_kill_cmux(app_path)
_launch_cmux(app_path, socket_path, mode="cmuxOnly")
# ── Summary ──
print("=" * 60)
print("Summary")
print("=" * 60)
passed = sum(1 for r in results if r.passed)
total = len(results)
for r in results:
status = "\u2705 PASS" if r.passed else "\u274c 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\U0001f389 All tests passed!")
return 0
else:
print(f"\n\u26a0\ufe0f {total - passed} test(s) failed")
return 1
if __name__ == "__main__":
sys.exit(run_tests())
-225
View File
@@ -1,225 +0,0 @@
#!/usr/bin/env python3
"""
End-to-end test for split CWD inheritance.
Verifies that new split panes and new workspace tabs inherit the current
working directory from the source terminal.
Requires:
- cmux running with allowAll socket mode
- bash shell integration sourced (cmux-bash-integration.bash)
Run with a tagged instance:
CMUX_TAG=<tag> python3 tests/test_split_cwd_inheritance.py
"""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux # noqa: E402
def _parse_sidebar_state(text: str) -> dict[str, str]:
data: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.rstrip("\n")
if not line or line.startswith(" "):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def _wait_for(predicate, timeout: float, interval: float, label: str):
start = time.time()
last_error: Exception | None = None
while time.time() - start < timeout:
try:
value = predicate()
if value:
return value
except Exception as e:
last_error = e
time.sleep(interval)
extra = ""
if last_error is not None:
extra = f" Last error: {last_error}"
raise AssertionError(f"Timed out waiting for {label}.{extra}")
def _wait_for_focused_cwd(
client: cmux,
expected: str,
timeout: float = 12.0,
panel: str | None = None,
tab: str | None = None,
) -> dict[str, str]:
"""Wait for focused_cwd to match expected.
If panel is given, also require that focused_panel matches that panel.
If tab is given, also require that the selected tab matches that tab.
"""
def pred():
state = _parse_sidebar_state(client.sidebar_state())
cwd = state.get("focused_cwd", "")
if cwd != expected:
return None
if panel and state.get("focused_panel", "") != panel:
return None
if tab and state.get("tab", "") != tab:
return None
return state
label = f"focused_cwd={expected!r}"
if panel:
label += f" (panel == {panel})"
if tab:
label += f" (tab == {tab})"
return _wait_for(pred, timeout=timeout, interval=0.3, label=label)
def _send_cd_and_wait(
client: cmux,
target: str,
timeout: float = 12.0,
surface: str | int | None = None,
) -> dict[str, str]:
"""cd to target and wait for sidebar focused_cwd to reflect it."""
if surface is None:
client.send(f"cd {target}\n")
else:
client.send_surface(surface, f"cd {target}\n")
return _wait_for_focused_cwd(client, target, timeout=timeout)
def _focus_first_surface(client: cmux) -> str:
surfaces = client.list_surfaces()
if not surfaces:
raise AssertionError("Current tab has no surfaces")
surface_id = surfaces[0][1]
client.focus_surface(surface_id)
return surface_id
def main() -> int:
tag = os.environ.get("CMUX_TAG", "")
socket_path = None
if tag:
socket_path = f"/tmp/cmux-debug-{tag}.sock"
client = cmux(socket_path=socket_path)
client.connect()
# Use resolved paths to avoid /tmp -> /private/tmp symlink mismatch on macOS
test_dir_a = str(Path("/tmp/cmux_split_cwd_test_a").resolve())
test_dir_b = str(Path("/tmp/cmux_split_cwd_test_b").resolve())
os.makedirs(test_dir_a, exist_ok=True)
os.makedirs(test_dir_b, exist_ok=True)
passed = 0
failed = 0
def check(name: str, condition: bool, detail: str = ""):
nonlocal passed, failed
if condition:
print(f" PASS {name}")
passed += 1
else:
print(f" FAIL {name}{': ' + detail if detail else ''}")
failed += 1
print("=== Split CWD Inheritance Tests ===")
print(" [setup] creating isolated workspace tab...")
setup_tab = client.new_tab()
client.select_tab(setup_tab)
time.sleep(1.0)
setup_surface = _focus_first_surface(client)
time.sleep(0.5)
# --- Setup: cd to test_dir_a in workspace 1 ---
print(" [setup] cd to test_dir_a and wait for shell integration...")
_send_cd_and_wait(client, test_dir_a, surface=setup_surface)
state = _parse_sidebar_state(client.sidebar_state())
check("setup: focused_cwd is test_dir_a", state.get("focused_cwd") == test_dir_a,
f"got {state.get('focused_cwd')!r}")
# --- Test 1: New split inherits test_dir_a ---
print(" [test1] creating right split from test_dir_a...")
split_result = client.new_split("right")
if not split_result:
check("split created", False)
print(f"\n{passed} passed, {failed} failed")
client.close()
return 1
check("split created", True)
# Socket split commands should not steal focus; focus the returned pane
# explicitly, then assert that pane inherited the source cwd.
new_panel = split_result.strip()
client.focus_surface_by_panel(new_panel)
time.sleep(4) # wait for new bash to start + run PROMPT_COMMAND
try:
state = _wait_for_focused_cwd(
client, test_dir_a, timeout=15.0, panel=new_panel,
)
check("test1: split inherited test_dir_a",
state.get("focused_cwd") == test_dir_a,
f"focused_cwd={state.get('focused_cwd')!r}")
except AssertionError:
state = _parse_sidebar_state(client.sidebar_state())
check("test1: split inherited test_dir_a", False,
f"focused_cwd={state.get('focused_cwd')!r}, focused_panel={state.get('focused_panel')!r}")
# --- Test 2: New workspace tab inherits CWD ---
# First cd to test_dir_b so we have a different dir to inherit
print(" [test2] cd to test_dir_b, then creating new workspace tab...")
_send_cd_and_wait(client, test_dir_b)
tab_result = client.new_tab()
if not tab_result:
check("new tab created", False)
print(f"\n{passed} passed, {failed} failed")
client.close()
return 1
check("new tab created", True)
# Focus the returned workspace explicitly, then assert it inherited cwd.
new_tab = tab_result.strip()
client.select_tab(new_tab)
time.sleep(4)
try:
state = _wait_for_focused_cwd(
client, test_dir_b, timeout=15.0, tab=new_tab,
)
check("test2: new workspace inherited test_dir_b",
state.get("focused_cwd") == test_dir_b,
f"focused_cwd={state.get('focused_cwd')!r}")
except AssertionError:
state = _parse_sidebar_state(client.sidebar_state())
check("test2: new workspace inherited test_dir_b", False,
f"focused_cwd={state.get('focused_cwd')!r}, tab={state.get('tab')!r}")
print(f"\n{passed} passed, {failed} failed")
client.close()
# Cleanup
for d in [test_dir_a, test_dir_b]:
try:
os.rmdir(d)
except OSError:
pass
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
-203
View File
@@ -1,203 +0,0 @@
#!/usr/bin/env python3
"""
Layout/flash regression tests for cmux splits.
Goals:
1) Ensure programmatic splits don't transiently render EmptyPanelView (visible flash).
2) Validate selected panel bounds are non-zero and aligned with bonsplit pane bounds.
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
def _rect_area(r: dict) -> float:
return max(0.0, float(r.get("width", 0.0))) * max(0.0, float(r.get("height", 0.0)))
def _rect_intersection_area(a: dict, b: dict) -> float:
ax1 = float(a["x"])
ay1 = float(a["y"])
ax2 = ax1 + float(a["width"])
ay2 = ay1 + float(a["height"])
bx1 = float(b["x"])
by1 = float(b["y"])
bx2 = bx1 + float(b["width"])
by2 = by1 + float(b["height"])
ix1 = max(ax1, bx1)
iy1 = max(ay1, by1)
ix2 = min(ax2, bx2)
iy2 = min(ay2, by2)
if ix2 <= ix1 or iy2 <= iy1:
return 0.0
return (ix2 - ix1) * (iy2 - iy1)
def _assert_selected_panels_healthy(payload: dict, *, min_wh: float = 80.0) -> None:
selected = payload.get("selectedPanels") or []
if not selected:
raise cmuxError("layout_debug returned no selectedPanels")
for i, row in enumerate(selected):
pane_id = row.get("paneId")
pane_frame = row.get("paneFrame")
view_frame = row.get("viewFrame")
panel_id = row.get("panelId")
if not panel_id:
raise cmuxError(f"selectedPanels[{i}] missing panelId (pane={pane_id})")
if row.get("inWindow") is not True:
raise cmuxError(f"selectedPanels[{i}] panel not in window (pane={pane_id}, panel={panel_id})")
if row.get("hidden") is True:
raise cmuxError(f"selectedPanels[{i}] panel is hidden (pane={pane_id}, panel={panel_id})")
if not view_frame:
raise cmuxError(f"selectedPanels[{i}] missing viewFrame (pane={pane_id}, panel={panel_id})")
if float(view_frame.get("width", 0.0)) < min_wh or float(view_frame.get("height", 0.0)) < min_wh:
raise cmuxError(
f"selectedPanels[{i}] viewFrame too small: {view_frame} (pane={pane_id}, panel={panel_id})"
)
# Coordinate sanity: selected panel should substantially overlap its pane.
# This implicitly verifies we're measuring in a consistent coordinate space.
if pane_frame:
inter = _rect_intersection_area(pane_frame, view_frame)
denom = min(_rect_area(pane_frame), _rect_area(view_frame))
ratio = inter / denom if denom > 0 else 0.0
if ratio < 0.50:
raise cmuxError(
f"selectedPanels[{i}] bounds mismatch (overlap={ratio:.2f}). "
f"pane={pane_frame} view={view_frame} pane_id={pane_id} panel={panel_id}"
)
def _assert_no_transient_detach_or_hide(
c: cmux,
*,
duration_s: float = 1.0,
cadence_s: float = 0.005,
max_false_samples: int = 2,
) -> None:
false_in_window: dict[str, int] = {}
hidden_true: dict[str, int] = {}
deadline = time.time() + duration_s
while time.time() < deadline:
rows = c.surface_health()
for row in rows:
if row.get("type") != "terminal":
continue
panel_id = (row.get("id") or "").lower()
if not panel_id:
continue
if row.get("in_window") is False:
false_in_window[panel_id] = false_in_window.get(panel_id, 0) + 1
if row.get("hidden") is True:
hidden_true[panel_id] = hidden_true.get(panel_id, 0) + 1
time.sleep(cadence_s)
detached = {k: v for k, v in false_in_window.items() if v > max_false_samples}
hidden = {k: v for k, v in hidden_true.items() if v > max_false_samples}
if detached or hidden:
raise cmuxError(
f"Transient detach/hide during split exceeds tolerance "
f"(detached={detached}, hidden={hidden})"
)
def main() -> int:
with cmux(SOCKET_PATH) as c:
# Run on a fresh workspace to avoid state carry-over from restored sessions.
test_workspace = c.new_workspace()
c.select_workspace(test_workspace)
time.sleep(0.2)
# Baseline: a fresh counter, no flashes just from connecting.
c.reset_empty_panel_count()
base = c.layout_debug()
_assert_selected_panels_healthy(base)
# Programmatic split should not show EmptyPanelView even briefly.
c.reset_empty_panel_count()
c.new_split("right")
time.sleep(0.3)
flashes = c.empty_panel_count()
if flashes != 0:
raise cmuxError(f"EmptyPanelView appeared during split (count={flashes})")
after = c.layout_debug()
# Expect at least 2 panes after split (exact count can vary if user already has splits).
panes = after.get("layout", {}).get("panes") or []
if len(panes) < 2:
raise cmuxError(f"Expected >= 2 panes after split, got {len(panes)}")
_assert_selected_panels_healthy(after)
# Drag-to-split from a single-surface pane should also avoid EmptyPanelView flashes.
drag_workspace = c.new_workspace()
c.select_workspace(drag_workspace)
time.sleep(0.2)
drag_before = c.layout_debug()
_assert_selected_panels_healthy(drag_before)
drag_selected = drag_before.get("selectedPanels") or []
if not drag_selected:
raise cmuxError("layout_debug returned no selectedPanels for drag split setup")
drag_panel_id = drag_selected[0].get("panelId")
if not drag_panel_id:
raise cmuxError("drag split setup selected panel has no panelId")
drag_panes_before = len(drag_before.get("layout", {}).get("panes") or [])
c.reset_empty_panel_count()
response = c._send_command(f"drag_surface_to_split {drag_panel_id} right")
if not response.startswith("OK "):
raise cmuxError(response)
_assert_no_transient_detach_or_hide(c)
time.sleep(0.4)
flashes = c.empty_panel_count()
if flashes != 0:
raise cmuxError(f"EmptyPanelView appeared during drag split (count={flashes})")
drag_after = c.layout_debug()
drag_panes_after = len(drag_after.get("layout", {}).get("panes") or [])
if drag_panes_after < drag_panes_before + 1:
raise cmuxError(
f"Expected drag split to add a pane: before={drag_panes_before} after={drag_panes_after}"
)
_assert_selected_panels_healthy(drag_after)
# Browser split should also avoid EmptyPanelView flashes.
c.reset_empty_panel_count()
browser_id = c._send_command("open_browser https://example.com")
if not browser_id.startswith("OK "):
raise cmuxError(browser_id)
time.sleep(0.4)
flashes = c.empty_panel_count()
if flashes != 0:
raise cmuxError(f"EmptyPanelView appeared during browser split (count={flashes})")
after_browser = c.layout_debug()
_assert_selected_panels_healthy(after_browser)
c.close_workspace(test_workspace)
time.sleep(0.1)
print("PASS: split flash + layout bounds checks")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -1,84 +0,0 @@
#!/usr/bin/env python3
"""
Regression test: terminal drop-target overlay should animate on initial show.
This exercises the focused terminal's drop-overlay code path via debug socket
commands (no Accessibility/TCC/sudo required).
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = (
os.environ.get("CMUX_SOCKET_PATH")
or "/tmp/cmux-debug.sock"
)
def _parse_probe_response(response: str) -> dict[str, str]:
if not response.startswith("OK "):
raise cmuxError(response)
parsed: dict[str, str] = {}
for token in response.split()[1:]:
if "=" not in token:
continue
key, value = token.split("=", 1)
parsed[key] = value
return parsed
def _parse_bounds(bounds: str) -> tuple[float, float]:
parts = bounds.split("x", 1)
if len(parts) != 2:
raise cmuxError(f"Unexpected bounds format: {bounds}")
return float(parts[0]), float(parts[1])
def main() -> int:
with cmux(SOCKET_PATH) as client:
client.activate_app()
workspace_id = client.new_workspace()
try:
client.select_workspace(workspace_id)
time.sleep(0.25)
deferred_raw = client._send_command("terminal_drop_overlay_probe deferred")
deferred = _parse_probe_response(deferred_raw)
direct_raw = client._send_command("terminal_drop_overlay_probe direct")
direct = _parse_probe_response(direct_raw)
width, height = _parse_bounds(deferred.get("bounds", "0x0"))
if width <= 2 or height <= 2:
raise cmuxError(
f"Focused terminal bounds too small for overlay probe: {width}x{height}"
)
if deferred.get("animated") != "1":
raise cmuxError(
"Deferred drop-overlay show did not animate. "
f"response={deferred_raw}"
)
if direct.get("animated") != "1":
raise cmuxError(
"Direct drop-overlay show did not animate. "
f"response={direct_raw}"
)
finally:
try:
client.close_workspace(workspace_id)
except Exception:
# Keep the test focused on overlay behavior; cleanup best-effort.
pass
print("PASS: terminal drop overlay animates for deferred and direct show paths")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-168
View File
@@ -1,168 +0,0 @@
#!/usr/bin/env python3
"""
Regression test: terminal focus must track the visible/focused surface across split operations.
Why: we've seen cases where the focused surface highlights correctly, but AppKit first responder
remains on another (often detached) terminal view. Users then type but nothing appears (input is
routed elsewhere).
This test validates:
1) The focused terminal is actually first responder (`is_terminal_focused`).
2) Text insertion via debug socket (`simulate_type`) lands in the expected terminal by writing
$CMUX_SURFACE_ID to a temp file.
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
FOCUS_FILE = Path(f"/tmp/cmux_focus_routing_{os.getpid()}.txt")
def _focused_surface_id(c: cmux) -> str:
surfaces = c.list_surfaces()
for _, sid, focused in surfaces:
if focused:
return sid
raise cmuxError(f"No focused surface in list_surfaces: {surfaces}")
def _wait_for_file_content(path: Path, timeout_s: float = 3.0) -> str:
start = time.time()
while time.time() - start < timeout_s:
if path.exists():
try:
data = path.read_text().strip()
except Exception:
data = ""
if data:
return data
time.sleep(0.05)
raise cmuxError(f"Timed out waiting for file content: {path}")
def _wait_for_terminal_focus(c: cmux, panel_id: str, timeout_s: float = 6.0) -> None:
start = time.time()
while time.time() - start < timeout_s:
if c.is_terminal_focused(panel_id):
return
time.sleep(0.05)
raise cmuxError(f"Timed out waiting for terminal focus: {panel_id}")
def _focus_and_wait(c: cmux, panel_id: str, *, total_timeout_s: float = 8.0) -> None:
"""
Focus can be racy under split/tree churn. Re-issue focus a few times before failing.
"""
deadline = time.time() + total_timeout_s
last_err = None
attempt = 0
while time.time() < deadline and attempt < 4:
attempt += 1
try:
c.activate_app()
except Exception:
pass
try:
c.focus_surface_by_panel(panel_id)
except Exception as e:
last_err = e
time.sleep(0.15)
continue
time.sleep(0.2)
try:
_wait_for_terminal_focus(c, panel_id, timeout_s=2.5)
return
except Exception as e:
last_err = e
time.sleep(0.15)
raise cmuxError(f"Failed to focus terminal surface (panel_id={panel_id}): {last_err}")
def _assert_routed_to_surface(c: cmux, expected_surface_id: str, panel_id: str) -> None:
last_actual = "<empty>"
for attempt in range(4):
_focus_and_wait(c, panel_id, total_timeout_s=4.0)
if FOCUS_FILE.exists():
try:
FOCUS_FILE.unlink()
except Exception:
pass
# Write the currently focused surface id into a well-known file.
c.simulate_type(f"echo $CMUX_SURFACE_ID > {FOCUS_FILE}")
c.simulate_shortcut("enter")
try:
actual = _wait_for_file_content(FOCUS_FILE, timeout_s=3.0 + (attempt * 0.5))
except cmuxError:
actual = ""
if actual == expected_surface_id:
return
last_actual = actual or "<empty>"
time.sleep(0.15)
raise cmuxError(
f"Input routed to wrong surface after retries: expected={expected_surface_id} actual={last_actual}"
)
def main() -> int:
with cmux(SOCKET_PATH) as c:
# Isolate from any user workspace state.
c.new_workspace()
time.sleep(0.2)
# Focus-sensitive assertions require the main window to be key.
# When launched via SSH, `open` does not always activate the app.
c.activate_app()
time.sleep(0.2)
# Create a bunch of terminals to stress layout/focus code paths.
for _ in range(12):
c.new_surface(panel_type="terminal")
time.sleep(0.02)
surfaces = c.list_surfaces()
if not surfaces:
raise cmuxError("Expected at least one surface after new_workspace")
left_id = surfaces[0][1]
# Create a split to the right (this may trigger bonsplit reparenting/structural updates).
right_id = c.new_split("right")
if not right_id:
# Should not happen with current server, but keep a fallback for older behavior.
right_id = _focused_surface_id(c)
time.sleep(0.25)
# Focus left then right, verifying both first responder and input routing.
_focus_and_wait(c, left_id, total_timeout_s=8.0)
_assert_routed_to_surface(c, left_id, left_id)
_focus_and_wait(c, right_id, total_timeout_s=8.0)
_assert_routed_to_surface(c, right_id, right_id)
# Stress: repeated split/close should never leave focus on a detached/hidden terminal.
for _ in range(10):
new_id = c.new_split("right")
time.sleep(0.1)
_focus_and_wait(c, new_id, total_timeout_s=8.0)
_assert_routed_to_surface(c, new_id, new_id)
c.close_surface(new_id)
time.sleep(0.25)
focused = _focused_surface_id(c)
_focus_and_wait(c, focused, total_timeout_s=8.0)
_assert_routed_to_surface(c, focused, focused)
print("PASS: terminal focus routing")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-291
View File
@@ -1,291 +0,0 @@
#!/usr/bin/env python3
"""
Manual visual report: terminal caret blink + single-character typing visibility.
This generates a self-contained HTML report (base64-embedded PNGs) so you can
open it locally and visually confirm:
1) The caret is blinking (or not).
2) A single typed character appears immediately (before Enter / focus toggle).
Usage:
python3 tests/test_terminal_input_render_report.py
# Then open: tests/terminal_input_report.html
Environment:
CMUX_SOCKET_PATH can override the socket path.
"""
import base64
import json
import os
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH") or "/tmp/cmux-debug.sock"
HTML_REPORT = Path(__file__).parent / "terminal_input_report.html"
@dataclass
class Shot:
path: Path
label: str
changed_pixels: int
def to_base64(self) -> str:
return base64.b64encode(self.path.read_bytes()).decode("utf-8")
def _wait_for(pred, timeout_s: float, step_s: float = 0.05) -> None:
start = time.time()
while time.time() - start < timeout_s:
if pred():
return
time.sleep(step_s)
raise cmuxError("Timed out waiting for condition")
def _focused_panel_id(c: cmux) -> str:
surfaces = c.list_surfaces()
if not surfaces:
raise cmuxError("Expected at least 1 surface")
return next((sid for _i, sid, focused in surfaces if focused), surfaces[0][1])
def _snap_panel(c: cmux, panel_id: str, label: str) -> Shot:
info = c.panel_snapshot(panel_id, label)
return Shot(
path=Path(info["path"]),
label=label,
changed_pixels=int(info["changed_pixels"]),
)
def _panel_sequence_blink_and_type(c: cmux, panel_id: str, prefix: str, typed_char: str = "x") -> tuple[list[Shot], dict]:
shots: list[Shot] = []
# Keep the app key/active while we probe focus + rendering; on a host machine the
# terminal running this script can steal focus mid-sequence.
c.activate_app()
time.sleep(0.15)
_wait_for(lambda: c.is_terminal_focused(panel_id), timeout_s=3.0)
stats0 = c.render_stats(panel_id)
# Blink probe: capture a few frames over ~1.3s
c.panel_snapshot_reset(panel_id)
shots.append(_snap_panel(c, panel_id, f"{prefix}_blink_0"))
time.sleep(0.65)
shots.append(_snap_panel(c, panel_id, f"{prefix}_blink_1"))
time.sleep(0.65)
shots.append(_snap_panel(c, panel_id, f"{prefix}_blink_2"))
# Type probe: before, after typing a single char, after Enter.
c.panel_snapshot_reset(panel_id)
shots.append(_snap_panel(c, panel_id, f"{prefix}_type_before"))
# Use keyDown path (not insertText) to match real typing.
c.simulate_shortcut(typed_char)
time.sleep(0.2)
shots.append(_snap_panel(c, panel_id, f"{prefix}_type_after_char_{ord(typed_char)}"))
c.simulate_shortcut("enter")
time.sleep(0.35)
shots.append(_snap_panel(c, panel_id, f"{prefix}_type_after_enter"))
# Grab stats after, for debugging.
stats1 = c.render_stats(panel_id)
meta = {
"panel_id": panel_id,
"render_stats_before": stats0,
"render_stats_after": stats1,
}
return shots, meta
def _write_report(cases: list[dict]) -> None:
generated = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
def esc(s: str) -> str:
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>cmux terminal input render report</title>
<style>
:root {{
--bg: #0b0f14;
--panel: #111826;
--border: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.92);
--muted: rgba(255,255,255,0.68);
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}}
body {{
margin: 0;
padding: 24px;
background: var(--bg);
color: var(--text);
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}}
h1 {{
margin: 0 0 6px 0;
font-size: 18px;
letter-spacing: 0.2px;
}}
.meta {{
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
margin-bottom: 18px;
}}
.case {{
border: 1px solid var(--border);
background: rgba(255,255,255,0.03);
border-radius: 12px;
padding: 14px 14px 10px 14px;
margin: 14px 0;
}}
.case h2 {{
font-size: 15px;
margin: 0 0 6px 0;
}}
.desc {{
color: var(--muted);
margin: 0 0 10px 0;
}}
.shots {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
align-items: start;
}}
figure {{
margin: 0;
padding: 10px;
border: 1px solid var(--border);
background: rgba(0,0,0,0.18);
border-radius: 10px;
}}
figcaption {{
margin: 0 0 8px 0;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
}}
img {{
width: 100%;
height: auto;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.06);
background: #000;
}}
pre {{
margin: 10px 0 0 0;
padding: 10px;
border: 1px solid var(--border);
background: rgba(0,0,0,0.25);
border-radius: 10px;
overflow: auto;
font-size: 12px;
line-height: 1.35;
font-family: var(--mono);
color: rgba(255,255,255,0.85);
}}
</style>
</head>
<body>
<h1>cmux terminal input render report</h1>
<div class="meta">generated: {esc(generated)} | socket: {esc(SOCKET_PATH)}</div>
"""
for case in cases:
html += f"""
<div class="case">
<h2>{esc(case["name"])}</h2>
<div class="desc">{esc(case["description"])}</div>
<div class="shots">
"""
for shot in case["shots"]:
label = f'{shot.label} | changed_pixels={shot.changed_pixels}'
html += f"""
<figure>
<figcaption>{esc(label)}</figcaption>
<img src="data:image/png;base64,{shot.to_base64()}" alt="{esc(shot.label)}" />
</figure>
"""
html += f"""
</div>
<pre>{esc(json.dumps(case.get("meta", {}), indent=2))}</pre>
</div>
"""
html += """
</body>
</html>
"""
HTML_REPORT.write_text(html)
def main() -> int:
cases: list[dict] = []
with cmux(SOCKET_PATH) as c:
c.activate_app()
time.sleep(0.25)
# Case 1: fresh workspace, initial terminal
ws_id = c.new_workspace()
c.select_workspace(ws_id)
time.sleep(0.35)
panel0 = _focused_panel_id(c)
shots0, meta0 = _panel_sequence_blink_and_type(c, panel0, "initial", typed_char="a")
cases.append(
{
"name": "Initial Terminal (Fresh Workspace)",
"description": "Caret blink probe + type a single character, then Enter.",
"shots": shots0,
"meta": meta0,
}
)
# Case 2: after split churn + new surface in a split
for _ in range(4):
c.new_split("right")
time.sleep(0.7)
new_id = c.new_surface(panel_type="terminal")
time.sleep(0.5)
# new_surface doesn't always steal focus (depends on split state); ensure we test the right panel.
c.focus_surface(new_id)
time.sleep(0.25)
shots1, meta1 = _panel_sequence_blink_and_type(c, new_id, "after_splits", typed_char="b")
cases.append(
{
"name": "After 4 Right Splits + New Surface",
"description": "Repro-oriented: split churn then create a new terminal surface; verify caret + typing.",
"shots": shots1,
"meta": meta1,
}
)
_write_report(cases)
print(f"Wrote report: {HTML_REPORT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env python3
"""Regression: terminal views should be portal-hosted near the window root.
This catches regressions where terminal NSViews are reattached deep inside the SwiftUI
hierarchy, which increases Core Animation commit traversal depth and input latency.
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
def _wait_portal_terminals(c: cmux, expected: int = 2, timeout: float = 8.0):
"""Poll surface_health until the terminals settle into their portal-hosted state.
Returns the settled list of terminal rows once there are >= `expected`
terminals and every one reports in_window=true and portal=true, which is
the async AppKit layout/attachment state the assertions below check. Raises
cmuxError with the last-seen snapshot if it never settles within `timeout`.
"""
start = time.time()
terminals: list = []
while time.time() - start < timeout:
health = c.surface_health()
terminals = [row for row in health if row.get("type") == "terminal"]
if len(terminals) >= expected and all(
row.get("in_window", False) and row.get("portal") is True
for row in terminals
):
return terminals
time.sleep(0.2)
raise cmuxError(
f"terminals did not become portal-hosted within {timeout}s: {terminals}"
)
def main() -> int:
with cmux(SOCKET_PATH) as c:
c.activate_app()
c.new_workspace()
c.new_split("right")
terminals = _wait_portal_terminals(c, expected=2)
for row in terminals:
if not row.get("in_window", False):
raise cmuxError(f"terminal not attached to window: {row}")
if row.get("portal") is not True:
raise cmuxError(f"terminal is not portal-hosted: {row}")
depth = row.get("view_depth")
if not isinstance(depth, int):
raise cmuxError(f"missing view_depth in surface_health: {row}")
if depth > 8:
raise cmuxError(f"terminal view depth too deep ({depth}): {row}")
print("PASS: terminal surfaces are portal-hosted with shallow view depth")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
-136
View File
@@ -1,136 +0,0 @@
#!/usr/bin/env python3
"""
Visual regression test: typing must visibly update the terminal as each character is entered.
Bug: the terminal can appear "frozen" where typed characters do not show up until Enter
or a focus toggle (unfocus/refocus, pane switch, alt-tab).
This test verifies *visual* updates by capturing per-panel screenshots via the debug socket
(`panel_snapshot`) and asserting the pixel-diff is non-trivial after each character.
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock")
def _wait_for(pred, timeout_s: float, step_s: float = 0.05) -> None:
start = time.time()
while time.time() - start < timeout_s:
if pred():
return
time.sleep(step_s)
raise cmuxError("Timed out waiting for condition")
def main() -> int:
with cmux(SOCKET_PATH) as c:
c.activate_app()
time.sleep(0.25)
ws_id = c.new_workspace()
c.select_workspace(ws_id)
time.sleep(0.35)
surfaces = c.list_surfaces()
if not surfaces:
raise cmuxError("Expected at least 1 surface after new_workspace")
panel_id = next((sid for _i, sid, focused in surfaces if focused), surfaces[0][1])
_wait_for(lambda: c.is_terminal_focused(panel_id), timeout_s=3.0)
# Type into the shell prompt without pressing Enter.
text = "cmux"
# Capture the static prompt line (empty input) once. The typed-text check
# below strips this prefix so a "cmux" already present in the prompt/path
# cannot satisfy the check before anything is typed. Trailing
# zsh-autosuggestion glyphs render AFTER the cursor, so the typed prefix
# still appears at the start of the post-prompt remainder.
def _last_line() -> str:
return (
c.read_terminal_text(panel_id)
.replace("\r", "")
.rstrip("\n")
.split("\n")[-1]
.rstrip()
)
baseline_line = _last_line()
# A single glyph can be surprisingly small at some font sizes; keep this low but
# non-zero to still catch the "no visual updates until Enter/unfocus" regression.
min_pixels = 20
for i, ch in enumerate(text):
c.panel_snapshot_reset(panel_id)
# Establish the diff baseline; subsequent panel_snapshot calls diff the current
# frame against this captured "before" image.
c.panel_snapshot(panel_id, f"typing_{i}_before")
# Use a real keyDown path (not NSTextInputClient.insertText) to better match
# physical typing behavior and catch "input doesn't render until Enter/unfocus".
c.simulate_shortcut(ch)
# The chain keystroke -> PTY echo -> Ghostty render -> committed frame -> snapshot
# diff is asynchronous; under CI/VM load a single committed frame can take well
# over 120ms. Instead of a fixed sleep + one hard assert, poll the real signals:
# the rendered pixel diff crossing min_pixels AND the terminal text buffer holding
# the typed prefix. Each panel_snapshot diffs against the prior call, so a frame can
# commit between two polls; latch on the first snapshot that crosses the threshold so
# a split diff across polls still counts. Fail only at the deadline.
expected_prefix = text[: i + 1]
state = {"changed": -1, "snap": None, "buf": ""}
def _typed_visible() -> bool:
snap = c.panel_snapshot(panel_id, f"typing_{i}_after_{ord(ch)}")
changed = int(snap.get("changed_pixels", -1))
state["snap"] = snap
if changed > state["changed"]:
state["changed"] = changed
buf = c.read_terminal_text(panel_id)
state["buf"] = buf
last_line = buf.replace("\r", "").rstrip("\n").split("\n")[-1].rstrip()
# Strip the prompt captured before typing so the prompt's own text
# cannot satisfy the check; only the region we typed into remains.
typed_region = (
last_line[len(baseline_line):]
if last_line.startswith(baseline_line)
else last_line
)
return state["changed"] >= min_pixels and expected_prefix in typed_region
try:
_wait_for(_typed_visible, timeout_s=6.0)
except cmuxError:
snap = state["snap"] or {}
if state["changed"] < min_pixels:
raise cmuxError(
"Expected visible pixel changes after typing a character.\n"
f"char={ch!r} index={i} changed_pixels={state['changed']} "
f"min_pixels={min_pixels}\n"
f"snapshot_path={snap.get('path')}"
)
# Pixels changed but the terminal text buffer never showed the prefix. (This is
# weaker than the visual assertion, but helps triage whether the issue is
# rendering vs tick/IO.)
tail = state["buf"][-600:].replace("\r", "\\r")
raise cmuxError(
"Terminal text did not update after typing.\n"
f"expected_prefix={expected_prefix!r}\n"
f"last_tail:\n{tail}"
)
print("PASS: visual typing updates char-by-char")
return 0
if __name__ == "__main__":
raise SystemExit(main())