214 lines
5.4 KiB
Go
214 lines
5.4 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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 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 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
|
|
}
|