Files
sanderling/internal/driver/driver.go
pj 1fb910e0a2 fix(driver): a CPU sample the driver never took is absent, not zero
CDP exposes no per-page CPU, so chrome's Metrics filled CPUPercent with a zero,
which is the same answer an idle app gives. Making the field optional is the
same fix as the iOS reads: absence is the only honest way to say nothing looked.
2026-08-22 21:41:40 +05:30

217 lines
9.7 KiB
Go

// Package driver defines the platform-agnostic device automation interface and shared types.
package driver
import (
"context"
"encoding/json"
"errors"
"time"
)
// ErrGestureUndelivered reports a coordinate gesture that reached no element at
// all, so the app cannot have responded to it. It is not a device fault and
// says nothing about the device's health: the runner records it on the step
// rather than counting it toward the apply-failure streak, which is what makes
// a gesture that did nothing distinguishable from one the app ignored.
var ErrGestureUndelivered = errors.New("gesture reached no element")
// ErrSelectorMatchedNothing reports an action dispatched by selector whose
// selector named nothing on the current screen. It is a resolution failure, not
// a delivery one: no point was ever computed, so it stays separate from
// ErrGestureUndelivered and the runner records it as an unresolved selector.
var ErrSelectorMatchedNothing = errors.New("selector matched no element")
// ErrNotSupported reports a contract method the driver has no way to answer on
// its platform. It is neither a device fault nor an observation: a caller that
// reads the zero value as one records a reading it never took, which is what
// lets a property over a channel the driver never opened hold vacuously with
// nothing in the run's output saying so. Callers check it with errors.Is and
// record that the read did not happen.
var ErrNotSupported = errors.New("driver: capability not supported")
// DeviceDriver abstracts the platform-specific UI automation backend. v0.1
// surface matches proto/driverpb/driver.proto. The sidecar implementation
// lives under driver/sidecar; the web implementation under driver/chrome;
// tests use driver/mock.
type DeviceDriver interface {
Launch(ctx context.Context, bundleID string, clearState bool, env map[string]string) error
Terminate(ctx context.Context) error
Tap(ctx context.Context, x, y int) error
TapSelector(ctx context.Context, selector string) error
DoubleTap(ctx context.Context, x, y int) error
DoubleTapSelector(ctx context.Context, selector string) error
InputText(ctx context.Context, text string) error
// EraseText deletes characterCount characters from the focused field.
// The runner calls it before InputText so the verb replaces existing
// content instead of appending to it.
EraseText(ctx context.Context, characterCount int) error
Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error
PressKey(ctx context.Context, key string) error
LongPress(ctx context.Context, x, y int) error
Hierarchy(ctx context.Context) (string, error)
Screenshot(ctx context.Context) (Image, error)
// Snapshot returns the hierarchy and screenshot as one paired capture,
// the closest a driver can put them and the reason to prefer it over
// separate Hierarchy and Screenshot reads. It is not atomic: no driver
// freezes the frame while the two reads run.
Snapshot(ctx context.Context) (string, Image, error)
// RecentLogs returns log entries at or after `since`, filtered to
// `minLevel` or above. Drivers disagree on an empty minLevel (sidecar
// defaults to "E", Chrome returns every level), so the runner always
// passes one. A driver with no log source returns ErrNotSupported, not
// an empty slice: only one of the two means the app logged nothing.
RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]LogEntry, error)
WaitForIdle(ctx context.Context, duration time.Duration) error
// Health reports whether the backend is attached and serving. A driver
// that runs no readiness check returns ErrNotSupported; Ready is a
// verdict, so reporting it true without one tells the caller a check
// passed that never ran.
Health(ctx context.Context) (Health, error)
// Metrics samples the app's memory, plus CPU where the platform exposes
// it (Chrome does not). CPUPercent is percent of a single core, so
// multi-core apps can exceed 100. HeapBytes and TotalMemoryBytes are the
// platform's nearest pair: RSS and virtual size on Android, used and
// total JS heap on Chrome. A driver that cannot sample returns
// ErrNotSupported rather than a zeroed sample.
Metrics(ctx context.Context, bundleID string) (Metrics, error)
}
// ForegroundChecker is the optional capability for reporting which app is
// currently in the foreground. The runner uses it to keep exploration scoped
// to the app under test: when an action backs out of (or otherwise leaves) the
// app, the runner relaunches it before acting again. Drivers that cannot
// determine the foreground app simply do not implement this interface.
type ForegroundChecker interface {
// ForegroundApp returns the bundle id / package of the app currently in
// the foreground. An empty string means "unknown" and the runner skips
// enforcement for that step rather than relaunching blindly.
ForegroundApp(ctx context.Context) (string, error)
}
// Scroller is the optional capability for drivers whose scroll interaction is
// not a finger drag. On a touch device the two are the same gesture, so a
// driver that does not implement this gets its Scroll actions as a Swipe. A
// browser scrolls on wheel input instead, and treats a drag as a drag.
type Scroller interface {
// Scroll moves the content under (fromX, fromY) by the vector to the
// destination point, the same endpoints Swipe takes.
Scroll(
ctx context.Context,
fromX, fromY, toX, toY int,
duration time.Duration,
) error
}
// TextReplacer is the optional capability for drivers whose InputText already
// replaces the field's content instead of appending to it. The runner must
// skip its pre-erase for such drivers: the erase would be a redundant
// round-trip on every InputText.
type TextReplacer interface {
// ReplacesTextOnInput reports whether InputText replaces existing
// content, making the runner's pre-erase unnecessary.
ReplacesTextOnInput() bool
}
// FocusedWindowChecker is the optional capability for reporting which app owns
// the focused (on-screen) window. The startup gate uses it on top of
// ForegroundChecker, never instead: the resumed-activity signal flips to a
// freshly launched app before its first frame draws, so a gate on that alone
// can let the first observe read the previous app's screen.
type FocusedWindowChecker interface {
// FocusedWindowApp returns the package owning the focused window, or ""
// when no window is focused yet (e.g. mid-launch transition).
FocusedWindowApp(ctx context.Context) (string, error)
}
// LogEntry is one line of device log. Level is logcat's single-letter scale on
// every platform: "V", "D", "I", "W", "E", "F", ordered as written. The runner
// fetches at "E" and the default properties count entries whose level equals
// "E", so a driver that spells a level any other way empties the channel
// without failing anything: the entries never arrive and every property reading
// state.logs holds vacuously.
type LogEntry struct {
UnixMillis int64
Level string
Tag string
Message string
}
// ExceptionReporter is the optional capability for reporting the uncaught
// errors an app has captured so far. The runner feeds them to state.exceptions,
// which the default noUncaughtExceptions property reads. Drivers with no way to
// observe them simply do not implement it and the property stays vacuous there.
type ExceptionReporter interface {
Exceptions(ctx context.Context) ([]Exception, error)
}
// NavigationReporter is the optional capability for reporting the
// document-replacing navigations seen since the last call. A navigation
// restarts the app's own runtime, so a trace without them cannot separate an
// app that reloaded from a generator that repeated itself.
type NavigationReporter interface {
Navigations(ctx context.Context) ([]Navigation, error)
}
// Navigation is one document-replacing navigation: a reload, a form submit, a
// route change that swapped the document.
type Navigation struct {
URL string
UnixMillis int64
}
// Exception is one uncaught throwable the app captured.
type Exception struct {
Class string
Message string
StackTrace string
UnixMillis int64
}
type Image struct {
PNG []byte
Width int
Height int
}
type Health struct {
Ready bool
Version string
Platform string
}
type Metrics struct {
// CPUPercent is nil on a driver that samples memory but not CPU, which
// Chrome does: zero is a reading an idle app can produce, so a driver that
// never looked has to answer with nothing rather than with that.
CPUPercent *float64
HeapBytes int64
TotalMemoryBytes int64
}
// WebDriver is the optional capability surface exposed by the chrome driver
// for the V8-native tick path. The runner type-asserts on this interface;
// mobile drivers stay binary-compatible by simply not implementing it.
//
// Element references never cross V8/host. V8 serializes targets as {x, y}
// (or bounds) into the returned action JSON; the host dispatches via the
// normal DeviceDriver methods (Tap, InputText, etc.).
type WebDriver interface {
// InstallBundle injects the given JS source so it runs once per
// freshly-navigated document, plus immediately in the current page.
// The bundle is expected to register globals
// `__sanderlingExtractors__` and `__sanderlingNextAction__` on
// `window`.
InstallBundle(ctx context.Context, source []byte) error
// EvaluateExtractors invokes the extractor table installed by the
// bundle and returns each extractor's JSON-encoded current value
// keyed by its registration index.
EvaluateExtractors(ctx context.Context) (map[int]json.RawMessage, error)
// NextActionFromV8 invokes the action generator installed by the
// bundle and returns the resulting Action JSON for the host to
// dispatch. The shape mirrors verifier.Action's JSON form.
NextActionFromV8(ctx context.Context) (json.RawMessage, error)
}