Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,12 +23,15 @@ require (
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
Expand All @@ -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
Expand Down
16 changes: 12 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -20,12 +22,16 @@ 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=
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=
Expand All @@ -36,6 +42,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=
Expand Down Expand Up @@ -111,8 +119,8 @@ 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=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
Expand Down
7 changes: 6 additions & 1 deletion justfiles/test.just
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
58 changes: 58 additions & 0 deletions tests/tui/box_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
174 changes: 174 additions & 0 deletions tests/tui/harness_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package tui

import (
"context"
"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
errBuild 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 {
errBuild = err
return
}
irisBin = filepath.Join(dir, "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 {
errBuild = err
t.Logf("go build: %s", out)
}
})
if errBuild != nil {
t.Fatalf("building iris: %v", errBuild)
}
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)
}
}
Loading
Loading