#!/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)