summaryrefslogtreecommitdiff
path: root/cmd/hexai-tmux-action
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-26 18:16:11 +0300
committerPaul Buetow <paul@buetow.org>2026-04-26 18:16:11 +0300
commitc4b872c5ec54340b1e62d7578ace400340573ce2 (patch)
treed224707b3dd25ac68693d44f084dbe85a0edd685 /cmd/hexai-tmux-action
parentf77d73244fa6585af0456fd374988b8b04b72646 (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/hexai-tmux-action')
-rw-r--r--cmd/hexai-tmux-action/main.go36
-rw-r--r--cmd/hexai-tmux-action/main_test.go77
2 files changed, 101 insertions, 12 deletions
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")
+ }
+}