From 235004660186f2bc2e664c8a92c69ffc5a7fb38a Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 2 May 2026 09:45:26 +1000 Subject: [PATCH] Add Runner abstraction with Real and Fake implementations Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/runner/runner.go | 119 +++++++++++++++++++++++++++++++++ internal/runner/runner_test.go | 60 +++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 internal/runner/runner.go create mode 100644 internal/runner/runner_test.go diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 0000000..c4610ab --- /dev/null +++ b/internal/runner/runner.go @@ -0,0 +1,119 @@ +package runner + +import ( + "context" + "fmt" + "os/exec" +) + +// Spec describes a single command to run. +type Spec struct { + Name string + Args []string + Dir string + Env []string + Stdin []byte +} + +// Runner runs external commands. Production code uses Real; tests use Fake. +type Runner interface { + Run(ctx context.Context, s Spec) ([]byte, error) +} + +// Real executes via os/exec and returns combined stdout+stderr. +type Real struct{} + +func (Real) Run(ctx context.Context, s Spec) ([]byte, error) { + cmd := exec.CommandContext(ctx, s.Name, s.Args...) + if s.Dir != "" { + cmd.Dir = s.Dir + } + if len(s.Env) > 0 { + cmd.Env = s.Env + } + if len(s.Stdin) > 0 { + cmd.Stdin = bytesReader(s.Stdin) + } + out, err := cmd.CombinedOutput() + if err != nil { + return out, fmt.Errorf("%s %v: %w (output: %s)", s.Name, s.Args, err, string(out)) + } + return out, nil +} + +// bytesReader avoids a bytes import in this small file. +func bytesReader(b []byte) *bytesBuf { return &bytesBuf{b: b} } + +type bytesBuf struct { + b []byte + i int +} + +func (r *bytesBuf) Read(p []byte) (int, error) { + if r.i >= len(r.b) { + return 0, eof + } + n := copy(p, r.b[r.i:]) + r.i += n + return n, nil +} + +var eof = fmt.Errorf("EOF") + +// Call is a recorded invocation made against a Fake. +type Call struct { + Name string + Args []string + Dir string +} + +type fakeResult struct { + out []byte + err error +} + +// Fake records calls and returns configured results. +type Fake struct { + Calls []Call + results map[string]fakeResult +} + +// On configures a result for the given name + args. nil args matches any. +func (f *Fake) On(name string, args []string) *FakeBuilder { + return &FakeBuilder{f: f, name: name, args: args} +} + +type FakeBuilder struct { + f *Fake + name string + args []string +} + +func (b *FakeBuilder) Return(out []byte, err error) { + if b.f.results == nil { + b.f.results = map[string]fakeResult{} + } + b.f.results[fakeKey(b.name, b.args)] = fakeResult{out: out, err: err} +} + +func fakeKey(name string, args []string) string { + if args == nil { + return name + "\x00*" + } + k := name + for _, a := range args { + k += "\x00" + a + } + return k +} + +func (f *Fake) Run(ctx context.Context, s Spec) ([]byte, error) { + f.Calls = append(f.Calls, Call{Name: s.Name, Args: append([]string(nil), s.Args...), Dir: s.Dir}) + if r, ok := f.results[fakeKey(s.Name, s.Args)]; ok { + return r.out, r.err + } + if r, ok := f.results[fakeKey(s.Name, nil)]; ok { + return r.out, r.err + } + return nil, fmt.Errorf("fake runner: no result configured for %s %v", s.Name, s.Args) +} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go new file mode 100644 index 0000000..aadda40 --- /dev/null +++ b/internal/runner/runner_test.go @@ -0,0 +1,60 @@ +package runner_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/leonmika/wails-release/internal/runner" +) + +func TestRealRunner_RunsCommandAndReturnsCombinedOutput(t *testing.T) { + r := runner.Real{} + out, err := r.Run(context.Background(), runner.Spec{Name: "echo", Args: []string{"hello"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(out), "hello") { + t.Fatalf("output %q does not contain 'hello'", out) + } +} + +func TestRealRunner_NonZeroExitReturnsError(t *testing.T) { + r := runner.Real{} + _, err := r.Run(context.Background(), runner.Spec{Name: "false"}) + if err == nil { + t.Fatal("expected error from `false`, got nil") + } +} + +func TestFakeRunner_RecordsCallsAndReturnsConfiguredResult(t *testing.T) { + f := &runner.Fake{} + f.On("greet", []string{"alice"}).Return([]byte("hi alice"), nil) + f.On("fail", nil).Return(nil, errors.New("boom")) + + out, err := f.Run(context.Background(), runner.Spec{Name: "greet", Args: []string{"alice"}}) + if err != nil || string(out) != "hi alice" { + t.Fatalf("unexpected: out=%q err=%v", out, err) + } + + _, err = f.Run(context.Background(), runner.Spec{Name: "fail"}) + if err == nil { + t.Fatal("expected error from configured fake") + } + + if len(f.Calls) != 2 { + t.Fatalf("expected 2 calls, got %d", len(f.Calls)) + } + if f.Calls[0].Name != "greet" || f.Calls[0].Args[0] != "alice" { + t.Fatalf("unexpected first call: %+v", f.Calls[0]) + } +} + +func TestFakeRunner_UnconfiguredCallFailsLoudly(t *testing.T) { + f := &runner.Fake{} + _, err := f.Run(context.Background(), runner.Spec{Name: "unknown"}) + if err == nil { + t.Fatal("expected error for unconfigured call") + } +}