From f74009231e857e66d1b119f54872cd3d22620af6 Mon Sep 17 00:00:00 2001 From: verse91 Date: Mon, 24 Aug 2026 18:04:39 +0700 Subject: [PATCH 1/5] test(tui): drive iris in a real pty with tuitest --- go.mod | 8 +- go.sum | 12 +++ tests/tui/box_test.go | 58 ++++++++++++ tests/tui/harness_test.go | 171 +++++++++++++++++++++++++++++++++++ tests/tui/longcmd_test.go | 97 ++++++++++++++++++++ tests/tui/nav_test.go | 126 ++++++++++++++++++++++++++ tests/tui/stress_test.go | 182 ++++++++++++++++++++++++++++++++++++++ tests/tui/wrap_test.go | 82 +++++++++++++++++ 8 files changed, 734 insertions(+), 2 deletions(-) create mode 100644 tests/tui/box_test.go create mode 100644 tests/tui/harness_test.go create mode 100644 tests/tui/longcmd_test.go create mode 100644 tests/tui/nav_test.go create mode 100644 tests/tui/stress_test.go create mode 100644 tests/tui/wrap_test.go diff --git a/go.mod b/go.mod index f76dd95..9300ed2 100644 --- a/go.mod +++ b/go.mod @@ -17,17 +17,21 @@ require ( ) require ( + github.com/Gaurav-Gosain/tuitest v0.0.0-20260821151822-797f5bbc19a8 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/conpty v0.1.1 // indirect + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/charmbracelet/x/xpty v0.1.3 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect @@ -50,7 +54,7 @@ require ( github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/sync v0.20.0 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/text v0.30.0 // indirect modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index c6717fb..1159364 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Gaurav-Gosain/tuitest v0.0.0-20260821151822-797f5bbc19a8 h1:sLrXcnA4d2IQiVZETrUEQ40pdAGncgsIj986+51pk/g= +github.com/Gaurav-Gosain/tuitest v0.0.0-20260821151822-797f5bbc19a8/go.mod h1:vtcfxGuFOpCyh2XJmdkPz9dHiFYvavMed441WWK3O/A= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= @@ -22,10 +24,16 @@ github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0r github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= @@ -36,6 +44,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -113,6 +123,8 @@ golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/tests/tui/box_test.go b/tests/tui/box_test.go new file mode 100644 index 0000000..ce4650b --- /dev/null +++ b/tests/tui/box_test.go @@ -0,0 +1,58 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/Gaurav-Gosain/tuitest" +) + +// box describes the suggestion box as it actually appears on screen. +type box struct { + top int + bottom int + items int + col int + rows []string +} + +func findBox(term *tuitest.Terminal) box { + var b box + b.top, b.bottom, b.col = -1, -1, -1 + for i, line := range strings.Split(term.Snapshot(), "\n") { + trimmed := strings.TrimRight(line, " ") + switch { + case strings.Contains(trimmed, "╭"): + b.top = i + b.col = strings.Index(trimmed, "╭") + case strings.Contains(trimmed, "╰"): + b.bottom = i + case strings.Contains(trimmed, "│"): + b.items++ + default: + continue + } + b.rows = append(b.rows, trimmed) + } + return b +} + +// assertBoxIntact checks the box is a single whole rectangle. A box drawn +// against a stale cursor row loses its top rows to the shell's repaint, and one +// left behind by a line that stopped wrapping shows up as extra pieces. +func assertBoxIntact(t *testing.T, term *tuitest.Terminal, wantItems int, stage string) { + t.Helper() + b := findBox(term) + + switch { + case b.top < 0: + t.Errorf("%s: box has no top border\n%s", stage, screen(term)) + case b.bottom < 0: + t.Errorf("%s: box has no bottom border\n%s", stage, screen(term)) + case b.items != wantItems: + t.Errorf("%s: box shows %d item rows, want %d\n%s", stage, b.items, wantItems, screen(term)) + case b.bottom-b.top != wantItems+1: + t.Errorf("%s: box spans rows %d..%d, not one contiguous block of %d\n%s", + stage, b.top, b.bottom, wantItems+2, screen(term)) + } +} diff --git a/tests/tui/harness_test.go b/tests/tui/harness_test.go new file mode 100644 index 0000000..f1f44b4 --- /dev/null +++ b/tests/tui/harness_test.go @@ -0,0 +1,171 @@ +package tui + +import ( + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Gaurav-Gosain/tuitest" +) + +const ( + cols = 197 + rows = 24 +) + +var ( + buildOnce sync.Once + irisBin string + buildErr error +) + +// binary builds iris once per run. The tests drive the real wrapper, so there +// is no substitute for the actual binary. +func binary(t *testing.T) string { + t.Helper() + // IRIS_TUI_BIN points the tests at a binary built elsewhere, so the same + // scenarios can be replayed against an older commit to confirm they fail + if custom := os.Getenv("IRIS_TUI_BIN"); custom != "" { + return custom + } + buildOnce.Do(func() { + // go test caches results per package, and it cannot see that these + // tests build and run a binary. Reading the sources registers them as + // test inputs so editing any of them invalidates the cached result. + registerSourcesAsInputs(t) + + dir, err := os.MkdirTemp("", "iris-tui-*") + if err != nil { + buildErr = err + return + } + irisBin = filepath.Join(dir, "iris") + cmd := exec.Command("go", "build", "-o", irisBin, "github.com/versenilvis/iris/cmd/iris") + cmd.Dir = repoRoot(t) + if out, err := cmd.CombinedOutput(); err != nil { + buildErr = err + t.Logf("go build: %s", out) + } + }) + if buildErr != nil { + t.Fatalf("building iris: %v", buildErr) + } + return irisBin +} + +func registerSourcesAsInputs(t *testing.T) { + t.Helper() + root := repoRoot(t) + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + switch d.Name() { + case ".git", "testdata", "docs", "dist": + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(path, ".go") { + _, _ = os.ReadFile(path) + } + return nil + }) +} + +func repoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + return filepath.Clean(filepath.Join(wd, "..", "..")) +} + +// start brings up iris wrapping zsh on its own pty, with every path pointed at +// a temp dir so the test never reads or writes the developer's real config, +// state or history. +func start(t *testing.T, extraEnv ...string) *tuitest.Terminal { + t.Helper() + + home := t.TempDir() + for _, sub := range []string{".config/iris", ".local/share/iris", ".cache"} { + if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { + t.Fatal(err) + } + } + return startIn(t, home, extraEnv...) +} + +func startIn(t *testing.T, home string, extraEnv ...string) *tuitest.Terminal { + t.Helper() + + if _, err := exec.LookPath("zsh"); err != nil { + t.Skip("zsh not installed") + } + + bin := binary(t) + + // a bare prompt keeps the geometry assertions readable, and the iris + // integration has to be sourced the way a real .zshrc sources it + prompt := os.Getenv("IRIS_TUI_PROMPT") + if prompt == "" { + prompt = "> " + } + zshrc := "PROMPT='" + prompt + "'\nRPROMPT=''\nunsetopt PROMPT_SP\neval \"$(" + bin + " init zsh)\"\n" + if err := os.WriteFile(filepath.Join(home, ".zshrc"), []byte(zshrc), 0o644); err != nil { + t.Fatal(err) + } + + env := []string{ + "HOME=" + home, + "ZDOTDIR=" + home, + "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"), + "XDG_DATA_HOME=" + filepath.Join(home, ".local/share"), + "XDG_CACHE_HOME=" + filepath.Join(home, ".cache"), + "SHELL=/bin/zsh", + "IRIS_ACTIVE_SHELL=zsh", + "PATH=" + os.Getenv("PATH"), + "TERM=xterm-256color", + } + env = append(env, extraEnv...) + + term := tuitest.StartT(t, []string{bin}, + tuitest.WithSize(cols, rows), + tuitest.WithEnv(env...), + tuitest.WithDir(home), + ) + + if err := term.WaitForText(">", 20*time.Second); err != nil { + t.Fatalf("iris never reached a prompt: %v\n%s", err, term.Snapshot()) + } + return term +} + +// screen returns the visible screen with trailing blank lines and trailing +// spaces removed, which is what the assertions care about. +func screen(term *tuitest.Terminal) string { + lines := strings.Split(term.Snapshot(), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + for len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return strings.Join(lines, "\n") +} + +func TestIrisStartsAndShowsAPrompt(t *testing.T) { + term := start(t) + defer func() { _ = term.Close() }() + + if got := screen(term); !strings.Contains(got, ">") { + t.Fatalf("no prompt on screen:\n%s", got) + } +} diff --git a/tests/tui/longcmd_test.go b/tests/tui/longcmd_test.go new file mode 100644 index 0000000..b8c5cbe --- /dev/null +++ b/tests/tui/longcmd_test.go @@ -0,0 +1,97 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Gaurav-Gosain/tuitest" +) + +// hangReport is the command from the bug report, kept verbatim: it wraps onto +// three rows on a wide terminal and mixes quotes, box drawing runes and +// backslash escapes. +const hangReport = `sleep 1; { echo "═══ ps ═══"; ps -eo pid,ppid,pgid,stat,wchan:20,etimes,cmd | grep -Ei "iris|zsh|tmux" | grep -v grep; echo "═══ env of hung shell ═══"; for p in $(pgrep -x zsh); do echo "-- pid $p --"; tr "\0" "\n" < /proc/$p/environ 2>/dev/null | grep -E "^(IRIS_|TMUX|FF_SHOWN)"; done; echo "═══ dump ═══"; cat ~/iris-hang-dump.log; } > ~/iris-hang-report.txt 2>&1` + +// TestLongCommandRendersIntact reproduces the reported screen: a wide terminal, +// the command wrapped onto three rows, and the menu open underneath. +func TestLongCommandRendersIntact(t *testing.T) { + home := t.TempDir() + for _, sub := range []string{".config/iris", ".local/share/iris", ".cache"} { + if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { + t.Fatal(err) + } + } + var b strings.Builder + for _, e := range []string{ + "su -", "ssh build-host", hangReport, "systemctl status", + "sudo pacman -Syu", "sort -u notes.txt", + } { + b.WriteString(": 1700000000:0;" + e + "\n") + } + if err := os.WriteFile(filepath.Join(home, ".zsh_history"), []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + + env := []string{ + "IRIS_CORE_MODE=history", + "IRIS_UI_MAX_HEIGHT=6", + "HISTFILE=" + filepath.Join(home, ".zsh_history"), + } + if os.Getenv("IRIS_TUI_NO_GHOST") != "" { + env = append(env, "IRIS_UI_GHOST_TEXT=0") + } + term := startIn(t, home, env...) + defer func() { _ = term.Close() }() + + if err := term.Type("s"); err != nil { + t.Fatal(err) + } + if err := term.WaitForText("history", 10*time.Second); err != nil { + t.Fatalf("menu never appeared: %v\n%s", err, screen(term)) + } + if err := term.WaitStable(2 * time.Second); err != nil { + t.Fatal(err) + } + + // walk onto the long entry, which replaces the line and wraps it + for range 4 { + if err := term.SendKeys(tuitest.Down); err != nil { + t.Fatal(err) + } + if err := term.WaitStable(2 * time.Second); err != nil { + t.Fatal(err) + } + if strings.Contains(term.Snapshot(), "iris-hang-report.txt") { + break + } + } + assertBoxIntact(t, term, 6, "long command selected") + assertCommandIntact(t, term, "long command selected") +} + +// assertCommandIntact checks the shell's line still reads as it should. The +// overlay writes runs of spaces to erase its own ghost text, and one landing on +// the wrong row punches a hole straight through the command. +func assertCommandIntact(t *testing.T, term *tuitest.Terminal, stage string) { + t.Helper() + var typed strings.Builder + for _, line := range strings.Split(term.Snapshot(), "\n") { + if strings.ContainsAny(line, "╭╮╰╯│") { + break + } + typed.WriteString(strings.TrimRight(line, " ")) + } + flat := typed.String() + for _, fragment := range []string{ + "ps -eo pid,ppid,pgid,stat,wchan:20,etimes,cmd", + "~/iris-hang-report.txt 2>&1", + } { + if !strings.Contains(flat, fragment) { + t.Errorf("%s: command line lost %q:\n%s", stage, fragment, screen(term)) + return + } + } +} diff --git a/tests/tui/nav_test.go b/tests/tui/nav_test.go new file mode 100644 index 0000000..2134428 --- /dev/null +++ b/tests/tui/nav_test.go @@ -0,0 +1,126 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Gaurav-Gosain/tuitest" +) + +// TestNavigatingOntoALongEntryKeepsTheBoxWhole walks the history menu with the +// arrow keys across entries of very different lengths. Selecting one rewrites +// the shell's line, so the input jumps between one row and several and the box +// moves with the cursor. +func TestNavigatingOntoALongEntryKeepsTheBoxWhole(t *testing.T) { + home := t.TempDir() + for _, sub := range []string{".config/iris", ".local/share/iris", ".cache"} { + if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { + t.Fatal(err) + } + } + + long := "echo " + strings.Repeat("L", 260) + entries := []string{ + "echo short-one", + long, + "echo short-two", + "echo " + strings.Repeat("M", 130), + "echo short-three", + } + var b strings.Builder + for _, e := range entries { + b.WriteString(": 1700000000:0;" + e + "\n") + } + if err := os.WriteFile(filepath.Join(home, ".zsh_history"), []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + + term := startIn(t, home, + "IRIS_CORE_MODE=history", + "IRIS_UI_MAX_HEIGHT=5", + "HISTFILE="+filepath.Join(home, ".zsh_history"), + ) + defer func() { _ = term.Close() }() + + if err := term.Type("echo "); err != nil { + t.Fatal(err) + } + if err := term.WaitForText("history", 10*time.Second); err != nil { + t.Fatalf("menu never appeared: %v\n%s", err, screen(term)) + } + + for step := range 6 { + if err := term.SendKeys(tuitest.Down); err != nil { + t.Fatal(err) + } + if err := term.WaitStable(2 * time.Second); err != nil { + t.Fatal(err) + } + assertBoxIntact(t, term, 5, fmt.Sprintf("after Down x%d", step+1)) + if t.Failed() { + return + } + } +} + +// TestBoxHoldsItsColumnWhileNavigating walks the list and checks the box does +// not slide sideways. Each step rewrites the shell's line to the selected +// entry, and entries differ in length, so a box that follows the cursor jumps +// to a new column under the entry the user is trying to read. +func TestBoxHoldsItsColumnWhileNavigating(t *testing.T) { + home := t.TempDir() + for _, sub := range []string{".config/iris", ".local/share/iris", ".cache"} { + if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { + t.Fatal(err) + } + } + + var b strings.Builder + for _, e := range []string{ + "nvim ~/.config/iris/config.toml", + "nv ~/.zshrc", + "nvim ~/.config/opencode/opencode.json", + "nvim x", + "nvim ~/.local/share/iris/history.db", + } { + b.WriteString(": 1700000000:0;" + e + "\n") + } + if err := os.WriteFile(filepath.Join(home, ".zsh_history"), []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + + term := startIn(t, home, + "IRIS_CORE_MODE=history", + "IRIS_UI_MAX_HEIGHT=5", + "HISTFILE="+filepath.Join(home, ".zsh_history"), + ) + defer func() { _ = term.Close() }() + + if err := term.Type("nv"); err != nil { + t.Fatal(err) + } + if err := term.WaitForText("history", 10*time.Second); err != nil { + t.Fatalf("menu never appeared: %v\n%s", err, screen(term)) + } + if err := term.WaitStable(2 * time.Second); err != nil { + t.Fatal(err) + } + want := findBox(term).col + + for step := range 4 { + if err := term.SendKeys(tuitest.Down); err != nil { + t.Fatal(err) + } + if err := term.WaitStable(2 * time.Second); err != nil { + t.Fatal(err) + } + if got := findBox(term).col; got != want { + t.Fatalf("after Down x%d the box moved from column %d to %d:\n%s", + step+1, want, got, screen(term)) + } + } +} diff --git a/tests/tui/stress_test.go b/tests/tui/stress_test.go new file mode 100644 index 0000000..ea6038a --- /dev/null +++ b/tests/tui/stress_test.go @@ -0,0 +1,182 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Gaurav-Gosain/tuitest" +) + +// TestWalkingDeepIntoTheListKeepsTheBoxWhole walks a long history whose entries +// swing between one row and three, the way a real history does. Each step +// rewrites the shell's line, so the box moves up and down the screen and every +// step is a chance to leave part of the old one behind. +func TestWalkingDeepIntoTheListKeepsTheBoxWhole(t *testing.T) { + home := t.TempDir() + for _, sub := range []string{".config/iris", ".local/share/iris", ".cache"} { + if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { + t.Fatal(err) + } + } + + var b strings.Builder + for i := range 40 { + var entry string + switch i % 4 { + case 0: + entry = fmt.Sprintf("echo short-%02d", i) + case 1: + entry = "echo " + strings.Repeat("w", 120) + fmt.Sprintf("-%02d", i) + case 2: + entry = fmt.Sprintf("echo mid-%02d ", i) + strings.Repeat("m", 40) + case 3: + entry = "echo " + strings.Repeat("v", 260) + fmt.Sprintf("-%02d", i) + } + b.WriteString(": 1700000000:0;" + entry + "\n") + } + if err := os.WriteFile(filepath.Join(home, ".zsh_history"), []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + + term := startIn(t, home, + "IRIS_CORE_MODE=history", + "IRIS_UI_MAX_HEIGHT=6", + "HISTFILE="+filepath.Join(home, ".zsh_history"), + ) + defer func() { _ = term.Close() }() + + if err := term.Type("echo "); err != nil { + t.Fatal(err) + } + if err := term.WaitForText("history", 10*time.Second); err != nil { + t.Fatalf("menu never appeared: %v\n%s", err, screen(term)) + } + if err := term.WaitStable(2 * time.Second); err != nil { + t.Fatal(err) + } + + // held down, not tapped: the keys arrive faster than the shell repaints + for range 30 { + if err := term.SendKeys(tuitest.Down); err != nil { + t.Fatal(err) + } + time.Sleep(8 * time.Millisecond) + } + if err := term.WaitStable(3 * time.Second); err != nil { + t.Fatal(err) + } + assertBoxIntact(t, term, 6, "after holding Down") +} + +// TestShrinkingTheQueryLeavesNoFragment types until the line wraps, which puts +// the box two rows further down, then deletes back to one row. The list is +// rebuilt as the query changes, so the box that has to be erased is a different +// one from the box being drawn. +func TestShrinkingTheQueryLeavesNoFragment(t *testing.T) { + home := t.TempDir() + for _, sub := range []string{".config/iris", ".local/share/iris", ".cache"} { + if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { + t.Fatal(err) + } + } + + prefix := "echo " + strings.Repeat("p", 200) + var b strings.Builder + for i := range 8 { + b.WriteString(fmt.Sprintf(": 1700000000:0;%s-tail-%02d\n", prefix, i)) + } + if err := os.WriteFile(filepath.Join(home, ".zsh_history"), []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + + term := startIn(t, home, + "IRIS_CORE_MODE=history", + "IRIS_UI_MAX_HEIGHT=6", + "HISTFILE="+filepath.Join(home, ".zsh_history"), + ) + defer func() { _ = term.Close() }() + + if err := term.Type(prefix[:150]); err != nil { + t.Fatal(err) + } + if err := term.WaitForText("history", 10*time.Second); err != nil { + t.Fatalf("menu never appeared: %v\n%s", err, screen(term)) + } + if err := term.WaitStable(3 * time.Second); err != nil { + t.Fatal(err) + } + assertBoxIntact(t, term, 6, "query wrapped onto two rows") + + // delete back onto a single row + for range 130 { + if err := term.SendKeys(tuitest.Backspace); err != nil { + t.Fatal(err) + } + time.Sleep(6 * time.Millisecond) + } + if err := term.WaitStable(3 * time.Second); err != nil { + t.Fatal(err) + } + assertBoxIntact(t, term, 6, "query back on one row") +} + +// TestReloadDoesNotStrandTheBox reloads while the menu is on screen. The reload +// replaces the process, so the one that comes back has no record of the box the +// old one drew and cannot erase it. +func TestReloadDoesNotStrandTheBox(t *testing.T) { + home := t.TempDir() + for _, sub := range []string{".config/iris", ".local/share/iris", ".cache"} { + if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { + t.Fatal(err) + } + } + var b strings.Builder + for _, e := range []string{ + "nvim ~/.config/", "nvim ~/.config/iris/", "nvim ~/.config/opencode/", + "nvim ~/.config/iris/config.toml", "nvim a.cppp", "nv a.go", "nv a.cpp", + } { + b.WriteString(": 1700000000:0;" + e + "\n") + } + if err := os.WriteFile(filepath.Join(home, ".zsh_history"), []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + + term := startIn(t, home, + "IRIS_CORE_MODE=history", + "IRIS_UI_MAX_HEIGHT=6", + "HISTFILE="+filepath.Join(home, ".zsh_history"), + ) + defer func() { _ = term.Close() }() + + if err := term.Type("nvim ~/.config/"); err != nil { + t.Fatal(err) + } + if err := term.WaitForText("history", 10*time.Second); err != nil { + t.Fatalf("menu never appeared: %v\n%s", err, screen(term)) + } + if err := term.WaitStable(2 * time.Second); err != nil { + t.Fatal(err) + } + + // clear the line and reload with the box still up + if err := term.SendKeys(tuitest.Ctrl('u')); err != nil { + t.Fatal(err) + } + if err := term.Type("iris reload"); err != nil { + t.Fatal(err) + } + if err := term.SendKeys(tuitest.Enter); err != nil { + t.Fatal(err) + } + if err := term.WaitStable(5 * time.Second); err != nil { + t.Fatal(err) + } + + if got := screen(term); strings.ContainsAny(got, "╭╮╰╯│") { + t.Errorf("a box survived the reload:\n%s", got) + } +} diff --git a/tests/tui/wrap_test.go b/tests/tui/wrap_test.go new file mode 100644 index 0000000..96e8eb9 --- /dev/null +++ b/tests/tui/wrap_test.go @@ -0,0 +1,82 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Gaurav-Gosain/tuitest" +) + +// seedHistory writes a zsh history file whose entries share a prefix long +// enough to wrap the prompt onto several rows while the menu stays populated. +func seedHistory(t *testing.T, home, prefix string) { + t.Helper() + var b strings.Builder + for _, suffix := range []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot"} { + b.WriteString(": 1700000000:0;" + prefix + suffix + "\n") + } + if err := os.WriteFile(filepath.Join(home, ".zsh_history"), []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } +} + +func startWithHistory(t *testing.T, prefix string) *tuitest.Terminal { + t.Helper() + home := t.TempDir() + for _, sub := range []string{".config/iris", ".local/share/iris", ".cache"} { + if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { + t.Fatal(err) + } + } + seedHistory(t, home, prefix) + return startIn(t, home, + "IRIS_CORE_MODE=history", + "IRIS_UI_MAX_HEIGHT=5", + "IRIS_UI_GHOST_TEXT=0", + "HISTFILE="+filepath.Join(home, ".zsh_history"), + ) +} + +// TestBoxStaysWholeWhenTheCommandWraps types past the width of the terminal so +// the input takes several rows, then deletes back across the boundary. The box +// hangs off the cursor, so both directions move it and anything the new +// position does not cover is left on screen. +func TestBoxStaysWholeWhenTheCommandWraps(t *testing.T) { + prefix := "echo " + strings.Repeat("z", 300) + " " + term := startWithHistory(t, prefix) + defer func() { _ = term.Close() }() + + if err := term.Type(prefix[:40]); err != nil { + t.Fatal(err) + } + if err := term.WaitForText("history", 10*time.Second); err != nil { + t.Fatalf("menu never appeared: %v\n%s", err, screen(term)) + } + if err := term.WaitStable(2 * time.Second); err != nil { + t.Fatal(err) + } + assertBoxIntact(t, term, 5, "one row of input") + + // grow the input across two wrap boundaries + if err := term.Type(prefix[40:250]); err != nil { + t.Fatal(err) + } + if err := term.WaitStable(3 * time.Second); err != nil { + t.Fatal(err) + } + assertBoxIntact(t, term, 5, "three rows of input") + + // and back to one row + for range 210 { + if err := term.SendKeys(tuitest.Backspace); err != nil { + t.Fatal(err) + } + } + if err := term.WaitStable(3 * time.Second); err != nil { + t.Fatal(err) + } + assertBoxIntact(t, term, 5, "back to one row") +} From 2add2a162648242aca8e4926dd3450e4cea7fea2 Mon Sep 17 00:00:00 2001 From: verse91 Date: Mon, 24 Aug 2026 18:09:41 +0700 Subject: [PATCH 2/5] test(tui): rename a fixture entry the typos check rejects --- tests/tui/stress_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tui/stress_test.go b/tests/tui/stress_test.go index ea6038a..feee60c 100644 --- a/tests/tui/stress_test.go +++ b/tests/tui/stress_test.go @@ -137,7 +137,7 @@ func TestReloadDoesNotStrandTheBox(t *testing.T) { var b strings.Builder for _, e := range []string{ "nvim ~/.config/", "nvim ~/.config/iris/", "nvim ~/.config/opencode/", - "nvim ~/.config/iris/config.toml", "nvim a.cppp", "nv a.go", "nv a.cpp", + "nvim ~/.config/iris/config.toml", "nvim a.cxx", "nv a.go", "nv a.cpp", } { b.WriteString(": 1700000000:0;" + e + "\n") } From e3b7dfb68feca17742c99a0568f623177176f81d Mon Sep 17 00:00:00 2001 From: verse91 Date: Mon, 24 Aug 2026 18:12:22 +0700 Subject: [PATCH 3/5] build(deps): mark tuitest as a direct dependency --- go.mod | 2 +- go.sum | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 9300ed2..818ec11 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( charm.land/lipgloss/v2 v2.0.5 github.com/BurntSushi/toml v1.6.0 + github.com/Gaurav-Gosain/tuitest v0.0.0-20260821151822-797f5bbc19a8 github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/x/ansi v0.11.7 github.com/creack/pty v1.1.24 @@ -17,7 +18,6 @@ require ( ) require ( - github.com/Gaurav-Gosain/tuitest v0.0.0-20260821151822-797f5bbc19a8 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect diff --git a/go.sum b/go.sum index 1159364..57bc4e0 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,6 @@ github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WK github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= -github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= @@ -121,8 +119,6 @@ golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From 0bbab879411728407901171d912b4a30d9ebb5be Mon Sep 17 00:00:00 2001 From: verse91 Date: Mon, 24 Aug 2026 18:16:29 +0700 Subject: [PATCH 4/5] test(tui): satisfy errname, noctx and staticcheck --- tests/tui/harness_test.go | 15 +++++++++------ tests/tui/stress_test.go | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/tui/harness_test.go b/tests/tui/harness_test.go index f1f44b4..02b7807 100644 --- a/tests/tui/harness_test.go +++ b/tests/tui/harness_test.go @@ -1,6 +1,7 @@ package tui import ( + "context" "io/fs" "os" "os/exec" @@ -21,7 +22,7 @@ const ( var ( buildOnce sync.Once irisBin string - buildErr error + errBuild error ) // binary builds iris once per run. The tests drive the real wrapper, so there @@ -41,19 +42,21 @@ func binary(t *testing.T) string { dir, err := os.MkdirTemp("", "iris-tui-*") if err != nil { - buildErr = err + errBuild = err return } irisBin = filepath.Join(dir, "iris") - cmd := exec.Command("go", "build", "-o", irisBin, "github.com/versenilvis/iris/cmd/iris") + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "build", "-o", irisBin, "github.com/versenilvis/iris/cmd/iris") cmd.Dir = repoRoot(t) if out, err := cmd.CombinedOutput(); err != nil { - buildErr = err + errBuild = err t.Logf("go build: %s", out) } }) - if buildErr != nil { - t.Fatalf("building iris: %v", buildErr) + if errBuild != nil { + t.Fatalf("building iris: %v", errBuild) } return irisBin } diff --git a/tests/tui/stress_test.go b/tests/tui/stress_test.go index feee60c..4deacb5 100644 --- a/tests/tui/stress_test.go +++ b/tests/tui/stress_test.go @@ -87,7 +87,7 @@ func TestShrinkingTheQueryLeavesNoFragment(t *testing.T) { prefix := "echo " + strings.Repeat("p", 200) var b strings.Builder for i := range 8 { - b.WriteString(fmt.Sprintf(": 1700000000:0;%s-tail-%02d\n", prefix, i)) + fmt.Fprintf(&b, ": 1700000000:0;%s-tail-%02d\n", prefix, i) } if err := os.WriteFile(filepath.Join(home, ".zsh_history"), []byte(b.String()), 0o644); err != nil { t.Fatal(err) From 8020452320d9650573c8ecc998a7fa9cc3dbf4b7 Mon Sep 17 00:00:00 2001 From: verse91 Date: Mon, 24 Aug 2026 18:16:29 +0700 Subject: [PATCH 5/5] build(just): pin the toolchain golangci-lint type checks against --- justfiles/test.just | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/justfiles/test.just b/justfiles/test.just index 0e2fc1f..84d8243 100644 --- a/justfiles/test.just +++ b/justfiles/test.just @@ -9,7 +9,12 @@ alias ana := analyze analyze: @go run scripts/test_analyzer.go +# golangci-lint type checks with the go/types it was built against, so a newer +# toolchain on PATH makes it panic on the standard library rather than report +# anything. Override when your golangci-lint was built against a different Go. +lint-toolchain := env('IRIS_LINT_TOOLCHAIN', 'go1.26.5') + # run linter [group('test')] lint: - @golangci-lint run ./... + @GOTOOLCHAIN={{lint-toolchain}} golangci-lint run ./...