This commit is contained in:
2026-05-15 00:28:06 +01:00
parent 2f969fa215
commit 0d578d54f1
31 changed files with 3209 additions and 164 deletions

View File

@@ -30,10 +30,29 @@ func EncodeChord(name string) ([]byte, error) {
return []byte{0x10}, nil
case "ctrl-u":
return []byte{0x15}, nil
case "ctrl-a":
return []byte{0x01}, nil
case "ctrl-d":
return []byte{0x04}, nil
case "ctrl-s":
return []byte{0x13}, nil
case "ctrl-w":
return []byte{0x17}, nil
case "ctrl-r":
return []byte{0x12}, nil
case "ctrl-b":
return []byte{0x02}, nil
case "tab":
return []byte{'\t'}, nil
case "space":
return []byte{' '}, nil
case "wheel-up":
// SGR-encoded scroll-wheel up at row/col 1,1. patterm enables
// 1006 mouse mode while a scratchpad is focused, so this is the
// form the host terminal would deliver.
return []byte("\x1b[<64;1;1M"), nil
case "wheel-down":
return []byte("\x1b[<65;1;1M"), nil
}
return nil, fmt.Errorf("unknown chord %q", name)
}

View 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
}

View File

@@ -0,0 +1,32 @@
{
"name": "chrome_survives_origin_mode",
"cols": 120,
"rows": 40,
"scripts": [
{
"name": "origin-mode",
"body": "#!/bin/sh\n# Child TUIs are allowed to use DEC origin mode internally, but the\n# host chrome must never inherit it. If CSI ? 6 h reaches the real\n# terminal, patterm's absolute CUPs for the tab bar/status/sidebar are\n# interpreted relative to the child scroll region and chrome appears\n# inside the viewport.\nprintf 'ORIGIN READY\\n'\nsleep 0.1\nprintf '\\033[5;20r'\nprintf '\\033[?6h'\nprintf '\\033[1;1HORIGIN MODE ACTIVE\\n'\nsleep 0.2\nprintf 'ORIGIN DONE\\n'\nsleep 5\n"
}
],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": { "kind": "command", "argv": ["origin-mode"], "name": "origin-mode" }
},
{ "type": "wait_text", "contains": "ORIGIN DONE", "timeout_ms": 5000 },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "+ new" },
{ "type": "assert_contains", "contains": "Processes" },
{ "type": "assert_contains", "contains": "Agent Tree" },
{ "type": "assert_contains", "contains": "Scratchpads" },
{
"type": "assert_regex",
"regex": "(?m)^[^\\n]*\\+ new[^\\n]*Processes[^\\n]*$"
},
{
"type": "assert_regex",
"regex": "(?m)^origin-mode · you have control[^\\n]*Ctrl-K · palette[^\\n]*$"
}
]
}

View File

@@ -0,0 +1,18 @@
{
"name": "scratchpad_focus",
"cols": 120,
"rows": 40,
"steps": [
{
"type": "mcp_call",
"method": "scratchpad_write",
"params": { "name": "notes.md", "content": "# Heading One\n\n- item alpha\n- item beta\n\nhello scratchpad" }
},
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "notes.md" },
{ "type": "send_chord", "chord": "ctrl-s" },
{ "type": "wait_text", "contains": "hello scratchpad", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "Heading One" },
{ "type": "assert_contains", "contains": "item alpha" }
]
}

View File

@@ -0,0 +1,40 @@
{
"name": "scratchpad_scroll",
"cols": 120,
"rows": 20,
"steps": [
{
"type": "mcp_call",
"method": "scratchpad_write",
"params": {
"name": "long.md",
"content": "# Long pad\n\nline-01\nline-02\nline-03\nline-04\nline-05\nline-06\nline-07\nline-08\nline-09\nline-10\nline-11\nline-12\nline-13\nline-14\nline-15\nline-16\nline-17\nline-18\nline-19\nline-20\nline-21\nline-22\nline-23\nline-24\nline-25\nline-26\nline-27\nline-28\nline-29\nline-30\nfinal-marker"
}
},
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "send_chord", "chord": "ctrl-s" },
{ "type": "wait_text", "contains": "line-01", "timeout_ms": 5000 },
{ "type": "assert_not_contains", "contains": "final-marker" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "wait_text", "contains": "final-marker", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "final-marker" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "wait_text", "contains": "line-01", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "line-01" }
]
}