Files
sanderling/internal/runner/runner_test.go
T
pj e713b0ea5e fix(runner): a read the driver cannot make is recorded, not counted as passed
collectLogs, captureMetrics and driverIsAndroid now separate an unsupported
read from a failed one: the first is not warned as a device fault and lands on
Summary.UnsupportedReads, which RenderSummary prints. Without it a green iOS run
was indistinguishable from a run over an app that logged nothing.
2026-08-22 21:16:24 +05:30

3270 lines
114 KiB
Go

package runner
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/priyanshujain/sanderling/internal/bundler"
"github.com/priyanshujain/sanderling/internal/driver"
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
"github.com/priyanshujain/sanderling/internal/hierarchy"
"github.com/priyanshujain/sanderling/internal/trace"
"github.com/priyanshujain/sanderling/internal/verifier"
)
const fixtureSpec = `
import { actions, always, extract, Tap } from "@sanderling/spec";
const balance = extract(state => state.snapshots.balance ?? 0);
globalThis.properties = {
balanceNonNegative: always(() => balance.current >= 0),
};
globalThis.actions = actions(() => [Tap({ on: "id:next" })]);
`
// zeroWaitSpec's only action is a Wait the runner cannot perform, so every step
// chooses an action that never reaches the device.
const zeroWaitSpec = `
import { actions, always, Wait } from "@sanderling/spec";
globalThis.properties = {
alwaysHolds: always(() => true),
};
globalThis.actions = actions(() => [Wait({ durationMillis: 0 })]);
`
// absentSelectorSpec names an element the tree never holds, so every step
// dispatches by selector and the driver is the layer that finds nothing.
const absentSelectorSpec = `
import { actions, always, Tap } from "@sanderling/spec";
globalThis.properties = {
alwaysHolds: always(() => true),
};
globalThis.actions = actions(() => [Tap({ on: "id:absent" })]);
`
// noActionSpec's generator offers nothing on any screen, so every step asks the
// source for an action and is handed none.
const noActionSpec = `
import { actions, always } from "@sanderling/spec";
globalThis.properties = {
alwaysHolds: always(() => true),
};
globalThis.actions = actions(() => []);
`
// logErrorSpec's only property reads state.logs, the channel a driver without a
// log source never opens.
const logErrorSpec = `
import { actions, always, extract } from "@sanderling/spec";
const errorLines = extract(state => state.logs.filter(entry => entry.level === "E").length);
globalThis.properties = {
noLoggedErrors: always(() => errorLines.current === 0),
};
globalThis.actions = actions(() => []);
`
const violationSpec = `
import { actions, always } from "@sanderling/spec";
globalThis.properties = {
balanceNonNegative: always(() => false),
};
globalThis.actions = actions(() => []);
`
type harness struct {
mock *mockdriver.Driver
verifier *verifier.Verifier
writer *trace.Writer
directory string
}
func newHarness(t *testing.T) *harness {
return newHarnessWithSpec(t, fixtureSpec)
}
func fastFocusSettle(t *testing.T) {
prev := focusTapSettle
focusTapSettle = time.Millisecond
t.Cleanup(func() { focusTapSettle = prev })
}
// fastForegroundGate shrinks the startup gate's wall-clock budget so a test that
// drives it to exhaustion takes milliseconds. The budget stays a duration, which
// is the property under test.
func fastForegroundGate(t *testing.T) {
budget, interval := foregroundReadyBudget, foregroundPollInterval
foregroundReadyBudget = 200 * time.Millisecond
foregroundPollInterval = time.Millisecond
t.Cleanup(func() {
foregroundReadyBudget, foregroundPollInterval = budget, interval
})
}
func mustDispatch(t *testing.T, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) {
t.Helper()
skipped, err := applyAction(context.Background(), drv, action, tree)
if err != nil {
t.Fatalf("applyAction: %v", err)
}
if skipped != "" {
t.Fatalf("applyAction reported %q: the action never reached the driver", skipped)
}
}
// bundleSpec compiles an authored TS spec with the goja runtime entry so the
// loaded bundle installs __sanderlingNextAction__ (the shared picker).
func bundleSpec(t *testing.T, specSource string) string {
t.Helper()
dir := t.TempDir()
specPath := filepath.Join(dir, "spec.ts")
if err := os.WriteFile(specPath, []byte(specSource), 0o600); err != nil {
t.Fatal(err)
}
apiPath, err := filepath.Abs("../../pkg/spec/src/index.ts")
if err != nil {
t.Fatal(err)
}
runtimePath, err := filepath.Abs("../../pkg/spec/src/goja-runtime.ts")
if err != nil {
t.Fatal(err)
}
bundle, err := bundler.Bundle(bundler.Options{
EntryFile: specPath,
RuntimeFile: runtimePath,
Aliases: map[string]string{"@sanderling/spec": apiPath},
})
if err != nil {
t.Fatal(err)
}
return string(bundle.JavaScript)
}
func newHarnessWithSpec(t *testing.T, spec string) *harness {
t.Helper()
directory := t.TempDir()
writer, err := trace.NewWriter(directory)
if err != nil {
t.Fatal(err)
}
verifierInstance, err := verifier.New()
if err != nil {
t.Fatal(err)
}
if err := verifierInstance.Load(bundleSpec(t, spec)); err != nil {
t.Fatal(err)
}
state := &harness{
mock: mockdriver.New(),
verifier: verifierInstance,
writer: writer,
directory: directory,
}
t.Cleanup(func() { _ = writer.Close() })
return state
}
func TestRunner_HappyPathStepsAndTraces(t *testing.T) {
state := newHarness(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
IdleTimeout: 50 * time.Millisecond,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps == 0 {
t.Errorf("expected at least one step, got 0")
}
if len(summary.Violations) != 0 {
t.Errorf("no violations expected, got %v", summary.Violations)
}
// Every builtin verb is supported on every platform, so a clean run must
// report no unsupported verbs (the runner still wires the field through).
if len(summary.UnsupportedVerbs) != 0 {
t.Errorf("expected no unsupported verbs, got %v", summary.UnsupportedVerbs)
}
actions := state.mock.Actions()
if !containsAction(actions, mockdriver.ActionTapSelector, "id:next") {
t.Errorf("expected TapSelector with id:next, got %v", actions)
}
}
// TestRunner_SeededRunRecordsNoModelCalls keeps the arms distinguishable: the
// seeded picker consults nothing, so its run directory must carry no model-call
// output at all rather than a file of empty records.
func TestRunner_SeededRunRecordsNoModelCalls(t *testing.T) {
state := newHarness(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 10 * time.Millisecond,
MaxSteps: 3,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
}); err != nil {
t.Fatalf("Run: %v", err)
}
if _, err := os.Stat(filepath.Join(state.directory, trace.LLMCallFileName)); !os.IsNotExist(err) {
t.Errorf("stat %s = %v, want no model-call file for a seeded run", trace.LLMCallFileName, err)
}
}
// seededLoginSetupSpec drives the first two steps from setup, the way a
// login-fronted spec does, and leaves the rest to the seeded action root.
const seededLoginSetupSpec = `
import { always, actions, taps, typing, weighted, Tap } from "@sanderling/spec";
globalThis.properties = { ok: always(() => true) };
let setupTapsLeft = 2;
globalThis.setup = actions(() => (setupTapsLeft-- > 0 ? [Tap({ on: "id:Submit" })] : []));
globalThis.actions = weighted([1, taps], [1, typing]);
`
// TestRunner_SeededSetupActionsAreNotTheGeneratorDrivingTheApp: a seeded run's
// login steps used to be indistinguishable from its exploration, so the arm
// divided its defect rate by every action it dispatched while the model arm
// divided by the ones its policy chose. The two rates were then compared.
func TestRunner_SeededSetupActionsAreNotTheGeneratorDrivingTheApp(t *testing.T) {
state := newHarnessWithSpec(t, seededLoginSetupSpec)
state.mock.HierarchyJSON = llmTreeJSON
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 30 * time.Second,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 4,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.DispatchedActions != 4 {
t.Errorf("DispatchedActions = %d, want 4: every step drove the app",
summary.DispatchedActions)
}
if summary.GeneratorActions != 2 {
t.Errorf("GeneratorActions = %d, want 2: the picker drove the two steps setup left it",
summary.GeneratorActions)
}
lines := readTraceLines(t, state.writer.Directory())
if len(lines) != 4 {
t.Fatalf("wrote %d trace lines, want 4", len(lines))
}
for _, line := range lines[:2] {
if line.NextAction == nil || line.NextAction.Source != trace.ActionSourceSetup {
t.Errorf("step %d action = %+v, want one named setup", line.Step, line.NextAction)
}
}
for _, line := range lines[2:] {
if line.NextAction == nil || line.NextAction.Source != trace.ActionSourceSeeded {
t.Errorf("step %d action = %+v, want one named seeded", line.Step, line.NextAction)
}
}
}
func TestRunner_MaxStepsStopsAfterExactlyNSteps(t *testing.T) {
state := newHarness(t)
const maxSteps = 3
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// A long duration ensures MaxSteps, not the deadline, ends the run.
summary, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 10 * time.Millisecond,
MaxSteps: maxSteps,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps != maxSteps {
t.Errorf("expected exactly %d steps, got %d", maxSteps, summary.Steps)
}
}
// TestRenderSummary_SurfacesUnsupportedVerbs exercises the real path that takes
// verbs the picker requested but the platform cannot dispatch and puts them in
// front of the operator. TestRunner_HappyPath only ever asserts the field stays
// empty (every builtin is supported, so its non-empty arm can never fire), so
// without this the "unsupported on %s: ..." branch could be deleted and every
// unsupported-verb regression would pass silently.
func TestRenderSummary_SurfacesUnsupportedVerbs(t *testing.T) {
summary := Summary{Steps: 3, UnsupportedVerbs: []string{"longPresses", "scrolls"}}
var out bytes.Buffer
RenderSummary(&out, summary, "ios")
if !strings.Contains(out.String(), "unsupported on ios: longPresses, scrolls") {
t.Errorf("expected unsupported verbs line for ios, got:\n%s", out.String())
}
}
// TestRenderSummary_OmitsUnsupportedLineWhenNone guards the inverse: a clean run
// must not print a stray "unsupported on" line, so a future refactor cannot
// start emitting an empty list and alarm the operator on every run.
func TestRenderSummary_OmitsUnsupportedLineWhenNone(t *testing.T) {
var out bytes.Buffer
RenderSummary(&out, Summary{Steps: 2}, "android")
if strings.Contains(out.String(), "unsupported on") {
t.Errorf("did not expect an unsupported-verbs line, got:\n%s", out.String())
}
}
// A step nothing judged is not a step that passed. The run prints its count so
// a green summary cannot hide a run that skipped most of its steps, which is
// what a screen that keeps moving under the reads would produce.
func TestRenderSummary_CountsTheStepsNothingJudged(t *testing.T) {
var out bytes.Buffer
RenderSummary(&out, Summary{Steps: 10, SkippedVerification: 4}, "android")
if !strings.Contains(out.String(), "4 step(s) judged by nothing") {
t.Errorf("expected the unjudged-step count, got:\n%s", out.String())
}
out.Reset()
RenderSummary(&out, Summary{Steps: 10}, "android")
if strings.Contains(out.String(), "judged by nothing") {
t.Errorf("a run that judged every step must not print the line, got:\n%s", out.String())
}
}
// A driver that cannot read a channel is not a device that had nothing to
// report on it. The property over state.logs holds here because nobody ever
// looked, so the run has to say which reads it never made: without the line a
// green iOS summary is indistinguishable from one over a silent app.
func TestRunner_ReadsTheDriverCannotMakeAreNotPassedChecks(t *testing.T) {
state := newHarnessWithSpec(t, logErrorSpec)
state.mock.Failures[mockdriver.ActionRecentLogs] = driver.ErrNotSupported
state.mock.Failures[mockdriver.ActionMetrics] = driver.ErrNotSupported
state.mock.Failures[mockdriver.ActionHealth] = driver.ErrNotSupported
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 5 * time.Second,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 3,
BundleID: "com.fixture",
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps != 3 {
t.Fatalf("Steps = %d, want 3", summary.Steps)
}
if len(summary.Violations) != 0 {
t.Fatalf("the log property fired; this test needs it holding on the empty channel: %v",
summary.Violations)
}
if summary.FailedObservations != 0 {
t.Errorf("FailedObservations = %d: a read the driver does not have is not a device fault",
summary.FailedObservations)
}
want := []string{unsupportedReadMetrics, unsupportedReadHealth, unsupportedReadLogs}
slices.Sort(want)
if !slices.Equal(summary.UnsupportedReads, want) {
t.Errorf("UnsupportedReads = %v, want %v", summary.UnsupportedReads, want)
}
var rendered bytes.Buffer
RenderSummary(&rendered, summary, "ios")
if !strings.Contains(rendered.String(), "never read on ios: "+strings.Join(want, ", ")) {
t.Errorf("the run reports as a clean one:\n%s", rendered.String())
}
}
// The inverse, so the line cannot start appearing on every run: a driver that
// answers all three reports none missing and prints nothing.
func TestRunner_ADriverThatAnswersEveryReadReportsNoneMissing(t *testing.T) {
state := newHarness(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 5 * time.Second,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 2,
BundleID: "com.fixture",
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if len(summary.UnsupportedReads) != 0 {
t.Errorf("UnsupportedReads = %v, want none", summary.UnsupportedReads)
}
var rendered bytes.Buffer
RenderSummary(&rendered, summary, "android")
if strings.Contains(rendered.String(), "never read on") {
t.Errorf("a run that made every read must not print the line:\n%s", rendered.String())
}
}
func TestRunner_ViolationSurfacesInSummary(t *testing.T) {
state := newHarnessWithSpec(t, violationSpec)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
IdleTimeout: 50 * time.Millisecond,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if len(summary.Violations) == 0 {
t.Errorf("expected at least one violation, got %v", summary.Violations)
}
if !containsProperty(summary.Violations, "balanceNonNegative") {
t.Errorf("expected balanceNonNegative in violations: %v", summary.Violations)
}
}
func TestRunner_ViolationSurfacesOnlyOnOnsetStep(t *testing.T) {
// violationSpec uses always(() => false): onset fires on step 1 and the
// residual stays violated forever. The runner must record the violation
// exactly once (at the onset step) in both summary.Violations and trace
// lines, not on every subsequent step.
state := newHarnessWithSpec(t, violationSpec)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 200 * time.Millisecond,
IdleTimeout: 20 * time.Millisecond,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps < 2 {
t.Fatalf("need at least 2 steps to prove onset-only behavior, got %d", summary.Steps)
}
if len(summary.Violations) != 1 {
t.Fatalf("expected exactly one ViolationRecord (onset only), got %d: %v",
len(summary.Violations), summary.Violations)
}
if summary.Violations[0].StepIndex != 1 {
t.Errorf("onset step: got %d, want 1 (always(()=>false) violates immediately)",
summary.Violations[0].StepIndex)
}
if !slices.Equal(summary.Violations[0].Properties, []string{"balanceNonNegative"}) {
t.Errorf("onset properties: got %v, want [balanceNonNegative]",
summary.Violations[0].Properties)
}
file, err := os.Open(filepath.Join(state.writer.Directory(), "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
defer file.Close()
type traceLine struct {
Step int `json:"step"`
Violations []string `json:"violations"`
}
linesWithViolations := 0
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for scanner.Scan() {
var line traceLine
if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
t.Fatalf("trace line decode: %v", err)
}
if len(line.Violations) == 0 {
continue
}
linesWithViolations++
if line.Step != 1 {
t.Errorf("step %d unexpectedly emitted violations %v (should be onset-only at step 1)",
line.Step, line.Violations)
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan trace: %v", err)
}
if linesWithViolations != 1 {
t.Errorf("expected exactly 1 trace line with violations, got %d", linesWithViolations)
}
}
func TestRunner_NextViolationAttributedToCausingStep(t *testing.T) {
// always(next(p)): the obligation spawned at step 2 fails against step 3's
// state. The summary record and the trace witness must attribute the
// violation to step 2 (the causing step); the trace line that carries it is
// still step 3, where the failure was detected.
const nextViolationSpec = `
import { actions, always, next, extract } from "@sanderling/spec";
let observed = 0;
const tick = extract(() => ++observed);
globalThis.properties = {
nextHolds: always(next(() => tick.current < 3)),
};
globalThis.actions = actions(() => []);
`
state := newHarnessWithSpec(t, nextViolationSpec)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 5,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if len(summary.Violations) != 1 {
t.Fatalf("expected exactly one ViolationRecord, got %d: %v",
len(summary.Violations), summary.Violations)
}
if summary.Violations[0].StepIndex != 2 {
t.Errorf("summary step: got %d, want 2 (the step that spawned the next obligation)",
summary.Violations[0].StepIndex)
}
if !slices.Equal(summary.Violations[0].Properties, []string{"nextHolds"}) {
t.Errorf("properties: got %v, want [nextHolds]", summary.Violations[0].Properties)
}
file, err := os.Open(filepath.Join(state.writer.Directory(), "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
defer file.Close()
type traceLine struct {
Step int `json:"step"`
Violations []string `json:"violations"`
Witnesses map[string]trace.Witness `json:"witnesses"`
}
found := false
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for scanner.Scan() {
var line traceLine
if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
t.Fatalf("trace line decode: %v", err)
}
if len(line.Violations) == 0 {
continue
}
found = true
if line.Step != 3 {
t.Errorf("violation detected on step %d, want 3", line.Step)
}
witness, ok := line.Witnesses["nextHolds"]
if !ok {
t.Fatalf("step %d carries no witness for nextHolds", line.Step)
}
if witness.Step != 2 {
t.Errorf("witness step: got %d, want 2 (causing step)", witness.Step)
}
// The two indices the witness spans are recorded separately: the
// extractor snapshot it carries is step 3's state, not step 2's.
if witness.DetectedStep != 3 {
t.Errorf("witness detected step: got %d, want 3", witness.DetectedStep)
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan trace: %v", err)
}
if !found {
t.Error("no trace line carried the violation")
}
}
func TestRunner_AlwaysNextLeavesNoEndOfRunViolation(t *testing.T) {
// always(next(p)) ends every run with a pending deferred check. That
// residue is vacuous (no successor state to check), so neither the
// summary nor the trace may report an end-of-run violation.
const spec = `
import { actions, always, next } from "@sanderling/spec";
globalThis.properties = {
nextHolds: always(next(() => true)),
};
globalThis.actions = actions(() => []);
`
state := newHarnessWithSpec(t, spec)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 3,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if len(summary.Violations) != 0 {
t.Errorf("expected no violations, got %v", summary.Violations)
}
}
func TestRunner_FinalizeRecordUsesDistinctStepIndex(t *testing.T) {
// An eventually that never fires is reported at run end through a
// synthetic trace record. That record must carry its own step index so no
// two trace lines share one (duplicate indices made the replay UI select
// two rows at once).
const spec = `
import { actions, eventually } from "@sanderling/spec";
globalThis.properties = {
neverFires: eventually(() => false),
};
globalThis.actions = actions(() => []);
`
state := newHarnessWithSpec(t, spec)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 3,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !containsProperty(summary.Violations, "neverFires") {
t.Fatalf("expected neverFires in violations: %v", summary.Violations)
}
file, err := os.Open(filepath.Join(state.writer.Directory(), "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
defer file.Close()
type traceLine struct {
Step int `json:"step"`
Violations []string `json:"violations"`
}
seen := map[int]bool{}
finalizeStep := 0
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for scanner.Scan() {
var line traceLine
if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
t.Fatalf("trace line decode: %v", err)
}
if seen[line.Step] {
t.Errorf("duplicate step index %d in trace", line.Step)
}
seen[line.Step] = true
if slices.Contains(line.Violations, "neverFires") {
finalizeStep = line.Step
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan trace: %v", err)
}
if finalizeStep != summary.Steps+1 {
t.Errorf("finalize record step = %d, want %d (steps+1)", finalizeStep, summary.Steps+1)
}
}
func TestRunner_ThrowingPredicateIsLoggedNotPanic(t *testing.T) {
const throwingSpec = `
import { actions, always, Tap } from "@sanderling/spec";
globalThis.properties = {
broken: always(() => { throw new Error("bad predicate"); }),
};
globalThis.actions = actions(() => [Tap({ on: "id:next" })]);
`
state := newHarnessWithSpec(t, throwingSpec)
var buffer bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buffer, &slog.HandlerOptions{Level: slog.LevelWarn}))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
IdleTimeout: 50 * time.Millisecond,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
Logger: logger,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !containsProperty(summary.Violations, "broken") {
t.Errorf("expected broken in violations: %v", summary.Violations)
}
if !strings.Contains(buffer.String(), "bad predicate") {
t.Errorf("expected predicate error in log, got %q", buffer.String())
}
}
func TestRunner_RejectsMissingFields(t *testing.T) {
_, err := Run(context.Background(), Options{Duration: time.Second})
if err == nil || !strings.Contains(err.Error(), "Driver") {
t.Errorf("expected Driver-required error, got %v", err)
}
}
func TestRunner_RejectsZeroDuration(t *testing.T) {
_, err := Run(context.Background(), Options{
Driver: mockdriver.New(),
Verifier: mustNewVerifier(t),
TraceWriter: mustNewTraceWriter(t),
})
if err == nil || !strings.Contains(err.Error(), "Duration") {
t.Errorf("expected Duration-required error, got %v", err)
}
}
func TestRunner_StampsHierarchyResolvedBoundsAndResiduals(t *testing.T) {
state := newHarness(t)
state.mock.HierarchyJSON = `{"attributes":{"resource-id":"com.fixture:id/next","bounds":"[40,80,240,160]"},"children":[],"clickable":true,"enabled":true}`
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
IdleTimeout: 50 * time.Millisecond,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
}); err != nil {
t.Fatalf("Run: %v", err)
}
body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
text := string(body)
if !strings.Contains(text, `"selector":"id:next"`) {
t.Errorf("expected selector in trace: %s", text)
}
if !strings.Contains(text, `"resolved_bounds":{"x":40,"y":80,"width":200,"height":80}`) {
t.Errorf("expected resolved_bounds in trace: %s", text)
}
if !strings.Contains(text, `"tap_point":{"x":140,"y":120}`) {
t.Errorf("expected tap_point in trace: %s", text)
}
if !strings.Contains(text, `"hierarchy":{"elements":`) {
t.Errorf("expected hierarchy in trace: %s", text)
}
if !strings.Contains(text, `"residuals":{`) {
t.Errorf("expected residuals in trace: %s", text)
}
}
// TestTraceActionFor_StaleCoordinatesDoNotOverrideTreeCenter pins the stamp
// to applyAction's resolution rule: when On resolves in the tree, the trace
// tap point must be the tree center even if the action carries stale X/Y
// from an earlier tick.
func TestTraceActionFor_StaleCoordinatesDoNotOverrideTreeCenter(t *testing.T) {
tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
{"attributes":{"resource-id":"next","bounds":"[100,200,300,400]"},"children":[]}
]}`)
if err != nil {
t.Fatalf("Parse: %v", err)
}
action := verifier.Action{Kind: verifier.ActionKindTap, On: "id:next", X: 50, Y: 60}
traceAction := traceActionFor(action, tree)
if traceAction.TapPoint == nil {
t.Fatal("expected a tap point")
}
if traceAction.TapPoint.X != 200 || traceAction.TapPoint.Y != 300 {
t.Errorf("tap point = (%d,%d), want tree center (200,300)",
traceAction.TapPoint.X, traceAction.TapPoint.Y)
}
if traceAction.ResolvedBounds == nil {
t.Fatal("expected resolved bounds")
}
if traceAction.ResolvedBounds.X != 100 || traceAction.ResolvedBounds.Y != 200 {
t.Errorf("resolved bounds origin = (%d,%d), want (100,200)",
traceAction.ResolvedBounds.X, traceAction.ResolvedBounds.Y)
}
}
// TestTraceActionFor_RecordsKindSpecificFields locks each action kind's trace
// encoding. PressKey must carry its Key and Wait its DurationMillis; if either
// branch of traceActionFor drops the field (or a field rename desyncs from the
// trace.Action struct) the replay UI silently renders a key-less PressKey or a
// zero-duration Wait. Swipe's endpoint encoding is covered separately via
// applyAction (TestApplyAction_ScrollWithPrecomputedEndpointsSwipes).
func TestTraceActionFor_RecordsKindSpecificFields(t *testing.T) {
cases := []struct {
name string
action verifier.Action
check func(*testing.T, *trace.Action)
}{
{
"PressKey records key",
verifier.Action{Kind: verifier.ActionKindPressKey, Key: "back"},
func(t *testing.T, a *trace.Action) {
if a.Key != "back" {
t.Errorf("Key = %q, want %q", a.Key, "back")
}
},
},
{
"Wait records duration",
verifier.Action{Kind: verifier.ActionKindWait, DurationMillis: 250},
func(t *testing.T, a *trace.Action) {
if a.DurationMillis != 250 {
t.Errorf("DurationMillis = %d, want 250", a.DurationMillis)
}
},
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
traceAction := traceActionFor(testCase.action, nil)
if traceAction.Kind != string(testCase.action.Kind) {
t.Errorf("Kind = %q, want %q", traceAction.Kind, testCase.action.Kind)
}
testCase.check(t, traceAction)
})
}
}
func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
state := newHarness(t)
state.mock.Failures[mockdriver.ActionWaitForIdle] = errors.New("sidecar lost gRPC stream")
var logBuf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
IdleTimeout: 50 * time.Millisecond,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
Logger: logger,
}); err != nil {
t.Fatalf("Run: %v", err)
}
output := logBuf.String()
if !strings.Contains(output, "wait_for_idle failed") {
t.Errorf("expected wait_for_idle warning, got: %q", output)
}
if !strings.Contains(output, "sidecar lost gRPC stream") {
t.Errorf("expected driver error message in warning, got: %q", output)
}
}
func TestApplyAction_InputTextErasesExistingTextBeforeTyping(t *testing.T) {
fastFocusSettle(t)
tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
{"attributes":{"resource-id":"username","text":"stale-value","bounds":"[10,10,500,100]"},"children":[]}
]}`)
if err != nil {
t.Fatalf("Parse: %v", err)
}
driverMock := mockdriver.New()
action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"}
mustDispatch(t, driverMock, action, tree)
// The post-tap settle is now a brief internal sleep, not a WaitForIdle RPC,
// so the recorded driver actions are tap, erase, input.
actions := driverMock.Actions()
if len(actions) != 3 {
t.Fatalf("want tap, erase, input; got %v", actions)
}
if actions[0].Kind != mockdriver.ActionTap {
t.Errorf("first action = %v, want tap", actions[0].Kind)
}
if actions[1].Kind != mockdriver.ActionEraseText || actions[1].CharacterCount != len("stale-value") {
t.Errorf("second action = %+v, want erase_text of %d characters", actions[1], len("stale-value"))
}
if actions[2].Kind != mockdriver.ActionInputText || actions[2].Text != "alice" {
t.Errorf("third action = %+v, want input_text alice", actions[2])
}
}
// TestApplyAction_InputTextSkipsEraseForReplacingDriver pins that a driver
// asserting the TextReplacer capability never pays the pre-erase round-trip:
// its InputText already replaces the field's content.
func TestApplyAction_InputTextSkipsEraseForReplacingDriver(t *testing.T) {
fastFocusSettle(t)
tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
{"attributes":{"resource-id":"username","text":"stale-value","bounds":"[10,10,500,100]"},"children":[]}
]}`)
if err != nil {
t.Fatalf("Parse: %v", err)
}
driverMock := mockdriver.New()
driverMock.ReplacesText = true
action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"}
mustDispatch(t, driverMock, action, tree)
if containsAction(driverMock.Actions(), mockdriver.ActionEraseText, "") {
t.Errorf("replacing driver must not be asked to erase: %v", driverMock.Actions())
}
if !containsAction(driverMock.Actions(), mockdriver.ActionInputText, "") {
t.Errorf("expected InputText, got %v", driverMock.Actions())
}
}
func TestApplyAction_InputTextSkipsEraseWhenTargetEmpty(t *testing.T) {
fastFocusSettle(t)
tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
{"attributes":{"resource-id":"username","bounds":"[10,10,500,100]"},"children":[]}
]}`)
if err != nil {
t.Fatalf("Parse: %v", err)
}
driverMock := mockdriver.New()
action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"}
mustDispatch(t, driverMock, action, tree)
if containsAction(driverMock.Actions(), mockdriver.ActionEraseText, "") {
t.Errorf("empty field must not be erased: %v", driverMock.Actions())
}
}
func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) {
t.Run("selector focus tap fails", func(t *testing.T) {
driverMock := mockdriver.New()
driverMock.Failures[mockdriver.ActionTapSelector] = errors.New("adb unreachable")
action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"}
_, err := applyAction(context.Background(), driverMock, action, nil)
if err == nil {
t.Fatalf("expected focus tap failure to surface, got nil")
}
if containsAction(driverMock.Actions(), mockdriver.ActionInputText, "") {
t.Errorf("InputText must not run after focus tap failed: %v", driverMock.Actions())
}
})
t.Run("coordinate focus tap fails", func(t *testing.T) {
driverMock := mockdriver.New()
driverMock.Failures[mockdriver.ActionTap] = errors.New("tap driver error")
action := verifier.Action{Kind: verifier.ActionKindInputText, X: 10, Y: 20, Text: "alice"}
_, err := applyAction(context.Background(), driverMock, action, nil)
if err == nil {
t.Fatalf("expected focus tap failure to surface, got nil")
}
if containsAction(driverMock.Actions(), mockdriver.ActionInputText, "") {
t.Errorf("InputText must not run after focus tap failed: %v", driverMock.Actions())
}
})
}
// loginFocusOnEmail is the folio login screen as Android reports it once the
// email field has been typed into: email holds focus, password does not.
const loginFocusOnEmail = `{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
{"attributes":{"resource-id":"LoginEmail","text":"[email protected]","bounds":"[94,240,986,372]"},"focused":true,"children":[]},
{"attributes":{"resource-id":"LoginPassword","bounds":"[94,461,986,593]"},"focused":false,"children":[]}
]}`
const loginFocusOnPassword = `{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
{"attributes":{"resource-id":"LoginEmail","text":"[email protected]","bounds":"[94,240,986,372]"},"focused":false,"children":[]},
{"attributes":{"resource-id":"LoginPassword","bounds":"[94,461,986,593]"},"focused":true,"children":[]}
]}`
// A keyboard overlay window can sit over the field the focus tap aims at, so
// the tap never reaches it and focus stays where it was. Typing then appends to
// the previously focused field: on folio the password ran into the email field
// and the login setup leaf retried forever.
func TestApplyAction_InputTextStopsWhenAnotherFieldHoldsFocus(t *testing.T) {
fastFocusSettle(t)
tree, err := hierarchy.Parse(loginFocusOnEmail)
if err != nil {
t.Fatalf("Parse: %v", err)
}
driverMock := mockdriver.New()
driverMock.HierarchyJSON = loginFocusOnEmail
action := verifier.Action{
Kind: verifier.ActionKindInputText,
On: "id:LoginPassword",
Text: "ledger123",
}
_, err = applyAction(context.Background(), driverMock, action, tree)
if err == nil {
t.Fatal("a focus tap that never focused the target must be reported, not typed through")
}
if !strings.Contains(err.Error(), "LoginEmail") {
t.Errorf("error must name the field holding focus, got: %v", err)
}
if containsAction(driverMock.Actions(), mockdriver.ActionInputText, "") {
t.Errorf("password text must not be typed into the focused email field: %v", driverMock.Actions())
}
}
func TestApplyAction_InputTextTypesWhenTargetTakesFocus(t *testing.T) {
fastFocusSettle(t)
tree, err := hierarchy.Parse(loginFocusOnEmail)
if err != nil {
t.Fatalf("Parse: %v", err)
}
driverMock := mockdriver.New()
driverMock.HierarchyJSON = loginFocusOnPassword
action := verifier.Action{
Kind: verifier.ActionKindInputText,
On: "id:LoginPassword",
Text: "ledger123",
}
mustDispatch(t, driverMock, action, tree)
if !typedText(driverMock.Actions(), "ledger123") {
t.Errorf("expected InputText once the target holds focus, got %v", driverMock.Actions())
}
}
// Platforms whose hierarchy omits focus entirely (iOS) have nothing to compare,
// so they must not pay a hierarchy read per InputText.
func TestApplyAction_InputTextSkipsFocusCheckWhenHierarchyOmitsFocus(t *testing.T) {
fastFocusSettle(t)
tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
{"attributes":{"resource-id":"LoginPassword","bounds":"[94,461,986,593]"},"children":[]}
]}`)
if err != nil {
t.Fatalf("Parse: %v", err)
}
driverMock := mockdriver.New()
action := verifier.Action{
Kind: verifier.ActionKindInputText,
On: "id:LoginPassword",
Text: "ledger123",
}
mustDispatch(t, driverMock, action, tree)
if containsAction(driverMock.Actions(), mockdriver.ActionHierarchy, "") {
t.Errorf("no focus to compare: expected no hierarchy read, got %v", driverMock.Actions())
}
if !typedText(driverMock.Actions(), "ledger123") {
t.Errorf("expected InputText, got %v", driverMock.Actions())
}
}
const loginFocusOnNothing = `{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
{"attributes":{"resource-id":"LoginEmail","bounds":"[94,240,986,372]"},"focused":false,"children":[]},
{"attributes":{"resource-id":"LoginPassword","bounds":"[94,461,986,593]"},"focused":false,"children":[]}
]}`
// Typed text can only be corrupted into a field that already holds focus, so
// a pre-tap hierarchy showing focus elsewhere is the one class worth the
// confirming read.
func TestApplyAction_InputTextConfirmsFocusWhenAnotherFieldHeldItBeforeTheTap(t *testing.T) {
fastFocusSettle(t)
tree, err := hierarchy.Parse(loginFocusOnEmail)
if err != nil {
t.Fatalf("Parse: %v", err)
}
driverMock := mockdriver.New()
driverMock.HierarchyJSON = loginFocusOnPassword
action := verifier.Action{
Kind: verifier.ActionKindInputText,
On: "id:LoginPassword",
Text: "ledger123",
}
mustDispatch(t, driverMock, action, tree)
if !containsAction(driverMock.Actions(), mockdriver.ActionHierarchy, "") {
t.Errorf("another field held focus before the tap: expected the confirming read, got %v", driverMock.Actions())
}
if !typedText(driverMock.Actions(), "ledger123") {
t.Errorf("expected InputText once the target took focus, got %v", driverMock.Actions())
}
}
// The confirming read is a device round-trip on every InputText step. Where no
// other element holds focus before the tap there is no field for the text to
// be corrupted into, so the read buys nothing and must not be paid for.
func TestApplyAction_InputTextSkipsFocusCheckWhenNoOtherFieldHoldsFocus(t *testing.T) {
fastFocusSettle(t)
for name, beforeTap := range map[string]string{
"target already holds focus": loginFocusOnPassword,
"nothing holds focus": loginFocusOnNothing,
} {
t.Run(name, func(t *testing.T) {
tree, err := hierarchy.Parse(beforeTap)
if err != nil {
t.Fatalf("Parse: %v", err)
}
driverMock := mockdriver.New()
driverMock.HierarchyJSON = loginFocusOnEmail
action := verifier.Action{
Kind: verifier.ActionKindInputText,
On: "id:LoginPassword",
Text: "ledger123",
}
mustDispatch(t, driverMock, action, tree)
if containsAction(driverMock.Actions(), mockdriver.ActionHierarchy, "") {
t.Errorf("no other field held focus: expected no confirming read, got %v", driverMock.Actions())
}
if !typedText(driverMock.Actions(), "ledger123") {
t.Errorf("expected InputText, got %v", driverMock.Actions())
}
})
}
}
// A selector the hierarchy cannot resolve is the guard saying "I cannot tell",
// not "another element holds focus". Answering the second manufactures an apply
// error on every InputText the tree has no node for, and three in a row abort
// the run.
func TestApplyAction_InputTextTypesWhenTheTargetIsNotInTheHierarchy(t *testing.T) {
fastFocusSettle(t)
tree, err := hierarchy.Parse(loginFocusOnEmail)
if err != nil {
t.Fatalf("Parse: %v", err)
}
driverMock := mockdriver.New()
driverMock.HierarchyJSON = loginFocusOnEmail
action := verifier.Action{
Kind: verifier.ActionKindInputText,
On: "data-testid:LoginPassword",
Text: "ledger123",
}
mustDispatch(t, driverMock, action, tree)
if !typedText(driverMock.Actions(), "ledger123") {
t.Errorf("an unresolvable target must not block typing, got %v", driverMock.Actions())
}
}
func typedText(actions []mockdriver.Action, text string) bool {
for _, action := range actions {
if action.Kind == mockdriver.ActionInputText && action.Text == text {
return true
}
}
return false
}
func TestApplyAction_V8InputTextTapsAtCoordinates(t *testing.T) {
fastFocusSettle(t)
driverMock := mockdriver.New()
action := verifier.Action{Kind: verifier.ActionKindInputText, X: 50, Y: 100, Text: "alice"}
mustDispatch(t, driverMock, action, nil)
actions := driverMock.Actions()
if !containsAction(actions, mockdriver.ActionTap, "") {
t.Errorf("expected focus Tap before InputText, got %v", actions)
}
if !containsAction(actions, mockdriver.ActionInputText, "") {
t.Errorf("expected InputText after focus Tap, got %v", actions)
}
}
func TestApplyAction_V8InputTextAtOriginStillTaps(t *testing.T) {
fastFocusSettle(t)
driverMock := mockdriver.New()
// V8 emits real (0,0) coordinates for an element at viewport top-left
// (post-#15 the runtime nullifies unresolved actions, so a non-null
// InputText with (0,0) is a deliberate edge tap, not a sentinel).
action := verifier.Action{Kind: verifier.ActionKindInputText, X: 0, Y: 0, Text: "alice"}
mustDispatch(t, driverMock, action, nil)
if !containsAction(driverMock.Actions(), mockdriver.ActionTap, "") {
t.Errorf("expected focus Tap at (0,0), got %v", driverMock.Actions())
}
}
// Attribute values match by substring, so "data-testid:card" answers to
// "card-1" and "card-10" alike. A candidate built where the selector named one
// element can execute where it names several, and the tree lookup would send
// every one of them to the first match.
func TestApplyAction_AmbiguousSelectorTapsTheActionsOwnCoordinates(t *testing.T) {
tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
{"attributes":{"data-testid":"card-1","bounds":"[0,100,200,200]"},"children":[]},
{"attributes":{"data-testid":"card-10","bounds":"[0,300,200,400]"},"children":[]}
]}`)
if err != nil {
t.Fatalf("Parse: %v", err)
}
driverMock := mockdriver.New()
action := verifier.Action{Kind: verifier.ActionKindTap, On: "data-testid:card-1", X: 100, Y: 350}
mustDispatch(t, driverMock, action, tree)
for _, dispatched := range driverMock.Actions() {
if dispatched.Kind == mockdriver.ActionTap && dispatched.X == 100 && dispatched.Y == 350 {
return
}
}
t.Errorf("tap reached the driver at %v, want the action's own (100,350)", driverMock.Actions())
}
// A bare-string target carries no coordinates of its own, so an ambiguous name
// is all there is to act on and the first match stays the answer. Refusing it
// would drop an authored action.
func TestApplyAction_AmbiguousSelectorWithoutCoordinatesTapsTheFirstMatch(t *testing.T) {
tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
{"attributes":{"data-testid":"card-1","bounds":"[0,100,200,200]"},"children":[]},
{"attributes":{"data-testid":"card-10","bounds":"[0,300,200,400]"},"children":[]}
]}`)
if err != nil {
t.Fatalf("Parse: %v", err)
}
driverMock := mockdriver.New()
action := verifier.Action{Kind: verifier.ActionKindTap, On: "data-testid:card-1"}
mustDispatch(t, driverMock, action, tree)
for _, dispatched := range driverMock.Actions() {
if dispatched.Kind == mockdriver.ActionTap && dispatched.X == 100 && dispatched.Y == 150 {
return
}
}
t.Errorf("tap reached the driver at %v, want the first match's centre (100,150)", driverMock.Actions())
}
func TestApplyAction_DoubleTapDispatchesDoubleTapAtCoordinates(t *testing.T) {
driverMock := mockdriver.New()
action := verifier.Action{Kind: verifier.ActionKindDoubleTap, X: 100, Y: 200}
mustDispatch(t, driverMock, action, nil)
taps := 0
for _, a := range driverMock.Actions() {
if a.Kind == mockdriver.ActionDoubleTap && a.X == 100 && a.Y == 200 {
taps++
}
}
if taps != 1 {
t.Errorf("expected 1 DoubleTap call at (100,200), got %d in %v", taps, driverMock.Actions())
}
}
func TestApplyAction_DoubleTapDispatchesDoubleTapSelector(t *testing.T) {
driverMock := mockdriver.New()
action := verifier.Action{Kind: verifier.ActionKindDoubleTap, On: "id:save"}
mustDispatch(t, driverMock, action, nil)
taps := 0
for _, a := range driverMock.Actions() {
if a.Kind == mockdriver.ActionDoubleTapSelector && a.Selector == "id:save" {
taps++
}
}
if taps != 1 {
t.Errorf("expected 1 DoubleTapSelector call with id:save, got %d in %v", taps, driverMock.Actions())
}
}
func TestApplyAction_LongPressDispatchesAtResolvedCoordinates(t *testing.T) {
driverMock := mockdriver.New()
action := verifier.Action{Kind: verifier.ActionKindLongPress, X: 120, Y: 240}
mustDispatch(t, driverMock, action, nil)
found := false
for _, a := range driverMock.Actions() {
if a.Kind == mockdriver.ActionLongPress && a.X == 120 && a.Y == 240 {
found = true
}
}
if !found {
t.Errorf("expected LongPress at (120,240), got %v", driverMock.Actions())
}
}
func TestApplyAction_ScrollWithPrecomputedEndpointsSwipes(t *testing.T) {
driverMock := mockdriver.New()
action := verifier.Action{
Kind: verifier.ActionKindScroll,
Direction: "down",
FromX: 100,
FromY: 500,
ToX: 100,
ToY: 300,
DurationMillis: 300,
}
mustDispatch(t, driverMock, action, nil)
found := false
for _, a := range driverMock.Actions() {
if a.Kind == mockdriver.ActionSwipe && a.FromX == 100 && a.FromY == 500 && a.ToX == 100 && a.ToY == 300 {
found = true
}
}
if !found {
t.Errorf("expected Swipe with precomputed endpoints, got %v", driverMock.Actions())
}
}
// scrollingDriver is a driver that scrolls by something other than a drag,
// which is what the web driver is: a browser scrolls on wheel input and only
// ever treats a drag as a drag.
type scrollingDriver struct {
*mockdriver.Driver
scrolls [][4]int
}
func (d *scrollingDriver) Scroll(
_ context.Context,
fromX, fromY, toX, toY int,
_ time.Duration,
) error {
d.scrolls = append(d.scrolls, [4]int{fromX, fromY, toX, toY})
return nil
}
func TestApplyAction_ScrollPrefersTheScrollCapabilityOverASwipe(t *testing.T) {
drv := &scrollingDriver{Driver: mockdriver.New()}
action := verifier.Action{
Kind: verifier.ActionKindScroll,
Direction: "down",
FromX: 100,
FromY: 500,
ToX: 100,
ToY: 300,
DurationMillis: 300,
}
mustDispatch(t, drv, action, nil)
want := [4]int{100, 500, 100, 300}
if len(drv.scrolls) != 1 || drv.scrolls[0] != want {
t.Fatalf("scrolls = %v, want one %v", drv.scrolls, want)
}
for _, a := range drv.Actions() {
if a.Kind == mockdriver.ActionSwipe {
t.Errorf("the scroll was dispatched as a swipe: %v", drv.Actions())
}
}
}
func TestApplyAction_ScrollDirectionUsesInversion(t *testing.T) {
driverMock := mockdriver.New()
treeJSON := `{"attributes":{"resource-id":"com.fixture:id/list","bounds":"[0,0,400,800]"},"children":[],"enabled":true}`
tree, err := hierarchy.Parse(treeJSON)
if err != nil {
t.Fatalf("parse tree: %v", err)
}
action := verifier.Action{Kind: verifier.ActionKindScroll, Direction: "down", On: "id:list"}
mustDispatch(t, driverMock, action, tree)
var swipe *mockdriver.Action
for i := range driverMock.Actions() {
if driverMock.Actions()[i].Kind == mockdriver.ActionSwipe {
a := driverMock.Actions()[i]
swipe = &a
}
}
if swipe == nil {
t.Fatalf("expected a Swipe, got %v", driverMock.Actions())
}
// "down" reveals lower content by dragging the finger up, so toY < fromY.
if swipe.ToY >= swipe.FromY {
t.Errorf("expected toY < fromY for scroll down, got from=%d to=%d", swipe.FromY, swipe.ToY)
}
}
func TestApplyAction_ScrollNearTopKeepsDirectionAfterClamp(t *testing.T) {
driverMock := mockdriver.New()
// Full-screen root sets the 1080x2400 screen (marginY=200); the scrollable
// list sits inside the top margin (y 20..180), where the clamp must fire.
treeJSON := `{"attributes":{"bounds":"[0,0,1080,2400]"},"children":[
{"attributes":{"resource-id":"com.fixture:id/toplist","scrollable":"true","bounds":"[0,20,1080,180]"},"children":[],"enabled":true}
]}`
tree, err := hierarchy.Parse(treeJSON)
if err != nil {
t.Fatalf("parse tree: %v", err)
}
action := verifier.Action{Kind: verifier.ActionKindScroll, Direction: "up", On: "id:toplist"}
mustDispatch(t, driverMock, action, tree)
var swipe *mockdriver.Action
for i := range driverMock.Actions() {
if driverMock.Actions()[i].Kind == mockdriver.ActionSwipe {
a := driverMock.Actions()[i]
swipe = &a
}
}
if swipe == nil {
t.Fatalf("expected a Swipe, got %v", driverMock.Actions())
}
if swipe.FromY != 200 {
t.Errorf("origin not pushed below the shade strip, got fromY=%d want 200", swipe.FromY)
}
if swipe.ToY <= swipe.FromY {
t.Errorf("scroll up reversed by the clamp: from=%d to=%d (want toY > fromY)", swipe.FromY, swipe.ToY)
}
}
func TestApplyAction_ScrollScreenFallback(t *testing.T) {
driverMock := mockdriver.New()
treeJSON := `{"attributes":{"bounds":"[0,0,400,800]"},"children":[],"enabled":true}`
tree, err := hierarchy.Parse(treeJSON)
if err != nil {
t.Fatalf("parse tree: %v", err)
}
// On unset: container falls back to whole-screen (root) bounds.
action := verifier.Action{Kind: verifier.ActionKindScroll, Direction: "up"}
mustDispatch(t, driverMock, action, tree)
var swipe *mockdriver.Action
for i := range driverMock.Actions() {
if driverMock.Actions()[i].Kind == mockdriver.ActionSwipe {
a := driverMock.Actions()[i]
swipe = &a
}
}
if swipe == nil {
t.Fatalf("expected a Swipe, got %v", driverMock.Actions())
}
if swipe.FromX != 200 || swipe.FromY != 400 {
t.Errorf("expected swipe to start at screen center (200,400), got (%d,%d)", swipe.FromX, swipe.FromY)
}
// "up" reveals upper content by dragging the finger down, so toY > fromY.
if swipe.ToY <= swipe.FromY {
t.Errorf("expected toY > fromY for scroll up, got from=%d to=%d", swipe.FromY, swipe.ToY)
}
}
// Every shape applyAction cannot dispatch has to name why. Returning a bare nil
// leaves the step recording a next_action that reached no driver at all, which
// an executed-action count then reads as work done.
func TestApplyAction_NonDispatchPathsReportWhy(t *testing.T) {
tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,400,800]"},"children":[]}`)
if err != nil {
t.Fatalf("parse tree: %v", err)
}
cases := []struct {
name string
action verifier.Action
tree *hierarchy.Tree
want actionSkipReason
}{
{
name: "long press whose selector is not on screen",
action: verifier.Action{Kind: verifier.ActionKindLongPress, On: "id:gone"},
tree: tree,
want: actionSkippedUnresolvedSelector,
},
{
name: "press key without a key",
action: verifier.Action{Kind: verifier.ActionKindPressKey},
want: actionSkippedMissingKey,
},
{
name: "wait without a duration",
action: verifier.Action{Kind: verifier.ActionKindWait},
want: actionSkippedZeroDurationWait,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
driverMock := mockdriver.New()
skipped, err := applyAction(context.Background(), driverMock, tc.action, tc.tree)
if err != nil {
t.Fatalf("applyAction: %v", err)
}
if skipped != tc.want {
t.Errorf("skip reason = %q, want %q", skipped, tc.want)
}
if len(driverMock.Actions()) != 0 {
t.Errorf("nothing must reach the driver, got %v", driverMock.Actions())
}
})
}
}
// TestRunner_RecordsWhyAChosenActionNeverRan drives a spec whose only action is
// undispatchable and pins that each step says so on its trace line. The step is
// left non-transitional: nothing was dispatched, so the verified screen still
// describes the device.
func TestRunner_RecordsWhyAChosenActionNeverRan(t *testing.T) {
state := newHarnessWithSpec(t, zeroWaitSpec)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 5 * time.Second,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 2,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps != 2 {
t.Fatalf("Steps = %d, want 2", summary.Steps)
}
lines := readTraceLines(t, state.writer.Directory())
acted := 0
for _, line := range lines {
if line.NextAction == nil {
continue
}
acted++
if line.ActionSkipped != string(actionSkippedZeroDurationWait) {
t.Errorf("step %d action_skipped = %q, want %q",
line.Step, line.ActionSkipped, actionSkippedZeroDurationWait)
}
if line.Transitional {
t.Errorf("step %d marked transitional; nothing was dispatched, so the screen is unchanged", line.Step)
}
}
if acted != 2 {
t.Fatalf("want 2 steps carrying a next_action, got %d", acted)
}
}
// The other half of the contract: a step whose action really was dispatched
// must carry no reason at all, or every step looks skipped.
func TestRunner_DispatchedActionRecordsNoSkipReason(t *testing.T) {
state := newHarness(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := Run(ctx, Options{
Duration: 5 * time.Second,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 2,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
}); err != nil {
t.Fatalf("Run: %v", err)
}
if !containsAction(state.mock.Actions(), mockdriver.ActionTapSelector, "id:next") {
t.Fatalf("fixture tap never dispatched, got %v", state.mock.Actions())
}
for _, line := range readTraceLines(t, state.writer.Directory()) {
if line.NextAction != nil && line.ActionSkipped != "" {
t.Errorf("step %d recorded action_skipped=%q for a dispatched action",
line.Step, line.ActionSkipped)
}
}
}
// TestRunner_ASourceAskedAndHandedNothingSaysSo covers the run that touched the
// app zero times: every step asked the source for an action and got none, which
// is what a picker whose every model call fails does. Without a reason on the
// step the trace, the summary and the exit status of that run are the ones a run
// that exercised all three steps produces.
func TestRunner_ASourceAskedAndHandedNothingSaysSo(t *testing.T) {
state := newHarnessWithSpec(t, noActionSpec)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 5 * time.Second,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 3,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps != 3 {
t.Fatalf("Steps = %d, want 3", summary.Steps)
}
if summary.DispatchedActions != 0 {
t.Fatalf("DispatchedActions = %d, want 0: the fixture offers no action to dispatch",
summary.DispatchedActions)
}
if got := summary.SkippedActions[string(actionSkippedNoActionProduced)]; got != 3 {
t.Errorf("summary counted %d step(s) as %q, want 3: %v",
got, actionSkippedNoActionProduced, summary.SkippedActions)
}
for _, line := range readTraceLines(t, state.writer.Directory()) {
if line.NextAction != nil {
t.Errorf("step %d carries a next_action the source never produced", line.Step)
}
if line.ActionSkipped != string(actionSkippedNoActionProduced) {
t.Errorf("step %d action_skipped = %q, want %q",
line.Step, line.ActionSkipped, actionSkippedNoActionProduced)
}
if line.SkippedVerification {
t.Errorf("step %d reads as held; the source was asked on it", line.Step)
}
}
var rendered bytes.Buffer
RenderSummary(&rendered, summary, "android")
if !strings.Contains(rendered.String(), "3 action(s) never reached the app") {
t.Errorf("the run reports as a clean one:\n%s", rendered.String())
}
}
// The other half: a held step never asked the source for anything, so it must
// stay distinguishable from one that asked and was handed nothing. The fixture
// here has an action to offer, and no step of this run gets to hear it.
func TestRunner_AHeldStepIsNotRecordedAsASourceThatDeclined(t *testing.T) {
state := newHarness(t)
state.mock.Failures[mockdriver.ActionSnapshot] = errors.New("adb: device offline")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 5 * time.Second,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 3,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.SkippedVerification != 3 {
t.Fatalf("SkippedVerification = %d, want 3", summary.SkippedVerification)
}
if len(summary.SkippedActions) != 0 {
t.Errorf("held steps counted as skipped actions: %v", summary.SkippedActions)
}
for _, line := range readTraceLines(t, state.writer.Directory()) {
if !line.SkippedVerification {
t.Errorf("step %d was not recorded as held", line.Step)
}
if line.ActionSkipped != "" {
t.Errorf("step %d action_skipped = %q; the source was never asked",
line.Step, line.ActionSkipped)
}
}
}
type traceStepLine struct {
Step int `json:"step"`
NextAction *trace.Action `json:"next_action"`
ActionSkipped string `json:"action_skipped"`
Transitional bool `json:"transitional"`
ObservationError string `json:"observation_error"`
SkippedVerification bool `json:"skipped_verification"`
}
func readTraceLines(t *testing.T, directory string) []traceStepLine {
t.Helper()
body, err := os.ReadFile(filepath.Join(directory, "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
var lines []traceStepLine
for _, raw := range bytes.Split(bytes.TrimSpace(body), []byte("\n")) {
var line traceStepLine
if err := json.Unmarshal(raw, &line); err != nil {
t.Fatalf("decode trace line: %v", err)
}
lines = append(lines, line)
}
return lines
}
func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) {
state := newHarness(t)
state.mock.MetricsData = driver.Metrics{CPUPercent: 5.0, HeapBytes: 1024, TotalMemoryBytes: 4096}
state.mock.LogEntries = []driver.LogEntry{
{UnixMillis: 1000, Level: "E", Tag: "test", Message: "boom"},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
IdleTimeout: 50 * time.Millisecond,
BundleID: "com.fixture",
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
actions := state.mock.Actions()
var hasSnapshot, hasMetrics, hasLogs bool
for _, a := range actions {
switch a.Kind {
case mockdriver.ActionSnapshot:
hasSnapshot = true
case mockdriver.ActionMetrics:
hasMetrics = true
case mockdriver.ActionRecentLogs:
hasLogs = true
}
}
if !hasSnapshot {
t.Error("expected Snapshot call in mock actions")
}
if !hasMetrics {
t.Error("expected Metrics call in mock actions")
}
if !hasLogs {
t.Error("expected RecentLogs call in mock actions")
}
}
// TestRunner_UsesAtomicSnapshot ensures the runner observes a step's UI
// through the paired Snapshot RPC instead of racing two independent
// hierarchy + screenshot reads. The pair must come from one on-device
// frame; a regression to separate calls is what this test catches.
func TestRunner_UsesAtomicSnapshot(t *testing.T) {
state := newHarness(t)
state.mock.ImageData = driver.Image{PNG: []byte("png"), Width: 1, Height: 1}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
IdleTimeout: 20 * time.Millisecond,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps == 0 {
t.Fatal("expected at least one step")
}
var snapshotCalls, hierarchyCalls, screenshotCalls int
for _, action := range state.mock.Actions() {
switch action.Kind {
case mockdriver.ActionSnapshot:
snapshotCalls++
case mockdriver.ActionHierarchy:
hierarchyCalls++
case mockdriver.ActionScreenshot:
screenshotCalls++
}
}
if snapshotCalls == 0 {
t.Errorf("expected at least one Snapshot call, got %d", snapshotCalls)
}
// The recorded pair still comes from Snapshot. The standalone hierarchy
// reads are the composition detector (changedOnReread), one per step at
// most, and they are never the source of what the step records.
if hierarchyCalls > summary.Steps {
t.Errorf("expected at most one standalone Hierarchy call per step (runner must observe through Snapshot), got %d over %d steps",
hierarchyCalls, summary.Steps)
}
if snapshotCalls < summary.Steps {
t.Errorf("expected a Snapshot per step, got %d over %d steps", snapshotCalls, summary.Steps)
}
if screenshotCalls != 0 {
t.Errorf("expected zero standalone Screenshot calls (runner must use Snapshot), got %d", screenshotCalls)
}
}
// TestRunner_OneScreenshotPerStep verifies the runner writes a single
// screenshot per step, captured concurrently with hierarchy so the two
// observations describe the same UI moment.
func TestRunner_OneScreenshotPerStep(t *testing.T) {
state := newHarness(t)
state.mock.ImageData = driver.Image{PNG: []byte("fakepng"), Width: 100, Height: 200}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 200 * time.Millisecond,
IdleTimeout: 50 * time.Millisecond,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps < 2 {
t.Fatalf("need at least 2 steps for screenshot test, got %d", summary.Steps)
}
screenshotDir := filepath.Join(state.writer.Directory(), "screenshots")
for step := 1; step <= summary.Steps; step++ {
path := filepath.Join(screenshotDir, fmt.Sprintf("step-%05d.png", step))
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Errorf("expected screenshot for step %d at %s", step, path)
}
}
entries, err := os.ReadDir(screenshotDir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.Contains(entry.Name(), "-after") {
t.Errorf("unexpected -after screenshot remains: %s", entry.Name())
}
}
}
// TestRunner_StableTransitionalTreeIsVerified feeds a driver whose hierarchy
// constantly carries two route-level *Screen ids but never changes between
// retry attempts. Such a tree is a settled state that merely matches the
// transitional heuristic, so the runner must verify it (the always-false
// predicate's violation surfaces) instead of skipping the verifier forever.
func TestRunner_StableTransitionalTreeIsVerified(t *testing.T) {
state := newHarnessWithSpec(t, violationSpec)
state.mock.HierarchyJSON = `{"attributes":{"resource-id":"root"},"children":[
{"attributes":{"resource-id":"AddAccountScreen"},"children":[]},
{"attributes":{"resource-id":"HomeScreen"},"children":[]}
]}`
state.mock.ImageData = driver.Image{PNG: []byte("fakepng"), Width: 100, Height: 200}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 200 * time.Millisecond,
IdleTimeout: 20 * time.Millisecond,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps == 0 {
t.Fatal("expected at least one step")
}
if !containsProperty(summary.Violations, "balanceNonNegative") {
t.Fatalf("expected verifier to run on a stable two-screen tree, got %v", summary.Violations)
}
type traceLine struct {
Step int `json:"step"`
Transitional bool `json:"transitional"`
}
body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
for _, raw := range bytes.Split(bytes.TrimSpace(body), []byte("\n")) {
var line traceLine
if err := json.Unmarshal(raw, &line); err != nil {
t.Fatalf("decode trace line: %v", err)
}
if line.Transitional {
t.Errorf("step %d: stable tree must not be marked transitional", line.Step)
}
}
// The early break must still persist the step's screenshot.
screenshotPath := filepath.Join(state.writer.Directory(), "screenshots", "step-00001.png")
if _, err := os.Stat(screenshotPath); err != nil {
t.Errorf("expected screenshot for step 1 at %s: %v", screenshotPath, err)
}
}
// snapshotCrossFade wraps a mock driver so every Snapshot call returns a
// transitional two-screen tree whose JSON differs from the previous call,
// mimicking a genuine cross-fade in flight.
type snapshotCrossFade struct {
*mockdriver.Driver
calls int
}
func (d *snapshotCrossFade) Snapshot(ctx context.Context) (string, driver.Image, error) {
d.calls++
_, image, err := d.Driver.Snapshot(ctx)
hierarchyJSON := fmt.Sprintf(`{"attributes":{"resource-id":"root"},"children":[
{"attributes":{"resource-id":"AddAccountScreen","text":"frame-%d"},"children":[]},
{"attributes":{"resource-id":"HomeScreen"},"children":[]}
]}`, d.calls)
return hierarchyJSON, image, err
}
// TestRunner_GenuineCrossFadeStillRetried pins the existing behavior for real
// transitions: a tree that keeps changing between retry attempts exhausts the
// budget, stays transitional, and the verifier is skipped for the step.
func TestRunner_GenuineCrossFadeStillRetried(t *testing.T) {
state := newHarnessWithSpec(t, violationSpec)
wrapped := &snapshotCrossFade{Driver: state.mock}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 200 * time.Millisecond,
IdleTimeout: 20 * time.Millisecond,
Driver: wrapped,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps == 0 {
t.Fatal("expected at least one step")
}
if len(summary.Violations) != 0 {
t.Fatalf("verifier must be skipped on cross-fade steps; got %v", summary.Violations)
}
type traceLine struct {
Step int `json:"step"`
Transitional bool `json:"transitional"`
Violations []string `json:"violations"`
}
body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
lines := 0
for _, raw := range bytes.Split(bytes.TrimSpace(body), []byte("\n")) {
var line traceLine
if err := json.Unmarshal(raw, &line); err != nil {
t.Fatalf("decode trace line: %v", err)
}
lines++
if !line.Transitional {
t.Errorf("step %d: expected transitional=true on every step, got false", line.Step)
}
if len(line.Violations) != 0 {
t.Errorf("step %d: verifier must be skipped, got violations %v", line.Step, line.Violations)
}
}
if lines == 0 {
t.Fatal("expected trace lines, got none")
}
}
// TestRunner_CleanTreeStillVerified is the control: a single-screen hierarchy
// must not be marked transitional and the verifier must still run, surfacing
// the always-false predicate's violation on the onset step.
func TestRunner_CleanTreeStillVerified(t *testing.T) {
state := newHarnessWithSpec(t, violationSpec)
state.mock.HierarchyJSON = `{"attributes":{"resource-id":"HomeScreen"},"children":[]}`
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 200 * time.Millisecond,
IdleTimeout: 20 * time.Millisecond,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !containsProperty(summary.Violations, "balanceNonNegative") {
t.Fatalf("expected verifier to surface balanceNonNegative on a clean tree, got %v", summary.Violations)
}
type traceLine struct {
Step int `json:"step"`
Transitional bool `json:"transitional"`
}
body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
for _, raw := range bytes.Split(bytes.TrimSpace(body), []byte("\n")) {
var line traceLine
if err := json.Unmarshal(raw, &line); err != nil {
t.Fatalf("decode trace line: %v", err)
}
if line.Transitional {
t.Errorf("step %d: clean tree must not be marked transitional", line.Step)
}
}
}
// snapshotFailFirst wraps a mock driver so the first Snapshot call returns an
// error (mimicking a sidecar timeout while fetching view hierarchy), then
// delegates every subsequent call back to the mock.
type snapshotFailFirst struct {
*mockdriver.Driver
calls int
}
func (d *snapshotFailFirst) Snapshot(ctx context.Context) (string, driver.Image, error) {
d.calls++
if d.calls == 1 {
return "", driver.Image{}, errors.New("Timeout while fetching view hierarchy")
}
return d.Driver.Snapshot(ctx)
}
// TestRunner_NilHierarchyMarksTransitional verifies that when the sidecar's
// hierarchy fetch fails (nil tree), the runner marks the step transitional and
// skips the verifier instead of pushing a nil tree that would crash the spec.
// Subsequent steps with a clean tree still drive the verifier normally.
func TestRunner_NilHierarchyMarksTransitional(t *testing.T) {
state := newHarnessWithSpec(t, violationSpec)
state.mock.HierarchyJSON = `{"attributes":{"resource-id":"HomeScreen"},"children":[]}`
wrapped := &snapshotFailFirst{Driver: state.mock}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 200 * time.Millisecond,
IdleTimeout: 20 * time.Millisecond,
Driver: wrapped,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps < 2 {
t.Fatalf("need at least 2 steps to verify the first is skipped and the second runs, got %d", summary.Steps)
}
// violationSpec always() => false fires on the first verifier push. With
// step 1's verifier skipped, onset moves to step 2.
if len(summary.Violations) != 1 {
t.Fatalf("expected exactly one onset record, got %d: %v", len(summary.Violations), summary.Violations)
}
if summary.Violations[0].StepIndex != 2 {
t.Errorf("onset step: got %d, want 2 (step 1 verifier skipped due to nil tree)", summary.Violations[0].StepIndex)
}
type traceLine struct {
Step int `json:"step"`
Transitional bool `json:"transitional"`
Violations []string `json:"violations"`
}
body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
var first traceLine
if err := json.Unmarshal(bytes.SplitN(bytes.TrimSpace(body), []byte("\n"), 2)[0], &first); err != nil {
t.Fatalf("decode first trace line: %v", err)
}
if first.Step != 1 {
t.Fatalf("first trace line step: got %d, want 1", first.Step)
}
if !first.Transitional {
t.Error("first step must be marked transitional when the hierarchy fetch failed")
}
if len(first.Violations) != 0 {
t.Errorf("step 1 must skip the verifier; got violations %v", first.Violations)
}
}
// tapSelectorFailFirst wraps a mock driver so the first TapSelector call
// returns a gRPC DeadlineExceeded error (mimicking a sidecar-side RPC hang),
// then delegates every subsequent call back to the mock.
type tapSelectorFailFirst struct {
*mockdriver.Driver
calls int
}
func (d *tapSelectorFailFirst) TapSelector(ctx context.Context, selector string) error {
d.calls++
if d.calls == 1 {
return status.Error(codes.DeadlineExceeded, "boom")
}
return d.Driver.TapSelector(ctx, selector)
}
// TestRunner_TransientApplyErrorMarksTransitional verifies that a transient
// gRPC error from applyAction (e.g. sidecar RPC deadline) does not kill the
// run: the step is marked transitional, the verifier is skipped for it, and
// the loop continues with the next step running cleanly.
func TestRunner_TransientApplyErrorMarksTransitional(t *testing.T) {
state := newHarness(t)
wrapped := &tapSelectorFailFirst{Driver: state.mock}
var logBuf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 300 * time.Millisecond,
IdleTimeout: 20 * time.Millisecond,
Driver: wrapped,
Verifier: state.verifier,
TraceWriter: state.writer,
Logger: logger,
})
if err != nil {
t.Fatalf("Run must not return on transient apply error, got %v", err)
}
if summary.Steps < 2 {
t.Fatalf("need at least 2 steps to prove the loop continued past the failed apply, got %d", summary.Steps)
}
if len(summary.Violations) != 0 {
t.Errorf("transient apply error must not surface as a violation, got %v", summary.Violations)
}
if !strings.Contains(logBuf.String(), "apply error; marking step transitional") {
t.Errorf("expected apply-error WARN log, got %q", logBuf.String())
}
type traceLine struct {
Step int `json:"step"`
Transitional bool `json:"transitional"`
Violations []string `json:"violations"`
}
body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
lines := bytes.Split(bytes.TrimSpace(body), []byte("\n"))
var first, second traceLine
if err := json.Unmarshal(lines[0], &first); err != nil {
t.Fatalf("decode first trace line: %v", err)
}
if err := json.Unmarshal(lines[1], &second); err != nil {
t.Fatalf("decode second trace line: %v", err)
}
if first.Step != 1 || !first.Transitional {
t.Errorf("step 1 must be transitional after transient apply error, got step=%d transitional=%v", first.Step, first.Transitional)
}
// The step still records a next_action it never dispatched, so the reason
// has to be on the line or an executed-action count includes it.
var firstSkip struct {
ActionSkipped string `json:"action_skipped"`
}
if err := json.Unmarshal(lines[0], &firstSkip); err != nil {
t.Fatalf("decode first trace line: %v", err)
}
if firstSkip.ActionSkipped != string(actionSkippedApplyError) {
t.Errorf("step 1 action_skipped = %q, want %q", firstSkip.ActionSkipped, actionSkippedApplyError)
}
if len(first.Violations) != 0 {
t.Errorf("transient apply step must have no violations, got %v", first.Violations)
}
if second.Step != 2 || second.Transitional {
t.Errorf("step 2 must run cleanly after the transient step, got step=%d transitional=%v", second.Step, second.Transitional)
}
}
// internalApplyErrorFailFirst wraps a mock driver so the first InputText call
// fails with the bare Internal error the iOS runner's input handler emits
// when it chokes (HTTP 500 with an empty body), then recovers.
type internalApplyErrorFailFirst struct {
*mockdriver.Driver
calls int
}
func (d *internalApplyErrorFailFirst) TapSelector(ctx context.Context, selector string) error {
d.calls++
if d.calls == 1 {
return status.Error(codes.Internal, "UnknownFailure(errorResponse=Request for inputText failed, code: 500, body: )")
}
return d.Driver.TapSelector(ctx, selector)
}
// TestRunner_InternalApplyErrorMarksTransitional pins the policy that a
// one-off device-side failure (e.g. the iOS input handler's bare 500) is
// absorbed as a transitional step instead of killing the run. Persistent
// failure is covered by the consecutive-failure cap.
func TestRunner_InternalApplyErrorMarksTransitional(t *testing.T) {
state := newHarness(t)
wrapped := &internalApplyErrorFailFirst{Driver: state.mock}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 300 * time.Millisecond,
IdleTimeout: 20 * time.Millisecond,
Driver: wrapped,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run must not return on a one-off internal apply error, got %v", err)
}
if summary.Steps < 2 {
t.Fatalf("need at least 2 steps to prove the loop continued, got %d", summary.Steps)
}
}
// TestIsWDADrop_Classification pins the fatal-vs-recoverable boundary: only
// the sidecar's explicit reconnect-failure signal is a drop. A structured
// UNAVAILABLE that happens to embed raw exception text (e.g. ConnectException
// from the original failure) means the sidecar already recovered and the run
// must continue.
// TestIsWDADrop_Classification pins isWDADrop to the exact phrase the sidecar
// throws at its reconnect site (sidecar DriverBackend.kt):
//
// throw IllegalStateException("WDA reconnect failed: $restartErr", cause)
//
// If that message is reworded on the Kotlin side without updating the Go
// matcher, a fatal, unrecoverable WDA drop is misclassified as transient and
// the run burns its budget retrying a dead channel instead of aborting.
func TestIsWDADrop_Classification(t *testing.T) {
// sidecarReconnectFailedMessage mirrors the literal the sidecar emits; the
// matcher's contract is keyed on this exact prefix.
const sidecarReconnectFailedMessage = "WDA reconnect failed"
cases := []struct {
name string
err error
want bool
}{
{
"unavailable with embedded ConnectException is recovered",
status.Error(codes.Unavailable, "connection dropped mid-action; the action may have applied: java.net.ConnectException: Failed to connect to /127.0.0.1:22161"),
false,
},
{
"sidecar reconnect-failed message is a drop",
status.Error(codes.Internal, "java.lang.IllegalStateException: "+sidecarReconnectFailedMessage+": IOSDriverTimeoutException"),
true,
},
{"generic internal is not a drop", status.Error(codes.Internal, "boom"), false},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
if got := isWDADrop(testCase.err); got != testCase.want {
t.Errorf("got %v, want %v", got, testCase.want)
}
})
}
}
// tapSelectorAlwaysUnavailable wraps a mock driver so every TapSelector call
// fails with a transient Unavailable error, mimicking a device whose channel
// never recovers between steps.
type tapSelectorAlwaysUnavailable struct {
*mockdriver.Driver
}
func (d *tapSelectorAlwaysUnavailable) TapSelector(ctx context.Context, selector string) error {
return status.Error(codes.Unavailable, "connection dropped mid-action; the action may have applied")
}
// TestRunner_ConsecutiveTransientApplyFailuresAbort verifies the run fails
// fast once transient apply errors form an unbroken streak instead of burning
// the whole budget on a wedged device.
func TestRunner_ConsecutiveTransientApplyFailuresAbort(t *testing.T) {
state := newHarness(t)
wrapped := &tapSelectorAlwaysUnavailable{Driver: state.mock}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 5 * time.Second,
IdleTimeout: 20 * time.Millisecond,
Driver: wrapped,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err == nil {
t.Fatal("Run must abort after consecutive transient apply failures")
}
if !strings.Contains(err.Error(), "consecutive failures") {
t.Errorf("expected consecutive-failure abort, got %v", err)
}
// The aborting step returns before it is recorded, so the summary holds
// the steps before the cap-hitting one.
if summary.Steps != maxConsecutiveApplyFailures-1 {
t.Errorf("run must stop at the failure cap, got %d recorded steps", summary.Steps)
}
}
// TestRunner_WaitActionSkipsIdle ensures the runner does not call WaitForIdle
// after a Wait action - the action already provides settling time.
func TestRunner_WaitActionSkipsIdle(t *testing.T) {
const waitSpec = `
import { actions, Wait } from "@sanderling/spec";
globalThis.actions = actions(() => [Wait({ durationMillis: 5 })]);
`
state := newHarnessWithSpec(t, waitSpec)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := Run(ctx, Options{
Duration: 150 * time.Millisecond,
IdleTimeout: 50 * time.Millisecond,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
for _, action := range state.mock.Actions() {
if action.Kind == mockdriver.ActionWaitForIdle {
t.Fatalf("Wait action must skip WaitForIdle, got: %v", action)
}
}
}
func mustNewVerifier(t *testing.T) *verifier.Verifier {
t.Helper()
verifierInstance, err := verifier.New()
if err != nil {
t.Fatal(err)
}
return verifierInstance
}
func mustNewTraceWriter(t *testing.T) *trace.Writer {
t.Helper()
writer, err := trace.NewWriter(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = writer.Close() })
return writer
}
func containsAction(actions []mockdriver.Action, kind mockdriver.ActionKind, payload string) bool {
for _, action := range actions {
if action.Kind != kind {
continue
}
switch kind {
case mockdriver.ActionLaunch:
if action.BundleID == payload {
return true
}
case mockdriver.ActionTapSelector:
if action.Selector == payload {
return true
}
case mockdriver.ActionTerminate:
return true
default:
return true
}
}
return false
}
func containsProperty(records []ViolationRecord, property string) bool {
for _, record := range records {
if slices.Contains(record.Properties, property) {
return true
}
}
return false
}
func TestRunner_RelaunchesWhenAppLeavesForeground(t *testing.T) {
fastForegroundGate(t)
state := newHarness(t)
// The app is in front when the run starts and a foreign app every time a
// step looks, so the startup gate passes and every step's guard must
// relaunch.
state.mock.ForegroundResults = []string{"app.folio", "com.android.chrome"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
IdleTimeout: 20 * time.Millisecond,
BundleID: "app.folio",
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
relaunches := 0
for _, a := range state.mock.Actions() {
if a.Kind == mockdriver.ActionLaunch && a.BundleID == "app.folio" && !a.ClearState {
relaunches++
}
}
if relaunches == 0 {
t.Fatal("expected runner to relaunch app.folio when foreground escaped, got none")
}
}
func TestRunner_NoRelaunchWhenAppInForeground(t *testing.T) {
state := newHarness(t)
state.mock.ForegroundResults = []string{"app.folio"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
IdleTimeout: 20 * time.Millisecond,
BundleID: "app.folio",
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
for _, a := range state.mock.Actions() {
if a.Kind == mockdriver.ActionLaunch {
t.Fatalf("expected no relaunch while app in foreground, got %v", a)
}
}
}
// TestRunner_WaitsForForegroundBeforeFirstAction verifies the startup gate
// brings the app forward (back-press + relaunch) before any tap fires when the
// device boots showing a system dialog.
func TestRunner_WaitsForForegroundBeforeFirstAction(t *testing.T) {
fastForegroundGate(t)
state := newHarness(t)
// First the device shows a system setup screen, then the app is on top.
state.mock.ForegroundResults = []string{"com.google.android.setupwizard", "app.folio"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
IdleTimeout: 20 * time.Millisecond,
BundleID: "app.folio",
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
actions := state.mock.Actions()
firstLaunch, firstTap := -1, -1
backPressed := false
for i, a := range actions {
switch {
case a.Kind == mockdriver.ActionLaunch && firstLaunch < 0:
firstLaunch = i
case a.Kind == mockdriver.ActionTap && firstTap < 0:
firstTap = i
case a.Kind == mockdriver.ActionPressKey && a.Key == "back":
backPressed = true
}
}
if firstLaunch < 0 {
t.Fatal("expected a relaunch to bring the app forward, got none")
}
if !backPressed {
t.Fatal("expected a back-press to dismiss the system dialog, got none")
}
if firstTap >= 0 && firstLaunch > firstTap {
t.Fatalf("expected the foreground gate (launch at %d) before the first tap (at %d)", firstLaunch, firstTap)
}
}
// TestRunner_WaitsForWindowDrawnBeforeFirstAction verifies the startup gate
// keeps waiting while the app is the resumed activity but its window has not
// drawn yet (a leftover screen still focused). It must poll the focused-window
// signal rather than relaunching, and only proceed once the window names the
// app.
func TestRunner_WaitsForWindowDrawnBeforeFirstAction(t *testing.T) {
fastForegroundGate(t)
state := newHarness(t)
// The app is resumed immediately, but its window lags: the outgoing
// settings screen stays focused for two checks before the app draws.
state.mock.ForegroundResults = []string{"app.folio"}
state.mock.FocusedWindowResults = []string{"com.android.settings", "com.android.settings", "app.folio"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
IdleTimeout: 20 * time.Millisecond,
BundleID: "app.folio",
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
// The gate must have polled the focused window until it named the app,
// i.e. at least the three queued results were consumed.
if calls := state.mock.FocusedWindowCalls(); calls < 3 {
t.Fatalf("expected the gate to poll the focused window until drawn (>=3 calls), got %d", calls)
}
// The resumed app was never a foreign app, so the gate must not relaunch
// or back-press to "fix" a window that simply had not drawn yet.
for _, a := range state.mock.Actions() {
if a.Kind == mockdriver.ActionPressKey && a.Key == "back" {
t.Fatal("expected no back-press while waiting for the window to draw")
}
}
}
// TestAwaitForeground_RelaunchesThenWaitsForWindow locks the per-step scope
// guard's recovery: after the app leaves to the launcher, it must relaunch AND
// keep polling the focused window until it names the app, so the step never
// observes or acts while the launcher is on screen (where InputText would land
// in the launcher's type-to-search filter). A single fire-and-forget relaunch,
// which returns before the window draws on a slow physical device, is the bug
// this guards against.
func TestAwaitForeground_RelaunchesThenWaitsForWindow(t *testing.T) {
fastForegroundGate(t)
m := mockdriver.New()
// Foreground: launcher on the first poll (still gone), then the app. Focus:
// the launcher window lingers one extra poll before the app's window draws.
m.ForegroundResults = []string{"com.android.launcher", "app.folio"}
m.FocusedWindowResults = []string{"com.android.launcher", "app.folio"}
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelWarn}))
options := Options{BundleID: "app.folio", Driver: m, IdleTimeout: 10 * time.Millisecond}
awaitForeground(context.Background(), options, logger, 7)
relaunches, backs := 0, 0
for _, a := range m.Actions() {
switch {
case a.Kind == mockdriver.ActionLaunch && a.BundleID == "app.folio" && !a.ClearState:
relaunches++
case a.Kind == mockdriver.ActionPressKey && a.Key == "back":
backs++
}
}
if relaunches != 1 {
t.Fatalf("expected exactly one relaunch while the app was gone, got %d", relaunches)
}
if backs != 1 {
t.Fatalf("expected one back-press to dismiss a possible dialog before relaunch, got %d", backs)
}
// The window lagged one poll behind the resumed activity, so the focused
// window must have been queried at least twice before the gate returned.
if calls := m.FocusedWindowCalls(); calls < 2 {
t.Fatalf("expected the guard to poll the focused window until drawn (>=2), got %d", calls)
}
}
func TestClampGestureToSafeArea_KeepsOriginBelowShadeStrip(t *testing.T) {
screen := hierarchy.Bounds{Left: 0, Top: 0, Right: 1080, Bottom: 2400} // marginY = 200
// Origin in the shade strip: the whole segment shifts down by 138, so the
// downward gesture stays downward (447 -> 585) instead of reversing.
fromX, fromY, toX, toY := clampGestureToSafeArea(802, 62, 802, 447, screen)
if fromX != 802 || fromY != 200 || toX != 802 || toY != 585 {
t.Errorf("segment not translated below the shade strip: from=(%d,%d) to=(%d,%d), want from=(802,200) to=(802,585)", fromX, fromY, toX, toY)
}
fromX, fromY, _, _ = clampGestureToSafeArea(5, 1200, 540, 1200, screen)
if fromX != 5 || fromY != 1200 {
t.Errorf("side origin must pass through, got (%d,%d), want (5,1200)", fromX, fromY)
}
fromX, fromY, _, _ = clampGestureToSafeArea(540, 2399, 540, 1200, screen)
if fromX != 540 || fromY != 2399 {
t.Errorf("bottom origin must pass through, got (%d,%d), want (540,2399)", fromX, fromY)
}
_, _, toX, toY = clampGestureToSafeArea(540, 1200, -50, 9999, screen)
if toX != 0 || toY != 2400 {
t.Errorf("off-screen destination not clamped to screen edges: got (%d,%d), want (0,2400)", toX, toY)
}
fromX, _, _, _ = clampGestureToSafeArea(-30, 1200, 540, 1200, screen)
if fromX != 0 {
t.Errorf("off-screen origin x must clamp to 0, got %d", fromX)
}
fromX, fromY, toX, toY = clampGestureToSafeArea(802, 62, 802, 447, hierarchy.Bounds{})
if fromX != 802 || fromY != 62 || toX != 802 || toY != 447 {
t.Error("coordinates must pass through unchanged when screen size is unknown")
}
}
// TestScreenBounds_UsesMaxExtentNotRoot guards the screen-size source: the
// Android hierarchy root reports zero bounds, so the screen rectangle must come
// from the maximum element extent or the gesture clamp silently no-ops.
func TestScreenBounds_UsesMaxExtentNotRoot(t *testing.T) {
tree := &hierarchy.Tree{
Root: &hierarchy.Node{Element: hierarchy.Element{Bounds: hierarchy.Bounds{}}},
Elements: []*hierarchy.Element{
{Bounds: hierarchy.Bounds{}},
{Bounds: hierarchy.Bounds{Left: 0, Top: 0, Right: 1080, Bottom: 2160}},
{Bounds: hierarchy.Bounds{Left: 0, Top: 2268, Right: 1080, Bottom: 2400}},
},
}
got := screenBounds(tree)
if got.Right != 1080 || got.Bottom != 2400 {
t.Fatalf("screenBounds = %+v, want right=1080 bottom=2400", got)
}
}
// TestEnsureForeground_DismissesSystemOverlay locks the shade fix: when the app
// is still the resumed activity but a system overlay (notification shade) holds
// the focused window, the guard must dismiss it with back rather than relaunch
// or act on the obscured app.
func TestEnsureForeground_DismissesSystemOverlay(t *testing.T) {
m := mockdriver.New()
// Resumed activity stays the app; the focused window is the shade.
m.ForegroundResults = []string{"app.folio"}
m.FocusedWindowResults = []string{"com.android.systemui"}
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelWarn}))
options := Options{BundleID: "app.folio", Driver: m, IdleTimeout: 10 * time.Millisecond}
got, inScope := ensureForeground(context.Background(), options, logger, 5)
if got != foregroundOverlayDismissed {
t.Fatalf("the guard reported %v, want foregroundOverlayDismissed; "+
"an obscured app is not a relaunched one", got)
}
if !inScope {
t.Fatal("the guard reported the app out of scope; a dismissed overlay leaves " +
"the app resumed, and marking the step unmet would hide the real ones")
}
backs, relaunches := 0, 0
for _, a := range m.Actions() {
switch {
case a.Kind == mockdriver.ActionPressKey && a.Key == "back":
backs++
case a.Kind == mockdriver.ActionLaunch:
relaunches++
}
}
if backs != 1 {
t.Fatalf("expected one back-press to collapse the shade, got %d", backs)
}
if relaunches != 0 {
t.Fatalf("a resumed-but-obscured app must not be relaunched, got %d relaunches", relaunches)
}
}
func TestAppIsForeground(t *testing.T) {
readErr := errors.New("adb read failed")
cases := []struct {
name string
bundleID string
foreground []string
foregErr error
focused []string
focusErr error
want bool
}{
{name: "no bundle id", bundleID: "", foreground: []string{"app.folio"}, want: true},
{name: "foreground unknown", bundleID: "app.folio", foreground: nil, want: true},
{name: "foreground read error", bundleID: "app.folio", foregErr: readErr, want: true},
{name: "foreign foreground", bundleID: "app.folio", foreground: []string{"com.android.chrome"}, want: false},
{name: "app resumed and focused", bundleID: "app.folio", foreground: []string{"app.folio"}, focused: []string{"app.folio"}, want: true},
{name: "app resumed but overlay focused", bundleID: "app.folio", foreground: []string{"app.folio"}, focused: []string{"com.android.systemui"}, want: false},
{name: "app resumed, focus unknown", bundleID: "app.folio", foreground: []string{"app.folio"}, focused: []string{""}, want: true},
{name: "app resumed, focus read error", bundleID: "app.folio", foreground: []string{"app.folio"}, focusErr: readErr, want: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := mockdriver.New()
m.ForegroundResults = tc.foreground
m.ForegroundErr = tc.foregErr
m.FocusedWindowResults = tc.focused
m.FocusedWindowErr = tc.focusErr
options := Options{BundleID: tc.bundleID, Driver: m}
if got := appIsForeground(context.Background(), options); got != tc.want {
t.Errorf("appIsForeground = %v, want %v", got, tc.want)
}
})
}
}
// A system overlay holds focus while the app stays resumed every step, so the
// fixture's id:next tap must never reach the driver.
func TestRunner_SkipsActionWhenOverlayStealsFocusAtApplyTime(t *testing.T) {
state := newHarness(t)
state.mock.ForegroundResults = []string{"app.folio"}
state.mock.FocusedWindowResults = []string{"app.folio", "com.android.systemui"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
IdleTimeout: 20 * time.Millisecond,
BundleID: "app.folio",
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps == 0 {
t.Fatal("expected the loop to run steps")
}
if containsAction(state.mock.Actions(), mockdriver.ActionTapSelector, "id:next") {
t.Error("apply-time guard failed: a tap fired while a system overlay held focus")
}
body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
var skipped bool
for _, line := range bytes.Split(bytes.TrimSpace(body), []byte("\n")) {
var step struct {
ActionSkipped string `json:"action_skipped"`
}
if err := json.Unmarshal(line, &step); err != nil {
t.Fatalf("decode trace line: %v", err)
}
skipped = skipped || step.ActionSkipped == string(actionSkippedForeground)
}
if !skipped {
t.Errorf("no step recorded action_skipped=%q, so the undispatched action looks executed", actionSkippedForeground)
}
}
// TestRunner_StopOnViolationEndsAtTheFirstViolation pins the gate CI runs on:
// a step budget of 8 against a spec that only violates on the third step must
// end on step 3 and write nothing after it, so the trace's last state is the
// one that produced the violation.
func TestRunner_StopOnViolationEndsAtTheFirstViolation(t *testing.T) {
const thirdStepViolationSpec = `
import { actions, always, extract } from "@sanderling/spec";
let observed = 0;
const tick = extract(() => ++observed);
globalThis.properties = {
staysUnderThree: always(() => tick.current < 3),
};
globalThis.actions = actions(() => []);
`
state := newHarnessWithSpec(t, thirdStepViolationSpec)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 8,
StopOnViolation: true,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !containsProperty(summary.Violations, "staysUnderThree") {
t.Fatalf("expected staysUnderThree to fire, got %v", summary.Violations)
}
if summary.Steps != 3 {
t.Errorf("steps: got %d, want 3 (the run must stop at the violating step, not run the 8-step budget)",
summary.Steps)
}
for _, step := range traceStepIndices(t, state.writer.Directory()) {
if step > summary.Steps {
t.Errorf("trace kept stepping after the violation: found step %d past step %d",
step, summary.Steps)
}
}
}
// TestRunner_WithoutStopOnViolationRunsTheWholeBudget is the other half: the
// default must stay a full-budget fuzz run, so turning the flag on is the only
// thing that shortens a run.
func TestRunner_WithoutStopOnViolationRunsTheWholeBudget(t *testing.T) {
state := newHarnessWithSpec(t, violationSpec)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 4,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps != 4 {
t.Errorf("steps: got %d, want 4; a violation must not shorten a default run", summary.Steps)
}
}
// traceStepIndices reads every step index the trace recorded, so a test can
// assert on what the run actually wrote rather than on the summary alone.
func traceStepIndices(t *testing.T, directory string) []int {
t.Helper()
file, err := os.Open(filepath.Join(directory, "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
defer file.Close()
var steps []int
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for scanner.Scan() {
var line struct {
Step int `json:"step"`
}
if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
t.Fatalf("trace line decode: %v", err)
}
steps = append(steps, line.Step)
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan trace: %v", err)
}
return steps
}
// siblingCardsSpec is the shape folio's Home screen authors: every account card
// carries the same testTag, and each one is its own tap target.
const siblingCardsSpec = `
import { actions, always, extract, Tap } from "@sanderling/spec";
const cards = extract(state => state.ax.findAll({ testTag: "AccountCard" }));
globalThis.properties = {
alwaysHolds: always(() => true),
};
globalThis.actions = actions(() => cards.current.map(card => Tap({ on: card })));
`
const siblingCardsTree = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,100,300]"},
"children": [
{"attributes": {"testTag": "AccountCard", "text": "Alpha", "bounds": "[0,0,100,100]"}, "clickable": true, "children": []},
{"attributes": {"testTag": "AccountCard", "text": "Beta", "bounds": "[0,100,100,200]"}, "clickable": true, "children": []},
{"attributes": {"testTag": "AccountCard", "text": "Gamma", "bounds": "[0,200,100,300]"}, "clickable": true, "children": []}
]
}`
// TestRunner_SiblingTapsReachTheDriverAtTheirOwnCoordinates is the check the
// whole selector-uniqueness rule exists for. Three cards sharing one testTag
// each produce their own tap; if they reach the driver naming a selector all
// three answer to, resolveCoordinates re-resolves every one of them onto the
// first card and the fuzzer can never open the other two.
func TestRunner_SiblingTapsReachTheDriverAtTheirOwnCoordinates(t *testing.T) {
state := newHarnessWithSpec(t, siblingCardsSpec)
tree, err := hierarchy.Parse(siblingCardsTree)
if err != nil {
t.Fatal(err)
}
if err := state.verifier.PushSnapshot(verifier.SnapshotInput{Tree: tree}); err != nil {
t.Fatal(err)
}
for range 40 {
action, err := state.verifier.NextAction()
if err != nil {
t.Fatalf("NextAction: %v", err)
}
if action.Kind != verifier.ActionKindTap {
t.Fatalf("spec offers taps only, got %q", action.Kind)
}
mustDispatch(t, state.mock, action, tree)
}
tapped := map[string]bool{}
for _, dispatched := range state.mock.Actions() {
if dispatched.Kind != mockdriver.ActionTap {
t.Fatalf("expected coordinate taps only, got %v", dispatched)
}
tapped[fmt.Sprintf("%d,%d", dispatched.X, dispatched.Y)] = true
}
want := []string{"50,50", "50,150", "50,250"}
for _, center := range want {
if !tapped[center] {
t.Errorf("no tap reached the driver at (%s); the driver saw %v", center, slices.Sorted(maps.Keys(tapped)))
}
}
if len(tapped) != len(want) {
t.Errorf("driver saw %d distinct tap points, want %d: %v", len(tapped), len(want), slices.Sorted(maps.Keys(tapped)))
}
}
// tapReachesNoElement wraps a mock driver so every tap reports what the chrome
// driver reports when the action's point holds no element: nothing was
// dispatched, so the app cannot have responded.
type tapReachesNoElement struct {
*mockdriver.Driver
}
func (d *tapReachesNoElement) TapSelector(
_ context.Context,
selector string,
) error {
return fmt.Errorf("%w: %s", driver.ErrGestureUndelivered, selector)
}
func (d *tapReachesNoElement) Tap(_ context.Context, x, y int) error {
return fmt.Errorf("%w: (%d,%d)", driver.ErrGestureUndelivered, x, y)
}
// TestRunner_UndeliveredGestureIsRecordedNotSilentlyClean is the runner half of
// the silent-actuation bug: a run whose every tap reached nothing used to look
// exactly like a run that exercised the app and found no violations. The step
// now names the reason, and the run says how many actions did nothing.
func TestRunner_UndeliveredGestureIsRecordedNotSilentlyClean(t *testing.T) {
state := newHarness(t)
wrapped := &tapReachesNoElement{Driver: state.mock}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 10 * time.Second,
MaxSteps: maxConsecutiveApplyFailures + 2,
IdleTimeout: 20 * time.Millisecond,
Driver: wrapped,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf(
"a gesture that reached nothing is not a device fault: %v",
err,
)
}
if summary.Steps <= maxConsecutiveApplyFailures {
t.Fatalf(
"the run must outlive the apply-failure cap, got %d steps",
summary.Steps,
)
}
undelivered := summary.SkippedActions[string(actionSkippedGestureUndelivered)]
if undelivered != summary.Steps {
t.Errorf(
"gesture_undelivered count = %d, want %d (every tap reached nothing)",
undelivered,
summary.Steps,
)
}
var rendered bytes.Buffer
RenderSummary(&rendered, summary, "web")
if !strings.Contains(rendered.String(), "gesture_undelivered") {
t.Errorf(
"the summary must not read clean while nothing was actuated, got:\n%s",
rendered.String(),
)
}
type traceLine struct {
Step int `json:"step"`
ActionSkipped string `json:"action_skipped"`
Transitional bool `json:"transitional"`
}
body, err := os.ReadFile(
filepath.Join(state.writer.Directory(), "trace.jsonl"),
)
if err != nil {
t.Fatal(err)
}
for _, raw := range bytes.Split(bytes.TrimSpace(body), []byte("\n")) {
var line traceLine
if err := json.Unmarshal(raw, &line); err != nil {
t.Fatalf("decode trace line: %v", err)
}
if line.ActionSkipped != "gesture_undelivered" {
t.Errorf("step %d: action_skipped = %q, want %q",
line.Step, line.ActionSkipped, "gesture_undelivered")
}
if line.Transitional {
t.Errorf(
"step %d: an undelivered gesture leaves the verified screen intact, "+
"so the step must not be transitional",
line.Step,
)
}
}
}
// selectorMatchesNothing wraps a mock driver so every by-selector tap reports
// what the drivers report when the selector names no element on the screen.
type selectorMatchesNothing struct {
*mockdriver.Driver
}
func (d *selectorMatchesNothing) TapSelector(_ context.Context, selector string) error {
return fmt.Errorf("%w: %q", driver.ErrSelectorMatchedNothing, selector)
}
// TestRunner_SelectorThatMatchesNothingIsRecordedApartFromAnUndeliveredGesture
// keeps the two silent paths distinguishable. A selector that named no element
// is a resolution failure, so the step records unresolved_selector, keeps the
// verified screen (not transitional), and does not spend the apply-failure
// budget that a wedged device is meant to exhaust.
func TestRunner_SelectorThatMatchesNothingIsRecordedApartFromAnUndeliveredGesture(t *testing.T) {
state := newHarnessWithSpec(t, absentSelectorSpec)
wrapped := &selectorMatchesNothing{Driver: state.mock}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 10 * time.Second,
MaxSteps: maxConsecutiveApplyFailures + 2,
IdleTimeout: 20 * time.Millisecond,
Driver: wrapped,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("a selector that matched nothing is not a device fault: %v", err)
}
if summary.Steps <= maxConsecutiveApplyFailures {
t.Fatalf("the run must outlive the apply-failure cap, got %d steps", summary.Steps)
}
if got := summary.SkippedActions[string(actionSkippedUnresolvedSelector)]; got != summary.Steps {
t.Errorf("unresolved_selector count = %d, want %d", got, summary.Steps)
}
if got := summary.SkippedActions[string(actionSkippedGestureUndelivered)]; got != 0 {
t.Errorf("gesture_undelivered count = %d, want 0: nothing was dispatched to a point", got)
}
var rendered bytes.Buffer
RenderSummary(&rendered, summary, "android")
if !strings.Contains(rendered.String(), "unresolved_selector") {
t.Errorf("the summary must name the actions that found no target, got:\n%s", rendered.String())
}
type traceLine struct {
Step int `json:"step"`
ActionSkipped string `json:"action_skipped"`
Transitional bool `json:"transitional"`
}
body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
for _, raw := range bytes.Split(bytes.TrimSpace(body), []byte("\n")) {
var line traceLine
if err := json.Unmarshal(raw, &line); err != nil {
t.Fatalf("decode trace line: %v", err)
}
if line.ActionSkipped != "unresolved_selector" {
t.Errorf("step %d: action_skipped = %q, want %q",
line.Step, line.ActionSkipped, "unresolved_selector")
}
if line.Transitional {
t.Errorf("step %d: nothing was dispatched, so the step must not be transitional", line.Step)
}
}
}
// TestApplyAction_TapAboveTheViewportReachesTheDriver is the other half of the
// off-screen reach fix. The web host names an unnamed candidate by coordinates
// alone, and a candidate the growing document pushed above the fold carries a
// negative y; dropping it here denies the driver the scroll that would bring it
// back, so reach below the fold works and reach above it does not.
func TestApplyAction_TapAboveTheViewportReachesTheDriver(t *testing.T) {
mock := mockdriver.New()
mustDispatch(t, mock, verifier.Action{
Kind: verifier.ActionKindTap,
X: 622,
Y: -208,
}, nil)
dispatched := mock.Actions()
if len(dispatched) != 1 {
t.Fatalf("driver saw %d actions, want 1: %v", len(dispatched), dispatched)
}
if dispatched[0].Kind != mockdriver.ActionTap ||
dispatched[0].X != 622 || dispatched[0].Y != -208 {
t.Errorf("driver saw %v, want a tap at (622,-208)", dispatched[0])
}
}
// TestApplyAction_TapAboveTheViewportKeepsTheUndeliveredReport holds the
// distinction the fix must not collapse: a point the driver cannot put an
// element under is still a failure, reported as ErrGestureUndelivered rather
// than as an action the runner declined to try.
func TestApplyAction_TapAboveTheViewportKeepsTheUndeliveredReport(t *testing.T) {
drv := &tapReachesNoElement{Driver: mockdriver.New()}
skipped, err := applyAction(context.Background(), drv, verifier.Action{
Kind: verifier.ActionKindTap,
X: 622,
Y: -208,
}, nil)
if !errors.Is(err, driver.ErrGestureUndelivered) {
t.Errorf("err = %v, want ErrGestureUndelivered", err)
}
if skipped != "" {
t.Errorf("skip reason = %q, want none: the driver was called", skipped)
}
}
// TestRenderSummary_NamesTheActionsThatNeverReachedTheApp is the report half of
// the silent-actuation class. A run that chose an action every step and dropped
// every one of them printed the same "no violations" as a run that exercised
// the app, because the reasons lived only in the trace and a warn line.
func TestRenderSummary_NamesTheActionsThatNeverReachedTheApp(t *testing.T) {
state := newHarnessWithSpec(t, zeroWaitSpec)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 5 * time.Second,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 3,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
reason := string(actionSkippedZeroDurationWait)
if summary.SkippedActions[reason] != 3 {
t.Errorf("SkippedActions[%s] = %d, want 3", reason, summary.SkippedActions[reason])
}
var rendered bytes.Buffer
RenderSummary(&rendered, summary, "web")
want := "3 action(s) never reached the app: " + reason + " 3"
if !strings.Contains(rendered.String(), want) {
t.Errorf("summary must carry %q, got:\n%s", want, rendered.String())
}
var clean bytes.Buffer
RenderSummary(&clean, Summary{Steps: 3}, "web")
if strings.Contains(clean.String(), "never reached the app") {
t.Errorf("a run that dropped nothing must not carry the line, got:\n%s", clean.String())
}
}
// wedgedTapSelector never answers the first by-selector tap, which is what a
// driver call that has stopped returning looks like from the step loop.
type wedgedTapSelector struct {
*mockdriver.Driver
calls int
}
func (d *wedgedTapSelector) TapSelector(ctx context.Context, selector string) error {
d.calls++
if d.calls == 1 {
<-ctx.Done()
return ctx.Err()
}
return d.Driver.TapSelector(ctx, selector)
}
// Duration is a loop condition checked between steps, so an action that never
// returns held the run for as long as the process lived and only a kill from
// outside ended it. The step is what must fail, under a reason of its own: a
// wedge is not the gesture that reached nothing and not the action the runner
// declined to dispatch, and an analysis that cannot tell them apart cannot say
// whether the device answered at all.
func TestRunner_AnActionThatNeverReturnsFailsTheStepNotTheRun(t *testing.T) {
state := newHarness(t)
previousApplyTimeout := applyTimeout
applyTimeout = 50 * time.Millisecond
t.Cleanup(func() { applyTimeout = previousApplyTimeout })
wrapped := &wedgedTapSelector{Driver: state.mock}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 3,
Driver: wrapped,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run must outlive an action that never returns, got %v", err)
}
if summary.Steps != 3 {
t.Fatalf("Steps = %d, want 3: the wedge costs one step, not the run", summary.Steps)
}
lines := readTraceLines(t, state.writer.Directory())
if lines[0].ActionSkipped != string(actionSkippedApplyTimeout) {
t.Errorf("step 1 action_skipped = %q, want %q",
lines[0].ActionSkipped, actionSkippedApplyTimeout)
}
if !lines[0].Transitional {
t.Error("step 1 must be transitional: the action was dispatched and its effect is unknown")
}
for _, line := range lines[1:] {
if line.ActionSkipped != "" {
t.Errorf("step %d action_skipped = %q, want the wedge confined to the step that wedged",
line.Step, line.ActionSkipped)
}
}
if summary.SkippedActions[string(actionSkippedApplyTimeout)] != 1 {
t.Errorf("SkippedActions = %v, want one %s", summary.SkippedActions, actionSkippedApplyTimeout)
}
}
// snapshotFailThenEmpty fails the first observation outright and answers the
// second with a dump holding no elements at all.
type snapshotFailThenEmpty struct {
*mockdriver.Driver
calls int
}
func (d *snapshotFailThenEmpty) Snapshot(ctx context.Context) (string, driver.Image, error) {
d.calls++
switch d.calls {
case 1:
return "", driver.Image{}, errors.New("Timeout while fetching view hierarchy")
case 2:
return "", driver.Image{}, nil
}
return d.Driver.Snapshot(ctx)
}
// A step that read nothing because the read failed and a step that read a
// screen with nothing on it are recorded identically as a nil tree, so a run
// that observed nothing at all reports exactly like a run that observed an
// empty app and found no violation in it.
func TestRunner_AFailedObservationIsNotAnObservationOfAnEmptyScreen(t *testing.T) {
state := newHarness(t)
state.mock.HierarchyJSON = `{"attributes":{"resource-id":"HomeScreen"},"children":[]}`
wrapped := &snapshotFailThenEmpty{Driver: state.mock}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 3,
Driver: wrapped,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps != 3 {
t.Fatalf("Steps = %d, want 3", summary.Steps)
}
lines := readTraceLines(t, state.writer.Directory())
if !strings.Contains(lines[0].ObservationError, "Timeout while fetching view hierarchy") {
t.Errorf("step 1 observation_error = %q, want the driver's own failure",
lines[0].ObservationError)
}
if lines[1].ObservationError != "" {
t.Errorf("step 2 observation_error = %q, want none: the screen was read and held no elements",
lines[1].ObservationError)
}
if lines[2].ObservationError != "" {
t.Errorf("step 3 observation_error = %q, want none", lines[2].ObservationError)
}
if summary.FailedObservations != 1 {
t.Errorf("FailedObservations = %d, want 1", summary.FailedObservations)
}
var rendered bytes.Buffer
RenderSummary(&rendered, summary, "android")
if !strings.Contains(rendered.String(), "1 step(s) observed nothing") {
t.Errorf("summary hides the failed observation: %q", rendered.String())
}
}