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