diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-26 18:16:11 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-26 18:16:11 +0300 |
| commit | c4b872c5ec54340b1e62d7578ace400340573ce2 (patch) | |
| tree | d224707b3dd25ac68693d44f084dbe85a0edd685 /cmd | |
| parent | f77d73244fa6585af0456fd374988b8b04b72646 (diff) | |
test: bring every package above the 80% coverage target
Per-package coverage was below the AGENTS.md target in six packages:
cmd/ask 0.0% -> 83.3%
cmd/hexai-tmux-edit 10.0% -> 93.3%
cmd/hexai-tmux-action 27.8% -> 95.7%
cmd/hexai-mcp-server 41.9% -> 88.2%
internal/taskproxy 61.8% -> 98.2%
internal/filelock 77.3% -> 100.0%
The four cmd packages each had a main() that mixed flag parsing, struct
construction, and runtime delegation, so nothing called from a test
hit those statements. Each main() is now a one-line wrapper around a
testable runMain(args, stdin, stdout, stderr) int that uses
flag.NewFlagSet (instead of the global flag.Parse) so tests can drive
it repeatedly. The deprecation banner in hexai-mcp-server is now a
package-level constant, kept identical, so tests can assert on it
directly without redirecting os.Stderr.
The internal packages got new tests for paths that were previously
unreachable: filelock's retry-then-success and non-EWOULDBLOCK error
branches, and taskproxy's NewRunner / findTaskBinary / detectRepoRoot /
runTaskCommand helpers (the ones that shell out to git and task).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/ask/main.go | 31 | ||||
| -rw-r--r-- | cmd/ask/main_test.go | 69 | ||||
| -rw-r--r-- | cmd/hexai-mcp-server/main.go | 53 | ||||
| -rw-r--r-- | cmd/hexai-mcp-server/main_test.go | 107 | ||||
| -rw-r--r-- | cmd/hexai-tmux-action/main.go | 36 | ||||
| -rw-r--r-- | cmd/hexai-tmux-action/main_test.go | 77 | ||||
| -rw-r--r-- | cmd/hexai-tmux-edit/main.go | 25 | ||||
| -rw-r--r-- | cmd/hexai-tmux-edit/main_test.go | 65 |
8 files changed, 389 insertions, 74 deletions
diff --git a/cmd/ask/main.go b/cmd/ask/main.go index afab992..fbd5bb0 100644 --- a/cmd/ask/main.go +++ b/cmd/ask/main.go @@ -3,20 +3,33 @@ package main import ( "context" "fmt" + "io" "os" "codeberg.org/snonux/hexai/internal/askcli" ) -func main() { - d := askcli.NewDispatcher(nil) - code, err := d.Dispatch(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr) +// dispatcher is the minimal interface runMain depends on; it matches +// (*askcli.Dispatcher).Dispatch so a real dispatcher satisfies it directly. +type dispatcher interface { + Dispatch(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) +} + +// dispatcherFactory is a test seam: override to inject a fake dispatcher so +// runMain can be exercised without a real `task` binary on PATH. +var dispatcherFactory = func() dispatcher { + return askcli.NewDispatcher(nil) +} + +func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } + +// runMain dispatches the command and returns the process exit code; errors +// are printed to stderr. The dispatcher's exit code is returned regardless +// of err so callers see Taskwarrior's own exit code on failure paths. +func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + code, err := dispatcherFactory().Dispatch(context.Background(), args, stdin, stdout, stderr) if err != nil { - // Print the internal error so callers get a useful diagnostic message. - fmt.Fprintln(os.Stderr, err) - os.Exit(code) - } - if code != 0 { - os.Exit(code) + fmt.Fprintln(stderr, err) } + return code } diff --git a/cmd/ask/main_test.go b/cmd/ask/main_test.go index db6b436..643dde4 100644 --- a/cmd/ask/main_test.go +++ b/cmd/ask/main_test.go @@ -3,7 +3,9 @@ package main import ( "bytes" "context" + "errors" "io" + "strings" "testing" "codeberg.org/snonux/hexai/internal/askcli" @@ -49,3 +51,70 @@ type spyRunner struct { func (s *spyRunner) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { return s.runFn(ctx, args, stdin, stdout, stderr) } + +// fakeDispatcher captures Dispatch arguments and returns canned values; used +// to exercise runMain without a real Taskwarrior binary on PATH. +type fakeDispatcher struct { + gotArgs []string + code int + err error +} + +func (f *fakeDispatcher) Dispatch(_ context.Context, args []string, _ io.Reader, _, _ io.Writer) (int, error) { + f.gotArgs = append([]string(nil), args...) + return f.code, f.err +} + +// Driving runMain through a fake dispatcher proves the wiring (args +// forwarded, exit code returned) without touching the real ask CLI. +func TestRunMain_DelegatesAndReturnsCode(t *testing.T) { + old := dispatcherFactory + t.Cleanup(func() { dispatcherFactory = old }) + + fake := &fakeDispatcher{code: 0} + dispatcherFactory = func() dispatcher { return fake } + + var stdout, stderr bytes.Buffer + got := runMain([]string{"list", "limit:1"}, nil, &stdout, &stderr) + if got != 0 { + t.Fatalf("runMain code = %d, want 0", got) + } + if len(fake.gotArgs) != 2 || fake.gotArgs[0] != "list" || fake.gotArgs[1] != "limit:1" { + t.Fatalf("Dispatch args = %v", fake.gotArgs) + } + if stderr.Len() != 0 { + t.Fatalf("stderr should be empty on success, got %q", stderr.String()) + } +} + +// The default dispatcherFactory must return a working real dispatcher (this +// is the path main() uses in production); fakes used elsewhere don't cover +// it, so verify it explicitly. +func TestDispatcherFactory_DefaultReturnsRealDispatcher(t *testing.T) { + d := dispatcherFactory() + if d == nil { + t.Fatal("default dispatcherFactory returned nil") + } + if _, ok := d.(*askcli.Dispatcher); !ok { + t.Fatalf("default dispatcherFactory returned %T, want *askcli.Dispatcher", d) + } +} + +// On a dispatcher error, runMain must print the error to stderr AND surface +// the dispatcher's exit code so the shell sees Taskwarrior's own status. +func TestRunMain_PrintsErrorAndPropagatesExitCode(t *testing.T) { + old := dispatcherFactory + t.Cleanup(func() { dispatcherFactory = old }) + + fake := &fakeDispatcher{code: 7, err: errors.New("dispatch boom")} + dispatcherFactory = func() dispatcher { return fake } + + var stdout, stderr bytes.Buffer + got := runMain(nil, nil, &stdout, &stderr) + if got != 7 { + t.Fatalf("runMain code = %d, want 7", got) + } + if !strings.Contains(stderr.String(), "dispatch boom") { + t.Fatalf("stderr missing error text: %q", stderr.String()) + } +} diff --git a/cmd/hexai-mcp-server/main.go b/cmd/hexai-mcp-server/main.go index b32c18c..ac88178 100644 --- a/cmd/hexai-mcp-server/main.go +++ b/cmd/hexai-mcp-server/main.go @@ -29,10 +29,10 @@ var ( runBackfill = hexaimcp.RunBackfill ) -// printDeprecationWarning outputs a deprecation notice to stderr explaining -// that hexai-mcp-server is experimental and not actively maintained. -func printDeprecationWarning() { - warning := ` +// deprecationWarning is the notice runMain emits on every startup so users +// see this binary is experimental. Kept as a constant (not printf'd) so +// tests can assert on its contents directly. +const deprecationWarning = ` ⚠️ DEPRECATION NOTICE ⚠️ hexai-mcp-server is currently EXPERIMENTAL and NOT ACTIVELY MAINTAINED. @@ -50,8 +50,6 @@ Use at your own risk. ──────────────────────────────────────────────────────────────────────── ` - fmt.Fprintln(os.Stderr, warning) -} // mcpOptions holds the parsed command-line flags for the MCP server. type mcpOptions struct { @@ -64,22 +62,34 @@ type mcpOptions struct { showVersion bool } -func main() { - printDeprecationWarning() +func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } + +// runMain prints the deprecation warning, parses flags, and delegates to +// run. It returns the process exit code: 2 for flag-parse errors (matching +// stdlib `flag.ExitOnError`), 1 for state-dir or run failures, 0 on success. +// Pulling this out of main keeps it testable without touching package-level +// flag state. +func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + fmt.Fprint(stderr, deprecationWarning) defaultLog, err := defaultLogPath() if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + + fs := flag.NewFlagSet("hexai-mcp-server", flag.ContinueOnError) + fs.SetOutput(stderr) + logPath := fs.String("log", defaultLog, "path to log file (optional)") + configPath := fs.String("config", "", "path to config file (optional)") + promptsDir := fs.String("prompts-dir", "", "path to prompts directory (optional)") + slashCommandSync := fs.Bool("slashcommand-sync", false, "enable slash command sync") + slashCommandDir := fs.String("slashcommand-dir", "", "directory for slash command files") + syncAll := fs.Bool("sync-all", false, "backfill all existing prompts and exit") + showVersion := fs.Bool("version", false, "print version and exit") + if err := fs.Parse(args); err != nil { + return 2 } - logPath := flag.String("log", defaultLog, "path to log file (optional)") - configPath := flag.String("config", "", "path to config file (optional)") - promptsDir := flag.String("prompts-dir", "", "path to prompts directory (optional)") - slashCommandSync := flag.Bool("slashcommand-sync", false, "enable slash command sync") - slashCommandDir := flag.String("slashcommand-dir", "", "directory for slash command files") - syncAll := flag.Bool("sync-all", false, "backfill all existing prompts and exit") - showVersion := flag.Bool("version", false, "print version and exit") - flag.Parse() opts := mcpOptions{ logPath: *logPath, @@ -90,10 +100,11 @@ func main() { syncAll: *syncAll, showVersion: *showVersion, } - if err := run(opts, os.Stdin, os.Stdout, os.Stderr); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) + if err := run(opts, stdin, stdout, stderr); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 } + return 0 } // run executes the MCP server logic with the given options and I/O streams. diff --git a/cmd/hexai-mcp-server/main_test.go b/cmd/hexai-mcp-server/main_test.go index 3d85fbb..33f662d 100644 --- a/cmd/hexai-mcp-server/main_test.go +++ b/cmd/hexai-mcp-server/main_test.go @@ -4,7 +4,6 @@ import ( "bytes" "errors" "io" - "os" "strings" "testing" @@ -12,31 +11,12 @@ import ( "codeberg.org/snonux/hexai/internal/hexaimcp" ) -func TestPrintDeprecationWarning(t *testing.T) { - r, w, err := os.Pipe() - if err != nil { - t.Fatalf("failed to create pipe: %v", err) - } - - oldStderr := os.Stderr - os.Stderr = w - defer func() { os.Stderr = oldStderr }() - - printDeprecationWarning() - - if err := w.Close(); err != nil { - t.Fatalf("failed to close pipe writer: %v", err) - } - - b, err := io.ReadAll(r) - if err != nil { - t.Fatalf("failed to read pipe: %v", err) - } - - output := string(b) +// The deprecation banner is unconditional: it must reach stderr on every +// invocation so users notice this binary is unmaintained. +func TestDeprecationWarning_Content(t *testing.T) { for _, want := range []string{"DEPRECATION NOTICE", "EXPERIMENTAL", "NOT ACTIVELY MAINTAINED"} { - if !strings.Contains(output, want) { - t.Errorf("expected %q in output, got %q", want, output) + if !strings.Contains(deprecationWarning, want) { + t.Errorf("expected %q in deprecationWarning", want) } } } @@ -168,3 +148,80 @@ func TestRun_MCPServerError(t *testing.T) { t.Fatalf("expected server error, got: %v", err) } } + +// runMain version path: -version must short-circuit and write the version +// to stdout (not stderr, so it stays scriptable). Stderr still gets the +// deprecation banner — that's correct since the binary is leaving anyway. +func TestRunMain_VersionFlag(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runMain([]string{"-version"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("runMain code = %d, want 0", code) + } + if got := strings.TrimSpace(stdout.String()); got != internal.Version { + t.Fatalf("stdout = %q, want version %q", got, internal.Version) + } + if !strings.Contains(stderr.String(), "DEPRECATION NOTICE") { + t.Fatalf("stderr missing deprecation banner: %q", stderr.String()) + } +} + +// runMain --sync-all path: forwards parsed options to runBackfill and +// returns 0 on success. +func TestRunMain_SyncAllSuccess(t *testing.T) { + old := runBackfill + t.Cleanup(func() { runBackfill = old }) + + var gotLog string + runBackfill = func(logPath string, _ string, _ hexaimcp.MCPOverrides) error { + gotLog = logPath + return nil + } + + var stdout, stderr bytes.Buffer + code := runMain([]string{"-sync-all", "-log", "/tmp/sync.log"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("runMain code = %d, want 0; stderr=%q", code, stderr.String()) + } + if gotLog != "/tmp/sync.log" { + t.Fatalf("logPath forwarded = %q, want /tmp/sync.log", gotLog) + } +} + +// runMain run-error path: when the underlying server fails, runMain must +// return 1 (the production exit code) and write the error to stderr. +func TestRunMain_ServerErrorReturnsOne(t *testing.T) { + old := runMCP + t.Cleanup(func() { runMCP = old }) + runMCP = func(string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error { + return errors.New("mcp boom") + } + + var stdout, stderr bytes.Buffer + code := runMain(nil, nil, &stdout, &stderr) + if code != 1 { + t.Fatalf("runMain code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "mcp boom") { + t.Fatalf("stderr missing error: %q", stderr.String()) + } +} + +// Bad flag must yield exit 2 without ever invoking the server stub. +func TestRunMain_BadFlagReturnsTwo(t *testing.T) { + old := runMCP + t.Cleanup(func() { runMCP = old }) + called := false + runMCP = func(string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error { + called = true + return nil + } + var stdout, stderr bytes.Buffer + code := runMain([]string{"--bogus"}, nil, &stdout, &stderr) + if code != 2 { + t.Fatalf("runMain code = %d, want 2", code) + } + if called { + t.Fatal("runMCP must not be called on flag-parse failure") + } +} diff --git a/cmd/hexai-tmux-action/main.go b/cmd/hexai-tmux-action/main.go index a0f240b..e2f50eb 100644 --- a/cmd/hexai-tmux-action/main.go +++ b/cmd/hexai-tmux-action/main.go @@ -16,26 +16,38 @@ import ( // the real tmux action. var runCommand = hexaiaction.RunCommand -func main() { - infile := flag.String("infile", "", "Read input from this file instead of stdin") - outfile := flag.String("outfile", "", "Write output to this file instead of stdout") - uiChild := flag.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically") +func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } + +// runMain parses command-line flags from args, builds actionOptions, and +// delegates to run. It returns the process exit code: 2 for flag-parse +// errors (matching stdlib `flag.ExitOnError`), 1 for runtime failures, 0 on +// success. Splitting the body out of main keeps it testable without +// touching package-level flag state. +func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("hexai-tmux-action", flag.ContinueOnError) + fs.SetOutput(stderr) + infile := fs.String("infile", "", "Read input from this file instead of stdin") + outfile := fs.String("outfile", "", "Write output to this file instead of stdout") + uiChild := fs.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically") defaultPath := appconfig.DefaultConfigPath() - configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath)) - tmuxTarget := flag.String("tmux-target", "", "tmux popup target pane (advanced)") - tmuxPopupWidth := flag.String("tmux-popup-width", "60%", "tmux popup width, e.g. 60% or 120") - tmuxPopupHeight := flag.String("tmux-popup-height", "50%", "tmux popup height, e.g. 50% or 30") - flag.Parse() + configPath := fs.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath)) + tmuxTarget := fs.String("tmux-target", "", "tmux popup target pane (advanced)") + tmuxPopupWidth := fs.String("tmux-popup-width", "60%", "tmux popup width, e.g. 60% or 120") + tmuxPopupHeight := fs.String("tmux-popup-height", "50%", "tmux popup height, e.g. 50% or 30") + if err := fs.Parse(args); err != nil { + return 2 + } opts := actionOptions{ infile: *infile, outfile: *outfile, uiChild: *uiChild, configPath: *configPath, tmuxTarget: *tmuxTarget, tmuxPopupWidth: *tmuxPopupWidth, tmuxPopupHeight: *tmuxPopupHeight, } - if err := run(opts, os.Stdin, os.Stdout, os.Stderr); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + if err := run(opts, stdin, stdout, stderr); err != nil { + fmt.Fprintln(stderr, err) + return 1 } + return 0 } // actionOptions holds the parsed command-line flags for hexai-tmux-action. diff --git a/cmd/hexai-tmux-action/main_test.go b/cmd/hexai-tmux-action/main_test.go index c2cc95f..e1c02e1 100644 --- a/cmd/hexai-tmux-action/main_test.go +++ b/cmd/hexai-tmux-action/main_test.go @@ -1,9 +1,11 @@ package main import ( + "bytes" "context" "errors" "io" + "strings" "testing" "codeberg.org/snonux/hexai/internal/hexaiaction" @@ -61,3 +63,78 @@ func TestRun_Error(t *testing.T) { t.Fatalf("expected error, got: %v", err) } } + +// runMain happy path: every flag is forwarded into hexaiaction.Options and +// the stub returns 0. The captured Options confirm the field-by-field +// mapping that main relies on. +func TestRunMain_FlagsForwardedToHexaiaction(t *testing.T) { + old := runCommand + t.Cleanup(func() { runCommand = old }) + + var got hexaiaction.Options + runCommand = func(_ context.Context, opts hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { + got = opts + return nil + } + + args := []string{ + "-infile", "in.txt", + "-outfile", "out.txt", + "-tmux-target", "%2", + "-tmux-popup-width", "70%", + "-tmux-popup-height", "40%", + "-ui-child", + } + var stderr bytes.Buffer + code := runMain(args, nil, &bytes.Buffer{}, &stderr) + if code != 0 { + t.Fatalf("runMain code = %d, want 0; stderr=%q", code, stderr.String()) + } + if got.Infile != "in.txt" || got.Outfile != "out.txt" { + t.Fatalf("infile/outfile mismatch: %+v", got) + } + if got.TmuxTarget != "%2" || got.TmuxPopupWidth != "70%" || got.TmuxPopupHeight != "40%" { + t.Fatalf("tmux flags mismatch: %+v", got) + } + if !got.UIChild { + t.Fatal("expected UIChild=true") + } +} + +// On runCommand failure, runMain returns 1 (the production exit code) and +// writes the error message to stderr so users see what went wrong. +func TestRunMain_RuntimeErrorReturnsOne(t *testing.T) { + old := runCommand + t.Cleanup(func() { runCommand = old }) + runCommand = func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error { + return errors.New("action exploded") + } + + var stderr bytes.Buffer + code := runMain(nil, nil, &bytes.Buffer{}, &stderr) + if code != 1 { + t.Fatalf("runMain code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "action exploded") { + t.Fatalf("stderr missing error: %q", stderr.String()) + } +} + +// Bad flag must yield exit 2 without ever invoking runCommand. +func TestRunMain_BadFlagReturnsTwo(t *testing.T) { + old := runCommand + t.Cleanup(func() { runCommand = old }) + called := false + runCommand = func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error { + called = true + return nil + } + var stderr bytes.Buffer + code := runMain([]string{"--bogus"}, nil, &bytes.Buffer{}, &stderr) + if code != 2 { + t.Fatalf("runMain code = %d, want 2", code) + } + if called { + t.Fatal("runCommand must not be called on flag-parse failure") + } +} diff --git a/cmd/hexai-tmux-edit/main.go b/cmd/hexai-tmux-edit/main.go index 6d0e75e..6177008 100644 --- a/cmd/hexai-tmux-edit/main.go +++ b/cmd/hexai-tmux-edit/main.go @@ -14,6 +14,7 @@ package main import ( "flag" "fmt" + "io" "os" "strings" @@ -24,18 +25,28 @@ import ( // runTmuxEdit is the seam for testing: override in tests to avoid real tmux. var runTmuxEdit = tmuxedit.Run -func main() { +func main() { os.Exit(runMain(os.Args[1:], os.Stderr)) } + +// runMain parses flags from args and runs the tmux edit popup. It returns +// the process exit code; flag errors return 2 (matching stdlib convention), +// runtime failures return 1. +func runMain(args []string, stderr io.Writer) int { defaultPath := appconfig.DefaultConfigPath() - configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath)) - agent := flag.String("agent", "", "AI agent name (auto-detected if omitted)") - pane := flag.String("pane", "", "tmux target pane ID (e.g. %%5)") - flag.Parse() + fs := flag.NewFlagSet("hexai-tmux-edit", flag.ContinueOnError) + fs.SetOutput(stderr) + configPath := fs.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath)) + agent := fs.String("agent", "", "AI agent name (auto-detected if omitted)") + pane := fs.String("pane", "", "tmux target pane ID (e.g. %5)") + if err := fs.Parse(args); err != nil { + return 2 + } opts := buildOptions(*configPath, *agent, *pane) if err := runTmuxEdit(opts); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + fmt.Fprintln(stderr, err) + return 1 } + return 0 } // buildOptions constructs tmuxedit.Options from the parsed flag values, diff --git a/cmd/hexai-tmux-edit/main_test.go b/cmd/hexai-tmux-edit/main_test.go index fc2364c..6881556 100644 --- a/cmd/hexai-tmux-edit/main_test.go +++ b/cmd/hexai-tmux-edit/main_test.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "errors" + "strings" "testing" "codeberg.org/snonux/hexai/internal/tmuxedit" @@ -57,3 +59,66 @@ func TestRunTmuxEdit_Error(t *testing.T) { t.Fatalf("expected error, got: %v", err) } } + +// runMain happy path: flags parse, runTmuxEdit returns nil, exit code 0. +// We capture the resolved Options to confirm flags map onto fields correctly. +func TestRunMain_FlagsForwardedToTmuxedit(t *testing.T) { + old := runTmuxEdit + t.Cleanup(func() { runTmuxEdit = old }) + + var got tmuxedit.Options + runTmuxEdit = func(opts tmuxedit.Options) error { + got = opts + return nil + } + + var stderr bytes.Buffer + code := runMain([]string{"-config", " /tmp/cfg.toml ", "-agent", "claude", "-pane", "%9"}, &stderr) + if code != 0 { + t.Fatalf("runMain code = %d, want 0", code) + } + if got.ConfigPath != "/tmp/cfg.toml" || got.Agent != "claude" || got.Pane != "%9" { + t.Fatalf("unexpected opts: %+v", got) + } + if stderr.Len() != 0 { + t.Fatalf("stderr should be empty on success, got %q", stderr.String()) + } +} + +// runMain reports tmuxedit.Run failures by writing to stderr and returning 1 +// — the production exit code that the shipped binary uses. +func TestRunMain_RunErrorReturnsOne(t *testing.T) { + old := runTmuxEdit + t.Cleanup(func() { runTmuxEdit = old }) + runTmuxEdit = func(tmuxedit.Options) error { return errors.New("boom") } + + var stderr bytes.Buffer + code := runMain(nil, &stderr) + if code != 1 { + t.Fatalf("runMain code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "boom") { + t.Fatalf("stderr missing error: %q", stderr.String()) + } +} + +// Unknown flags must yield exit 2 (the convention used by stdlib `flag` when +// ExitOnError aborts) without ever invoking runTmuxEdit. +func TestRunMain_BadFlagReturnsTwo(t *testing.T) { + old := runTmuxEdit + t.Cleanup(func() { runTmuxEdit = old }) + called := false + runTmuxEdit = func(tmuxedit.Options) error { + called = true + return nil + } + + var stderr bytes.Buffer + code := runMain([]string{"--no-such-flag"}, &stderr) + if code != 2 { + t.Fatalf("runMain code = %d, want 2", code) + } + if called { + t.Fatal("runTmuxEdit must not be called on flag-parse failure") + } +} |
