package app import ( "context" "encoding/json" "io" "net" "os" "path/filepath" "strings" "testing" "time" "github.com/hjbdev/patterm/internal/preset" "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 TestDaemonPaneDisplayOwnerSizing(t *testing.T) { t.Setenv("XDG_DATA_HOME", t.TempDir()) t.Setenv("XDG_CONFIG_HOME", t.TempDir()) projectDir := t.TempDir() ctx, cancel := context.WithCancel(context.Background()) defer cancel() reg := newProjectRegistry(preset.Set{}, defaultSettings(), nil, 80, 24) defer reg.Shutdown() project, err := reg.Open(ctx, projectDir) if err != nil { t.Fatalf("open project: %v", err) } client1, daemon1 := protocol.NewLoopbackPair() go handleDaemonConn(ctx, cancel, reg, daemon1, "") 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 sleep 1; done"}, "name": "owner-pane", }) sendFrame(t, client1, protocol.FramePaletteCommand, protocol.PaletteCommand{ Kind: "spawn_command", Data: data, }) paneID := waitForLifecycleID(t, client1, protocol.LifecycleSpawned, 3*time.Second) snap1 := waitForSnapshot(t, client1, paneID, 3*time.Second) if !snap1.DisplayOwner || snap1.Size != (protocol.Size{Cols: 80, Rows: 24}) { t.Fatalf("owner snapshot = owner:%v size:%+v, want owner true size 80x24", snap1.DisplayOwner, snap1.Size) } waitForEmulatorSize(t, project, paneID, 80, 24) client2, daemon2 := protocol.NewLoopbackPair() go handleDaemonConn(ctx, cancel, reg, daemon2, "") sendFrame(t, client2, protocol.FrameAttach, protocol.Attach{ ProjectPath: projectDir, TermSize: protocol.Size{Cols: 100, Rows: 30}, }) expectFrame(t, client2, protocol.FrameHello) expectFrame(t, client2, protocol.FrameProjectList) expectFrame(t, client2, protocol.FrameChrome) snap2 := waitForSnapshot(t, client2, paneID, 3*time.Second) if snap2.DisplayOwner || snap2.Size != (protocol.Size{Cols: 80, Rows: 24}) { t.Fatalf("viewer snapshot = owner:%v size:%+v, want owner false size 80x24", snap2.DisplayOwner, snap2.Size) } sendFrame(t, client2, protocol.FrameResize, protocol.Resize{Size: protocol.Size{Cols: 100, Rows: 30}}) time.Sleep(100 * time.Millisecond) waitForEmulatorSize(t, project, paneID, 80, 24) sendFrame(t, client1, protocol.FrameDetach, protocol.Detach{}) _ = client1.Close() time.Sleep(100 * time.Millisecond) sendFrame(t, client2, protocol.FrameFocus, protocol.Focus{PaneID: paneID}) snap3 := waitForSnapshot(t, client2, paneID, 3*time.Second) if !snap3.DisplayOwner || snap3.Size != (protocol.Size{Cols: 100, Rows: 30}) { t.Fatalf("claimed snapshot = owner:%v size:%+v, want owner true size 100x30", snap3.DisplayOwner, snap3.Size) } waitForEmulatorSize(t, project, paneID, 100, 30) sendFrame(t, client2, protocol.FrameDetach, protocol.Detach{}) _ = client2.Close() } 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 waitForLifecycleID(t *testing.T, tr protocol.Transport, kind protocol.LifecycleKind, timeout time.Duration) string { 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 msg.ChildID } } t.Fatalf("lifecycle %s not received", kind) return "" } func waitForSnapshot(t *testing.T, tr protocol.Transport, paneID string, timeout time.Duration) protocol.PaneSnapshot { 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 snapshot: %v", err) } if f.Type != protocol.FramePaneSnapshot { continue } msg, err := protocol.Decode[protocol.PaneSnapshot](f) if err != nil { t.Fatalf("decode snapshot: %v", err) } if msg.PaneID == paneID { return msg } } t.Fatalf("snapshot for %s not received", paneID) return protocol.PaneSnapshot{} } func waitForEmulatorSize(t *testing.T, project *Project, paneID string, cols, rows uint16) { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { if c := project.Session.FindChild(paneID); c != nil { if em := c.Emulator(); em != nil { gotCols, gotRows := em.Size() if gotCols == cols && gotRows == rows { return } } } time.Sleep(25 * time.Millisecond) } if c := project.Session.FindChild(paneID); c != nil { if em := c.Emulator(); em != nil { gotCols, gotRows := em.Size() t.Fatalf("emulator size = %dx%d, want %dx%d", gotCols, gotRows, cols, rows) } } t.Fatalf("pane %s missing emulator", paneID) } 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 }