refactoring default action layer (#51)

* feat(hierarchy): add editable signal with native derivation

* feat(chrome): emit editable flag in hierarchy dump

* feat(verifier): expose editable on ax element objects

* feat(spec): add editable to selector and element types

* feat(verifier): register typing builtin generator

* feat(verifier): typing builtin types edge-case corpus into editable fields

* feat(spec): export typing builtin generator

* feat(spec): add defaultActions bundle

* feat(spec): export @sanderling/spec/defaults subpath

* feat(folio): layer defaultActions breadth over targeted flows

* test(verifier): typing builtin targets editable fields, declines otherwise

* test(hierarchy): editable derivation and selector matching

* test(spec): defaultActions, typing, and defaults barrel resolve

* fix(testrun): alias @sanderling/spec/defaults for the bundler

* test(chrome): editable flag for inputs, textarea, contenteditable

* feat(spec): typing builtin for the web (V8) action path

* chore(folio): auto-boot a bootable AVD in just test/install when none connected

* feat(driver): add ForegroundChecker optional capability

* feat(android): detect foreground package via adb dumpsys

* feat(sidecar): implement ForegroundApp via adb for android

* feat(runner): relaunch app when foreground escapes during exploration

* fix(spec): drop hardware back from defaultActions to stay in-app

* feat(spec): add DoubleTap action type and constructor

* feat(spec): wire DoubleTap through web-runtime serializer

* feat(verifier): bind doubleTap and decode DoubleTap actions

* feat(runner): dispatch DoubleTap as two taps inside one step

* test(doubleTap): cover constructor, verifier round-trip, and runner dispatch

* feat(folio): add noDuplicateTxnPerStep invariant and doubleSubmitTxn action

* fix(folio): track ledger row count across non-ledger steps; pin reproducer seed

* feat(spec): add doubleTaps random-target builtin to defaultActions

* feat(verifier): add doubleTaps random-target generator

* refactor(folio): drop doubleSubmitTxn; fuzzer surfaces double-submit via defaultActions

* fix(folio): make ledgerRowsSeen monotonic to suppress transient-render false positives

* feat(verifier): track newly-violated property set per step

Sticky `always(P)` violations re-surfaced on every step after onset,
flooding traces and summaries with duplicate records. EvaluateProperties
now diffs against the prior verdict map and records the onset set; a new
NewlyViolatedProperties accessor exposes it so callers can emit each
violation exactly once at its onset step. The verdict-map return is
preserved for residual / current-verdict consumers.

* refactor(runner): emit onset-only violations to trace and summary

Switch the per-step violation list from the sticky verdict map to the
verifier's onset set. Each property now appears exactly once across a
run: at the step it first violates, not on every subsequent step where
the residual stays false. Removes the dead violationNames helper.

* style(verifier): use maps.Copy for verdict snapshot

* fix(folio): make login spec content-driven (idempotent across re-entries)

* fix(verifier): canonicalize selector strings

Object/chain JS selectors used to fall through to goja's default
stringification, producing "[object Object]" tags that surfaced as
garbage in trace.action.selector. Emit canonical "k:v" / " > "-joined
strings instead so the tag round-trips back through the hierarchy
selector grammar.

* refactor(folio): replace txn invariants with balanceMatchesAddedTxn

Collapse noDuplicateTxnPerStep and newTxnChangesBalance into a single
per-row property: every newly-appearing ledger row's signed amount must
match the ledger balance delta. A double-submit lands two rows whose
individual amounts cannot both equal the aggregate delta, so each row
fires the property, catching both the row-count and balance-math
classes of bug under one semantic invariant.

* refactor(trace): drop WriteScreenshotAfter

Only one screenshot per step is captured now (concurrently with
hierarchy after settle), so the -after.png variant is unused.

* refactor(runner): one concurrent screenshot per step

Move screenshot capture into the post-action errgroup so it observes
the same UI moment as the hierarchy fetch. Drop the pre-action and
deferred -after captures. Skip WaitForIdle when the action is Wait
since the wait itself provides settling time.

* refactor(inspect-ui): use next step's screenshot for state after

Each step now has one screenshot (the moment of observation). The
"state after" view of step N is the same moment as step (N+1)'s
observation, so reuse that file rather than expecting a separate
-after.png.

* feat(sidecar): structural-hash settle poll

Add pollUntilStable and structuralHash helpers; wire them into the
Stub, Maestro, and iOS backends' waitForIdle. The structural hash
ignores bounds-only flicker (measure passes) but trips on any change
in resource-id/class/content-desc/text, so a Compose cross-fade where
both source and destination composables are momentarily alive no
longer slips through Maestro's waitForAppToSettle and contaminates
the next hierarchy fetch.

* test(sidecar): cover pollUntilStable and structuralHash

Verify the poll returns on two equal snapshots, after transient
churn, and at the cap when never stable; assert the hash ignores
bounds-only flicker and detects content changes.

* feat(spec): accept optional name on extract()

Add an (name, getter) overload so each extractor handle carries a
debuggable label that future trace fields (per-step diffs) can key
off. The web-runtime falls back to extractor_\${index} when none is
supplied so existing call sites keep working unchanged.

* test(spec): cover extract name overload

Verify the runtime receives an undefined name in the legacy shape,
the supplied name in the (name, getter) shape, and that
extract("name") with no getter throws.

* feat(verifier): name extractors for diff surfacing

bindExtract accepts an optional name argument; falls back to
extractor_N when omitted. The name is stored on extractorState
alongside prev/curr value caches that the next change will use to
emit per-step diffs.

* chore(folio): name every extract() call

Give each extractor in the Folio spec a debuggable label so the
inspect UI can render extractor-value diffs at violation steps
keyed by intent (ledgerRows, route, ledgerBalance, ...) rather
than by registration index.

* feat(verifier): track extractor value transitions

Cache each extractor's prior and current JSON-encoded value during
PushSnapshot; expose ChangedExtractors to surface per-step diffs the
runner can emit into the trace. The first observation flushes every
non-null extractor as a change so the inspect UI shows initial state
breadcrumbs alongside later transitions.

* test(verifier): cover ChangedExtractors diffs

Verify initial snapshot reports both named and fallback-named
extractors, a subsequent change surfaces prev/curr, and a no-op
snapshot leaves the diff empty.

* feat(trace): emit extractor_changes per step

Add ExtractorChanges to trace.Step and a runner helper that converts
the verifier's diff map into the trace shape. The inspect UI keys
its violation breadcrumbs off this field.

* feat(inspect-ui): render extractor-change breadcrumbs at violations

Show prev -> curr for each extractor whose value changed on the
selected step, anchored under the violation row in ActionList.
Long values collapse into <details> so the inline diff stays
readable while the full payload is one click away.

* fix(sidecar): cap stability poll independently of settle budget

The previous shape halved durationMillis between waitForAppToSettle
and the structural poll, then hammered hierarchy() at 80ms intervals
- on Maestro this stacked enough RPCs that hierarchy fetches began
timing out under load and the run stalled. Pass the full budget to
waitForAppToSettle and cap the follow-up structural poll at 600ms
with a 120ms interval, so the device sees at most a handful of
extra hierarchy reads per step.

* feat(cli): default --clear-data on so runs start fresh

* feat(sidecar): streak-based settle with route-transition detection

Two changes layered into the stability poll:

1. stabilitySnapshot returns null while the tree carries more than one
   route-level Screen tag (resource-id / testTag / identifier ending
   in "Screen"), so the poll cannot declare a NavHost cross-fade
   stable. Apps following the Compose route convention get this
   detection for free; apps that don't fall through to the generic
   signal below.

2. pollUntilStable now requires an uninterrupted stable streak of at
   least MIN_STABLE_STREAK_MILLIS rather than just N consecutive
   matches. A late transition that fires after a brief calm window
   breaks the streak instead of slipping past. Interval widened to
   250ms so UiAutomation isn't hammered under fuzz load.

* test(sidecar): cover streak reset and route-transition rejection

Verify the poll honors MIN_STABLE_STREAK_MILLIS, that a transient
mid-stream change resets the streak, that null returns block streak
progress through a NavHost cross-fade, and that stabilitySnapshot
counts only route-level attribute keys when summing Screen tags.

* feat(runner): re-fetch on transitional hierarchy capture

Some actions trigger async work (DB write, ViewModel coroutine) whose
navigation transition begins after the sidecar settle poll has already
exited. Without intervention, the next iteration's hierarchy fetch
lands mid cross-fade and the verifier observes a partial extractor
state which then surfaces as a false-positive violation at the step
where the transition completes.

fetchSyncedState pairs hierarchy + screenshot in one goroutine and
retries the pair (up to 4 times, 200ms apart) while the captured tree
contains more than one route-level *Screen tag. Steps that observe
no transition get no added cost; steps that catch a transition pay
up to ~600ms extra wall time but record a tree that matches the
post-transition state the property language expects to compare.

* feat(runner): gate first action on app reaching foreground

* test(runner): cover startup foreground gate and back-press

* feat(verifier): scope random-action targets to app package

Random tap/doubleTap/type/swipe candidates now exclude nodes whose package differs from the app under test, so exploration never fuzzes the soft keyboard, system UI, or permission dialogs. An unset app package or an element with no package stays in scope, preserving behavior on iOS.

* feat(testrun): pass app package into verifier scope filter

* test(verifier): cover package-scoped target selection

* feat(hierarchy): derive package from resource-id prefix

The Android sidecar omits an explicit package attribute, so the verifier's package scope filter was a no-op and the keyboard still leaked into targets. Native nodes carry their package as the resource-id prefix; derive it there when the attribute is absent. Compose testTags are colon-less and stay empty, keeping them in scope.

* test(hierarchy): cover package derivation from resource-id

* chore: stop tracking inspect-ui/dist build artifacts

* feat(android): detect focused-window package via dumpsys window

* feat(driver): add FocusedWindowChecker capability

* fix(runner): gate first observe on the app window being drawn, not just resumed

* test(mock): add FocusedWindowApp with foreground mirroring

* test(runner): cover startup gate waiting for app window to draw

* feat(proto): add Snapshot RPC for atomic hierarchy+screenshot

Pairs hierarchy and screenshot in a single response so the runner can
capture both under a backend mutex, avoiding the cross-fade race where
the two reads describe different frames.

* feat(sidecar): add snapshot default on DriverBackend

Default impl calls hierarchy() then screenshot(). The service layer wraps
the call in a mutex so concurrent runners observe a serialized pair.

* feat(sidecar): wire Snapshot handler with serialization lock

Synchronizes backend.snapshot() so concurrent runners observe a
serialized hierarchy+screenshot pair, eliminating the cross-fade race
where two parallel reads describe different frames.

* test(sidecar): cover Snapshot wire path and serialization lock

SnapshotHandlerTest asserts both fields are populated, concurrent calls
are serialized, and the default impl runs hierarchy then screenshot.

* feat(driver): expose Snapshot on DeviceDriver and sidecar client

Snapshot wraps the new atomic-snapshot gRPC: the runner gets hierarchy
and screenshot from one round-trip whose two reads are serialized on
the sidecar side.

* feat(driver): add Snapshot to chrome and mock drivers

The chrome tab is single-threaded so its Snapshot pairs the two reads
without extra locking. The mock records ActionSnapshot so tests can
assert the runner reaches for the paired RPC.

* refactor(runner): observe each step via the atomic Snapshot RPC

fetchSyncedState now issues one Snapshot per attempt so hierarchy and
screenshot describe the same on-device frame. The transitional retry
stays: that case handles a fully-captured but mid cross-fade frame,
which atomic capture cannot fix.

* test(runner): assert step uses Snapshot, not raw hierarchy/screenshot

TestRunner_UsesAtomicSnapshot catches regressions to the two-goroutine
race, and the existing parallel-fetch test now keys off ActionSnapshot.

* test(driver): cover Snapshot in proto descriptor and sidecar client

Adds Snapshot to the descriptor allowlist and a sidecar-client test that
asserts both fields come back over the wire.

* feat(trace): add Transitional flag to Step

* fix(runner): skip verifier for transitional trees after retry budget

When fetchSyncedState exits its retry loop with a tree that still shows a NavHost cross-fade, the runner now marks the step transitional, writes the step + screenshot to the trace, and skips Verifier.PushSnapshot / EvaluateProperties / ChangedExtractors so the previous-to-current extractor advance is not poisoned by transient state. The next clean step's previous still references the prior clean state. NextAction continues to run so the loop never deadlocks on a never-stabilizing screen.

* test(runner): cover transitional step skips verifier and clean control

* refactor(trace): rename Step.Action to Step.NextAction

The trace step's action field is the action chosen FOR THE NEXT iteration
based on observing this step's hierarchy, not the action that produced
this step. Rename Step.Action to Step.NextAction and the JSON tag to
next_action to make causality explicit at the data level.

* refactor(runner): assign trace action to Step.NextAction field

Follows the rename of trace.Step.Action to Step.NextAction. The runner
already computed the next iteration's action here; only the field name
changes.

* refactor(inspect): decode trace step's next_action JSON field

Mirrors the trace schema rename of action to next_action. The summary
shape exposed to the SPA (action_kind/action_label) keeps its current
JSON tags since these are derived labels, not the raw next-action.

* test(inspect): update fixtures to use next_action trace field

Aligns inspect tests with the trace schema rename. Step constructors
now set NextAction and the JSONL fixtures use the next_action tag.

* refactor(inspect-ui): rename Step.action to Step.next_action

Aligns the SPA type and consumers with the trace schema rename. The
StepSummary.action_kind/action_label labels stay unchanged since they
are derived labels, not the raw next-action.

* fix(folio): extract balanceMatchesAddedSum predicate as testable helper

Move the ledger-balance-vs-added-rows predicate into a pure helper module
so the property's logic is unit-testable in isolation. Marks the sanderling
example as an ES module so cross-package ESM imports resolve under node.

* fix(folio): use sum-of-added-rows in balanceMatchesAddedTxn

The old predicate (every row's signed amount equals delta) silently passed
the double-submit bug because two same-amount rows each match the delta in
isolation. Switching to the sum check (addedSum === delta) catches both the
double-submit case and any future multi-row append whose total drifts from
the balance change.

* test(spec): cover balanceMatchesAddedSum single, sum-match, over, under cases

Pins the sum-based predicate: a single new row matching delta and two new
rows summing to delta both hold; two-row over-sum (double-submit) and
under-sum cases both violate.

* fix(build): rebuild sidecar JAR when Kotlin sources change

Without source-file deps on $(SIDECAR_JAR), make never re-ran shadowJar
after a Kotlin edit, so a stale embedded JAR shipped on every install
and the new sidecar code was silently absent at runtime.

* fix(chrome): launch with no-sandbox so headless Chrome starts in CI

* fix(sidecar): type text at cursor instead of clearing the field

InputText now appends at the focus caret, matching the native driver
and the standard mobile-input contract, instead of deleting existing
content first. Adds an injectable command runner so the behavior is
testable without a device.

* test(sidecar): assert InputText types at cursor without clearing

Captures the adb command stream and verifies a single input-text call
with no preceding delete keyevents, plus the adb escaping cases.

* feat(proto): add LongPress RPC

* chore(proto): regenerate Go stubs for LongPress

* feat(driver): add LongPress to DeviceDriver interface

* feat(sidecar): add LongPress client method

* feat(mock): record LongPress action

* feat(chrome): implement LongPress as press-and-hold

* feat(sidecar): implement longPress across backends

* feat(sidecar): dispatch LongPress RPC to backend

* test(sidecar): cover LongPress dispatch

* test(sidecar): implement longPress in snapshot test backend

* feat(verifier): add LongPress and Scroll action kinds

* feat(folio-spec): predicate that gates balance check on TxnSubmit tap

Replaces the row-sum predicate (which always held by construction since
balance is derived from rows in Folio) with one that compares the typed
amount to the actual balance delta after a tap on TxnSubmit. Catches the
planted double-submit bug.

* feat(folio-spec): wire submitMovesBalanceByTypedAmount property

Adds lastAction and totalBalance extractors and uses them in the new
property. Drops ledgerRows/ledgerBalance extractors since nothing else
referenced them.

* feat(verifier): wire longPresses and scrolls generators

* test(verifier): cover longPresses and scrolls generators

* test(folio-spec): unit tests for submitChangesBalanceByTypedAmount

Covers single vs double submit, the DoubleTap variant, vacuous cases
(null action, wrong kind, wrong target, zero typed), and selector-as-
object coercion.

* feat(spec): add LongPress and Scroll authoring surface

* feat(spec): no-op LongPress and Scroll in web runtime

* feat(spec): re-export longPresses and scrolls as opt-in generators

* test(spec): cover LongPress and Scroll runtime members

* test(proto): expect LongPress in service descriptor

* feat(runner): dispatch LongPress and Scroll actions

* test(runner): cover LongPress and Scroll dispatch

* docs(action-space): move LongPress, Scroll, DoubleTap to current actions

* fix(runner): mark nil/empty hierarchy as transitional

A failed or empty sidecar hierarchy fetch was pushed straight to the
verifier, letting spec extractors crash with "Cannot read property 'map'
of undefined" when findAll returned null. Treat that case like a
transitional capture: skip the verifier push, still record the step, and
keep the loop progressing.

* fix(verifier): populate Action.On when tap chooser picks an element

Coordinate-targeted Taps/DoubleTaps left On empty, so action-gated
properties reading lastAction.on couldn't tell which target was hit and
were vacuously skipped. Resolve the picked element to a stable
key:value selector (resource-id, testTag, text, desc) and validate it
resolves back to the same element so we don't accidentally redirect the
tap to a sibling that shares the identifier.

* fix(folio): add parseTypedAmount helper matching app's parseCents

Raw user input like "50" must become 5000 cents, not 50. The existing
parseDollarCents helper strips non-digits and so reads "50" as 50 cents,
which is correct for formatted balance text but off by 100x for raw
input from the amount field.

* fix(folio): parse raw amount input as cents in submit predicate

txnAmountField holds raw user keystrokes, not formatted balance text.
Route it through parseTypedAmount so "50" reads as $50, matching how
the app commits the transaction.

* fix(folio): carry forward total balance across off-screen transitions

AddTransactionScreen shows neither AccountCard nor LedgerBalance, so the
extractor used to report 0 at the step before submit. That made every
non-zero current balance look like the full delta and tripped the typed
amount property on every honest submit. Remember the last-seen sum and
return it whenever the current snapshot has no balance signal.

* test(folio): cover submit predicate with raw typed-amount inputs

Pipes realistic raw keystrokes through parseTypedAmount + the predicate
so single submits clear and double submits fire as expected.

* feat(folio): add computeHomeTotalBalance helper

Pure helper that tracks Home multi-account total only and carries the last
Home sum across off-Home steps. Ledger's single-account balance is excluded
because mixing it would corrupt cross-screen scale comparisons.

* fix(folio): totalBalance carrier tracks only Home, not Ledger

Home cardSum is a multi-account total; Ledger's LedgerBalance is a single
account on a different scale. Blending them in the carrier produced bogus
cross-screen deltas (prev from Ledger, curr from Home), triggering false
positives in submitMovesBalanceByTypedAmount. Restrict the carrier to
Home AccountCard totals via the computeHomeTotalBalance helper.

* test(spec): cover computeHomeTotalBalance carrier behaviour

Tests Home sums, carrier passthrough on off-Home steps, the Ledger
scale-mismatch case, and a Home > off-Home > Home sequence.

* feat(runner): treat transient apply errors as transitional steps

Sidecar input RPCs occasionally hang with DEADLINE_EXCEEDED or
UNAVAILABLE on long fuzzing runs. The per-step loop previously
propagated any applyAction error and killed the run after a single
flake. Detect transient gRPC failures via status.FromError, mark the
step transitional, skip the post-action idle poll, and continue to the
next step. Fatal errors (outer ctx cancellation, non-transient codes,
verifier crashes) still propagate.

* test(runner): cover transient apply error resilience

TestRunner_TransientApplyErrorMarksTransitional drives the runner
through a wrapper that fails the first TapSelector with a gRPC
DeadlineExceeded then succeeds. Asserts the run does not exit, the
failed step is marked transitional with no violations, and the next
step runs cleanly. TestIsTransientApplyError_Classification covers the
helper's matching rules directly so future code changes don't quietly
drop a transient case.

* fix(folio): gate submit-balance property on Home route landing

totalBalance is only freshly computed when AccountCards are visible on
Home; off-Home landings return the carrier and would false-fire the
property, latching always(next(F)) to false and masking the real
double-submit bug. Skip vacuously when route is not "home".

* test(spec): cover route gate in submit-balance predicate

Adds route arg to existing cases (all use "home") and adds five new
cases: ledger landing with stale carrier, add-transaction with
double-insert delta, null route, plus home-landing positive and
double-insert negative cases anchoring the gate's allow path.
This commit is contained in:
pj authored and GitHub committed 2026-06-01 12:48:51 +05:30
1 parent f572c8ba66
commit 88db9653e5
65 files changed
+4698 -546

No files matched your search

+2 -1
View File
@@ -8,6 +8,7 @@ BUF := buf
GO_PACKAGES := ./...
SIDECAR_JAR := sidecar/build/libs/sidecar-all.jar
SIDECAR_EMBED := internal/sidecar/assets/sidecar-all.jar
SIDECAR_SRC := $(shell find sidecar/src -type f \( -name '*.kt' -o -name '*.kts' \) 2>/dev/null) build.gradle.kts settings.gradle.kts
SANDERLING_BIN := bin/sanderling
DOCS_SRC := $(shell find docs -type f -name '*.md' -not -path 'docs/_*')
@@ -61,7 +62,7 @@ inspect-dev: $(SIDECAR_EMBED)
web-typecheck:
cd inspect-ui && bun install --frozen-lockfile && bun run typecheck
$(SIDECAR_JAR):
$(SIDECAR_JAR): $(SIDECAR_SRC)
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:shadowJar
$(SIDECAR_EMBED): $(SIDECAR_JAR)
+1 -1
View File
@@ -52,7 +52,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration")
flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)")
flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces")
flagSet.BoolVar(&options.clearData, "clear-data", false, "clear app data before launching so the run starts from a fresh install")
flagSet.BoolVar(&options.clearData, "clear-data", true, "clear app data before launching so each run starts from a fresh install; pass --clear-data=false to resume prior state")
if err := flagSet.Parse(args); err != nil {
return testOptions{}, err
}
+6 -6
View File
@@ -32,22 +32,22 @@ func TestParseTestArgs_Defaults(t *testing.T) {
if options.seed != 0 {
t.Errorf("seed default: got %d, want 0", options.seed)
}
if options.clearData {
t.Errorf("clearData default: got true, want false")
if !options.clearData {
t.Errorf("clearData default: got false, want true")
}
}
func TestParseTestArgs_ClearDataFlag(t *testing.T) {
func TestParseTestArgs_ClearDataDisabled(t *testing.T) {
options, err := parseTestArgs([]string{
"--spec", "s.ts",
"--bundle-id", "com.example",
"--clear-data",
"--clear-data=false",
}, io.Discard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !options.clearData {
t.Errorf("expected clearData=true, got false")
if options.clearData {
t.Errorf("expected clearData=false, got true")
}
}
+49
View File
@@ -0,0 +1,49 @@
# Action Space
Inventory of user interaction primitives Sanderling currently supports, compared against the full set available through the Maestro driver layer.
## Current Actions
| Action | Parameters | Notes |
|--------|-----------|-------|
| `Tap` | `on` (selector or element) | Single tap on element or coordinates |
| `DoubleTap` | `on` (selector or element) | Two taps within a sub-100 ms window |
| `LongPress` | `on` (selector or element) | Press and hold; resolves to coordinates |
| `InputText` | `into`, `text` | Type into focused field |
| `Swipe` | `from`, `to`, `durationMillis?` | Point-to-point or element-to-element |
| `Scroll` | `direction`, `in?`, `durationMillis?` | Scroll a container up, down, left, or right |
| `PressKey` | `key` | back, home, enter, tab, up, down, left, right |
| `Wait` | `durationMillis` | Sleep for N ms |
## Gaps vs Maestro Driver
The Maestro driver layer (both `AndroidDriver` and `IOSDriver`) already implements these actions. They are not yet surfaced in Sanderling's action model.
### User Interaction Gaps
| Action | Priority | Notes |
|--------|----------|-------|
| `EraseText` | Medium | Clear N characters from focused field |
| `HideKeyboard` | Medium | Dismiss software keyboard after text input |
| `PasteText` | Low | Paste clipboard content into focused field |
### Device/Environment Gaps
These are less about user gesture modeling and more about test environment setup. Separate decision on whether they belong in the action model.
| Action | Notes |
|--------|-------|
| `SetLocation` | GPS coordinate mocking |
| `SetOrientation` | Portrait, landscape-left, landscape-right |
| `SetAirplaneMode` | Network condition simulation |
| `OpenLink` | Deep links and URLs |
| `AddMedia` | Inject photos or videos into device gallery |
### Out of Scope
These Maestro commands don't fit Sanderling's interaction model:
- `LaunchApp`, `StopApp`, `KillApp`, `ClearState` - app lifecycle, handled outside the action stream
- `AssertVisible`, `AssertWithAI` - Sanderling has its own property and assertion model
- `RunScript`, `EvalScript`, `RunFlow`, `Repeat` - Sanderling has its own flow and generator model
- `InputRandomText/Number/Email/...` - Sanderling's `from()` and `weighted()` generators cover this
+43 -3
View File
@@ -35,8 +35,48 @@ _android-home:
echo "could not locate Android SDK (set ANDROID_HOME)" >&2
exit 1
# Ensure an Android device is online. If none is connected and no AVD is
# provided, boot the first AVD whose system image is actually installed
# (headless) and wait for it to finish booting.
_ensure-device:
#!/usr/bin/env bash
set -euo pipefail
sdk="$(just _android-home)"
adb="$sdk/platform-tools/adb"
if "$adb" devices | awk 'NR>1 && $2=="device"{f=1} END{exit !f}'; then
exit 0
fi
if [[ -n "{{avd}}" ]]; then
exit 0 # sanderling boots the named AVD itself
fi
emulator="$sdk/emulator/emulator"
avd=""
for candidate in $("$emulator" -list-avds); do
# The AVD directory is named in <name>.ini's path= field, which is not
# always "<name>.avd" (e.g. Medium_Phone_API_36.0 -> Medium_Phone.avd).
avddir="$(sed -n 's/^path=//p' "$HOME/.android/avd/$candidate.ini" 2>/dev/null || true)"
[[ -n "$avddir" ]] || avddir="$HOME/.android/avd/$candidate.avd"
sysdir="$(sed -n 's/^image\.sysdir\.1=//p' "$avddir/config.ini" 2>/dev/null || true)"
if [[ -n "$sysdir" && -d "$sdk/$sysdir" ]]; then avd="$candidate"; break; fi
done
if [[ -z "$avd" ]]; then
echo "no bootable AVD found (no installed system image); create one or set AVD=" >&2
exit 1
fi
echo "booting emulator: $avd"
ANDROID_SDK_ROOT="$sdk" ANDROID_HOME="$sdk" nohup "$emulator" -avd "$avd" \
-no-window -no-snapshot -gpu swiftshader_indirect -no-audio >/tmp/folio-emulator.log 2>&1 &
for _ in $(seq 1 150); do
if [[ "$("$adb" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; then
echo "emulator ready"; exit 0
fi
sleep 2
done
echo "emulator did not finish booting in time (see /tmp/folio-emulator.log)" >&2
exit 1
# Build and install the folio APK on a running emulator/device.
install:
install: _ensure-device
#!/usr/bin/env bash
set -euo pipefail
export ANDROID_HOME="$(just _android-home)"
@@ -82,8 +122,8 @@ ios:
xcrun simctl launch booted app.folio
# Run 'sanderling test' against the folio app. Uses a connected device if one is
# online, otherwise boots AVD=<name> if provided.
test:
# online; otherwise boots AVD=<name> when provided, or auto-boots a bootable AVD.
test: _ensure-device
#!/usr/bin/env bash
set -euo pipefail
avd_flag=()
+1
View File
@@ -1,6 +1,7 @@
{
"name": "folio",
"private": true,
"type": "module",
"description": "Minimal sanderling integration example.",
"devDependencies": {
"@sanderling/spec": "file:../../../pkg/spec"
+62
View File
@@ -0,0 +1,62 @@
// Computes the Home-screen total balance from the visible AccountCard balances.
// When no Home cards are visible (Ledger, AddTransaction, etc.) the carrier
// value is returned so the property compares apples to apples across screen
// transitions. The Ledger LedgerBalance is intentionally ignored because it is
// a single-account number on a different scale than the Home multi-account sum.
export function computeHomeTotalBalance(args: {
cardBalanceTexts: (string | undefined)[];
previousCarrier: number;
}): number {
const { cardBalanceTexts, previousCarrier } = args;
if (cardBalanceTexts.length === 0) return previousCarrier;
return cardBalanceTexts.reduce((sum, text) => sum + parseDollarCents(text), 0);
}
// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents.
function parseDollarCents(text: string | undefined): number {
if (!text) return 0;
const sign = text.startsWith("-") ? -1 : 1;
const digits = text.replace(/[^0-9]/g, "");
return digits ? sign * parseInt(digits, 10) : 0;
}
// Parses raw user input in the transaction amount field into integer cents.
// Mirrors the Folio app's parseCents: whole numbers like "50" become 5000
// cents, decimals like "5.50" become 550, more than 2 decimals or non-numeric
// input return 0. Leading +/- signs are tolerated and treated as positive.
export function parseTypedAmount(text: string | undefined | null): number {
if (!text) return 0;
let trimmed = text.trim().replace(/,/g, "");
if (trimmed.startsWith("+") || trimmed.startsWith("-")) trimmed = trimmed.slice(1);
if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return 0;
const dot = trimmed.indexOf(".");
const whole = dot < 0 ? trimmed : trimmed.slice(0, dot);
const frac = dot < 0 ? "" : trimmed.slice(dot + 1);
const fracPadded = (frac + "00").slice(0, 2);
return parseInt(whole, 10) * 100 + parseInt(fracPadded, 10);
}
// When the last action is a tap (or double-tap) on the transaction Submit
// button, the absolute change in total balance must equal the amount the
// user typed. A double-submit lands two transactions and shifts the balance
// by 2x the typed amount, tripping this check. The route gate skips steps
// whose landing screen is not Home: totalBalance is only freshly computed
// from visible AccountCards on Home, so off-Home comparisons would read a
// stale carrier value and false-fire.
export function submitChangesBalanceByTypedAmount(args: {
route: string | null;
lastAction: { kind?: string; on?: string | object } | null;
typedAmount: number;
prevTotalBalance: number;
currTotalBalance: number;
}): boolean {
const { route, lastAction, typedAmount, prevTotalBalance, currTotalBalance } = args;
if (route !== "home") return true;
if (lastAction == null) return true;
if (lastAction.kind !== "Tap" && lastAction.kind !== "DoubleTap") return true;
const on = lastAction.on;
const onString = typeof on === "string" ? on : on != null ? JSON.stringify(on) : "";
if (!onString.includes("TxnSubmit")) return true;
if (typedAmount === 0) return true;
return Math.abs(currTotalBalance - prevTotalBalance) === typedAmount;
}
+62 -63
View File
@@ -5,23 +5,22 @@ import {
always,
extract,
from,
keyedBy,
next,
now,
weighted,
whenRoute,
} from "@sanderling/spec";
import { defaultActions } from "@sanderling/spec/defaults";
import {
computeHomeTotalBalance,
parseTypedAmount,
submitChangesBalanceByTypedAmount,
} from "./predicates";
interface Account {
name: string;
balance: number;
}
interface LedgerRow {
key: string;
signed: number;
}
// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents.
function parseDollarCents(text: string | undefined): number {
if (!text) return 0;
@@ -31,8 +30,8 @@ function parseDollarCents(text: string | undefined): number {
}
// Route detection via testTag (resource-id on Android, accessibilityIdentifier on iOS)
const loggedIn = extract(s => s.ax.find({ testTag: "LoginScreen" }) == null);
const route = extract<string | null>(s => {
const loggedIn = extract("loggedIn", s => s.ax.find({ testTag: "LoginScreen" }) == null);
const route = extract<string | null>("route", s => {
if (s.ax.find({ testTag: "LoginScreen" })) return "login";
if (s.ax.find({ testTag: "AddAccountScreen" })) return "add-account";
if (s.ax.find({ testTag: "AddTransactionScreen" })) return "add-transaction";
@@ -42,45 +41,47 @@ const route = extract<string | null>(s => {
});
// Account cards on Home: identity is the AccountName text; balance comes from AccountBalance.
const accounts = extract<Account[]>(s =>
const accounts = extract<Account[]>("accounts", s =>
s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]).map(card => ({
name: card.find({ testTag: "AccountName" })?.text ?? "",
balance: parseDollarCents(card.find({ testTag: "AccountBalance" })?.text),
})));
// Ledger rows: identity composed from the row's stable testTag'd cells.
const ledgerRows = extract<LedgerRow[]>(s =>
s.ax.findAll([{ testTag: "LedgerScreen" }, { testTag: "LedgerRow" }]).map(row => ({
key: keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]),
signed: parseDollarCents(row.find({ testTag: "TxnAmount" })?.text),
})));
// Total balance: sum of AccountCard balances visible on Home. The carrier
// deliberately tracks only the Home multi-account total. Ledger's
// LedgerBalance is a single-account number on a different scale and would
// corrupt cross-screen comparisons if mixed in. Off-Home steps carry forward
// the last-seen Home sum so `previous` and `current` stay on the same scale.
let lastHomeTotal = 0;
const totalBalance = extract("totalBalance", s => {
const cards = s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]);
const cardBalanceTexts = cards.map(c => c.find({ testTag: "AccountBalance" })?.text);
lastHomeTotal = computeHomeTotalBalance({ cardBalanceTexts, previousCarrier: lastHomeTotal });
return lastHomeTotal;
});
const ledgerBalance = extract(s =>
parseDollarCents(s.ax.find({ testTag: "LedgerBalance" })?.text));
const lastAction = extract("lastAction", s => s.lastAction);
const focusedFieldTag = extract(s => s.ax.find({ focused: true })?.id ?? null);
const loginEmailField = extract(s =>
const loginEmailField = extract("loginEmailField", s =>
s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginEmail" }]));
const loginPasswordField = extract(s =>
const loginPasswordField = extract("loginPasswordField", s =>
s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginPassword" }]));
const loginSubmit = extract(s =>
const loginSubmit = extract("loginSubmit", s =>
s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginSubmit" }]));
const addAccountButton = extract(s =>
const addAccountButton = extract("addAccountButton", s =>
s.ax.find([{ testTag: "HomeScreen" }, { testTag: "AddAccountButton" }]));
const accountNameField = extract(s =>
const accountNameField = extract("accountNameField", s =>
s.ax.find([{ testTag: "AddAccountScreen" }, { testTag: "AccountNameField" }]));
const addAccountSubmit = extract(s =>
const addAccountSubmit = extract("addAccountSubmit", s =>
s.ax.find([{ testTag: "AddAccountScreen" }, { testTag: "AddAccountSubmit" }]));
const addTxnButton = extract(s =>
const addTxnButton = extract("addTxnButton", s =>
s.ax.find([{ testTag: "LedgerScreen" }, { testTag: "AddTransactionButton" }]));
const txnAmountField = extract(s =>
const txnAmountField = extract("txnAmountField", s =>
s.ax.find([{ testTag: "AddTransactionScreen" }, { testTag: "TxnAmountField" }]));
const txnSubmit = extract(s =>
const txnSubmit = extract("txnSubmit", s =>
s.ax.find([{ testTag: "AddTransactionScreen" }, { testTag: "TxnSubmit" }]));
const accountCards = extract(s =>
const accountCards = extract("accountCards", s =>
s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]));
const backButton = extract(s => s.ax.find({ testTag: "BackButton" }));
// Property 1: every newly-appearing account starts with balance === 0.
// Identity is by visible name. Guard against navigation transitions where
@@ -95,39 +96,39 @@ const newAccountBalanceIsZero = always(
})
);
// Property 2: a newly-added ledger row changes the ledger balance by exactly its signed amount.
const newTxnChangesBalance = always(
now(() => route.current === "ledger").implies(
next(() => {
const prev = ledgerRows.previous ?? [];
const curr = ledgerRows.current;
if (curr.length !== prev.length + 1) return true;
const prevKeys = new Set(prev.map(r => r.key));
const added = curr.find(r => !prevKeys.has(r.key));
if (!added) return true;
const delta = ledgerBalance.current - (ledgerBalance.previous ?? 0);
return delta === added.signed && delta !== 0;
})
)
// Property 2: a tap on TxnSubmit must move the total balance by exactly the
// typed amount. A double-submit lands two transactions, so the balance shifts
// by twice the typed amount and the check fires. The route gate inside the
// predicate skips off-Home landings where totalBalance.current is the carrier.
const submitMovesBalanceByTypedAmount = always(
next(() =>
submitChangesBalanceByTypedAmount({
route: route.current,
lastAction: lastAction.current,
typedAmount: parseTypedAmount(txnAmountField.previous?.text),
prevTotalBalance: totalBalance.previous ?? 0,
currTotalBalance: totalBalance.current,
}),
),
);
const DEMO_EMAIL = "[email protected]";
const DEMO_PASSWORD = "ledger123";
// Login: drive the form via focus state read from the native focused="true" attr.
// Login: drive the form by reading what's currently in each field, not by
// inferring intent from which one happens to be focused. A focus-driven
// approach loops forever if we re-enter the login screen with the password
// field already focused (e.g. after an exception bounces us back from
// another screen): it would type the password then tap submit with an
// empty email field, see no progress, and repeat indefinitely.
const login = actions(() => {
if (loggedIn.current) return [];
const focus = focusedFieldTag.current;
if (focus === "LoginPassword") {
const email = loginEmailField.current;
const pwd = loginPasswordField.current;
if (email && !email.text) return [InputText({ into: email, text: DEMO_EMAIL })];
if (pwd && !pwd.text) return [InputText({ into: pwd, text: DEMO_PASSWORD })];
const submit = loginSubmit.current;
return submit ? [Tap({ on: submit })] : [];
}
if (focus === "LoginEmail") {
const pwd = loginPasswordField.current;
return pwd ? [InputText({ into: pwd, text: DEMO_PASSWORD })] : [];
}
const email = loginEmailField.current;
return email ? [InputText({ into: email, text: DEMO_EMAIL })] : [];
});
const accountNames = from(["Checking", "Savings", "Travel", "Emergency Fund", "Investments"]);
@@ -165,22 +166,20 @@ const addTxn = whenRoute(route, ["home", "ledger", "add-transaction"], () => {
return opts;
});
const back = actions(() => {
const btn = backButton.current;
return btn ? [Tap({ on: btn })] : [];
});
export const properties = {
newAccountBalanceIsZero,
newTxnChangesBalance,
submitMovesBalanceByTypedAmount,
};
export const setup = login;
// Targeted depth (addAccount / addTxn) drives the deep flows; defaultActions
// adds breadth so the fuzzer wanders the whole app and types edge-case values
// into every field, stressing the balance invariants above.
export const actionsRoot = weighted(
[50, addAccount],
[40, addTxn],
[10, back],
[30, addTxn],
[20, defaultActions],
);
(globalThis as { actions?: unknown; properties?: unknown; setup?: unknown }).actions = actionsRoot;
-1
View File
@@ -1 +0,0 @@
@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/JetBrainsMono-Regular.woff2) format("woff2")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/JetBrainsMono-Medium.woff2) format("woff2")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/JetBrainsMono-Bold.woff2) format("woff2")}body{font-family:var(--font-mono);font-size:13px;line-height:1.5;color:var(--text-primary);background:var(--background);margin:0}:root{--background: #ffffff;--surface: #f7f7f7;--surface-elevated: #ffffff;--border: #e4e4e4;--border-strong: #c8c8c8;--text-primary: #111111;--text-muted: #6b6b6b;--accent-violation: #d32f2f;--accent-positive: #2e7d32;--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}:root[data-theme=dark],:root[data-theme=dark] body{--background: #0e0e0e;--surface: #161616;--surface-elevated: #1d1d1d;--border: #2a2a2a;--border-strong: #3a3a3a;--text-primary: #f0f0f0;--text-muted: #9a9a9a;--accent-violation: #f06b6b;--accent-positive: #7cc47c}@media (prefers-color-scheme: dark){:root:not([data-theme=light]){--background: #0e0e0e;--surface: #161616;--surface-elevated: #1d1d1d;--border: #2a2a2a;--border-strong: #3a3a3a;--text-primary: #f0f0f0;--text-muted: #9a9a9a;--accent-violation: #f06b6b;--accent-positive: #7cc47c}}body{background:var(--background);color:var(--text-primary);margin:0;font-family:var(--font-mono)}*,*:before,*:after{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{overflow:hidden}a{color:inherit;text-decoration:none}a:hover{text-decoration:underline}button{font:inherit;color:inherit;background:var(--surface);border:1px solid var(--border);padding:4px 10px;cursor:pointer}button:hover{border-color:var(--border-strong)}.app-shell{height:100vh;display:flex;flex-direction:column;overflow:hidden}.app-main{flex:1 1 auto;min-height:0;padding:0;display:flex;flex-direction:column}.app-main>*{flex:1 1 auto;min-height:0}.run-table{width:100%;border-collapse:collapse;align-self:flex-start;flex:0 0 auto}.run-table th,.run-table td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--border)}.run-table th{color:var(--text-muted);font-weight:500;font-size:12px;text-transform:uppercase;letter-spacing:.04em}.run-table tr:hover td{background:var(--surface)}.chip{display:inline-block;padding:2px 8px;border:1px solid var(--border-strong);border-radius:999px;font-size:11px}.chip-violation{border-color:var(--accent-violation);color:var(--accent-violation)}.chip-progress{border-color:var(--border-strong);color:var(--text-muted)}.detail-root{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden}.detail-toolbar{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;border-bottom:1px solid var(--border);flex:0 0 auto}.detail-toolbar-meta{display:flex;gap:16px;align-items:baseline;font-size:12px;color:var(--text-muted)}.detail-toolbar-meta strong{color:var(--text-primary);font-weight:500}.theme-toggle{display:inline-flex;align-items:center;justify-content:center;width:28px;height:24px;padding:0;color:var(--text-muted)}.theme-toggle:hover{color:var(--text-primary)}.theme-toggle svg{display:block}.detail-grid{flex:1 1 auto;display:grid;grid-template-columns:340px minmax(0,1fr) minmax(0,1fr);grid-template-rows:minmax(0,1fr) minmax(0,130px);grid-template-areas:"actions before after" "metrics metrics metrics";gap:0;padding:0;min-height:0;overflow:hidden}.detail-grid>.detail-actions{grid-area:actions;min-height:0;overflow:hidden}.detail-grid>.detail-state-before,.detail-grid>.detail-state-after{display:flex;flex-direction:column;gap:0;min-height:0;min-width:0;overflow:hidden}.detail-grid>.detail-state-before{grid-area:before}.detail-grid>.detail-state-after{grid-area:after}.detail-grid>.detail-actions,.detail-grid>.detail-state-before{border-right:1px solid var(--border)}.detail-grid>.detail-state-before .screenshot-frame,.detail-grid>.detail-state-after .screenshot-frame,.detail-grid>.detail-state-before .screenshot-placeholder,.detail-grid>.detail-state-after .screenshot-placeholder{flex:1 1 auto;max-height:100%;max-width:100%;margin:0 auto;min-height:0}.detail-grid>.detail-metrics{grid-area:metrics;overflow:hidden;display:flex;flex-direction:column;min-height:0;border-top:1px solid var(--border)}.detail-grid>.detail-metrics .metrics-chart{flex:1 1 auto;min-height:0;max-height:100%}.detail-metrics .detail-panel-body{overflow:hidden}.detail-metrics-exceptions{padding:4px 8px;border-top:1px solid var(--border);overflow:auto;max-height:80px}.detail-panel{border:none;background:transparent;padding:0;min-height:0;display:flex;flex-direction:column}.detail-panel h2{margin:0;padding:4px 8px;font-size:10px;font-weight:500;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid var(--border);flex:0 0 auto}.detail-panel-body{flex:1 1 auto;mLine truncated
-67
View File
@@ -1,67 +0,0 @@
function wf(e,t){for(var n=0;n<t.length;n++){const r=t[n];if(typeof r!="string"&&!Array.isArray(r)){for(const l in r)if(l!=="default"&&!(l in e)){const i=Object.getOwnPropertyDescriptor(r,l);i&&Object.defineProperty(e,l,i.get?i:{enumerable:!0,get:()=>r[l]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();function Sf(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Vu={exports:{}},yl={},Bu={exports:{}},O={};/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var sr=Symbol.for("react.element"),kf=Symbol.for("react.portal"),Ef=Symbol.for("react.fragment"),Nf=Symbol.for("react.strict_mode"),Cf=Symbol.for("react.profiler"),jf=Symbol.for("react.provider"),_f=Symbol.for("react.context"),Pf=Symbol.for("react.forward_ref"),Tf=Symbol.for("react.suspense"),Lf=Symbol.for("react.memo"),Rf=Symbol.for("react.lazy"),cs=Symbol.iterator;function zf(e){return e===null||typeof e!="object"?null:(e=cs&&e[cs]||e["@@iterator"],typeof e=="function"?e:null)}var Wu={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Hu=Object.assign,Qu={};function mn(e,t,n){this.props=e,this.context=t,this.refs=Qu,this.updater=n||Wu}mn.prototype.isReactComponent={};mn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};mn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Ku(){}Ku.prototype=mn.prototype;function uo(e,t,n){this.props=e,this.context=t,this.refs=Qu,this.updater=n||Wu}var ao=uo.prototype=new Ku;ao.constructor=uo;Hu(ao,mn.prototype);ao.isPureReactComponent=!0;var fs=Array.isArray,Gu=Object.prototype.hasOwnProperty,co={current:null},Ju={key:!0,ref:!0,__self:!0,__source:!0};function Yu(e,t,n){var r,l={},i=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(i=""+t.key),t)Gu.call(t,r)&&!Ju.hasOwnProperty(r)&&(l[r]=t[r]);var s=arguments.length-2;if(s===1)l.children=n;else if(1<s){for(var u=Array(s),a=0;a<s;a++)u[a]=arguments[a+2];l.children=u}if(e&&e.defaultProps)for(r in s=e.defaultProps,s)l[r]===void 0&&(l[r]=s[r]);return{$$typeof:sr,type:e,key:i,ref:o,props:l,_owner:co.current}}function If(e,t){return{$$typeof:sr,type:e.type,key:t,ref:e.ref,props:e.props,_owner:e._owner}}function fo(e){return typeof e=="object"&&e!==null&&e.$$typeof===sr}function Of(e){var t={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,function(n){return t[n]})}var ds=/\/+/g;function $l(e,t){return typeof e=="object"&&e!==null&&e.key!=null?Of(""+e.key):t.toString(36)}function Mr(e,t,n,r,l){var i=typeof e;(i==="undefined"||i==="boolean")&&(e=null);var o=!1;if(e===null)o=!0;else switch(i){case"string":case"number":o=!0;break;case"object":switch(e.$$typeof){case sr:case kf:o=!0}}if(o)return o=e,l=l(o),e=r===""?"."+$l(o,0):r,fs(l)?(n="",e!=null&&(n=e.replace(ds,"$&/")+"/"),Mr(l,t,n,"",function(a){return a})):l!=null&&(fo(l)&&(l=If(l,n+(!l.key||o&&o.key===l.key?"":(""+l.key).replace(ds,"$&/")+"/")+e)),t.push(l)),1;if(o=0,r=r===""?".":r+":",fs(e))for(var s=0;s<e.length;s++){i=e[s];var u=r+$l(i,s);o+=Mr(i,t,n,u,l)}else if(u=zf(e),typeof u=="function")for(e=u.call(e),s=0;!(i=e.next()).done;)i=i.value,u=r+$l(i,s++),o+=Mr(i,t,n,u,l);else if(i==="object")throw t=String(e),Error("Objects are not valid as a React child (found: "+(t==="[object Object]"?"object with keys {"+Object.keys(e).join(", ")+"}":t)+"). If you meant to render a collection of children, use an array instead.");return o}function vr(e,t,n){if(e==null)return e;var r=[],l=0;return Mr(e,r,"","",function(i){return t.call(n,i,l++)}),r}function Mf(e){if(e._status===-1){var t=e._result;t=t(),t.then(function(n){(e._status===0||e._status===-1)&&(e._status=1,e._result=n)},function(n){(e._status===0||e._status===-1)&&(e._status=2,e._result=n)}),e._status===-1&&(e._status=0,e._result=t)}if(e._status===1)return e._result.default;throw e._result}var de={current:null},Fr={transition:null},Ff={ReactCurrentDispatcher:de,ReactCurrentBatchConfig:Fr,ReactCurrentOwner:co};function Xu(){throw Error("act(...) is not supported in production builds of React.")}O.Children={map:vr,forEach:function(e,t,n){vr(e,function(){t.apply(this,arguments)},n)},count:function(e){var t=0;return vr(e,function(){t++}),t},toArray:function(e){return vr(e,function(t){return t})||[]},only:function(e){if(!fo(e))throw Error("React.Children.only expected to receive a single React element child.");return e}};O.Component=mn;O.Fragment=Ef;O.Profiler=Cf;O.PureComponent=uo;O.StrictMode=Nf;O.Suspense=Tf;O.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=Ff;O.act=Xu;O.cloneElement=function(e,t,n){if(e==null)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+e+".");var r=Hu({},e.props),l=e.key,i=e.ref,o=e._owner;if(t!=null){if(t.ref!==void 0&&(i=t.ref,o=co.current),t.key!==void 0&&(l=""+t.key),e.type&&e.type.defaultProps)var s=e.type.defaultProps;for(u in t)Gu.call(t,u)&&!Ju.hasOwnProperty(u)&&(r[u]=t[u]===void 0&&s!==void 0?s[u]:t[u])}var u=arguments.length-2;if(u===1)r.children=n;else if(1<u){s=Array(u);for(var a=0;a<u;a++)s[a]=arguments[a+2];r.children=s}return{$$typeof:sr,type:e.type,key:l,ref:i,props:r,_owner:o}};O.createContext=function(e){return e={$$typeof:_f,_currentValue:e,_currentValue2:e,_threadCount:0,ProviderLine truncated
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var $f=S,Uf=Symbol.for("react.element"),Af=Symbol.for("react.fragment"),Vf=Object.prototype.hasOwnProperty,Bf=$f.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,Wf={key:!0,ref:!0,__self:!0,__source:!0};function qu(e,t,n){var r,l={},i=null,o=null;n!==void 0&&(i=""+n),t.key!==void 0&&(i=""+t.key),t.ref!==void 0&&(o=t.ref);for(r in t)Vf.call(t,r)&&!Wf.hasOwnProperty(r)&&(l[r]=t[r]);if(e&&e.defaultProps)for(r in t=e.defaultProps,t)l[r]===void 0&&(l[r]=t[r]);return{$$typeof:Uf,type:e,key:i,ref:o,props:l,_owner:Bf.current}}yl.Fragment=Af;yl.jsx=qu;yl.jsxs=qu;Vu.exports=yl;var c=Vu.exports,bu={exports:{}},Ee={},ea={exports:{}},ta={};/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/(function(e){function t(T,z){var I=T.length;T.push(z);e:for(;0<I;){var K=I-1>>>1,ee=T[K];if(0<l(ee,z))T[K]=z,T[I]=ee,I=K;else break e}}function n(T){return T.length===0?null:T[0]}function r(T){if(T.length===0)return null;var z=T[0],I=T.pop();if(I!==z){T[0]=I;e:for(var K=0,ee=T.length,hr=ee>>>1;K<hr;){var Et=2*(K+1)-1,Dl=T[Et],Nt=Et+1,mr=T[Nt];if(0>l(Dl,I))Nt<ee&&0>l(mr,Dl)?(T[K]=mr,T[Nt]=I,K=Nt):(T[K]=Dl,T[Et]=I,K=Et);else if(Nt<ee&&0>l(mr,I))T[K]=mr,T[Nt]=I,K=Nt;else break e}}return z}function l(T,z){var I=T.sortIndex-z.sortIndex;return I!==0?I:T.id-z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var u=[],a=[],h=1,f=null,d=3,y=!1,g=!1,w=!1,C=typeof setTimeout=="function"?setTimeout:null,m=typeof clearTimeout=="function"?clearTimeout:null,p=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function v(T){for(var z=n(a);z!==null;){if(z.callback===null)r(a);else if(z.startTime<=T)r(a),z.sortIndex=z.expirationTime,t(u,z);else break;z=n(a)}}function x(T){if(w=!1,v(T),!g)if(n(u)!==null)g=!0,Ml(E);else{var z=n(a);z!==null&&Fl(x,z.startTime-T)}}function E(T,z){g=!1,w&&(w=!1,m(N),N=-1),y=!0;var I=d;try{for(v(z),f=n(u);f!==null&&(!(f.expirationTime>z)||T&&!X());){var K=f.callback;if(typeof K=="function"){f.callback=null,d=f.priorityLevel;var ee=K(f.expirationTime<=z);z=e.unstable_now(),typeof ee=="function"?f.callback=ee:f===n(u)&&r(u),v(z)}else r(u);f=n(u)}if(f!==null)var hr=!0;else{var Et=n(a);Et!==null&&Fl(x,Et.startTime-z),hr=!1}return hr}finally{f=null,d=I,y=!1}}var _=!1,P=null,N=-1,R=5,L=-1;function X(){return!(e.unstable_now()-L<R)}function Q(){if(P!==null){var T=e.unstable_now();L=T;var z=!0;try{z=P(!0,T)}finally{z?b():(_=!1,P=null)}}else _=!1}var b;if(typeof p=="function")b=function(){p(Q)};else if(typeof MessageChannel<"u"){var pr=new MessageChannel,xf=pr.port2;pr.port1.onmessage=Q,b=function(){xf.postMessage(null)}}else b=function(){C(Q,0)};function Ml(T){P=T,_||(_=!0,b())}function Fl(T,z){N=C(function(){T(e.unstable_now())},z)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(T){T.callback=null},e.unstable_continueExecution=function(){g||y||(g=!0,Ml(E))},e.unstable_forceFrameRate=function(T){0>T||125<T?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):R=0<T?Math.floor(1e3/T):5},e.unstable_getCurrentPriorityLevel=function(){return d},e.unstable_getFirstCallbackNode=function(){return n(u)},e.unstable_next=function(T){switch(d){case 1:case 2:case 3:var z=3;break;default:z=d}var I=d;d=z;try{return T()}finally{d=I}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(T,z){switch(T){case 1:case 2:case 3:case 4:case 5:break;default:T=3}var I=d;d=T;try{return z()}finally{d=I}},e.unstable_scheduleCallback=function(T,z,I){var K=e.unstable_now();switch(typeof I=="object"&&I!==null?(I=I.delay,I=typeof I=="number"&&0<I?K+I:K):I=K,T){case 1:var ee=-1;break;case 2:ee=250;break;case 5:ee=1073741823;break;case 4:ee=1e4;break;default:ee=5e3}return ee=I+ee,T={id:h++,callback:z,priorityLevel:T,startTime:I,expirationTime:ee,sortIndex:-1},I>K?(T.sortIndex=I,t(a,T),n(u)===null&&T===n(a)&&(w?(m(N),N=-1):w=!0,Fl(x,I-K))):(T.sortIndex=ee,t(u,T),g||y||(g=!0,Ml(E))),T},e.unstable_shouldYield=X,e.unstable_wrapCallback=function(T){var z=d;return function(){var I=d;d=z;try{return T.apply(this,arguments)}finally{d=I}}}})(ta);ea.exports=ta;var Hf=ea.exports;/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Qf=S,ke=Hf;function k(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n<arguments.length;n++)t+="&args[]="+encodeURIComponent(arguments[n]);return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}var na=new Set,Bn={};function Dt(e,t){un(e,t),un(e+"Capture",t)}function un(e,t){for(Bn[e]=t,e=0;e<t.length;e++)na.add(t[e])}var Je=!(typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),fi=Object.prototype.hasOwnProperty,Kf=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,ps={},hs={};function Gf(e){return fi.call(hs,e)?!0:fi.call(ps,e)?!1:Kf.test(e)?hs[e]=!0:(ps[e]=!0,!1)}function Jf(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Yf(e,t,n,r){if(t===null||typeof t>"u"||Jf(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function pe(e,t,n,r,l,i,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var ie={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ie[e]=new pe(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ie[t]=new pe(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ie[e]=new pe(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ie[e]=new pe(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ie[e]=new pe(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ie[e]=new pe(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ie[e]=new pe(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ie[e]=new pe(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ie[e]=new pe(e,5,!1,e.toLowerCase(),null,!1,!1)});var po=/[\-:]([a-z])/g;function ho(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(po,ho);ie[t]=new pe(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(po,ho);ie[t]=new pe(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(po,ho);ie[t]=new pe(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ie[e]=new pe(e,1,!1,e.toLowerCase(),null,!1,!1)});ie.xlinkHref=new pe("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ie[e]=new pe(e,1,!1,e.toLowerCase(),null,!0,!0)});function mo(e,t,n,r){var l=ie.hasOwnProperty(t)?ie[t]:null;(l!==null?l.type!==0:r||!(2<t.length)||t[0]!=="o"&&t[0]!==" Line truncated
`+Ul+e}var Al=!1;function Vl(e,t){if(!e||Al)return"";Al=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(t)if(t=function(){throw Error()},Object.defineProperty(t.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(t,[])}catch(a){var r=a}Reflect.construct(e,[],t)}else{try{t.call()}catch(a){r=a}e.call(t.prototype)}else{try{throw Error()}catch(a){r=a}e()}}catch(a){if(a&&r&&typeof a.stack=="string"){for(var l=a.stack.split(`
`),i=r.stack.split(`
`),o=l.length-1,s=i.length-1;1<=o&&0<=s&&l[o]!==i[s];)s--;for(;1<=o&&0<=s;o--,s--)if(l[o]!==i[s]){if(o!==1||s!==1)do if(o--,s--,0>s||l[o]!==i[s]){var u=`
`+l[o].replace(" at new "," at ");return e.displayName&&u.includes("<anonymous>")&&(u=u.replace("<anonymous>",e.displayName)),u}while(1<=o&&0<=s);break}}}finally{Al=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Pn(e):""}function Xf(e){switch(e.tag){case 5:return Pn(e.type);case 16:return Pn("Lazy");case 13:return Pn("Suspense");case 19:return Pn("SuspenseList");case 0:case 2:case 15:return e=Vl(e.type,!1),e;case 11:return e=Vl(e.type.render,!1),e;case 1:return e=Vl(e.type,!0),e;default:return""}}function mi(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Wt:return"Fragment";case Bt:return"Portal";case di:return"Profiler";case vo:return"StrictMode";case pi:return"Suspense";case hi:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case la:return(e.displayName||"Context")+".Consumer";case ra:return(e._context.displayName||"Context")+".Provider";case go:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case yo:return t=e.displayName||null,t!==null?t:mi(e.type)||"Memo";case tt:t=e._payload,e=e._init;try{return mi(e(t))}catch{}}return null}function Zf(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return mi(t);case 8:return t===vo?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function gt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function oa(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function qf(e){var t=oa(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,i.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function yr(e){e._valueTracker||(e._valueTracker=qf(e))}function sa(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=oa(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Gr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function vi(e,t){var n=t.checked;return W({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function vs(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=gt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function ua(e,t){t=t.checked,t!=null&&mo(e,"checked",t,!1)}function gi(e,t){ua(e,t);var n=gt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?yi(e,t.type,n):t.hasOwnProperty("defaultValue")&&yi(e,t.type,gt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function gs(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function yi(e,t,n){(t!=="number"||Gr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Tn=Array.isArray;function tn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l<n.length;l++)t["$"+n[l]]=!0;for(n=0;n<e.length;n++)l=t.hasOwnProperty("$"+e[n].value),e[n].selected!==l&&(e[n].selected=l),l&&r&&(e[n].defaultSelected=!0)}else{for(n=""+gt(n),t=null,l=0;l<e.length;l++){if(e[l].value===n){e[l].selected=!0,r&&(e[l].defaultSelected=!0);return}t!==null||e[l].disabled||(t=e[l])}t!==null&&(t.selected=!0)}}function xi(e,t){if(t.dang Line truncated
`).replace(up,"")}function jr(e,t,n){if(t=As(t),As(e)!==t&&n)throw Error(k(425))}function tl(){}var zi=null,Ii=null;function Oi(e,t){return e==="textarea"||e==="noscript"||typeof t.children=="string"||typeof t.children=="number"||typeof t.dangerouslySetInnerHTML=="object"&&t.dangerouslySetInnerHTML!==null&&t.dangerouslySetInnerHTML.__html!=null}var Mi=typeof setTimeout=="function"?setTimeout:void 0,ap=typeof clearTimeout=="function"?clearTimeout:void 0,Vs=typeof Promise=="function"?Promise:void 0,cp=typeof queueMicrotask=="function"?queueMicrotask:typeof Vs<"u"?function(e){return Vs.resolve(null).then(e).catch(fp)}:Mi;function fp(e){setTimeout(function(){throw e})}function ei(e,t){var n=t,r=0;do{var l=n.nextSibling;if(e.removeChild(n),l&&l.nodeType===8)if(n=l.data,n==="/$"){if(r===0){e.removeChild(l),Gn(t);return}r--}else n!=="$"&&n!=="$?"&&n!=="$!"||r++;n=l}while(n);Gn(t)}function ft(e){for(;e!=null;e=e.nextSibling){var t=e.nodeType;if(t===1||t===3)break;if(t===8){if(t=e.data,t==="$"||t==="$!"||t==="$?")break;if(t==="/$")return null}}return e}function Bs(e){e=e.previousSibling;for(var t=0;e;){if(e.nodeType===8){var n=e.data;if(n==="$"||n==="$!"||n==="$?"){if(t===0)return e;t--}else n==="/$"&&t++}e=e.previousSibling}return null}var gn=Math.random().toString(36).slice(2),Ae="__reactFiber$"+gn,qn="__reactProps$"+gn,Ye="__reactContainer$"+gn,Fi="__reactEvents$"+gn,dp="__reactListeners$"+gn,pp="__reactHandles$"+gn;function _t(e){var t=e[Ae];if(t)return t;for(var n=e.parentNode;n;){if(t=n[Ye]||n[Ae]){if(n=t.alternate,t.child!==null||n!==null&&n.child!==null)for(e=Bs(e);e!==null;){if(n=e[Ae])return n;e=Bs(e)}return t}e=n,n=e.parentNode}return null}function cr(e){return e=e[Ae]||e[Ye],!e||e.tag!==5&&e.tag!==6&&e.tag!==13&&e.tag!==3?null:e}function Gt(e){if(e.tag===5||e.tag===6)return e.stateNode;throw Error(k(33))}function El(e){return e[qn]||null}var Di=[],Jt=-1;function wt(e){return{current:e}}function U(e){0>Jt||(e.current=Di[Jt],Di[Jt]=null,Jt--)}function D(e,t){Jt++,Di[Jt]=e.current,e.current=t}var yt={},ae=wt(yt),ve=wt(!1),zt=yt;function an(e,t){var n=e.type.contextTypes;if(!n)return yt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function ge(e){return e=e.childContextTypes,e!=null}function nl(){U(ve),U(ae)}function Ws(e,t,n){if(ae.current!==yt)throw Error(k(168));D(ae,t),D(ve,n)}function Xa(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(k(108,Zf(e)||"Unknown",l));return W({},n,r)}function rl(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||yt,zt=ae.current,D(ae,e),D(ve,ve.current),!0}function Hs(e,t,n){var r=e.stateNode;if(!r)throw Error(k(169));n?(e=Xa(e,t,zt),r.__reactInternalMemoizedMergedChildContext=e,U(ve),U(ae),D(ae,e)):U(ve),D(ve,n)}var He=null,Nl=!1,ti=!1;function Za(e){He===null?He=[e]:He.push(e)}function hp(e){Nl=!0,Za(e)}function St(){if(!ti&&He!==null){ti=!0;var e=0,t=F;try{var n=He;for(F=1;e<n.length;e++){var r=n[e];do r=r(!0);while(r!==null)}He=null,Nl=!1}catch(l){throw He!==null&&(He=He.slice(e+1)),ka(wo,St),l}finally{F=t,ti=!1}}return null}var Yt=[],Xt=0,ll=null,il=0,Ce=[],je=0,It=null,Qe=1,Ke="";function Ct(e,t){Yt[Xt++]=il,Yt[Xt++]=ll,ll=e,il=t}function qa(e,t,n){Ce[je++]=Qe,Ce[je++]=Ke,Ce[je++]=It,It=e;var r=Qe;e=Ke;var l=32-Me(r)-1;r&=~(1<<l),n+=1;var i=32-Me(t)+l;if(30<i){var o=l-l%5;i=(r&(1<<o)-1).toString(32),r>>=o,l-=o,Qe=1<<32-Me(t)+l|n<<l|r,Ke=i+e}else Qe=1<<i|n<<l|r,Ke=e}function To(e){e.return!==null&&(Ct(e,1),qa(e,1,0))}function Lo(e){for(;e===ll;)ll=Yt[--Xt],Yt[Xt]=null,il=Yt[--Xt],Yt[Xt]=null;for(;e===It;)It=Ce[--je],Ce[je]=null,Ke=Ce[--je],Ce[je]=null,Qe=Ce[--je],Ce[je]=null}var Se=null,we=null,A=!1,Oe=null;function ba(e,t){var n=_e(5,null,null,0);n.elementType="DELETED",n.stateNode=t,n.return=e,t=e.deletions,t===null?(e.deletions=[n],e.flags|=16):t.push(n)}function Qs(e,t){switch(e.tag){case 5:var n=e.type;return t=t.nodeType!==1||n.toLowerCase()!==t.nodeName.toLowerCase()?null:t,t!==null?(e.stateNode=t,Se=e,we=ft(t.firstChild),!0):!1;case 6:return t=e.pendingProps===""||t.nodeType!==3?null:t,t!==null?(e.stateNode=t,Se=e,we=null,!0):!1;case 13:return t=t.nodeType!==8?null:t,t!==null?(n=It!==null?{id:Qe,overflow:Ke}:null,e.memoizedState={dehydrated:t,treeContext:n,retryLane:1073741824},n=_e(18,null,null,0),n.stateNode=t,n.return=e,e.child=n,Se=e,we=null,!0):!1;default:return!1}}function $i(e){return(e.mode&1)!==0&&(e.flags&128)===0}function Ui(e){if(A){var t=we;if(t){var n=t;if(!Qs(e,t)){if($i(e))throw Error(k(418));t=ft(n.nextSibling);var r=Se;t&&Qs(e,t)?ba(r,n):(e.flags=e.flags&-4097|2,A=!1,Se=e)}}else{if($i(e))throw Error(k(418));e.flags=e.flags&-4097|2,A=!1,Se=e}}}function Ks(e){for(e=e.return;e!==null&&e.t Line truncated
Error generating stack: `+i.message+`
`+i.stack}return{value:e,source:t,stack:l,digest:null}}function oi(e,t,n){return{value:e,source:null,stack:n??null,digest:t??null}}function Wi(e,t){try{console.error(t.value)}catch(n){setTimeout(function(){throw n})}}var Ep=typeof WeakMap=="function"?WeakMap:Map;function jc(e,t,n){n=Ge(-1,n),n.tag=3,n.payload={element:null};var r=t.value;return n.callback=function(){pl||(pl=!0,bi=r),Wi(e,t)},n}function _c(e,t,n){n=Ge(-1,n),n.tag=3;var r=e.type.getDerivedStateFromError;if(typeof r=="function"){var l=t.value;n.payload=function(){return r(l)},n.callback=function(){Wi(e,t)}}var i=e.stateNode;return i!==null&&typeof i.componentDidCatch=="function"&&(n.callback=function(){Wi(e,t),typeof r!="function"&&(pt===null?pt=new Set([this]):pt.add(this));var o=t.stack;this.componentDidCatch(t.value,{componentStack:o!==null?o:""})}),n}function eu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Ep;var l=new Set;r.set(t,l)}else l=r.get(t),l===void 0&&(l=new Set,r.set(t,l));l.has(n)||(l.add(n),e=Dp.bind(null,e,t,n),t.then(e,e))}function tu(e){do{var t;if((t=e.tag===13)&&(t=e.memoizedState,t=t!==null?t.dehydrated!==null:!0),t)return e;e=e.return}while(e!==null);return null}function nu(e,t,n,r,l){return e.mode&1?(e.flags|=65536,e.lanes=l,e):(e===t?e.flags|=65536:(e.flags|=128,n.flags|=131072,n.flags&=-52805,n.tag===1&&(n.alternate===null?n.tag=17:(t=Ge(-1,1),t.tag=2,dt(n,t,1))),n.lanes|=1),e)}var Np=qe.ReactCurrentOwner,me=!1;function ce(e,t,n,r){t.child=e===null?nc(t,null,n,r):fn(t,e.child,n,r)}function ru(e,t,n,r,l){n=n.render;var i=t.ref;return on(t,l),r=Vo(e,t,n,r,i,l),n=Bo(),e!==null&&!me?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Ze(e,t,l)):(A&&n&&To(t),t.flags|=1,ce(e,t,r,l),t.child)}function lu(e,t,n,r,l){if(e===null){var i=n.type;return typeof i=="function"&&!qo(i)&&i.defaultProps===void 0&&n.compare===null&&n.defaultProps===void 0?(t.tag=15,t.type=i,Pc(e,t,i,r,l)):(e=Kr(n.type,null,r,t,t.mode,l),e.ref=t.ref,e.return=t,t.child=e)}if(i=e.child,!(e.lanes&l)){var o=i.memoizedProps;if(n=n.compare,n=n!==null?n:Yn,n(o,r)&&e.ref===t.ref)return Ze(e,t,l)}return t.flags|=1,e=mt(i,r),e.ref=t.ref,e.return=t,t.child=e}function Pc(e,t,n,r,l){if(e!==null){var i=e.memoizedProps;if(Yn(i,r)&&e.ref===t.ref)if(me=!1,t.pendingProps=r=i,(e.lanes&l)!==0)e.flags&131072&&(me=!0);else return t.lanes=e.lanes,Ze(e,t,l)}return Hi(e,t,n,r,l)}function Tc(e,t,n){var r=t.pendingProps,l=r.children,i=e!==null?e.memoizedState:null;if(r.mode==="hidden")if(!(t.mode&1))t.memoizedState={baseLanes:0,cachePool:null,transitions:null},D(bt,xe),xe|=n;else{if(!(n&1073741824))return e=i!==null?i.baseLanes|n:n,t.lanes=t.childLanes=1073741824,t.memoizedState={baseLanes:e,cachePool:null,transitions:null},t.updateQueue=null,D(bt,xe),xe|=e,null;t.memoizedState={baseLanes:0,cachePool:null,transitions:null},r=i!==null?i.baseLanes:n,D(bt,xe),xe|=r}else i!==null?(r=i.baseLanes|n,t.memoizedState=null):r=n,D(bt,xe),xe|=r;return ce(e,t,l,n),t.child}function Lc(e,t){var n=t.ref;(e===null&&n!==null||e!==null&&e.ref!==n)&&(t.flags|=512,t.flags|=2097152)}function Hi(e,t,n,r,l){var i=ge(n)?zt:ae.current;return i=an(t,i),on(t,l),n=Vo(e,t,n,r,i,l),r=Bo(),e!==null&&!me?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Ze(e,t,l)):(A&&r&&To(t),t.flags|=1,ce(e,t,n,l),t.child)}function iu(e,t,n,r,l){if(ge(n)){var i=!0;rl(t)}else i=!1;if(on(t,l),t.stateNode===null)Wr(e,t),Cc(t,n,r),Bi(t,n,r,l),r=!0;else if(e===null){var o=t.stateNode,s=t.memoizedProps;o.props=s;var u=o.context,a=n.contextType;typeof a=="object"&&a!==null?a=Te(a):(a=ge(n)?zt:ae.current,a=an(t,a));var h=n.getDerivedStateFromProps,f=typeof h=="function"||typeof o.getSnapshotBeforeUpdate=="function";f||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(s!==r||u!==a)&&bs(t,o,r,a),nt=!1;var d=t.memoizedState;o.state=d,ul(t,r,o,l),u=t.memoizedState,s!==r||d!==u||ve.current||nt?(typeof h=="function"&&(Vi(t,n,h,r),u=t.memoizedState),(s=nt||qs(t,n,s,r,d,u,a))?(f||typeof o.UNSAFE_componentWillMount!="function"&&typeof o.componentWillMount!="function"||(typeof o.componentWillMount=="function"&&o.componentWillMount(),typeof o.UNSAFE_componentWillMount=="function"&&o.UNSAFE_componentWillMount()),typeof o.componentDidMount=="function"&&(t.flags|=4194308)):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=r,t.memoizedState=u),o.props=r,o.state=u,o.context=a,r=s):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),r=!1)}else{o=t.stateNode,lc(e,t),s=t.memoizedProps,a=t.type===t.elementType?s:ze(t.type,s),o.props=a,f=t.pendingProps,d=o.context,u=n.contextType,typeof u=="object"&&u!==null?u=Te(u):(u=ge(n)?zt:ae.current,u=an(t,u));var y=n.getDerivedStateFromProps;(h=typeof y=="function"||typeof o.getSnapshotBeforeUpdate=="function")||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(s!==f||d!==u)&&bs(t,o,r,u),nt=!1,d=t.memoizedState,o.state=d,ul(t,r,o,l);var g=t.memoizedState;s Line truncated
* @remix-run/router v1.23.2
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function ir(){return ir=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},ir.apply(this,arguments)}var st;(function(e){e.Pop="POP",e.Push="PUSH",e.Replace="REPLACE"})(st||(st={}));const xu="popstate";function Yp(e){e===void 0&&(e={});function t(r,l){let{pathname:i,search:o,hash:s}=r.location;return ro("",{pathname:i,search:o,hash:s},l.state&&l.state.usr||null,l.state&&l.state.key||"default")}function n(r,l){return typeof l=="string"?l:gl(l)}return Zp(t,n,null,e)}function J(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function rs(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function Xp(){return Math.random().toString(36).substr(2,8)}function wu(e,t){return{usr:e.state,key:e.key,idx:t}}function ro(e,t,n,r){return n===void 0&&(n=null),ir({pathname:typeof e=="string"?e:e.pathname,search:"",hash:""},typeof t=="string"?yn(t):t,{state:n,key:t&&t.key||r||Xp()})}function gl(e){let{pathname:t="/",search:n="",hash:r=""}=e;return n&&n!=="?"&&(t+=n.charAt(0)==="?"?n:"?"+n),r&&r!=="#"&&(t+=r.charAt(0)==="#"?r:"#"+r),t}function yn(e){let t={};if(e){let n=e.indexOf("#");n>=0&&(t.hash=e.substr(n),e=e.substr(0,n));let r=e.indexOf("?");r>=0&&(t.search=e.substr(r),e=e.substr(0,r)),e&&(t.pathname=e)}return t}function Zp(e,t,n,r){r===void 0&&(r={});let{window:l=document.defaultView,v5Compat:i=!1}=r,o=l.history,s=st.Pop,u=null,a=h();a==null&&(a=0,o.replaceState(ir({},o.state,{idx:a}),""));function h(){return(o.state||{idx:null}).idx}function f(){s=st.Pop;let C=h(),m=C==null?null:C-a;a=C,u&&u({action:s,location:w.location,delta:m})}function d(C,m){s=st.Push;let p=ro(w.location,C,m);a=h()+1;let v=wu(p,a),x=w.createHref(p);try{o.pushState(v,"",x)}catch(E){if(E instanceof DOMException&&E.name==="DataCloneError")throw E;l.location.assign(x)}i&&u&&u({action:s,location:w.location,delta:1})}function y(C,m){s=st.Replace;let p=ro(w.location,C,m);a=h();let v=wu(p,a),x=w.createHref(p);o.replaceState(v,"",x),i&&u&&u({action:s,location:w.location,delta:0})}function g(C){let m=l.location.origin!=="null"?l.location.origin:l.location.href,p=typeof C=="string"?C:gl(C);return p=p.replace(/ $/,"%20"),J(m,"No window.location.(origin|href) available to create URL for href: "+p),new URL(p,m)}let w={get action(){return s},get location(){return e(l,o)},listen(C){if(u)throw new Error("A history only accepts one active listener");return l.addEventListener(xu,f),u=C,()=>{l.removeEventListener(xu,f),u=null}},createHref(C){return t(l,C)},createURL:g,encodeLocation(C){let m=g(C);return{pathname:m.pathname,search:m.search,hash:m.hash}},push:d,replace:y,go(C){return o.go(C)}};return w}var Su;(function(e){e.data="data",e.deferred="deferred",e.redirect="redirect",e.error="error"})(Su||(Su={}));function qp(e,t,n){return n===void 0&&(n="/"),bp(e,t,n)}function bp(e,t,n,r){let l=typeof t=="string"?yn(t):t,i=ls(l.pathname||"/",n);if(i==null)return null;let o=tf(e);eh(o);let s=null;for(let u=0;s==null&&u<o.length;++u){let a=dh(i);s=ah(o[u],a)}return s}function tf(e,t,n,r){t===void 0&&(t=[]),n===void 0&&(n=[]),r===void 0&&(r="");let l=(i,o,s)=>{let u={relativePath:s===void 0?i.path||"":s,caseSensitive:i.caseSensitive===!0,childrenIndex:o,route:i};u.relativePath.startsWith("/")&&(J(u.relativePath.startsWith(r),'Absolute route path "'+u.relativePath+'" nested under path '+('"'+r+'" is not valid. An absolute child route path ')+"must start with the combined path of all its parent routes."),u.relativePath=u.relativePath.slice(r.length));let a=vt([r,u.relativePath]),h=n.concat(u);i.children&&i.children.length>0&&(J(i.index!==!0,"Index routes must not have child routes. Please remove "+('all child routes from route path "'+a+'".')),tf(i.children,t,h,a)),!(i.path==null&&!i.index)&&t.push({path:a,score:sh(a,i.index),routesMeta:h})};return e.forEach((i,o)=>{var s;if(i.path===""||!((s=i.path)!=null&&s.includes("?")))l(i,o);else for(let u of nf(i.path))l(i,o,u)}),t}function nf(e){let t=e.split("/");if(t.length===0)return[];let[n,...r]=t,l=n.endsWith("?"),i=n.replace(/\?$/,"");if(r.length===0)return l?[i,""]:[i];let o=nf(r.join("/")),s=[];return s.push(...o.map(u=>u===""?i:[i,u].join("/"))),l&&s.push(...o),s.map(u=>e.startsWith("/")&&u===""?"/":u)}function eh(e){e.sort((t,n)=>t.score!==n.score?n.score-t.score:uh(t.routesMeta.map(r=>r.childrenIndex),n.routesMeta.map(r=>r.childrenIndex)))}const th=/^:[\w-]+$/,nh=3,rh=2,lh=1,ih=10,oh=-2,ku=e=>e==="*";function sh(e,t){let n=e.split("/"),r=n.length;return n.some(ku)&&(r+=oh),t&&(r+=rh),n.filter(l=>!ku(l)).reduce((l,i)=>l+(th.test(i)?nh:i===""?lh:ih),r)}function uh(e,t){return e.length===t.length&&e.slice(0,-1).every((r,l)=>r===t[l])?e[e.length-1]-t[t.length-1]:0}function ah(e,t,n){let{routesMeta:r}=e,l={},i="/",o=[];for(let s=0;s<r.length;++s){let u=r[s],a=s===r.length-1,h=i==="/"?t:t.slice(i.length)||"/",f=ch({path:u.relativePath,caseSensitive:u.caseSensitLine truncated
* React Router v6.30.3
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function or(){return or=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},or.apply(this,arguments)}const ss=S.createContext(null),kh=S.createContext(null),kt=S.createContext(null),Ol=S.createContext(null),be=S.createContext({outlet:null,matches:[],isDataRoute:!1}),lf=S.createContext(null);function Eh(e,t){let{relative:n}=t===void 0?{}:t;xn()||J(!1);let{basename:r,navigator:l}=S.useContext(kt),{hash:i,pathname:o,search:s}=uf(e,{relative:n}),u=o;return r!=="/"&&(u=o==="/"?r:vt([r,o])),l.createHref({pathname:u,search:s,hash:i})}function xn(){return S.useContext(Ol)!=null}function dr(){return xn()||J(!1),S.useContext(Ol).location}function of(e){S.useContext(kt).static||S.useLayoutEffect(e)}function us(){let{isDataRoute:e}=S.useContext(be);return e?Fh():Nh()}function Nh(){xn()||J(!1);let e=S.useContext(ss),{basename:t,future:n,navigator:r}=S.useContext(kt),{matches:l}=S.useContext(be),{pathname:i}=dr(),o=JSON.stringify(is(l,n.v7_relativeSplatPath)),s=S.useRef(!1);return of(()=>{s.current=!0}),S.useCallback(function(a,h){if(h===void 0&&(h={}),!s.current)return;if(typeof a=="number"){r.go(a);return}let f=os(a,JSON.parse(o),i,h.relative==="path");e==null&&t!=="/"&&(f.pathname=f.pathname==="/"?t:vt([t,f.pathname])),(h.replace?r.replace:r.push)(f,h.state,h)},[t,r,o,i,e])}function sf(){let{matches:e}=S.useContext(be),t=e[e.length-1];return t?t.params:{}}function uf(e,t){let{relative:n}=t===void 0?{}:t,{future:r}=S.useContext(kt),{matches:l}=S.useContext(be),{pathname:i}=dr(),o=JSON.stringify(is(l,r.v7_relativeSplatPath));return S.useMemo(()=>os(e,JSON.parse(o),i,n==="path"),[e,o,i,n])}function Ch(e,t){return jh(e,t)}function jh(e,t,n,r){xn()||J(!1);let{navigator:l}=S.useContext(kt),{matches:i}=S.useContext(be),o=i[i.length-1],s=o?o.params:{};o&&o.pathname;let u=o?o.pathnameBase:"/";o&&o.route;let a=dr(),h;if(t){var f;let C=typeof t=="string"?yn(t):t;u==="/"||(f=C.pathname)!=null&&f.startsWith(u)||J(!1),h=C}else h=a;let d=h.pathname||"/",y=d;if(u!=="/"){let C=u.replace(/^\//,"").split("/");y="/"+d.replace(/^\//,"").split("/").slice(C.length).join("/")}let g=qp(e,{pathname:y}),w=Rh(g&&g.map(C=>Object.assign({},C,{params:Object.assign({},s,C.params),pathname:vt([u,l.encodeLocation?l.encodeLocation(C.pathname).pathname:C.pathname]),pathnameBase:C.pathnameBase==="/"?u:vt([u,l.encodeLocation?l.encodeLocation(C.pathnameBase).pathname:C.pathnameBase])})),i,n,r);return t&&w?S.createElement(Ol.Provider,{value:{location:or({pathname:"/",search:"",hash:"",state:null,key:"default"},h),navigationType:st.Pop}},w):w}function _h(){let e=Mh(),t=wh(e)?e.status+" "+e.statusText:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,l={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"};return S.createElement(S.Fragment,null,S.createElement("h2",null,"Unexpected Application Error!"),S.createElement("h3",{style:{fontStyle:"italic"}},t),n?S.createElement("pre",{style:l},n):null,null)}const Ph=S.createElement(_h,null);class Th extends S.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,n){return n.location!==t.location||n.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error!==void 0?t.error:n.error,location:n.location,revalidation:t.revalidation||n.revalidation}}componentDidCatch(t,n){console.error("React Router caught the following error during render",t,n)}render(){return this.state.error!==void 0?S.createElement(be.Provider,{value:this.props.routeContext},S.createElement(lf.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function Lh(e){let{routeContext:t,match:n,children:r}=e,l=S.useContext(ss);return l&&l.static&&l.staticContext&&(n.route.errorElement||n.route.ErrorBoundary)&&(l.staticContext._deepestRenderedBoundaryId=n.route.id),S.createElement(be.Provider,{value:t},r)}function Rh(e,t,n,r){var l;if(t===void 0&&(t=[]),n===void 0&&(n=null),r===void 0&&(r=null),e==null){var i;if(!n)return null;if(n.errors)e=n.matches;else if((i=r)!=null&&i.v7_partialHydration&&t.length===0&&!n.initialized&&n.matches.length>0)e=n.matches;else return null}let o=e,s=(l=n)==null?void 0:l.errors;if(s!=null){let h=o.findIndex(f=>f.route.id&&(s==null?void 0:s[f.route.id])!==void 0);h>=0||J(!1),o=o.slice(0,Math.min(o.length,h+1))}let u=!1,a=-1;if(n&&r&&r.v7_partialHydration)for(let h=0;h<o.length;h++){let f=o[h];if((f.route.HydrateFallback||f.route.hydrateFallbackElement)&&(a=h),f.route.id){let{loaderData:d,errors:y}=n,g=f.route.loader&&d[f.route.id]===void 0&&(!y||y[f.route.id]===void 0);if(f.route.lazy||g){u=!0,a>=0?o=o.slice(0,a+1):o=[o[0]];break}}}return o.reduceRight((h,f,d)=>{let y,g=!1,w=null,C=null;n&&(y=s&&f.route.id?s[f.route.id]:void 0Line truncated
* React Router DOM v6.30.3
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function io(){return io=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},io.apply(this,arguments)}function Bh(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,i;for(i=0;i<r.length;i++)l=r[i],!(t.indexOf(l)>=0)&&(n[l]=e[l]);return n}function Wh(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function Hh(e,t){return e.button===0&&(!t||t==="_self")&&!Wh(e)}const Qh=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],Kh="6";try{window.__reactRouterVersion=Kh}catch{}const Gh="startTransition",Cu=Df[Gh];function Jh(e){let{basename:t,children:n,future:r,window:l}=e,i=S.useRef();i.current==null&&(i.current=Yp({window:l,v5Compat:!0}));let o=i.current,[s,u]=S.useState({action:o.action,location:o.location}),{v7_startTransition:a}=r||{},h=S.useCallback(f=>{a&&Cu?Cu(()=>u(f)):u(f)},[u,a]);return S.useLayoutEffect(()=>o.listen(h),[o,h]),S.useEffect(()=>$h(r),[r]),S.createElement(Ah,{basename:t,children:n,location:s.location,navigationType:s.action,navigator:o,future:r})}const Yh=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",Xh=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,df=S.forwardRef(function(t,n){let{onClick:r,relative:l,reloadDocument:i,replace:o,state:s,target:u,to:a,preventScrollReset:h,viewTransition:f}=t,d=Bh(t,Qh),{basename:y}=S.useContext(kt),g,w=!1;if(typeof a=="string"&&Xh.test(a)&&(g=a,Yh))try{let v=new URL(window.location.href),x=a.startsWith("//")?new URL(v.protocol+a):new URL(a),E=ls(x.pathname,y);x.origin===v.origin&&E!=null?a=E+x.search+x.hash:w=!0}catch{}let C=Eh(a,{relative:l}),m=Zh(a,{replace:o,state:s,target:u,preventScrollReset:h,relative:l,viewTransition:f});function p(v){r&&r(v),v.defaultPrevented||m(v)}return S.createElement("a",io({},d,{href:g||C,onClick:w||i?r:p,ref:n,target:u}))});var ju;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(ju||(ju={}));var _u;(function(e){e.UseFetcher="useFetcher",e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"})(_u||(_u={}));function Zh(e,t){let{target:n,replace:r,state:l,preventScrollReset:i,relative:o,viewTransition:s}=t===void 0?{}:t,u=us(),a=dr(),h=uf(e,{relative:o});return S.useCallback(f=>{if(Hh(f,n)){f.preventDefault();let d=r!==void 0?r:gl(a)===gl(h);u(e,{replace:d,state:l,preventScrollReset:i,relative:o,viewTransition:s})}},[a,u,h,r,l,n,e,i,o,s])}async function as(e){const t=await fetch(e,{headers:{Accept:"application/json"}});if(!t.ok)throw new Error(`request failed: ${t.status} ${t.statusText} (${e})`);return await t.json()}function qh(){return as("/api/runs")}function bh(e){return as(`/api/runs/${encodeURIComponent(e)}`)}function em(e,t){return as(`/api/runs/${encodeURIComponent(e)}/steps/${t}`)}function Pu(e,t){return`/api/runs/${encodeURIComponent(e)}/screenshots/${encodeURIComponent(t)}`}function tm(e,t){S.useEffect(()=>{if(typeof EventSource>"u")return;const n=new EventSource("/api/events"),r=()=>{t()};return n.addEventListener(e,r),()=>{n.removeEventListener(e,r),n.close()}},[e,t])}function nm(e){if(!e)return"-";if(e<1e3)return`${e}ms`;const t=e/1e3;if(t<60)return`${t.toFixed(1)}s`;const n=Math.floor(t/60),r=Math.round(t-n*60);return`${n}m${r}s`}function rm(e){const t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString()}function lm(){const[e,t]=S.useState(null),[n,r]=S.useState(null),l=S.useCallback(()=>{qh().then(t).catch(i=>{r(i instanceof Error?i.message:String(i))})},[]);return S.useEffect(()=>{l()},[l]),tm("runs.changed",l),n?c.jsxs("div",{className:"status-block status-error",children:["failed to load runs: ",n]}):e?e.length===0?c.jsx("div",{className:"status-block",children:"no runs yet. run `sanderling test` to produce some."}):c.jsxs("table",{className:"run-table",children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"started"}),c.jsx("th",{children:"spec"}),c.jsx("th",{children:"seed"}),c.jsx("th",{children:"platform"}),c.jsx("th",{children:"duration"}),c.jsx("th",{children:"steps"}),c.jsx("th",{children:"violations"}),c.jsx("th",{})]})}),c.jsx("tbody",{children:e.map(i=>c.jsxs("tr",{children:[c.jsx("td",{children:c.jsx(df,{to:`/runs/${i.id}`,children:rm(i.started_at)})}),c.jsx("td",{children:i.spec_path}),c.jsx("td",{children:i.seed}),c.jsx("td",{children:i.platform}),c.jsx("td",{children:nm(i.duration_millis)}),c.jsx("td",{children:i.step_count}),c.jsx("td",{children:i.violation_count>0?c.jsx("span",{className:"chip chip-violation",children:i.violation_count}):"0"}),c.jsx("td",{children:i.in_progress?c.jsx("span",{className:"chip chip-progress",children:"in progress"}):null})]},i.id))})]}):c.jsx("div",{className:"status-block",children:"loading runs..."})}const im=["id","text","textPrefix","dLine truncated
View File
Whitespace-only changes.
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sanderling inspect</title>
<script type="module" crossorigin src="/assets/index-CEStFXHZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BV6z-_9-.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+51
View File
@@ -130,3 +130,54 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.action-list-extractor-changes {
grid-column: 2;
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--surface-border);
}
.action-list-extractor-changes-heading {
color: var(--text-muted);
text-transform: lowercase;
letter-spacing: 0.5px;
}
.action-list-extractor-change-row {
display: grid;
grid-template-columns: minmax(80px, max-content) 1fr;
gap: 6px;
color: var(--text-primary);
}
.action-list-extractor-change-name {
color: var(--text-muted);
}
.action-list-extractor-change-diff {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-list-extractor-change-row-collapsed > summary {
display: grid;
grid-template-columns: minmax(80px, max-content) 1fr;
gap: 6px;
cursor: pointer;
}
.action-list-extractor-change-full {
grid-column: 1 / -1;
margin: 4px 0 0;
white-space: pre-wrap;
word-break: break-word;
background: var(--surface-base);
padding: 4px 6px;
border-radius: 2px;
font-size: 10px;
}
+70 -2
View File
@@ -111,7 +111,7 @@ function renderTarget(target: string, isTag: boolean) {
}
function contentTextForStep(step: Step): string {
const action = step.action;
const action = step.next_action;
if (!action) {
return "";
}
@@ -121,8 +121,26 @@ function contentTextForStep(step: Step): string {
return "";
}
const MAX_INLINE_VALUE = 80;
function formatExtractorValue(value: unknown): string {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "string") return JSON.stringify(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function truncateForInline(text: string): string {
if (text.length <= MAX_INLINE_VALUE) return text;
return `${text.slice(0, MAX_INLINE_VALUE - 1)}...`;
}
function positionTextForStep(step: Step): string | null {
const action = step.action;
const action = step.next_action;
if (!action) {
return null;
}
@@ -289,6 +307,56 @@ export default function ActionList({
{contentText ? contentText : `""`}
</span>
</div>
{step.has_violations && selectedStep?.extractor_changes
? (() => {
const entries = Object.entries(selectedStep.extractor_changes);
if (entries.length === 0) return null;
entries.sort(([a], [b]) => a.localeCompare(b));
return (
<div className="action-list-extractor-changes">
<div className="action-list-extractor-changes-heading">
extractor changes
</div>
{entries.map(([name, change]) => {
const prev = formatExtractorValue(change.prev);
const curr = formatExtractorValue(change.curr);
const inlineFits =
prev.length + curr.length <= MAX_INLINE_VALUE * 2;
if (inlineFits) {
return (
<div key={name} className="action-list-extractor-change-row">
<span className="action-list-extractor-change-name">
{name}
</span>
<span className="action-list-extractor-change-diff">
{truncateForInline(prev)} → {truncateForInline(curr)}
</span>
</div>
);
}
return (
<details
key={name}
className="action-list-extractor-change-row action-list-extractor-change-row-collapsed"
>
<summary>
<span className="action-list-extractor-change-name">
{name}
</span>
<span className="action-list-extractor-change-diff">
{truncateForInline(prev)} → {truncateForInline(curr)}
</span>
</summary>
<pre className="action-list-extractor-change-full">
{`prev: ${prev}\ncurr: ${curr}`}
</pre>
</details>
);
})}
</div>
);
})()
: null}
</div>
) : null}
</li>
+7 -4
View File
@@ -92,15 +92,18 @@ export default function RunDetail() {
onJumpNextViolation: jumpToNextViolation,
});
// One screenshot per step depicts the UI at observation time (post-prior-
// action, post-settle). The "after" view for step N is therefore step
// (N+1)'s screenshot: the same moment, observed from the next iteration.
const beforeScreenshot = useMemo(() => {
if (!runId || !currentStep) return undefined;
return screenshotUrl(runId, `step-${String(currentStep.step).padStart(5, "0")}.png`);
}, [runId, currentStep]);
const afterScreenshot = useMemo(() => {
if (!runId || !currentStep) return undefined;
return screenshotUrl(runId, `step-${String(currentStep.step).padStart(5, "0")}-after.png`);
}, [runId, currentStep]);
if (!runId || !nextStep) return undefined;
return screenshotUrl(runId, `step-${String(nextStep.step).padStart(5, "0")}.png`);
}, [runId, nextStep]);
const runStartMillis = useMemo(() => {
if (!run?.steps.length) return 0;
@@ -124,7 +127,7 @@ export default function RunDetail() {
{
id: "screenshot",
label: "Screenshot",
content: <Screenshot src={beforeScreenshot} action={currentStep?.action} />,
content: <Screenshot src={beforeScreenshot} action={currentStep?.next_action} />,
},
{
id: "snapshots",
+8 -1
View File
@@ -115,15 +115,22 @@ export interface Metrics {
total_memory_bytes?: number;
}
export interface ExtractorChange {
prev: unknown;
curr: unknown;
}
export interface Step {
step: number;
timestamp: string;
screen?: string;
snapshots?: Record<string, unknown>;
action?: Action;
// next_action is the action chosen for the next iteration based on observing this step.
next_action?: Action;
exceptions?: Exception[];
violations?: string[];
hierarchy?: Hierarchy;
residuals?: Record<string, ResidualNode>;
metrics?: Metrics;
extractor_changes?: Record<string, ExtractorChange>;
}
+66
View File
@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
@@ -236,6 +237,71 @@ func waitForBoot(ctx context.Context, timeout time.Duration) error {
}
}
// ForegroundPackage returns the package of the currently resumed activity on
// the connected device, or "" when it cannot be determined.
func ForegroundPackage(ctx context.Context) (string, error) {
adb, err := AdbBinary()
if err != nil {
return "", err
}
output, err := exec.CommandContext(ctx, adb, "shell", "dumpsys", "activity", "activities").Output()
if err != nil {
return "", err
}
return parseForegroundPackage(string(output)), nil
}
// FocusedWindowPackage returns the package owning the currently focused window,
// or "" when no window is focused (e.g. mid-launch, before the app has drawn).
// Unlike ForegroundPackage, this reflects what is actually on screen:
// ResumedActivity flips to a newly launched app before its first frame renders,
// while mCurrentFocus only names the app once its window is up.
func FocusedWindowPackage(ctx context.Context) (string, error) {
adb, err := AdbBinary()
if err != nil {
return "", err
}
output, err := exec.CommandContext(ctx, adb, "shell", "dumpsys", "window").Output()
if err != nil {
return "", err
}
return parseFocusedWindowPackage(string(output)), nil
}
// resumedActivityPackage matches the "<package>/<activity>" component name that
// dumpsys prints on ResumedActivity and mCurrentFocus lines, capturing the
// package.
var resumedActivityPackage = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9_.]*)/[a-zA-Z0-9_.$]+`)
// parseForegroundPackage extracts the foreground package from `dumpsys activity
// activities` output by reading the first ResumedActivity component name.
func parseForegroundPackage(dumpsys string) string {
for line := range strings.SplitSeq(dumpsys, "\n") {
if !strings.Contains(line, "ResumedActivity") {
continue
}
if match := resumedActivityPackage.FindStringSubmatch(line); match != nil {
return match[1]
}
}
return ""
}
// parseFocusedWindowPackage extracts the focused-window package from
// `dumpsys window` output by reading the mCurrentFocus component name. A
// "mCurrentFocus=null" line (no focused window) yields "".
func parseFocusedWindowPackage(dumpsys string) string {
for line := range strings.SplitSeq(dumpsys, "\n") {
if !strings.Contains(line, "mCurrentFocus") {
continue
}
if match := resumedActivityPackage.FindStringSubmatch(line); match != nil {
return match[1]
}
}
return ""
}
func bootCompleted(ctx context.Context) (bool, error) {
adb, err := AdbBinary()
if err != nil {
+72
View File
@@ -91,3 +91,75 @@ func TestPathContains(t *testing.T) {
t.Error("did not expect /nope in PATH")
}
}
func TestParseForegroundPackage(t *testing.T) {
cases := []struct {
name string
dumpsys string
want string
}{
{
name: "mResumedActivity folio",
dumpsys: " Stack #0:\n mResumedActivity: ActivityRecord{a1b2c3 u0 app.folio/.MainActivity t42}\n",
want: "app.folio",
},
{
name: "topResumedActivity chrome",
dumpsys: "ResumedActivity: ActivityRecord{ff u0 com.android.chrome/com.google.android.apps.chrome.Main t9}\n topResumedActivity=ActivityRecord{ff u0 com.android.chrome/com.google.android.apps.chrome.Main}",
want: "com.android.chrome",
},
{
name: "launcher",
dumpsys: " mResumedActivity: ActivityRecord{x u0 com.google.android.apps.nexuslauncher/.NexusLauncherActivity t1}",
want: "com.google.android.apps.nexuslauncher",
},
{
name: "no resumed activity",
dumpsys: " some unrelated dumpsys output\n with no resumed line\n",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := parseForegroundPackage(tc.dumpsys); got != tc.want {
t.Errorf("parseForegroundPackage = %q, want %q", got, tc.want)
}
})
}
}
func TestParseFocusedWindowPackage(t *testing.T) {
cases := []struct {
name string
dumpsys string
want string
}{
{
name: "folio focused",
dumpsys: " mCurrentFocus=Window{e00f63a u0 app.folio/app.folio.MainActivity}\n mFocusedApp=ActivityRecord{c0 u0 app.folio/.MainActivity t202}",
want: "app.folio",
},
{
name: "settings focused",
dumpsys: " mCurrentFocus=Window{709 u0 com.android.settings/com.android.settings.SubSettings}",
want: "com.android.settings",
},
{
name: "no focused window mid-launch",
dumpsys: " mCurrentFocus=null\n mFocusedApp=null",
want: "",
},
{
name: "no focus line",
dumpsys: " some unrelated dumpsys window output\n",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := parseFocusedWindowPackage(tc.dumpsys); got != tc.want {
t.Errorf("parseFocusedWindowPackage = %q, want %q", got, tc.want)
}
})
}
}
+39 -1
View File
@@ -36,6 +36,7 @@ func New() *Driver {
append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true),
chromedp.NoSandbox,
)...,
)
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
@@ -190,6 +191,23 @@ func (d *Driver) PressKey(_ context.Context, key string) error {
return chromedp.Run(d.tabCtx, chromedp.KeyEvent(k))
}
func (d *Driver) LongPress(_ context.Context, x, y int) error {
script := fmt.Sprintf(`
(function() {
const el = document.elementFromPoint(%d, %d);
if (!el) return;
el.dispatchEvent(new PointerEvent('pointerdown', {clientX: %d, clientY: %d, bubbles: true}));
setTimeout(function() {
el.dispatchEvent(new PointerEvent('pointerup', {clientX: %d, clientY: %d, bubbles: true}));
}, 600);
})();`,
x, y,
x, y,
x, y,
)
return chromedp.Run(d.tabCtx, chromedp.Evaluate(script, nil))
}
// keyMap covers the keys web specs may emit (enter/tab/escape/arrows).
// "back"/"home" are intentionally absent: backspace/NUL have no navigation
// semantics in a browser, and the V8 action mix already excludes them.
@@ -218,7 +236,8 @@ func (d *Driver) Hierarchy(_ context.Context) (string, error) {
if (el.id) attrs['resource-id'] = el.id;
const label = el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') || '';
if (label) attrs['content-desc'] = label;
if (el.tagName) attrs['tag'] = el.tagName.toLowerCase();
const tag = (el.tagName || '').toLowerCase();
if (tag) attrs['tag'] = tag;
if (el.className && typeof el.className === 'string' && el.className.trim()) {
attrs['class'] = el.className.trim();
}
@@ -226,6 +245,9 @@ func (d *Driver) Hierarchy(_ context.Context) (string, error) {
const isClickable = !!(el.onclick || el.tagName === 'A' || el.tagName === 'BUTTON' ||
el.tagName === 'INPUT' || el.tagName === 'SELECT' ||
el.getAttribute('role') === 'button' || el.getAttribute('onclick'));
const isEditable = el.isContentEditable || tag === 'textarea' ||
(tag === 'input' && !['button','submit','checkbox','radio','range','color','file','image','reset']
.includes((el.type || '').toLowerCase()));
const children = [];
for (const child of el.children) {
children.push(buildTree(child, false));
@@ -238,6 +260,7 @@ func (d *Driver) Hierarchy(_ context.Context) (string, error) {
focused: document.activeElement === el || null,
checked: el.checked || null,
selected: el.selected || null,
editable: isEditable || null,
};
}
return buildTree(document.body, true);
@@ -263,6 +286,21 @@ func (d *Driver) Screenshot(_ context.Context) (driver.Image, error) {
return driver.Image{PNG: buf, Width: w, Height: h}, nil
}
// Snapshot pairs hierarchy and screenshot back-to-back. The chromedp tab
// is single-threaded so the two CDP round-trips are already serialized:
// pairing them here matches the DeviceDriver contract without extra locking.
func (d *Driver) Snapshot(ctx context.Context) (string, driver.Image, error) {
hierarchy, err := d.Hierarchy(ctx)
if err != nil {
return "", driver.Image{}, err
}
image, err := d.Screenshot(ctx)
if err != nil {
return hierarchy, driver.Image{}, err
}
return hierarchy, image, nil
}
func (d *Driver) RecentLogs(_ context.Context, since time.Time, minLevel string) ([]driver.LogEntry, error) {
sinceMillis := since.UnixMilli()
d.logsMu.Lock()
+61
View File
@@ -2,10 +2,71 @@ package chrome
import (
"context"
"encoding/json"
"testing"
"time"
)
// TestHierarchy_EditableFlag confirms the injected hierarchy script marks text
// inputs, textareas, and contenteditable elements editable while leaving
// buttons and non-text inputs alone.
func TestHierarchy_EditableFlag(t *testing.T) {
const html = `<body>` +
`<input id="name">` +
`<textarea id="bio"></textarea>` +
`<button id="go">go</button>` +
`<div id="rich" contenteditable="true">x</div>` +
`<input id="chk" type="checkbox">` +
`</body>`
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, "data:text/html,"+html, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
dump, err := d.Hierarchy(ctx)
if err != nil {
t.Fatalf("Hierarchy: %v", err)
}
type node struct {
Attributes map[string]string `json:"attributes"`
Children []node `json:"children"`
Editable *bool `json:"editable"`
}
var root node
if err := json.Unmarshal([]byte(dump), &root); err != nil {
t.Fatalf("unmarshal hierarchy: %v", err)
}
editableByID := map[string]*bool{}
var walk func(n node)
walk = func(n node) {
if id := n.Attributes["resource-id"]; id != "" {
editableByID[id] = n.Editable
}
for _, c := range n.Children {
walk(c)
}
}
walk(root)
isEditable := func(id string) bool {
return editableByID[id] != nil && *editableByID[id]
}
for _, id := range []string{"name", "bio", "rich"} {
if !isEditable(id) {
t.Errorf("%q: editable = %v, want true", id, editableByID[id])
}
}
for _, id := range []string{"go", "chk"} {
if isEditable(id) {
t.Errorf("%q: editable = true, want false/absent", id)
}
}
}
// TestRunCtx_CallerCancelPropagates confirms that cancelling the caller's
// context cancels the chromedp-bound context returned by runCtx. This is the
// channel by which step deadlines and Ctrl-C reach in-flight CDP calls.
+30
View File
@@ -19,9 +19,15 @@ type DeviceDriver interface {
InputText(ctx context.Context, text string) error
Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error
PressKey(ctx context.Context, key string) error
LongPress(ctx context.Context, x, y int) error
Hierarchy(ctx context.Context) (string, error)
Screenshot(ctx context.Context) (Image, error)
// Snapshot returns the hierarchy and screenshot captured back-to-back
// under a backend-side mutex, so the pair describes the same on-device
// frame. Prefer this over calling Hierarchy and Screenshot separately:
// independent reads can land on different frames during transitions.
Snapshot(ctx context.Context) (string, Image, error)
// RecentLogs returns log entries at or after `since`, filtered to
// `minLevel` or above. An empty minLevel defaults to "E".
RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]LogEntry, error)
@@ -35,6 +41,30 @@ type DeviceDriver interface {
Metrics(ctx context.Context, bundleID string) (Metrics, error)
}
// ForegroundChecker is the optional capability for reporting which app is
// currently in the foreground. The runner uses it to keep exploration scoped
// to the app under test: when an action backs out of (or otherwise leaves) the
// app, the runner relaunches it before acting again. Drivers that cannot
// determine the foreground app simply do not implement this interface.
type ForegroundChecker interface {
// ForegroundApp returns the bundle id / package of the app currently in
// the foreground. An empty string means "unknown" and the runner skips
// enforcement for that step rather than relaunching blindly.
ForegroundApp(ctx context.Context) (string, error)
}
// FocusedWindowChecker is the optional capability for reporting which app owns
// the focused (on-screen) window. The startup gate prefers it over
// ForegroundChecker: the resumed-activity signal flips to a freshly launched
// app before its first frame draws, so observing on it alone can capture the
// previous app's screen. The focused window only names the app once its window
// is actually up.
type FocusedWindowChecker interface {
// FocusedWindowApp returns the package owning the focused window, or ""
// when no window is focused yet (e.g. mid-launch transition).
FocusedWindowApp(ctx context.Context) (string, error)
}
type LogEntry struct {
UnixMillis int64
Level string
+77
View File
@@ -18,8 +18,10 @@ const (
ActionInputText ActionKind = "input_text"
ActionSwipe ActionKind = "swipe"
ActionPressKey ActionKind = "press_key"
ActionLongPress ActionKind = "long_press"
ActionHierarchy ActionKind = "hierarchy"
ActionScreenshot ActionKind = "screenshot"
ActionSnapshot ActionKind = "snapshot"
ActionRecentLogs ActionKind = "recent_logs"
ActionWaitForIdle ActionKind = "wait_for_idle"
ActionHealth ActionKind = "health"
@@ -55,6 +57,21 @@ type Driver struct {
LogEntries []driver.LogEntry
MetricsData driver.Metrics
Failures map[ActionKind]error
// ForegroundResults is consumed one entry per ForegroundApp call (the
// last entry repeats). Empty yields "", which disables the runner's
// app-scope guard so tests that don't care are unaffected.
ForegroundResults []string
foregroundIndex int
// FocusedWindowResults is consumed one entry per FocusedWindowApp call
// (the last entry repeats). When empty, FocusedWindowApp mirrors the
// last ForegroundApp result, so the startup gate treats the window as
// already drawn and tests that don't care are unaffected.
FocusedWindowResults []string
focusedWindowIndex int
focusedWindowCalls int
lastForeground string
}
func New() *Driver {
@@ -96,6 +113,44 @@ func (d *Driver) Launch(_ context.Context, bundleID string, clearState bool, _ m
return nil
}
func (d *Driver) ForegroundApp(_ context.Context) (string, error) {
d.mutex.Lock()
defer d.mutex.Unlock()
if len(d.ForegroundResults) == 0 {
d.lastForeground = ""
return "", nil
}
index := d.foregroundIndex
if index >= len(d.ForegroundResults) {
index = len(d.ForegroundResults) - 1
}
d.foregroundIndex++
d.lastForeground = d.ForegroundResults[index]
return d.lastForeground, nil
}
func (d *Driver) FocusedWindowApp(_ context.Context) (string, error) {
d.mutex.Lock()
defer d.mutex.Unlock()
d.focusedWindowCalls++
if len(d.FocusedWindowResults) == 0 {
return d.lastForeground, nil
}
index := d.focusedWindowIndex
if index >= len(d.FocusedWindowResults) {
index = len(d.FocusedWindowResults) - 1
}
d.focusedWindowIndex++
return d.FocusedWindowResults[index], nil
}
// FocusedWindowCalls reports how many times FocusedWindowApp has been called.
func (d *Driver) FocusedWindowCalls() int {
d.mutex.Lock()
defer d.mutex.Unlock()
return d.focusedWindowCalls
}
func (d *Driver) Terminate(ctx context.Context) error {
if err := d.failure(ActionTerminate); err != nil {
return err
@@ -112,6 +167,14 @@ func (d *Driver) Tap(ctx context.Context, x, y int) error {
return nil
}
func (d *Driver) LongPress(ctx context.Context, x, y int) error {
if err := d.failure(ActionLongPress); err != nil {
return err
}
d.record(Action{Kind: ActionLongPress, X: x, Y: y})
return nil
}
func (d *Driver) TapSelector(ctx context.Context, selector string) error {
if err := d.failure(ActionTapSelector); err != nil {
return err
@@ -181,6 +244,20 @@ func (d *Driver) Screenshot(ctx context.Context) (driver.Image, error) {
return d.ImageData, nil
}
// Snapshot returns the hierarchy + screenshot pair atomically, mirroring
// the real driver's contract. It records a single ActionSnapshot so tests
// can assert the runner reached for the paired RPC instead of racing the
// two reads.
func (d *Driver) Snapshot(ctx context.Context) (string, driver.Image, error) {
if err := d.failure(ActionSnapshot); err != nil {
return "", driver.Image{}, err
}
d.record(Action{Kind: ActionSnapshot})
d.mutex.Lock()
defer d.mutex.Unlock()
return d.HierarchyJSON, d.ImageData, nil
}
func (d *Driver) WaitForIdle(ctx context.Context, duration time.Duration) error {
if err := d.failure(ActionWaitForIdle); err != nil {
return err
+48
View File
@@ -8,6 +8,7 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/priyanshujain/sanderling/internal/android"
"github.com/priyanshujain/sanderling/internal/driver"
driverpb "github.com/priyanshujain/sanderling/proto/driverpb"
)
@@ -15,6 +16,31 @@ import (
type Client struct {
connection *grpc.ClientConn
stub driverpb.DriverClient
platform string
}
// SetPlatform records the target platform so capability methods (e.g.
// ForegroundApp) can pick the right backend. The caller sets this right after
// Dial.
func (c *Client) SetPlatform(platform string) { c.platform = platform }
// ForegroundApp reports the foreground package. Only Android is supported (via
// adb); other platforms return "" so the runner skips app-scope enforcement.
func (c *Client) ForegroundApp(ctx context.Context) (string, error) {
if c.platform != "android" {
return "", nil
}
return android.ForegroundPackage(ctx)
}
// FocusedWindowApp reports the package owning the focused window. Only Android
// is supported (via adb); other platforms return "" so the startup gate falls
// back to the foreground-app signal.
func (c *Client) FocusedWindowApp(ctx context.Context) (string, error) {
if c.platform != "android" {
return "", nil
}
return android.FocusedWindowPackage(ctx)
}
// Dial connects to the sidecar gRPC server at the given address.
@@ -67,6 +93,11 @@ func (c *Client) Tap(ctx context.Context, x, y int) error {
return err
}
func (c *Client) LongPress(ctx context.Context, x, y int) error {
_, err := c.stub.LongPress(ctx, &driverpb.Point{X: int32(x), Y: int32(y)})
return err
}
func (c *Client) TapSelector(ctx context.Context, selector string) error {
_, err := c.stub.TapSelector(ctx, &driverpb.Selector{Value: selector})
return err
@@ -136,6 +167,23 @@ func (c *Client) Screenshot(ctx context.Context) (driver.Image, error) {
}, nil
}
// Snapshot fetches hierarchy and screenshot in a single sidecar round-trip.
// The sidecar serializes the two reads behind a mutex so the returned pair
// describes the same on-device frame, removing the cross-fade race the
// runner used to see when fetching them as independent goroutines.
func (c *Client) Snapshot(ctx context.Context) (string, driver.Image, error) {
response, err := c.stub.Snapshot(ctx, &driverpb.Empty{})
if err != nil {
return "", driver.Image{}, err
}
image := response.GetScreenshot()
return response.GetHierarchy().GetJson(), driver.Image{
PNG: image.GetPng(),
Width: int(image.GetWidth()),
Height: int(image.GetHeight()),
}, nil
}
func (c *Client) WaitForIdle(ctx context.Context, duration time.Duration) error {
_, err := c.stub.WaitForIdle(ctx, &driverpb.Duration{Millis: duration.Milliseconds()})
return err
+26
View File
@@ -105,6 +105,15 @@ func (s *fakeServer) Screenshot(_ context.Context, _ *driverpb.Empty) (*driverpb
return &driverpb.Image{Png: s.imagePNG, Width: s.imageWidth, Height: s.imageHeight}, nil
}
func (s *fakeServer) Snapshot(_ context.Context, _ *driverpb.Empty) (*driverpb.SnapshotResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
return &driverpb.SnapshotResponse{
Hierarchy: &driverpb.HierarchyJSON{Json: s.hierarchy},
Screenshot: &driverpb.Image{Png: s.imagePNG, Width: s.imageWidth, Height: s.imageHeight},
}, nil
}
type harness struct {
server *grpc.Server
fake *fakeServer
@@ -260,6 +269,23 @@ func TestClient_HierarchyAndScreenshot(t *testing.T) {
}
}
func TestClient_SnapshotPairsHierarchyAndScreenshot(t *testing.T) {
state := newHarness(t)
client, _ := Dial(state.address)
defer client.Close()
hierarchyJSON, image, err := client.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if hierarchyJSON != `{"x":1}` {
t.Errorf("hierarchy wrong: %q", hierarchyJSON)
}
if image.Width != 1080 || image.Height != 2340 || len(image.PNG) != 1 {
t.Errorf("image wrong: %+v", image)
}
}
func TestClient_WaitForIdleForwardsMillis(t *testing.T) {
state := newHarness(t)
client, _ := Dial(state.address)
+20
View File
@@ -66,6 +66,7 @@ type Element struct {
Checked bool `json:"checked,omitempty"`
Focused bool `json:"focused,omitempty"`
Selected bool `json:"selected,omitempty"`
Editable bool `json:"editable,omitempty"`
Bounds Bounds `json:"bounds"`
Attributes map[string]string `json:"attrs,omitempty"`
}
@@ -91,6 +92,7 @@ type treeNodeJSON struct {
Focused *bool `json:"focused"`
Checked *bool `json:"checked"`
Selected *bool `json:"selected"`
Editable *bool `json:"editable"`
}
// Selector describes a multi-attribute AND match.
@@ -204,6 +206,18 @@ func elementFromNode(node *treeNodeJSON) *Element {
}
element.Class = attrs["class"]
element.Package = attrs["package"]
if element.Package == "" {
// Android omits an explicit package attribute, but native views carry
// it as the resource-id prefix (`com.android.systemui:id/...`). Compose
// testTags are colon-less and leave the package empty, which keeps them
// in scope. This lets target selection tell the app apart from the soft
// keyboard and system UI.
if resourceID := attrs["resource-id"]; resourceID != "" {
if colon := strings.IndexByte(resourceID, ':'); colon > 0 {
element.Package = resourceID[:colon]
}
}
}
element.Screen = attrs["sanderling-screen"]
if node.Clickable != nil {
@@ -221,6 +235,11 @@ func elementFromNode(node *treeNodeJSON) *Element {
if node.Selected != nil {
element.Selected = *node.Selected
}
if node.Editable != nil {
element.Editable = *node.Editable
} else {
element.Editable = strings.Contains(element.Class, "EditText") || attrs["hintText"] != ""
}
if b, ok := attrs["bounds"]; ok && b != "" {
bounds, err := parseBounds(b)
@@ -246,6 +265,7 @@ func elementFromNode(node *treeNodeJSON) *Element {
if node.Selected != nil {
element.Attributes["selected"] = strconv.FormatBool(*node.Selected)
}
element.Attributes["editable"] = strconv.FormatBool(element.Editable)
return element
}
+103
View File
@@ -141,6 +141,61 @@ func TestBoolFieldsFromNode(t *testing.T) {
}
}
func TestEditableDerivation(t *testing.T) {
cases := []struct {
name string
node string
want bool
}{
{"driver flag", `{"attributes": {"bounds": "[0,0,10,10]"}, "editable": true}`, true},
{"driver flag false", `{"attributes": {"bounds": "[0,0,10,10]"}, "editable": false}`, false},
{"native EditText class", `{"attributes": {"class": "android.widget.EditText", "bounds": "[0,0,10,10]"}}`, true},
{"hintText attr", `{"attributes": {"hintText": "Enter amount", "bounds": "[0,0,10,10]"}}`, true},
{"plain button", `{"attributes": {"class": "android.widget.Button", "bounds": "[0,0,10,10]"}}`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tree, err := Parse(tc.node)
if err != nil {
t.Fatalf("Parse: %v", err)
}
el := tree.Elements[0]
if el.Editable != tc.want {
t.Errorf("Editable = %v, want %v", el.Editable, tc.want)
}
if got := el.Attributes["editable"]; got != boolString(tc.want) {
t.Errorf("attrs[editable] = %q, want %q", got, boolString(tc.want))
}
})
}
}
func boolString(b bool) string {
if b {
return "true"
}
return "false"
}
// TestFindByEditableSelector confirms find({editable:true}) matches via the
// mirrored attrs entry, the same path clickable uses.
func TestFindByEditableSelector(t *testing.T) {
input := `{
"attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"},
"children": [
{"attributes": {"class": "android.widget.EditText", "bounds": "[0,0,100,40]"}, "children": []},
{"attributes": {"class": "android.widget.Button", "bounds": "[0,40,100,80]"}, "children": []}
]
}`
tree, _ := Parse(input)
if el := tree.Find("editable:true"); el == nil {
t.Fatal("expected to find the EditText via editable:true")
}
if matches := tree.FindAll("editable:true"); len(matches) != 1 {
t.Fatalf("editable:true matched %d elements, want 1", len(matches))
}
}
func TestIdentifierFallback(t *testing.T) {
input := `{
"attributes": {"identifier": "my-button", "bounds": "[0,0,100,100]"},
@@ -627,3 +682,51 @@ func TestNodeFindDoesNotReturnSiblings(t *testing.T) {
t.Fatal("Node.Find should not return nodes from sibling subtrees")
}
}
// TestPackageDerivedFromResourceIDPrefix verifies that when the sidecar omits an
// explicit package attribute, native nodes pick it up from the resource-id
// prefix while colon-less Compose testTags stay empty (in scope for the app).
func TestPackageDerivedFromResourceIDPrefix(t *testing.T) {
const dump = `{
"attributes": {"class": "android.widget.FrameLayout", "bounds": "[0,0,320,640]"},
"children": [
{"attributes": {"resource-id": "AddAccountScreen", "bounds": "[0,0,320,400]"}, "clickable": true, "enabled": true, "children": []},
{"attributes": {"resource-id": "com.google.android.inputmethod.latin:id/key_pos_0_0", "bounds": "[0,400,40,440]"}, "clickable": true, "enabled": true, "children": []},
{"attributes": {"resource-id": "android:id/content", "bounds": "[0,0,320,640]"}, "children": []}
]
}`
tree, err := Parse(dump)
if err != nil {
t.Fatalf("Parse: %v", err)
}
want := map[string]string{
"AddAccountScreen": "",
"com.google.android.inputmethod.latin:id/key_pos_0_0": "com.google.android.inputmethod.latin",
"android:id/content": "android",
}
for _, element := range tree.Elements {
expected, ok := want[element.ResourceID]
if !ok {
continue
}
if element.Package != expected {
t.Errorf("resource-id %q: package = %q, want %q", element.ResourceID, element.Package, expected)
}
}
}
// TestExplicitPackageAttributeWins verifies an explicit package attribute is not
// overridden by the resource-id prefix fallback.
func TestExplicitPackageAttributeWins(t *testing.T) {
const dump = `{
"attributes": {"resource-id": "android:id/content", "package": "app.folio", "bounds": "[0,0,320,640]"},
"children": []
}`
tree, err := Parse(dump)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got := tree.Elements[0].Package; got != "app.folio" {
t.Errorf("package = %q, want app.folio (explicit attr should win)", got)
}
}
+18 -18
View File
@@ -61,7 +61,7 @@ func decodeStepSummary(line []byte) (StepSummary, int, error) {
Index int `json:"step"`
Timestamp time.Time `json:"timestamp"`
Screen string `json:"screen,omitempty"`
Action *struct {
NextAction *struct {
Kind string `json:"kind"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
@@ -73,7 +73,7 @@ func decodeStepSummary(line []byte) (StepSummary, int, error) {
Text string `json:"text,omitempty"`
Selector string `json:"selector,omitempty"`
DurationMillis int `json:"duration_millis,omitempty"`
} `json:"action,omitempty"`
} `json:"next_action,omitempty"`
Exceptions []json.RawMessage `json:"exceptions,omitempty"`
Violations []string `json:"violations,omitempty"`
}
@@ -87,29 +87,29 @@ func decodeStepSummary(line []byte) (StepSummary, int, error) {
HasViolations: len(partial.Violations) > 0,
HasExceptions: len(partial.Exceptions) > 0,
}
if partial.Action != nil {
summary.ActionKind = partial.Action.Kind
switch partial.Action.Kind {
case "Tap":
if partial.Action.Selector != "" {
summary.ActionLabel = partial.Action.Selector
} else if partial.Action.Text != "" {
summary.ActionLabel = partial.Action.Text
} else if partial.Action.X != 0 || partial.Action.Y != 0 {
summary.ActionLabel = fmt.Sprintf("(%d,%d)", partial.Action.X, partial.Action.Y)
if partial.NextAction != nil {
summary.ActionKind = partial.NextAction.Kind
switch partial.NextAction.Kind {
case "Tap", "DoubleTap":
if partial.NextAction.Selector != "" {
summary.ActionLabel = partial.NextAction.Selector
} else if partial.NextAction.Text != "" {
summary.ActionLabel = partial.NextAction.Text
} else if partial.NextAction.X != 0 || partial.NextAction.Y != 0 {
summary.ActionLabel = fmt.Sprintf("(%d,%d)", partial.NextAction.X, partial.NextAction.Y)
}
case "InputText":
summary.ActionLabel = fmt.Sprintf("%q", partial.Action.Text)
summary.ActionLabel = fmt.Sprintf("%q", partial.NextAction.Text)
case "Swipe":
summary.ActionLabel = swipeDirectionLabel(
partial.Action.FromX, partial.Action.FromY,
partial.Action.ToX, partial.Action.ToY,
partial.NextAction.FromX, partial.NextAction.FromY,
partial.NextAction.ToX, partial.NextAction.ToY,
)
case "PressKey":
summary.ActionLabel = partial.Action.Key
summary.ActionLabel = partial.NextAction.Key
case "Wait":
if partial.Action.DurationMillis > 0 {
summary.ActionLabel = fmt.Sprintf("%dms", partial.Action.DurationMillis)
if partial.NextAction.DurationMillis > 0 {
summary.ActionLabel = fmt.Sprintf("%dms", partial.NextAction.DurationMillis)
}
}
}
+8 -8
View File
@@ -104,7 +104,7 @@ func TestCacheStep_LazyDecodeReturnsFullStep(t *testing.T) {
startedAt := time.Now().UTC()
steps := []trace.Step{
{Index: 1, Timestamp: startedAt, Screen: "A"},
{Index: 2, Timestamp: startedAt.Add(time.Second), Screen: "B", Action: &trace.Action{Kind: "tap"}},
{Index: 2, Timestamp: startedAt.Add(time.Second), Screen: "B", NextAction: &trace.Action{Kind: "tap"}},
{Index: 3, Timestamp: startedAt.Add(2 * time.Second), Screen: "C", Violations: []string{"prop1"}},
}
writeRun(t, root, "r1", trace.Meta{StartedAt: startedAt, EndedAt: timePointer(startedAt.Add(3 * time.Second))}, steps)
@@ -144,13 +144,13 @@ func TestDecodeStepSummary_ActionLabelPerKind(t *testing.T) {
wantKind string
wantLabel string
}{
{`{"step":1,"timestamp":"2026-04-20T10:00:00Z","action":{"kind":"Tap","selector":"id:save"}}`, "Tap", "id:save"},
{`{"step":2,"timestamp":"2026-04-20T10:00:01Z","action":{"kind":"Tap","x":140,"y":220}}`, "Tap", "(140,220)"},
{`{"step":3,"timestamp":"2026-04-20T10:00:02Z","action":{"kind":"InputText","text":"alice"}}`, "InputText", `"alice"`},
{`{"step":4,"timestamp":"2026-04-20T10:00:03Z","action":{"kind":"Swipe","from_x":10,"from_y":500,"to_x":10,"to_y":50}}`, "Swipe", "up"},
{`{"step":5,"timestamp":"2026-04-20T10:00:04Z","action":{"kind":"Swipe","from_x":100,"from_y":50,"to_x":600,"to_y":50}}`, "Swipe", "right"},
{`{"step":6,"timestamp":"2026-04-20T10:00:05Z","action":{"kind":"PressKey","key":"back"}}`, "PressKey", "back"},
{`{"step":7,"timestamp":"2026-04-20T10:00:06Z","action":{"kind":"Wait","duration_millis":500}}`, "Wait", "500ms"},
{`{"step":1,"timestamp":"2026-04-20T10:00:00Z","next_action":{"kind":"Tap","selector":"id:save"}}`, "Tap", "id:save"},
{`{"step":2,"timestamp":"2026-04-20T10:00:01Z","next_action":{"kind":"Tap","x":140,"y":220}}`, "Tap", "(140,220)"},
{`{"step":3,"timestamp":"2026-04-20T10:00:02Z","next_action":{"kind":"InputText","text":"alice"}}`, "InputText", `"alice"`},
{`{"step":4,"timestamp":"2026-04-20T10:00:03Z","next_action":{"kind":"Swipe","from_x":10,"from_y":500,"to_x":10,"to_y":50}}`, "Swipe", "up"},
{`{"step":5,"timestamp":"2026-04-20T10:00:04Z","next_action":{"kind":"Swipe","from_x":100,"from_y":50,"to_x":600,"to_y":50}}`, "Swipe", "right"},
{`{"step":6,"timestamp":"2026-04-20T10:00:05Z","next_action":{"kind":"PressKey","key":"back"}}`, "PressKey", "back"},
{`{"step":7,"timestamp":"2026-04-20T10:00:06Z","next_action":{"kind":"Wait","duration_millis":500}}`, "Wait", "500ms"},
}
for _, tc := range cases {
summary, _, err := decodeStepSummary([]byte(tc.line))
+1 -1
View File
@@ -38,7 +38,7 @@ func newFixtureServer(t *testing.T) (*Server, string) {
BundleID: "com.example",
}, []trace.Step{
{Index: 1, Timestamp: startedAt, Screen: "Home"},
{Index: 2, Timestamp: startedAt.Add(time.Second), Screen: "Home", Action: &trace.Action{Kind: "tap"}},
{Index: 2, Timestamp: startedAt.Add(time.Second), Screen: "Home", NextAction: &trace.Action{Kind: "tap"}},
{Index: 3, Timestamp: startedAt.Add(2 * time.Second), Screen: "Cart", Violations: []string{"propA"}},
})
writeRun(t, root, "run-b", trace.Meta{
+401 -71
View File
@@ -10,6 +10,8 @@ import (
"time"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/hierarchy"
@@ -18,6 +20,11 @@ import (
"github.com/priyanshujain/sanderling/internal/verifier"
)
// doubleTapGap is the inter-tap delay for ActionKindDoubleTap: short enough to
// land both events inside a sub-100 ms race window, long enough for adb
// `input tap` to serialize two MotionEvent streams.
const doubleTapGap = 50 * time.Millisecond
type Options struct {
Duration time.Duration
IdleTimeout time.Duration
@@ -53,13 +60,16 @@ func Run(ctx context.Context, options Options) (Summary, error) {
logger = slog.Default()
}
// Gate on the app actually being on top before acting, so the first
// action never fires against a leftover screen or a system dialog. Done
// before the deadline is set so the settle time does not eat the run.
waitForForeground(ctx, options, logger)
summary := Summary{StartTime: time.Now()}
deadline := summary.StartTime.Add(options.Duration)
stepIndex := 0
var lastAction *verifier.Action
var lastLogTime time.Time
var pendingPostScreenshotStep int
pendingPostScreenshot := false
for time.Now().Before(deadline) {
if err := ctx.Err(); err != nil {
break
@@ -67,10 +77,19 @@ func Run(ctx context.Context, options Options) (Summary, error) {
stepIndex++
stepStart := time.Now()
// Hierarchy, metrics, and logs are independent device reads — run
// Keep exploration scoped to the app under test. If a prior action
// backed out of (or otherwise left) the app, relaunch it before we
// observe or act, so properties never evaluate against a foreign app
// and actions never land outside the app.
if ensureForeground(ctx, options, logger, stepIndex) {
lastAction = nil
}
// Hierarchy, metrics, and logs are independent device reads. Run
// them concurrently so metrics+logs hide behind the hierarchy fetch.
var tree *hierarchy.Tree
var hierarchyErr error
var transitional bool
var metrics *trace.Metrics
var logs []verifier.LogEntry
@@ -79,11 +98,14 @@ func Run(ctx context.Context, options Options) (Summary, error) {
// goroutine, whose CDP round-trip can otherwise outrun the step
// budget on a hung tab.
g, gctx := errgroup.WithContext(ctx)
si := stepIndex
// fetchSyncedState issues a single Snapshot RPC so hierarchy and
// screenshot describe the same frame, then re-fetches the pair
// while the tree still looks transitional.
g.Go(func() error {
tree, hierarchyErr = fetchHierarchy(gctx, options.Driver)
tree, transitional, hierarchyErr = fetchSyncedState(gctx, options, logger, si)
return nil
})
si := stepIndex
g.Go(func() error {
metrics = captureMetrics(gctx, options, logger, si)
return nil
@@ -105,14 +127,6 @@ func Run(ctx context.Context, options Options) (Summary, error) {
return nil
})
}
if pendingPostScreenshot {
postStep := pendingPostScreenshotStep
g.Go(func() error {
captureScreenshot(gctx, options, logger, postStep, true)
return nil
})
pendingPostScreenshot = false
}
// All goroutines write to local variables and return nil, so the Wait
// error is always nil; ignored intentionally.
_ = g.Wait()
@@ -127,8 +141,32 @@ func Run(ctx context.Context, options Options) (Summary, error) {
if tree != nil {
treeSize = len(tree.Elements)
}
// A nil or empty tree means the sidecar's hierarchy fetch failed or
// returned nothing (e.g. transient device-side timeout). Pushing it
// would let spec extractors call findAll() and chain .map() on a null
// result; treat it like a transitional capture so the verifier is
// skipped, the step is still recorded, and the loop progresses.
if treeSize == 0 {
transitional = true
}
lastLogTime = stepStart
screen := ""
if tree != nil && len(tree.Elements) > 0 {
screen = tree.Elements[0].Screen
}
// Transitional trees describe a NavHost mid cross-fade. Pushing
// one would poison the verifier's previous/current extractor
// advance, so the next clean step would compare against this
// transient state and emit false-positive violations. We still
// record the step (hierarchy + screenshot) for inspect-side
// debugging, but skip the verifier entirely and pick the next
// action against the unchanged prior state to keep the loop
// progressing.
var violations []string
var extractorChanges map[string]trace.ExtractorChange
if !transitional {
if err := options.Verifier.PushSnapshot(verifier.SnapshotInput{
Tree: tree,
LastAction: lastAction,
@@ -146,19 +184,19 @@ func Run(ctx context.Context, options Options) (Summary, error) {
logger.Warn("v8 override skipped out-of-range entries",
"step", stepIndex, "skipped", skipped, "have", len(v8Overrides))
}
screen := ""
if tree != nil && len(tree.Elements) > 0 {
screen = tree.Elements[0].Screen
}
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
verdicts := options.Verifier.EvaluateProperties()
violations := violationNames(verdicts)
options.Verifier.EvaluateProperties()
violations = options.Verifier.NewlyViolatedProperties()
for _, name := range violations {
if predicateErr := options.Verifier.PredicateError(name); predicateErr != nil {
logger.Warn("predicate error", "step", stepIndex, "property", name, "err", predicateErr)
}
}
extractorChanges = encodeExtractorChanges(options.Verifier.ChangedExtractors())
} else {
logger.Warn("transitional tree after retry budget; skipping verifier",
"step", stepIndex, "screen", screen, "nodes", treeSize)
}
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
var nextAction verifier.Action
var nextErr error
@@ -179,20 +217,43 @@ func Run(ctx context.Context, options Options) (Summary, error) {
logger.Warn("residual encode failed", "step", stepIndex, "err", residualErr)
}
applySkipped := false
if nextErr == nil {
if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil {
if isWDADrop(err) {
return summary, fmt.Errorf("step %d: iOS XCTest runner lost connection - known WDA startup flake, re-run the test: %w", stepIndex, err)
}
if isTransientApplyError(ctx, err) {
logger.Warn("transient apply error; marking step transitional", "step", stepIndex, "err", err)
transitional = true
applySkipped = true
lastAction = nil
} else {
return summary, fmt.Errorf("step %d apply: %w", stepIndex, err)
}
} else {
actionCopy := nextAction
lastAction = &actionCopy
}
} else {
lastAction = nil
}
step := trace.Step{
Index: stepIndex,
Timestamp: stepStart,
Screen: screen,
Action: traceAction,
NextAction: traceAction,
Violations: violations,
Hierarchy: tree,
Residuals: residuals,
Metrics: metrics,
ExtractorChanges: extractorChanges,
Transitional: transitional,
}
if err := options.TraceWriter.WriteStep(step); err != nil {
return summary, fmt.Errorf("step %d trace: %w", stepIndex, err)
}
captureScreenshot(ctx, options, logger, stepIndex, false)
summary.Steps = stepIndex
if len(violations) > 0 {
summary.Violations = append(summary.Violations, ViolationRecord{
@@ -200,34 +261,19 @@ func Run(ctx context.Context, options Options) (Summary, error) {
Properties: violations,
})
}
if nextErr == nil {
if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil {
if isWDADrop(err) {
return summary, fmt.Errorf("step %d: iOS XCTest runner lost connection - known WDA startup flake, re-run the test: %w", stepIndex, err)
}
return summary, fmt.Errorf("step %d apply: %w", stepIndex, err)
}
actionCopy := nextAction
lastAction = &actionCopy
} else {
lastAction = nil
}
// Wait actions are themselves a settling: skip the idle poll. Actions
// that mutate the UI fall through to WaitForIdle so the next step's
// concurrent fetches observe a stable post-action state. A transient
// apply error means nothing landed, so the idle poll has nothing to
// settle and may itself hang on the same device condition.
if nextErr == nil && !applySkipped && nextAction.Kind != verifier.ActionKindWait {
idleCtx, idleCancel := context.WithTimeout(ctx, options.IdleTimeout)
idleErr := options.Driver.WaitForIdle(idleCtx, options.IdleTimeout)
if nextErr == nil {
pendingPostScreenshot = true
pendingPostScreenshotStep = stepIndex
}
if idleErr != nil && idleCtx.Err() == nil {
logger.Warn("wait_for_idle failed", "step", stepIndex, "err", idleErr)
}
idleCancel()
}
if pendingPostScreenshot {
captureScreenshot(ctx, options, logger, pendingPostScreenshotStep, true)
}
summary.EndTime = time.Now()
@@ -253,14 +299,109 @@ func validate(options Options) error {
return nil
}
func violationNames(verdicts map[string]ltl.Verdict) []string {
var names []string
for name, verdict := range verdicts {
if verdict == ltl.VerdictViolated {
names = append(names, name)
// ensureForeground keeps the app under test in the foreground. When the driver
// can report the foreground app and it no longer matches the bundle under test,
// the app is relaunched. Returns true when a relaunch happened so the caller
// can drop the now-stale lastAction. Drivers without ForegroundChecker (web,
// iOS) are a no-op.
func ensureForeground(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) bool {
checker, ok := options.Driver.(driver.ForegroundChecker)
if !ok || options.BundleID == "" {
return false
}
foreground, err := checker.ForegroundApp(ctx)
if err != nil {
logger.Warn("foreground check failed", "step", stepIndex, "err", err)
return false
}
return names
if foreground == "" || foreground == options.BundleID {
return false
}
logger.Warn("app left foreground; relaunching",
"step", stepIndex, "foreground", foreground, "want", options.BundleID)
return bringToForeground(ctx, options, logger, stepIndex)
}
// foregroundReadyAttempts bounds how many times waitForForeground tries to
// bring the app forward before the first step, so a stuck system dialog can
// never hang the run.
const foregroundReadyAttempts = 8
// waitForForeground blocks until the app under test is actually on screen, so
// the first observe never captures a leftover screen or a freshly-booted
// device's system dialog (e.g. Android's "set a screen lock" prompt). Drivers
// without ForegroundChecker (web) and an unknown foreground both skip the gate.
//
// It is not enough that the app is the resumed activity: ResumedActivity flips
// to a freshly launched app ~before its first frame draws, so gating on it
// alone lets the first observe read the outgoing app. When the driver can also
// report the focused window, the gate additionally waits for that window to
// name the app, which only happens once it is genuinely drawn.
func waitForForeground(ctx context.Context, options Options, logger *slog.Logger) {
checker, ok := options.Driver.(driver.ForegroundChecker)
if !ok || options.BundleID == "" {
return
}
focusChecker, hasFocus := options.Driver.(driver.FocusedWindowChecker)
for attempt := range foregroundReadyAttempts {
if err := ctx.Err(); err != nil {
return
}
foreground, err := checker.ForegroundApp(ctx)
if err != nil {
logger.Warn("foreground check failed before first step", "err", err)
return
}
if foreground == "" {
return // foreground unknowable (e.g. iOS); don't block the run
}
if foreground != options.BundleID {
logger.Warn("app not in foreground at start; bringing it forward",
"foreground", foreground, "want", options.BundleID, "attempt", attempt)
bringToForeground(ctx, options, logger, 0)
continue
}
if !hasFocus {
return // resumed is the app and no finer signal exists
}
focused, err := focusChecker.FocusedWindowApp(ctx)
if err != nil {
logger.Warn("focus check failed before first step", "err", err)
return
}
if focused == options.BundleID {
return // window is drawn; safe to observe
}
logger.Warn("app resumed but window not yet drawn; waiting",
"focused", focused, "want", options.BundleID, "attempt", attempt)
settleForForeground(ctx, options)
}
logger.Warn("app never reached foreground before first step; proceeding anyway",
"want", options.BundleID)
}
// bringToForeground returns the app under test to the foreground. It first
// presses BACK to dismiss any modal system dialog (a relaunch alone does not
// close one), then relaunches and waits for the UI to settle. Returns true
// when the relaunch itself succeeded.
func bringToForeground(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) bool {
if err := options.Driver.PressKey(ctx, "back"); err != nil {
logger.Warn("dismiss key before relaunch failed", "step", stepIndex, "err", err)
}
if err := options.Driver.Launch(ctx, options.BundleID, false, nil); err != nil {
logger.Warn("relaunch failed", "step", stepIndex, "err", err)
return false
}
settleForForeground(ctx, options)
return true
}
// settleForForeground waits one idle window for the UI to settle, bounding the
// wait by the driver's idle timeout.
func settleForForeground(ctx context.Context, options Options) {
idleCtx, cancel := context.WithTimeout(ctx, options.IdleTimeout)
_ = options.Driver.WaitForIdle(idleCtx, options.IdleTimeout)
cancel()
}
func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) error {
@@ -274,6 +415,43 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A
return drv.TapSelector(ctx, action.On)
}
return drv.Tap(ctx, x, y)
case verifier.ActionKindDoubleTap:
x, y, ok := resolveCoordinates(action, tree)
tap := func() error {
if !ok {
if action.On == "" {
return nil
}
return drv.TapSelector(ctx, action.On)
}
return drv.Tap(ctx, x, y)
}
if err := tap(); err != nil {
return err
}
timer := time.NewTimer(doubleTapGap)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
}
return tap()
case verifier.ActionKindLongPress:
x, y, ok := resolveCoordinates(action, tree)
if !ok {
// No long-press-by-selector RPC exists, so an unresolved target is
// nothing we can dispatch; skip rather than error.
return nil
}
return drv.LongPress(ctx, x, y)
case verifier.ActionKindScroll:
fromX, fromY, toX, toY := scrollEndpoints(action, tree)
duration := time.Duration(action.DurationMillis) * time.Millisecond
if duration <= 0 {
duration = 300 * time.Millisecond
}
return drv.Swipe(ctx, fromX, fromY, toX, toY, duration)
case verifier.ActionKindInputText:
if x, y, ok := resolveCoordinates(action, tree); ok {
if err := drv.Tap(ctx, x, y); err != nil {
@@ -360,18 +538,139 @@ func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int,
return 0, 0, false
}
func fetchHierarchy(ctx context.Context, drv driver.DeviceDriver) (*hierarchy.Tree, error) {
xmlText, err := drv.Hierarchy(ctx)
if err != nil {
return nil, err
// scrollEndpoints lowers a Scroll to a swipe's from/to points. Pre-computed
// endpoints (from the generator) win. Otherwise it derives them from the
// container bounds: the named node when On resolves, else the whole screen.
func scrollEndpoints(action verifier.Action, tree *hierarchy.Tree) (fromX, fromY, toX, toY int) {
if action.FromX != 0 || action.FromY != 0 || action.ToX != 0 || action.ToY != 0 {
return action.FromX, action.FromY, action.ToX, action.ToY
}
return hierarchy.Parse(xmlText)
bounds := scrollBounds(action, tree)
cx, cy := bounds.Center()
width := bounds.Width()
height := bounds.Height()
toX, toY = cx, cy
// Scroll direction names content motion; the gesture swipes the opposite
// way. Revealing lower content ("down") drags the finger up, so toY drops.
switch action.Direction {
case "down":
toY = cy - (4*height)/10
case "up":
toY = cy + (4*height)/10
case "left":
toX = cx + (4*width)/10
case "right":
toX = cx - (4*width)/10
}
if toX < 0 {
toX = 0
}
if toY < 0 {
toY = 0
}
return cx, cy, toX, toY
}
// scrollBounds returns the container bounds for an authored Scroll: the node
// named by On when it resolves, otherwise the root (whole-screen) bounds.
func scrollBounds(action verifier.Action, tree *hierarchy.Tree) hierarchy.Bounds {
if tree == nil {
return hierarchy.Bounds{}
}
if action.On != "" {
if element := tree.Find(action.On); element != nil {
return element.Bounds
}
}
if tree.Root != nil {
return tree.Root.Bounds
}
return hierarchy.Bounds{}
}
// transitionalRetryAttempts caps how many times we re-fetch hierarchy when a
// tree carries more than one route-level Screen tag (NavHost cross-fade in
// flight). Each retry pauses transitionalRetrySleep before the next fetch.
const (
transitionalRetryAttempts = 4
transitionalRetrySleep = 200 * time.Millisecond
)
// fetchSyncedState fetches hierarchy and screenshot together so the recorded
// pair shows the same UI moment. If the hierarchy looks like a NavHost
// cross-fade (multiple route-level *Screen tags), the function waits briefly
// and re-fetches the pair, up to transitionalRetryAttempts times. This
// handles transitions whose async work begins after the sidecar's settle
// poll has already exited.
//
// The driver's Snapshot RPC captures both reads under a backend-side mutex
// so they describe the same on-device frame; the retry exists for the
// orthogonal case where the frame itself is transitional.
//
// The transitional return reports whether the retry budget was exhausted
// on a still-transitional tree. Callers use it to skip the verifier for
// that step so the previous/current extractor advance does not absorb
// transient state.
func fetchSyncedState(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) (tree *hierarchy.Tree, transitional bool, err error) {
var pngBytes []byte
retryLoop:
for attempt := range transitionalRetryAttempts {
hierarchyJSON, image, snapshotErr := options.Driver.Snapshot(ctx)
if snapshotErr != nil {
err = snapshotErr
tree = nil
} else {
tree, err = hierarchy.Parse(hierarchyJSON)
pngBytes = image.PNG
}
if err != nil || !isTransitionalHierarchy(tree) {
break
}
if attempt == transitionalRetryAttempts-1 {
transitional = true
break
}
timer := time.NewTimer(transitionalRetrySleep)
select {
case <-ctx.Done():
timer.Stop()
break retryLoop
case <-timer.C:
}
}
if len(pngBytes) > 0 {
if writeErr := options.TraceWriter.WriteScreenshot(stepIndex, pngBytes); writeErr != nil {
logger.Warn("screenshot write failed", "step", stepIndex, "err", writeErr)
}
}
return tree, transitional, err
}
// isTransitionalHierarchy returns true when the tree carries more than one
// resource-id ending in "Screen" - the marker of a Compose NavHost mid
// cross-fade where both source and destination route composables are alive.
// Mirrors the sidecar's stabilitySnapshot heuristic so runner-side rejection
// stays consistent with the settle poll.
func isTransitionalHierarchy(tree *hierarchy.Tree) bool {
if tree == nil {
return false
}
screens := 0
for _, element := range tree.Elements {
if strings.HasSuffix(element.ResourceID, "Screen") {
screens++
if screens > 1 {
return true
}
}
}
return false
}
func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action {
traceAction := &trace.Action{Kind: string(action.Kind), X: action.X, Y: action.Y}
switch action.Kind {
case verifier.ActionKindTap:
case verifier.ActionKindTap, verifier.ActionKindDoubleTap, verifier.ActionKindLongPress:
traceAction.Selector = action.On
stampSelectorTarget(traceAction, action, tree)
case verifier.ActionKindInputText:
@@ -386,6 +685,15 @@ func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action
traceAction.DurationMillis = action.DurationMillis
traceAction.X = 0
traceAction.Y = 0
case verifier.ActionKindScroll:
fromX, fromY, toX, toY := scrollEndpoints(action, tree)
traceAction.FromX = fromX
traceAction.FromY = fromY
traceAction.ToX = toX
traceAction.ToY = toY
traceAction.DurationMillis = action.DurationMillis
traceAction.X = 0
traceAction.Y = 0
case verifier.ActionKindPressKey:
traceAction.Key = action.Key
case verifier.ActionKindWait:
@@ -469,6 +777,8 @@ func nextActionFromV8(ctx context.Context, web driver.WebDriver) (verifier.Actio
switch decoded.Kind {
case "Tap":
return verifier.Action{Kind: verifier.ActionKindTap, X: decoded.X, Y: decoded.Y}, nil
case "DoubleTap":
return verifier.Action{Kind: verifier.ActionKindDoubleTap, X: decoded.X, Y: decoded.Y}, nil
case "InputText":
return verifier.Action{
Kind: verifier.ActionKindInputText,
@@ -493,24 +803,18 @@ func nextActionFromV8(ctx context.Context, web driver.WebDriver) (verifier.Actio
}
}
func captureScreenshot(ctx context.Context, options Options, logger *slog.Logger, stepIndex int, after bool) {
image, err := options.Driver.Screenshot(ctx)
if err != nil {
logger.Warn("screenshot capture failed", "step", stepIndex, "after", after, "err", err)
return
func encodeExtractorChanges(changes map[string]verifier.ExtractorChange) map[string]trace.ExtractorChange {
if len(changes) == 0 {
return nil
}
if len(image.PNG) == 0 {
return
out := make(map[string]trace.ExtractorChange, len(changes))
for name, change := range changes {
out[name] = trace.ExtractorChange{
Prev: json.RawMessage(change.Prev),
Curr: json.RawMessage(change.Curr),
}
var writeErr error
if after {
writeErr = options.TraceWriter.WriteScreenshotAfter(stepIndex, image.PNG)
} else {
writeErr = options.TraceWriter.WriteScreenshot(stepIndex, image.PNG)
}
if writeErr != nil {
logger.Warn("screenshot write failed", "step", stepIndex, "after", after, "err", writeErr)
}
return out
}
func encodeResiduals(residuals map[string]ltl.Formula) (map[string]json.RawMessage, error) {
@@ -537,3 +841,29 @@ func isWDADrop(err error) bool {
return strings.Contains(msg, "ConnectException") ||
(strings.Contains(msg, "code = Internal") && strings.Contains(msg, "SocketException"))
}
// isTransientApplyError reports whether an applyAction failure is a transient
// device-side hang (sidecar RPC deadline, momentary unavailability) rather than
// a fatal condition. Such steps are recorded as transitional and the loop
// continues. The run context being cancelled is never transient: it means the
// caller wants to stop.
func isTransientApplyError(runCtx context.Context, err error) bool {
if err == nil || runCtx.Err() != nil {
return false
}
if s, ok := status.FromError(err); ok {
switch s.Code() {
case codes.DeadlineExceeded, codes.Unavailable:
return true
case codes.Internal:
message := s.Message()
if strings.Contains(message, "DEADLINE_EXCEEDED") || strings.Contains(message, "UNAVAILABLE") {
return true
}
}
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
return false
}
+764 -17
View File
@@ -1,8 +1,10 @@
package runner
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -13,8 +15,12 @@ import (
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"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"
)
@@ -118,6 +124,76 @@ func TestRunner_ViolationSurfacesInSummary(t *testing.T) {
}
}
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_ThrowingPredicateIsLoggedNotPanic(t *testing.T) {
const throwingSpec = `
globalThis.properties = {
@@ -294,6 +370,151 @@ func TestApplyAction_V8InputTextAtOriginStillTaps(t *testing.T) {
}
}
func TestApplyAction_DoubleTapDispatchesTwoTapsAtCoordinates(t *testing.T) {
driverMock := mockdriver.New()
action := verifier.Action{Kind: verifier.ActionKindDoubleTap, X: 100, Y: 200}
start := time.Now()
if err := applyAction(context.Background(), driverMock, action, nil); err != nil {
t.Fatalf("apply action: %v", err)
}
elapsed := time.Since(start)
if elapsed < 40*time.Millisecond {
t.Errorf("expected >= 40ms gap between taps, elapsed %v", elapsed)
}
taps := 0
for _, a := range driverMock.Actions() {
if a.Kind == mockdriver.ActionTap && a.X == 100 && a.Y == 200 {
taps++
}
}
if taps != 2 {
t.Errorf("expected 2 Tap calls at (100,200), got %d in %v", taps, driverMock.Actions())
}
}
func TestApplyAction_DoubleTapDispatchesTwoSelectorTaps(t *testing.T) {
driverMock := mockdriver.New()
action := verifier.Action{Kind: verifier.ActionKindDoubleTap, On: "id:save"}
if err := applyAction(context.Background(), driverMock, action, nil); err != nil {
t.Fatalf("apply action: %v", err)
}
taps := 0
for _, a := range driverMock.Actions() {
if a.Kind == mockdriver.ActionTapSelector && a.Selector == "id:save" {
taps++
}
}
if taps != 2 {
t.Errorf("expected 2 TapSelector calls 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}
if err := applyAction(context.Background(), driverMock, action, nil); err != nil {
t.Fatalf("apply action: %v", err)
}
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,
}
if err := applyAction(context.Background(), driverMock, action, nil); err != nil {
t.Fatalf("apply action: %v", err)
}
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())
}
}
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"}
if err := applyAction(context.Background(), driverMock, action, tree); err != nil {
t.Fatalf("apply action: %v", err)
}
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_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"}
if err := applyAction(context.Background(), driverMock, action, tree); err != nil {
t.Fatalf("apply action: %v", err)
}
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)
}
}
func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) {
state := newHarness(t)
state.mock.MetricsData = driver.Metrics{CPUPercent: 5.0, HeapBytes: 1024, TotalMemoryBytes: 4096}
@@ -316,19 +537,19 @@ func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) {
}
actions := state.mock.Actions()
var hasHierarchy, hasMetrics, hasLogs bool
var hasSnapshot, hasMetrics, hasLogs bool
for _, a := range actions {
switch a.Kind {
case mockdriver.ActionHierarchy:
hasHierarchy = true
case mockdriver.ActionSnapshot:
hasSnapshot = true
case mockdriver.ActionMetrics:
hasMetrics = true
case mockdriver.ActionRecentLogs:
hasLogs = true
}
}
if !hasHierarchy {
t.Error("expected Hierarchy call in mock actions")
if !hasSnapshot {
t.Error("expected Snapshot call in mock actions")
}
if !hasMetrics {
t.Error("expected Metrics call in mock actions")
@@ -338,7 +559,56 @@ func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) {
}
}
func TestRunner_PipelinedPostScreenshotWritten(t *testing.T) {
// 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)
}
if hierarchyCalls != 0 {
t.Errorf("expected zero standalone Hierarchy calls (runner must use Snapshot), got %d", hierarchyCalls)
}
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}
@@ -355,24 +625,362 @@ func TestRunner_PipelinedPostScreenshotWritten(t *testing.T) {
t.Fatalf("Run: %v", err)
}
if summary.Steps < 2 {
t.Fatalf("need at least 2 steps for pipelining test, got %d", summary.Steps)
t.Fatalf("need at least 2 steps for screenshot test, got %d", summary.Steps)
}
screenshotDir := filepath.Join(state.writer.Directory(), "screenshots")
preFile := filepath.Join(screenshotDir, "step-00001.png")
if _, err := os.Stat(preFile); os.IsNotExist(err) {
t.Errorf("expected pre-screenshot for step 1: %s", preFile)
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)
}
}
postFile := filepath.Join(screenshotDir, "step-00001-after.png")
if _, err := os.Stat(postFile); os.IsNotExist(err) {
t.Errorf("expected pipelined post-screenshot for step 1: %s", postFile)
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())
}
}
}
// TestIsTransitionalHierarchy_DetectsMultipleScreens covers the runner-side
// guard that re-fetches when the hierarchy still carries two route-level
// *Screen ids - the NavHost cross-fade signature.
func TestIsTransitionalHierarchy_DetectsMultipleScreens(t *testing.T) {
multi, err := hierarchy.Parse(`{"attributes":{"resource-id":"root"},"children":[
{"attributes":{"resource-id":"AddAccountScreen"},"children":[]},
{"attributes":{"resource-id":"HomeScreen"},"children":[]}
]}`)
if err != nil {
t.Fatal(err)
}
if !isTransitionalHierarchy(multi) {
t.Error("expected multi-screen tree to be flagged as transitional")
}
lastAfter := filepath.Join(screenshotDir, fmt.Sprintf("step-%05d-after.png", summary.Steps))
if _, err := os.Stat(lastAfter); os.IsNotExist(err) {
t.Errorf("expected flushed post-screenshot for last step %d: %s", summary.Steps, lastAfter)
single, err := hierarchy.Parse(`{"attributes":{"resource-id":"HomeScreen"},"children":[]}`)
if err != nil {
t.Fatal(err)
}
if isTransitionalHierarchy(single) {
t.Error("single-screen tree must not be flagged as transitional")
}
if isTransitionalHierarchy(nil) {
t.Error("nil tree must not be flagged as transitional")
}
}
// TestRunner_TransitionalSkipsVerifier feeds a driver whose hierarchy stays
// transitional (multiple route-level *Screen ids) on every Snapshot call.
// Every step must be marked transitional in the trace, no violations may be
// emitted (the verifier never ran), and the summary must stay clean even
// though the spec is a guaranteed always-false predicate.
func TestRunner_TransitionalSkipsVerifier(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":[]}
]}`
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 len(summary.Violations) != 0 {
t.Fatalf("verifier must be skipped on transitional 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(), "transient apply error") {
t.Errorf("expected transient-apply 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)
}
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)
}
}
// TestIsTransientApplyError_Classification covers the helper's matching rules
// directly so future code changes don't quietly drop a transient case.
func TestIsTransientApplyError_Classification(t *testing.T) {
cleanCtx := context.Background()
cancelledCtx, cancel := context.WithCancel(context.Background())
cancel()
cases := []struct {
name string
ctx context.Context
err error
want bool
}{
{"nil error", cleanCtx, nil, false},
{"deadline exceeded", cleanCtx, status.Error(codes.DeadlineExceeded, "boom"), true},
{"unavailable", cleanCtx, status.Error(codes.Unavailable, "boom"), true},
{"internal wrapping deadline", cleanCtx, status.Error(codes.Internal, "io.grpc.StatusRuntimeException: DEADLINE_EXCEEDED: ..."), true},
{"internal wrapping unavailable", cleanCtx, status.Error(codes.Internal, "io.grpc.StatusRuntimeException: UNAVAILABLE: ..."), true},
{"internal generic", cleanCtx, status.Error(codes.Internal, "boom"), false},
{"raw context deadline", cleanCtx, context.DeadlineExceeded, true},
{"run context cancelled overrides", cancelledCtx, status.Error(codes.DeadlineExceeded, "boom"), false},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
if got := isTransientApplyError(testCase.ctx, testCase.err); got != testCase.want {
t.Errorf("got %v, want %v", got, testCase.want)
}
})
}
}
// 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 = `
globalThis.actions = __sanderling__.actions(() => [__sanderling__.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)
}
}
}
@@ -426,3 +1034,142 @@ func containsProperty(records []ViolationRecord, property string) bool {
}
return false
}
func TestRunner_RelaunchesWhenAppLeavesForeground(t *testing.T) {
state := newHarness(t)
// Always report a foreign app, so every step's guard must relaunch.
state.mock.ForegroundResults = []string{"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) {
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) {
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")
}
}
}
+1
View File
@@ -63,6 +63,7 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
_ = sidecarCommand.Process.Kill()
return nil, nil, fmt.Errorf("dial sidecar: %w", err)
}
driverClient.SetPlatform(options.Platform)
// WaitForHealth confirms the gRPC sidecar is up. For iOS, the WDA warmup
// (absorbing the XCUITest startup race) runs inside IosDriverBackend.init
// in the sidecar - no additional sleep needed here.
+5 -1
View File
@@ -50,6 +50,7 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
if specAPIPath != "" {
aliases["@sanderling/spec"] = specAPIPath
base := filepath.Dir(specAPIPath)
aliases["@sanderling/spec/defaults"] = filepath.Join(base, "defaults/index.ts")
aliases["@sanderling/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts")
}
defines := map[string]string{
@@ -104,7 +105,10 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
if seed == 0 {
seed = time.Now().UnixNano()
}
verifierInstance, err := verifier.New(verifier.WithRand(rand.New(rand.NewPCG(uint64(seed), 0))))
verifierInstance, err := verifier.New(
verifier.WithRand(rand.New(rand.NewPCG(uint64(seed), 0))),
verifier.WithAppPackage(options.BundleID),
)
if err != nil {
return fmt.Errorf("verifier: %w", err)
}
+17 -8
View File
@@ -17,12 +17,28 @@ type Step struct {
Timestamp time.Time `json:"timestamp"`
Screen string `json:"screen,omitempty"`
Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
Action *Action `json:"action,omitempty"`
// NextAction is the action chosen for the next iteration based on observing this step.
NextAction *Action `json:"next_action,omitempty"`
Exceptions []Exception `json:"exceptions,omitempty"`
Violations []string `json:"violations,omitempty"`
Hierarchy *hierarchy.Tree `json:"hierarchy,omitempty"`
Residuals map[string]json.RawMessage `json:"residuals,omitempty"`
Metrics *Metrics `json:"metrics,omitempty"`
ExtractorChanges map[string]ExtractorChange `json:"extractor_changes,omitempty"`
// Transitional marks a step whose hierarchy still showed a NavHost
// cross-fade (multiple route-level *Screen ids) after the runner's
// retry budget. The verifier is skipped for these steps so transient
// state does not poison the previous/current extractor advance.
Transitional bool `json:"transitional,omitempty"`
}
// ExtractorChange records the prev/curr JSON values of an extractor whose
// observation differed between two consecutive steps. Surfaced under
// violation rows in the inspect UI as a "what changed at this step"
// breadcrumb.
type ExtractorChange struct {
Prev json.RawMessage `json:"prev"`
Curr json.RawMessage `json:"curr"`
}
type Metrics struct {
@@ -125,13 +141,6 @@ func (w *Writer) WriteScreenshot(stepIndex int, png []byte) error {
return w.writePNG(fmt.Sprintf("step-%05d.png", stepIndex), png)
}
// WriteScreenshotAfter writes the post-action screenshot for a step.
// Callers use this after applyAction + waitForIdle so the UI can show a
// before/after pair.
func (w *Writer) WriteScreenshotAfter(stepIndex int, png []byte) error {
return w.writePNG(fmt.Sprintf("step-%05d-after.png", stepIndex), png)
}
func (w *Writer) writePNG(name string, png []byte) error {
if len(png) == 0 {
return nil
+9 -9
View File
@@ -102,7 +102,7 @@ func TestWriteStep_HierarchyAndResidualsRoundTrip(t *testing.T) {
step := Step{
Index: 1,
Timestamp: time.Now().UTC(),
Action: &Action{
NextAction: &Action{
Kind: "tap",
Selector: "id:next",
ResolvedBounds: &BoundsRecord{X: 10, Y: 20, Width: 100, Height: 50},
@@ -120,14 +120,14 @@ func TestWriteStep_HierarchyAndResidualsRoundTrip(t *testing.T) {
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("bad jsonl: %v\n%s", err, body)
}
if got.Action.Selector != "id:next" {
t.Errorf("selector = %q", got.Action.Selector)
if got.NextAction.Selector != "id:next" {
t.Errorf("selector = %q", got.NextAction.Selector)
}
if got.Action.ResolvedBounds == nil || got.Action.ResolvedBounds.Width != 100 {
t.Errorf("resolved_bounds round-trip wrong: %+v", got.Action.ResolvedBounds)
if got.NextAction.ResolvedBounds == nil || got.NextAction.ResolvedBounds.Width != 100 {
t.Errorf("resolved_bounds round-trip wrong: %+v", got.NextAction.ResolvedBounds)
}
if got.Action.TapPoint == nil || got.Action.TapPoint.X != 60 {
t.Errorf("tap_point round-trip wrong: %+v", got.Action.TapPoint)
if got.NextAction.TapPoint == nil || got.NextAction.TapPoint.X != 60 {
t.Errorf("tap_point round-trip wrong: %+v", got.NextAction.TapPoint)
}
if string(got.Residuals["prop1"]) != `{"op":"true"}` {
t.Errorf("residuals round-trip wrong: %s", got.Residuals["prop1"])
@@ -162,7 +162,7 @@ func TestWriteStep_AppendsOneJsonLine(t *testing.T) {
Snapshots: map[string]json.RawMessage{
"ledger.balance": json.RawMessage(`1500`),
},
Action: &Action{Kind: "tap", X: 100, Y: 200},
NextAction: &Action{Kind: "tap", X: 100, Y: 200},
Violations: []string{"ledgerBalanceMatchesTxns"},
}
if err := writer.WriteStep(step); err != nil {
@@ -177,7 +177,7 @@ func TestWriteStep_AppendsOneJsonLine(t *testing.T) {
if err := json.Unmarshal([]byte(lines[0]), &got); err != nil {
t.Fatalf("invalid JSONL line: %v\n%s", err, lines[0])
}
if got.Index != 1 || got.Screen != "customer_ledger" || got.Action.X != 100 || got.Violations[0] != "ledgerBalanceMatchesTxns" {
if got.Index != 1 || got.Screen != "customer_ledger" || got.NextAction.X != 100 || got.Violations[0] != "ledgerBalanceMatchesTxns" {
t.Errorf("step round-trip wrong: %+v", got)
}
}
+78 -3
View File
@@ -10,6 +10,12 @@ import (
type extractorState struct {
getter goja.Callable
handle *goja.Object
name string
// prev/curr cache the JSON-encoded extractor values from the prior and
// current PushSnapshot, used by ChangedExtractors to surface per-step
// diffs in the trace.
prev []byte
curr []byte
}
type formulaState struct {
@@ -63,9 +69,13 @@ const (
internalKindActions = "actions"
internalKindWeighted = "weighted"
internalKindBuiltinTaps = "taps"
internalKindBuiltinDoubleTaps = "doubleTaps"
internalKindBuiltinTyping = "typing"
internalKindBuiltinSwipes = "swipes"
internalKindBuiltinWaitOnce = "waitOnce"
internalKindBuiltinPressKey = "pressKey"
internalKindBuiltinLongPresses = "longPresses"
internalKindBuiltinScrolls = "scrolls"
)
// installRuntimeBindings exposes globalThis.__sanderling__ to the loaded spec.
@@ -99,6 +109,15 @@ func (v *Verifier) installRuntimeBindings() error {
if err := sanderling.Set("tap", v.bindTap); err != nil {
return err
}
if err := sanderling.Set("doubleTap", v.bindDoubleTap); err != nil {
return err
}
if err := sanderling.Set("longPress", v.bindLongPress); err != nil {
return err
}
if err := sanderling.Set("scroll", v.bindScroll); err != nil {
return err
}
if err := sanderling.Set("inputText", v.bindInputText); err != nil {
return err
}
@@ -114,6 +133,12 @@ func (v *Verifier) installRuntimeBindings() error {
if err := sanderling.Set("taps", v.builtinGenerator(internalKindBuiltinTaps)); err != nil {
return err
}
if err := sanderling.Set("doubleTaps", v.builtinGenerator(internalKindBuiltinDoubleTaps)); err != nil {
return err
}
if err := sanderling.Set("typing", v.builtinGenerator(internalKindBuiltinTyping)); err != nil {
return err
}
if err := sanderling.Set("swipes", v.builtinGenerator(internalKindBuiltinSwipes)); err != nil {
return err
}
@@ -123,24 +148,40 @@ func (v *Verifier) installRuntimeBindings() error {
if err := sanderling.Set("pressKeys", v.builtinGenerator(internalKindBuiltinPressKey)); err != nil {
return err
}
if err := sanderling.Set("longPresses", v.builtinGenerator(internalKindBuiltinLongPresses)); err != nil {
return err
}
if err := sanderling.Set("scrolls", v.builtinGenerator(internalKindBuiltinScrolls)); err != nil {
return err
}
return v.runtime.GlobalObject().Set("__sanderling__", sanderling)
}
func (v *Verifier) bindExtract(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 1 {
panic(v.runtime.NewTypeError("extract requires exactly one argument"))
if len(call.Arguments) < 1 || len(call.Arguments) > 2 {
panic(v.runtime.NewTypeError("extract requires (getter) or (getter, name)"))
}
getter, ok := goja.AssertFunction(call.Arguments[0])
if !ok {
panic(v.runtime.NewTypeError("extract argument must be a function"))
}
name := ""
if len(call.Arguments) == 2 {
arg := call.Arguments[1]
if !goja.IsUndefined(arg) && !goja.IsNull(arg) {
name = arg.String()
}
}
if name == "" {
name = fmt.Sprintf("extractor_%d", len(v.extractors))
}
handle := v.runtime.NewObject()
_ = handle.Set("current", goja.Undefined())
_ = handle.Set("previous", goja.Undefined())
v.extractors = append(v.extractors, &extractorState{getter: getter, handle: handle})
v.extractors = append(v.extractors, &extractorState{getter: getter, handle: handle, name: name})
return handle
}
@@ -387,6 +428,40 @@ func (v *Verifier) bindTap(call goja.FunctionCall) goja.Value {
return handle
}
func (v *Verifier) bindDoubleTap(call goja.FunctionCall) goja.Value {
parameters := call.Argument(0).ToObject(v.runtime)
if parameters == nil {
panic(v.runtime.NewTypeError("DoubleTap requires {on}"))
}
handle := v.runtime.NewObject()
_ = handle.Set("kind", "DoubleTap")
_ = handle.Set("on", parameters.Get("on"))
return handle
}
func (v *Verifier) bindLongPress(call goja.FunctionCall) goja.Value {
parameters := call.Argument(0).ToObject(v.runtime)
if parameters == nil {
panic(v.runtime.NewTypeError("LongPress requires {on}"))
}
handle := v.runtime.NewObject()
_ = handle.Set("kind", "LongPress")
_ = handle.Set("on", parameters.Get("on"))
return handle
}
func (v *Verifier) bindScroll(call goja.FunctionCall) goja.Value {
parameters := call.Argument(0).ToObject(v.runtime)
if parameters == nil {
panic(v.runtime.NewTypeError("Scroll requires {direction}"))
}
handle := v.runtime.NewObject()
_ = handle.Set("kind", "Scroll")
_ = handle.Set("direction", parameters.Get("direction"))
_ = handle.Set("in", parameters.Get("in"))
return handle
}
func (v *Verifier) bindInputText(call goja.FunctionCall) goja.Value {
parameters := call.Argument(0).ToObject(v.runtime)
if parameters == nil {
+85 -8
View File
@@ -3,6 +3,7 @@ package verifier
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/dop251/goja"
@@ -69,7 +70,7 @@ func accessibilityObject(runtime *goja.Runtime, tree *hierarchy.Tree) *goja.Obje
if node == nil {
return goja.Undefined()
}
return nodeObject(runtime, node, selectorStringFromJS(call.Argument(0)))
return nodeObject(runtime, node, selectorStringFromJS(runtime, call.Argument(0)))
}
findAll := func(call goja.FunctionCall) goja.Value {
if tree == nil {
@@ -78,7 +79,7 @@ func accessibilityObject(runtime *goja.Runtime, tree *hierarchy.Tree) *goja.Obje
nodes := findAllNodesFromJS(runtime, tree, call.Argument(0))
array := runtime.NewArray()
for i, n := range nodes {
_ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(call.Argument(0))))
_ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(runtime, call.Argument(0))))
}
return array
}
@@ -100,6 +101,7 @@ func nodeObject(runtime *goja.Runtime, node *hierarchy.Node, selector string) go
_ = object.Set("checked", element.Checked)
_ = object.Set("focused", element.Focused)
_ = object.Set("selected", element.Selected)
_ = object.Set("editable", element.Editable)
_ = object.Set("x", centerX)
_ = object.Set("y", centerY)
_ = object.Set(tagSelector, selector)
@@ -120,14 +122,14 @@ func nodeObject(runtime *goja.Runtime, node *hierarchy.Node, selector string) go
if childNode == nil {
return goja.Undefined()
}
return nodeObject(runtime, childNode, selectorStringFromJS(arg))
return nodeObject(runtime, childNode, selectorStringFromJS(runtime, arg))
}
childFindAll := func(call goja.FunctionCall) goja.Value {
arg := call.Argument(0)
childNodes := findAllNodesInSubtreeFromJS(runtime, node, arg)
array := runtime.NewArray()
for i, n := range childNodes {
_ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(arg)))
_ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(runtime, arg)))
}
return array
}
@@ -265,15 +267,62 @@ func selectorPathFromJS(runtime *goja.Runtime, arg goja.Value) ([]hierarchy.Sele
// selectorStringFromJS returns a string representation of the selector argument
// for tagging returned element objects (used by selectorOf to reconstruct the
// selector when the element is passed back as an action target).
func selectorStringFromJS(arg goja.Value) string {
if goja.IsUndefined(arg) || goja.IsNull(arg) {
// selector when the element is passed back as an action target). Output
// follows the canonical hierarchy selector grammar: "k:v" pairs space-joined
// per object, chains joined by " > ".
func selectorStringFromJS(runtime *goja.Runtime, arg goja.Value) string {
if arg == nil || goja.IsUndefined(arg) || goja.IsNull(arg) {
return ""
}
if s, ok := arg.Export().(string); ok {
return s
}
return arg.String()
exported := arg.Export()
if slice, ok := exported.([]any); ok {
object := arg.ToObject(runtime)
if object == nil {
return ""
}
parts := make([]string, 0, len(slice))
for index := range slice {
entry := object.Get(fmt.Sprintf("%d", index))
if entry == nil || goja.IsUndefined(entry) || goja.IsNull(entry) {
continue
}
segment := selectorObjectToString(runtime, entry)
if segment == "" {
continue
}
parts = append(parts, segment)
}
return strings.Join(parts, " > ")
}
return selectorObjectToString(runtime, arg)
}
// selectorObjectToString formats a single JS object as a space-joined sequence
// of "k:v" pairs, mirroring the hierarchy package's predicate grammar.
func selectorObjectToString(runtime *goja.Runtime, arg goja.Value) string {
if arg == nil || goja.IsUndefined(arg) || goja.IsNull(arg) {
return ""
}
object := arg.ToObject(runtime)
if object == nil {
return ""
}
keys := object.Keys()
parts := make([]string, 0, len(keys))
for _, key := range keys {
if key == tagSelector {
continue
}
value := object.Get(key)
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
continue
}
parts = append(parts, fmt.Sprintf("%s:%s", key, value.String()))
}
return strings.Join(parts, " ")
}
func lastActionObject(runtime *goja.Runtime, action *Action) goja.Value {
@@ -301,6 +350,16 @@ func lastActionObject(runtime *goja.Runtime, action *Action) goja.Value {
if action.DurationMillis > 0 {
_ = object.Set("durationMillis", action.DurationMillis)
}
case ActionKindScroll:
_ = object.Set("direction", action.Direction)
from := runtime.NewObject()
_ = from.Set("x", action.FromX)
_ = from.Set("y", action.FromY)
to := runtime.NewObject()
_ = to.Set("x", action.ToX)
_ = to.Set("y", action.ToY)
_ = object.Set("from", from)
_ = object.Set("to", to)
case ActionKindPressKey:
_ = object.Set("key", action.Key)
case ActionKindWait:
@@ -371,6 +430,10 @@ func jsValueToAction(runtime *goja.Runtime, value goja.Value) (Action, error) {
on := object.Get("on")
x, y := coordinatesOf(runtime, on)
return Action{Kind: ActionKindTap, On: selectorOf(runtime, on), X: x, Y: y}, nil
case "DoubleTap":
on := object.Get("on")
x, y := coordinatesOf(runtime, on)
return Action{Kind: ActionKindDoubleTap, On: selectorOf(runtime, on), X: x, Y: y}, nil
case "InputText":
into := object.Get("into")
text := object.Get("text")
@@ -395,6 +458,20 @@ func jsValueToAction(runtime *goja.Runtime, value goja.Value) (Action, error) {
ToY: toY,
DurationMillis: intField(object, "durationMillis"),
}, nil
case "LongPress":
on := object.Get("on")
x, y := coordinatesOf(runtime, on)
return Action{Kind: ActionKindLongPress, On: selectorOf(runtime, on), X: x, Y: y}, nil
case "Scroll":
in := object.Get("in")
x, y := coordinatesOf(runtime, in)
return Action{
Kind: ActionKindScroll,
Direction: stringOf(object.Get("direction")),
On: selectorOf(runtime, in),
X: x,
Y: y,
}, nil
case "PressKey":
return Action{Kind: ActionKindPressKey, Key: stringOf(object.Get("key"))}, nil
case "Wait":
+232
View File
@@ -0,0 +1,232 @@
package verifier
import (
"errors"
"testing"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
// A clickable app button and a clickable soft-keyboard key sharing the screen.
// The keyboard key sits where a random tap would otherwise insert a glyph.
const scopedTreeJSON = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,100,500]", "package": "com.folio"},
"children": [
{"attributes": {"testTag": "SubmitButton", "bounds": "[0,40,100,80]", "package": "com.folio"}, "clickable": true, "editable": false, "enabled": true, "children": []},
{"attributes": {"testTag": "Emoticon", "bounds": "[0,400,100,440]", "package": "com.google.android.inputmethod.latin"}, "clickable": true, "enabled": true, "children": []}
]
}`
func pushTree(t *testing.T, v *Verifier, treeJSON string) {
t.Helper()
tree, err := hierarchy.Parse(treeJSON)
if err != nil {
t.Fatal(err)
}
if err := v.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil {
t.Fatal(err)
}
}
// TestTaps_ExcludeOffAppPackage proves the tap generator never targets the soft
// keyboard: with the app package set, only the in-app button is a candidate, so
// the result lands on its center regardless of seed.
func TestTaps_ExcludeOffAppPackage(t *testing.T) {
verifier := newVerifier(t, WithAppPackage("com.folio"))
mustLoad(t, verifier, `globalThis.actions = __sanderling__.taps;`)
pushTree(t, verifier, scopedTreeJSON)
action, err := verifier.NextAction()
if err != nil {
t.Fatal(err)
}
if action.X != 50 || action.Y != 60 {
t.Errorf("coords = (%d,%d), want (50,60) at SubmitButton; keyboard key leaked into targets", action.X, action.Y)
}
}
// TestTyping_ExcludeOffAppPackage proves keyboard glyph buttons that report as
// editable never become typing targets once the app package is set.
func TestTyping_ExcludeOffAppPackage(t *testing.T) {
const treeJSON = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,100,500]", "package": "com.folio"},
"children": [
{"attributes": {"testTag": "NameField", "bounds": "[0,0,100,40]", "package": "com.folio"}, "editable": true, "enabled": true, "children": []},
{"attributes": {"testTag": "SearchBox", "bounds": "[0,400,100,440]", "package": "com.google.android.inputmethod.latin"}, "editable": true, "enabled": true, "children": []}
]
}`
verifier := newVerifier(t, WithAppPackage("com.folio"))
mustLoad(t, verifier, `globalThis.actions = __sanderling__.typing;`)
pushTree(t, verifier, treeJSON)
action, err := verifier.NextAction()
if err != nil {
t.Fatal(err)
}
if action.X != 50 || action.Y != 20 {
t.Errorf("coords = (%d,%d), want (50,20) at NameField; off-app field leaked into targets", action.X, action.Y)
}
}
// TestSwipes_ExcludeOffAppPackage proves swipes anchor on app nodes only, so
// exploration never scrolls the keyboard's emoji list instead of the app.
func TestSwipes_ExcludeOffAppPackage(t *testing.T) {
verifier := newVerifier(t, WithAppPackage("com.folio"))
mustLoad(t, verifier, `globalThis.actions = __sanderling__.swipes;`)
pushTree(t, verifier, scopedTreeJSON)
// Both the root and SubmitButton (com.folio) are valid anchors; only the
// keyboard key at center (50,420) must be excluded. Draw many times so the
// invariant is not satisfied by a lucky seed.
for i := range 200 {
action, err := verifier.NextAction()
if err != nil {
t.Fatal(err)
}
if action.Kind != ActionKindSwipe {
t.Fatalf("kind = %v, want Swipe", action.Kind)
}
if action.FromX == 50 && action.FromY == 420 {
t.Fatalf("draw %d anchored on the keyboard key (50,420); off-app node leaked into swipe targets", i)
}
}
}
// TestTaps_AllOffAppYieldsErrNoAction proves the generator declines when every
// clickable node belongs to another package, so a weighted layer falls through
// instead of fuzzing the keyboard.
func TestTaps_AllOffAppYieldsErrNoAction(t *testing.T) {
const treeJSON = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,100,500]", "package": "com.folio"},
"children": [
{"attributes": {"testTag": "Emoticon", "bounds": "[0,400,100,440]", "package": "com.google.android.inputmethod.latin"}, "clickable": true, "enabled": true, "children": []}
]
}`
verifier := newVerifier(t, WithAppPackage("com.folio"))
mustLoad(t, verifier, `globalThis.actions = __sanderling__.taps;`)
pushTree(t, verifier, treeJSON)
if _, err := verifier.NextAction(); !errors.Is(err, ErrNoAction) {
t.Fatalf("err = %v, want ErrNoAction", err)
}
}
// TestTaps_UnsetAppPackageKeepsAllNodes proves the filter is opt-in: with no app
// package configured, an off-app node is still a valid target (prior behavior).
func TestTaps_UnsetAppPackageKeepsAllNodes(t *testing.T) {
const treeJSON = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,100,500]", "package": "com.folio"},
"children": [
{"attributes": {"testTag": "Emoticon", "bounds": "[0,400,100,440]", "package": "com.google.android.inputmethod.latin"}, "clickable": true, "enabled": true, "children": []}
]
}`
verifier := newVerifier(t)
mustLoad(t, verifier, `globalThis.actions = __sanderling__.taps;`)
pushTree(t, verifier, treeJSON)
action, err := verifier.NextAction()
if err != nil {
t.Fatalf("err = %v, want a tap on the only node when unscoped", err)
}
if action.X != 50 || action.Y != 420 {
t.Errorf("coords = (%d,%d), want (50,420); unscoped run should target any node", action.X, action.Y)
}
}
// TestLongPresses_TargetsClickableElement proves the longPresses generator
// mirrors taps: it yields a LongPress on the only clickable in-app node.
func TestLongPresses_TargetsClickableElement(t *testing.T) {
verifier := newVerifier(t, WithAppPackage("com.folio"))
mustLoad(t, verifier, `globalThis.actions = __sanderling__.longPresses;`)
pushTree(t, verifier, scopedTreeJSON)
action, err := verifier.NextAction()
if err != nil {
t.Fatal(err)
}
if action.Kind != ActionKindLongPress {
t.Fatalf("kind = %v, want LongPress", action.Kind)
}
if action.X != 50 || action.Y != 60 {
t.Errorf("coords = (%d,%d), want (50,60) at SubmitButton", action.X, action.Y)
}
}
// TestScrolls_TargetsScrollableContainer proves the scrolls generator anchors on
// a scrollable container and pre-computes swipe endpoints inside its bounds.
func TestScrolls_TargetsScrollableContainer(t *testing.T) {
const treeJSON = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,100,500]", "package": "com.folio"},
"children": [
{"attributes": {"testTag": "Feed", "bounds": "[0,0,100,400]", "scrollable": "true", "package": "com.folio"}, "enabled": true, "children": []},
{"attributes": {"testTag": "Header", "bounds": "[0,400,100,440]", "package": "com.folio"}, "clickable": true, "enabled": true, "children": []}
]
}`
verifier := newVerifier(t, WithAppPackage("com.folio"))
mustLoad(t, verifier, `globalThis.actions = __sanderling__.scrolls;`)
pushTree(t, verifier, treeJSON)
for range 50 {
action, err := verifier.NextAction()
if err != nil {
t.Fatal(err)
}
if action.Kind != ActionKindScroll {
t.Fatalf("kind = %v, want Scroll", action.Kind)
}
// Feed center is (50,200); both endpoints must stay anchored there.
if action.FromX != 50 || action.FromY != 200 {
t.Fatalf("from = (%d,%d), want (50,200) at Feed center", action.FromX, action.FromY)
}
switch action.Direction {
case "up", "down":
if action.ToX != 50 {
t.Fatalf("vertical scroll moved x: to = (%d,%d)", action.ToX, action.ToY)
}
case "left", "right":
if action.ToY != 200 {
t.Fatalf("horizontal scroll moved y: to = (%d,%d)", action.ToX, action.ToY)
}
default:
t.Fatalf("unexpected direction %q", action.Direction)
}
if action.DurationMillis != 300 {
t.Fatalf("durationMillis = %d, want 300", action.DurationMillis)
}
}
}
// TestScrolls_NoScrollableYieldsErrNoAction proves the generator declines when
// no scrollable container is present.
func TestScrolls_NoScrollableYieldsErrNoAction(t *testing.T) {
verifier := newVerifier(t, WithAppPackage("com.folio"))
mustLoad(t, verifier, `globalThis.actions = __sanderling__.scrolls;`)
pushTree(t, verifier, scopedTreeJSON)
if _, err := verifier.NextAction(); !errors.Is(err, ErrNoAction) {
t.Fatalf("err = %v, want ErrNoAction", err)
}
}
// TestTaps_EmptyPackageNodeStaysInScope proves nodes that omit the package
// attribute (e.g. iOS, decor views) are kept, so the filter never empties a
// legitimate app screen.
func TestTaps_EmptyPackageNodeStaysInScope(t *testing.T) {
const treeJSON = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,100,500]"},
"children": [
{"attributes": {"testTag": "SubmitButton", "bounds": "[0,40,100,80]"}, "clickable": true, "enabled": true, "children": []}
]
}`
verifier := newVerifier(t, WithAppPackage("com.folio"))
mustLoad(t, verifier, `globalThis.actions = __sanderling__.taps;`)
pushTree(t, verifier, treeJSON)
action, err := verifier.NextAction()
if err != nil {
t.Fatalf("err = %v, want the empty-package node kept in scope", err)
}
if action.X != 50 || action.Y != 60 {
t.Errorf("coords = (%d,%d), want (50,60)", action.X, action.Y)
}
}
+14
View File
@@ -5,10 +5,13 @@ type ActionKind string
const (
ActionKindTap ActionKind = "Tap"
ActionKindDoubleTap ActionKind = "DoubleTap"
ActionKindInputText ActionKind = "InputText"
ActionKindSwipe ActionKind = "Swipe"
ActionKindPressKey ActionKind = "PressKey"
ActionKindWait ActionKind = "Wait"
ActionKindLongPress ActionKind = "LongPress"
ActionKindScroll ActionKind = "Scroll"
)
// Action is a single UI interaction produced by the spec's action generator.
@@ -27,6 +30,9 @@ type Action struct {
DurationMillis int
// Key is the logical key name for ActionKindPressKey.
Key string
// Direction is the scroll direction for ActionKindScroll: one of "up",
// "down", "left", "right". Empty for every other kind.
Direction string
}
// LogEntry mirrors a logcat line captured between steps.
@@ -44,3 +50,11 @@ type Exception struct {
StackTrace string
UnixMillis int64
}
// ExtractorChange records a single extractor's value transition across one
// step. Used to surface "what changed at this step" breadcrumbs at violation
// markers in the inspect UI.
type ExtractorChange struct {
Prev []byte
Curr []byte
}
+384
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"math/rand/v2"
"slices"
"strings"
"testing"
@@ -118,6 +119,117 @@ func TestEvaluateProperties_HoldsThenViolates(t *testing.T) {
}
}
func TestNewlyViolatedProperties_OnsetOnly(t *testing.T) {
verifier := newVerifier(t)
mustLoad(t, verifier, helloSpec)
// Balance trajectory: holds, holds, violates, stays violated, stays violated.
// Onset must appear only on step 3 even though the residual stays false
// through steps 4 and 5 (LTL `always` sticky semantics).
balances := []int{1500, 1500, -1, 500, 500}
for index, balance := range balances {
raw, _ := json.Marshal(balance)
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": raw}}); err != nil {
t.Fatal(err)
}
_ = verifier.EvaluateProperties()
got := verifier.NewlyViolatedProperties()
step := index + 1
if step == 3 {
want := []string{"balanceNonNegative"}
if !slices.Equal(got, want) {
t.Errorf("step %d (onset): got %v, want %v", step, got, want)
}
} else if len(got) != 0 {
t.Errorf("step %d: expected empty onset set, got %v", step, got)
}
}
}
func TestNewlyViolatedProperties_FirstStepViolation(t *testing.T) {
const spec = `
globalThis.properties = {
alwaysFalse: __sanderling__.always(() => false),
};
`
verifier := newVerifier(t)
mustLoad(t, verifier, spec)
for step := 1; step <= 3; step++ {
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}); err != nil {
t.Fatal(err)
}
_ = verifier.EvaluateProperties()
got := verifier.NewlyViolatedProperties()
if step == 1 {
want := []string{"alwaysFalse"}
if !slices.Equal(got, want) {
t.Errorf("step 1 (onset): got %v, want %v", got, want)
}
} else if len(got) != 0 {
t.Errorf("step %d: expected empty onset set after first-step violation, got %v", step, got)
}
}
}
func TestNewlyViolatedProperties_MultipleProperties(t *testing.T) {
const spec = `
const a = __sanderling__.extract(state => state.snapshots["a"] ?? 0);
const b = __sanderling__.extract(state => state.snapshots["b"] ?? 0);
globalThis.properties = {
propA: __sanderling__.always(() => a.current >= 0),
propB: __sanderling__.always(() => b.current >= 0),
};
`
verifier := newVerifier(t)
mustLoad(t, verifier, spec)
// propA violates at step 2, propB violates at step 4. Each must surface
// only on its own onset step.
aValues := []int{1, -1, -1, -1}
bValues := []int{1, 1, 1, -1}
expectOnset := map[int][]string{
2: {"propA"},
4: {"propB"},
}
for index := range aValues {
aRaw, _ := json.Marshal(aValues[index])
bRaw, _ := json.Marshal(bValues[index])
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"a": aRaw, "b": bRaw}}); err != nil {
t.Fatal(err)
}
_ = verifier.EvaluateProperties()
got := verifier.NewlyViolatedProperties()
step := index + 1
want := expectOnset[step]
if !slices.Equal(got, want) {
t.Errorf("step %d: got %v, want %v", step, got, want)
}
}
}
func TestNewlyViolatedProperties_DeterministicOrder(t *testing.T) {
const spec = `
globalThis.properties = {
zebra: __sanderling__.always(() => false),
apple: __sanderling__.always(() => false),
mango: __sanderling__.always(() => false),
};
`
verifier := newVerifier(t)
mustLoad(t, verifier, spec)
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}); err != nil {
t.Fatal(err)
}
_ = verifier.EvaluateProperties()
got := verifier.NewlyViolatedProperties()
want := []string{"apple", "mango", "zebra"}
if !slices.Equal(got, want) {
t.Errorf("onset order: got %v, want %v (sorted lexicographically)", got, want)
}
}
func TestNextAction_FromActionsGenerator(t *testing.T) {
verifier := newVerifier(t)
mustLoad(t, verifier, helloSpec)
@@ -281,6 +393,75 @@ func TestNextAction_NoSetupRegistered(t *testing.T) {
}
}
// TestDoubleTapsBuiltin_TargetsClickable verifies the doubleTaps builtin emits
// a DoubleTap action targeting a clickable, enabled element's center.
func TestDoubleTapsBuiltin_TargetsClickable(t *testing.T) {
const treeJSON = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"},
"children": [
{"attributes": {"testTag": "SubmitButton", "bounds": "[0,40,100,80]"}, "clickable": true, "enabled": true, "children": []}
]
}`
verifier := newVerifier(t)
mustLoad(t, verifier, `globalThis.actions = __sanderling__.doubleTaps;`)
tree, err := hierarchy.Parse(treeJSON)
if err != nil {
t.Fatal(err)
}
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil {
t.Fatal(err)
}
action, err := verifier.NextAction()
if err != nil {
t.Fatal(err)
}
if action.Kind != ActionKindDoubleTap {
t.Fatalf("kind = %v, want DoubleTap", action.Kind)
}
if action.X != 50 || action.Y != 60 {
t.Errorf("coords = (%d,%d), want (50,60) at SubmitButton center", action.X, action.Y)
}
// Action-gated properties read lastAction.on to tell which target the
// chooser hit. An empty On reduces those properties to vacuously-true and
// they never fire on the real tap event.
if action.On == "" {
t.Fatal("On must be populated so action-gated properties can identify the target")
}
if !strings.Contains(action.On, "SubmitButton") {
t.Errorf("On = %q, want a selector containing SubmitButton", action.On)
}
resolved := tree.Find(action.On)
if resolved == nil {
t.Fatalf("On = %q does not resolve in the same tree", action.On)
}
rx, ry := resolved.Bounds.Center()
if rx != action.X || ry != action.Y {
t.Errorf("On %q resolves to (%d,%d), want the picked element's center (%d,%d)",
action.On, rx, ry, action.X, action.Y)
}
}
func TestDoubleTap_RoundTrip(t *testing.T) {
verifier := newVerifier(t)
mustLoad(t, verifier, `
globalThis.actions = __sanderling__.actions(() => [
__sanderling__.doubleTap({ on: "id:save" }),
]);
`)
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
action, err := verifier.NextAction()
if err != nil {
t.Fatal(err)
}
if action.Kind != ActionKindDoubleTap {
t.Errorf("kind: got %v, want DoubleTap", action.Kind)
}
if action.On != "id:save" {
t.Errorf("selector: got %q, want id:save", action.On)
}
}
func TestInputText_RoundTrip(t *testing.T) {
verifier := newVerifier(t)
mustLoad(t, verifier, `
@@ -302,6 +483,65 @@ func TestInputText_RoundTrip(t *testing.T) {
}
}
// TestTypingBuiltin_TargetsEditableField verifies the typing generator emits an
// InputText action aimed at an editable, enabled element's center and fills it
// with a corpus value, ignoring clickable-but-not-editable elements.
func TestTypingBuiltin_TargetsEditableField(t *testing.T) {
const treeJSON = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"},
"children": [
{"attributes": {"testTag": "EmailField", "bounds": "[0,0,100,40]"}, "editable": true, "enabled": true, "children": []},
{"attributes": {"testTag": "SubmitButton", "bounds": "[0,40,100,80]"}, "clickable": true, "enabled": true, "children": []}
]
}`
verifier := newVerifier(t)
mustLoad(t, verifier, `globalThis.actions = __sanderling__.typing;`)
tree, err := hierarchy.Parse(treeJSON)
if err != nil {
t.Fatal(err)
}
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil {
t.Fatal(err)
}
action, err := verifier.NextAction()
if err != nil {
t.Fatal(err)
}
if action.Kind != ActionKindInputText {
t.Fatalf("kind = %v, want InputText", action.Kind)
}
if action.X != 50 || action.Y != 20 {
t.Errorf("coords = (%d,%d), want (50,20) at EmailField center", action.X, action.Y)
}
if !slices.Contains(inputCorpus, action.Text) {
t.Errorf("text %q not drawn from inputCorpus", action.Text)
}
}
// TestTypingBuiltin_NoEditableYieldsErrNoAction verifies the typing generator
// declines (ErrNoAction) when no editable element is present, so a weighted
// layer falls through to another generator.
func TestTypingBuiltin_NoEditableYieldsErrNoAction(t *testing.T) {
const treeJSON = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"},
"children": [
{"attributes": {"testTag": "SubmitButton", "bounds": "[0,0,100,40]"}, "clickable": true, "enabled": true, "children": []}
]
}`
verifier := newVerifier(t)
mustLoad(t, verifier, `globalThis.actions = __sanderling__.typing;`)
tree, err := hierarchy.Parse(treeJSON)
if err != nil {
t.Fatal(err)
}
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil {
t.Fatal(err)
}
if _, err := verifier.NextAction(); !errors.Is(err, ErrNoAction) {
t.Fatalf("err = %v, want ErrNoAction", err)
}
}
func TestPushSnapshot_FeedsSnapshotsToExtractorState(t *testing.T) {
verifier := newVerifier(t)
mustLoad(t, verifier, `
@@ -609,6 +849,150 @@ globalThis.properties = {
globalThis.actions = __sanderling__.actions(() => []);
`
// TestSelectorStringFromJS_CanonicalGrammar guarantees the selector tag stamped
// on returned AX nodes round-trips back to a parseable selector string when the
// node is later used as an action target. Without this, an action emitted from
// `tap({ on: state.ax.find({ testTag: "LoginEmail" }) })` ends up with
// `action.selector = "[object Object]"` in the trace.
func TestSelectorStringFromJS_CanonicalGrammar(t *testing.T) {
const treeJSON = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"},
"children": [
{"attributes": {"testTag": "LoginScreen", "bounds": "[0,0,100,40]"},
"children": [
{"attributes": {"testTag": "LoginEmail", "bounds": "[0,0,100,20]"}, "editable": true, "enabled": true, "children": []}
]}
]
}`
verifier := newVerifier(t)
mustLoad(t, verifier, `
globalThis.objectSelector = __sanderling__.extract(state =>
state.ax.find({ testTag: "LoginScreen" })
);
globalThis.chainSelector = __sanderling__.extract(state =>
state.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginEmail" }])
);
globalThis.stringSelector = __sanderling__.extract(state =>
state.ax.find("testTag:LoginScreen")
);
`)
tree, err := hierarchy.Parse(treeJSON)
if err != nil {
t.Fatal(err)
}
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil {
t.Fatal(err)
}
read := func(name string) string {
handle := verifier.runtime.GlobalObject().Get(name).ToObject(verifier.runtime)
current := handle.Get("current")
if goja.IsUndefined(current) || goja.IsNull(current) {
return ""
}
object := current.ToObject(verifier.runtime)
return object.Get(tagSelector).String()
}
cases := []struct {
name string
want string
}{
{"objectSelector", "testTag:LoginScreen"},
{"chainSelector", "testTag:LoginScreen > testTag:LoginEmail"},
{"stringSelector", "testTag:LoginScreen"},
}
for _, testCase := range cases {
got := read(testCase.name)
if got != testCase.want {
t.Errorf("%s: got %q, want %q", testCase.name, got, testCase.want)
}
if strings.Contains(got, "[object") {
t.Errorf("%s: selector contains garbage %q", testCase.name, got)
}
}
}
// TestChangedExtractors_DiffsBetweenSnapshots verifies the per-step diff
// surfaces only extractors whose value actually changed, keyed by name (or
// extractor_N fallback when unnamed).
func TestChangedExtractors_DiffsBetweenSnapshots(t *testing.T) {
verifier := newVerifier(t)
mustLoad(t, verifier, `
__sanderling__.extract(state => state.snapshots["a"] ?? 0, "alpha");
__sanderling__.extract(state => state.snapshots["b"] ?? 0);
`)
push := func(a, b int) {
raw := func(n int) json.RawMessage {
body, _ := json.Marshal(n)
return body
}
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"a": raw(a), "b": raw(b)}}); err != nil {
t.Fatal(err)
}
}
push(1, 1)
first := verifier.ChangedExtractors()
if _, ok := first["alpha"]; !ok {
t.Errorf("step 1: expected alpha in diff (initial value), got %+v", first)
}
if _, ok := first["extractor_1"]; !ok {
t.Errorf("step 1: expected extractor_1 fallback name in diff, got %+v", first)
}
push(1, 2)
second := verifier.ChangedExtractors()
if _, ok := second["alpha"]; ok {
t.Errorf("step 2: alpha did not change, should not appear: %+v", second)
}
change, ok := second["extractor_1"]
if !ok {
t.Fatalf("step 2: expected extractor_1 in diff, got %+v", second)
}
if string(change.Prev) != "1" || string(change.Curr) != "2" {
t.Errorf("step 2: extractor_1 diff prev=%s curr=%s, want 1 -> 2", change.Prev, change.Curr)
}
push(1, 2)
third := verifier.ChangedExtractors()
if len(third) != 0 {
t.Errorf("step 3: nothing changed, diff should be empty, got %+v", third)
}
}
// TestExtract_DefaultsAndNamedNames verifies bindExtract assigns a fallback
// `extractor_N` name when no name is supplied and respects an explicit one.
func TestExtract_DefaultsAndNamedNames(t *testing.T) {
verifier := newVerifier(t)
mustLoad(t, verifier, `
__sanderling__.extract(state => 1);
__sanderling__.extract(state => 2, "ledgerRows");
__sanderling__.extract(state => 3);
`)
if len(verifier.extractors) != 3 {
t.Fatalf("extractors registered: got %d, want 3", len(verifier.extractors))
}
want := []string{"extractor_0", "ledgerRows", "extractor_2"}
for i, name := range want {
if got := verifier.extractors[i].name; got != name {
t.Errorf("extractor %d name: got %q, want %q", i, got, name)
}
}
}
// TestSelectorStringFromJS_NullEmpty verifies that nil/undefined args produce
// an empty string instead of "null"/"undefined" garbage.
func TestSelectorStringFromJS_NullEmpty(t *testing.T) {
verifier := newVerifier(t)
if got := selectorStringFromJS(verifier.runtime, goja.Undefined()); got != "" {
t.Errorf("undefined: got %q, want empty", got)
}
if got := selectorStringFromJS(verifier.runtime, goja.Null()); got != "" {
t.Errorf("null: got %q, want empty", got)
}
}
func TestOverrideExtractorValues_PropagatesNestedObjectFields(t *testing.T) {
verifier := newVerifier(t)
mustLoad(t, verifier, objectExtractorSpec)
+299 -2
View File
@@ -1,10 +1,14 @@
package verifier
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"maps"
"math/rand/v2"
"sort"
"strings"
"time"
"github.com/dop251/goja"
@@ -25,6 +29,9 @@ type Verifier struct {
evaluators map[string]*ltl.Evaluator
priorVerdicts map[string]ltl.Verdict
newlyViolated []string
lastTree *hierarchy.Tree
lastAction *Action
lastLogs []LogEntry
@@ -32,6 +39,8 @@ type Verifier struct {
stepTime time.Time
runStart time.Time
appPackage string
rng *rand.Rand
}
@@ -41,11 +50,20 @@ func WithRand(rng *rand.Rand) Option {
return func(v *Verifier) { v.rng = rng }
}
// WithAppPackage scopes random-action target selection to the app under test.
// Nodes belonging to another package (the soft keyboard, system UI, permission
// dialogs) are excluded so exploration never spends steps fuzzing the IME or
// inserting keyboard glyphs into fields. Empty package keeps current behavior.
func WithAppPackage(appPackage string) Option {
return func(v *Verifier) { v.appPackage = appPackage }
}
func New(options ...Option) (*Verifier, error) {
verifier := &Verifier{
runtime: goja.New(),
properties: map[string]int{},
evaluators: map[string]*ltl.Evaluator{},
priorVerdicts: map[string]ltl.Verdict{},
rng: rand.New(rand.NewPCG(0, 0)),
}
for _, option := range options {
@@ -235,10 +253,55 @@ func (v *Verifier) PushSnapshot(input SnapshotInput) error {
return fmt.Errorf("extractor %d: %w", index, err)
}
_ = extractor.handle.Set("current", newValue)
extractor.prev = extractor.curr
extractor.curr = encodeExtractorValue(newValue)
}
return nil
}
// encodeExtractorValue produces a stable JSON encoding of an extractor's
// current value for diff comparison. goja values that don't survive Export
// (e.g. wrapped host functions) yield nil; callers treat nil as "unknown" and
// emit no diff entry.
func encodeExtractorValue(value goja.Value) []byte {
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
return []byte("null")
}
exported := value.Export()
body, err := json.Marshal(exported)
if err != nil {
return nil
}
return body
}
// ChangedExtractors returns the named extractors whose value changed between
// the prior PushSnapshot and the current one. The map is keyed by extractor
// name; unnamed extractors (extractor_N fallback) are included so the inspect
// UI can still display them under a numeric label. The very first snapshot
// emits every non-null extractor as a change (Prev=null, Curr=current) since
// the runner can otherwise misread "no diff yet" as "nothing initialized".
func (v *Verifier) ChangedExtractors() map[string]ExtractorChange {
changes := map[string]ExtractorChange{}
for _, extractor := range v.extractors {
if extractor.curr == nil {
continue
}
prev := extractor.prev
if prev == nil {
prev = []byte("null")
}
if bytes.Equal(prev, extractor.curr) {
continue
}
changes[extractor.name] = ExtractorChange{
Prev: append([]byte(nil), prev...),
Curr: append([]byte(nil), extractor.curr...),
}
}
return changes
}
// OverrideExtractorValues replaces each extractor's `current` slot with a
// caller-supplied value, keyed by registration index. Used by the web tick
// path so extractor bodies that ran in V8 (against the real DOM) drive the
@@ -287,6 +350,9 @@ type SnapshotInput struct {
// after the most recent PushSnapshot. The step time passed in PushSnapshot is
// forwarded to each evaluator so deadline-bound operators see the snapshot's
// wall clock rather than time.Now().
//
// As a side effect, the set of properties that newly transitioned to violated
// on this call is recorded; see NewlyViolatedProperties.
func (v *Verifier) EvaluateProperties() map[string]ltl.Verdict {
verdicts := map[string]ltl.Verdict{}
stepTime := v.stepTime
@@ -297,9 +363,38 @@ func (v *Verifier) EvaluateProperties() map[string]ltl.Verdict {
verdicts[name] = evaluator.ObserveAt(stepTime)
}
v.refreshPredicateErrors()
var onset []string
for name, verdict := range verdicts {
if verdict == ltl.VerdictViolated && v.priorVerdicts[name] != ltl.VerdictViolated {
onset = append(onset, name)
}
}
sort.Strings(onset)
v.newlyViolated = onset
next := make(map[string]ltl.Verdict, len(verdicts))
maps.Copy(next, verdicts)
v.priorVerdicts = next
return verdicts
}
// NewlyViolatedProperties returns the names of properties whose verdict
// transitioned from non-Violated to Violated on the most recent
// EvaluateProperties call, sorted lexicographically. Returns nil if no
// transition occurred or EvaluateProperties has not been called.
//
// This is the onset set: each property name appears at most once across a
// run's traces, at the step where the violation first fired. Subsequent
// steps where the property remains violated (LTL `always` sticky semantics)
// will not list it. Use this for trace emission and summary reporting so the
// onset is the only step that surfaces the violation event; use
// EvaluateProperties for residual / current-verdict needs.
func (v *Verifier) NewlyViolatedProperties() []string {
return append([]string(nil), v.newlyViolated...)
}
// Residuals returns the residual formula for each registered property after
// the most recent EvaluateProperties call. Properties that errored during
// predicate evaluation surface as ErrorFormula so the inspect UI can render
@@ -451,12 +546,20 @@ func (v *Verifier) resolveGenerator(generator goja.Value) (Action, error) {
return v.resolveGenerator(picked)
case internalKindBuiltinTaps:
return v.generateRandomTap()
case internalKindBuiltinDoubleTaps:
return v.generateRandomDoubleTap()
case internalKindBuiltinTyping:
return v.generateRandomInput()
case internalKindBuiltinSwipes:
return v.generateRandomSwipe()
case internalKindBuiltinWaitOnce:
return Action{Kind: ActionKindWait, DurationMillis: 500}, nil
case internalKindBuiltinPressKey:
return v.generateRandomPressKey()
case internalKindBuiltinLongPresses:
return v.generateRandomLongPress()
case internalKindBuiltinScrolls:
return v.generateRandomScroll()
default:
return Action{}, fmt.Errorf("unknown generator kind %q", kindValue.String())
}
@@ -465,6 +568,29 @@ func (v *Verifier) resolveGenerator(generator goja.Value) (Action, error) {
// generateRandomTap picks a visible, tappable element from the last
// hierarchy snapshot and returns a Tap action targeting its center.
func (v *Verifier) generateRandomTap() (Action, error) {
return v.generateRandomTapKind(ActionKindTap)
}
// generateRandomDoubleTap is the DoubleTap counterpart of generateRandomTap.
// Real user gestures include double-tap (image zoom, like-to-favorite,
// play/pause); a fuzzer that never emits one cannot exercise either those
// features or the sub-100ms race windows that single-step Tap cadence misses.
func (v *Verifier) generateRandomDoubleTap() (Action, error) {
return v.generateRandomTapKind(ActionKindDoubleTap)
}
// inScope reports whether an element belongs to the app under test. Nodes from
// another package (the soft keyboard, system UI, permission dialogs) are out of
// scope. An unset app package or an element with no package falls through to in
// scope, preserving behavior on platforms that omit the attribute (e.g. iOS).
func (v *Verifier) inScope(element *hierarchy.Element) bool {
if v.appPackage == "" || element.Package == "" {
return true
}
return element.Package == v.appPackage
}
func (v *Verifier) generateRandomTapKind(kind ActionKind) (Action, error) {
if v.lastTree == nil {
return Action{}, ErrNoAction
}
@@ -473,6 +599,9 @@ func (v *Verifier) generateRandomTap() (Action, error) {
if !element.Clickable || !element.Enabled {
continue
}
if !v.inScope(element) {
continue
}
if element.Bounds.Right-element.Bounds.Left <= 0 || element.Bounds.Bottom-element.Bounds.Top <= 0 {
continue
}
@@ -483,7 +612,99 @@ func (v *Verifier) generateRandomTap() (Action, error) {
}
picked := candidates[v.rng.IntN(len(candidates))]
x, y := picked.Bounds.Center()
return Action{Kind: ActionKindTap, X: x, Y: y}, nil
return Action{Kind: kind, On: selectorForElement(v.lastTree, picked), X: x, Y: y}, nil
}
// selectorForElement builds a canonical "key:value" selector that resolves
// back to the given element via hierarchy.Tree.Find. Prefers resource-id (the
// testTag carrier on Android / accessibilityIdentifier on iOS), falling back
// to text and content-description so action-gated properties can still tell
// what was tapped even on legacy nodes without a testTag. Returns "" when no
// candidate selector uniquely resolves to the picked element so the runner
// keeps using the action's coordinates without re-routing to a sibling that
// shares the same id/text.
func selectorForElement(tree *hierarchy.Tree, element *hierarchy.Element) string {
if element == nil || tree == nil {
return ""
}
candidates := make([]string, 0, 4)
if element.ResourceID != "" {
candidates = append(candidates, "id:"+element.ResourceID)
}
// Some platforms surface the Compose testTag only in the attributes map
// (the sidecar doesn't always promote it to resource-id). Try the raw
// attribute keys before falling back to text-based selectors so an
// element with a unique testTag still gets identified.
for _, key := range []string{"testTag", "identifier", "accessibilityIdentifier"} {
if value := element.Attributes[key]; value != "" {
candidates = append(candidates, key+":"+value)
}
}
if element.Text != "" {
candidates = append(candidates, "text:"+element.Text)
}
if element.Description != "" {
candidates = append(candidates, "desc:"+element.Description)
}
for _, selector := range candidates {
resolved := tree.Find(selector)
if resolved == nil || resolved != element {
continue
}
return selector
}
return ""
}
// inputCorpus is the edge-case string pool the typing builtin draws from to
// stress field parsing: empty, whitespace, overflow length, unicode, numeric
// boundaries, and common injection payloads.
var inputCorpus = []string{
"",
"a",
strings.Repeat("a", 4096),
"🙂🔥💸",
" ",
"\t\n",
"-1",
"999999999999999999999",
"0.0000001",
"1e10",
"'; DROP TABLE--",
"<script>alert(1)</script>",
"../../etc/passwd",
"%s%n",
"NaN",
}
// generateRandomInput picks a visible, editable, enabled element from the last
// hierarchy snapshot and types a random edge-case value into it. The runner
// taps the target coordinates to focus before typing, so this works on both
// native and web with no driver-side dispatch change.
func (v *Verifier) generateRandomInput() (Action, error) {
if v.lastTree == nil {
return Action{}, ErrNoAction
}
candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements))
for _, element := range v.lastTree.Elements {
if !element.Editable || !element.Enabled {
continue
}
if !v.inScope(element) {
continue
}
if element.Bounds.Right-element.Bounds.Left <= 0 || element.Bounds.Bottom-element.Bounds.Top <= 0 {
continue
}
candidates = append(candidates, element)
}
if len(candidates) == 0 {
return Action{}, ErrNoAction
}
picked := candidates[v.rng.IntN(len(candidates))]
x, y := picked.Bounds.Center()
value := inputCorpus[v.rng.IntN(len(inputCorpus))]
return Action{Kind: ActionKindInputText, X: x, Y: y, Text: value}, nil
}
// generateRandomSwipe emits a swipe over a random enabled element or the
@@ -493,7 +714,16 @@ func (v *Verifier) generateRandomSwipe() (Action, error) {
if v.lastTree == nil || len(v.lastTree.Elements) == 0 {
return Action{}, ErrNoAction
}
element := v.lastTree.Elements[v.rng.IntN(len(v.lastTree.Elements))]
candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements))
for _, element := range v.lastTree.Elements {
if v.inScope(element) {
candidates = append(candidates, element)
}
}
if len(candidates) == 0 {
return Action{}, ErrNoAction
}
element := candidates[v.rng.IntN(len(candidates))]
cx, cy := element.Bounds.Center()
if cx <= 0 || cy <= 0 {
return Action{}, ErrNoAction
@@ -527,6 +757,73 @@ func (v *Verifier) generateRandomSwipe() (Action, error) {
}, nil
}
// generateRandomLongPress mirrors generateRandomTap: it picks a visible,
// clickable, enabled, in-scope element and targets its center. Real users
// long-press (context menus, reorder handles, multi-select), so a fuzzer that
// never emits one cannot reach those affordances.
func (v *Verifier) generateRandomLongPress() (Action, error) {
return v.generateRandomTapKind(ActionKindLongPress)
}
// generateRandomScroll picks a scrollable, in-scope container and emits a swipe
// across it in a random direction.
func (v *Verifier) generateRandomScroll() (Action, error) {
if v.lastTree == nil {
return Action{}, ErrNoAction
}
candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements))
for _, element := range v.lastTree.Elements {
if element.Attributes["scrollable"] != "true" {
continue
}
if !v.inScope(element) {
continue
}
if element.Bounds.Width() <= 0 || element.Bounds.Height() <= 0 {
continue
}
candidates = append(candidates, element)
}
if len(candidates) == 0 {
return Action{}, ErrNoAction
}
picked := candidates[v.rng.IntN(len(candidates))]
cx, cy := picked.Bounds.Center()
width := picked.Bounds.Width()
height := picked.Bounds.Height()
directions := []string{"up", "down", "left", "right"}
dir := directions[v.rng.IntN(len(directions))]
toX, toY := cx, cy
// Scroll direction names the content motion; the gesture swipes the
// opposite way. Revealing content below ("down") means dragging the
// finger up, so toY decreases, and likewise for the other directions.
switch dir {
case "down":
toY = cy - (4*height)/10
case "up":
toY = cy + (4*height)/10
case "left":
toX = cx + (4*width)/10
case "right":
toX = cx - (4*width)/10
}
if toX < 0 {
toX = 0
}
if toY < 0 {
toY = 0
}
return Action{
Kind: ActionKindScroll,
Direction: dir,
FromX: cx,
FromY: cy,
ToX: toX,
ToY: toY,
DurationMillis: 300,
}, nil
}
func (v *Verifier) generateRandomPressKey() (Action, error) {
// Keep exploration gentle: only "back" for now. Home/menu would navigate
// away from the app under test.
+4
View File
@@ -10,6 +10,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./defaults": {
"types": "./dist/defaults/index.d.ts",
"import": "./dist/defaults/index.js"
},
"./defaults/properties": {
"types": "./dist/defaults/properties.d.ts",
"import": "./dist/defaults/properties.js"
+34 -1
View File
@@ -2,11 +2,15 @@ import type {
AccessibilityElement,
Action,
ActionGenerator,
Direction,
DoubleTapAction,
InputTextAction,
Key,
LongPressAction,
Point,
PressKeyAction,
Sampler,
ScrollAction,
SwipeAction,
TapAction,
WaitAction,
@@ -42,6 +46,21 @@ export function Tap(parameters: { on: string | AccessibilityElement }): TapActio
return globalThis.__sanderling__.tap(parameters);
}
export function DoubleTap(parameters: { on: string | AccessibilityElement }): DoubleTapAction {
return globalThis.__sanderling__.doubleTap(parameters);
}
export function LongPress(parameters: { on: string | AccessibilityElement }): LongPressAction {
return globalThis.__sanderling__.longPress(parameters);
}
export function Scroll(parameters: {
direction: Direction;
in?: string | AccessibilityElement;
}): ScrollAction {
return globalThis.__sanderling__.scroll(parameters);
}
export function InputText(parameters: {
into: string | AccessibilityElement;
text: string;
@@ -65,7 +84,17 @@ export function Wait(parameters: { durationMillis: number }): WaitAction {
return globalThis.__sanderling__.wait(parameters);
}
function builtinGenerator(name: "taps" | "swipes" | "waitOnce" | "pressKeys"): ActionGenerator {
function builtinGenerator(
name:
| "taps"
| "doubleTaps"
| "longPresses"
| "scrolls"
| "typing"
| "swipes"
| "waitOnce"
| "pressKeys",
): ActionGenerator {
return new Proxy({} as ActionGenerator, {
get(_target, property) {
const runtime = globalThis.__sanderling__[name] as unknown as Record<
@@ -78,6 +107,10 @@ function builtinGenerator(name: "taps" | "swipes" | "waitOnce" | "pressKeys"): A
}
export const taps: ActionGenerator = builtinGenerator("taps");
export const doubleTaps: ActionGenerator = builtinGenerator("doubleTaps");
export const longPresses: ActionGenerator = builtinGenerator("longPresses");
export const scrolls: ActionGenerator = builtinGenerator("scrolls");
export const typing: ActionGenerator = builtinGenerator("typing");
export const swipes: ActionGenerator = builtinGenerator("swipes");
export const waitOnce: ActionGenerator = builtinGenerator("waitOnce");
export const pressKey: ActionGenerator = builtinGenerator("pressKeys");
+24
View File
@@ -0,0 +1,24 @@
import { doubleTaps, swipes, taps, typing, weighted } from "../actions.ts";
import type { ActionGenerator } from "../types.ts";
export { doubleTaps, pressKey, swipes, taps, typing, waitOnce } from "../actions.ts";
// longPresses and scrolls are opt-in generators: not part of defaultActions,
// but authors can include them in their own weighted() set.
export { longPresses, scrolls } from "../actions.ts";
// A broad exploration generator: tap things, type edge-case values into fields,
// and swipe. Layer it under targeted depth flows so the fuzzer wanders the whole
// app while still driving the paths an author wrote. It deliberately omits the
// hardware back key: backing out past the app's root screen exits the app under
// test, and exploration must stay within the app.
//
// doubleTaps fires the same target twice ~50ms apart inside one step. Real users
// double-tap (image zoom, like-to-favorite, play/pause); without this the fuzzer
// can never produce sub-100ms event spacing, so race-window bugs (debounce gaps,
// in-flight guards, init races) stay unreachable.
export const defaultActions: ActionGenerator = weighted(
[45, taps],
[15, doubleTaps],
[25, typing],
[15, swipes],
);
+2
View File
@@ -0,0 +1,2 @@
export * from "./properties.ts";
export * from "./actions.ts";
+13 -2
View File
@@ -1,7 +1,18 @@
import type { AccessibilityElement, Extracted, State } from "./types.ts";
export function extract<T>(getter: (state: State) => T): Extracted<T> {
return globalThis.__sanderling__.extract(getter);
export function extract<T>(getter: (state: State) => T): Extracted<T>;
export function extract<T>(name: string, getter: (state: State) => T): Extracted<T>;
export function extract<T>(
nameOrGetter: string | ((state: State) => T),
maybeGetter?: (state: State) => T,
): Extracted<T> {
if (typeof nameOrGetter === "string") {
if (maybeGetter == null) {
throw new TypeError("extract(name, getter): getter is required when a name is supplied");
}
return globalThis.__sanderling__.extract(maybeGetter, nameOrGetter);
}
return globalThis.__sanderling__.extract(nameOrGetter);
}
const KEY_DELIMITER = "\x1f";
+4
View File
@@ -4,6 +4,7 @@ export type {
Action,
ActionGenerator,
AttrSelector,
DoubleTapAction,
EventuallyFormula,
ExceptionRecord,
Extracted,
@@ -30,16 +31,19 @@ export type {
export { extract, keyedBy } from "./extract.ts";
export { always, eventually, next, now } from "./ltl.ts";
export {
DoubleTap,
InputText,
PressKey,
Swipe,
Tap,
Wait,
actions,
doubleTaps,
from,
pressKey,
swipes,
taps,
typing,
waitOnce,
weighted,
whenRoute,
+25 -1
View File
@@ -41,6 +41,7 @@ export interface KnownAttrSelectors {
focused?: boolean;
checked?: boolean;
selected?: boolean;
editable?: boolean;
}
/**
@@ -73,6 +74,7 @@ export interface AccessibilityElement {
checked?: boolean;
focused?: boolean;
selected?: boolean;
editable?: boolean;
bounds?: { left: number; top: number; right: number; bottom: number };
x?: number;
y?: number;
@@ -130,7 +132,16 @@ export interface Point {
y: number;
}
export type Direction = "up" | "down" | "left" | "right";
export type TapAction = { kind: "Tap"; on: string | AccessibilityElement };
export type DoubleTapAction = { kind: "DoubleTap"; on: string | AccessibilityElement };
export type LongPressAction = { kind: "LongPress"; on: string | AccessibilityElement };
export type ScrollAction = {
kind: "Scroll";
direction: Direction;
in?: string | AccessibilityElement;
};
export type InputTextAction = {
kind: "InputText";
into: string | AccessibilityElement;
@@ -146,6 +157,9 @@ export type PressKeyAction = { kind: "PressKey"; key: Key };
export type WaitAction = { kind: "Wait"; durationMillis: number };
export type Action =
| TapAction
| DoubleTapAction
| LongPressAction
| ScrollAction
| InputTextAction
| SwipeAction
| PressKeyAction
@@ -183,7 +197,7 @@ export interface Sampler<T> {
}
export interface SanderlingRuntime {
extract: <T>(getter: (state: State) => T) => Extracted<T>;
extract: <T>(getter: (state: State) => T, name?: string) => Extracted<T>;
always: (predicateOrFormula: (() => boolean) | Formula) => Formula;
now: (predicate: () => boolean) => Formula;
next: (predicate: () => boolean) => Formula;
@@ -192,6 +206,12 @@ export interface SanderlingRuntime {
weighted: (...entries: WeightedEntry[]) => ActionGenerator;
from: <T>(items: readonly T[]) => Sampler<T>;
tap: (parameters: { on: string | AccessibilityElement }) => TapAction;
doubleTap: (parameters: { on: string | AccessibilityElement }) => DoubleTapAction;
longPress: (parameters: { on: string | AccessibilityElement }) => LongPressAction;
scroll: (parameters: {
direction: Direction;
in?: string | AccessibilityElement;
}) => ScrollAction;
inputText: (parameters: {
into: string | AccessibilityElement;
text: string;
@@ -204,6 +224,10 @@ export interface SanderlingRuntime {
pressKey: (parameters: { key: Key }) => PressKeyAction;
wait: (parameters: { durationMillis: number }) => WaitAction;
taps: ActionGenerator;
doubleTaps: ActionGenerator;
longPresses: ActionGenerator;
scrolls: ActionGenerator;
typing: ActionGenerator;
swipes: ActionGenerator;
waitOnce: ActionGenerator;
pressKeys: ActionGenerator;
+100 -5
View File
@@ -20,6 +20,7 @@ interface Handle {
interface ExtractorEntry {
getter: (state: unknown) => unknown;
handle: Handle;
name: string;
}
interface ActionGeneratorHandle {
@@ -290,9 +291,14 @@ function buildState(): unknown {
}
const runtime = {
extract<T>(getter: (state: unknown) => T): Handle {
extract<T>(getter: (state: unknown) => T, name?: string): Handle {
const handle: Handle = { current: undefined, previous: undefined };
extractors.push({ getter: getter as (s: unknown) => unknown, handle });
const resolvedName = name && name.length > 0 ? name : `extractor_${extractors.length}`;
extractors.push({
getter: getter as (s: unknown) => unknown,
handle,
name: resolvedName,
});
return handle;
},
always: noopFormula,
@@ -328,6 +334,17 @@ const runtime = {
tap(p: { on: unknown }): unknown {
return { kind: "Tap", on: p.on };
},
doubleTap(p: { on: unknown }): unknown {
return { kind: "DoubleTap", on: p.on };
},
longPress(): unknown {
// Why: web has no long-press gesture for v1; the factory returns null so LongPress() calls no-op.
return null;
},
scroll(): unknown {
// Why: web has no native scroll gesture for v1; the factory returns null so Scroll() calls no-op.
return null;
},
inputText(p: { into: unknown; text: string }): unknown {
return { kind: "InputText", into: p.into, text: p.text };
},
@@ -347,6 +364,10 @@ const runtime = {
return { kind: "Wait", durationMillis: p.durationMillis };
},
taps: { __sanderlingActionGenerator: true, __sanderlingKind: "taps" } as ActionGeneratorHandle,
doubleTaps: { __sanderlingActionGenerator: true, __sanderlingKind: "doubleTaps" } as ActionGeneratorHandle,
longPresses: { __sanderlingActionGenerator: true, __sanderlingKind: "longPresses" } as ActionGeneratorHandle,
scrolls: { __sanderlingActionGenerator: true, __sanderlingKind: "scrolls" } as ActionGeneratorHandle,
typing: { __sanderlingActionGenerator: true, __sanderlingKind: "typing" } as ActionGeneratorHandle,
swipes: { __sanderlingActionGenerator: true, __sanderlingKind: "swipes" } as ActionGeneratorHandle,
waitOnce: { __sanderlingActionGenerator: true, __sanderlingKind: "waitOnce" } as ActionGeneratorHandle,
pressKeys: { __sanderlingActionGenerator: true, __sanderlingKind: "pressKey" } as ActionGeneratorHandle,
@@ -440,9 +461,17 @@ function resolveGenerator(handle: ActionGeneratorHandle): unknown {
return resolveGenerator(inner);
}
case "taps":
return randomTap();
return randomTap("Tap");
case "doubleTaps":
return randomTap("DoubleTap");
case "typing":
return randomInput();
case "swipes":
return randomSwipe();
case "longPresses":
case "scrolls":
// Why: web has no long-press or native scroll gesture for v1; both no-op.
return null;
case "waitOnce":
return { kind: "Wait", durationMillis: 500 };
case "pressKey":
@@ -456,7 +485,7 @@ function resolveGenerator(handle: ActionGeneratorHandle): unknown {
// doesn't re-walk the DOM and re-flush layout on every iteration.
let randomTapCandidates: HTMLElement[] | null = null;
function randomTap(): unknown {
function randomTap(kind: "Tap" | "DoubleTap"): unknown {
if (!randomTapCandidates) {
randomTapCandidates = Array.from(
document.querySelectorAll<HTMLElement>(
@@ -474,7 +503,7 @@ function randomTap(): unknown {
if (!picked) return null;
const rect = picked.getBoundingClientRect();
return {
kind: "Tap",
kind,
on: {
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
@@ -482,6 +511,59 @@ function randomTap(): unknown {
};
}
// Per-tick cache mirroring randomTapCandidates, reset each NextAction tick.
let randomInputCandidates: HTMLElement[] | null = null;
const NON_TEXT_INPUT_TYPES = [
"button", "submit", "checkbox", "radio", "range", "color", "file", "image", "reset",
];
// Edge-case input pool mirroring the goja-side inputCorpus: empty, whitespace,
// overflow length, unicode, numeric boundaries, and common injection payloads.
const WEB_INPUT_CORPUS = [
"", "a", "a".repeat(4096), "🙂🔥💸", " ", "\t\n", "-1",
"999999999999999999999", "0.0000001", "1e10", "'; DROP TABLE--",
"<script>alert(1)</script>", "../../etc/passwd", "%s%n", "NaN",
];
function isEditableElement(element: HTMLElement): boolean {
if (element.isContentEditable) return true;
const tag = element.tagName.toLowerCase();
if (tag === "textarea") return true;
if (tag === "input") {
const type = ((element as HTMLInputElement).type || "").toLowerCase();
return !NON_TEXT_INPUT_TYPES.includes(type);
}
return false;
}
function randomInput(): unknown {
if (!randomInputCandidates) {
randomInputCandidates = Array.from(
document.querySelectorAll<HTMLElement>("input, textarea, [contenteditable]"),
).filter((element) => {
if (!isEditableElement(element)) return false;
if ((element as HTMLInputElement).disabled) return false;
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
}
const candidates = randomInputCandidates;
if (candidates.length === 0) return null;
const picked = candidates[Math.floor(Math.random() * candidates.length)];
if (!picked) return null;
const rect = picked.getBoundingClientRect();
const text = WEB_INPUT_CORPUS[Math.floor(Math.random() * WEB_INPUT_CORPUS.length)];
return {
kind: "InputText",
into: {
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
},
text,
};
}
function randomSwipe(): unknown {
// Why: web has no swipe gesture; pointer drags into empty divs are noise.
return null;
@@ -514,6 +596,14 @@ function serializeAction(action: unknown): unknown {
}
return { kind: "Tap", x: point.x, y: point.y };
}
case "DoubleTap": {
const point = pointOf(obj.on);
if (!point) {
console.warn("[sanderling] DoubleTap target did not resolve to coordinates");
return null;
}
return { kind: "DoubleTap", x: point.x, y: point.y };
}
case "InputText": {
const point = pointOf(obj.into);
if (!point) {
@@ -543,6 +633,10 @@ function serializeAction(action: unknown): unknown {
duration_millis: obj.durationMillis ?? 250,
};
}
case "LongPress":
case "Scroll":
// Why: web has no long-press or native scroll gesture for v1; drop the action.
return null;
case "PressKey":
return { kind: "PressKey", key: obj.key };
case "Wait":
@@ -569,6 +663,7 @@ defineLockedGlobal("__sanderlingNextAction__", function (): unknown {
if (!actionsRoot) return null;
// Reset per-tick caches so each invocation gets a fresh DOM scan.
randomTapCandidates = null;
randomInputCandidates = null;
// Match the goja runtime: retry up to 16 times when a weighted entry's
// generator returns []. Otherwise on routes where most generators are
// gated to other pages, ~80% of ticks would emit no action.
+36 -1
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import {
DoubleTap,
InputText,
PressKey,
Swipe,
@@ -37,6 +38,7 @@ import type {
interface RecordedRuntime extends SanderlingRuntime {
extracts: Array<(state: State) => unknown>;
extractNames: Array<string | undefined>;
alwaysArgs: Array<(() => boolean) | Formula>;
nowPredicates: Array<() => boolean>;
nextPredicates: Array<() => boolean>;
@@ -91,6 +93,7 @@ function makeChainableEventually(record: RecordedRuntime): EventuallyFormula {
function installFakeRuntime(): RecordedRuntime {
const calls = {
extracts: [] as Array<(state: State) => unknown>,
extractNames: [] as Array<string | undefined>,
alwaysArgs: [] as Array<(() => boolean) | Formula>,
nowPredicates: [] as Array<() => boolean>,
nextPredicates: [] as Array<() => boolean>,
@@ -105,8 +108,9 @@ function installFakeRuntime(): RecordedRuntime {
fromCalls: [] as unknown[][],
};
const runtime = {
extract: <T>(getter: (state: State) => T): Extracted<T> => {
extract: <T>(getter: (state: State) => T, name?: string): Extracted<T> => {
calls.extracts.push(getter as (state: State) => unknown);
calls.extractNames.push(name);
return { current: undefined as unknown as T, previous: undefined };
},
always: (predicateOrFormula: (() => boolean) | Formula): Formula => {
@@ -138,6 +142,9 @@ function installFakeRuntime(): RecordedRuntime {
return { generate: () => items[0] as T };
},
tap: ({ on }) => ({ kind: "Tap", on }),
doubleTap: ({ on }) => ({ kind: "DoubleTap", on }),
longPress: ({ on }) => ({ kind: "LongPress", on }),
scroll: ({ direction, in: container }) => ({ kind: "Scroll", direction, in: container }),
inputText: ({ into, text }) => ({ kind: "InputText", into, text }),
swipe: ({ from: fromPoint, to, durationMillis }) => ({
kind: "Swipe",
@@ -148,6 +155,10 @@ function installFakeRuntime(): RecordedRuntime {
pressKey: ({ key }) => ({ kind: "PressKey", key }),
wait: ({ durationMillis }) => ({ kind: "Wait", durationMillis }),
taps: { __sanderlingActionGenerator: true, generate: () => [] },
doubleTaps: { __sanderlingActionGenerator: true, generate: () => [] },
longPresses: { __sanderlingActionGenerator: true, generate: () => [] },
scrolls: { __sanderlingActionGenerator: true, generate: () => [] },
typing: { __sanderlingActionGenerator: true, generate: () => [] },
swipes: { __sanderlingActionGenerator: true, generate: () => [] },
waitOnce: { __sanderlingActionGenerator: true, generate: () => [] },
pressKeys: { __sanderlingActionGenerator: true, generate: () => [] },
@@ -163,6 +174,24 @@ test("extract forwards the getter to the runtime", () => {
extract<unknown>(getter);
assert.equal(runtime.extracts.length, 1);
assert.equal(runtime.extracts[0], getter);
assert.equal(runtime.extractNames[0], undefined);
});
test("extract accepts an explicit name", () => {
const runtime = installFakeRuntime();
const getter = (state: State) => state.snapshots["balance"];
extract<unknown>("ledgerBalance", getter);
assert.equal(runtime.extracts.length, 1);
assert.equal(runtime.extracts[0], getter);
assert.equal(runtime.extractNames[0], "ledgerBalance");
});
test("extract throws when given a name with no getter", () => {
installFakeRuntime();
assert.throws(
() => (extract as unknown as (n: string) => unknown)("orphan"),
/getter is required/,
);
});
test("always wraps a predicate into a formula via the runtime", () => {
@@ -217,6 +246,12 @@ test("Tap returns a TapAction with the supplied selector", () => {
assert.deepEqual(action, { kind: "Tap", on: "id:login_continue" });
});
test("DoubleTap returns a DoubleTapAction with the supplied selector", () => {
installFakeRuntime();
const action = DoubleTap({ on: "id:save" });
assert.deepEqual(action, { kind: "DoubleTap", on: "id:save" });
});
test("Tap accepts an AccessibilityElement", () => {
installFakeRuntime();
const element: AccessibilityElement = {
+34
View File
@@ -58,11 +58,18 @@ function installRuntime(initialState: State): RecordedRuntime {
}),
from: <T>(_items: readonly T[]): Sampler<T> => ({ generate: () => _items[0] as T }),
tap: ({ on }) => ({ kind: "Tap", on }),
doubleTap: ({ on }) => ({ kind: "DoubleTap", on }),
longPress: ({ on }) => ({ kind: "LongPress", on }),
scroll: ({ direction, in: container }) => ({ kind: "Scroll", direction, in: container }),
inputText: ({ into, text }) => ({ kind: "InputText", into, text }),
swipe: (p) => ({ kind: "Swipe", from: p.from, to: p.to, durationMillis: p.durationMillis }),
pressKey: ({ key }) => ({ kind: "PressKey", key }),
wait: ({ durationMillis }) => ({ kind: "Wait", durationMillis }),
taps: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
doubleTaps: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
longPresses: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
scrolls: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
typing: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
swipes: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
waitOnce: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
pressKeys: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
@@ -108,3 +115,30 @@ test("defaults bundle exports formulas tagged as LTL properties", async () => {
assert.equal(defaults.noUncaughtExceptions.__sanderlingFormula, true);
assert.equal(defaults.noLogcatErrors.__sanderlingFormula, true);
});
test("defaults bundle exports defaultActions as a valid ActionGenerator", async () => {
installRuntime(emptyState);
const defaults = await import("../src/defaults/actions.ts");
assert.equal(defaults.defaultActions.__sanderlingActionGenerator, true);
assert.equal(typeof defaults.defaultActions.generate, "function");
});
test("typing resolves as a valid ActionGenerator", async () => {
installRuntime(emptyState);
const { typing } = await import("../src/defaults/actions.ts");
assert.equal(typing.__sanderlingActionGenerator, true);
});
test("longPresses and scrolls resolve as valid ActionGenerators", async () => {
installRuntime(emptyState);
const { longPresses, scrolls } = await import("../src/defaults/actions.ts");
assert.equal(longPresses.__sanderlingActionGenerator, true);
assert.equal(scrolls.__sanderlingActionGenerator, true);
});
test("defaults barrel re-exports both properties and actions", async () => {
installRuntime(emptyState);
const barrel = await import("../src/defaults/index.ts");
assert.equal(barrel.defaultActions.__sanderlingActionGenerator, true);
assert.equal(barrel.noUncaughtExceptions.__sanderlingFormula, true);
});
@@ -0,0 +1,243 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
parseTypedAmount,
submitChangesBalanceByTypedAmount,
} from "../../../examples/folio/sanderling/predicates.ts";
const submitOn = "testTag:LedgerScreen > testTag:TxnSubmit";
test("single submit: delta matches typed amount", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 1500,
}),
true,
);
});
test("double submit: delta is twice the typed amount, fires", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 2000,
}),
false,
);
});
test("DoubleTap kind also caught when delta exceeds typed amount", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
typedAmount: 500,
prevTotalBalance: 0,
currTotalBalance: 1000,
}),
false,
);
});
test("wrong action kind: vacuous true even with mismatch", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "InputText", on: submitOn },
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 1000,
}),
true,
);
});
test("wrong target: vacuous true even with mismatch", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit" },
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 1000,
}),
true,
);
});
test("null lastAction: vacuous true", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: null,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 2000,
}),
true,
);
});
test("zero typedAmount: vacuous true", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
typedAmount: 0,
prevTotalBalance: 1000,
currTotalBalance: 1500,
}),
true,
);
});
test("selector as object: coerced safely and TxnSubmit detected", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" } },
typedAmount: 500,
prevTotalBalance: 0,
currTotalBalance: 1000,
}),
false,
);
});
test("selector as object without TxnSubmit: vacuous true", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" } },
typedAmount: 500,
prevTotalBalance: 0,
currTotalBalance: 1000,
}),
true,
);
});
test("raw whole-dollar input: single submit clears", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
typedAmount: parseTypedAmount("50"),
prevTotalBalance: 5000,
currTotalBalance: 10000,
}),
true,
);
});
test("raw whole-dollar input: double submit fires", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
typedAmount: parseTypedAmount("50"),
prevTotalBalance: 5000,
currTotalBalance: 15000,
}),
false,
);
});
test("decimal input from empty prior balance clears", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
typedAmount: parseTypedAmount("5.50"),
prevTotalBalance: 0,
currTotalBalance: 550,
}),
true,
);
});
test("DoubleTap kind with raw whole-dollar input fires", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
typedAmount: parseTypedAmount("100"),
prevTotalBalance: 0,
currTotalBalance: 20000,
}),
false,
);
});
test("route gate: ledger landing with stale carrier is skipped", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "ledger",
lastAction: { kind: "Tap", on: submitOn },
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 0,
}),
true,
);
});
test("route gate: add-transaction landing with double-submit delta is skipped", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "add-transaction",
lastAction: { kind: "DoubleTap", on: submitOn },
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 10000,
}),
true,
);
});
test("route gate: null route is skipped", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: null,
lastAction: { kind: "Tap", on: submitOn },
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 0,
}),
true,
);
});
test("route gate: home landing with matching delta passes", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 5000,
}),
true,
);
});
test("route gate: home landing with double-insert delta fires", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 10000,
}),
false,
);
});
+97
View File
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { computeHomeTotalBalance } from "../../../examples/folio/sanderling/predicates.ts";
test("on Home with two cards ($10, $20): returns $30", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: ["$10.00", "$20.00"],
previousCarrier: 0,
}),
3000,
);
});
test("off Home (no cards) after a Home visit of $30: returns carrier $30", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: [],
previousCarrier: 3000,
}),
3000,
);
});
test("off Home (no cards) with carrier still 0: returns 0", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: [],
previousCarrier: 0,
}),
0,
);
});
test("sequence: Home $30, off-Home, Home $50 tracks new Home totals", () => {
let carrier = 0;
carrier = computeHomeTotalBalance({
cardBalanceTexts: ["$10.00", "$20.00"],
previousCarrier: carrier,
});
assert.equal(carrier, 3000);
carrier = computeHomeTotalBalance({
cardBalanceTexts: [],
previousCarrier: carrier,
});
assert.equal(carrier, 3000);
carrier = computeHomeTotalBalance({
cardBalanceTexts: ["$20.00", "$30.00"],
previousCarrier: carrier,
});
assert.equal(carrier, 5000);
});
test("Ledger step (no Home cards) holds the carrier, ignores Ledger balance", () => {
let carrier = 0;
carrier = computeHomeTotalBalance({
cardBalanceTexts: ["$10.00", "$20.00"],
previousCarrier: carrier,
});
assert.equal(carrier, 3000);
carrier = computeHomeTotalBalance({
cardBalanceTexts: [],
previousCarrier: carrier,
});
assert.equal(carrier, 3000);
});
test("negative card balance parses with sign and sums correctly", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: ["-$5.00", "$10.00"],
previousCarrier: 0,
}),
500,
);
});
test("single card on Home overrides any previous carrier", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: ["$7.50"],
previousCarrier: 9999,
}),
750,
);
});
test("undefined card balance text is treated as 0", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: [undefined, "$10.00"],
previousCarrier: 0,
}),
1000,
);
});
+56
View File
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { parseTypedAmount } from "../../../examples/folio/sanderling/predicates.ts";
test("empty string returns 0", () => {
assert.equal(parseTypedAmount(""), 0);
});
test("undefined returns 0", () => {
assert.equal(parseTypedAmount(undefined), 0);
});
test("null returns 0", () => {
assert.equal(parseTypedAmount(null), 0);
});
test("whole number 50 parses as 5000 cents", () => {
assert.equal(parseTypedAmount("50"), 5000);
});
test("decimal 5.50 parses as 550 cents", () => {
assert.equal(parseTypedAmount("5.50"), 550);
});
test("decimal 5.05 parses as 505 cents", () => {
assert.equal(parseTypedAmount("5.05"), 505);
});
test("one-decimal 0.5 parses as 50 cents", () => {
assert.equal(parseTypedAmount("0.5"), 50);
});
test("non-numeric returns 0", () => {
assert.equal(parseTypedAmount("abc"), 0);
});
test("too many decimals returns 0", () => {
assert.equal(parseTypedAmount("5.123"), 0);
});
test("zero returns 0", () => {
assert.equal(parseTypedAmount("0"), 0);
});
test("leading plus sign tolerated as positive", () => {
assert.equal(parseTypedAmount("+50"), 5000);
});
test("leading minus sign tolerated as positive", () => {
assert.equal(parseTypedAmount("-50"), 5000);
});
test("comma-separated thousands accepted", () => {
assert.equal(parseTypedAmount("1,234.56"), 123456);
});
+144 -78
View File
@@ -361,6 +361,58 @@ func (x *HierarchyJSON) GetJson() string {
return ""
}
type SnapshotResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Hierarchy *HierarchyJSON `protobuf:"bytes,1,opt,name=hierarchy,proto3" json:"hierarchy,omitempty"`
Screenshot *Image `protobuf:"bytes,2,opt,name=screenshot,proto3" json:"screenshot,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SnapshotResponse) Reset() {
*x = SnapshotResponse{}
mi := &file_driverpb_driver_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SnapshotResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SnapshotResponse) ProtoMessage() {}
func (x *SnapshotResponse) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SnapshotResponse.ProtoReflect.Descriptor instead.
func (*SnapshotResponse) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{7}
}
func (x *SnapshotResponse) GetHierarchy() *HierarchyJSON {
if x != nil {
return x.Hierarchy
}
return nil
}
func (x *SnapshotResponse) GetScreenshot() *Image {
if x != nil {
return x.Screenshot
}
return nil
}
type Duration struct {
state protoimpl.MessageState `protogen:"open.v1"`
Millis int64 `protobuf:"varint,1,opt,name=millis,proto3" json:"millis,omitempty"`
@@ -370,7 +422,7 @@ type Duration struct {
func (x *Duration) Reset() {
*x = Duration{}
mi := &file_driverpb_driver_proto_msgTypes[7]
mi := &file_driverpb_driver_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -382,7 +434,7 @@ func (x *Duration) String() string {
func (*Duration) ProtoMessage() {}
func (x *Duration) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[7]
mi := &file_driverpb_driver_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -395,7 +447,7 @@ func (x *Duration) ProtoReflect() protoreflect.Message {
// Deprecated: Use Duration.ProtoReflect.Descriptor instead.
func (*Duration) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{7}
return file_driverpb_driver_proto_rawDescGZIP(), []int{8}
}
func (x *Duration) GetMillis() int64 {
@@ -416,7 +468,7 @@ type HealthStatus struct {
func (x *HealthStatus) Reset() {
*x = HealthStatus{}
mi := &file_driverpb_driver_proto_msgTypes[8]
mi := &file_driverpb_driver_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -428,7 +480,7 @@ func (x *HealthStatus) String() string {
func (*HealthStatus) ProtoMessage() {}
func (x *HealthStatus) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[8]
mi := &file_driverpb_driver_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -441,7 +493,7 @@ func (x *HealthStatus) ProtoReflect() protoreflect.Message {
// Deprecated: Use HealthStatus.ProtoReflect.Descriptor instead.
func (*HealthStatus) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{8}
return file_driverpb_driver_proto_rawDescGZIP(), []int{9}
}
func (x *HealthStatus) GetReady() bool {
@@ -476,7 +528,7 @@ type SwipeRequest struct {
func (x *SwipeRequest) Reset() {
*x = SwipeRequest{}
mi := &file_driverpb_driver_proto_msgTypes[9]
mi := &file_driverpb_driver_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -488,7 +540,7 @@ func (x *SwipeRequest) String() string {
func (*SwipeRequest) ProtoMessage() {}
func (x *SwipeRequest) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[9]
mi := &file_driverpb_driver_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -501,7 +553,7 @@ func (x *SwipeRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use SwipeRequest.ProtoReflect.Descriptor instead.
func (*SwipeRequest) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{9}
return file_driverpb_driver_proto_rawDescGZIP(), []int{10}
}
func (x *SwipeRequest) GetFrom() *Point {
@@ -536,7 +588,7 @@ type PressKeyRequest struct {
func (x *PressKeyRequest) Reset() {
*x = PressKeyRequest{}
mi := &file_driverpb_driver_proto_msgTypes[10]
mi := &file_driverpb_driver_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -548,7 +600,7 @@ func (x *PressKeyRequest) String() string {
func (*PressKeyRequest) ProtoMessage() {}
func (x *PressKeyRequest) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[10]
mi := &file_driverpb_driver_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -561,7 +613,7 @@ func (x *PressKeyRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use PressKeyRequest.ProtoReflect.Descriptor instead.
func (*PressKeyRequest) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{10}
return file_driverpb_driver_proto_rawDescGZIP(), []int{11}
}
func (x *PressKeyRequest) GetKey() string {
@@ -584,7 +636,7 @@ type RecentLogsRequest struct {
func (x *RecentLogsRequest) Reset() {
*x = RecentLogsRequest{}
mi := &file_driverpb_driver_proto_msgTypes[11]
mi := &file_driverpb_driver_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -596,7 +648,7 @@ func (x *RecentLogsRequest) String() string {
func (*RecentLogsRequest) ProtoMessage() {}
func (x *RecentLogsRequest) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[11]
mi := &file_driverpb_driver_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -609,7 +661,7 @@ func (x *RecentLogsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RecentLogsRequest.ProtoReflect.Descriptor instead.
func (*RecentLogsRequest) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{11}
return file_driverpb_driver_proto_rawDescGZIP(), []int{12}
}
func (x *RecentLogsRequest) GetSinceUnixMillis() int64 {
@@ -635,7 +687,7 @@ type LogEntries struct {
func (x *LogEntries) Reset() {
*x = LogEntries{}
mi := &file_driverpb_driver_proto_msgTypes[12]
mi := &file_driverpb_driver_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -647,7 +699,7 @@ func (x *LogEntries) String() string {
func (*LogEntries) ProtoMessage() {}
func (x *LogEntries) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[12]
mi := &file_driverpb_driver_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -660,7 +712,7 @@ func (x *LogEntries) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogEntries.ProtoReflect.Descriptor instead.
func (*LogEntries) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{12}
return file_driverpb_driver_proto_rawDescGZIP(), []int{13}
}
func (x *LogEntries) GetEntries() []*LogEntry {
@@ -682,7 +734,7 @@ type LogEntry struct {
func (x *LogEntry) Reset() {
*x = LogEntry{}
mi := &file_driverpb_driver_proto_msgTypes[13]
mi := &file_driverpb_driver_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -694,7 +746,7 @@ func (x *LogEntry) String() string {
func (*LogEntry) ProtoMessage() {}
func (x *LogEntry) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[13]
mi := &file_driverpb_driver_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -707,7 +759,7 @@ func (x *LogEntry) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogEntry.ProtoReflect.Descriptor instead.
func (*LogEntry) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{13}
return file_driverpb_driver_proto_rawDescGZIP(), []int{14}
}
func (x *LogEntry) GetUnixMillis() int64 {
@@ -747,7 +799,7 @@ type MetricsRequest struct {
func (x *MetricsRequest) Reset() {
*x = MetricsRequest{}
mi := &file_driverpb_driver_proto_msgTypes[14]
mi := &file_driverpb_driver_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -759,7 +811,7 @@ func (x *MetricsRequest) String() string {
func (*MetricsRequest) ProtoMessage() {}
func (x *MetricsRequest) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[14]
mi := &file_driverpb_driver_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -772,7 +824,7 @@ func (x *MetricsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use MetricsRequest.ProtoReflect.Descriptor instead.
func (*MetricsRequest) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{14}
return file_driverpb_driver_proto_rawDescGZIP(), []int{15}
}
func (x *MetricsRequest) GetBundleId() string {
@@ -793,7 +845,7 @@ type MetricsResponse struct {
func (x *MetricsResponse) Reset() {
*x = MetricsResponse{}
mi := &file_driverpb_driver_proto_msgTypes[15]
mi := &file_driverpb_driver_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -805,7 +857,7 @@ func (x *MetricsResponse) String() string {
func (*MetricsResponse) ProtoMessage() {}
func (x *MetricsResponse) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[15]
mi := &file_driverpb_driver_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -818,7 +870,7 @@ func (x *MetricsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use MetricsResponse.ProtoReflect.Descriptor instead.
func (*MetricsResponse) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{15}
return file_driverpb_driver_proto_rawDescGZIP(), []int{16}
}
func (x *MetricsResponse) GetCpuPercent() float64 {
@@ -868,7 +920,12 @@ const file_driverpb_driver_proto_rawDesc = "" +
"\x05width\x18\x02 \x01(\x05R\x05width\x12\x16\n" +
"\x06height\x18\x03 \x01(\x05R\x06height\"#\n" +
"\rHierarchyJSON\x12\x12\n" +
"\x04json\x18\x01 \x01(\tR\x04json\"\"\n" +
"\x04json\x18\x01 \x01(\tR\x04json\"\x92\x01\n" +
"\x10SnapshotResponse\x12A\n" +
"\thierarchy\x18\x01 \x01(\v2#.sanderling.driver.v1.HierarchyJSONR\thierarchy\x12;\n" +
"\n" +
"screenshot\x18\x02 \x01(\v2\x1b.sanderling.driver.v1.ImageR\n" +
"screenshot\"\"\n" +
"\bDuration\x12\x16\n" +
"\x06millis\x18\x01 \x01(\x03R\x06millis\"Z\n" +
"\fHealthStatus\x12\x14\n" +
@@ -900,7 +957,7 @@ const file_driverpb_driver_proto_rawDesc = "" +
"cpuPercent\x12\x1d\n" +
"\n" +
"heap_bytes\x18\x02 \x01(\x03R\theapBytes\x12,\n" +
"\x12total_memory_bytes\x18\x03 \x01(\x03R\x10totalMemoryBytes2\xe7\a\n" +
"\x12total_memory_bytes\x18\x03 \x01(\x03R\x10totalMemoryBytes2\xff\b\n" +
"\x06Driver\x12J\n" +
"\x06Launch\x12#.sanderling.driver.v1.LaunchRequest\x1a\x1b.sanderling.driver.v1.Empty\x12E\n" +
"\tTerminate\x12\x1b.sanderling.driver.v1.Empty\x1a\x1b.sanderling.driver.v1.Empty\x12?\n" +
@@ -908,10 +965,12 @@ const file_driverpb_driver_proto_rawDesc = "" +
"\vTapSelector\x12\x1e.sanderling.driver.v1.Selector\x1a\x1b.sanderling.driver.v1.Empty\x12D\n" +
"\tInputText\x12\x1a.sanderling.driver.v1.Text\x1a\x1b.sanderling.driver.v1.Empty\x12H\n" +
"\x05Swipe\x12\".sanderling.driver.v1.SwipeRequest\x1a\x1b.sanderling.driver.v1.Empty\x12N\n" +
"\bPressKey\x12%.sanderling.driver.v1.PressKeyRequest\x1a\x1b.sanderling.driver.v1.Empty\x12F\n" +
"\bPressKey\x12%.sanderling.driver.v1.PressKeyRequest\x1a\x1b.sanderling.driver.v1.Empty\x12E\n" +
"\tLongPress\x12\x1b.sanderling.driver.v1.Point\x1a\x1b.sanderling.driver.v1.Empty\x12F\n" +
"\n" +
"Screenshot\x12\x1b.sanderling.driver.v1.Empty\x1a\x1b.sanderling.driver.v1.Image\x12M\n" +
"\tHierarchy\x12\x1b.sanderling.driver.v1.Empty\x1a#.sanderling.driver.v1.HierarchyJSON\x12W\n" +
"\tHierarchy\x12\x1b.sanderling.driver.v1.Empty\x1a#.sanderling.driver.v1.HierarchyJSON\x12O\n" +
"\bSnapshot\x12\x1b.sanderling.driver.v1.Empty\x1a&.sanderling.driver.v1.SnapshotResponse\x12W\n" +
"\n" +
"RecentLogs\x12'.sanderling.driver.v1.RecentLogsRequest\x1a .sanderling.driver.v1.LogEntries\x12J\n" +
"\vWaitForIdle\x12\x1e.sanderling.driver.v1.Duration\x1a\x1b.sanderling.driver.v1.Empty\x12I\n" +
@@ -931,7 +990,7 @@ func file_driverpb_driver_proto_rawDescGZIP() []byte {
return file_driverpb_driver_proto_rawDescData
}
var file_driverpb_driver_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
var file_driverpb_driver_proto_msgTypes = make([]protoimpl.MessageInfo, 18)
var file_driverpb_driver_proto_goTypes = []any{
(*Empty)(nil), // 0: sanderling.driver.v1.Empty
(*LaunchRequest)(nil), // 1: sanderling.driver.v1.LaunchRequest
@@ -940,53 +999,60 @@ var file_driverpb_driver_proto_goTypes = []any{
(*Selector)(nil), // 4: sanderling.driver.v1.Selector
(*Image)(nil), // 5: sanderling.driver.v1.Image
(*HierarchyJSON)(nil), // 6: sanderling.driver.v1.HierarchyJSON
(*Duration)(nil), // 7: sanderling.driver.v1.Duration
(*HealthStatus)(nil), // 8: sanderling.driver.v1.HealthStatus
(*SwipeRequest)(nil), // 9: sanderling.driver.v1.SwipeRequest
(*PressKeyRequest)(nil), // 10: sanderling.driver.v1.PressKeyRequest
(*RecentLogsRequest)(nil), // 11: sanderling.driver.v1.RecentLogsRequest
(*LogEntries)(nil), // 12: sanderling.driver.v1.LogEntries
(*LogEntry)(nil), // 13: sanderling.driver.v1.LogEntry
(*MetricsRequest)(nil), // 14: sanderling.driver.v1.MetricsRequest
(*MetricsResponse)(nil), // 15: sanderling.driver.v1.MetricsResponse
nil, // 16: sanderling.driver.v1.LaunchRequest.EnvEntry
(*SnapshotResponse)(nil), // 7: sanderling.driver.v1.SnapshotResponse
(*Duration)(nil), // 8: sanderling.driver.v1.Duration
(*HealthStatus)(nil), // 9: sanderling.driver.v1.HealthStatus
(*SwipeRequest)(nil), // 10: sanderling.driver.v1.SwipeRequest
(*PressKeyRequest)(nil), // 11: sanderling.driver.v1.PressKeyRequest
(*RecentLogsRequest)(nil), // 12: sanderling.driver.v1.RecentLogsRequest
(*LogEntries)(nil), // 13: sanderling.driver.v1.LogEntries
(*LogEntry)(nil), // 14: sanderling.driver.v1.LogEntry
(*MetricsRequest)(nil), // 15: sanderling.driver.v1.MetricsRequest
(*MetricsResponse)(nil), // 16: sanderling.driver.v1.MetricsResponse
nil, // 17: sanderling.driver.v1.LaunchRequest.EnvEntry
}
var file_driverpb_driver_proto_depIdxs = []int32{
16, // 0: sanderling.driver.v1.LaunchRequest.env:type_name -> sanderling.driver.v1.LaunchRequest.EnvEntry
2, // 1: sanderling.driver.v1.SwipeRequest.from:type_name -> sanderling.driver.v1.Point
2, // 2: sanderling.driver.v1.SwipeRequest.to:type_name -> sanderling.driver.v1.Point
13, // 3: sanderling.driver.v1.LogEntries.entries:type_name -> sanderling.driver.v1.LogEntry
1, // 4: sanderling.driver.v1.Driver.Launch:input_type -> sanderling.driver.v1.LaunchRequest
0, // 5: sanderling.driver.v1.Driver.Terminate:input_type -> sanderling.driver.v1.Empty
2, // 6: sanderling.driver.v1.Driver.Tap:input_type -> sanderling.driver.v1.Point
4, // 7: sanderling.driver.v1.Driver.TapSelector:input_type -> sanderling.driver.v1.Selector
3, // 8: sanderling.driver.v1.Driver.InputText:input_type -> sanderling.driver.v1.Text
9, // 9: sanderling.driver.v1.Driver.Swipe:input_type -> sanderling.driver.v1.SwipeRequest
10, // 10: sanderling.driver.v1.Driver.PressKey:input_type -> sanderling.driver.v1.PressKeyRequest
0, // 11: sanderling.driver.v1.Driver.Screenshot:input_type -> sanderling.driver.v1.Empty
0, // 12: sanderling.driver.v1.Driver.Hierarchy:input_type -> sanderling.driver.v1.Empty
11, // 13: sanderling.driver.v1.Driver.RecentLogs:input_type -> sanderling.driver.v1.RecentLogsRequest
7, // 14: sanderling.driver.v1.Driver.WaitForIdle:input_type -> sanderling.driver.v1.Duration
0, // 15: sanderling.driver.v1.Driver.Health:input_type -> sanderling.driver.v1.Empty
14, // 16: sanderling.driver.v1.Driver.Metrics:input_type -> sanderling.driver.v1.MetricsRequest
0, // 17: sanderling.driver.v1.Driver.Launch:output_type -> sanderling.driver.v1.Empty
0, // 18: sanderling.driver.v1.Driver.Terminate:output_type -> sanderling.driver.v1.Empty
0, // 19: sanderling.driver.v1.Driver.Tap:output_type -> sanderling.driver.v1.Empty
0, // 20: sanderling.driver.v1.Driver.TapSelector:output_type -> sanderling.driver.v1.Empty
0, // 21: sanderling.driver.v1.Driver.InputText:output_type -> sanderling.driver.v1.Empty
0, // 22: sanderling.driver.v1.Driver.Swipe:output_type -> sanderling.driver.v1.Empty
0, // 23: sanderling.driver.v1.Driver.PressKey:output_type -> sanderling.driver.v1.Empty
5, // 24: sanderling.driver.v1.Driver.Screenshot:output_type -> sanderling.driver.v1.Image
6, // 25: sanderling.driver.v1.Driver.Hierarchy:output_type -> sanderling.driver.v1.HierarchyJSON
12, // 26: sanderling.driver.v1.Driver.RecentLogs:output_type -> sanderling.driver.v1.LogEntries
0, // 27: sanderling.driver.v1.Driver.WaitForIdle:output_type -> sanderling.driver.v1.Empty
8, // 28: sanderling.driver.v1.Driver.Health:output_type -> sanderling.driver.v1.HealthStatus
15, // 29: sanderling.driver.v1.Driver.Metrics:output_type -> sanderling.driver.v1.MetricsResponse
17, // [17:30] is the sub-list for method output_type
4, // [4:17] is the sub-list for method input_type
4, // [4:4] is the sub-list for extension type_name
4, // [4:4] is the sub-list for extension extendee
0, // [0:4] is the sub-list for field type_name
17, // 0: sanderling.driver.v1.LaunchRequest.env:type_name -> sanderling.driver.v1.LaunchRequest.EnvEntry
6, // 1: sanderling.driver.v1.SnapshotResponse.hierarchy:type_name -> sanderling.driver.v1.HierarchyJSON
5, // 2: sanderling.driver.v1.SnapshotResponse.screenshot:type_name -> sanderling.driver.v1.Image
2, // 3: sanderling.driver.v1.SwipeRequest.from:type_name -> sanderling.driver.v1.Point
2, // 4: sanderling.driver.v1.SwipeRequest.to:type_name -> sanderling.driver.v1.Point
14, // 5: sanderling.driver.v1.LogEntries.entries:type_name -> sanderling.driver.v1.LogEntry
1, // 6: sanderling.driver.v1.Driver.Launch:input_type -> sanderling.driver.v1.LaunchRequest
0, // 7: sanderling.driver.v1.Driver.Terminate:input_type -> sanderling.driver.v1.Empty
2, // 8: sanderling.driver.v1.Driver.Tap:input_type -> sanderling.driver.v1.Point
4, // 9: sanderling.driver.v1.Driver.TapSelector:input_type -> sanderling.driver.v1.Selector
3, // 10: sanderling.driver.v1.Driver.InputText:input_type -> sanderling.driver.v1.Text
10, // 11: sanderling.driver.v1.Driver.Swipe:input_type -> sanderling.driver.v1.SwipeRequest
11, // 12: sanderling.driver.v1.Driver.PressKey:input_type -> sanderling.driver.v1.PressKeyRequest
2, // 13: sanderling.driver.v1.Driver.LongPress:input_type -> sanderling.driver.v1.Point
0, // 14: sanderling.driver.v1.Driver.Screenshot:input_type -> sanderling.driver.v1.Empty
0, // 15: sanderling.driver.v1.Driver.Hierarchy:input_type -> sanderling.driver.v1.Empty
0, // 16: sanderling.driver.v1.Driver.Snapshot:input_type -> sanderling.driver.v1.Empty
12, // 17: sanderling.driver.v1.Driver.RecentLogs:input_type -> sanderling.driver.v1.RecentLogsRequest
8, // 18: sanderling.driver.v1.Driver.WaitForIdle:input_type -> sanderling.driver.v1.Duration
0, // 19: sanderling.driver.v1.Driver.Health:input_type -> sanderling.driver.v1.Empty
15, // 20: sanderling.driver.v1.Driver.Metrics:input_type -> sanderling.driver.v1.MetricsRequest
0, // 21: sanderling.driver.v1.Driver.Launch:output_type -> sanderling.driver.v1.Empty
0, // 22: sanderling.driver.v1.Driver.Terminate:output_type -> sanderling.driver.v1.Empty
0, // 23: sanderling.driver.v1.Driver.Tap:output_type -> sanderling.driver.v1.Empty
0, // 24: sanderling.driver.v1.Driver.TapSelector:output_type -> sanderling.driver.v1.Empty
0, // 25: sanderling.driver.v1.Driver.InputText:output_type -> sanderling.driver.v1.Empty
0, // 26: sanderling.driver.v1.Driver.Swipe:output_type -> sanderling.driver.v1.Empty
0, // 27: sanderling.driver.v1.Driver.PressKey:output_type -> sanderling.driver.v1.Empty
0, // 28: sanderling.driver.v1.Driver.LongPress:output_type -> sanderling.driver.v1.Empty
5, // 29: sanderling.driver.v1.Driver.Screenshot:output_type -> sanderling.driver.v1.Image
6, // 30: sanderling.driver.v1.Driver.Hierarchy:output_type -> sanderling.driver.v1.HierarchyJSON
7, // 31: sanderling.driver.v1.Driver.Snapshot:output_type -> sanderling.driver.v1.SnapshotResponse
13, // 32: sanderling.driver.v1.Driver.RecentLogs:output_type -> sanderling.driver.v1.LogEntries
0, // 33: sanderling.driver.v1.Driver.WaitForIdle:output_type -> sanderling.driver.v1.Empty
9, // 34: sanderling.driver.v1.Driver.Health:output_type -> sanderling.driver.v1.HealthStatus
16, // 35: sanderling.driver.v1.Driver.Metrics:output_type -> sanderling.driver.v1.MetricsResponse
21, // [21:36] is the sub-list for method output_type
6, // [6:21] is the sub-list for method input_type
6, // [6:6] is the sub-list for extension type_name
6, // [6:6] is the sub-list for extension extendee
0, // [0:6] is the sub-list for field type_name
}
func init() { file_driverpb_driver_proto_init() }
@@ -1000,7 +1066,7 @@ func file_driverpb_driver_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_driverpb_driver_proto_rawDesc), len(file_driverpb_driver_proto_rawDesc)),
NumEnums: 0,
NumMessages: 17,
NumMessages: 18,
NumExtensions: 0,
NumServices: 1,
},
+10
View File
@@ -15,9 +15,14 @@ service Driver {
rpc InputText(Text) returns (Empty);
rpc Swipe(SwipeRequest) returns (Empty);
rpc PressKey(PressKeyRequest) returns (Empty);
rpc LongPress(Point) returns (Empty);
rpc Screenshot(Empty) returns (Image);
rpc Hierarchy(Empty) returns (HierarchyJSON);
// Snapshot captures hierarchy and screenshot back-to-back under a
// backend-side mutex so the pair describes the same on-device frame.
// Use this instead of racing Hierarchy and Screenshot from the runner.
rpc Snapshot(Empty) returns (SnapshotResponse);
rpc RecentLogs(RecentLogsRequest) returns (LogEntries);
rpc WaitForIdle(Duration) returns (Empty);
@@ -56,6 +61,11 @@ message HierarchyJSON {
string json = 1;
}
message SnapshotResponse {
HierarchyJSON hierarchy = 1;
Image screenshot = 2;
}
message Duration {
int64 millis = 1;
}
+82
View File
@@ -26,8 +26,10 @@ const (
Driver_InputText_FullMethodName = "/sanderling.driver.v1.Driver/InputText"
Driver_Swipe_FullMethodName = "/sanderling.driver.v1.Driver/Swipe"
Driver_PressKey_FullMethodName = "/sanderling.driver.v1.Driver/PressKey"
Driver_LongPress_FullMethodName = "/sanderling.driver.v1.Driver/LongPress"
Driver_Screenshot_FullMethodName = "/sanderling.driver.v1.Driver/Screenshot"
Driver_Hierarchy_FullMethodName = "/sanderling.driver.v1.Driver/Hierarchy"
Driver_Snapshot_FullMethodName = "/sanderling.driver.v1.Driver/Snapshot"
Driver_RecentLogs_FullMethodName = "/sanderling.driver.v1.Driver/RecentLogs"
Driver_WaitForIdle_FullMethodName = "/sanderling.driver.v1.Driver/WaitForIdle"
Driver_Health_FullMethodName = "/sanderling.driver.v1.Driver/Health"
@@ -45,8 +47,13 @@ type DriverClient interface {
InputText(ctx context.Context, in *Text, opts ...grpc.CallOption) (*Empty, error)
Swipe(ctx context.Context, in *SwipeRequest, opts ...grpc.CallOption) (*Empty, error)
PressKey(ctx context.Context, in *PressKeyRequest, opts ...grpc.CallOption) (*Empty, error)
LongPress(ctx context.Context, in *Point, opts ...grpc.CallOption) (*Empty, error)
Screenshot(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Image, error)
Hierarchy(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*HierarchyJSON, error)
// Snapshot captures hierarchy and screenshot back-to-back under a
// backend-side mutex so the pair describes the same on-device frame.
// Use this instead of racing Hierarchy and Screenshot from the runner.
Snapshot(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*SnapshotResponse, error)
RecentLogs(ctx context.Context, in *RecentLogsRequest, opts ...grpc.CallOption) (*LogEntries, error)
WaitForIdle(ctx context.Context, in *Duration, opts ...grpc.CallOption) (*Empty, error)
Health(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*HealthStatus, error)
@@ -131,6 +138,16 @@ func (c *driverClient) PressKey(ctx context.Context, in *PressKeyRequest, opts .
return out, nil
}
func (c *driverClient) LongPress(ctx context.Context, in *Point, opts ...grpc.CallOption) (*Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Empty)
err := c.cc.Invoke(ctx, Driver_LongPress_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *driverClient) Screenshot(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Image, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Image)
@@ -151,6 +168,16 @@ func (c *driverClient) Hierarchy(ctx context.Context, in *Empty, opts ...grpc.Ca
return out, nil
}
func (c *driverClient) Snapshot(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*SnapshotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SnapshotResponse)
err := c.cc.Invoke(ctx, Driver_Snapshot_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *driverClient) RecentLogs(ctx context.Context, in *RecentLogsRequest, opts ...grpc.CallOption) (*LogEntries, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LogEntries)
@@ -202,8 +229,13 @@ type DriverServer interface {
InputText(context.Context, *Text) (*Empty, error)
Swipe(context.Context, *SwipeRequest) (*Empty, error)
PressKey(context.Context, *PressKeyRequest) (*Empty, error)
LongPress(context.Context, *Point) (*Empty, error)
Screenshot(context.Context, *Empty) (*Image, error)
Hierarchy(context.Context, *Empty) (*HierarchyJSON, error)
// Snapshot captures hierarchy and screenshot back-to-back under a
// backend-side mutex so the pair describes the same on-device frame.
// Use this instead of racing Hierarchy and Screenshot from the runner.
Snapshot(context.Context, *Empty) (*SnapshotResponse, error)
RecentLogs(context.Context, *RecentLogsRequest) (*LogEntries, error)
WaitForIdle(context.Context, *Duration) (*Empty, error)
Health(context.Context, *Empty) (*HealthStatus, error)
@@ -239,12 +271,18 @@ func (UnimplementedDriverServer) Swipe(context.Context, *SwipeRequest) (*Empty,
func (UnimplementedDriverServer) PressKey(context.Context, *PressKeyRequest) (*Empty, error) {
return nil, status.Error(codes.Unimplemented, "method PressKey not implemented")
}
func (UnimplementedDriverServer) LongPress(context.Context, *Point) (*Empty, error) {
return nil, status.Error(codes.Unimplemented, "method LongPress not implemented")
}
func (UnimplementedDriverServer) Screenshot(context.Context, *Empty) (*Image, error) {
return nil, status.Error(codes.Unimplemented, "method Screenshot not implemented")
}
func (UnimplementedDriverServer) Hierarchy(context.Context, *Empty) (*HierarchyJSON, error) {
return nil, status.Error(codes.Unimplemented, "method Hierarchy not implemented")
}
func (UnimplementedDriverServer) Snapshot(context.Context, *Empty) (*SnapshotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Snapshot not implemented")
}
func (UnimplementedDriverServer) RecentLogs(context.Context, *RecentLogsRequest) (*LogEntries, error) {
return nil, status.Error(codes.Unimplemented, "method RecentLogs not implemented")
}
@@ -404,6 +442,24 @@ func _Driver_PressKey_Handler(srv interface{}, ctx context.Context, dec func(int
return interceptor(ctx, in, info, handler)
}
func _Driver_LongPress_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Point)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DriverServer).LongPress(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Driver_LongPress_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DriverServer).LongPress(ctx, req.(*Point))
}
return interceptor(ctx, in, info, handler)
}
func _Driver_Screenshot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Empty)
if err := dec(in); err != nil {
@@ -440,6 +496,24 @@ func _Driver_Hierarchy_Handler(srv interface{}, ctx context.Context, dec func(in
return interceptor(ctx, in, info, handler)
}
func _Driver_Snapshot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DriverServer).Snapshot(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Driver_Snapshot_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DriverServer).Snapshot(ctx, req.(*Empty))
}
return interceptor(ctx, in, info, handler)
}
func _Driver_RecentLogs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RecentLogsRequest)
if err := dec(in); err != nil {
@@ -547,6 +621,10 @@ var Driver_ServiceDesc = grpc.ServiceDesc{
MethodName: "PressKey",
Handler: _Driver_PressKey_Handler,
},
{
MethodName: "LongPress",
Handler: _Driver_LongPress_Handler,
},
{
MethodName: "Screenshot",
Handler: _Driver_Screenshot_Handler,
@@ -555,6 +633,10 @@ var Driver_ServiceDesc = grpc.ServiceDesc{
MethodName: "Hierarchy",
Handler: _Driver_Hierarchy_Handler,
},
{
MethodName: "Snapshot",
Handler: _Driver_Snapshot_Handler,
},
{
MethodName: "RecentLogs",
Handler: _Driver_RecentLogs_Handler,
+2
View File
@@ -20,8 +20,10 @@ func TestDriverServiceDescriptor(t *testing.T) {
"InputText": true,
"Swipe": true,
"PressKey": true,
"LongPress": true,
"Screenshot": true,
"Hierarchy": true,
"Snapshot": true,
"RecentLogs": true,
"WaitForIdle": true,
"Health": true,
@@ -8,12 +8,165 @@ interface DriverBackend {
fun inputText(text: String)
fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long)
fun pressKey(key: String)
fun longPress(x: Int, y: Int)
fun screenshot(): Triple<ByteArray, Int, Int>
fun hierarchy(): String
fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine>
fun waitForIdle(durationMillis: Long)
fun healthy(): Boolean
fun metrics(bundleId: String): MetricsSample
// snapshot captures hierarchy then screenshot back-to-back. The service
// layer holds a mutex around the call so concurrent callers observe a
// serialized pair from the same on-device frame. Backends may override
// to fuse the two reads more tightly when their native API allows.
fun snapshot(): SnapshotSample = SnapshotSample(hierarchy(), screenshot())
}
data class SnapshotSample(
val hierarchyJson: String,
val screenshot: Triple<ByteArray, Int, Int>,
)
// STABILITY_POLL_INTERVAL_MILLIS is set wide enough that UiAutomation /
// Maestro's contentDescriptor doesn't get hammered: tighter intervals were
// observed to back the sidecar gRPC stream up under fuzz load to the point
// of triggering 120s inputText deadlines.
internal const val STABILITY_POLL_INTERVAL_MILLIS = 250L
internal const val STABILITY_POLL_CAP_MILLIS = 2000L
// MIN_STABLE_STREAK_MILLIS is the duration the tree must stay structurally
// identical (and non-transitional) before the poll declares settle. Actions
// whose effect is async (e.g. tap-submit fires a DB write that later calls
// popBackStack) leave the UI momentarily stable before the navigation
// transition fires; requiring an uninterrupted streak of this length means
// any churn that starts during the window resets the clock instead of being
// missed.
internal const val MIN_STABLE_STREAK_MILLIS = 750L
// pollUntilStable returns when the snapshot has been non-null and equal to
// itself for an uninterrupted stretch of at least MIN_STABLE_STREAK_MILLIS,
// capped at timeoutMillis. snapshot must omit transient attributes (e.g.
// measure-pass bounds) so layout-only flicker doesn't extend the wait, and
// must return null when the snapshot looks transitional (e.g. mid NavHost
// cross-fade) so the streak resets and the loop keeps polling instead of
// declaring a partial state stable.
internal fun pollUntilStable(timeoutMillis: Long, snapshot: () -> String?) {
if (timeoutMillis <= 0) return
val deadline = System.currentTimeMillis() + timeoutMillis
var prior = try {
snapshot()
} catch (_: Exception) {
null
}
var streakStart = 0L
while (System.currentTimeMillis() < deadline) {
Thread.sleep(STABILITY_POLL_INTERVAL_MILLIS)
val current = try {
snapshot()
} catch (_: Exception) {
null
}
val now = System.currentTimeMillis()
if (prior != null && current != null && prior == current) {
if (streakStart == 0L) streakStart = now
if (now - streakStart >= MIN_STABLE_STREAK_MILLIS) return
} else {
streakStart = 0L
}
prior = current
}
}
// stabilitySnapshot probes the current hierarchy for both structural shape
// and route-transition state. Returns null while more than one route-level
// destination tag (resource-id/testTag/identifier ending in "Screen") is
// present, because NavHost keeps both source and destination composables
// alive during a cross-fade and a snapshot taken in that window is
// unreliable (lazy lists in the incoming screen mount over multiple frames).
internal fun stabilitySnapshot(treeJson: String): String? {
if (treeJson.isBlank()) return ""
if (countRouteScreens(treeJson) > 1) return null
return structuralHash(treeJson)
}
private val ROUTE_TAG_KEYS = setOf(
"resource-id", "resourceId", "testTag",
"identifier", "accessibilityIdentifier",
)
internal fun countRouteScreens(treeJson: String): Int {
if (treeJson.isBlank()) return 0
return try {
val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
val root = mapper.readTree(treeJson)
countRouteScreens(root)
} catch (_: Exception) {
0
}
}
private fun countRouteScreens(node: com.fasterxml.jackson.databind.JsonNode): Int {
var count = 0
val attributes = node.get("attributes")
if (attributes != null && attributes.isObject) {
for (key in ROUTE_TAG_KEYS) {
val value = attributes.get(key) ?: continue
if (value.isNull) continue
if (value.asText().endsWith("Screen")) {
count++
break
}
}
}
val children = node.get("children")
if (children != null && children.isArray) {
for (child in children) count += countRouteScreens(child)
}
return count
}
// structuralHash hashes a Maestro TreeNode-shaped JSON string by walking it
// and concatenating only the stable identity attributes (resource-id, class,
// content-desc, text), in tree order. The transient `bounds` and per-frame
// layout coordinates are excluded so a measure-pass that shifts pixels
// without changing what's on screen does not extend the wait.
internal fun structuralHash(treeJson: String): String {
if (treeJson.isBlank()) return ""
return try {
val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
val root = mapper.readTree(treeJson)
val builder = StringBuilder()
walkForStructuralHash(root, builder)
builder.toString()
} catch (_: Exception) {
treeJson
}
}
private val STABLE_ATTRIBUTE_KEYS = listOf(
"resource-id", "resourceId",
"class", "className",
"content-desc", "contentDescription", "accessibilityText",
"text",
"testTag", "identifier", "accessibilityIdentifier",
)
private fun walkForStructuralHash(node: com.fasterxml.jackson.databind.JsonNode, out: StringBuilder) {
out.append('(')
val attributes = node.get("attributes")
if (attributes != null && attributes.isObject) {
for (key in STABLE_ATTRIBUTE_KEYS) {
val value = attributes.get(key) ?: continue
if (value.isNull) continue
out.append(key).append(':').append(value.asText()).append('|')
}
}
val children = node.get("children")
if (children != null && children.isArray) {
for (child in children) walkForStructuralHash(child, out)
}
out.append(')')
}
data class MetricsSample(
@@ -119,7 +272,20 @@ private fun parseKb(line: String): Long? {
return parts[1].toLongOrNull()
}
class StubDriverBackend(private val platform: String) : DriverBackend {
private fun execAdb(arguments: List<String>) {
try {
val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start()
command.inputStream.bufferedReader().readText()
command.waitFor()
} catch (cause: Exception) {
println("adb ${arguments.joinToString(" ")} failed: $cause")
}
}
class StubDriverBackend(
private val platform: String,
private val commandRunner: (List<String>) -> Unit = ::execAdb,
) : DriverBackend {
@Volatile var launchCount: Int = 0
private set
@Volatile var lastBundleId: String? = null
@@ -157,16 +323,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return null
}
internal const val MAX_CLEAR_DELETES: Int = 1024
internal fun buildClearKeyevents(textLength: Int): List<String> {
if (textLength <= 0) return emptyList()
val deletes = minOf(textLength, MAX_CLEAR_DELETES)
val args = mutableListOf("shell", "input", "keyevent", "KEYCODE_MOVE_END")
repeat(deletes) { args.add("KEYCODE_DEL") }
return args
}
internal fun escapeForAdbInputText(text: String): String {
val sb = StringBuilder(text.length)
for (ch in text) {
@@ -180,24 +336,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return sb.toString()
}
private val FOCUSED_NODE = Regex(
"<node\\b([^>]*\\bfocused=\"true\"[^>]*)/?>",
)
private val TEXT_ATTRIBUTE = Regex("\\btext=\"([^\"]*)\"")
internal fun parseFocusedText(xml: String): String? {
val node = FOCUSED_NODE.find(xml) ?: return null
val match = TEXT_ATTRIBUTE.find(node.groupValues[1]) ?: return ""
return decodeXmlAttribute(match.groupValues[1])
}
private fun decodeXmlAttribute(value: String): String = value
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
internal val KEY_MAP: Map<String, String> = mapOf(
"back" to "KEYCODE_BACK",
"home" to "KEYCODE_HOME",
@@ -261,31 +399,15 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
override fun inputText(text: String) {
lastInputText = text
clearFocusedField()
runAdb(listOf("shell", "input", "text", escapeForAdbInputText(text)))
}
private fun clearFocusedField() {
val current = focusedFieldText() ?: return
if (current.isEmpty()) return
runAdb(buildClearKeyevents(current.length))
}
private fun focusedFieldText(): String? {
val xml = try {
hierarchy()
} catch (cause: Exception) {
println("inputText: hierarchy dump failed: $cause")
return null
}
if (xml.isBlank() || xml == "<hierarchy/>") return null
return parseFocusedText(xml)
}
@Volatile var lastSwipe: SwipeRecord? = null
private set
@Volatile var lastKey: String? = null
private set
@Volatile var lastLongPress: Pair<Int, Int>? = null
private set
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) {
lastSwipe = SwipeRecord(fromX, fromY, toX, toY, durationMillis)
@@ -307,20 +429,17 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
runAdb(listOf("shell", "input", "keyevent", keyCode))
}
override fun longPress(x: Int, y: Int) {
lastLongPress = x to y
runAdb(listOf("shell", "input", "swipe", x.toString(), y.toString(), x.toString(), y.toString(), "600"))
}
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> =
readLogcat(null, sinceUnixMillis, minLevel)
data class SwipeRecord(val fromX: Int, val fromY: Int, val toX: Int, val toY: Int, val durationMillis: Long)
private fun runAdb(arguments: List<String>) {
try {
val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start()
command.inputStream.bufferedReader().readText()
command.waitFor()
} catch (cause: Exception) {
println("adb ${arguments.joinToString(" ")} failed: $cause")
}
}
private fun runAdb(arguments: List<String>) = commandRunner(arguments)
override fun screenshot(): Triple<ByteArray, Int, Int> {
return try {
@@ -354,12 +473,18 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
}
override fun waitForIdle(durationMillis: Long) {
// Two-stage settle: an mAnimating poll catches View-system animations,
// then a short structural-hash poll catches Compose cross-fades where
// two composables are simultaneously alive but mAnimating is already
// false. The structural poll is hard-capped so we don't hammer the
// device with repeat hierarchy fetches when the UI never stabilizes.
if (durationMillis <= 0) return
val deadline = System.currentTimeMillis() + durationMillis
while (System.currentTimeMillis() < deadline) {
if (isDeviceIdle()) return
val animationDeadline = System.currentTimeMillis() + durationMillis
while (System.currentTimeMillis() < animationDeadline) {
if (isDeviceIdle()) break
Thread.sleep(IDLE_POLL_INTERVAL_MILLIS)
}
pollUntilStable(STABILITY_POLL_CAP_MILLIS) { stabilitySnapshot(hierarchy()) }
}
private fun isDeviceIdle(): Boolean {
@@ -396,6 +521,8 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
override fun tap(x: Int, y: Int) = driver.tap(maestro.Point(x, y))
override fun longPress(x: Int, y: Int) = driver.longPress(maestro.Point(x, y))
override fun tapSelector(selector: String) {
val root = driver.contentDescriptor(false)
val bounds = findBoundsBySelector(root, selector) ?: return
@@ -427,7 +554,20 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
readLogcat(serial, sinceUnixMillis, minLevel)
override fun waitForIdle(durationMillis: Long) {
// waitForAppToSettle returns early on Compose cross-fade transitions
// where both source and destination composables are semantically
// alive; a follow-up short structural-hash poll lands on a single
// stable frame before the runner reads hierarchy + screenshot
// concurrently. The structural poll is hard-capped independently of
// durationMillis so we don't pile on hierarchy fetches when settle
// never converges.
driver.waitForAppToSettle(null, null, durationMillis.toInt())
pollUntilStable(STABILITY_POLL_CAP_MILLIS) {
stabilitySnapshot(
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(driver.contentDescriptor(false)),
)
}
}
override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false }
@@ -580,6 +720,8 @@ class IosDriverBackend(private val udid: String) : DriverBackend {
override fun tap(x: Int, y: Int) = withReconnect { driver.tap(maestro.Point(x, y)) }
override fun longPress(x: Int, y: Int) = withReconnect { driver.longPress(maestro.Point(x, y)) }
override fun tapSelector(selector: String) = withReconnect {
val root = driver.contentDescriptor(false)
val bounds = findBoundsBySelector(root, selector) ?: return@withReconnect
@@ -615,6 +757,12 @@ class IosDriverBackend(private val udid: String) : DriverBackend {
override fun waitForIdle(durationMillis: Long) = withReconnect {
driver.waitForAppToSettle(null, null, durationMillis.toInt())
pollUntilStable(STABILITY_POLL_CAP_MILLIS) {
stabilitySnapshot(
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(driver.contentDescriptor(false)),
)
}
Unit
}
@@ -16,6 +16,7 @@ import dev.sanderling.driver.v1.Point
import dev.sanderling.driver.v1.PressKeyRequest
import dev.sanderling.driver.v1.RecentLogsRequest
import dev.sanderling.driver.v1.Selector
import dev.sanderling.driver.v1.SnapshotResponse
import dev.sanderling.driver.v1.SwipeRequest
import dev.sanderling.driver.v1.Text
import io.grpc.stub.StreamObserver
@@ -27,6 +28,7 @@ class DriverService(
) : DriverGrpc.DriverImplBase() {
private val launchedBundleId = AtomicReference<String?>(null)
private val snapshotLock = Any()
override fun launch(request: LaunchRequest, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) {
@@ -51,6 +53,13 @@ class DriverService(
}
}
override fun longPress(request: Point, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) {
backend.longPress(request.x, request.y)
Empty.getDefaultInstance()
}
}
override fun tapSelector(request: Selector, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) {
backend.tapSelector(request.value)
@@ -116,6 +125,23 @@ class DriverService(
}
}
override fun snapshot(request: Empty, responseObserver: StreamObserver<SnapshotResponse>) {
runRpc(responseObserver) {
val sample = synchronized(snapshotLock) { backend.snapshot() }
val (png, width, height) = sample.screenshot
SnapshotResponse.newBuilder()
.setHierarchy(HierarchyJSON.newBuilder().setJson(sample.hierarchyJson).build())
.setScreenshot(
Image.newBuilder()
.setPng(ByteString.copyFrom(png))
.setWidth(width)
.setHeight(height)
.build(),
)
.build()
}
}
override fun waitForIdle(request: Duration, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) {
backend.waitForIdle(request.millis)
@@ -61,6 +61,19 @@ class DriverServiceTest {
assertEquals(100 to 250, backend.lastTap)
}
@Test fun longPressForwardsCoordinates() {
var observed: Pair<Int, Int>? = null
val backend = object : DriverBackend by StubDriverBackend("android") {
override fun longPress(x: Int, y: Int) {
observed = x to y
}
}
val client = newClient(backend)
client.longPress(Point.newBuilder().setX(400).setY(900).build())
assertEquals(400 to 900, observed)
}
@Test fun inputTextForwardsValue() {
val backend = StubDriverBackend("android")
val client = newClient(backend)
@@ -1,90 +0,0 @@
package dev.sanderling.sidecar
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class InputTextClearTest {
@Test fun buildClearKeyeventsEmptyWhenNothingToDelete() {
assertEquals(emptyList(), StubDriverBackend.buildClearKeyevents(0))
assertEquals(emptyList(), StubDriverBackend.buildClearKeyevents(-3))
}
@Test fun buildClearKeyeventsPrefixesMoveEndThenOneDelPerChar() {
val args = StubDriverBackend.buildClearKeyevents(3)
assertEquals(listOf("shell", "input", "keyevent", "KEYCODE_MOVE_END",
"KEYCODE_DEL", "KEYCODE_DEL", "KEYCODE_DEL"), args)
}
@Test fun buildClearKeyeventsCapsAtMaxClearDeletes() {
val huge = StubDriverBackend.MAX_CLEAR_DELETES * 10
val args = StubDriverBackend.buildClearKeyevents(huge)
val deletes = args.count { it == "KEYCODE_DEL" }
assertEquals(StubDriverBackend.MAX_CLEAR_DELETES, deletes)
assertEquals("KEYCODE_MOVE_END", args[3])
}
@Test fun escapeForAdbInputTextSubstitutesSpaces() {
assertEquals("hello%sworld", StubDriverBackend.escapeForAdbInputText("hello world"))
}
@Test fun escapeForAdbInputTextEscapesShellMetacharacters() {
val escaped = StubDriverBackend.escapeForAdbInputText("a&b|c;d\$e`f")
assertEquals("a\\&b\\|c\\;d\\\$e\\`f", escaped)
}
@Test fun escapeForAdbInputTextEscapesQuotesAndBackslash() {
assertEquals("\\'", StubDriverBackend.escapeForAdbInputText("'"))
assertEquals("\\\"", StubDriverBackend.escapeForAdbInputText("\""))
assertEquals("\\\\", StubDriverBackend.escapeForAdbInputText("\\"))
}
@Test fun escapeForAdbInputTextLeavesSimpleTextAlone() {
assertEquals("12.34", StubDriverBackend.escapeForAdbInputText("12.34"))
assertEquals("Coffee", StubDriverBackend.escapeForAdbInputText("Coffee"))
assertTrue("-5" == StubDriverBackend.escapeForAdbInputText("-5"))
}
@Test fun parsesTextFromFocusedNode() {
val xml = """
<hierarchy>
<node text="ignored" focused="false"/>
<node resource-id="app:id/email" text="old@value" focused="true"/>
</hierarchy>
""".trimIndent()
assertEquals("old@value", StubDriverBackend.parseFocusedText(xml))
}
@Test fun returnsEmptyWhenFocusedNodeHasNoTextAttribute() {
val xml = """<hierarchy><node focused="true"/></hierarchy>"""
assertEquals("", StubDriverBackend.parseFocusedText(xml))
}
@Test fun returnsNullWhenNoFocusedNode() {
val xml = """<hierarchy><node text="x" focused="false"/></hierarchy>"""
assertNull(StubDriverBackend.parseFocusedText(xml))
}
@Test fun decodesXmlEntitiesInAttribute() {
val xml = """<hierarchy><node text="a&amp;b&lt;c&quot;d" focused="true"/></hierarchy>"""
assertEquals("a&b<c\"d", StubDriverBackend.parseFocusedText(xml))
}
@Test fun picksFirstFocusedNodeWhenMultiplePresent() {
val xml = """
<hierarchy>
<node text="first" focused="true"/>
<node text="second" focused="true"/>
</hierarchy>
""".trimIndent()
assertEquals("first", StubDriverBackend.parseFocusedText(xml))
}
}
@@ -0,0 +1,38 @@
package dev.sanderling.sidecar
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class InputTextTest {
@Test fun inputTextTypesAtCursorWithoutClearing() {
val commands = mutableListOf<List<String>>()
val backend = StubDriverBackend("android") { commands.add(it) }
backend.inputText("Emergency Fund")
assertEquals(listOf(listOf("shell", "input", "text", "Emergency%sFund")), commands)
}
@Test fun escapeForAdbInputTextSubstitutesSpaces() {
assertEquals("hello%sworld", StubDriverBackend.escapeForAdbInputText("hello world"))
}
@Test fun escapeForAdbInputTextEscapesShellMetacharacters() {
val escaped = StubDriverBackend.escapeForAdbInputText("a&b|c;d\$e`f")
assertEquals("a\\&b\\|c\\;d\\\$e\\`f", escaped)
}
@Test fun escapeForAdbInputTextEscapesQuotesAndBackslash() {
assertEquals("\\'", StubDriverBackend.escapeForAdbInputText("'"))
assertEquals("\\\"", StubDriverBackend.escapeForAdbInputText("\""))
assertEquals("\\\\", StubDriverBackend.escapeForAdbInputText("\\"))
}
@Test fun escapeForAdbInputTextLeavesSimpleTextAlone() {
assertEquals("12.34", StubDriverBackend.escapeForAdbInputText("12.34"))
assertEquals("Coffee", StubDriverBackend.escapeForAdbInputText("Coffee"))
assertTrue("-5" == StubDriverBackend.escapeForAdbInputText("-5"))
}
}
@@ -0,0 +1,135 @@
package dev.sanderling.sidecar
import dev.sanderling.driver.v1.DriverGrpc
import dev.sanderling.driver.v1.Empty
import io.grpc.ManagedChannel
import io.grpc.inprocess.InProcessChannelBuilder
import io.grpc.inprocess.InProcessServerBuilder
import io.grpc.testing.GrpcCleanupRule
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.locks.ReentrantLock
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SnapshotHandlerTest {
@get:Rule val grpcCleanup: GrpcCleanupRule = GrpcCleanupRule()
private fun newClient(backend: DriverBackend): DriverGrpc.DriverBlockingStub {
val serverName = InProcessServerBuilder.generateName()
val service = DriverService(platform = "android", backend = backend)
grpcCleanup.register(
InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start(),
)
val channel: ManagedChannel = grpcCleanup.register(
InProcessChannelBuilder.forName(serverName).directExecutor().build(),
)
return DriverGrpc.newBlockingStub(channel)
}
@Test fun snapshotReturnsBothHierarchyAndScreenshot() {
// The default snapshot() impl calls hierarchy() then screenshot() on
// the same backend instance; using interface delegation here would
// forward those calls to the delegate, not these overrides. Override
// snapshot() directly so the test exercises the wire path end-to-end.
val backend = object : DriverBackend by StubDriverBackend("android") {
override fun snapshot(): SnapshotSample =
SnapshotSample("{\"x\":1}", Triple(byteArrayOf(7, 8, 9), 1080, 2340))
}
val client = newClient(backend)
val response = client.snapshot(Empty.getDefaultInstance())
assertEquals("{\"x\":1}", response.hierarchy.json)
assertEquals(1080, response.screenshot.width)
assertEquals(2340, response.screenshot.height)
assertEquals(3, response.screenshot.png.size())
}
@Test fun defaultSnapshotPairsHierarchyAndScreenshotSequentially() {
// Covers the DriverBackend default impl: hierarchy() runs before
// screenshot() so a transitional re-fetch keeps the screenshot
// aligned with the final hierarchy snapshot the runner accepts.
val callOrder = mutableListOf<String>()
val backend = object : DriverBackend {
override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) {}
override fun terminate(bundleId: String) {}
override fun tap(x: Int, y: Int) {}
override fun tapSelector(selector: String) {}
override fun inputText(text: String) {}
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) {}
override fun pressKey(key: String) {}
override fun longPress(x: Int, y: Int) {}
override fun screenshot(): Triple<ByteArray, Int, Int> {
callOrder.add("screenshot")
return Triple(byteArrayOf(1), 0, 0)
}
override fun hierarchy(): String {
callOrder.add("hierarchy")
return "{}"
}
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> = emptyList()
override fun waitForIdle(durationMillis: Long) {}
override fun healthy(): Boolean = true
override fun metrics(bundleId: String): MetricsSample = MetricsSample(0.0, 0L, 0L)
}
backend.snapshot()
assertEquals(listOf("hierarchy", "screenshot"), callOrder)
}
@Test fun concurrentSnapshotsAreSerialized() {
// The recording backend asserts no overlap: a sleep-bearing snapshot()
// makes any race observable as inFlight > 1.
val inFlight = AtomicInteger(0)
val maxObserved = AtomicInteger(0)
val callCount = AtomicInteger(0)
val lock = ReentrantLock()
val recordingBackend = object : DriverBackend by StubDriverBackend("android") {
override fun snapshot(): SnapshotSample {
val now = inFlight.incrementAndGet()
try {
lock.lock()
try {
if (now > maxObserved.get()) maxObserved.set(now)
} finally {
lock.unlock()
}
Thread.sleep(50)
callCount.incrementAndGet()
return SnapshotSample("{}", Triple(ByteArray(0), 0, 0))
} finally {
inFlight.decrementAndGet()
}
}
}
// Use a real (multi-threaded) executor on the server side so the service
// is not artificially serialized by directExecutor.
val serverName = InProcessServerBuilder.generateName()
val service = DriverService(platform = "android", backend = recordingBackend)
grpcCleanup.register(
InProcessServerBuilder.forName(serverName).addService(service).build().start(),
)
val channel: ManagedChannel = grpcCleanup.register(
InProcessChannelBuilder.forName(serverName).build(),
)
val stub = DriverGrpc.newBlockingStub(channel)
val pool = Executors.newFixedThreadPool(4)
try {
val tasks = (1..4).map {
pool.submit { stub.snapshot(Empty.getDefaultInstance()) }
}
for (task in tasks) task.get(5, TimeUnit.SECONDS)
} finally {
pool.shutdownNow()
}
assertEquals(4, callCount.get())
assertTrue(
maxObserved.get() <= 1,
"snapshot calls overlapped (maxObserved=${maxObserved.get()}); the service lock did not serialize them",
)
}
}
@@ -0,0 +1,149 @@
package dev.sanderling.sidecar
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class StabilityPollTest {
@Test fun returnsAfterUninterruptedStableStreak() {
val start = System.currentTimeMillis()
pollUntilStable(3000L) { "stable" }
val elapsed = System.currentTimeMillis() - start
assertTrue(
elapsed >= MIN_STABLE_STREAK_MILLIS,
"must observe a stable streak of at least ${MIN_STABLE_STREAK_MILLIS}ms, elapsed=${elapsed}ms",
)
assertTrue(elapsed < 3000L, "should not run to cap when stable, elapsed=${elapsed}ms")
}
@Test fun streakResetsOnAnyChange() {
// A late transition that fires after the prior streak has already
// begun must reset the clock: the post-transition stable window has
// to start over and meet MIN_STABLE_STREAK_MILLIS from scratch.
var calls = 0
val start = System.currentTimeMillis()
// First 4 samples are "calm", then 1 transient change, then "stable"
// forever - the calm prefix is meaningless because of the transition.
pollUntilStable(3000L) {
calls++
when {
calls <= 4 -> "calm"
calls == 5 -> "transient"
else -> "stable"
}
}
val elapsed = System.currentTimeMillis() - start
assertTrue(
elapsed >= MIN_STABLE_STREAK_MILLIS,
"post-transition streak must reach ${MIN_STABLE_STREAK_MILLIS}ms, elapsed=${elapsed}ms",
)
assertTrue(calls >= 10, "expected enough samples to span calm + transient + stable streak, got $calls")
}
@Test fun transitionalNullsForceLoopToKeepWaiting() {
// Simulates a NavHost cross-fade where stabilitySnapshot returns null
// for several samples (multi-screen detected) before the destination
// screen settles. Streak must not start while null returns persist.
var calls = 0
val transitionalCount = 5
pollUntilStable(3000L) {
calls++
if (calls <= transitionalCount) null else "stable"
}
assertTrue(
calls >= transitionalCount + 2,
"must consume the null prefix before starting the streak, calls=$calls",
)
}
@Test fun stopsAtDeadlineWhenNeverStable() {
var calls = 0
val budget = 240L
val start = System.currentTimeMillis()
pollUntilStable(budget) {
calls++
"frame-$calls"
}
val elapsed = System.currentTimeMillis() - start
assertTrue(elapsed in budget..(budget + 200L), "expected to hit cap, elapsed=$elapsed")
}
@Test fun zeroBudgetReturnsImmediately() {
var calls = 0
pollUntilStable(0L) {
calls++
"x"
}
assertEquals(0, calls)
}
@Test fun structuralHashIgnoresBoundsAndIdenticalForSemanticallyEqualTrees() {
val a = """
{"attributes":{"resource-id":"LoginScreen","bounds":"[0,0,1080,2340]"},
"children":[
{"attributes":{"resource-id":"LoginEmail","bounds":"[10,10,1070,100]","text":"a@b"},"children":[]}
]}
""".trimIndent()
val b = """
{"attributes":{"resource-id":"LoginScreen","bounds":"[0,0,1080,2342]"},
"children":[
{"attributes":{"resource-id":"LoginEmail","bounds":"[10,11,1070,101]","text":"a@b"},"children":[]}
]}
""".trimIndent()
assertEquals(structuralHash(a), structuralHash(b), "bounds-only flicker must not change hash")
}
@Test fun structuralHashDiffersWhenContentChanges() {
val a = """{"attributes":{"resource-id":"LoginEmail","text":"a@b"},"children":[]}"""
val b = """{"attributes":{"resource-id":"LoginEmail","text":"c@d"},"children":[]}"""
assertTrue(structuralHash(a) != structuralHash(b), "text change must alter hash")
}
@Test fun stabilitySnapshotReturnsNullDuringNavHostCrossFade() {
val midTransition = """
{"attributes":{"resource-id":"root"},
"children":[
{"attributes":{"resource-id":"AddAccountScreen"},"children":[]},
{"attributes":{"resource-id":"HomeScreen"},"children":[
{"attributes":{"resource-id":"AccountCard","text":"a"},"children":[]}
]}
]}
""".trimIndent()
assertEquals(null, stabilitySnapshot(midTransition))
}
@Test fun stabilitySnapshotReturnsHashOnSingleScreen() {
val singleScreen = """
{"attributes":{"resource-id":"HomeScreen"},
"children":[
{"attributes":{"resource-id":"AccountCard","text":"a"},"children":[]}
]}
""".trimIndent()
val hash = stabilitySnapshot(singleScreen)
assertTrue(hash != null && hash.isNotBlank(), "single-screen tree must yield a hash, got $hash")
}
@Test fun stabilitySnapshotIgnoresNonRouteAttributeValues() {
// Random text content ending in "Screen" must not trip the detector;
// only route-level attribute keys (resource-id, testTag, identifier)
// are considered for the route-tag count.
val tree = """
{"attributes":{"resource-id":"HomeScreen"},
"children":[
{"attributes":{"text":"Welcome to MyScreen"},"children":[]}
]}
""".trimIndent()
assertTrue(stabilitySnapshot(tree) != null, "non-route attribute must not be counted as a screen")
}
@Test fun countRouteScreensCountsTestTagAndIdentifier() {
val tree = """
{"attributes":{"resource-id":"root"},
"children":[
{"attributes":{"testTag":"HomeScreen"},"children":[]},
{"attributes":{"accessibilityIdentifier":"LedgerScreen"},"children":[]}
]}
""".trimIndent()
assertEquals(2, countRouteScreens(tree))
}
}