wip
This commit is contained in:
187
internal/harness/restart_persist_test.go
Normal file
187
internal/harness/restart_persist_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user