cmux/tests_v2/test_workspace_relative.py
Lawrence Chen 97085be775
Remove index-based CLI APIs, make commands workspace-relative (#89)
* Remove index-based CLI APIs and make commands workspace-relative

Migrate 14 CLI commands from v1 text protocol to v2 JSON-RPC, making them
workspace-relative via CMUX_WORKSPACE_ID env var fallback. Update 7 more
commands to use normalize functions instead of legacy resolvers. Remove dead
code (5 structs, 5 parsers, 3 functions). Add regression tests.

Commands migrated v1→v2: send, send-key, send-panel, send-key-panel,
new-split, new-pane, new-surface, close-surface, list-panes,
list-pane-surfaces, list-panels, surface-health, focus-pane, focus-panel.

Commands updated: move-workspace-to-window, list-workspaces,
close-workspace, select-workspace, trigger-flash, resolveWorkspaceId,
resolveSurfaceId.

* Fix CMUX_SURFACE_ID env fallback when --workspace is overridden

When --workspace is explicitly passed, don't fall back to CMUX_SURFACE_ID
from the caller's environment. The caller's surface belongs to a different
workspace, causing "surface not found" errors. Only use the env var fallback
when the workspace is implicit (from CMUX_WORKSPACE_ID or server default).

Affects: send, send-key, new-split, close-surface, trigger-flash, identify,
notify, claude-hook.

* Validate surface before close and respect -- option terminator in send

P1: close-surface now validates the surface handle exists before sending
surface.close, preventing silent fallback to focused surface when a stale
or mistyped ref is provided.

P2: parseOption now respects -- as an option terminator. send, send-key,
send-panel, send-key-panel strip the -- marker from payload args. This
prevents payload tokens like --workspace from being consumed as routing
flags (e.g. `cmux send -- echo --workspace foo` sends all text correctly).

* Fix test_send_workspace_relative: add to main() and send valid text

The test was never called from main() and sent empty string which would
raise immediately via _run_cli. Send a space character instead and add
the test to the main() call list.

* Respect --id-format in text output and resolve workspace refs across windows

Text-mode list commands (list-workspaces, list-panes, list-panels,
list-pane-surfaces, surface-health) now honor --id-format for plain output,
not just --json. Added textHandle() helper that picks ref/id/both based on
the selected format.

resolveWorkspaceId now enumerates all windows when resolving a workspace
ref, so notify/claude-hook work correctly in multi-window sessions where
the target workspace may be in a non-active window.

* Preserve escape-sequence semantics for send text

The v1 server unescaped \n, \r, \t in send payloads before injecting into
the terminal. The v2 surface.send_text handler sends text verbatim. Add
CLI-side unescapeSendText() to restore the same behavior: \n and \r map
to carriage return (Enter key), \t maps to tab. Applied to send and
send-panel commands.

* Reject malformed handles instead of passing through to server

All four normalize functions (window, workspace, pane, surface) now throw
a clear error for unrecognized handle formats instead of passing them
through. Previously, a typo like `--panel foo` would forward `foo` to the
server which would silently fall back to the focused surface.

* Allow cross-workspace surface refs in close-surface and strip plural ID arrays

Remove workspace-scoped pre-validation from close-surface so explicit
surface refs/UUIDs from other workspaces work without requiring
--workspace. The server resolves workspace from surface_id directly.
Malformed handles are already caught by normalizeSurfaceHandle.

Extend formatIDs to strip plural _ids/_refs array pairs (e.g.
surface_ids/surface_refs in pane.list output) based on --id-format,
matching the existing singular _id/_ref stripping behavior.

* Honor explicit --window over CMUX_WORKSPACE_ID env fallback

When --window is passed globally, skip the CMUX_WORKSPACE_ID env var
fallback so commands operate on the targeted window's selected workspace
instead of the caller's workspace from a different window. Affects all
migrated commands that use workspaceFromArgsOrEnv or inline env fallback.
2026-02-18 22:50:02 -08:00

250 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""Regression: CLI commands are workspace-relative via CMUX_WORKSPACE_ID.
Tests that when CMUX_WORKSPACE_ID is set, CLI commands target that workspace
(not the focused workspace). This is the core P0 #2 behavior: agents in
background workspaces should not affect the user's active workspace.
"""
import glob
import json
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli(cli: str, args: List[str], env_overrides: Optional[Dict[str, str]] = None) -> str:
"""Run CLI command and return stdout."""
cmd = [cli, "--socket", SOCKET_PATH] + args
env = os.environ.copy()
if env_overrides:
env.update(env_overrides)
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
if proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
return proc.stdout.strip()
def _run_cli_json(cli: str, args: List[str], env_overrides: Optional[Dict[str, str]] = None) -> Any:
"""Run CLI command with --json and return parsed output."""
output = _run_cli(cli, ["--json"] + args, env_overrides=env_overrides)
try:
return json.loads(output or "{}")
except Exception as exc:
raise cmuxError(f"Invalid JSON output: {output!r} ({exc})")
def test_list_panels_workspace_relative(c: cmux, cli: str) -> None:
"""list-panels with --workspace targets the specified workspace."""
# Get current workspaces
ws_result = c._call("workspace.list")
workspaces = ws_result.get("workspaces", [])
_must(len(workspaces) >= 1, "Need at least 1 workspace")
ws_a = workspaces[0]
ws_a_ref = ws_a.get("ref", ws_a["id"])
# Use CLI with explicit --workspace flag
payload = _run_cli_json(cli, ["list-panels", "--workspace", ws_a_ref])
surfaces = payload.get("surfaces", [])
_must(isinstance(surfaces, list), f"Expected surfaces array, got: {payload}")
# Also test via env var
payload_env = _run_cli_json(
cli, ["list-panels"],
env_overrides={"CMUX_WORKSPACE_ID": ws_a["id"]}
)
surfaces_env = payload_env.get("surfaces", [])
_must(isinstance(surfaces_env, list), f"Expected surfaces array from env, got: {payload_env}")
# Both should return surfaces for the same workspace
ws_id_flag = payload.get("workspace_id") or payload.get("workspace_ref")
ws_id_env = payload_env.get("workspace_id") or payload_env.get("workspace_ref")
_must(ws_id_flag is not None, f"Missing workspace ID in flag response: {payload}")
_must(ws_id_env is not None, f"Missing workspace ID in env response: {payload_env}")
print(" PASS: list-panels workspace-relative (flag and env)")
def test_list_panes_workspace_relative(c: cmux, cli: str) -> None:
"""list-panes with --workspace targets the specified workspace."""
ws_result = c._call("workspace.list")
workspaces = ws_result.get("workspaces", [])
_must(len(workspaces) >= 1, "Need at least 1 workspace")
ws_ref = workspaces[0].get("ref", workspaces[0]["id"])
payload = _run_cli_json(cli, ["list-panes", "--workspace", ws_ref])
panes = payload.get("panes", [])
_must(isinstance(panes, list), f"Expected panes array, got: {payload}")
_must(len(panes) >= 1, f"Expected at least 1 pane, got: {panes}")
print(" PASS: list-panes workspace-relative")
def test_send_workspace_relative(c: cmux, cli: str) -> None:
"""send with CMUX_WORKSPACE_ID env var targets that workspace's surface."""
ws_result = c._call("workspace.list")
workspaces = ws_result.get("workspaces", [])
_must(len(workspaces) >= 1, "Need at least 1 workspace")
ws = workspaces[0]
# Get a surface in this workspace
surfaces = c._call("surface.list", {"workspace_id": ws["id"]})
surface_list = surfaces.get("surfaces", [])
_must(len(surface_list) >= 1, "Need at least 1 surface in workspace")
# Send a harmless empty echo via env var to verify workspace routing
output = _run_cli(
cli, ["send", " "],
env_overrides={"CMUX_WORKSPACE_ID": ws["id"]}
)
_must("OK" in output or "surface" in output.lower(),
f"Expected OK from send, got: {output}")
print(" PASS: send workspace-relative (env var accepted)")
def test_send_with_explicit_workspace(c: cmux, cli: str) -> None:
"""send with --workspace flag targets the specified workspace's surface."""
ws_result = c._call("workspace.list")
workspaces = ws_result.get("workspaces", [])
_must(len(workspaces) >= 1, "Need at least 1 workspace")
ws_ref = workspaces[0].get("ref", workspaces[0]["id"])
# Send a space character (harmless) with explicit workspace
output = _run_cli(cli, ["send", "--workspace", ws_ref, " "])
_must(output.startswith("OK") or "surface" in output.lower(),
f"Expected OK from send, got: {output}")
print(" PASS: send with explicit --workspace")
def test_v2_migrated_commands_output_refs(c: cmux, cli: str) -> None:
"""Verify migrated commands output refs in JSON by default."""
# list-panels should output refs
payload = _run_cli_json(cli, ["list-panels"])
surfaces = payload.get("surfaces", [])
if surfaces:
first = surfaces[0]
_must("ref" in first or "id" in first,
f"Expected ref or id in surface: {first}")
# Default should suppress _id when _ref exists
if "ref" in first:
_must("id" not in first,
f"Default format should suppress id when ref exists: {first}")
# list-panes should output refs
payload = _run_cli_json(cli, ["list-panes"])
panes = payload.get("panes", [])
if panes:
first = panes[0]
_must("ref" in first or "id" in first,
f"Expected ref or id in pane: {first}")
# list-workspaces should output refs
payload = _run_cli_json(cli, ["list-workspaces"])
workspaces = payload.get("workspaces", [])
if workspaces:
first = workspaces[0]
_must("ref" in first or "id" in first,
f"Expected ref or id in workspace: {first}")
if "ref" in first:
_must("id" not in first,
f"Default format should suppress id when ref exists: {first}")
print(" PASS: migrated commands output refs by default")
def test_surface_health_workspace_relative(c: cmux, cli: str) -> None:
"""surface-health with --workspace targets the specified workspace."""
ws_result = c._call("workspace.list")
workspaces = ws_result.get("workspaces", [])
_must(len(workspaces) >= 1, "Need at least 1 workspace")
ws_ref = workspaces[0].get("ref", workspaces[0]["id"])
payload = _run_cli_json(cli, ["surface-health", "--workspace", ws_ref])
surfaces = payload.get("surfaces", [])
_must(isinstance(surfaces, list), f"Expected surfaces array, got: {payload}")
print(" PASS: surface-health workspace-relative")
def test_non_json_output_uses_refs(c: cmux, cli: str) -> None:
"""Non-JSON output from migrated commands uses ref format."""
# list-panels non-JSON
output = _run_cli(cli, ["list-panels"])
_must("surface:" in output or "No surfaces" in output,
f"Expected ref format in list-panels output, got: {output}")
# list-panes non-JSON
output = _run_cli(cli, ["list-panes"])
_must("pane:" in output or "No panes" in output,
f"Expected ref format in list-panes output, got: {output}")
# list-workspaces non-JSON
output = _run_cli(cli, ["list-workspaces"])
_must("workspace:" in output or "No workspaces" in output,
f"Expected ref format in list-workspaces output, got: {output}")
print(" PASS: non-JSON output uses refs")
def main() -> int:
cli = _find_cli_binary()
print(f"Using CLI: {cli}")
c = cmux(SOCKET_PATH)
c.connect()
try:
test_list_panels_workspace_relative(c, cli)
test_list_panes_workspace_relative(c, cli)
test_send_workspace_relative(c, cli)
test_send_with_explicit_workspace(c, cli)
test_v2_migrated_commands_output_refs(c, cli)
test_surface_health_workspace_relative(c, cli)
test_non_json_output_uses_refs(c, cli)
finally:
c.close()
print("\nPASS: All workspace-relative tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())