188 lines
5.5 KiB
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, "--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
|
|
}
|