cmux/daemon/remote/cmd/cmuxd-remote/cli_test.go
2026-03-17 00:59:45 -07:00

923 lines
25 KiB
Go

package main
import (
"bufio"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
original := os.Stdout
reader, writer, err := os.Pipe()
if err != nil {
t.Fatalf("pipe stdout: %v", err)
}
os.Stdout = writer
defer func() {
os.Stdout = original
}()
fn()
if err := writer.Close(); err != nil {
t.Fatalf("close stdout writer: %v", err)
}
output, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read stdout: %v", err)
}
if err := reader.Close(); err != nil {
t.Fatalf("close stdout reader: %v", err)
}
return string(output)
}
func makeShortUnixSocketPath(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("/tmp", "cmuxd-")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
return filepath.Join(dir, "cmux.sock")
}
// 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()
sockPath := makeShortUnixSocketPath(t)
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()
sockPath := makeShortUnixSocketPath(t)
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
}
func startMockV2SocketWithRequestCapture(t *testing.T) (string, <-chan map[string]any) {
t.Helper()
sockPath := makeShortUnixSocketPath(t)
requests := make(chan map[string]any, 8)
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
}
go func(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
if n == 0 {
return
}
var req map[string]any
if err := json.Unmarshal(buf[:n], &req); err != nil {
_, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n"))
return
}
requests <- req
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'))
}(conn)
}
}()
return sockPath, requests
}
func startMockV2TCPSocketWithResult(t *testing.T, result any) 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
}
go func(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
if n == 0 {
return
}
var req map[string]any
if err := json.Unmarshal(buf[:n], &req); err != nil {
_, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n"))
return
}
resp := map[string]any{
"id": req["id"],
"ok": true,
"result": result,
}
payload, _ := json.Marshal(resp)
_, _ = conn.Write(append(payload, '\n'))
}(conn)
}
}()
return ln.Addr().String()
}
// 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 startMockAuthenticatedTCPSocket(t *testing.T, relayID, relayToken, response string) string {
t.Helper()
relayTokenBytes := mustHex(t, relayToken)
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
}
go func(conn net.Conn) {
defer conn.Close()
nonce := "testnonce"
challenge, _ := json.Marshal(map[string]any{
"protocol": "cmux-relay-auth",
"version": 1,
"relay_id": relayID,
"nonce": nonce,
})
_, _ = conn.Write(append(challenge, '\n'))
reader := bufio.NewReader(conn)
line, err := reader.ReadString('\n')
if err != nil {
return
}
var authResp map[string]any
if err := json.Unmarshal([]byte(line), &authResp); err != nil {
_, _ = conn.Write([]byte(`{"ok":false}` + "\n"))
return
}
macHex, _ := authResp["mac"].(string)
receivedMAC, err := hex.DecodeString(macHex)
if err != nil {
_, _ = conn.Write([]byte(`{"ok":false}` + "\n"))
return
}
h := hmac.New(sha256.New, relayTokenBytes)
_, _ = io.WriteString(h, fmt.Sprintf("relay_id=%s\nnonce=%s\nversion=%d", relayID, nonce, 1))
expectedMAC := h.Sum(nil)
if !hmac.Equal(receivedMAC, expectedMAC) {
_, _ = conn.Write([]byte(`{"ok":false}` + "\n"))
return
}
_, _ = conn.Write([]byte(`{"ok":true}` + "\n"))
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
_, _ = conn.Write([]byte(response))
if n > 0 && !strings.HasSuffix(response, "\n") {
_, _ = conn.Write([]byte("\n"))
}
}(conn)
}
}()
return ln.Addr().String()
}
func mustHex(t *testing.T, value string) []byte {
t.Helper()
data, err := hex.DecodeString(value)
if err != nil {
t.Fatalf("decode hex: %v", err)
}
return data
}
func TestDialSocketRefreshesToUpdatedTCPAddressWithoutPolling(t *testing.T) {
staleListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen stale: %v", err)
}
staleAddr := staleListener.Addr().String()
staleListener.Close()
readyListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen ready: %v", err)
}
defer readyListener.Close()
accepted := make(chan struct{})
go func() {
defer close(accepted)
conn, acceptErr := readyListener.Accept()
if acceptErr != nil {
return
}
conn.Close()
}()
refreshCalls := 0
start := time.Now()
conn, err := dialSocket(staleAddr, func() string {
refreshCalls++
return readyListener.Addr().String()
})
elapsed := time.Since(start)
if err != nil {
t.Fatalf("dialSocket should refresh to updated address, got: %v", err)
}
conn.Close()
<-accepted
if refreshCalls != 1 {
t.Fatalf("refreshAddr should be called once, got %d", refreshCalls)
}
if elapsed > 500*time.Millisecond {
t.Fatalf("dialSocket should fail over without polling, took %v", elapsed)
}
}
func TestDialSocketFailsFastWhenTCPAddressStaysStale(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := ln.Addr().String()
ln.Close()
refreshCalls := 0
start := time.Now()
_, err = dialSocket(addr, func() string {
refreshCalls++
return addr
})
elapsed := time.Since(start)
if err == nil {
t.Fatal("dialSocket should fail when the relay address stays stale")
}
if refreshCalls != 1 {
t.Fatalf("refreshAddr should be called once on stale TCP failure, got %d", refreshCalls)
}
if elapsed > 500*time.Millisecond {
t.Fatalf("dialSocket should fail fast without polling, 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 TestCLIPingV1OverAuthenticatedTCPWithEnv(t *testing.T) {
relayID := "relay-1"
relayToken := strings.Repeat("a1", 32)
addr := startMockAuthenticatedTCPSocket(t, relayID, relayToken, "pong")
t.Setenv("CMUX_RELAY_ID", relayID)
t.Setenv("CMUX_RELAY_TOKEN", relayToken)
code := runCLI([]string{"--socket", addr, "ping"})
if code != 0 {
t.Fatalf("ping over authenticated TCP should return 0, got %d", code)
}
}
func TestCLIPingV1OverAuthenticatedTCPWithRelayFile(t *testing.T) {
relayID := "relay-2"
relayToken := strings.Repeat("b2", 32)
addr := startMockAuthenticatedTCPSocket(t, relayID, relayToken, "pong")
_, port, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split host port: %v", err)
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("CMUX_RELAY_ID", "")
t.Setenv("CMUX_RELAY_TOKEN", "")
relayDir := filepath.Join(home, ".cmux", "relay")
if err := os.MkdirAll(relayDir, 0o700); err != nil {
t.Fatalf("mkdir relay dir: %v", err)
}
authPayload, _ := json.Marshal(relayAuthState{RelayID: relayID, RelayToken: relayToken})
if err := os.WriteFile(filepath.Join(relayDir, port+".auth"), authPayload, 0o600); err != nil {
t.Fatalf("write auth file: %v", err)
}
code := runCLI([]string{"--socket", addr, "ping"})
if code != 0 {
t.Fatalf("ping over authenticated TCP file relay 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 TestSocketRoundTripReadsFullMultilineV1Response(t *testing.T) {
addr := startMockTCPSocket(t, "window:alpha\nwindow:beta\nwindow:gamma")
resp, err := socketRoundTrip(addr, "list_windows", nil)
if err != nil {
t.Fatalf("socketRoundTrip should succeed, got error: %v", err)
}
want := "window:alpha\nwindow:beta\nwindow:gamma"
if resp != want {
t.Fatalf("socketRoundTrip truncated v1 response: got %q want %q", resp, want)
}
}
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")
receivedCh := make(chan string, 1)
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)
receivedCh <- 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)
}
select {
case received := <-receivedCh:
if received != "close_window win-42" {
t.Fatalf("expected 'close_window win-42', got %q", received)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for close-window payload")
}
}
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 TestCLIListWorkspacesV2DefaultOutputShowsResult(t *testing.T) {
sockPath := startMockV2TCPSocketWithResult(t, map[string]any{"method": "workspace.list", "params": map[string]any{}})
output := captureStdout(t, func() {
code := runCLI([]string{"--socket", sockPath, "list-workspaces"})
if code != 0 {
t.Fatalf("list-workspaces should return 0, got %d", code)
}
})
if !strings.Contains(output, "\"method\": \"workspace.list\"") {
t.Fatalf("expected default output to include result payload, got %q", output)
}
}
func TestCLINotifyDefaultOutputPrintsOKForEmptyResult(t *testing.T) {
sockPath := startMockV2TCPSocketWithResult(t, map[string]any{})
output := captureStdout(t, func() {
code := runCLI([]string{"--socket", sockPath, "notify", "--body", "hi"})
if code != 0 {
t.Fatalf("notify should return 0, got %d", code)
}
})
if strings.TrimSpace(output) != "OK" {
t.Fatalf("expected empty-result command to print OK, got %q", output)
}
}
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")
receivedParamsCh := make(chan map[string]any, 1)
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)
receivedParamsCh <- receivedParams
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)
}
select {
case receivedParams := <-receivedParamsCh:
if receivedParams["workspace_id"] != "ws-abc" {
t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for close-workspace payload")
}
}
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 TestCLINewPaneDefaultsDirectionAndForwardsExtraFlags(t *testing.T) {
sockPath, requests := startMockV2SocketWithRequestCapture(t)
code := runCLI([]string{
"--socket", sockPath, "--json",
"new-pane",
"--workspace", "ws-1",
"--type", "browser",
"--url", "https://example.com",
})
if code != 0 {
t.Fatalf("new-pane should return 0, got %d", code)
}
select {
case req := <-requests:
if got := req["method"]; got != "pane.create" {
t.Fatalf("expected pane.create, got %v", got)
}
params, _ := req["params"].(map[string]any)
if got := params["workspace_id"]; got != "ws-1" {
t.Fatalf("expected workspace_id ws-1, got %v", got)
}
if got := params["direction"]; got != "right" {
t.Fatalf("expected default direction right, got %v", got)
}
if got := params["type"]; got != "browser" {
t.Fatalf("expected type browser, got %v", got)
}
if got := params["url"]; got != "https://example.com" {
t.Fatalf("expected url to be forwarded, got %v", got)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for new-pane request")
}
}
func TestCLIListPanelsUsesSurfaceList(t *testing.T) {
sockPath, requests := startMockV2SocketWithRequestCapture(t)
code := runCLI([]string{"--socket", sockPath, "--json", "list-panels", "--workspace", "ws-1"})
if code != 0 {
t.Fatalf("list-panels should return 0, got %d", code)
}
select {
case req := <-requests:
if got := req["method"]; got != "surface.list" {
t.Fatalf("expected surface.list, got %v", got)
}
params, _ := req["params"].(map[string]any)
if got := params["workspace_id"]; got != "ws-1" {
t.Fatalf("expected workspace_id ws-1, got %v", got)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for list-panels request")
}
}
func TestCLIFocusPanelUsesSurfaceFocus(t *testing.T) {
sockPath, requests := startMockV2SocketWithRequestCapture(t)
code := runCLI([]string{"--socket", sockPath, "--json", "focus-panel", "--workspace", "ws-1", "--panel", "surface-1"})
if code != 0 {
t.Fatalf("focus-panel should return 0, got %d", code)
}
select {
case req := <-requests:
if got := req["method"]; got != "surface.focus" {
t.Fatalf("expected surface.focus, got %v", got)
}
params, _ := req["params"].(map[string]any)
if got := params["workspace_id"]; got != "ws-1" {
t.Fatalf("expected workspace_id ws-1, got %v", got)
}
if got := params["surface_id"]; got != "surface-1" {
t.Fatalf("expected surface_id surface-1, got %v", got)
}
if _, ok := params["panel_id"]; ok {
t.Fatalf("did not expect panel_id in params: %v", params)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for focus-panel request")
}
}
func TestCLIBrowserOpenUsesOpenSplitAndWorkspaceEnv(t *testing.T) {
sockPath, requests := startMockV2SocketWithRequestCapture(t)
t.Setenv("CMUX_WORKSPACE_ID", "env-ws")
code := runCLI([]string{"--socket", sockPath, "--json", "browser", "open", "https://example.com"})
if code != 0 {
t.Fatalf("browser open should return 0, got %d", code)
}
select {
case req := <-requests:
if got := req["method"]; got != "browser.open_split" {
t.Fatalf("expected browser.open_split, got %v", got)
}
params, _ := req["params"].(map[string]any)
if got := params["workspace_id"]; got != "env-ws" {
t.Fatalf("expected workspace_id env-ws, got %v", got)
}
if got := params["url"]; got != "https://example.com" {
t.Fatalf("expected positional url to be forwarded, got %v", got)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for browser open request")
}
}
func TestCLIBrowserGetURLUsesCurrentMethodAndSurfaceEnv(t *testing.T) {
sockPath, requests := startMockV2SocketWithRequestCapture(t)
t.Setenv("CMUX_SURFACE_ID", "env-sf")
code := runCLI([]string{"--socket", sockPath, "--json", "browser", "get-url"})
if code != 0 {
t.Fatalf("browser get-url should return 0, got %d", code)
}
select {
case req := <-requests:
if got := req["method"]; got != "browser.url.get" {
t.Fatalf("expected browser.url.get, got %v", got)
}
params, _ := req["params"].(map[string]any)
if got := params["surface_id"]; got != "env-sf" {
t.Fatalf("expected surface_id env-sf, got %v", got)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for browser get-url request")
}
}
func TestCLINoArgs(t *testing.T) {
code := runCLI([]string{})
if code != 2 {
t.Fatalf("no args should return 2, got %d", code)
}
}
func TestCLIHelpFlag(t *testing.T) {
code := runCLI([]string{"--help"})
if code != 0 {
t.Fatalf("--help should return 0, got %d", code)
}
}
func TestCLIHelpCommand(t *testing.T) {
code := runCLI([]string{"help"})
if code != 0 {
t.Fatalf("help should return 0, 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"}
_, err := parseFlags(args, []string{"workspace", "surface"})
if err == nil {
t.Fatal("parseFlags should reject unknown flags")
}
}
func TestParseFlagsCollectsKnownFlagsAndPositionalArgs(t *testing.T) {
args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2"}
result, err := parseFlags(args, []string{"workspace", "surface"})
if err != nil {
t.Fatalf("parseFlags should succeed for known flags: %v", err)
}
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 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")
receivedParamsCh := make(chan map[string]any, 1)
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)
receivedParamsCh <- receivedParams
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)
}
select {
case receivedParams := <-receivedParamsCh:
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"])
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for close-surface payload")
}
}