From 4ffb22e7f69f1c9c79b095d4e60bad3d97aac55b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 18 Jun 2026 07:45:37 +0300 Subject: ik0 replace test seams with dependency injection --- internal/hexaiaction/cmdentry.go | 54 +++++++++++++++++------- internal/hexaiaction/cmdentry_runcommand_test.go | 18 +++----- internal/hexaiaction/cmdentry_test.go | 36 ++++++---------- internal/hexaiaction/custom_action_test.go | 9 +--- internal/hexaiaction/run.go | 28 +++++++++++- internal/hexaiaction/tui.go | 12 +++++- internal/hexaiaction/tui_config_test.go | 9 ++-- internal/hexaiaction/tui_custom.go | 22 +++++++--- internal/hexaiaction/tui_custom_test.go | 9 ++-- 9 files changed, 121 insertions(+), 76 deletions(-) (limited to 'internal/hexaiaction') diff --git a/internal/hexaiaction/cmdentry.go b/internal/hexaiaction/cmdentry.go index 85e9059..ec4970c 100644 --- a/internal/hexaiaction/cmdentry.go +++ b/internal/hexaiaction/cmdentry.go @@ -25,23 +25,47 @@ type Options struct { // RunCommand is the CLI orchestrator used by cmd/hexai-tmux-action. It runs in tmux // split-pane mode by default, or child mode when -ui-child is set. func RunCommand(ctx context.Context, opts Options, stdin io.Reader, stdout, stderr io.Writer) error { + return commandRunner{}.RunCommand(ctx, opts, stdin, stdout, stderr) +} + +type commandRunner struct { + popupRun func(tmux.PopupOpts, []string) error + osExecutable func() (string, error) + run func(context.Context, io.Reader, io.Writer, io.Writer) error +} + +func (r commandRunner) popup(opts tmux.PopupOpts, argv []string) error { + if r.popupRun != nil { + return r.popupRun(opts, argv) + } + return tmux.PopupRun(opts, argv) +} + +func (r commandRunner) executable() (string, error) { + if r.osExecutable != nil { + return r.osExecutable() + } + return os.Executable() +} + +func (r commandRunner) runAction(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error { + if r.run != nil { + return r.run(ctx, stdin, stdout, stderr) + } + return Run(ctx, stdin, stdout, stderr) +} + +func (r commandRunner) RunCommand(ctx context.Context, opts Options, stdin io.Reader, stdout, stderr io.Writer) error { if err := llm.RegisterAllProviders(); err != nil { return fmt.Errorf("failed to register LLM providers: %w", err) } if opts.UIChild { - return runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr) + return r.runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr) } // Always use tmux popup path - return runInTmuxParent(ctx, stdin, stdout, opts.TmuxTarget, opts.TmuxPopupWidth, opts.TmuxPopupHeight) + return r.runInTmuxParent(ctx, stdin, stdout, opts.TmuxTarget, opts.TmuxPopupWidth, opts.TmuxPopupHeight) } -// seams for unit tests -var ( - popupRunFn = tmux.PopupRun - osExecutableFn = os.Executable - runFn = Run -) - // openIO returns readers/writers for infile/outfile flags with deferred closers. func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) { in := io.Reader(os.Stdin) @@ -68,7 +92,7 @@ func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error } // runChild runs the interactive flow and writes the final output atomically when outfile is set. -func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Writer) error { +func (r commandRunner) runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Writer) error { if outfile == "" { // No atomic handoff needed; just run normally to provided stdout var in io.Reader = os.Stdin @@ -80,7 +104,7 @@ func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Wri defer func() { _ = f.Close() }() in = f } - return runFn(ctx, in, stdout, stderr) + return r.runAction(ctx, in, stdout, stderr) } tmp := outfile + ".tmp" in, out, closeIn, closeOut, err := openIO(infile, tmp) @@ -88,7 +112,7 @@ func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Wri return err } defer closeIn() - if err := runFn(ctx, in, out, stderr); err != nil { + if err := r.runAction(ctx, in, out, stderr); err != nil { closeOut() if copyErr := echoThrough(infile, tmp, os.Stdin, stdout); copyErr != nil { // Wrap the primary child error with %w so callers can inspect it @@ -102,7 +126,7 @@ func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Wri return os.Rename(tmp, outfile) } -func runInTmuxParent(ctx context.Context, stdin io.Reader, stdout io.Writer, target, popupWidth, popupHeight string) error { +func (r commandRunner) runInTmuxParent(ctx context.Context, stdin io.Reader, stdout io.Writer, target, popupWidth, popupHeight string) error { dir, err := os.MkdirTemp("", "hexai-tmux-action-") if err != nil { return err @@ -113,13 +137,13 @@ func runInTmuxParent(ctx context.Context, stdin io.Reader, stdout io.Writer, tar if err := persistStdin(inPath, stdin); err != nil { return err } - exe, err := osExecutableFn() + exe, err := r.executable() if err != nil { return err } argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath} opts := tmux.PopupOpts{Target: target, Width: popupWidth, Height: popupHeight} - if err := popupRunFn(opts, argv); err != nil { + if err := r.popup(opts, argv); err != nil { return err } if err := waitForFile(ctx, outPath, 60*time.Second); err != nil { diff --git a/internal/hexaiaction/cmdentry_runcommand_test.go b/internal/hexaiaction/cmdentry_runcommand_test.go index ac6106d..b71a0f2 100644 --- a/internal/hexaiaction/cmdentry_runcommand_test.go +++ b/internal/hexaiaction/cmdentry_runcommand_test.go @@ -16,14 +16,12 @@ func TestRunCommand_UIChild(t *testing.T) { in := filepath.Join(dir, "in.txt") out := filepath.Join(dir, "out.txt") _ = os.WriteFile(in, []byte("sel"), 0o600) - old := runFn - runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { + r := commandRunner{run: func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { _, _ = io.WriteString(w, "OK") return nil - } - t.Cleanup(func() { runFn = old }) + }} opts := Options{Infile: in, Outfile: out, UIChild: true} - if err := RunCommand(context.Background(), opts, bytes.NewBuffer(nil), io.Discard, io.Discard); err != nil { + if err := r.RunCommand(context.Background(), opts, bytes.NewBuffer(nil), io.Discard, io.Discard); err != nil { t.Fatalf("RunCommand UIChild: %v", err) } b, _ := os.ReadFile(out) @@ -33,10 +31,9 @@ func TestRunCommand_UIChild(t *testing.T) { } func TestRunCommand_Tmux(t *testing.T) { - oldExec := osExecutableFn - oldPopup := popupRunFn - osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil } - popupRunFn = func(_ tmux.PopupOpts, argv []string) error { + r := commandRunner{} + r.osExecutable = func() (string, error) { return "/bin/hexai-tmux-action", nil } + r.popupRun = func(_ tmux.PopupOpts, argv []string) error { for i := 0; i < len(argv)-1; i++ { if argv[i] == "-outfile" && i+1 < len(argv) { _ = os.WriteFile(argv[i+1], []byte("OUT"), 0o600) @@ -45,9 +42,8 @@ func TestRunCommand_Tmux(t *testing.T) { } return nil } - defer func() { osExecutableFn = oldExec; popupRunFn = oldPopup }() var out bytes.Buffer - if err := RunCommand(context.Background(), Options{}, bytes.NewBufferString("X"), &out, io.Discard); err != nil { + if err := r.RunCommand(context.Background(), Options{}, bytes.NewBufferString("X"), &out, io.Discard); err != nil { t.Fatalf("RunCommand tmux: %v", err) } if out.String() != "OUT" { diff --git a/internal/hexaiaction/cmdentry_test.go b/internal/hexaiaction/cmdentry_test.go index b9d5e9b..99805ca 100644 --- a/internal/hexaiaction/cmdentry_test.go +++ b/internal/hexaiaction/cmdentry_test.go @@ -77,10 +77,8 @@ func TestRunInTmuxParent_Stubbed(t *testing.T) { _ = w.Close() // capture stdout rout, wout, _ := os.Pipe() - oldExec := osExecutableFn - oldPopup := popupRunFn - osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil } - popupRunFn = func(opts tmux.PopupOpts, argv []string) error { + runner := commandRunner{osExecutable: func() (string, error) { return "/bin/hexai-tmux-action", nil }} + runner.popupRun = func(opts tmux.PopupOpts, argv []string) error { for i := 0; i < len(argv)-1; i++ { if argv[i] == "-outfile" && i+1 < len(argv) { _ = os.WriteFile(argv[i+1], []byte("OUT:"+strings.Join(argv, ",")), 0o600) @@ -89,8 +87,7 @@ func TestRunInTmuxParent_Stubbed(t *testing.T) { } return nil } - t.Cleanup(func() { osExecutableFn = oldExec; popupRunFn = oldPopup }) - if err := runInTmuxParent(context.Background(), r, wout, "", "", ""); err != nil { + if err := runner.runInTmuxParent(context.Background(), r, wout, "", "", ""); err != nil { t.Fatalf("runInTmuxParent: %v", err) } _ = wout.Close() @@ -102,27 +99,24 @@ func TestRunInTmuxParent_Stubbed(t *testing.T) { } func TestRunInTmuxParent_ExecutableError(t *testing.T) { - old := osExecutableFn - osExecutableFn = func() (string, error) { return "", fmt.Errorf("no exe") } - t.Cleanup(func() { osExecutableFn = old }) + runner := commandRunner{osExecutable: func() (string, error) { return "", fmt.Errorf("no exe") }} r, w, _ := os.Pipe() _, _ = w.Write([]byte("x")) _ = w.Close() - if err := runInTmuxParent(context.Background(), r, io.Discard, "", "", ""); err == nil { + if err := runner.runInTmuxParent(context.Background(), r, io.Discard, "", "", ""); err == nil { t.Fatal("expected error from missing executable") } } func TestRunInTmuxParent_PopupError(t *testing.T) { - oldExec := osExecutableFn - osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil } - oldPopup := popupRunFn - popupRunFn = func(_ tmux.PopupOpts, _ []string) error { return fmt.Errorf("popup failed") } - t.Cleanup(func() { osExecutableFn = oldExec; popupRunFn = oldPopup }) + runner := commandRunner{ + osExecutable: func() (string, error) { return "/bin/hexai-tmux-action", nil }, + popupRun: func(_ tmux.PopupOpts, _ []string) error { return fmt.Errorf("popup failed") }, + } r, w, _ := os.Pipe() _, _ = w.Write([]byte("x")) _ = w.Close() - if err := runInTmuxParent(context.Background(), r, io.Discard, "", "", ""); err == nil { + if err := runner.runInTmuxParent(context.Background(), r, io.Discard, "", "", ""); err == nil { t.Fatal("expected popup error") } } @@ -133,13 +127,11 @@ func TestRunChild_StdoutAndOutfile(t *testing.T) { in := filepath.Join(dir, "in.txt") out := filepath.Join(dir, "out.txt") _ = os.WriteFile(in, []byte("sel"), 0o600) - oldRun := runFn - runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { + runner := commandRunner{run: func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { _, _ = io.WriteString(w, "RESULT") return nil - } - t.Cleanup(func() { runFn = oldRun }) - if err := runChild(context.Background(), in, out, io.Discard, io.Discard); err != nil { + }} + if err := runner.runChild(context.Background(), in, out, io.Discard, io.Discard); err != nil { t.Fatalf("runChild: %v", err) } b, _ := os.ReadFile(out) @@ -148,7 +140,7 @@ func TestRunChild_StdoutAndOutfile(t *testing.T) { } // Stdout mode r, w, _ := os.Pipe() - if err := runChild(context.Background(), in, "", w, io.Discard); err != nil { + if err := runner.runChild(context.Background(), in, "", w, io.Discard); err != nil { t.Fatalf("runChild: %v", err) } _ = w.Close() diff --git a/internal/hexaiaction/custom_action_test.go b/internal/hexaiaction/custom_action_test.go index e2f7902..7bda0ad 100644 --- a/internal/hexaiaction/custom_action_test.go +++ b/internal/hexaiaction/custom_action_test.go @@ -3,11 +3,9 @@ package hexaiaction import ( "bytes" "context" - "os" "testing" "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/editor" "codeberg.org/snonux/hexai/internal/llm" ) @@ -29,12 +27,9 @@ func TestActionCustom_UsesEditorPrompt(t *testing.T) { } runner.newClient = func(_ appconfig.App) (actionClient, error) { return llmFake2{}, nil } - oldRunEd := editor.RunEditor - editor.RunEditor = func(_ context.Context, _ string, path string) error { - return os.WriteFile(path, []byte("make it done"), 0o600) + runner.openEditor = func(context.Context, []byte) (string, error) { + return "make it done", nil } - t.Cleanup(func() { editor.RunEditor = oldRunEd }) - t.Setenv("HEXAI_EDITOR", "dummy") in := bytes.NewBufferString("some code") var out bytes.Buffer diff --git a/internal/hexaiaction/run.go b/internal/hexaiaction/run.go index f0ce7ee..8b78bd0 100644 --- a/internal/hexaiaction/run.go +++ b/internal/hexaiaction/run.go @@ -66,6 +66,8 @@ type actionClientFactory func(cfg appconfig.App) (actionClient, error) type actionConfigLoader func(context.Context, *log.Logger) appconfig.App +type actionEditorOpener func(context.Context, []byte) (string, error) + type actionStatusSink interface { SetLLMStart(provider, model string) error } @@ -75,6 +77,7 @@ type Runner struct { chooseAction actionChooser newClient actionClientFactory loadConfig actionConfigLoader + openEditor actionEditorOpener statusSink actionStatusSink } @@ -84,6 +87,7 @@ func NewRunner() *Runner { chooseAction: chooseActionFromConfig, newClient: defaultActionClientFactory, loadConfig: loadActionConfig, + openEditor: editor.OpenTempAndEdit, statusSink: tmuxActionStatusSink{}, } } @@ -117,6 +121,22 @@ func loadActionConfig(ctx context.Context, logger *log.Logger) appconfig.App { return appconfig.LoadWithOptions(ctx, logger, appconfig.LoadOptions{ConfigPath: configPathFromContext(ctx)}) } +type actionEditorKey struct{} + +func withActionEditor(ctx context.Context, open actionEditorOpener) context.Context { + if open == nil { + open = editor.OpenTempAndEdit + } + return context.WithValue(ctx, actionEditorKey{}, open) +} + +func actionEditorFromContext(ctx context.Context) actionEditorOpener { + if open, ok := ctx.Value(actionEditorKey{}).(actionEditorOpener); ok && open != nil { + return open + } + return editor.OpenTempAndEdit +} + type actionPlan struct { fallback string run func(context.Context) (string, error) @@ -154,6 +174,7 @@ func (r *Runner) Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Wri chooser := chooseActionFromConfig newClient := defaultActionClientFactory loadConfig := loadActionConfig + openEditor := actionEditorOpener(editor.OpenTempAndEdit) statusSink := actionStatusSink(tmuxActionStatusSink{}) if r != nil { if r.chooseAction != nil { @@ -165,6 +186,9 @@ func (r *Runner) Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Wri if r.loadConfig != nil { loadConfig = r.loadConfig } + if r.openEditor != nil { + openEditor = r.openEditor + } if r.statusSink != nil { statusSink = r.statusSink } @@ -208,7 +232,7 @@ func (r *Runner) Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Wri if err != nil { return err } - out, err := executeAction(ctx, choice.kind, parts, &cfg, client, stderr, choice.custom) + out, err := executeAction(withActionEditor(ctx, openEditor), choice.kind, parts, &cfg, client, stderr, choice.custom) if err != nil { return err } @@ -381,7 +405,7 @@ func handleCustomAction(ctx context.Context, parts InputParts, cfg actionConfig, } func handleCustomPromptAction(ctx context.Context, parts InputParts, cfg actionConfig, client chatDoer, stderr io.Writer) (string, error) { - prompt, err := editor.OpenTempAndEdit(ctx, nil) + prompt, err := actionEditorFromContext(ctx)(ctx, nil) if err != nil || strings.TrimSpace(prompt) == "" { _, _ = fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: custom prompt canceled or empty; echoing input"+logging.AnsiReset) return parts.Selection, nil diff --git a/internal/hexaiaction/tui.go b/internal/hexaiaction/tui.go index 9155cfe..7506cdc 100644 --- a/internal/hexaiaction/tui.go +++ b/internal/hexaiaction/tui.go @@ -124,7 +124,11 @@ func (m model) View() string { // RunTUI returns the chosen ActionKind from the default hardcoded menu. func RunTUI() (ActionKind, error) { - p := tea.NewProgram(newModel()) + return tuiRunner{}.RunTUI() +} + +func (r tuiRunner) RunTUI() (ActionKind, error) { + p := r.program(newModel()) md, err := p.Run() if err != nil { return ActionSkip, err @@ -142,8 +146,12 @@ func RunTUI() (ActionKind, error) { // Custom entries are resolved by ID against customs. Falls back to ActionSkip // if the program returns an unexpected model type. func RunTUIFromConfig(entries []appconfig.TmuxActionMenuEntry, customs []appconfig.CustomAction) (ActionKind, *appconfig.CustomAction, error) { + return tuiRunner{}.RunTUIFromConfig(entries, customs) +} + +func (r tuiRunner) RunTUIFromConfig(entries []appconfig.TmuxActionMenuEntry, customs []appconfig.CustomAction) (ActionKind, *appconfig.CustomAction, error) { m := newModelFromMenuEntries(entries, customs) - p := teaNewProgram(m) + p := r.program(m) md, err := p.Run() if err != nil { return ActionSkip, nil, err diff --git a/internal/hexaiaction/tui_config_test.go b/internal/hexaiaction/tui_config_test.go index e8e178f..e1723ab 100644 --- a/internal/hexaiaction/tui_config_test.go +++ b/internal/hexaiaction/tui_config_test.go @@ -154,19 +154,16 @@ func TestHandleKey_ChosenCustomIsSet(t *testing.T) { } func TestRunTUIFromConfig_ViaTmuxActionSeam(t *testing.T) { - old := teaNewProgram - t.Cleanup(func() { teaNewProgram = old }) - - teaNewProgram = func(m model) teaProgram { + r := tuiRunner{newProgram: func(m model) teaProgram { return fakeProg{m: m, onRun: func(mm *model) { mm.chosen = ActionSkip }} - } + }} entries := []appconfig.TmuxActionMenuEntry{ {Kind: "skip", Hotkey: "s"}, } - kind, custom, err := RunTUIFromConfig(entries, nil) + kind, custom, err := r.RunTUIFromConfig(entries, nil) if err != nil { t.Fatalf("RunTUIFromConfig: %v", err) } diff --git a/internal/hexaiaction/tui_custom.go b/internal/hexaiaction/tui_custom.go index 2e6561b..910242f 100644 --- a/internal/hexaiaction/tui_custom.go +++ b/internal/hexaiaction/tui_custom.go @@ -12,6 +12,21 @@ import ( // RunTUIWithCustom shows the main menu plus a configurable "Custom actions…" item. // If the user selects that item, it shows a submenu listing user-defined custom actions. func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (ActionKind, *appconfig.CustomAction, error) { + return tuiRunner{}.RunTUIWithCustom(customs, menuHotkey) +} + +type tuiRunner struct { + newProgram func(model) teaProgram +} + +func (r tuiRunner) program(m model) teaProgram { + if r.newProgram != nil { + return r.newProgram(m) + } + return tea.NewProgram(m) +} + +func (r tuiRunner) RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (ActionKind, *appconfig.CustomAction, error) { // When no customs, fall back to default menu if len(customs) == 0 { kind, err := RunTUI() @@ -28,7 +43,7 @@ func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (Acti items = append(items, item{title: "Custom actions…", desc: "", kind: ActionCustom, hotkey: hk}) m.list.SetItems(items) // Run main menu - p := teaNewProgram(m) + p := r.program(m) md, err := p.Run() if err != nil { return ActionSkip, nil, err @@ -50,7 +65,7 @@ func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (Acti subItems = append(subItems, item{title: ca.Title, desc: "", kind: ActionCustom, hotkey: r}) } sub.list.SetItems(subItems) - sp := teaNewProgram(sub) + sp := r.program(sub) smd, err := sp.Run() if err != nil { return ActionSkip, nil, err @@ -69,8 +84,5 @@ func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (Acti return ActionSkip, nil, nil } -// teaNewProgram is a tiny seam for tests to stub bubbletea program creation. -var teaNewProgram = func(m model) teaProgram { return tea.NewProgram(m) } - // teaProgram is the subset of bubbletea.Program we need; enables testing seam. type teaProgram interface{ Run() (tea.Model, error) } diff --git a/internal/hexaiaction/tui_custom_test.go b/internal/hexaiaction/tui_custom_test.go index 5ded806..7f7e2d3 100644 --- a/internal/hexaiaction/tui_custom_test.go +++ b/internal/hexaiaction/tui_custom_test.go @@ -21,11 +21,8 @@ func (f fakeProg) Run() (tea.Model, error) { } func TestRunTUIWithCustom_SubmenuAndHotkeys(t *testing.T) { - old := teaNewProgram - t.Cleanup(func() { teaNewProgram = old }) - calls := 0 - teaNewProgram = func(m model) teaProgram { + r := tuiRunner{newProgram: func(m model) teaProgram { calls++ if calls == 1 { // Main menu should have "Custom actions…" with configured hotkey @@ -57,13 +54,13 @@ func TestRunTUIWithCustom_SubmenuAndHotkeys(t *testing.T) { return fakeProg{m: m, onRun: func(mm *model) { mm.list.Select(0) }} } return fakeProg{m: m} - } + }} customs := []appconfig.CustomAction{ {ID: "a", Title: "A", Hotkey: "x", Instruction: "do"}, {ID: "b", Title: "B", Hotkey: "y", Instruction: "do2"}, } - kind, selected, err := RunTUIWithCustom(customs, "z") + kind, selected, err := r.RunTUIWithCustom(customs, "z") if err != nil { t.Fatalf("RunTUIWithCustom error: %v", err) } -- cgit v1.2.3