mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
* fix(hierarchy): bounds-containment fallback for scoped and path queries Compose on iOS surfaces a testTag node as an empty leaf sibling of the content it labels instead of as an ancestor, so descendant search under the tagged node finds nothing and every path or scoped query returns null. When structural search yields no match, fall back to nodes whose bounds lie inside the scope node's bounds. * feat(sidecar): derive iOS clickable and editable from element type The XCTest hierarchy mapping dropped the element type, leaving no clickable or editable flags on iOS, so the fuzzer's tap and typing verbs never found a candidate inside the app. Map the raw accessibility tree directly and derive clickable, editable, scrollable, and class from the XCUIElementType raw value. * feat(proto): add EraseText RPC for InputText replace semantics * feat(driver): add EraseText to the device driver surface * fix(runner): erase existing field text before InputText InputText appended on native platforms, so repeated draws grew fields without bound. The folio fuzz run wedged on the add-account screen: each draw concatenated another name until the 40-character validation error became permanent. Replace semantics also makes retried typing idempotent. The web driver already replaced via select-all; native now matches. * feat(sidecar): EraseText backend support on android and ios * fix(folio): saturation-gate account creation in the spec The 2-3 step add-account loop outcompeted the 5-step transaction chain at every weighted re-draw, so runs filled with account creation and rarely exercised the balance properties. Stop offering add-account once three accounts exist; the renormalized weights then favor the transaction flow at every step of its chain. * fix(folio): author spec weights to match testing intent Revert the account saturation gate: it starved newAccountBalanceIsZero once it tripped, and a magic account count is app-state tuning, not intent. Instead weight the generators by what the properties need: the transaction chain leads, account creation stays exercised, and doubleTaps gets explicit weight everywhere because double-submission idempotency is what the spec is testing for. * fix(folio): lower doubleTaps weight to 5 * fix(sidecar): surface visible text on iOS static elements Static text and button strings live in the accessibility label on iOS, so the text attribute came through empty and every balance extractor parsed to zero, silently disarming both folio properties. Non-editable elements now fall back title, value, then label; editable fields keep value-only so an empty field's caption does not read as content. * feat(driver): native DoubleTap RPC for a tight inter-tap gap Composing two Tap round trips from the Go client spread the taps by hundreds of milliseconds on iOS, wide enough for the app to navigate between them, so double-submission races could never reproduce. The sidecar now lands both taps back-to-back next to the device transport. * feat(sidecar): pipeline iOS double-tap requests Queue the second tap at the XCTest runner while the first executes. The runner serializes handlers, so this is the tightest gap the transport allows (~350ms per tap round trip); recorded here with measurements for the iOS double-tap limitation.
329 lines
8.9 KiB
Go
329 lines
8.9 KiB
Go
// Package mock provides an in-memory device driver that records actions for tests.
|
|
package mock
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/driver"
|
|
)
|
|
|
|
type ActionKind string
|
|
|
|
const (
|
|
ActionLaunch ActionKind = "launch"
|
|
ActionTerminate ActionKind = "terminate"
|
|
ActionTap ActionKind = "tap"
|
|
ActionTapSelector ActionKind = "tap_selector"
|
|
ActionDoubleTap ActionKind = "double_tap"
|
|
ActionDoubleTapSelector ActionKind = "double_tap_selector"
|
|
ActionInputText ActionKind = "input_text"
|
|
ActionEraseText ActionKind = "erase_text"
|
|
ActionSwipe ActionKind = "swipe"
|
|
ActionPressKey ActionKind = "press_key"
|
|
ActionLongPress ActionKind = "long_press"
|
|
ActionHierarchy ActionKind = "hierarchy"
|
|
ActionScreenshot ActionKind = "screenshot"
|
|
ActionSnapshot ActionKind = "snapshot"
|
|
ActionRecentLogs ActionKind = "recent_logs"
|
|
ActionWaitForIdle ActionKind = "wait_for_idle"
|
|
ActionHealth ActionKind = "health"
|
|
ActionMetrics ActionKind = "metrics"
|
|
)
|
|
|
|
type Action struct {
|
|
Kind ActionKind
|
|
BundleID string
|
|
ClearState bool
|
|
X, Y int
|
|
FromX, FromY int
|
|
ToX, ToY int
|
|
Duration time.Duration
|
|
Selector string
|
|
Text string
|
|
CharacterCount int
|
|
Key string
|
|
LogLevel string
|
|
LogSince time.Time
|
|
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
|
|
LogEntries []driver.LogEntry
|
|
MetricsData driver.Metrics
|
|
Failures map[ActionKind]error
|
|
|
|
// ForegroundResults is consumed one entry per ForegroundApp call (the
|
|
// last entry repeats). Empty yields "", which disables the runner's
|
|
// app-scope guard so tests that don't care are unaffected.
|
|
ForegroundResults []string
|
|
foregroundIndex int
|
|
|
|
// FocusedWindowResults is consumed one entry per FocusedWindowApp call
|
|
// (the last entry repeats). When empty, FocusedWindowApp mirrors the
|
|
// last ForegroundApp result, so the startup gate treats the window as
|
|
// already drawn and tests that don't care are unaffected.
|
|
FocusedWindowResults []string
|
|
focusedWindowIndex int
|
|
focusedWindowCalls int
|
|
lastForeground string
|
|
}
|
|
|
|
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(_ context.Context, bundleID string, clearState bool, _ map[string]string) error {
|
|
if err := d.failure(ActionLaunch); err != nil {
|
|
return err
|
|
}
|
|
d.record(Action{Kind: ActionLaunch, BundleID: bundleID, ClearState: clearState})
|
|
return nil
|
|
}
|
|
|
|
func (d *Driver) ForegroundApp(_ context.Context) (string, error) {
|
|
d.mutex.Lock()
|
|
defer d.mutex.Unlock()
|
|
if len(d.ForegroundResults) == 0 {
|
|
d.lastForeground = ""
|
|
return "", nil
|
|
}
|
|
index := d.foregroundIndex
|
|
if index >= len(d.ForegroundResults) {
|
|
index = len(d.ForegroundResults) - 1
|
|
}
|
|
d.foregroundIndex++
|
|
d.lastForeground = d.ForegroundResults[index]
|
|
return d.lastForeground, nil
|
|
}
|
|
|
|
func (d *Driver) FocusedWindowApp(_ context.Context) (string, error) {
|
|
d.mutex.Lock()
|
|
defer d.mutex.Unlock()
|
|
d.focusedWindowCalls++
|
|
if len(d.FocusedWindowResults) == 0 {
|
|
return d.lastForeground, nil
|
|
}
|
|
index := d.focusedWindowIndex
|
|
if index >= len(d.FocusedWindowResults) {
|
|
index = len(d.FocusedWindowResults) - 1
|
|
}
|
|
d.focusedWindowIndex++
|
|
return d.FocusedWindowResults[index], nil
|
|
}
|
|
|
|
// FocusedWindowCalls reports how many times FocusedWindowApp has been called.
|
|
func (d *Driver) FocusedWindowCalls() int {
|
|
d.mutex.Lock()
|
|
defer d.mutex.Unlock()
|
|
return d.focusedWindowCalls
|
|
}
|
|
|
|
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) LongPress(ctx context.Context, x, y int) error {
|
|
if err := d.failure(ActionLongPress); err != nil {
|
|
return err
|
|
}
|
|
d.record(Action{Kind: ActionLongPress, X: x, Y: y})
|
|
return nil
|
|
}
|
|
|
|
func (d *Driver) TapSelector(ctx context.Context, selector string) error {
|
|
if err := d.failure(ActionTapSelector); err != nil {
|
|
return err
|
|
}
|
|
d.record(Action{Kind: ActionTapSelector, Selector: selector})
|
|
return nil
|
|
}
|
|
|
|
func (d *Driver) DoubleTap(ctx context.Context, x, y int) error {
|
|
if err := d.failure(ActionDoubleTap); err != nil {
|
|
return err
|
|
}
|
|
d.record(Action{Kind: ActionDoubleTap, X: x, Y: y})
|
|
return nil
|
|
}
|
|
|
|
func (d *Driver) DoubleTapSelector(ctx context.Context, selector string) error {
|
|
if err := d.failure(ActionDoubleTapSelector); err != nil {
|
|
return err
|
|
}
|
|
d.record(Action{Kind: ActionDoubleTapSelector, Selector: selector})
|
|
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) EraseText(ctx context.Context, characterCount int) error {
|
|
if err := d.failure(ActionEraseText); err != nil {
|
|
return err
|
|
}
|
|
d.record(Action{Kind: ActionEraseText, CharacterCount: characterCount})
|
|
return nil
|
|
}
|
|
|
|
func (d *Driver) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
|
|
if err := d.failure(ActionSwipe); err != nil {
|
|
return err
|
|
}
|
|
d.record(Action{
|
|
Kind: ActionSwipe,
|
|
FromX: fromX,
|
|
FromY: fromY,
|
|
ToX: toX,
|
|
ToY: toY,
|
|
Duration: duration,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (d *Driver) PressKey(ctx context.Context, key string) error {
|
|
if err := d.failure(ActionPressKey); err != nil {
|
|
return err
|
|
}
|
|
d.record(Action{Kind: ActionPressKey, Key: key})
|
|
return nil
|
|
}
|
|
|
|
func (d *Driver) RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]driver.LogEntry, error) {
|
|
if err := d.failure(ActionRecentLogs); err != nil {
|
|
return nil, err
|
|
}
|
|
d.record(Action{Kind: ActionRecentLogs, LogSince: since, LogLevel: minLevel})
|
|
d.mutex.Lock()
|
|
defer d.mutex.Unlock()
|
|
return append([]driver.LogEntry(nil), d.LogEntries...), 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
|
|
}
|
|
|
|
// Snapshot returns the hierarchy + screenshot pair atomically, mirroring
|
|
// the real driver's contract. It records a single ActionSnapshot so tests
|
|
// can assert the runner reached for the paired RPC instead of racing the
|
|
// two reads.
|
|
func (d *Driver) Snapshot(ctx context.Context) (string, driver.Image, error) {
|
|
if err := d.failure(ActionSnapshot); err != nil {
|
|
return "", driver.Image{}, err
|
|
}
|
|
d.record(Action{Kind: ActionSnapshot})
|
|
d.mutex.Lock()
|
|
defer d.mutex.Unlock()
|
|
return d.HierarchyJSON, 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})
|
|
if duration <= 0 {
|
|
return nil
|
|
}
|
|
timer := time.NewTimer(duration)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
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
|
|
}
|
|
|
|
func (d *Driver) Metrics(ctx context.Context, bundleID string) (driver.Metrics, error) {
|
|
if err := d.failure(ActionMetrics); err != nil {
|
|
return driver.Metrics{}, err
|
|
}
|
|
d.record(Action{Kind: ActionMetrics, BundleID: bundleID})
|
|
d.mutex.Lock()
|
|
defer d.mutex.Unlock()
|
|
return d.MetricsData, nil
|
|
}
|
|
|
|
var _ driver.DeviceDriver = (*Driver)(nil)
|