Files
patterm/internal/harness/restart_persist_test.go

188 lines
5.5 KiB
Go

package harness
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
pkgpty "github.com/hjbdev/patterm/internal/pty"
"github.com/hjbdev/patterm/internal/vt"
)
// TestRestartRestoresUserCommandProcess verifies that a process the
// user spawned in one patterm run reappears after the binary is
// restarted against the same XDG dirs / project dir. SPEC §2 keeps
// runs ephemeral except for the persisted-process state file:
// processes.json under $XDG_DATA_HOME/patterm/projects/<key>/.
func TestRestartRestoresUserCommandProcess(t *testing.T) {
if testing.Short() {
t.Skip("skipping end-to-end restart test in short mode")
}
sc := &Scenario{
Name: "restart_persist",
Cols: 120,
Rows: 40,
Trust: []string{"persist-target"},
Presets: ScenarioPresets{
Processes: []ScenarioPreset{{
Name: "persist-target",
Argv: []string{"persist-target"},
}},
},
Scripts: []ScenarioScript{{
Name: "persist-target",
Body: "#!/bin/sh\necho RESTORED\nsleep 30\n",
}},
}
env, childEnv, err := prepareEnv(Options{Scenario: sc})
if err != nil {
t.Fatalf("prepareEnv: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(env.Root) })
// ── Session 1 — spawn the process via MCP. ──────────────────
s1 := openSession(t, env, childEnv)
spawnRaw, err := s1.MCPCall("spawn_process", mustJSON(t, map[string]any{
"preset": "persist-target",
}))
if err != nil {
_ = s1.Close()
t.Fatalf("spawn_process: %v", err)
}
var spawned map[string]any
if err := json.Unmarshal(spawnRaw, &spawned); err != nil {
_ = s1.Close()
t.Fatalf("decode spawn: %v", err)
}
if id, _ := spawned["process_id"].(string); id == "" {
_ = s1.Close()
t.Fatalf("spawn returned no process_id: %s", string(spawnRaw))
}
if err := waitForListEntry(s1, "persist-target", 3*time.Second); err != nil {
_ = s1.Close()
t.Fatalf("list_processes (session 1): %v", err)
}
// Verify the on-disk record exists before tearing down.
stateFile := filepath.Join(env.DataHome, "patterm", "projects")
if entries, err := os.ReadDir(stateFile); err != nil || len(entries) == 0 {
_ = s1.Close()
t.Fatalf("expected per-project state dir under %s before shutdown: err=%v entries=%v", stateFile, err, entries)
}
if err := s1.Close(); err != nil {
t.Fatalf("close session 1: %v", err)
}
// ── Session 2 — same env, same project. The persisted entry
// must be replayed and show up in list_processes again. ─────
s2 := openSession(t, env, childEnv)
t.Cleanup(func() { _ = s2.Close() })
if err := waitForListEntry(s2, "persist-target", 5*time.Second); err != nil {
t.Fatalf("list_processes (session 2): %v", err)
}
// Closing the restored process should also drop it from the
// persist store, so a third session starts clean.
listRaw, err := s2.MCPCall("list_processes", json.RawMessage(`{}`))
if err != nil {
t.Fatalf("list_processes: %v", err)
}
var list []map[string]any
if err := json.Unmarshal(listRaw, &list); err != nil {
t.Fatalf("decode list: %v", err)
}
var restoredID string
for _, p := range list {
if name, _ := p["name"].(string); name == "persist-target" {
restoredID, _ = p["process_id"].(string)
break
}
}
if restoredID == "" {
t.Fatalf("restored process missing id in list: %s", string(listRaw))
}
if _, err := s2.MCPCall("close_process", mustJSON(t, map[string]any{
"process_id": restoredID,
})); err != nil {
t.Fatalf("close_process: %v", err)
}
if err := s2.Close(); err != nil {
t.Fatalf("close session 2: %v", err)
}
s3 := openSession(t, env, childEnv)
t.Cleanup(func() { _ = s3.Close() })
listRaw, err = s3.MCPCall("list_processes", json.RawMessage(`{}`))
if err != nil {
t.Fatalf("list_processes (session 3): %v", err)
}
if err := json.Unmarshal(listRaw, &list); err != nil {
t.Fatalf("decode list 3: %v", err)
}
for _, p := range list {
if name, _ := p["name"].(string); name == "persist-target" {
t.Fatalf("closed process re-appeared in session 3: %s", string(listRaw))
}
}
}
// openSession spawns one patterm process against the supplied env and
// blocks until its MCP socket is ready. Mirrors NewCLI but skips
// prepareEnv so multiple sessions can share the same XDG dirs.
func openSession(t *testing.T, env *testEnv, childEnv []string) *Session {
t.Helper()
em, err := vt.NewGhosttyEmulator(env.Cols, env.Rows)
if err != nil {
t.Fatalf("vt emulator: %v", err)
}
p, err := pkgpty.Start([]string{env.PattermBin, "--in-process", "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows)
if err != nil {
_ = em.Close()
t.Fatalf("pty start: %v", err)
}
em.OnWritePTY(func(b []byte) { _, _ = p.Write(b) })
s := &Session{pty: p, em: em, env: env, readerDone: make(chan struct{})}
go s.readLoop()
if err := s.bootstrapMCP(3 * time.Second); err != nil {
_ = s.Close()
t.Fatalf("mcp bootstrap: %v", err)
}
return s
}
func waitForListEntry(s *Session, name string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
raw, err := s.MCPCall("list_processes", json.RawMessage(`{}`))
if err == nil {
var list []map[string]any
if err := json.Unmarshal(raw, &list); err == nil {
for _, p := range list {
if n, _ := p["name"].(string); n == name {
return nil
}
}
}
}
time.Sleep(50 * time.Millisecond)
}
return fmt.Errorf("process %q never appeared in list_processes within %s", name, timeout)
}
func mustJSON(t *testing.T, v any) json.RawMessage {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}