Files
sanderling/internal/driver/driver.go
pj 410602d2e1 Fix action-system audit findings (#60)
* fix(replay-ui): size overlay viewBox from hierarchy root bounds

Tap points are recorded in the hierarchy's coordinate space (iOS points,
Android pixels, web CSS px) while screenshots are device pixels, so the
overlay rendered at 1/3 position on iOS 3x screens. Derive the viewBox
from the root element bounds; natural image size stays the fallback.

* fix(runner): derive trace tap point from resolveCoordinates

stampSelectorTarget preferred possibly-stale action X/Y while dispatch
preferred the fresh tree-resolved center, so the trace could record a
different point than the one tapped. Both now share resolveCoordinates.

* fix(runner): settle after InputText focus tap before key events

The focus tap raises the keyboard; with no settle the keyboard
animation races the erase/type key events on iOS, landing them in the
wrong field or dropping them. Wait for idle after a successful focus
tap, bounded by the run's idle timeout.

* fix(driver): skip pre-erase for replace-on-input drivers

The web driver's InputText already replaces content via select-all, so
the runner's unconditional EraseText was a redundant round-trip on
every InputText. A new optional TextReplacer capability lets a driver
assert replace semantics; the runner skips the erase when asserted.

* fix(hierarchy): rank spatial-fallback matches by specificity

The bounds-containment fallback returned the first pre-order match, so
a screen-sized container could win over the intended small element.
Matches are now ordered smallest-area first; equal-area matches keep
pre-order, preserving the iOS-flat equal-bounds sibling pattern.

* fix(runner): treat an unchanging transitional tree as settled

A UI persistently showing two route-level Screen ids (overlay, both
route ids alive at rest) burned the full retry budget every step and
skipped the verifier forever. A tree byte-identical to the previous
attempt now breaks the retry loop as settled; genuine cross-fades
differ between attempts and keep the retry/skip behavior.

* fix(replay-ui): skip synthetic zero-bounds root in deviceSpaceOf

The iOS hierarchy prepends a zero-bounds node before the real root
window, so elements[0] returned undefined and the overlay fell back to
the screenshot's pixel size. Take the first element with positive
extent instead; pre-order puts the root window before any content.
Verified against a real iOS trace in the replay UI.

* fix(sidecar): never replay non-idempotent actions after reconnect

A dropped connection mid-action (e.g. a read timeout while the device
is still typing) re-ran the whole block after reconnecting, typing the
text twice and double-firing taps. Non-idempotent actions now reconnect
for the next RPC's benefit but surface UNAVAILABLE, which the runner
already treats as transient; idempotent reads keep the replay.

* fix(sidecar): land the second double-tap sequentially on gesture collision

The overlapped second tap can hit the XCTest runner while the first
gesture is still executing ('only one gesture can be performed at a
time'), failing the step. The second tap now waits the first out and
retries once, keeping the tight gap on the happy path.

* fix(sidecar): map non-Exception throwables to INTERNAL status

The vendored iOS client throws failures that do not extend Exception;
runRpc missed them, killing the RPC as a channel-level Unknown the
runner cannot classify. Catch Throwable instead.

* feat(sidecar): close the driver and app under test on shutdown

* test(sidecar): cover service shutdown paths

* fix(testrun): stop the sidecar with SIGTERM before killing

* fix(sidecar): reap orphaned XCTest runner sessions at iOS init

* fix(sidecar): probe channel liveness before restarting the XCTest runner

* test(sidecar): cover WdaRecovery restart and retry policy

* fix(sidecar): absorb first-leg double-tap collision sequentially

* fix(runner): scope WDA-drop detection and cap consecutive transient failures

* chore(sidecar): silence vendored loggers on expected failure paths

* fix(runner): absorb one-off apply errors; only an unbroken streak aborts

* fix(folio): install the current build before the Android fuzz run

* chore(sidecar): silence absorbed view-hierarchy poll noise in Android runs

The driver logs an ERROR for every on-device view-hierarchy fetch that the
device-side server cancels or times out while the UI animates. The stability
poll fetches the hierarchy on a sub-second cadence and swallows those throws
to keep polling, so each line is advisory with no effect on the run. Real
failures still reach the runner as gRPC status errors, so nothing is lost.
2026-06-06 12:21:11 +05:30

133 lines
5.6 KiB
Go

// Package driver defines the platform-agnostic device automation interface and shared types.
package driver
import (
"context"
"encoding/json"
"time"
)
// 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 captured back-to-back
// under a backend-side mutex, so the pair describes the same on-device
// frame. Prefer this over calling Hierarchy and Screenshot separately:
// independent reads can land on different frames during transitions.
Snapshot(ctx context.Context) (string, Image, error)
// RecentLogs returns log entries at or after `since`, filtered to
// `minLevel` or above. An empty minLevel defaults to "E".
RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]LogEntry, error)
WaitForIdle(ctx context.Context, duration time.Duration) error
Health(ctx context.Context) (Health, error)
// Metrics samples the app's CPU and memory at the time of the call.
// CPUPercent is percent of a single core (multi-core apps can exceed
// 100). HeapBytes is resident set size; TotalMemoryBytes includes
// native allocations.
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)
}
// 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 prefers it over
// ForegroundChecker: the resumed-activity signal flips to a freshly launched
// app before its first frame draws, so observing on it alone can capture the
// previous app's screen. The focused window only names the app once its window
// is actually up.
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)
}
type LogEntry struct {
UnixMillis int64
Level string
Tag string
Message string
}
type Image struct {
PNG []byte
Width int
Height int
}
type Health struct {
Ready bool
Version string
Platform string
}
type Metrics struct {
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 WebAction 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)
}