mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
* fix(runner): surface non-deadline WaitForIdle errors Previously the WaitForIdle return value was discarded entirely, hiding real driver failures (gRPC transport errors, sidecar crashes) behind the expected deadline-exceeded case. Log non-deadline errors so they are visible without changing control flow. * chore(sample-app): drop unused uptime_millis extractor Registered in SampleApplication but never consumed by spec.ts. * fix(sample-app): drop trivial appIsRunning property app_state was hardcoded to 'running' so the property was a tautology that could never fail. Removing both the extractor and the property is the simplest fix; demo-grade properties that can fail land next. * feat(sample-app): add Reset button that zeroes clickCount Pairs with the next commit's tap-reset action so the fuzzer can violate clickCountNeverDecreases and demonstrate uatu actually finding a property violation. * feat(sample-app): add tap-reset action to exercise Reset button Weighted at 10/122, fuzzer reaches it within a short run. Pairs with the Reset button to demonstrate uatu detecting the clickCountNeverDecreases violation. * fix(runner): filter WaitForIdle errors via context state, not errors.Is errors.Is(err, context.DeadlineExceeded) misses gRPC's wrapped status.DeadlineExceeded, so every step under the maestro driver logged a spurious warning. Check idleCtx.Err() instead — captures both deadline-fired and parent-canceled cases regardless of how the driver wraps them. * chore(sample-app): tune action weights so demo violates in ~30s Prior weights left tap-reset rare enough that short demo runs missed the violation by chance. Bumped to 30/107, with typeUsername reduced since username noise doesn't help exercise clickCount. * refactor(runner): route warnings through slog Adds Options.Logger (defaults to slog.Default()) and converts the three warning sites that were using fmt.Printf. Progress line stays on Printf since it's user-facing UI, not a log. Makes the warnings testable via a capturing handler. * test(runner): assert WaitForIdle driver errors are logged Captures slog output via TextHandler into a buffer and asserts the warning message + injected error text appear when the mock driver returns a non-context error from WaitForIdle. Guards against a regression of the silent-error swallow.
386 lines
11 KiB
Go
386 lines
11 KiB
Go
package runner
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/priyanshujain/uatu/internal/agent"
|
|
mockdriver "github.com/priyanshujain/uatu/internal/driver/mock"
|
|
"github.com/priyanshujain/uatu/internal/trace"
|
|
"github.com/priyanshujain/uatu/internal/verifier"
|
|
)
|
|
|
|
const fixtureSpec = `
|
|
const screen = __uatu__.extract(state => state.snapshots.screen ?? "");
|
|
const balance = __uatu__.extract(state => state.snapshots.balance ?? 0);
|
|
globalThis.properties = {
|
|
balanceNonNegative: __uatu__.always(() => balance.current >= 0),
|
|
};
|
|
globalThis.actions = __uatu__.actions(() => [__uatu__.tap({ on: "id:next" })]);
|
|
`
|
|
|
|
type harness struct {
|
|
server *agent.Server
|
|
listener net.Listener
|
|
clientWG sync.WaitGroup
|
|
conn *agent.Conn
|
|
mock *mockdriver.Driver
|
|
verifier *verifier.Verifier
|
|
writer *trace.Writer
|
|
snapshot []map[string]json.RawMessage
|
|
}
|
|
|
|
func newHarness(t *testing.T, snapshots []map[string]json.RawMessage) *harness {
|
|
t.Helper()
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server := agent.NewServer(listener)
|
|
directory := t.TempDir()
|
|
writer, err := trace.NewWriter(directory)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
verifierInstance, err := verifier.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := verifierInstance.Load(fixtureSpec); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state := &harness{
|
|
server: server,
|
|
listener: listener,
|
|
mock: mockdriver.New(),
|
|
verifier: verifierInstance,
|
|
writer: writer,
|
|
snapshot: snapshots,
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = listener.Close()
|
|
_ = writer.Close()
|
|
})
|
|
return state
|
|
}
|
|
|
|
func (h *harness) startSDK(t *testing.T) {
|
|
t.Helper()
|
|
h.clientWG.Go(func() {
|
|
conn, err := net.Dial("tcp", h.listener.Addr().String())
|
|
if err != nil {
|
|
t.Errorf("dial: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
if err := agent.WriteMessage(conn, agent.Hello("0.0.1", "android", "com.fixture")); err != nil {
|
|
t.Errorf("hello: %v", err)
|
|
return
|
|
}
|
|
index := 0
|
|
for {
|
|
message, err := agent.ReadMessage(conn)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if message.Type == agent.MessageTypePause {
|
|
snapshots := map[string]json.RawMessage{}
|
|
if index < len(h.snapshot) {
|
|
snapshots = h.snapshot[index]
|
|
}
|
|
if err := agent.WriteMessage(conn, agent.State(message.ID, snapshots)); err != nil {
|
|
return
|
|
}
|
|
index++
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func (h *harness) acceptConnection(t *testing.T) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
connection, err := h.server.Accept(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Accept: %v", err)
|
|
}
|
|
h.conn = connection
|
|
}
|
|
|
|
func TestRunner_HappyPathStepsAndTraces(t *testing.T) {
|
|
snapshots := []map[string]json.RawMessage{
|
|
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`100`)},
|
|
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`200`)},
|
|
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`300`)},
|
|
}
|
|
state := newHarness(t, snapshots)
|
|
state.startSDK(t)
|
|
state.acceptConnection(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
summary, err := Run(ctx, Options{
|
|
Duration: 100 * time.Millisecond,
|
|
SnapshotTimeout: 2 * time.Second,
|
|
IdleTimeout: 50 * time.Millisecond,
|
|
Connection: state.conn,
|
|
Driver: state.mock,
|
|
Verifier: state.verifier,
|
|
TraceWriter: state.writer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if summary.Steps == 0 {
|
|
t.Errorf("expected at least one step, got 0")
|
|
}
|
|
if len(summary.Violations) != 0 {
|
|
t.Errorf("no violations expected, got %v", summary.Violations)
|
|
}
|
|
|
|
actions := state.mock.Actions()
|
|
if !containsAction(actions, mockdriver.ActionTapSelector, "id:next") {
|
|
t.Errorf("expected TapSelector with id:next, got %v", actions)
|
|
}
|
|
}
|
|
|
|
func TestRunner_ViolationSurfacesInSummary(t *testing.T) {
|
|
snapshots := []map[string]json.RawMessage{
|
|
{"balance": json.RawMessage(`100`)},
|
|
{"balance": json.RawMessage(`-1`)},
|
|
{"balance": json.RawMessage(`50`)},
|
|
}
|
|
state := newHarness(t, snapshots)
|
|
state.startSDK(t)
|
|
state.acceptConnection(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
summary, err := Run(ctx, Options{
|
|
Duration: 100 * time.Millisecond,
|
|
SnapshotTimeout: 2 * time.Second,
|
|
IdleTimeout: 50 * time.Millisecond,
|
|
Connection: state.conn,
|
|
Driver: state.mock,
|
|
Verifier: state.verifier,
|
|
TraceWriter: state.writer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if len(summary.Violations) == 0 {
|
|
t.Errorf("expected at least one violation, got %v", summary.Violations)
|
|
}
|
|
if !containsProperty(summary.Violations, "balanceNonNegative") {
|
|
t.Errorf("expected balanceNonNegative in violations: %v", summary.Violations)
|
|
}
|
|
}
|
|
|
|
func TestRunner_RejectsMissingFields(t *testing.T) {
|
|
_, err := Run(context.Background(), Options{Duration: time.Second})
|
|
if err == nil || !strings.Contains(err.Error(), "Connection") {
|
|
t.Errorf("expected Connection-required error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunner_RejectsZeroDuration(t *testing.T) {
|
|
_, err := Run(context.Background(), Options{
|
|
Connection: &agent.Conn{},
|
|
Driver: mockdriver.New(),
|
|
Verifier: mustNewVerifier(t),
|
|
TraceWriter: mustNewTraceWriter(t),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "Duration") {
|
|
t.Errorf("expected Duration-required error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunner_RecordsScreenFieldFromSnapshot(t *testing.T) {
|
|
snapshots := []map[string]json.RawMessage{
|
|
{"screen": json.RawMessage(`"customer_ledger"`), "balance": json.RawMessage(`1`)},
|
|
}
|
|
state := newHarness(t, snapshots)
|
|
state.startSDK(t)
|
|
state.acceptConnection(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if _, err := Run(ctx, Options{
|
|
Duration: 100 * time.Millisecond,
|
|
SnapshotTimeout: 2 * time.Second,
|
|
IdleTimeout: 50 * time.Millisecond,
|
|
Connection: state.conn,
|
|
Driver: state.mock,
|
|
Verifier: state.verifier,
|
|
TraceWriter: state.writer,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(body), `"screen":"customer_ledger"`) {
|
|
t.Errorf("screen field not in trace: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestScreenFromSnapshot(t *testing.T) {
|
|
t.Run("string value returns screen", func(t *testing.T) {
|
|
snapshots := map[string]json.RawMessage{"screen": json.RawMessage(`"home"`)}
|
|
screen, err := screenFromSnapshot(snapshots)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if screen != "home" {
|
|
t.Errorf("screen = %q, want %q", screen, "home")
|
|
}
|
|
})
|
|
t.Run("missing key returns empty with no error", func(t *testing.T) {
|
|
screen, err := screenFromSnapshot(map[string]json.RawMessage{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if screen != "" {
|
|
t.Errorf("screen = %q, want empty", screen)
|
|
}
|
|
})
|
|
t.Run("non-string value returns error", func(t *testing.T) {
|
|
snapshots := map[string]json.RawMessage{"screen": json.RawMessage(`{"nested":1}`)}
|
|
screen, err := screenFromSnapshot(snapshots)
|
|
if err == nil {
|
|
t.Fatalf("expected error for non-string screen, got nil")
|
|
}
|
|
if screen != "" {
|
|
t.Errorf("screen = %q, want empty on error", screen)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
|
|
snapshots := []map[string]json.RawMessage{
|
|
{"balance": json.RawMessage(`100`)},
|
|
}
|
|
state := newHarness(t, snapshots)
|
|
state.startSDK(t)
|
|
state.acceptConnection(t)
|
|
state.mock.Failures[mockdriver.ActionWaitForIdle] = errors.New("sidecar lost gRPC stream")
|
|
|
|
var logBuf bytes.Buffer
|
|
logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if _, err := Run(ctx, Options{
|
|
Duration: 100 * time.Millisecond,
|
|
SnapshotTimeout: 2 * time.Second,
|
|
IdleTimeout: 50 * time.Millisecond,
|
|
Connection: state.conn,
|
|
Driver: state.mock,
|
|
Verifier: state.verifier,
|
|
TraceWriter: state.writer,
|
|
Logger: logger,
|
|
}); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
output := logBuf.String()
|
|
if !strings.Contains(output, "wait_for_idle failed") {
|
|
t.Errorf("expected wait_for_idle warning, got: %q", output)
|
|
}
|
|
if !strings.Contains(output, "sidecar lost gRPC stream") {
|
|
t.Errorf("expected driver error message in warning, got: %q", output)
|
|
}
|
|
}
|
|
|
|
func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) {
|
|
t.Run("selector focus tap fails", func(t *testing.T) {
|
|
driverMock := mockdriver.New()
|
|
driverMock.Failures[mockdriver.ActionTapSelector] = errors.New("adb unreachable")
|
|
action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"}
|
|
|
|
err := applyAction(context.Background(), driverMock, action, nil)
|
|
if err == nil {
|
|
t.Fatalf("expected focus tap failure to surface, got nil")
|
|
}
|
|
if containsAction(driverMock.Actions(), mockdriver.ActionInputText, "") {
|
|
t.Errorf("InputText must not run after focus tap failed: %v", driverMock.Actions())
|
|
}
|
|
})
|
|
t.Run("coordinate focus tap fails", func(t *testing.T) {
|
|
driverMock := mockdriver.New()
|
|
driverMock.Failures[mockdriver.ActionTap] = errors.New("tap driver error")
|
|
action := verifier.Action{Kind: verifier.ActionKindInputText, X: 10, Y: 20, Text: "alice"}
|
|
|
|
err := applyAction(context.Background(), driverMock, action, nil)
|
|
if err == nil {
|
|
t.Fatalf("expected focus tap failure to surface, got nil")
|
|
}
|
|
if containsAction(driverMock.Actions(), mockdriver.ActionInputText, "") {
|
|
t.Errorf("InputText must not run after focus tap failed: %v", driverMock.Actions())
|
|
}
|
|
})
|
|
}
|
|
|
|
func mustNewVerifier(t *testing.T) *verifier.Verifier {
|
|
t.Helper()
|
|
verifierInstance, err := verifier.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return verifierInstance
|
|
}
|
|
|
|
func mustNewTraceWriter(t *testing.T) *trace.Writer {
|
|
t.Helper()
|
|
writer, err := trace.NewWriter(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = writer.Close() })
|
|
return writer
|
|
}
|
|
|
|
func containsAction(actions []mockdriver.Action, kind mockdriver.ActionKind, payload string) bool {
|
|
for _, action := range actions {
|
|
if action.Kind != kind {
|
|
continue
|
|
}
|
|
switch kind {
|
|
case mockdriver.ActionLaunch:
|
|
if action.BundleID == payload {
|
|
return true
|
|
}
|
|
case mockdriver.ActionTapSelector:
|
|
if action.Selector == payload {
|
|
return true
|
|
}
|
|
case mockdriver.ActionTerminate:
|
|
return true
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsProperty(records []ViolationRecord, property string) bool {
|
|
for _, record := range records {
|
|
if slices.Contains(record.Properties, property) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|