Add Runner abstraction with Real and Fake implementations
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b86a9dc9eb
commit
2350046601
119
internal/runner/runner.go
Normal file
119
internal/runner/runner.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
60
internal/runner/runner_test.go
Normal file
60
internal/runner/runner_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue