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
86 changes: 86 additions & 0 deletions e2e/ticket_terminal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//go:build darwin || linux

package e2e

import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"

"github.com/creack/pty"
)

func TestREV010_TicketPickerInheritsTerminalWithoutShellRedirection(t *testing.T) {
s := newSandbox(t)
repo := s.repo()
herdr := s.fakeHerdr(repo)
s.tool("tickets", `
[ -t 0 ] && [ -t 2 ] || { printf 'picker needs terminal stdin/stderr\n' >&2; exit 2; }
size=$(/bin/stty size <&2)
[ "$size" = '30 100' ] || { printf 'incorrect dimensions: %s\n' "$size" >&2; exit 2; }
printf 'PICKER_READY %s\n' "$size" >&2
IFS= read -r selection
[ "$selection" = select ] || exit 23
printf '%s\n' '{"branchName":"test-123-selected","metadata":{"identifier":"TEST-123"}}'
`)
mustWrite(t, filepath.Join(repo, ".herdr-worktree.yaml"), "ticket_commands:\n default:\n command: [tickets, '{input}']\n", 0o600)
master, slave, err := pty.Open()
if err != nil {
t.Fatal(err)
}
defer slave.Close()
fd, err := syscall.Dup(int(master.Fd()))
_ = master.Close()
if err != nil {
t.Fatal(err)
}
syscall.CloseOnExec(fd)
if err := syscall.SetNonblock(fd, true); err != nil {
_ = syscall.Close(fd)
t.Fatal(err)
}
master = os.NewFile(uintptr(fd), "ticket-pty")
defer master.Close()
if err := master.SetReadDeadline(time.Now().Add(15 * time.Second)); err != nil {
t.Fatal(err)
}
if err := pty.Setsize(slave, &pty.Winsize{Rows: 30, Cols: 100}); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, hwtBinary, "--herdr-bin", herdr, "create", "--ticket", "--json")
cmd.Dir, cmd.Env = repo, s.env
cmd.Stdin, cmd.Stderr = slave, slave
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
cmd.WaitDelay = time.Second
var stdout strings.Builder
cmd.Stdout = &stdout
// This canonical-mode input is buffered until the fake picker reads it.
if _, err := io.WriteString(master, "select\n"); err != nil {
t.Fatal(err)
}
if err := cmd.Run(); err != nil {
t.Fatalf("ticket create failed: %v; stdout: %s", err, stdout.String())
}
result := decode(t, stdout.String())
if result["branch"] != "test-123-selected" {
t.Fatalf("selected branch = %#v", result)
}
gitDir := s.git(result["path"].(string), "rev-parse", "--git-dir")
metadata := mustRead(t, filepath.Join(gitDir, "hwt-ticket-metadata-v1.json"))
requireContains(t, metadata, "TEST-123")
ui := make([]byte, 4096)
n, err := master.Read(ui)
if err != nil {
t.Fatal(err)
}
requireContains(t, string(ui[:n]), "PICKER_READY 30 100")
}
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ go 1.25.0
require (
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
github.com/charmbracelet/x/term v0.2.2
github.com/creack/pty v1.1.24
github.com/spf13/cobra v1.10.2
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/sys v0.47.0
Expand All @@ -14,7 +16,6 @@ require (
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // 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/clipperhouse/displaywidth v0.11.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
Expand Down
16 changes: 14 additions & 2 deletions internal/worktree/ticket_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os/exec"
"strings"

"github.com/charmbracelet/x/term"
"github.com/dkarter/hwt/internal/config"
)

Expand All @@ -27,13 +28,24 @@ func runTicketCommandWithStderr(cwd string, ticket config.TicketCommand, input s
cmd.Stdin = os.Stdin
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = io.MultiWriter(terminalStderr, &stderr)
// exec.Cmd only inherits a file descriptor when the writer is an *os.File.
// Wrapping a terminal in MultiWriter creates a pipe instead, hiding terminal
// dimensions and preventing interactive pickers from rendering their UI.
terminalFile, isFile := terminalStderr.(*os.File)
interactiveStderr := isFile && term.IsTerminal(terminalFile.Fd())
if interactiveStderr {
cmd.Stderr = terminalFile
} else {
cmd.Stderr = io.MultiWriter(terminalStderr, &stderr)
}
if err := cmd.Run(); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return ticketResult{}, fmt.Errorf("find ticket command %q: %w", ticket.Command[0], err)
}
message := ""
if strings.TrimSpace(stderr.String()) == "" {
// Terminal diagnostics have already been displayed and are not captured;
// only use stdout as a fallback when we know stderr was empty.
if !interactiveStderr && strings.TrimSpace(stderr.String()) == "" {
message = strings.TrimSpace(stdout.String())
}
if message != "" {
Expand Down
209 changes: 209 additions & 0 deletions internal/worktree/ticket_command_pty_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//go:build darwin || linux

package worktree

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
"testing"
"time"

tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/term"
"github.com/creack/pty"
"github.com/dkarter/hwt/internal/config"
)

// Exercise both subprocess boundaries: HWT inherits terminal stdin/stderr,
// then launches a picker with captured JSON stdout and terminal UI on stderr.
func TestRunTicketCommandInteractiveTerminal(t *testing.T) {
for _, test := range []struct {
name string
key string
want string
}{
{name: "select", key: "\r", want: "feature/selected"},
{name: "cancel", key: "\x1b", want: "exit status 23"},
} {
t.Run(test.name, func(t *testing.T) {
master, slave, err := pty.Open()
if err != nil {
t.Fatal(err)
}
defer slave.Close()
// Register a nonblocking descriptor with Go's poller so deadlines
// and Close can interrupt the UI reader, including on Darwin.
fd, err := syscall.Dup(int(master.Fd()))
_ = master.Close()
if err != nil {
t.Fatal(err)
}
syscall.CloseOnExec(fd)
if err := syscall.SetNonblock(fd, true); err != nil {
_ = syscall.Close(fd)
t.Fatal(err)
}
master = os.NewFile(uintptr(fd), "picker-pty")
defer master.Close()
if err := pty.Setsize(slave, &pty.Winsize{Rows: 30, Cols: 100}); err != nil {
t.Fatal(err)
}
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
deadline, _ := ctx.Deadline()
if err := master.SetReadDeadline(deadline); err != nil {
t.Fatal(err)
}
cmd := exec.CommandContext(ctx, executable, "-test.run=^TestTicketPickerProcess$", "--", "transport")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// Cancel the picker as well as its transport parent on timeout.
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
cmd.WaitDelay = 2 * time.Second
cmd.Stdin, cmd.Stderr = slave, slave
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
// Read while the UI runs; wait for the measured size before sending input.
uiDone := make(chan string, 1)
go func() {
var ui strings.Builder
buf := make([]byte, 4096)
sent := false
for {
n, err := master.Read(buf)
ui.Write(buf[:n])
if !sent && strings.Contains(ui.String(), "PICKER_READY 100x30") {
_, _ = io.WriteString(master, test.key)
sent = true
}
if err != nil {
uiDone <- ui.String()
return
}
}
}()
waitErr := cmd.Wait()
_ = slave.Close()
// Darwin PTYs need the master closed to unblock a read after slave exit.
_ = master.Close()
ui := <-uiDone
if waitErr != nil {
t.Fatalf("transport: %v; UI: %q; stdout: %q", waitErr, ui, stdout.String())
}
if !strings.Contains(ui, "PICKER_READY 100x30") {
t.Fatalf("picker did not receive terminal dimensions: %q", ui)
}
var result struct {
Branch string `json:"branch"`
Metadata map[string]string `json:"metadata"`
Error string `json:"error"`
}
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("UI leaked into JSON stdout %q: %v", stdout.String(), err)
}
if test.name == "select" {
if result.Branch != test.want || result.Metadata["identifier"] != "TEST-123" || result.Error != "" {
t.Fatalf("selected result = %#v", result)
}
} else if !strings.Contains(result.Error, test.want) || result.Branch != "" {
t.Fatalf("cancellation result = %#v", result)
}
})
}
}

// Invoked only as a subprocess, with fake ticket data and no API access.
func TestTicketPickerProcess(t *testing.T) {
args := os.Args
if len(args) < 2 || args[len(args)-2] != "--" {
return
}
switch args[len(args)-1] {
case "transport":
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
result, err := runTicketCommand("", config.TicketCommand{
Command: []string{executable, "-test.run=^TestTicketPickerProcess$", "--", "picker"},
}, "")
output := map[string]any{"branch": result.BranchName, "metadata": result.Metadata}
if err != nil {
output["error"] = err.Error()
}
if err := json.NewEncoder(os.Stdout).Encode(output); err != nil {
t.Fatal(err)
}
os.Exit(0) // Keep the test runner's PASS line out of machine-readable stdout.
case "picker":
if !term.IsTerminal(os.Stdin.Fd()) || !term.IsTerminal(os.Stderr.Fd()) {
fmt.Fprintln(os.Stderr, "picker stdin/stderr must be terminals")
os.Exit(2)
}
result, err := tea.NewProgram(ticketPickerTestModel{}, tea.WithInput(os.Stdin), tea.WithOutput(os.Stderr)).Run()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
model := result.(ticketPickerTestModel)
if model.selected && model.width == 100 && model.height == 30 {
fmt.Println(`{"branchName":"feature/selected","metadata":{"identifier":"TEST-123"}}`)
os.Exit(0)
}
if model.cancelled {
os.Exit(23)
}
fmt.Fprintln(os.Stderr, "picker timed out or received incorrect dimensions")
os.Exit(2)
}
}

type ticketPickerTestTimeout struct{}

type ticketPickerTestModel struct {
width, height int
selected bool
cancelled bool
}

func (m ticketPickerTestModel) Init() tea.Cmd {
return tea.Tick(5*time.Second, func(time.Time) tea.Msg { return ticketPickerTestTimeout{} })
}

func (m ticketPickerTestModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
switch msg.String() {
case "enter":
m.selected = true
return m, tea.Quit
case "esc":
m.cancelled = true
return m, tea.Quit
}
case ticketPickerTestTimeout:
return m, tea.Quit
}
return m, nil
}

func (m ticketPickerTestModel) View() tea.View {
view := tea.NewView(fmt.Sprintf("PICKER_READY %dx%d\nEnter selects; Escape cancels", m.width, m.height))
view.AltScreen = true
return view
}
Loading
Loading