Skip to content

Commit b6d9a56

Browse files
committed
test(tui): add tests for --mouse flag and program options
- Extract parseConfig and buildProgramOptions so they are unit-testable. - Cover default parsing, --mouse, extra args after --, unknown flags, and help. - Verify program option counts for default and mouse-enabled modes.
1 parent 4401a7f commit b6d9a56

2 files changed

Lines changed: 152 additions & 33 deletions

File tree

cmd/bodek/main.go

Lines changed: 60 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -26,29 +26,60 @@ func main() {
2626
}
2727
}
2828

29-
func run() error {
30-
var (
31-
url = flag.String("url", "", "attach to an already-running odek serve URL (e.g. http://127.0.0.1:8080/?token=…)")
32-
token = flag.String("token", "", "WS auth token for an attached odek serve (as printed at its startup)")
33-
sandbox = flag.Bool("sandbox", false, "run tool calls inside odek's Docker sandbox")
34-
bin = flag.String("odek-bin", "", "path to the odek binary to spawn (default: odek on PATH)")
35-
mouse = flag.Bool("mouse", false, "enable mouse wheel scrolling (disables native text selection/copy)")
36-
)
37-
flag.Usage = func() {
38-
fmt.Fprintf(os.Stderr, "Usage: bodek [options] [-- <odek serve flags>]\n\n")
39-
fmt.Fprintf(os.Stderr, "A terminal interface for the odek agent.\n\n")
40-
fmt.Fprintf(os.Stderr, "Options:\n")
41-
flag.PrintDefaults()
42-
fmt.Fprintf(os.Stderr, "\nExamples:\n")
43-
fmt.Fprintf(os.Stderr, " bodek # spawn odek serve and start chatting\n")
44-
fmt.Fprintf(os.Stderr, " bodek --sandbox # spawn odek serve with Docker sandbox\n")
45-
fmt.Fprintf(os.Stderr, " bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed\n")
46-
fmt.Fprintf(os.Stderr, " bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token\n")
47-
fmt.Fprintf(os.Stderr, " bodek -- --prompt-caching # pass extra flags to odek serve\n")
29+
type config struct {
30+
url string
31+
token string
32+
sandbox bool
33+
bin string
34+
mouse bool
35+
extraArgs []string
36+
}
37+
38+
func parseConfig(args []string, output io.Writer) (config, error) {
39+
var cfg config
40+
fs := flag.NewFlagSet("bodek", flag.ContinueOnError)
41+
if output != nil {
42+
fs.SetOutput(output)
43+
}
44+
fs.StringVar(&cfg.url, "url", "", "attach to an already-running odek serve URL (e.g. http://127.0.0.1:8080/?token=…)")
45+
fs.StringVar(&cfg.token, "token", "", "WS auth token for an attached odek serve (as printed at its startup)")
46+
fs.BoolVar(&cfg.sandbox, "sandbox", false, "run tool calls inside odek's Docker sandbox")
47+
fs.StringVar(&cfg.bin, "odek-bin", "", "path to the odek binary to spawn (default: odek on PATH)")
48+
fs.BoolVar(&cfg.mouse, "mouse", false, "enable mouse wheel scrolling (disables native text selection/copy)")
49+
fs.Usage = func() {
50+
fmt.Fprintf(fs.Output(), "Usage: bodek [options] [-- <odek serve flags>]\n\n")
51+
fmt.Fprintf(fs.Output(), "A terminal interface for the odek agent.\n\n")
52+
fmt.Fprintf(fs.Output(), "Options:\n")
53+
fs.PrintDefaults()
54+
fmt.Fprintf(fs.Output(), "\nExamples:\n")
55+
fmt.Fprintf(fs.Output(), " bodek # spawn odek serve and start chatting\n")
56+
fmt.Fprintf(fs.Output(), " bodek --sandbox # spawn odek serve with Docker sandbox\n")
57+
fmt.Fprintf(fs.Output(), " bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed\n")
58+
fmt.Fprintf(fs.Output(), " bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token\n")
59+
fmt.Fprintf(fs.Output(), " bodek --mouse # enable mouse wheel scrolling (blocks text selection)\n")
60+
fmt.Fprintf(fs.Output(), " bodek -- --prompt-caching # pass extra flags to odek serve\n")
61+
}
62+
if err := fs.Parse(args); err != nil {
63+
fs.Usage()
64+
return config{}, err
65+
}
66+
cfg.extraArgs = fs.Args()
67+
return cfg, nil
68+
}
69+
70+
func buildProgramOptions(mouse bool) []tea.ProgramOption {
71+
opts := []tea.ProgramOption{tea.WithAltScreen()}
72+
if mouse {
73+
opts = append(opts, tea.WithMouseCellMotion())
4874
}
49-
flag.Parse()
75+
return opts
76+
}
5077

51-
extraArgs := flag.Args()
78+
func run() error {
79+
cfg, err := parseConfig(os.Args[1:], os.Stderr)
80+
if err != nil {
81+
return err
82+
}
5283

5384
// A spawned `odek serve` logs to stderr. Routing that to our own terminal
5485
// would corrupt the Bubble Tea alt-screen (stray writes desync the diff
@@ -63,7 +94,7 @@ func run() error {
6394
// io.MultiWriter / *ringWriter below) without the redundant typed-var
6495
// declaration that staticcheck's QF1011 flags.
6596
serverErr := io.Writer(io.Discard)
66-
if *url == "" {
97+
if cfg.url == "" {
6798
logTail = newRingWriter(50)
6899
if f, path, closeLog := openServerLog(); path != "" {
69100
defer closeLog()
@@ -76,11 +107,11 @@ func run() error {
76107

77108
// Spawn or attach to the odek serve backend.
78109
srv, err := server.Connect(server.Options{
79-
URL: *url,
80-
Token: *token,
81-
Bin: *bin,
82-
Sandbox: *sandbox,
83-
ExtraArgs: extraArgs,
110+
URL: cfg.url,
111+
Token: cfg.token,
112+
Bin: cfg.bin,
113+
Sandbox: cfg.sandbox,
114+
ExtraArgs: cfg.extraArgs,
84115
Stderr: serverErr,
85116
})
86117
if err != nil {
@@ -109,7 +140,7 @@ func run() error {
109140
setupSignalHandler(srv, cl)
110141

111142
model := tui.New(cl, tui.Options{
112-
Sandbox: *sandbox,
143+
Sandbox: cfg.sandbox,
113144
CWD: cwd,
114145
LogPath: logPath,
115146
})
@@ -118,11 +149,7 @@ func run() error {
118149
// captures the terminal mouse and blocks native click-drag text selection
119150
// and copy. Keep it off by default so users can copy freely; enable it only
120151
// when explicitly requested with --mouse.
121-
programOpts := []tea.ProgramOption{tea.WithAltScreen()}
122-
if *mouse {
123-
programOpts = append(programOpts, tea.WithMouseCellMotion())
124-
}
125-
p := tea.NewProgram(model, programOpts...)
152+
p := tea.NewProgram(model, buildProgramOptions(cfg.mouse)...)
126153
if _, err := p.Run(); err != nil {
127154
return fmt.Errorf("TUI exited: %w", err)
128155
}

cmd/bodek/main_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"flag"
7+
"io"
8+
"testing"
9+
10+
tea "github.com/charmbracelet/bubbletea"
11+
)
12+
13+
func TestParseConfigDefaults(t *testing.T) {
14+
cfg, err := parseConfig(nil, io.Discard)
15+
if err != nil {
16+
t.Fatalf("parseConfig returned error: %v", err)
17+
}
18+
if cfg.url != "" || cfg.token != "" || cfg.bin != "" {
19+
t.Errorf("unexpected non-empty defaults: %+v", cfg)
20+
}
21+
if cfg.sandbox || cfg.mouse {
22+
t.Errorf("expected sandbox and mouse to be false by default, got sandbox=%v mouse=%v", cfg.sandbox, cfg.mouse)
23+
}
24+
if len(cfg.extraArgs) != 0 {
25+
t.Errorf("expected no extra args, got %v", cfg.extraArgs)
26+
}
27+
}
28+
29+
func TestParseConfigMouseFlag(t *testing.T) {
30+
cfg, err := parseConfig([]string{"--mouse"}, io.Discard)
31+
if err != nil {
32+
t.Fatalf("parseConfig returned error: %v", err)
33+
}
34+
if !cfg.mouse {
35+
t.Error("expected --mouse to set mouse=true")
36+
}
37+
}
38+
39+
func TestParseConfigExtraArgs(t *testing.T) {
40+
cfg, err := parseConfig([]string{"--mouse", "--", "--prompt-caching", "--verbose"}, io.Discard)
41+
if err != nil {
42+
t.Fatalf("parseConfig returned error: %v", err)
43+
}
44+
if !cfg.mouse {
45+
t.Error("expected --mouse to be parsed before --")
46+
}
47+
want := []string{"--prompt-caching", "--verbose"}
48+
if len(cfg.extraArgs) != len(want) {
49+
t.Fatalf("expected extra args %v, got %v", want, cfg.extraArgs)
50+
}
51+
for i := range want {
52+
if cfg.extraArgs[i] != want[i] {
53+
t.Errorf("extra arg %d: want %q, got %q", i, want[i], cfg.extraArgs[i])
54+
}
55+
}
56+
}
57+
58+
func TestParseConfigUnknownFlag(t *testing.T) {
59+
_, err := parseConfig([]string{"--unknown"}, io.Discard)
60+
if err == nil {
61+
t.Fatal("expected error for unknown flag")
62+
}
63+
}
64+
65+
func TestParseConfigHelp(t *testing.T) {
66+
var out bytes.Buffer
67+
_, err := parseConfig([]string{"-h"}, &out)
68+
if !errors.Is(err, flag.ErrHelp) {
69+
t.Fatalf("expected flag.ErrHelp, got %v", err)
70+
}
71+
if out.Len() == 0 {
72+
t.Error("expected usage output on -h")
73+
}
74+
}
75+
76+
func TestBuildProgramOptionsDefault(t *testing.T) {
77+
opts := buildProgramOptions(false)
78+
if len(opts) != 1 {
79+
t.Fatalf("expected 1 default program option, got %d", len(opts))
80+
}
81+
// Sanity check: the option is callable like a real tea.ProgramOption.
82+
var p tea.Program
83+
_ = p
84+
_ = opts[0]
85+
}
86+
87+
func TestBuildProgramOptionsWithMouse(t *testing.T) {
88+
opts := buildProgramOptions(true)
89+
if len(opts) != 2 {
90+
t.Fatalf("expected 2 program options with mouse, got %d", len(opts))
91+
}
92+
}

0 commit comments

Comments
 (0)