From 6353afdf8370f49b3d49bf632c7392a7d736fe7e Mon Sep 17 00:00:00 2001 From: PJ Date: Fri, 17 Apr 2026 23:40:28 +0700 Subject: [PATCH] feat(driver/mock): in-memory Driver for unit tests Records every call as an Action and lets tests program hierarchy, screenshot, health, and per-method failures. Actions() returns a copy so test code can't mutate the driver's history. --- internal/driver/mock/mock.go | 147 ++++++++++++++++++++++++++++++ internal/driver/mock/mock_test.go | 141 ++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 internal/driver/mock/mock.go create mode 100644 internal/driver/mock/mock_test.go diff --git a/internal/driver/mock/mock.go b/internal/driver/mock/mock.go new file mode 100644 index 0000000..7315e80 --- /dev/null +++ b/internal/driver/mock/mock.go @@ -0,0 +1,147 @@ +package mock + +import ( + "context" + "sync" + "time" + + "github.com/priyanshujain/uatu/internal/driver" +) + +type ActionKind string + +const ( + ActionLaunch ActionKind = "launch" + ActionTerminate ActionKind = "terminate" + ActionTap ActionKind = "tap" + ActionInputText ActionKind = "input_text" + ActionHierarchy ActionKind = "hierarchy" + ActionScreenshot ActionKind = "screenshot" + ActionWaitForIdle ActionKind = "wait_for_idle" + ActionHealth ActionKind = "health" +) + +type Action struct { + Kind ActionKind + BundleID string + ClearState bool + X, Y int + Text string + Idle time.Duration +} + +// Driver is an in-memory Driver implementation for unit tests. +// Tests can program HierarchyJSON, ImageData, HealthInfo, and per-method +// Failures, and read back Actions to assert what the runner asked for. +type Driver struct { + mutex sync.Mutex + actions []Action + + HierarchyJSON string + ImageData driver.Image + HealthInfo driver.Health + Failures map[ActionKind]error +} + +func New() *Driver { + return &Driver{ + Failures: map[ActionKind]error{}, + HealthInfo: driver.Health{ + Ready: true, + Version: "mock", + Platform: "android", + }, + HierarchyJSON: `{"children":[]}`, + ImageData: driver.Image{PNG: []byte{}, Width: 0, Height: 0}, + } +} + +func (d *Driver) Actions() []Action { + d.mutex.Lock() + defer d.mutex.Unlock() + return append([]Action(nil), d.actions...) +} + +func (d *Driver) record(action Action) { + d.mutex.Lock() + defer d.mutex.Unlock() + d.actions = append(d.actions, action) +} + +func (d *Driver) failure(kind ActionKind) error { + d.mutex.Lock() + defer d.mutex.Unlock() + return d.Failures[kind] +} + +func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool) error { + if err := d.failure(ActionLaunch); err != nil { + return err + } + d.record(Action{Kind: ActionLaunch, BundleID: bundleID, ClearState: clearState}) + return nil +} + +func (d *Driver) Terminate(ctx context.Context) error { + if err := d.failure(ActionTerminate); err != nil { + return err + } + d.record(Action{Kind: ActionTerminate}) + return nil +} + +func (d *Driver) Tap(ctx context.Context, x, y int) error { + if err := d.failure(ActionTap); err != nil { + return err + } + d.record(Action{Kind: ActionTap, X: x, Y: y}) + return nil +} + +func (d *Driver) InputText(ctx context.Context, text string) error { + if err := d.failure(ActionInputText); err != nil { + return err + } + d.record(Action{Kind: ActionInputText, Text: text}) + return nil +} + +func (d *Driver) Hierarchy(ctx context.Context) (string, error) { + if err := d.failure(ActionHierarchy); err != nil { + return "", err + } + d.record(Action{Kind: ActionHierarchy}) + d.mutex.Lock() + defer d.mutex.Unlock() + return d.HierarchyJSON, nil +} + +func (d *Driver) Screenshot(ctx context.Context) (driver.Image, error) { + if err := d.failure(ActionScreenshot); err != nil { + return driver.Image{}, err + } + d.record(Action{Kind: ActionScreenshot}) + d.mutex.Lock() + defer d.mutex.Unlock() + return d.ImageData, nil +} + +func (d *Driver) WaitForIdle(ctx context.Context, duration time.Duration) error { + if err := d.failure(ActionWaitForIdle); err != nil { + return err + } + d.record(Action{Kind: ActionWaitForIdle, Idle: duration}) + return nil +} + +func (d *Driver) Health(ctx context.Context) (driver.Health, error) { + if err := d.failure(ActionHealth); err != nil { + return driver.Health{}, err + } + d.record(Action{Kind: ActionHealth}) + d.mutex.Lock() + defer d.mutex.Unlock() + return d.HealthInfo, nil +} + +var _ driver.Driver = (*Driver)(nil) diff --git a/internal/driver/mock/mock_test.go b/internal/driver/mock/mock_test.go new file mode 100644 index 0000000..cd4f2ff --- /dev/null +++ b/internal/driver/mock/mock_test.go @@ -0,0 +1,141 @@ +package mock + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/priyanshujain/uatu/internal/driver" +) + +func TestNew_Defaults(t *testing.T) { + mock := New() + if !mock.HealthInfo.Ready { + t.Errorf("default HealthInfo should be ready") + } + if mock.HealthInfo.Platform != "android" { + t.Errorf("default platform: got %q", mock.HealthInfo.Platform) + } + if mock.HierarchyJSON == "" { + t.Errorf("default hierarchy should be a non-empty JSON") + } + if len(mock.Actions()) != 0 { + t.Errorf("fresh mock should have zero recorded actions") + } +} + +func TestRecordsAllActionsInOrder(t *testing.T) { + mock := New() + ctx := context.Background() + + if err := mock.Launch(ctx, "com.example", true); err != nil { + t.Fatal(err) + } + if err := mock.Tap(ctx, 100, 200); err != nil { + t.Fatal(err) + } + if err := mock.InputText(ctx, "hello"); err != nil { + t.Fatal(err) + } + if _, err := mock.Hierarchy(ctx); err != nil { + t.Fatal(err) + } + if _, err := mock.Screenshot(ctx); err != nil { + t.Fatal(err) + } + if err := mock.WaitForIdle(ctx, 500*time.Millisecond); err != nil { + t.Fatal(err) + } + if _, err := mock.Health(ctx); err != nil { + t.Fatal(err) + } + if err := mock.Terminate(ctx); err != nil { + t.Fatal(err) + } + + actions := mock.Actions() + expected := []ActionKind{ + ActionLaunch, + ActionTap, + ActionInputText, + ActionHierarchy, + ActionScreenshot, + ActionWaitForIdle, + ActionHealth, + ActionTerminate, + } + if len(actions) != len(expected) { + t.Fatalf("recorded %d actions, want %d", len(actions), len(expected)) + } + for index, kind := range expected { + if actions[index].Kind != kind { + t.Errorf("action[%d]: got %q, want %q", index, actions[index].Kind, kind) + } + } + if actions[0].BundleID != "com.example" || !actions[0].ClearState { + t.Errorf("launch payload wrong: %+v", actions[0]) + } + if actions[1].X != 100 || actions[1].Y != 200 { + t.Errorf("tap payload wrong: %+v", actions[1]) + } + if actions[2].Text != "hello" { + t.Errorf("input payload wrong: %+v", actions[2]) + } + if actions[5].Idle != 500*time.Millisecond { + t.Errorf("idle payload wrong: %+v", actions[5]) + } +} + +func TestProgrammableHierarchyIsReturned(t *testing.T) { + mock := New() + mock.HierarchyJSON = `{"children":[{"id":"login_button"}]}` + + got, err := mock.Hierarchy(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != mock.HierarchyJSON { + t.Errorf("hierarchy round-trip failed: got %q", got) + } +} + +func TestProgrammableScreenshotIsReturned(t *testing.T) { + mock := New() + mock.ImageData = driver.Image{PNG: []byte{0x89, 0x50, 0x4e, 0x47}, Width: 1080, Height: 2340} + + got, err := mock.Screenshot(context.Background()) + if err != nil { + t.Fatal(err) + } + if got.Width != 1080 || got.Height != 2340 || len(got.PNG) != 4 { + t.Errorf("screenshot round-trip failed: %+v", got) + } +} + +func TestFailureInjection(t *testing.T) { + boom := errors.New("boom") + mock := New() + mock.Failures[ActionTap] = boom + + if err := mock.Tap(context.Background(), 0, 0); !errors.Is(err, boom) { + t.Fatalf("expected boom, got %v", err) + } + if got := mock.Actions(); len(got) != 0 { + t.Errorf("failed action should not be recorded, got %v", got) + } +} + +func TestActionsReturnsCopy(t *testing.T) { + mock := New() + _ = mock.Tap(context.Background(), 1, 1) + snapshot := mock.Actions() + snapshot[0].X = 99 + if mock.Actions()[0].X != 1 { + t.Errorf("Actions() returned the internal slice; tests can mutate driver state") + } +} + +func TestSatisfiesDriverInterface(t *testing.T) { + var _ driver.Driver = New() +}