package app import ( "context" "encoding/json" "io" "net" "os" "path/filepath" "strings" "testing" "time" "github.com/hjbdev/patterm/internal/protocol" ) func TestDaemonDetachReattachPreservesProcess(t *testing.T) { root := t.TempDir() t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config")) t.Setenv("XDG_DATA_HOME", filepath.Join(root, "data")) t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime")) projectDir := filepath.Join(root, "project") if err := os.MkdirAll(projectDir, 0o700); err != nil { t.Fatalf("mkdir project: %v", err) } socket := filepath.Join(root, "runtime", "patterm", "daemon.sock") pid := filepath.Join(root, "runtime", "patterm", "daemon.pid") ctx, cancel := context.WithCancel(context.Background()) defer cancel() errCh := make(chan error, 1) go func() { errCh <- RunDaemon(ctx, DaemonOptions{ ProjectDir: projectDir, SocketPath: socket, PidPath: pid, Cols: 80, Rows: 24, }) }() waitForSocket(t, socket, errCh) client1 := dialDaemon(t, socket) sendFrame(t, client1, protocol.FrameAttach, protocol.Attach{ ProjectPath: projectDir, TermSize: protocol.Size{Cols: 80, Rows: 24}, }) expectFrame(t, client1, protocol.FrameHello) expectFrame(t, client1, protocol.FrameProjectList) expectFrame(t, client1, protocol.FrameChrome) data, _ := json.Marshal(map[string]any{ "argv": []string{"sh", "-c", "trap 'exit 0' TERM; while :; do echo STILL-HERE; sleep 1; done"}, "name": "survivor", }) sendFrame(t, client1, protocol.FramePaletteCommand, protocol.PaletteCommand{ Kind: "spawn_command", Data: data, }) waitForLifecycle(t, client1, protocol.LifecycleSpawned, 3*time.Second) sendFrame(t, client1, protocol.FrameDetach, protocol.Detach{}) _ = client1.Close() client2 := dialDaemon(t, socket) defer client2.Close() sendFrame(t, client2, protocol.FrameAttach, protocol.Attach{ ProjectPath: projectDir, TermSize: protocol.Size{Cols: 80, Rows: 24}, }) expectFrame(t, client2, protocol.FrameHello) expectFrame(t, client2, protocol.FrameProjectList) chrome := expectChrome(t, client2) if !chromeHasProcess(chrome, "survivor") { t.Fatalf("reattached chrome did not include surviving process: %s", string(chrome.Model)) } expectFrame(t, client2, protocol.FramePaneSnapshot) cancel() select { case err := <-errCh: if err != nil { t.Fatalf("daemon returned error: %v", err) } case <-time.After(3 * time.Second): t.Fatalf("daemon did not stop") } } func TestDaemonTCPTokenAuthAndUnixExemption(t *testing.T) { root := t.TempDir() t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config")) t.Setenv("XDG_DATA_HOME", filepath.Join(root, "data")) t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime")) projectDir := filepath.Join(root, "project") if err := os.MkdirAll(projectDir, 0o700); err != nil { t.Fatalf("mkdir project: %v", err) } socket := filepath.Join(root, "runtime", "patterm", "daemon.sock") pid := filepath.Join(root, "runtime", "patterm", "daemon.pid") ctx, cancel := context.WithCancel(context.Background()) defer cancel() errCh := make(chan error, 1) ready := make(chan string, 1) go func() { errCh <- RunDaemon(ctx, DaemonOptions{ ProjectDir: projectDir, SocketPath: socket, PidPath: pid, ListenAddr: "127.0.0.1:0", Token: "secret-token", TokenOut: io.Discard, ListenReady: ready, Cols: 80, Rows: 24, }) }() waitForSocket(t, socket, errCh) tcpAddr := waitForTCPAddr(t, ready, errCh) assertTCPAttachDenied(t, tcpAddr, "") assertTCPAttachDenied(t, tcpAddr, "wrong-token") tcpClient := dialTCPDaemon(t, tcpAddr) defer tcpClient.Close() sendFrame(t, tcpClient, protocol.FrameAttach, protocol.Attach{ Token: "secret-token", ProjectPath: projectDir, TermSize: protocol.Size{Cols: 80, Rows: 24}, }) expectFrame(t, tcpClient, protocol.FrameHello) expectFrame(t, tcpClient, protocol.FrameProjectList) expectFrame(t, tcpClient, protocol.FrameChrome) data, _ := json.Marshal(map[string]any{ "argv": []string{"sh", "-c", "trap 'exit 0' TERM; echo TCP-SNAPSHOT; sleep 30"}, "name": "tcp-survivor", }) sendFrame(t, tcpClient, protocol.FramePaletteCommand, protocol.PaletteCommand{ Kind: "spawn_command", Data: data, }) expectFrame(t, tcpClient, protocol.FramePaneSnapshot) unixClient := dialDaemon(t, socket) defer unixClient.Close() sendFrame(t, unixClient, protocol.FrameAttach, protocol.Attach{ ProjectPath: projectDir, TermSize: protocol.Size{Cols: 80, Rows: 24}, }) expectFrame(t, unixClient, protocol.FrameHello) cancel() select { case err := <-errCh: if err != nil { t.Fatalf("daemon returned error: %v", err) } case <-time.After(3 * time.Second): t.Fatalf("daemon did not stop") } } func waitForSocket(t *testing.T, socket string, errCh <-chan error) { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { if _, err := os.Stat(socket); err == nil { return } select { case err := <-errCh: if err != nil && strings.Contains(err.Error(), "operation not permitted") { t.Skipf("unix sockets unavailable in this sandbox: %v", err) } t.Fatalf("daemon exited before creating socket: %v", err) default: } time.Sleep(25 * time.Millisecond) } t.Fatalf("socket %s was not created", socket) } func dialDaemon(t *testing.T, socket string) protocol.Transport { t.Helper() conn, err := net.Dial("unix", socket) if err != nil { t.Fatalf("dial daemon: %v", err) } return protocol.NewConnTransport(conn) } func dialTCPDaemon(t *testing.T, addr string) protocol.Transport { t.Helper() conn, err := net.Dial("tcp", addr) if err != nil { t.Fatalf("dial tcp daemon: %v", err) } return protocol.NewConnTransport(conn) } func waitForTCPAddr(t *testing.T, ready <-chan string, errCh <-chan error) string { t.Helper() select { case addr := <-ready: return addr case err := <-errCh: if err != nil && strings.Contains(err.Error(), "operation not permitted") { t.Skipf("tcp sockets unavailable in this sandbox: %v", err) } t.Fatalf("daemon exited before TCP listener was ready: %v", err) case <-time.After(3 * time.Second): t.Fatalf("tcp listener was not ready") } return "" } func assertTCPAttachDenied(t *testing.T, addr, token string) { t.Helper() client := dialTCPDaemon(t, addr) defer client.Close() sendFrame(t, client, protocol.FrameAttach, protocol.Attach{ Token: token, TermSize: protocol.Size{Cols: 80, Rows: 24}, }) f := expectFrame(t, client, protocol.FrameError) msg, err := protocol.Decode[protocol.Error](f) if err != nil { t.Fatalf("decode error frame: %v", err) } if !strings.Contains(msg.Message, "auth denied") { t.Fatalf("error message = %q, want auth denied", msg.Message) } } func sendFrame[T any](t *testing.T, tr protocol.Transport, typ protocol.FrameType, payload T) { t.Helper() f, err := protocol.NewFrame(typ, payload) if err != nil { t.Fatalf("frame %s: %v", typ, err) } if err := tr.Send(f); err != nil { t.Fatalf("send %s: %v", typ, err) } } func expectFrame(t *testing.T, tr protocol.Transport, typ protocol.FrameType) protocol.Frame { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { f, err, ok := recvFrameWithin(tr, time.Until(deadline)) if !ok { break } if err != nil { t.Fatalf("recv %s: %v", typ, err) } if f.Type == typ { return f } } t.Fatalf("frame %s not received", typ) return protocol.Frame{} } func expectChrome(t *testing.T, tr protocol.Transport) protocol.Chrome { t.Helper() f := expectFrame(t, tr, protocol.FrameChrome) chrome, err := protocol.Decode[protocol.Chrome](f) if err != nil { t.Fatalf("decode chrome: %v", err) } return chrome } func waitForLifecycle(t *testing.T, tr protocol.Transport, kind protocol.LifecycleKind, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { f, err, ok := recvFrameWithin(tr, time.Until(deadline)) if !ok { break } if err != nil { t.Fatalf("recv lifecycle: %v", err) } if f.Type != protocol.FrameLifecycle { continue } msg, err := protocol.Decode[protocol.Lifecycle](f) if err != nil { t.Fatalf("decode lifecycle: %v", err) } if msg.Kind == kind { return } } t.Fatalf("lifecycle %s not received", kind) } func recvFrameWithin(tr protocol.Transport, timeout time.Duration) (protocol.Frame, error, bool) { type result struct { f protocol.Frame err error } ch := make(chan result, 1) go func() { f, err := tr.Recv() ch <- result{f: f, err: err} }() select { case r := <-ch: return r.f, r.err, true case <-time.After(timeout): return protocol.Frame{}, nil, false } } func chromeHasProcess(chrome protocol.Chrome, name string) bool { var model struct { Processes []childModel `json:"processes"` } if err := json.Unmarshal(chrome.Model, &model); err != nil { return false } for _, p := range model.Processes { if p.Name == name { return true } } return false }