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//. 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 }