* Fix multi-workspace drag/drop, WebView click focus, and add regression tests - Wire bonsplit isInteractive to workspace active state so inactive workspace NSViews are hidden from AppKit event routing - Add CmuxWebView.mouseDown notification for browser panel focus tracking (AppKit delivers clicks to WKWebView, not SwiftUI overlays) - Add multi-workspace focus regression test covering isHidden fix, rapid workspace switching, and browser panel focus routing * Bump version to 1.36.0
383 lines
11 KiB
Python
383 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Regression test: multi-workspace terminal and browser focus.
|
|
|
|
Bug 1 (isHidden): When multiple workspaces exist in a ZStack, inactive workspaces'
|
|
AppKit NSViews (NSSplitView, NSHostingController containers) remain in the window's
|
|
view hierarchy and intercept events (drags, clicks) even when SwiftUI sets
|
|
.allowsHitTesting(false). Fix: set isHidden=true on NSView containers for inactive
|
|
workspaces via bonsplit's isInteractive flag.
|
|
|
|
Bug 2 (webview click focus): Clicking inside a WKWebView didn't focus the browser
|
|
tab because AppKit delivers the click to WKWebView, not to the SwiftUI Color.clear
|
|
overlay used for focus tracking. Fix: CmuxWebView.mouseDown posts a notification
|
|
that BrowserPanelView listens for to call onRequestPanelFocus().
|
|
|
|
This test validates:
|
|
1) Terminals in non-active workspaces remain responsive after switching back.
|
|
2) Terminals in workspaces with splits remain responsive after cycling through
|
|
multiple workspaces (the isHidden toggle doesn't break view attachment).
|
|
3) Browser panel can receive focus and the terminal can reclaim focus afterward.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from cmux import cmux, cmuxError
|
|
|
|
|
|
MARKER_DIR = Path(tempfile.gettempdir())
|
|
|
|
|
|
def _marker(name: str) -> Path:
|
|
return MARKER_DIR / f"cmux_mwf_{name}_{os.getpid()}"
|
|
|
|
|
|
def _clear(marker: Path):
|
|
marker.unlink(missing_ok=True)
|
|
|
|
|
|
def _wait_marker(marker: Path, timeout: float = 5.0) -> bool:
|
|
start = time.time()
|
|
while time.time() - start < timeout:
|
|
if marker.exists():
|
|
return True
|
|
time.sleep(0.1)
|
|
return False
|
|
|
|
|
|
def _verify_responsive(c: cmux, marker: Path, surface_idx: int, retries: int = 3) -> bool:
|
|
"""Send a touch command to a specific terminal surface and check the marker appears."""
|
|
for attempt in range(retries):
|
|
_clear(marker)
|
|
try:
|
|
c.send_key_surface(surface_idx, "ctrl-c")
|
|
except Exception:
|
|
time.sleep(0.5)
|
|
continue
|
|
time.sleep(0.3)
|
|
try:
|
|
c.send_surface(surface_idx, f"touch {marker}\n")
|
|
except Exception:
|
|
time.sleep(0.5)
|
|
continue
|
|
if _wait_marker(marker, timeout=3.0):
|
|
return True
|
|
time.sleep(0.5)
|
|
return False
|
|
|
|
|
|
def _wait_terminal_in_window(c: cmux, surface_idx: int, timeout: float = 5.0) -> bool:
|
|
start = time.time()
|
|
while time.time() - start < timeout:
|
|
try:
|
|
health = c.surface_health()
|
|
except Exception:
|
|
health = []
|
|
for h in health:
|
|
if h.get("index") == surface_idx and h.get("type") == "terminal" and h.get("in_window"):
|
|
return True
|
|
time.sleep(0.2)
|
|
return False
|
|
|
|
|
|
def test_multi_workspace_terminal_responsive(c: cmux) -> None:
|
|
"""
|
|
Create two workspaces with splits, cycle between them, and verify all terminals
|
|
in each workspace remain responsive. Before the isHidden fix, terminals in
|
|
workspace A would lose input when workspace B's NSViews sat on top in the
|
|
view hierarchy.
|
|
"""
|
|
# Workspace A
|
|
ws_a = c.new_workspace()
|
|
time.sleep(0.3)
|
|
c.new_split("right")
|
|
time.sleep(0.8)
|
|
_wait_terminal_in_window(c, 0, timeout=5.0)
|
|
_wait_terminal_in_window(c, 1, timeout=5.0)
|
|
|
|
# Workspace B
|
|
ws_b = c.new_workspace()
|
|
time.sleep(0.3)
|
|
c.new_split("right")
|
|
time.sleep(0.8)
|
|
_wait_terminal_in_window(c, 0, timeout=5.0)
|
|
_wait_terminal_in_window(c, 1, timeout=5.0)
|
|
|
|
# Verify workspace B terminals work (this is the "last" workspace)
|
|
m_b0 = _marker("wsb_0")
|
|
m_b1 = _marker("wsb_1")
|
|
try:
|
|
assert _verify_responsive(c, m_b0, 0), "Workspace B surface 0 not responsive"
|
|
assert _verify_responsive(c, m_b1, 1), "Workspace B surface 1 not responsive"
|
|
finally:
|
|
_clear(m_b0)
|
|
_clear(m_b1)
|
|
|
|
# Switch back to workspace A
|
|
c.select_workspace(ws_a)
|
|
time.sleep(0.5)
|
|
_wait_terminal_in_window(c, 0, timeout=5.0)
|
|
_wait_terminal_in_window(c, 1, timeout=5.0)
|
|
|
|
# Verify workspace A terminals work (this was the bug: non-last workspace lost input)
|
|
m_a0 = _marker("wsa_0")
|
|
m_a1 = _marker("wsa_1")
|
|
try:
|
|
assert _verify_responsive(c, m_a0, 0), \
|
|
"Workspace A surface 0 not responsive after switching back (isHidden regression)"
|
|
assert _verify_responsive(c, m_a1, 1), \
|
|
"Workspace A surface 1 not responsive after switching back (isHidden regression)"
|
|
finally:
|
|
_clear(m_a0)
|
|
_clear(m_a1)
|
|
|
|
# Cycle back to B and verify
|
|
c.select_workspace(ws_b)
|
|
time.sleep(0.5)
|
|
_wait_terminal_in_window(c, 0, timeout=5.0)
|
|
m_b0_2 = _marker("wsb_0_2")
|
|
try:
|
|
assert _verify_responsive(c, m_b0_2, 0), \
|
|
"Workspace B surface 0 not responsive after cycling"
|
|
finally:
|
|
_clear(m_b0_2)
|
|
|
|
# Cleanup
|
|
c.close_workspace(ws_b)
|
|
time.sleep(0.3)
|
|
c.close_workspace(ws_a)
|
|
time.sleep(0.3)
|
|
|
|
|
|
def test_three_workspaces_non_last_responsive(c: cmux) -> None:
|
|
"""
|
|
Three workspaces: verify the FIRST workspace (furthest back in ZStack) is still
|
|
responsive. This is the worst case for the old bug since it has two inactive
|
|
workspaces' NSViews stacked above it.
|
|
"""
|
|
ws_a = c.new_workspace()
|
|
time.sleep(0.3)
|
|
c.new_split("down")
|
|
time.sleep(0.8)
|
|
|
|
ws_b = c.new_workspace()
|
|
time.sleep(0.3)
|
|
|
|
ws_c = c.new_workspace()
|
|
time.sleep(0.3)
|
|
|
|
# Switch back to workspace A (furthest back in ZStack)
|
|
c.select_workspace(ws_a)
|
|
time.sleep(0.5)
|
|
_wait_terminal_in_window(c, 0, timeout=5.0)
|
|
_wait_terminal_in_window(c, 1, timeout=5.0)
|
|
|
|
m0 = _marker("3ws_0")
|
|
m1 = _marker("3ws_1")
|
|
try:
|
|
assert _verify_responsive(c, m0, 0), \
|
|
"First workspace surface 0 not responsive with 2 workspaces stacked above"
|
|
assert _verify_responsive(c, m1, 1), \
|
|
"First workspace surface 1 not responsive with 2 workspaces stacked above"
|
|
finally:
|
|
_clear(m0)
|
|
_clear(m1)
|
|
|
|
# Cleanup
|
|
c.close_workspace(ws_c)
|
|
time.sleep(0.2)
|
|
c.close_workspace(ws_b)
|
|
time.sleep(0.2)
|
|
c.close_workspace(ws_a)
|
|
time.sleep(0.2)
|
|
|
|
|
|
def test_rapid_workspace_switching_preserves_focus(c: cmux) -> None:
|
|
"""
|
|
Rapidly switch between workspaces and verify terminals remain responsive.
|
|
The isHidden toggle must not break view attachment or cause blank terminals.
|
|
"""
|
|
ws_a = c.new_workspace()
|
|
time.sleep(0.3)
|
|
c.new_split("right")
|
|
time.sleep(0.8)
|
|
|
|
ws_b = c.new_workspace()
|
|
time.sleep(0.3)
|
|
|
|
# Rapid switching
|
|
for _ in range(5):
|
|
c.select_workspace(ws_a)
|
|
time.sleep(0.15)
|
|
c.select_workspace(ws_b)
|
|
time.sleep(0.15)
|
|
|
|
# Settle on workspace A and verify
|
|
c.select_workspace(ws_a)
|
|
time.sleep(0.5)
|
|
_wait_terminal_in_window(c, 0, timeout=5.0)
|
|
_wait_terminal_in_window(c, 1, timeout=5.0)
|
|
|
|
m0 = _marker("rapid_0")
|
|
m1 = _marker("rapid_1")
|
|
try:
|
|
assert _verify_responsive(c, m0, 0), \
|
|
"Surface 0 not responsive after rapid workspace switching"
|
|
assert _verify_responsive(c, m1, 1), \
|
|
"Surface 1 not responsive after rapid workspace switching"
|
|
finally:
|
|
_clear(m0)
|
|
_clear(m1)
|
|
|
|
# Cleanup
|
|
c.close_workspace(ws_b)
|
|
time.sleep(0.2)
|
|
c.close_workspace(ws_a)
|
|
time.sleep(0.2)
|
|
|
|
|
|
def test_browser_panel_focus_and_return(c: cmux) -> None:
|
|
"""
|
|
Create a terminal and a browser surface in the same pane, focus the browser,
|
|
then switch back to the terminal. Verifies focus routing works correctly for
|
|
browser panels.
|
|
"""
|
|
ws = c.new_workspace()
|
|
time.sleep(0.3)
|
|
|
|
# Get the terminal panel ID
|
|
surfaces = c.list_pane_surfaces()
|
|
if not surfaces:
|
|
raise cmuxError("No surfaces after new_workspace")
|
|
term_panel_id = surfaces[0][1]
|
|
|
|
# Create a browser surface in the same pane
|
|
browser_panel_id = c.new_surface(panel_type="browser", url="about:blank")
|
|
time.sleep(0.5)
|
|
|
|
# Focus the browser and verify
|
|
c.focus_webview(browser_panel_id)
|
|
time.sleep(0.3)
|
|
assert c.is_webview_focused(browser_panel_id), \
|
|
"Browser panel should have focus after focus_webview"
|
|
|
|
# Switch back to terminal and verify it's responsive
|
|
c.focus_surface_by_panel(term_panel_id)
|
|
time.sleep(0.3)
|
|
|
|
m = _marker("browser_return")
|
|
try:
|
|
# Use the focused terminal
|
|
_clear(m)
|
|
c.send_key("ctrl-c")
|
|
time.sleep(0.2)
|
|
c.send(f"touch {m}\n")
|
|
assert _wait_marker(m, timeout=3.0), \
|
|
"Terminal not responsive after switching back from browser"
|
|
finally:
|
|
_clear(m)
|
|
|
|
# Cleanup
|
|
c.close_workspace(ws)
|
|
time.sleep(0.2)
|
|
|
|
|
|
def test_browser_focus_across_workspaces(c: cmux) -> None:
|
|
"""
|
|
Workspace A has a terminal, workspace B has a browser. Switching between them
|
|
should correctly route focus to each panel type.
|
|
"""
|
|
ws_a = c.new_workspace()
|
|
time.sleep(0.3)
|
|
|
|
ws_b = c.new_workspace()
|
|
time.sleep(0.3)
|
|
# Create a browser in workspace B
|
|
browser_panel_id = c.new_surface(panel_type="browser", url="about:blank")
|
|
time.sleep(0.5)
|
|
|
|
# Focus browser in workspace B
|
|
c.focus_webview(browser_panel_id)
|
|
time.sleep(0.3)
|
|
assert c.is_webview_focused(browser_panel_id), \
|
|
"Browser should have focus in workspace B"
|
|
|
|
# Switch to workspace A (terminal)
|
|
c.select_workspace(ws_a)
|
|
time.sleep(0.5)
|
|
_wait_terminal_in_window(c, 0, timeout=5.0)
|
|
|
|
m = _marker("cross_ws_term")
|
|
try:
|
|
assert _verify_responsive(c, m, 0), \
|
|
"Terminal in workspace A not responsive after switching from browser workspace"
|
|
finally:
|
|
_clear(m)
|
|
|
|
# Switch back to workspace B and verify browser still works
|
|
c.select_workspace(ws_b)
|
|
time.sleep(0.5)
|
|
c.focus_webview(browser_panel_id)
|
|
time.sleep(0.3)
|
|
assert c.is_webview_focused(browser_panel_id), \
|
|
"Browser should regain focus after switching back to workspace B"
|
|
|
|
# Cleanup
|
|
c.close_workspace(ws_b)
|
|
time.sleep(0.2)
|
|
c.close_workspace(ws_a)
|
|
time.sleep(0.2)
|
|
|
|
|
|
def main() -> int:
|
|
print("=" * 60)
|
|
print("Multi-Workspace Focus Regression Tests")
|
|
print("=" * 60)
|
|
print()
|
|
|
|
tests = [
|
|
("Multi-workspace terminal responsive (isHidden regression)", test_multi_workspace_terminal_responsive),
|
|
("Three workspaces non-last responsive", test_three_workspaces_non_last_responsive),
|
|
("Rapid workspace switching preserves focus", test_rapid_workspace_switching_preserves_focus),
|
|
("Browser panel focus and return", test_browser_panel_focus_and_return),
|
|
("Browser focus across workspaces", test_browser_focus_across_workspaces),
|
|
]
|
|
|
|
with cmux() as c:
|
|
c.activate_app()
|
|
time.sleep(0.2)
|
|
|
|
passed = 0
|
|
failed = 0
|
|
|
|
for name, test_fn in tests:
|
|
print(f" {name}...", end=" ", flush=True)
|
|
try:
|
|
test_fn(c)
|
|
print("PASS")
|
|
passed += 1
|
|
except (AssertionError, cmuxError) as e:
|
|
print(f"FAIL: {e}")
|
|
failed += 1
|
|
except Exception as e:
|
|
print(f"ERROR: {type(e).__name__}: {e}")
|
|
failed += 1
|
|
|
|
print()
|
|
print(f"Results: {passed} passed, {failed} failed out of {passed + failed}")
|
|
|
|
if failed == 0:
|
|
print("\nPASS: multi-workspace focus")
|
|
return 0
|
|
else:
|
|
print(f"\nFAIL: {failed} test(s) failed")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|