diff --git a/internal/driver/driver.go b/internal/driver/driver.go index f109b27..4d1ca25 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -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 diff --git a/internal/runner/runner.go b/internal/runner/runner.go index d7a7431..91be589 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -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") }