cmux/daemon/remote/cmd/cmuxd-remote/cli_test.go
Raghav Pillai 1a1caca99d Add cmux CLI relay and tests
- Introduce CLI client (cmux) to relay v1 text and v2 JSON-RPC commands over Unix/TCP sockets
- Implement command specs, flag parsing, v1/v2 round-trips, TCP retry with address refresh
- Add browser subcommand mapping and rpc passthrough support
- Support busybox-style invocation when argv[0]=="cmux" and add 'cli' subcommand
- Add comprehensive unit tests for socket dialing, CLI commands, flag-to-param mapping, and env defaults
- Add socket_addr file fallback reader and random request id generation
2026-02-23 18:24:14 +02:00

456 lines
12 KiB
Go

package main
import (
"encoding/json"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// startMockSocket creates a Unix socket that accepts one connection,
// reads a line, and responds with the given canned response.
func startMockSocket(t *testing.T, response string) string {
t.Helper()
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
_ = n // consume request
conn.Write([]byte(response + "\n"))
conn.Close()
}
}()
return sockPath
}
// startMockV2Socket creates a Unix socket that echoes the received request's method
// back as a successful JSON-RPC response with the method name in the result.
func startMockV2Socket(t *testing.T) string {
t.Helper()
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
if n > 0 {
var req map[string]any
if err := json.Unmarshal(buf[:n], &req); err == nil {
resp := map[string]any{
"id": req["id"],
"ok": true,
"result": map[string]any{"method": req["method"], "params": req["params"]},
}
payload, _ := json.Marshal(resp)
conn.Write(append(payload, '\n'))
} else {
conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n"))
}
}
conn.Close()
}
}()
return sockPath
}
// startMockTCPSocket creates a TCP listener that responds with a canned response.
func startMockTCPSocket(t *testing.T, response string) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen on TCP: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
_ = n
conn.Write([]byte(response + "\n"))
conn.Close()
}
}()
return ln.Addr().String()
}
func TestDialTCPRetrySuccess(t *testing.T) {
// Get a free port, then close the listener so connection is refused initially.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := ln.Addr().String()
ln.Close()
// Start a listener after a delay so the retry logic finds it.
go func() {
time.Sleep(400 * time.Millisecond)
ln2, err := net.Listen("tcp", addr)
if err != nil {
return
}
defer ln2.Close()
conn, err := ln2.Accept()
if err != nil {
return
}
conn.Close()
}()
conn, err := dialTCPRetry(addr, 3*time.Second, nil)
if err != nil {
t.Fatalf("dialTCPRetry should succeed after retry, got: %v", err)
}
conn.Close()
}
func TestDialTCPRetryTimeout(t *testing.T) {
// Get a free port and close it — nothing will ever listen.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := ln.Addr().String()
ln.Close()
start := time.Now()
_, err = dialTCPRetry(addr, 600*time.Millisecond, nil)
elapsed := time.Since(start)
if err == nil {
t.Fatal("dialTCPRetry should fail when nothing is listening")
}
if elapsed < 500*time.Millisecond {
t.Fatalf("should have retried for ~600ms, only took %v", elapsed)
}
}
func TestCLIPingV1(t *testing.T) {
sockPath := startMockSocket(t, "pong")
code := runCLI([]string{"--socket", sockPath, "ping"})
if code != 0 {
t.Fatalf("ping should return 0, got %d", code)
}
}
func TestCLIPingV1OverTCP(t *testing.T) {
addr := startMockTCPSocket(t, "pong")
code := runCLI([]string{"--socket", addr, "ping"})
if code != 0 {
t.Fatalf("ping over TCP should return 0, got %d", code)
}
}
func TestDialSocketDetection(t *testing.T) {
// Unix socket paths should attempt Unix dial
for _, path := range []string{"/tmp/cmux-nonexistent-test-99999.sock", "/var/run/cmux-nonexistent.sock"} {
conn, err := dialSocket(path, nil)
if conn != nil {
conn.Close()
}
// We expect a connection error (not found), not a panic
if err == nil {
t.Fatalf("dialSocket(%q) should fail for non-existent path", path)
}
}
// TCP addresses should attempt TCP dial
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go func() {
conn, _ := ln.Accept()
if conn != nil {
conn.Close()
}
}()
conn, err := dialSocket(ln.Addr().String(), nil)
if err != nil {
t.Fatalf("dialSocket(%q) should succeed for TCP: %v", ln.Addr().String(), err)
}
conn.Close()
}
func TestCLINewWindowV1(t *testing.T) {
sockPath := startMockSocket(t, "OK window_id=abc123")
code := runCLI([]string{"--socket", sockPath, "new-window"})
if code != 0 {
t.Fatalf("new-window should return 0, got %d", code)
}
}
func TestCLICloseWindowV1(t *testing.T) {
// Verify that the flag value is appended to the v1 command
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
var received string
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
received = strings.TrimSpace(string(buf[:n]))
conn.Write([]byte("OK\n"))
conn.Close()
}()
code := runCLI([]string{"--socket", sockPath, "close-window", "--window", "win-42"})
if code != 0 {
t.Fatalf("close-window should return 0, got %d", code)
}
if received != "close_window win-42" {
t.Fatalf("expected 'close_window win-42', got %q", received)
}
}
func TestCLIListWorkspacesV2(t *testing.T) {
sockPath := startMockV2Socket(t)
code := runCLI([]string{"--socket", sockPath, "--json", "list-workspaces"})
if code != 0 {
t.Fatalf("list-workspaces should return 0, got %d", code)
}
}
func TestCLIRPCPassthrough(t *testing.T) {
sockPath := startMockV2Socket(t)
code := runCLI([]string{"--socket", sockPath, "rpc", "system.capabilities"})
if code != 0 {
t.Fatalf("rpc should return 0, got %d", code)
}
}
func TestCLIRPCWithParams(t *testing.T) {
sockPath := startMockV2Socket(t)
code := runCLI([]string{"--socket", sockPath, "rpc", "workspace.create", `{"title":"test"}`})
if code != 0 {
t.Fatalf("rpc with params should return 0, got %d", code)
}
}
func TestCLIUnknownCommand(t *testing.T) {
code := runCLI([]string{"--socket", "/dev/null", "does-not-exist"})
if code != 2 {
t.Fatalf("unknown command should return 2, got %d", code)
}
}
func TestCLINoSocket(t *testing.T) {
// Without CMUX_SOCKET_PATH set, should fail
os.Unsetenv("CMUX_SOCKET_PATH")
code := runCLI([]string{"ping"})
if code != 1 {
t.Fatalf("missing socket should return 1, got %d", code)
}
}
func TestCLISocketEnvVar(t *testing.T) {
sockPath := startMockSocket(t, "pong")
os.Setenv("CMUX_SOCKET_PATH", sockPath)
defer os.Unsetenv("CMUX_SOCKET_PATH")
code := runCLI([]string{"ping"})
if code != 0 {
t.Fatalf("ping with env socket should return 0, got %d", code)
}
}
func TestCLIV2FlagMapping(t *testing.T) {
// Verify that --workspace gets mapped to workspace_id in params
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
var receivedParams map[string]any
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
var req map[string]any
json.Unmarshal(buf[:n], &req)
receivedParams, _ = req["params"].(map[string]any)
resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}}
payload, _ := json.Marshal(resp)
conn.Write(append(payload, '\n'))
conn.Close()
}()
code := runCLI([]string{"--socket", sockPath, "--json", "close-workspace", "--workspace", "ws-abc"})
if code != 0 {
t.Fatalf("close-workspace should return 0, got %d", code)
}
if receivedParams["workspace_id"] != "ws-abc" {
t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams)
}
}
func TestBusyboxArgv0Detection(t *testing.T) {
// Verify that when argv[0] base is "cmux", we enter CLI mode
base := filepath.Base("cmux")
if base != "cmux" {
t.Fatalf("expected base 'cmux', got %q", base)
}
base2 := filepath.Base("/home/user/.cmux/bin/cmux")
if base2 != "cmux" {
t.Fatalf("expected base 'cmux', got %q", base2)
}
base3 := filepath.Base("cmuxd-remote")
if base3 == "cmux" {
t.Fatalf("cmuxd-remote should not match cmux")
}
}
func TestCLIBrowserSubcommand(t *testing.T) {
sockPath := startMockV2Socket(t)
code := runCLI([]string{"--socket", sockPath, "--json", "browser", "open", "--url", "https://example.com"})
if code != 0 {
t.Fatalf("browser open should return 0, got %d", code)
}
}
func TestCLINoArgs(t *testing.T) {
code := runCLI([]string{})
if code != 2 {
t.Fatalf("no args should return 2, got %d", code)
}
}
func TestFlagToParamKey(t *testing.T) {
tests := []struct {
input, expected string
}{
{"workspace", "workspace_id"},
{"surface", "surface_id"},
{"panel", "panel_id"},
{"pane", "pane_id"},
{"window", "window_id"},
{"command", "initial_command"},
{"name", "title"},
{"working-directory", "working_directory"},
{"title", "title"},
{"url", "url"},
{"direction", "direction"},
}
for _, tc := range tests {
got := flagToParamKey(tc.input)
if got != tc.expected {
t.Errorf("flagToParamKey(%q) = %q, want %q", tc.input, got, tc.expected)
}
}
}
func TestParseFlags(t *testing.T) {
args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2", "--unknown", "val"}
result := parseFlags(args, []string{"workspace", "surface"})
if result.flags["workspace"] != "ws-1" {
t.Errorf("expected workspace=ws-1, got %q", result.flags["workspace"])
}
if result.flags["surface"] != "sf-2" {
t.Errorf("expected surface=sf-2, got %q", result.flags["surface"])
}
if _, ok := result.flags["unknown"]; ok {
t.Errorf("unknown flag should not be parsed")
}
if len(result.positional) == 0 || result.positional[0] != "positional-cmd" {
t.Errorf("expected first positional=positional-cmd, got %v", result.positional)
}
}
func TestCLIEnvVarDefaults(t *testing.T) {
// Test that CMUX_WORKSPACE_ID and CMUX_SURFACE_ID are used as defaults
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
var receivedParams map[string]any
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
var req map[string]any
json.Unmarshal(buf[:n], &req)
receivedParams, _ = req["params"].(map[string]any)
resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}}
payload, _ := json.Marshal(resp)
conn.Write(append(payload, '\n'))
conn.Close()
}()
os.Setenv("CMUX_WORKSPACE_ID", "env-ws-id")
os.Setenv("CMUX_SURFACE_ID", "env-sf-id")
defer os.Unsetenv("CMUX_WORKSPACE_ID")
defer os.Unsetenv("CMUX_SURFACE_ID")
code := runCLI([]string{"--socket", sockPath, "--json", "close-surface"})
if code != 0 {
t.Fatalf("close-surface should return 0, got %d", code)
}
if receivedParams["workspace_id"] != "env-ws-id" {
t.Errorf("expected workspace_id from env, got %v", receivedParams["workspace_id"])
}
if receivedParams["surface_id"] != "env-sf-id" {
t.Errorf("expected surface_id from env, got %v", receivedParams["surface_id"])
}
}