mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
* chore: delete internal/agent package
* chore(build): remove sdk-android from gradle settings
* chore(makefile): remove sdk-android targets
* chore(ci): remove release-android job from release workflow
* chore(folio): remove sdk-android dependency
* chore(folio): remove SDK initialization from FolioApplication
* chore(folio): delete snapshot extractor files
* feat(folio): add balance to account card content description
* feat(folio): add hierarchy content descriptions to LedgerScreen
* refactor(folio): rewrite spec.ts to use ax extractors
* docs: remove in-app SDK from README
* feat(folio): add focused_input indicator to App
* docs: remove in-app SDK from index
* refactor(runner): remove agent SDK connection and snapshot step
* test(runner): update tests for SDK removal
* docs: remove Android SDK section from getting-started
* refactor(testrun): remove agent SDK connection setup
* docs: remove snapshots from writing-specs
* docs: remove in-app SDK from architecture doc
* docs(folio): update README for SDK removal
* docs: update per-step cycle diagram in architecture doc
* fix(folio): detect screens from unique element presence, not id: selectors
testTag() in Compose is not exposed as resource-id without testTagsAsResourceId.
Use desc: selectors for elements unique to each screen instead of id: path queries.
* feat(folio): add screen root contentDescription for scoped ax selection
Each screen root gets semantics { contentDescription = "ScreenName" } so
sanderling specs can scope element lookups through the screen: desc:LoginScreen > desc:login_submit.
* fix(folio): scope all ax selectors through screen root nodes
Use desc:ScreenName > desc:element path queries so every selector is
rooted at the screen level. focusedInput stays unscoped since it lives
in the app root, outside any screen.
* fix(folio): guard newAccountBalanceIsZero against navigation false positives
Scoped selectors return [] when not on HomeScreen so accounts vanish and
reappear as apparently-new on each visit. Skip the check when prev was empty.
* chore(folio): link @sanderling/spec to local pkg/spec for IDE type checking
* feat(spec): add desc, class, clickable, enabled, checked, focused, selected to AccessibilityElement
Runtime fields set by the verifier were missing from the TypeScript type,
causing linting errors on el.desc and related accesses in specs.
* chore(folio): switch to bun, add tsconfig.json for IDE type checking
- Remove package-lock.json, add bun.lock
- Add tsconfig.json so VSCode resolves @sanderling/spec types
- Fix parseAccount/parseLedgerRow to accept string | undefined
444 lines
12 KiB
Go
444 lines
12 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/driver"
|
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
|
"github.com/priyanshujain/sanderling/internal/ltl"
|
|
"github.com/priyanshujain/sanderling/internal/trace"
|
|
"github.com/priyanshujain/sanderling/internal/verifier"
|
|
)
|
|
|
|
type Options struct {
|
|
Duration time.Duration
|
|
IdleTimeout time.Duration
|
|
|
|
BundleID string
|
|
Driver driver.DeviceDriver
|
|
Verifier *verifier.Verifier
|
|
TraceWriter *trace.Writer
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
type Summary struct {
|
|
StartTime time.Time
|
|
EndTime time.Time
|
|
Steps int
|
|
Violations []ViolationRecord
|
|
}
|
|
|
|
type ViolationRecord struct {
|
|
StepIndex int
|
|
Properties []string
|
|
}
|
|
|
|
// 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.
|
|
func Run(ctx context.Context, options Options) (Summary, error) {
|
|
if err := validate(options); err != nil {
|
|
return Summary{}, err
|
|
}
|
|
logger := options.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
|
|
summary := Summary{StartTime: time.Now()}
|
|
deadline := summary.StartTime.Add(options.Duration)
|
|
stepIndex := 0
|
|
var lastAction *verifier.Action
|
|
var lastLogTime time.Time
|
|
var pendingPostScreenshotStep int
|
|
pendingPostScreenshot := false
|
|
for time.Now().Before(deadline) {
|
|
if err := ctx.Err(); err != nil {
|
|
break
|
|
}
|
|
stepIndex++
|
|
stepStart := time.Now()
|
|
|
|
// Hierarchy, metrics, and logs are independent device reads — run
|
|
// them concurrently so metrics+logs hide behind the hierarchy fetch.
|
|
var tree *hierarchy.Tree
|
|
var hierarchyErr error
|
|
var metrics *trace.Metrics
|
|
var logs []verifier.LogEntry
|
|
|
|
g, _ := errgroup.WithContext(ctx)
|
|
g.Go(func() error {
|
|
tree, hierarchyErr = fetchHierarchy(ctx, options.Driver)
|
|
return nil
|
|
})
|
|
si := stepIndex
|
|
g.Go(func() error {
|
|
metrics = captureMetrics(ctx, options, logger, si)
|
|
return nil
|
|
})
|
|
logSince := lastLogTime
|
|
g.Go(func() error {
|
|
logs = collectLogs(ctx, options.Driver, logSince)
|
|
return nil
|
|
})
|
|
if pendingPostScreenshot {
|
|
postStep := pendingPostScreenshotStep
|
|
g.Go(func() error {
|
|
captureScreenshot(ctx, options, logger, postStep, true)
|
|
return nil
|
|
})
|
|
pendingPostScreenshot = false
|
|
}
|
|
g.Wait()
|
|
|
|
if hierarchyErr != nil {
|
|
if isWDADrop(hierarchyErr) {
|
|
return summary, fmt.Errorf("WDA connection permanently lost at step %d - re-run the test: %w", stepIndex, hierarchyErr)
|
|
}
|
|
logger.Warn("hierarchy fetch failed", "step", stepIndex, "err", hierarchyErr)
|
|
}
|
|
treeSize := 0
|
|
if tree != nil {
|
|
treeSize = len(tree.Elements)
|
|
}
|
|
lastLogTime = stepStart
|
|
|
|
if err := options.Verifier.PushSnapshot(verifier.SnapshotInput{
|
|
Tree: tree,
|
|
LastAction: lastAction,
|
|
StepTime: stepStart,
|
|
RunStart: summary.StartTime,
|
|
Logs: logs,
|
|
}); err != nil {
|
|
return summary, fmt.Errorf("step %d push: %w", stepIndex, err)
|
|
}
|
|
|
|
screen := ""
|
|
if tree != nil && len(tree.Elements) > 0 {
|
|
screen = tree.Elements[0].Screen
|
|
}
|
|
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
|
|
verdicts := options.Verifier.EvaluateProperties()
|
|
violations := violationNames(verdicts)
|
|
for _, name := range violations {
|
|
if predicateErr := options.Verifier.PredicateError(name); predicateErr != nil {
|
|
logger.Warn("predicate error", "step", stepIndex, "property", name, "err", predicateErr)
|
|
}
|
|
}
|
|
|
|
nextAction, nextErr := options.Verifier.NextAction()
|
|
var traceAction *trace.Action
|
|
if nextErr == nil {
|
|
traceAction = traceActionFor(nextAction, tree)
|
|
} else if !errors.Is(nextErr, verifier.ErrNoAction) {
|
|
return summary, fmt.Errorf("step %d next action: %w", stepIndex, nextErr)
|
|
}
|
|
|
|
residuals, residualErr := encodeResiduals(options.Verifier.Residuals())
|
|
if residualErr != nil {
|
|
logger.Warn("residual encode failed", "step", stepIndex, "err", residualErr)
|
|
}
|
|
|
|
step := trace.Step{
|
|
Index: stepIndex,
|
|
Timestamp: stepStart,
|
|
Screen: screen,
|
|
Action: traceAction,
|
|
Violations: violations,
|
|
Hierarchy: tree,
|
|
Residuals: residuals,
|
|
Metrics: metrics,
|
|
}
|
|
if err := options.TraceWriter.WriteStep(step); err != nil {
|
|
return summary, fmt.Errorf("step %d trace: %w", stepIndex, err)
|
|
}
|
|
captureScreenshot(ctx, options, logger, stepIndex, false)
|
|
summary.Steps = stepIndex
|
|
if len(violations) > 0 {
|
|
summary.Violations = append(summary.Violations, ViolationRecord{
|
|
StepIndex: stepIndex,
|
|
Properties: violations,
|
|
})
|
|
}
|
|
|
|
if nextErr == nil {
|
|
if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil {
|
|
if isWDADrop(err) {
|
|
return summary, fmt.Errorf("step %d: iOS XCTest runner lost connection - known WDA startup flake, re-run the test: %w", stepIndex, err)
|
|
}
|
|
return summary, fmt.Errorf("step %d apply: %w", stepIndex, err)
|
|
}
|
|
actionCopy := nextAction
|
|
lastAction = &actionCopy
|
|
} else {
|
|
lastAction = nil
|
|
}
|
|
|
|
idleCtx, idleCancel := context.WithTimeout(ctx, options.IdleTimeout)
|
|
idleErr := options.Driver.WaitForIdle(idleCtx, options.IdleTimeout)
|
|
if nextErr == nil {
|
|
pendingPostScreenshot = true
|
|
pendingPostScreenshotStep = stepIndex
|
|
}
|
|
if idleErr != nil && idleCtx.Err() == nil {
|
|
logger.Warn("wait_for_idle failed", "step", stepIndex, "err", idleErr)
|
|
}
|
|
idleCancel()
|
|
}
|
|
|
|
if pendingPostScreenshot {
|
|
captureScreenshot(ctx, options, logger, pendingPostScreenshotStep, true)
|
|
}
|
|
|
|
summary.EndTime = time.Now()
|
|
return summary, nil
|
|
}
|
|
|
|
func validate(options Options) error {
|
|
if options.Driver == nil {
|
|
return errors.New("runner: Driver is required")
|
|
}
|
|
if options.Verifier == nil {
|
|
return errors.New("runner: Verifier is required")
|
|
}
|
|
if options.TraceWriter == nil {
|
|
return errors.New("runner: TraceWriter is required")
|
|
}
|
|
if options.Duration <= 0 {
|
|
return errors.New("runner: Duration must be positive")
|
|
}
|
|
if options.IdleTimeout <= 0 {
|
|
options.IdleTimeout = 2 * time.Second
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func violationNames(verdicts map[string]ltl.Verdict) []string {
|
|
var names []string
|
|
for name, verdict := range verdicts {
|
|
if verdict == ltl.VerdictViolated {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) error {
|
|
switch action.Kind {
|
|
case verifier.ActionKindTap:
|
|
x, y, ok := resolveCoordinates(action, tree)
|
|
if !ok {
|
|
if action.On == "" {
|
|
return nil
|
|
}
|
|
return drv.TapSelector(ctx, action.On)
|
|
}
|
|
return drv.Tap(ctx, x, y)
|
|
case verifier.ActionKindInputText:
|
|
if x, y, ok := resolveCoordinates(action, tree); ok {
|
|
if err := drv.Tap(ctx, x, y); err != nil {
|
|
return err
|
|
}
|
|
} else if action.On != "" {
|
|
if err := drv.TapSelector(ctx, action.On); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return drv.InputText(ctx, action.Text)
|
|
case verifier.ActionKindSwipe:
|
|
duration := time.Duration(action.DurationMillis) * time.Millisecond
|
|
if duration <= 0 {
|
|
duration = 250 * time.Millisecond
|
|
}
|
|
return drv.Swipe(ctx, action.FromX, action.FromY, action.ToX, action.ToY, duration)
|
|
case verifier.ActionKindPressKey:
|
|
if action.Key == "" {
|
|
return nil
|
|
}
|
|
return drv.PressKey(ctx, action.Key)
|
|
case verifier.ActionKindWait:
|
|
duration := time.Duration(action.DurationMillis) * time.Millisecond
|
|
if duration <= 0 {
|
|
return nil
|
|
}
|
|
timer := time.NewTimer(duration)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
return nil
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown action kind %q", action.Kind)
|
|
}
|
|
}
|
|
|
|
// collectLogs pulls recent error-level log entries from the driver since the
|
|
// previous fetch. A failure is warned-on but not fatal: log capture is a
|
|
// best-effort observability channel, not a correctness dependency.
|
|
func collectLogs(ctx context.Context, drv driver.DeviceDriver, since time.Time) []verifier.LogEntry {
|
|
entries, err := drv.RecentLogs(ctx, since, "E")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
result := make([]verifier.LogEntry, 0, len(entries))
|
|
for _, entry := range entries {
|
|
result = append(result, verifier.LogEntry{
|
|
UnixMillis: entry.UnixMillis,
|
|
Level: entry.Level,
|
|
Tag: entry.Tag,
|
|
Message: entry.Message,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, bool) {
|
|
if action.X > 0 && action.Y > 0 {
|
|
return action.X, action.Y, true
|
|
}
|
|
if tree != nil && action.On != "" {
|
|
if element := tree.Find(action.On); element != nil {
|
|
x, y := element.Bounds.Center()
|
|
if x > 0 && y > 0 {
|
|
return x, y, true
|
|
}
|
|
}
|
|
}
|
|
return 0, 0, false
|
|
}
|
|
|
|
func fetchHierarchy(ctx context.Context, drv driver.DeviceDriver) (*hierarchy.Tree, error) {
|
|
xmlText, err := drv.Hierarchy(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return hierarchy.Parse(xmlText)
|
|
}
|
|
|
|
func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action {
|
|
traceAction := &trace.Action{Kind: string(action.Kind), X: action.X, Y: action.Y}
|
|
switch action.Kind {
|
|
case verifier.ActionKindTap:
|
|
traceAction.Selector = action.On
|
|
stampSelectorTarget(traceAction, action, tree)
|
|
case verifier.ActionKindInputText:
|
|
traceAction.Text = action.Text
|
|
traceAction.Selector = action.On
|
|
stampSelectorTarget(traceAction, action, tree)
|
|
case verifier.ActionKindSwipe:
|
|
traceAction.FromX = action.FromX
|
|
traceAction.FromY = action.FromY
|
|
traceAction.ToX = action.ToX
|
|
traceAction.ToY = action.ToY
|
|
traceAction.DurationMillis = action.DurationMillis
|
|
traceAction.X = 0
|
|
traceAction.Y = 0
|
|
case verifier.ActionKindPressKey:
|
|
traceAction.Key = action.Key
|
|
case verifier.ActionKindWait:
|
|
traceAction.DurationMillis = action.DurationMillis
|
|
}
|
|
return traceAction
|
|
}
|
|
|
|
// stampSelectorTarget mirrors applyAction's coordinate-resolution rule so the
|
|
// trace records the same point the runner taps.
|
|
func stampSelectorTarget(traceAction *trace.Action, action verifier.Action, tree *hierarchy.Tree) {
|
|
if action.X > 0 && action.Y > 0 {
|
|
traceAction.TapPoint = &trace.PointRecord{X: action.X, Y: action.Y}
|
|
return
|
|
}
|
|
if tree == nil || action.On == "" {
|
|
return
|
|
}
|
|
element := tree.Find(action.On)
|
|
if element == nil {
|
|
return
|
|
}
|
|
bounds := element.Bounds
|
|
traceAction.ResolvedBounds = &trace.BoundsRecord{
|
|
X: bounds.Left,
|
|
Y: bounds.Top,
|
|
Width: bounds.Width(),
|
|
Height: bounds.Height(),
|
|
}
|
|
x, y := bounds.Center()
|
|
if x > 0 && y > 0 {
|
|
traceAction.TapPoint = &trace.PointRecord{X: x, Y: y}
|
|
}
|
|
}
|
|
|
|
func captureMetrics(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) *trace.Metrics {
|
|
if options.BundleID == "" {
|
|
return nil
|
|
}
|
|
sample, err := options.Driver.Metrics(ctx, options.BundleID)
|
|
if err != nil {
|
|
logger.Warn("metrics capture failed", "step", stepIndex, "err", err)
|
|
return nil
|
|
}
|
|
if sample.CPUPercent == 0 && sample.HeapBytes == 0 && sample.TotalMemoryBytes == 0 {
|
|
return nil
|
|
}
|
|
return &trace.Metrics{
|
|
CPUPercent: sample.CPUPercent,
|
|
HeapBytes: sample.HeapBytes,
|
|
TotalMemoryBytes: sample.TotalMemoryBytes,
|
|
}
|
|
}
|
|
|
|
func captureScreenshot(ctx context.Context, options Options, logger *slog.Logger, stepIndex int, after bool) {
|
|
image, err := options.Driver.Screenshot(ctx)
|
|
if err != nil {
|
|
logger.Warn("screenshot capture failed", "step", stepIndex, "after", after, "err", err)
|
|
return
|
|
}
|
|
if len(image.PNG) == 0 {
|
|
return
|
|
}
|
|
var writeErr error
|
|
if after {
|
|
writeErr = options.TraceWriter.WriteScreenshotAfter(stepIndex, image.PNG)
|
|
} else {
|
|
writeErr = options.TraceWriter.WriteScreenshot(stepIndex, image.PNG)
|
|
}
|
|
if writeErr != nil {
|
|
logger.Warn("screenshot write failed", "step", stepIndex, "after", after, "err", writeErr)
|
|
}
|
|
}
|
|
|
|
func encodeResiduals(residuals map[string]ltl.Formula) (map[string]json.RawMessage, error) {
|
|
if len(residuals) == 0 {
|
|
return nil, nil
|
|
}
|
|
encoded := make(map[string]json.RawMessage, len(residuals))
|
|
var firstErr error
|
|
for name, formula := range residuals {
|
|
body, err := json.Marshal(formula)
|
|
if err != nil {
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
encoded[name] = body
|
|
}
|
|
return encoded, firstErr
|
|
}
|
|
|
|
func isWDADrop(err error) bool {
|
|
msg := err.Error()
|
|
return strings.Contains(msg, "ConnectException") ||
|
|
(strings.Contains(msg, "code = Internal") && strings.Contains(msg, "SocketException"))
|
|
}
|