docs: correct the false statements on DeviceDriver and in the runner

Snapshot is not atomic on any driver, only the sidecar takes a lock. An empty
minLevel is not a default: the three implementations genuinely disagree, so the
comment names that and says the runner always passes one. HeapBytes is RSS only
on Android. The startup gate uses FocusedWindowChecker on top of
ForegroundChecker, never instead. WebAction names no type in the tree. Run
relaunches the app it says the caller must launch, and the errgroup propagates
no error because all three goroutines return nil.
This commit is contained in:
pj committed 2026-08-22 21:31:13 +05:30
1 parent 3caf53cfb6
commit 658574b568
2 files changed
+30 -37

No files matched your search

+18 -18
View File
@@ -52,16 +52,16 @@ type DeviceDriver interface {
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 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. An empty minLevel defaults to "E". A driver with
// no log source returns ErrNotSupported rather than an empty slice: the
// two are the same answer to a spec reading state.logs, and only one of
// them means the app logged nothing.
// `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
@@ -70,10 +70,11 @@ type DeviceDriver interface {
// 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 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. A driver that cannot sample returns
// 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)
}
@@ -115,11 +116,10 @@ type TextReplacer interface {
}
// 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.
// 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).
@@ -193,7 +193,7 @@ type Metrics struct {
// 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
// (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
+12 -19
View File
@@ -98,8 +98,8 @@ type ViolationRecord struct {
}
// Run drives the evaluate/act loop until the duration elapses or the context
// is canceled. The caller is responsible for launching the app before Run is
// called and for terminating it afterwards.
// is canceled. It relaunches the app whenever the foreground check says it is
// not on top; the caller terminates it afterwards.
func Run(ctx context.Context, options Options) (Summary, error) {
if err := validate(options); err != nil {
return Summary{}, err
@@ -192,16 +192,11 @@ func Run(ctx context.Context, options Options) (Summary, error) {
var logs []verifier.LogEntry
var logsRead, metricsRead bool
// gctx is bound to the errgroup so a returned error (or outer
// cancellation) propagates to every sibling read rather than leaving
// one blocked on a hung device, and to observationTimeout so a read
// that never answers ends the step instead of the run.
// gctx carries observationTimeout so a read that never answers ends
// the step instead of the run.
observeCtx, observeCancel := context.WithTimeout(ctx, observationTimeout)
g, gctx := errgroup.WithContext(observeCtx)
si := stepIndex
// fetchSyncedState issues a single Snapshot RPC so hierarchy and
// screenshot describe the same frame, then re-fetches the pair
// while the tree still looks transitional.
g.Go(func() error {
tree, screenshotPNG, transitional, hierarchyErr = fetchSyncedState(
gctx, options, logger, si, rereadHierarchy)
@@ -242,7 +237,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
if tree != nil {
treeSize = len(tree.Elements)
}
// A nil or empty tree means the sidecar's hierarchy fetch failed or
// A nil or empty tree means the driver's hierarchy fetch failed or
// returned nothing (e.g. transient device-side timeout). Pushing it
// would let spec extractors call findAll() and chain .map() on a null
// result; treat it like a transitional capture so the verifier is
@@ -1467,12 +1462,11 @@ const (
// pair shows the same UI moment. If the hierarchy looks like a NavHost
// cross-fade (multiple route-level *Screen tags), the function waits briefly
// and re-fetches the pair, up to transitionalRetryAttempts times. This
// handles transitions whose async work begins after the sidecar's settle
// handles transitions whose async work begins after the driver's settle
// poll has already exited.
//
// The driver's Snapshot RPC captures both reads under a backend-side mutex
// so they describe the same on-device frame; the retry exists for the
// orthogonal case where the frame itself is transitional.
// Snapshot pairs the two reads as closely as the driver can, not atomically;
// the retry exists for the orthogonal case where the frame is transitional.
//
// The transitional return reports whether the retry budget was exhausted
// on a still-transitional tree, or (when reread is set) whether a second
@@ -1879,11 +1873,10 @@ func applyBound(action verifier.Action) time.Duration {
return applyTimeout + time.Duration(action.DurationMillis)*time.Millisecond
}
// isWDADrop reports that the sidecar could not restart the iOS XCTest
// runner: the channel is gone for good and the run must abort. Transient
// drops are classified by the sidecar itself (it reconnects and surfaces
// UNAVAILABLE), so matching on raw exception text like "ConnectException"
// here would kill runs the sidecar already recovered.
// isWDADrop matches the phrase the JVM sidecar's WdaRecovery threw when it
// could not restart the iOS XCTest runner. iOS no longer routes through the
// sidecar and nothing constructs WdaRecovery any more, so no run reaches this
// path; the iOS driver restarts its own companion instead (withRecovery).
func isWDADrop(err error) bool {
return strings.Contains(err.Error(), "WDA reconnect failed")
}