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:
+1
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
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())
|
||||
@@ -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
@@ -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())
|
||||
Reference in New Issue
Block a user