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 --- cmd/ask/main.go | 16 +- cmd/ask/main_test.go | 28 ++-- cmd/hexai-tmux-action/main.go | 32 ++-- cmd/hexai-tmux-action/main_test.go | 52 +++--- cmd/hexai-tmux-edit/main.go | 13 +- cmd/hexai-tmux-edit/main_test.go | 39 ++--- internal/askcli/command_edit.go | 10 +- internal/askcli/command_edit_test.go | 31 ++-- internal/askcli/command_watch.go | 7 +- internal/askcli/command_watch_test.go | 33 ++-- internal/askcli/dispatch.go | 17 +- internal/editor/editor.go | 36 ++++- internal/editor/editor_test.go | 56 +++---- 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 +- internal/hexaicli/cache.go | 57 ++++++- internal/hexaicli/cache_test.go | 25 +-- internal/hexaicli/editor_integration_test.go | 33 ++-- internal/hexaicli/run.go | 8 +- internal/hexaicli/run_editor_behavior_test.go | 17 +- internal/hexaicli/runner.go | 23 +-- internal/hexaicli/runner_test.go | 15 +- internal/stats/stats.go | 34 +++- internal/stats/stats_test.go | 12 +- internal/tmux/status.go | 9 +- internal/tmux/status_coverage_test.go | 8 +- internal/tmux/tmux.go | 42 ++++- internal/tmux/tmux_test.go | 28 ++-- internal/tmuxedit/agent.go | 21 ++- internal/tmuxedit/agent_test.go | 16 +- internal/tmuxedit/agentutil.go | 16 +- internal/tmuxedit/agentutil_test.go | 24 ++- internal/tmuxedit/capture.go | 16 +- internal/tmuxedit/capture_test.go | 24 ++- internal/tmuxedit/cursor_agent.go | 4 +- internal/tmuxedit/cursor_agent_test.go | 20 +-- internal/tmuxedit/pane.go | 28 +++- internal/tmuxedit/pane_test.go | 40 ++--- internal/tmuxedit/run.go | 45 ++++-- internal/tmuxedit/run_test.go | 195 +++++++---------------- internal/tmuxedit/send.go | 39 +++-- internal/tmuxedit/send_test.go | 40 ++--- 49 files changed, 729 insertions(+), 677 deletions(-) diff --git a/cmd/ask/main.go b/cmd/ask/main.go index fbd5bb0..689cce0 100644 --- a/cmd/ask/main.go +++ b/cmd/ask/main.go @@ -15,19 +15,21 @@ 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) +type app struct { + newDispatcher func() dispatcher } -func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } +func newApp() *app { + return &app{newDispatcher: func() dispatcher { return askcli.NewDispatcher(nil) }} +} + +func main() { os.Exit(newApp().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) +func (a *app) runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + code, err := a.newDispatcher().Dispatch(context.Background(), args, stdin, stdout, stderr) if err != nil { fmt.Fprintln(stderr, err) } diff --git a/cmd/ask/main_test.go b/cmd/ask/main_test.go index 643dde4..0f6a0c7 100644 --- a/cmd/ask/main_test.go +++ b/cmd/ask/main_test.go @@ -68,14 +68,11 @@ func (f *fakeDispatcher) Dispatch(_ context.Context, args []string, _ io.Reader, // 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 } + a := &app{newDispatcher: func() dispatcher { return fake }} var stdout, stderr bytes.Buffer - got := runMain([]string{"list", "limit:1"}, nil, &stdout, &stderr) + got := a.runMain([]string{"list", "limit:1"}, nil, &stdout, &stderr) if got != 0 { t.Fatalf("runMain code = %d, want 0", got) } @@ -87,30 +84,27 @@ func TestRunMain_DelegatesAndReturnsCode(t *testing.T) { } } -// 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() +// The default app dispatcher factory 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 TestNewApp_DefaultReturnsRealDispatcher(t *testing.T) { + d := newApp().newDispatcher() if d == nil { - t.Fatal("default dispatcherFactory returned nil") + t.Fatal("default dispatcher factory returned nil") } if _, ok := d.(*askcli.Dispatcher); !ok { - t.Fatalf("default dispatcherFactory returned %T, want *askcli.Dispatcher", d) + t.Fatalf("default dispatcher factory 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 } + a := &app{newDispatcher: func() dispatcher { return fake }} var stdout, stderr bytes.Buffer - got := runMain(nil, nil, &stdout, &stderr) + got := a.runMain(nil, nil, &stdout, &stderr) if got != 7 { t.Fatalf("runMain code = %d, want 7", got) } diff --git a/cmd/hexai-tmux-action/main.go b/cmd/hexai-tmux-action/main.go index e2f50eb..8830aa8 100644 --- a/cmd/hexai-tmux-action/main.go +++ b/cmd/hexai-tmux-action/main.go @@ -12,18 +12,20 @@ import ( "codeberg.org/snonux/hexai/internal/hexaiaction" ) -// runCommand is the seam for testing: override in tests to avoid launching -// the real tmux action. -var runCommand = hexaiaction.RunCommand +// actionRunner is the dependency that performs the tmux action. Production +// code uses hexaiaction.RunCommand; tests inject a stub to avoid launching the +// real tmux popup. +type actionRunner func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error -func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } +func main() { os.Exit(newApp().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 { +// touching package-level flag state. It is a method on app so tests can inject +// a stub runCommand. +func (a *app) 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") @@ -43,7 +45,7 @@ func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int { uiChild: *uiChild, configPath: *configPath, tmuxTarget: *tmuxTarget, tmuxPopupWidth: *tmuxPopupWidth, tmuxPopupHeight: *tmuxPopupHeight, } - if err := run(opts, stdin, stdout, stderr); err != nil { + if err := a.run(opts, stdin, stdout, stderr); err != nil { fmt.Fprintln(stderr, err) return 1 } @@ -61,8 +63,18 @@ type actionOptions struct { tmuxPopupHeight string } -// run builds the hexaiaction.Options and context, then delegates to runCommand. -func run(opts actionOptions, stdin io.Reader, stdout, stderr io.Writer) error { +// app wires the injected dependencies for the command. runCommand defaults to +// hexaiaction.RunCommand in production and is replaced by tests. +type app struct { + runCommand actionRunner +} + +// newApp returns an app with the production action runner installed. +func newApp() *app { return &app{runCommand: hexaiaction.RunCommand} } + +// run builds the hexaiaction.Options and context, then delegates to the +// injected runCommand dependency. +func (a *app) run(opts actionOptions, stdin io.Reader, stdout, stderr io.Writer) error { haOpts := hexaiaction.Options{ Infile: opts.infile, Outfile: opts.outfile, UIChild: opts.uiChild, TmuxTarget: opts.tmuxTarget, @@ -72,5 +84,5 @@ func run(opts actionOptions, stdin io.Reader, stdout, stderr io.Writer) error { if path := strings.TrimSpace(opts.configPath); path != "" { ctx = hexaiaction.WithConfigPath(ctx, path) } - return runCommand(ctx, haOpts, stdin, stdout, stderr) + return a.runCommand(ctx, haOpts, stdin, stdout, stderr) } diff --git a/cmd/hexai-tmux-action/main_test.go b/cmd/hexai-tmux-action/main_test.go index e1c02e1..98f0c7c 100644 --- a/cmd/hexai-tmux-action/main_test.go +++ b/cmd/hexai-tmux-action/main_test.go @@ -12,20 +12,17 @@ import ( ) func TestRun_DelegatesToRunCommand(t *testing.T) { - old := runCommand - t.Cleanup(func() { runCommand = old }) - var gotOpts hexaiaction.Options - runCommand = func(_ context.Context, opts hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { + a := &app{runCommand: func(_ context.Context, opts hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { gotOpts = opts return nil - } + }} opts := actionOptions{ infile: "in.txt", outfile: "out.txt", tmuxPopupWidth: "90%", tmuxPopupHeight: "70%", } - if err := run(opts, nil, nil, nil); err != nil { + if err := a.run(opts, nil, nil, nil); err != nil { t.Fatalf("run: %v", err) } if gotOpts.Infile != "in.txt" || gotOpts.Outfile != "out.txt" { @@ -37,29 +34,23 @@ func TestRun_DelegatesToRunCommand(t *testing.T) { } func TestRun_WithConfigPath(t *testing.T) { - old := runCommand - t.Cleanup(func() { runCommand = old }) - - runCommand = func(_ context.Context, _ hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { + a := &app{runCommand: func(_ context.Context, _ hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { return nil - } + }} opts := actionOptions{configPath: " /tmp/test.toml "} - if err := run(opts, nil, nil, nil); err != nil { + if err := a.run(opts, nil, nil, nil); err != nil { t.Fatalf("run: %v", err) } } func TestRun_Error(t *testing.T) { - old := runCommand - t.Cleanup(func() { runCommand = old }) - wantErr := errors.New("action failed") - runCommand = func(_ context.Context, _ hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { + a := &app{runCommand: func(_ context.Context, _ hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { return wantErr - } + }} - if err := run(actionOptions{}, nil, nil, nil); !errors.Is(err, wantErr) { + if err := a.run(actionOptions{}, nil, nil, nil); !errors.Is(err, wantErr) { t.Fatalf("expected error, got: %v", err) } } @@ -68,14 +59,11 @@ func TestRun_Error(t *testing.T) { // 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 { + a := &app{runCommand: func(_ context.Context, opts hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { got = opts return nil - } + }} args := []string{ "-infile", "in.txt", @@ -86,7 +74,7 @@ func TestRunMain_FlagsForwardedToHexaiaction(t *testing.T) { "-ui-child", } var stderr bytes.Buffer - code := runMain(args, nil, &bytes.Buffer{}, &stderr) + code := a.runMain(args, nil, &bytes.Buffer{}, &stderr) if code != 0 { t.Fatalf("runMain code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -104,14 +92,12 @@ func TestRunMain_FlagsForwardedToHexaiaction(t *testing.T) { // 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 { + a := &app{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) + code := a.runMain(nil, nil, &bytes.Buffer{}, &stderr) if code != 1 { t.Fatalf("runMain code = %d, want 1", code) } @@ -122,15 +108,13 @@ func TestRunMain_RuntimeErrorReturnsOne(t *testing.T) { // 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 { + a := &app{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) + code := a.runMain([]string{"--bogus"}, nil, &bytes.Buffer{}, &stderr) if code != 2 { t.Fatalf("runMain code = %d, want 2", code) } diff --git a/cmd/hexai-tmux-edit/main.go b/cmd/hexai-tmux-edit/main.go index 6177008..d61f68a 100644 --- a/cmd/hexai-tmux-edit/main.go +++ b/cmd/hexai-tmux-edit/main.go @@ -22,15 +22,18 @@ import ( "codeberg.org/snonux/hexai/internal/tmuxedit" ) -// runTmuxEdit is the seam for testing: override in tests to avoid real tmux. -var runTmuxEdit = tmuxedit.Run +type app struct { + runTmuxEdit func(tmuxedit.Options) error +} + +func newApp() *app { return &app{runTmuxEdit: tmuxedit.Run} } -func main() { os.Exit(runMain(os.Args[1:], os.Stderr)) } +func main() { os.Exit(newApp().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 { +func (a *app) runMain(args []string, stderr io.Writer) int { defaultPath := appconfig.DefaultConfigPath() fs := flag.NewFlagSet("hexai-tmux-edit", flag.ContinueOnError) fs.SetOutput(stderr) @@ -42,7 +45,7 @@ func runMain(args []string, stderr io.Writer) int { } opts := buildOptions(*configPath, *agent, *pane) - if err := runTmuxEdit(opts); err != nil { + if err := a.runTmuxEdit(opts); err != nil { fmt.Fprintln(stderr, err) return 1 } diff --git a/cmd/hexai-tmux-edit/main_test.go b/cmd/hexai-tmux-edit/main_test.go index 6881556..3171b86 100644 --- a/cmd/hexai-tmux-edit/main_test.go +++ b/cmd/hexai-tmux-edit/main_test.go @@ -30,17 +30,14 @@ func TestBuildOptions_TrimsWhitespace(t *testing.T) { } func TestRunTmuxEdit_Success(t *testing.T) { - old := runTmuxEdit - t.Cleanup(func() { runTmuxEdit = old }) - var gotOpts tmuxedit.Options - runTmuxEdit = func(opts tmuxedit.Options) error { + a := &app{runTmuxEdit: func(opts tmuxedit.Options) error { gotOpts = opts return nil - } + }} opts := buildOptions("/tmp/cfg.toml", "cursor", "%3") - if err := runTmuxEdit(opts); err != nil { + if err := a.runTmuxEdit(opts); err != nil { t.Fatalf("runTmuxEdit: %v", err) } if gotOpts.ConfigPath != "/tmp/cfg.toml" || gotOpts.Agent != "cursor" || gotOpts.Pane != "%3" { @@ -49,13 +46,10 @@ func TestRunTmuxEdit_Success(t *testing.T) { } func TestRunTmuxEdit_Error(t *testing.T) { - old := runTmuxEdit - t.Cleanup(func() { runTmuxEdit = old }) - wantErr := errors.New("tmux not found") - runTmuxEdit = func(_ tmuxedit.Options) error { return wantErr } + a := &app{runTmuxEdit: func(_ tmuxedit.Options) error { return wantErr }} - if err := runTmuxEdit(tmuxedit.Options{}); !errors.Is(err, wantErr) { + if err := a.runTmuxEdit(tmuxedit.Options{}); !errors.Is(err, wantErr) { t.Fatalf("expected error, got: %v", err) } } @@ -63,17 +57,14 @@ func TestRunTmuxEdit_Error(t *testing.T) { // 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 { + a := &app{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) + code := a.runMain([]string{"-config", " /tmp/cfg.toml ", "-agent", "claude", "-pane", "%9"}, &stderr) if code != 0 { t.Fatalf("runMain code = %d, want 0", code) } @@ -88,12 +79,10 @@ func TestRunMain_FlagsForwardedToTmuxedit(t *testing.T) { // 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") } + a := &app{runTmuxEdit: func(tmuxedit.Options) error { return errors.New("boom") }} var stderr bytes.Buffer - code := runMain(nil, &stderr) + code := a.runMain(nil, &stderr) if code != 1 { t.Fatalf("runMain code = %d, want 1", code) } @@ -105,16 +94,14 @@ func TestRunMain_RunErrorReturnsOne(t *testing.T) { // 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 { + a := &app{runTmuxEdit: func(tmuxedit.Options) error { called = true return nil - } + }} var stderr bytes.Buffer - code := runMain([]string{"--no-such-flag"}, &stderr) + code := a.runMain([]string{"--no-such-flag"}, &stderr) if code != 2 { t.Fatalf("runMain code = %d, want 2", code) } diff --git a/internal/askcli/command_edit.go b/internal/askcli/command_edit.go index a82d575..e2c2dbf 100644 --- a/internal/askcli/command_edit.go +++ b/internal/askcli/command_edit.go @@ -8,11 +8,11 @@ import ( "codeberg.org/snonux/hexai/internal/editor" ) -// captureFromEditor opens the user's editor on a temporary file pre-filled with +// editorCapture opens the user's editor on a temporary file pre-filled with // the given initial content and returns its trimmed contents after the editor // exits. ctx is forwarded to the editor subprocess so it can be cancelled with -// the surrounding command. It is a variable so tests can stub it. -var captureFromEditor = func(ctx context.Context, initial []byte) (string, error) { +// the surrounding command. +func editorCapture(ctx context.Context, initial []byte) (string, error) { return editor.OpenTempAndEdit(ctx, initial) } @@ -23,7 +23,7 @@ func (d *Dispatcher) handleEdit(ctx context.Context, args []string, stdout, stde if len(args) >= 2 { return d.editTaskDescription(ctx, args[1], stdout, stderr) } - description, err := captureFromEditor(ctx, nil) + description, err := d.capture(ctx, nil) if err != nil { writeInfoError(stderr, err) return 1, nil @@ -44,7 +44,7 @@ func (d *Dispatcher) editTaskDescription(ctx context.Context, selector string, s return code, nil } - description, err := captureFromEditor(ctx, []byte(tasks[0].Description)) + description, err := d.capture(ctx, []byte(tasks[0].Description)) if err != nil { writeInfoError(stderr, err) return 1, nil diff --git a/internal/askcli/command_edit_test.go b/internal/askcli/command_edit_test.go index 4db19c1..91569c8 100644 --- a/internal/askcli/command_edit_test.go +++ b/internal/askcli/command_edit_test.go @@ -9,13 +9,10 @@ import ( "testing" ) -func stubEditorCapture(t *testing.T, content string, err error) { - t.Helper() - old := captureFromEditor - captureFromEditor = func(_ context.Context, initial []byte) (string, error) { +func stubEditorCapture(d *Dispatcher, content string, err error) { + d.capture = func(_ context.Context, initial []byte) (string, error) { return content, err } - t.Cleanup(func() { captureFromEditor = old }) } func TestHandleEdit_Success(t *testing.T) { @@ -26,15 +23,14 @@ func TestHandleEdit_Success(t *testing.T) { {UUID: "existing-uuid", Alias: "0", CreatedAt: now}, }, }) - // editor.OpenTempAndEdit trims content, so mimic that here. - stubEditorCapture(t, "Multi line\ntask description", nil) - var capturedArgs []string d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { capturedArgs = args _, _ = io.WriteString(stdout, "Created task abc-123-def.") return 0, nil }}) + // editor.OpenTempAndEdit trims content, so mimic that here. + stubEditorCapture(d, "Multi line\ntask description", nil) var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr) @@ -58,15 +54,8 @@ func TestHandleEdit_ExistingTaskModifiesDescription(t *testing.T) { }, }) - var initialContent []byte - old := captureFromEditor - captureFromEditor = func(_ context.Context, initial []byte) (string, error) { - initialContent = initial - return "updated description", nil - } - t.Cleanup(func() { captureFromEditor = old }) - var capturedArgs []string + var initialContent []byte d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { // First call: export to resolve selector. Subsequent: modify. if len(args) >= 2 && args[1] == "export" { @@ -76,6 +65,10 @@ func TestHandleEdit_ExistingTaskModifiesDescription(t *testing.T) { capturedArgs = args return 0, nil }}) + d.capture = func(_ context.Context, initial []byte) (string, error) { + initialContent = initial + return "updated description", nil + } var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"edit", "0"}, nil, &stdout, &stderr) @@ -97,12 +90,11 @@ func TestHandleEdit_ExistingTaskModifiesDescription(t *testing.T) { } func TestHandleEdit_EmptyContentAborts(t *testing.T) { - stubEditorCapture(t, "", nil) - d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { t.Fatalf("runner should not be called on empty content") return 0, nil }}) + stubEditorCapture(d, "", nil) var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr) @@ -115,12 +107,11 @@ func TestHandleEdit_EmptyContentAborts(t *testing.T) { } func TestHandleEdit_EditorError(t *testing.T) { - stubEditorCapture(t, "", errors.New("boom")) - d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { t.Fatalf("runner should not be called when editor fails") return 0, nil }}) + stubEditorCapture(d, "", errors.New("boom")) var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr) diff --git a/internal/askcli/command_watch.go b/internal/askcli/command_watch.go index 8d8e35f..0e0a678 100644 --- a/internal/askcli/command_watch.go +++ b/internal/askcli/command_watch.go @@ -30,7 +30,10 @@ func (t realWatchTicker) Stop() { t.ticker.Stop() } -var newWatchTicker = func(interval time.Duration) watchTicker { +// newRealWatchTicker is the default watchTicker factory wired into a +// Dispatcher by NewDispatcher. It is injected as Dispatcher.newTicker so tests +// can substitute a fake ticker instead of mutating package state. +func newRealWatchTicker(interval time.Duration) watchTicker { return realWatchTicker{ticker: time.NewTicker(interval)} } @@ -63,7 +66,7 @@ func (d *Dispatcher) handleWatch(ctx context.Context, args []string, stdout, std return 1, nil } - ticker := newWatchTicker(watchInterval) + ticker := d.newTicker(watchInterval) defer ticker.Stop() var lastOutput []byte diff --git a/internal/askcli/command_watch_test.go b/internal/askcli/command_watch_test.go index abd74ff..7618060 100644 --- a/internal/askcli/command_watch_test.go +++ b/internal/askcli/command_watch_test.go @@ -27,14 +27,11 @@ func (t *fakeWatchTicker) Stop() { func TestHandleWatch_ForwardsNonZeroCode(t *testing.T) { ticks := make(chan time.Time) - oldTicker := newWatchTicker - newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } - t.Cleanup(func() { newWatchTicker = oldTicker }) - ctx := context.Background() d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { return 1, nil }}) + d.newTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } var stdout, stderr bytes.Buffer code, err := d.Dispatch(ctx, []string{"watch", "urgency"}, nil, &stdout, &stderr) @@ -70,10 +67,6 @@ func TestHandleWatch_ForwardsInnerError(t *testing.T) { func TestHandleWatch_DrawsStderrOnNonZero(t *testing.T) { ticks := make(chan time.Time) - oldTicker := newWatchTicker - newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } - t.Cleanup(func() { newWatchTicker = oldTicker }) - ctx, cancel := context.WithCancel(context.Background()) callCount := 0 d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -87,6 +80,7 @@ func TestHandleWatch_DrawsStderrOnNonZero(t *testing.T) { _, _ = io.WriteString(stdout, "[]") return 0, nil }}) + d.newTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } var out bytes.Buffer code, err := d.Dispatch(ctx, []string{"watch", "info"}, nil, &out, &bytes.Buffer{}) @@ -118,14 +112,6 @@ func TestHandleWatch_DefaultsToListAndRedrawsOnChange(t *testing.T) { ticks := make(chan time.Time, 1) ticks <- time.Now() fakeTicker := &fakeWatchTicker{ch: ticks} - oldTicker := newWatchTicker - newWatchTicker = func(interval time.Duration) watchTicker { - if interval != watchInterval { - t.Fatalf("watch interval = %s, want %s", interval, watchInterval) - } - return fakeTicker - } - t.Cleanup(func() { newWatchTicker = oldTicker }) ctx, cancel := context.WithCancel(context.Background()) var calls [][]string @@ -139,6 +125,12 @@ func TestHandleWatch_DefaultsToListAndRedrawsOnChange(t *testing.T) { cancel() return 0, nil }}) + d.newTicker = func(interval time.Duration) watchTicker { + if interval != watchInterval { + t.Fatalf("watch interval = %s, want %s", interval, watchInterval) + } + return fakeTicker + } var stdout, stderr bytes.Buffer code, err := d.Dispatch(ctx, []string{"watch"}, nil, &stdout, &stderr) @@ -176,9 +168,6 @@ func TestHandleWatch_DoesNotRedrawUnchangedOutput(t *testing.T) { ticks := make(chan time.Time, 1) ticks <- time.Now() - oldTicker := newWatchTicker - newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } - t.Cleanup(func() { newWatchTicker = oldTicker }) ctx, cancel := context.WithCancel(context.Background()) runCount := 0 @@ -190,6 +179,7 @@ func TestHandleWatch_DoesNotRedrawUnchangedOutput(t *testing.T) { } return 0, nil }}) + d.newTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } var stdout, stderr bytes.Buffer code, err := d.Dispatch(ctx, []string{"watch", "list"}, nil, &stdout, &stderr) @@ -206,10 +196,6 @@ func TestHandleWatch_DoesNotRedrawUnchangedOutput(t *testing.T) { func TestHandleWatch_ForwardsSubcommandArgs(t *testing.T) { ticks := make(chan time.Time) - oldTicker := newWatchTicker - newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } - t.Cleanup(func() { newWatchTicker = oldTicker }) - ctx, cancel := context.WithCancel(context.Background()) var gotArgs []string d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -218,6 +204,7 @@ func TestHandleWatch_ForwardsSubcommandArgs(t *testing.T) { _, _ = io.WriteString(stdout, `[]`) return 0, nil }}) + d.newTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } var stdout, stderr bytes.Buffer code, err := d.Dispatch(ctx, []string{"watch", "ready", "limit:2"}, nil, &stdout, &stderr) diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go index 924ca67..cdfc1d8 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "time" ) // Runner performs CLI work that would otherwise be handled by the ask CLI itself. @@ -14,18 +15,30 @@ type Runner interface { } // Dispatcher translates CLI arguments into concrete subcommands and presents the output. +// +// newTicker is the injected factory for the watch-loop ticker. Production code +// uses the real time.Ticker-backed factory installed by NewDispatcher; tests +// inject a fake ticker so they can drive the watch loop without real delays. type Dispatcher struct { runner Runner jsonOutput bool + newTicker func(time.Duration) watchTicker + capture func(context.Context, []byte) (string, error) } -// NewDispatcher creates a Dispatcher backed by the provided Runner or a default executor when nil. +// NewDispatcher creates a Dispatcher backed by the provided Runner or a default +// executor when nil. It wires the default real-ticker factory used by the +// `ask watch` loop. func NewDispatcher(runner Runner) *Dispatcher { if runner == nil { e := NewExecutor("ask") runner = &e } - return &Dispatcher{runner: runner} + return &Dispatcher{ + runner: runner, + newTicker: newRealWatchTicker, + capture: editorCapture, + } } func parseGlobalFlags(args []string) ([]string, bool) { diff --git a/internal/editor/editor.go b/internal/editor/editor.go index 9a9e737..aad7047 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -21,11 +21,25 @@ func Resolve() (string, error) { return ed, nil } -// RunEditor is the seam that invokes the editor on the given file path. -// Override in tests to avoid launching a real editor. It uses -// exec.CommandContext so a cancelled ctx (e.g. process shutdown) kills the -// editor subprocess instead of leaving it blocking on terminal input. -var RunEditor = func(ctx context.Context, editor, path string) error { +type Runner struct { + runEditor func(context.Context, string, string) error +} + +func (r Runner) edit(ctx context.Context, editor, path string) error { + if r.runEditor != nil { + return r.runEditor(ctx, editor, path) + } + return runEditor(ctx, editor, path) +} + +// RunEditor invokes the editor on the given file path. It uses +// exec.CommandContext so a cancelled ctx kills the editor subprocess instead +// of leaving it blocking on terminal input. +func RunEditor(ctx context.Context, editor, path string) error { + return runEditor(ctx, editor, path) +} + +func runEditor(ctx context.Context, editor, path string) error { cmd := exec.CommandContext(ctx, editor, path) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout @@ -38,6 +52,10 @@ var RunEditor = func(ctx context.Context, editor, path string) error { // Returns the trimmed content. ctx is forwarded to the editor subprocess so it // can be cancelled along with the surrounding command. func OpenTempAndEdit(ctx context.Context, initial []byte) (string, error) { + return Runner{}.OpenTempAndEdit(ctx, initial) +} + +func (r Runner) OpenTempAndEdit(ctx context.Context, initial []byte) (string, error) { ed, err := Resolve() if err != nil { return "", err @@ -63,7 +81,7 @@ func OpenTempAndEdit(ctx context.Context, initial []byte) (string, error) { if err := f.Close(); err != nil { return "", err } - if err := RunEditor(ctx, ed, path); err != nil { + if err := r.edit(ctx, ed, path); err != nil { return "", err } b, err := os.ReadFile(filepath.Clean(path)) @@ -77,6 +95,10 @@ func OpenTempAndEdit(ctx context.Context, initial []byte) (string, error) { // from Resolve() (HEXAI_EDITOR or EDITOR). ctx is forwarded to the editor // subprocess so it can be cancelled with the surrounding command. func OpenFile(ctx context.Context, path string) error { + return Runner{}.OpenFile(ctx, path) +} + +func (r Runner) OpenFile(ctx context.Context, path string) error { ed, err := Resolve() if err != nil { return err @@ -91,5 +113,5 @@ func OpenFile(ctx context.Context, path string) error { return mkErr } } - return RunEditor(ctx, ed, path) + return r.edit(ctx, ed, path) } diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go index f2c1e22..543235e 100644 --- a/internal/editor/editor_test.go +++ b/internal/editor/editor_test.go @@ -75,17 +75,15 @@ func TestResolve_WhitespaceOnly(t *testing.T) { } func TestOpenTempAndEdit_UsesRunEditor(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) // Ensure Resolve() succeeds t.Setenv("HEXAI_EDITOR", "dummy") var capturedPath string - RunEditor = func(_ context.Context, editor, path string) error { + r := Runner{runEditor: func(_ context.Context, editor, path string) error { capturedPath = path // simulate user writing content return os.WriteFile(path, []byte("Hello\nWorld\n"), 0o600) - } - out, err := OpenTempAndEdit(context.Background(), []byte("# Start\n\n")) + }} + out, err := r.OpenTempAndEdit(context.Background(), []byte("# Start\n\n")) if err != nil { t.Fatalf("OpenTempAndEdit: %v", err) } @@ -109,14 +107,12 @@ func TestOpenTempAndEdit_NoEditor(t *testing.T) { // TestOpenTempAndEdit_NilInitial verifies that nil initial content works (empty file). func TestOpenTempAndEdit_NilInitial(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) t.Setenv("HEXAI_EDITOR", "dummy") - RunEditor = func(_ context.Context, editor, path string) error { + r := Runner{runEditor: func(_ context.Context, editor, path string) error { // simulate user writing content into a file that started empty return os.WriteFile(path, []byte("result"), 0o600) - } - out, err := OpenTempAndEdit(context.Background(), nil) + }} + out, err := r.OpenTempAndEdit(context.Background(), nil) if err != nil { t.Fatalf("OpenTempAndEdit with nil initial: %v", err) } @@ -128,13 +124,11 @@ func TestOpenTempAndEdit_NilInitial(t *testing.T) { // TestOpenTempAndEdit_EmptyInitial verifies that empty (zero-length) initial content // skips the write branch but still works end-to-end. func TestOpenTempAndEdit_EmptyInitial(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) t.Setenv("HEXAI_EDITOR", "dummy") - RunEditor = func(_ context.Context, editor, path string) error { + r := Runner{runEditor: func(_ context.Context, editor, path string) error { return os.WriteFile(path, []byte(" trimmed "), 0o600) - } - out, err := OpenTempAndEdit(context.Background(), []byte{}) + }} + out, err := r.OpenTempAndEdit(context.Background(), []byte{}) if err != nil { t.Fatalf("OpenTempAndEdit with empty initial: %v", err) } @@ -145,14 +139,12 @@ func TestOpenTempAndEdit_EmptyInitial(t *testing.T) { // TestOpenTempAndEdit_EditorError verifies that an editor failure propagates the error. func TestOpenTempAndEdit_EditorError(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) t.Setenv("HEXAI_EDITOR", "dummy") editorErr := errors.New("editor crashed") - RunEditor = func(_ context.Context, editor, path string) error { + r := Runner{runEditor: func(_ context.Context, editor, path string) error { return editorErr - } - _, err := OpenTempAndEdit(context.Background(), []byte("some content")) + }} + _, err := r.OpenTempAndEdit(context.Background(), []byte("some content")) if err == nil { t.Fatal("expected error when editor fails") } @@ -163,14 +155,12 @@ func TestOpenTempAndEdit_EditorError(t *testing.T) { // TestOpenTempAndEdit_EditorDeletesFile verifies error when the editor removes the temp file. func TestOpenTempAndEdit_EditorDeletesFile(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) t.Setenv("HEXAI_EDITOR", "dummy") - RunEditor = func(_ context.Context, editor, path string) error { + r := Runner{runEditor: func(_ context.Context, editor, path string) error { // simulate the editor deleting the file return os.Remove(path) - } - _, err := OpenTempAndEdit(context.Background(), []byte("content")) + }} + _, err := r.OpenTempAndEdit(context.Background(), []byte("content")) if err == nil { t.Fatal("expected error when temp file is deleted by editor") } @@ -178,15 +168,13 @@ func TestOpenTempAndEdit_EditorDeletesFile(t *testing.T) { // TestOpenTempAndEdit_TempFileCleanup verifies the temp file is removed after success. func TestOpenTempAndEdit_TempFileCleanup(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) t.Setenv("HEXAI_EDITOR", "dummy") var capturedPath string - RunEditor = func(_ context.Context, editor, path string) error { + r := Runner{runEditor: func(_ context.Context, editor, path string) error { capturedPath = path return os.WriteFile(path, []byte("done"), 0o600) - } - _, err := OpenTempAndEdit(context.Background(), nil) + }} + _, err := r.OpenTempAndEdit(context.Background(), nil) if err != nil { t.Fatalf("OpenTempAndEdit: %v", err) } @@ -197,17 +185,15 @@ func TestOpenTempAndEdit_TempFileCleanup(t *testing.T) { } func TestOpenFile_CreatesParentAndInvokesEditor(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) t.Setenv("HEXAI_EDITOR", "dummy") target := filepath.Join(t.TempDir(), "nested", "config.toml") var gotEditor, gotPath string - RunEditor = func(_ context.Context, editorCmd, path string) error { + r := Runner{runEditor: func(_ context.Context, editorCmd, path string) error { gotEditor = editorCmd gotPath = path return nil - } - if err := OpenFile(context.Background(), target); err != nil { + }} + if err := r.OpenFile(context.Background(), target); err != nil { t.Fatalf("OpenFile: %v", err) } if gotEditor != "dummy" { 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) } diff --git a/internal/hexaicli/cache.go b/internal/hexaicli/cache.go index 544eab0..742ffce 100644 --- a/internal/hexaicli/cache.go +++ b/internal/hexaicli/cache.go @@ -1,6 +1,7 @@ package hexaicli import ( + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -14,7 +15,38 @@ import ( const cliResponseCacheTTL = 24 * time.Hour -var nowCLIResponseCache = time.Now +// responseCache carries the injectable dependencies for the on-disk CLI +// response cache. The only dependency is the clock used to stamp entries and +// decide expiry. Production code uses defaultResponseCache (backed by +// time.Now); tests construct a responseCache with a fake clock to exercise TTL +// expiry without sleeping. +type responseCache struct { + now func() time.Time +} + +// defaultResponseCache is the production cache used by the package-level +// lookup/store wrappers. It reads the real wall clock. +var defaultResponseCache = responseCache{now: time.Now} + +// cacheNowContextKey carries an injected clock through the request context so +// the cache TTL logic can be driven deterministically (e.g. in tests) without +// mutating package state. +type cacheNowContextKey struct{} + +// withCLIResponseCacheNow returns a context carrying now as the clock the CLI +// response cache should use for stamping and expiring entries. +func withCLIResponseCacheNow(ctx context.Context, now func() time.Time) context.Context { + return context.WithValue(ctx, cacheNowContextKey{}, now) +} + +// responseCacheFromContext builds a responseCache using the clock injected via +// withCLIResponseCacheNow, falling back to the real wall clock. +func responseCacheFromContext(ctx context.Context) responseCache { + if now, ok := ctx.Value(cacheNowContextKey{}).(func() time.Time); ok && now != nil { + return responseCache{now: now} + } + return defaultResponseCache +} type cliResponseCacheKey struct { Provider string `json:"provider"` @@ -39,7 +71,21 @@ func newCLIResponseCacheKey(provider, model string, req requestArgs, msgs []llm. } } -func lookupCLIResponseCache(key cliResponseCacheKey) (string, time.Duration, bool) { +// lookupCLIResponseCache reads a cached response using the clock injected into +// ctx (defaulting to the real wall clock). +func lookupCLIResponseCache(ctx context.Context, key cliResponseCacheKey) (string, time.Duration, bool) { + return responseCacheFromContext(ctx).lookup(key) +} + +// storeCLIResponseCache writes a cached response using the clock injected into +// ctx (defaulting to the real wall clock). +func storeCLIResponseCache(ctx context.Context, key cliResponseCacheKey, output string) { + responseCacheFromContext(ctx).store(key, output) +} + +// lookup returns the cached output for key, its age, and whether it is a valid +// (non-expired) hit. Expired entries are removed. +func (c responseCache) lookup(key cliResponseCacheKey) (string, time.Duration, bool) { path, ok := cliResponseCachePath(key) if !ok { return "", 0, false @@ -48,7 +94,7 @@ func lookupCLIResponseCache(key cliResponseCacheKey) (string, time.Duration, boo if !ok { return "", 0, false } - age := nowCLIResponseCache().Sub(entry.CreatedAt) + age := c.now().Sub(entry.CreatedAt) if age > cliResponseCacheTTL { _ = os.Remove(path) return "", 0, false @@ -56,7 +102,8 @@ func lookupCLIResponseCache(key cliResponseCacheKey) (string, time.Duration, boo return entry.Output, age, true } -func storeCLIResponseCache(key cliResponseCacheKey, output string) { +// store persists output for key, stamping it with the injected clock. +func (c responseCache) store(key cliResponseCacheKey, output string) { path, ok := cliResponseCachePath(key) if !ok { return @@ -64,7 +111,7 @@ func storeCLIResponseCache(key cliResponseCacheKey, output string) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return } - entry := cliResponseCacheEntry{CreatedAt: nowCLIResponseCache().UTC(), Output: output} + entry := cliRe