mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
* 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.
1022 lines
33 KiB
Go
1022 lines
33 KiB
Go
package verifier
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"math/rand/v2"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/dop251/goja"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
|
"github.com/priyanshujain/sanderling/internal/ltl"
|
|
)
|
|
|
|
func newVerifier(t *testing.T, options ...Option) *Verifier {
|
|
t.Helper()
|
|
verifier, err := New(options...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return verifier
|
|
}
|
|
|
|
func mustLoad(t *testing.T, verifier *Verifier, source string) {
|
|
t.Helper()
|
|
if err := verifier.Load(source); err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
}
|
|
|
|
const helloSpec = `
|
|
const screen = __sanderling__.extract(state => state.snapshots.screen ?? "");
|
|
const balance = __sanderling__.extract(state => state.snapshots["ledger.balance"] ?? 0);
|
|
|
|
globalThis.screen = screen;
|
|
globalThis.balance = balance;
|
|
|
|
globalThis.properties = {
|
|
balanceNonNegative: __sanderling__.always(() => balance.current >= 0),
|
|
};
|
|
|
|
globalThis.actions = __sanderling__.actions(() => [
|
|
__sanderling__.tap({ on: "id:home_button" }),
|
|
]);
|
|
`
|
|
|
|
func TestLoad_ExposesRuntimeBindings(t *testing.T) {
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, helloSpec)
|
|
if len(verifier.extractors) != 2 {
|
|
t.Errorf("extractors registered: got %d, want 2", len(verifier.extractors))
|
|
}
|
|
if len(verifier.formulas) != 1 {
|
|
t.Errorf("formulas registered: got %d, want 1", len(verifier.formulas))
|
|
}
|
|
if _, ok := verifier.properties["balanceNonNegative"]; !ok {
|
|
t.Errorf("balanceNonNegative property missing: %+v", verifier.properties)
|
|
}
|
|
}
|
|
|
|
func TestPushSnapshot_UpdatesExtractorCurrentAndPrevious(t *testing.T) {
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, helloSpec)
|
|
|
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{
|
|
"screen": json.RawMessage(`"customer_ledger"`),
|
|
"ledger.balance": json.RawMessage(`1500`),
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
screenValue := verifier.runtime.GlobalObject().Get("screen").ToObject(verifier.runtime)
|
|
if screenValue.Get("current").String() != "customer_ledger" {
|
|
t.Errorf("screen.current wrong: %v", screenValue.Get("current"))
|
|
}
|
|
|
|
balanceValue := verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime)
|
|
if balanceValue.Get("current").ToInteger() != 1500 {
|
|
t.Errorf("balance.current wrong: %v", balanceValue.Get("current"))
|
|
}
|
|
|
|
// Push again: previous should mirror the prior current.
|
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": json.RawMessage(`2000`)}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
balanceValue = verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime)
|
|
if balanceValue.Get("previous").ToInteger() != 1500 {
|
|
t.Errorf("balance.previous wrong: %v", balanceValue.Get("previous"))
|
|
}
|
|
if balanceValue.Get("current").ToInteger() != 2000 {
|
|
t.Errorf("balance.current wrong: %v", balanceValue.Get("current"))
|
|
}
|
|
}
|
|
|
|
func TestEvaluateProperties_HoldsThenViolates(t *testing.T) {
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, helloSpec)
|
|
|
|
cases := []struct {
|
|
balance int
|
|
want ltl.Verdict
|
|
}{
|
|
{1500, ltl.VerdictHolds},
|
|
{0, ltl.VerdictHolds},
|
|
{-1, ltl.VerdictViolated},
|
|
{500, ltl.VerdictViolated}, // sticky
|
|
}
|
|
for index, testCase := range cases {
|
|
raw, _ := json.Marshal(testCase.balance)
|
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": raw}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
verdicts := verifier.EvaluateProperties()
|
|
if got := verdicts["balanceNonNegative"]; got != testCase.want {
|
|
t.Errorf("step %d (balance=%d): got %v, want %v", index, testCase.balance, got, testCase.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
|
|
|
action, err := verifier.NextAction()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action.Kind != ActionKindTap {
|
|
t.Errorf("kind: got %v, want Tap", action.Kind)
|
|
}
|
|
if action.On != "id:home_button" {
|
|
t.Errorf("selector: got %q, want id:home_button", action.On)
|
|
}
|
|
}
|
|
|
|
func TestNextAction_WeightedSelectsByWeight(t *testing.T) {
|
|
verifier := newVerifier(t, WithRand(rand.New(rand.NewPCG(42, 0))))
|
|
mustLoad(t, verifier, `
|
|
const tapHome = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:home" })]);
|
|
const tapAway = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:away" })]);
|
|
globalThis.actions = __sanderling__.weighted(
|
|
[1, tapHome],
|
|
[99, tapAway],
|
|
);
|
|
`)
|
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
|
|
|
awayCount := 0
|
|
homeCount := 0
|
|
for range 200 {
|
|
action, err := verifier.NextAction()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
switch action.On {
|
|
case "id:home":
|
|
homeCount++
|
|
case "id:away":
|
|
awayCount++
|
|
}
|
|
}
|
|
if awayCount <= homeCount {
|
|
t.Errorf("expected away-skewed distribution, got home=%d away=%d", homeCount, awayCount)
|
|
}
|
|
}
|
|
|
|
func TestNextAction_EmptyGeneratorReturnsErrNoAction(t *testing.T) {
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, `
|
|
globalThis.actions = __sanderling__.actions(() => []);
|
|
`)
|
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
|
|
|
_, err := verifier.NextAction()
|
|
if !errors.Is(err, ErrNoAction) {
|
|
t.Errorf("expected ErrNoAction, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNextAction_SetupTakesPrecedenceWhenYielding(t *testing.T) {
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, `
|
|
globalThis.setup = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:setup" })]);
|
|
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]);
|
|
`)
|
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
|
|
|
action, err := verifier.NextAction()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action.On != "id:setup" {
|
|
t.Errorf("setup precedence: got %q, want id:setup", action.On)
|
|
}
|
|
}
|
|
|
|
func TestNextAction_FallsThroughToActionsWhenSetupEmpty(t *testing.T) {
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, `
|
|
globalThis.setup = __sanderling__.actions(() => []);
|
|
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]);
|
|
`)
|
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
|
|
|
action, err := verifier.NextAction()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action.On != "id:main" {
|
|
t.Errorf("fallthrough: got %q, want id:main", action.On)
|
|
}
|
|
}
|
|
|
|
func TestNextAction_SetupReengagesAfterRegression(t *testing.T) {
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, `
|
|
globalThis.loggedIn = __sanderling__.extract(state => state.snapshots["loggedIn"] === true);
|
|
globalThis.setup = __sanderling__.actions(() => {
|
|
if (loggedIn.current) return [];
|
|
return [__sanderling__.tap({ on: "id:login" })];
|
|
});
|
|
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]);
|
|
`)
|
|
|
|
push := func(loggedIn bool) {
|
|
raw := json.RawMessage(`false`)
|
|
if loggedIn {
|
|
raw = json.RawMessage(`true`)
|
|
}
|
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"loggedIn": raw}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
push(false)
|
|
action, err := verifier.NextAction()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action.On != "id:login" {
|
|
t.Fatalf("step 1 (logged out): got %q, want id:login", action.On)
|
|
}
|
|
|
|
push(true)
|
|
action, err = verifier.NextAction()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action.On != "id:main" {
|
|
t.Fatalf("step 2 (logged in): got %q, want id:main", action.On)
|
|
}
|
|
|
|
push(false)
|
|
action, err = verifier.NextAction()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action.On != "id:login" {
|
|
t.Fatalf("step 3 (regressed): got %q, want id:login", action.On)
|
|
}
|
|
}
|
|
|
|
func TestNextAction_NoSetupRegistered(t *testing.T) {
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, `
|
|
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]);
|
|
`)
|
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
|
|
|
if verifier.setupGenerator != nil {
|
|
t.Errorf("setupGenerator should be nil when spec does not export setup")
|
|
}
|
|
action, err := verifier.NextAction()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action.On != "id:main" {
|
|
t.Errorf("got %q, want id:main", action.On)
|
|
}
|
|
}
|
|
|
|
// 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, `
|
|
globalThis.actions = __sanderling__.actions(() => [
|
|
__sanderling__.inputText({ into: "id:phone", text: "+919876543210" }),
|
|
]);
|
|
`)
|
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
|
|
|
action, err := verifier.NextAction()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action.Kind != ActionKindInputText {
|
|
t.Errorf("kind: %v", action.Kind)
|
|
}
|
|
if action.On != "id:phone" || action.Text != "+919876543210" {
|
|
t.Errorf("payload wrong: %+v", action)
|
|
}
|
|
}
|
|
|
|
// 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, `
|
|
globalThis.captured = __sanderling__.extract(state => state.snapshots["k"]);
|
|
`)
|
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"k": json.RawMessage(`"hello"`)}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
value := verifier.runtime.GlobalObject().Get("captured").ToObject(verifier.runtime).Get("current")
|
|
if value.String() != "hello" {
|
|
t.Errorf("snapshot value not propagated: %v", value)
|
|
}
|
|
}
|
|
|
|
func TestLoad_PropagatesSyntaxError(t *testing.T) {
|
|
verifier := newVerifier(t)
|
|
err := verifier.Load(`const x = ;`)
|
|
if err == nil || !strings.Contains(err.Error(), "run spec") {
|
|
t.Errorf("expected run-spec error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateProperties_ThrowingPredicateDoesNotPanic(t *testing.T) {
|
|
const spec = `
|
|
globalThis.properties = {
|
|
broken: __sanderling__.always(() => { throw new Error("bad predicate"); }),
|
|
};
|
|
`
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, spec)
|
|
|
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
verdicts := verifier.EvaluateProperties()
|
|
if got := verdicts["broken"]; got != ltl.VerdictViolated {
|
|
t.Errorf("verdict: got %v, want %v", got, ltl.VerdictViolated)
|
|
}
|
|
|
|
predicateErr := verifier.PredicateError("broken")
|
|
if predicateErr == nil {
|
|
t.Fatal("PredicateError: got nil, want non-nil")
|
|
}
|
|
if !strings.Contains(predicateErr.Error(), "bad predicate") {
|
|
t.Errorf("PredicateError message: got %q, want to contain %q", predicateErr.Error(), "bad predicate")
|
|
}
|
|
}
|
|
|
|
func TestLoad_AcceptsSpecWithoutPropertiesOrActions(t *testing.T) {
|
|
verifier := newVerifier(t)
|
|
if err := verifier.Load(`const noop = 1;`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := verifier.EvaluateProperties(); len(got) != 0 {
|
|
t.Errorf("no properties expected, got %v", got)
|
|
}
|
|
if _, err := verifier.NextAction(); !errors.Is(err, ErrNoAction) {
|
|
t.Errorf("expected ErrNoAction, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSelectorPath_ScopedDescent ensures the JS-side `find([{...}, {...}])`
|
|
// shape walks each segment scoped under the previous match.
|
|
func TestSelectorPath_ScopedDescent(t *testing.T) {
|
|
const treeJSON = `{
|
|
"attributes": {"resource-id": "rootView", "bounds": "[0,0,1080,2340]"},
|
|
"children": [
|
|
{
|
|
"attributes": {"testTag": "HomeScreen", "bounds": "[0,0,540,2340]"},
|
|
"children": [
|
|
{
|
|
"attributes": {"testTag": "AccountCard", "bounds": "[0,0,540,200]"},
|
|
"children": [
|
|
{"attributes": {"testTag": "AccountName", "text": "Checking", "bounds": "[10,10,200,40]"}, "children": []}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"attributes": {"testTag": "LedgerScreen", "bounds": "[540,0,1080,2340]"},
|
|
"children": [
|
|
{"attributes": {"testTag": "AccountName", "text": "Other", "bounds": "[600,10,800,40]"}, "children": []}
|
|
]
|
|
}
|
|
]
|
|
}`
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, `
|
|
globalThis.found = __sanderling__.extract(state =>
|
|
state.ax.find([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }, { testTag: "AccountName" }])
|
|
);
|
|
globalThis.foundUnreachable = __sanderling__.extract(state =>
|
|
state.ax.find([{ testTag: "LedgerScreen" }, { testTag: "AccountCard" }])
|
|
);
|
|
globalThis.allInHome = __sanderling__.extract(state =>
|
|
state.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountName" }])
|
|
);
|
|
`)
|
|
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)
|
|
}
|
|
found := verifier.runtime.GlobalObject().Get("found").ToObject(verifier.runtime).Get("current")
|
|
if found == nil || goja.IsUndefined(found) {
|
|
t.Fatal("expected path lookup to find AccountName under HomeScreen > AccountCard")
|
|
}
|
|
text := found.ToObject(verifier.runtime).Get("text")
|
|
if text.String() != "Checking" {
|
|
t.Fatalf("text = %q, want Checking", text.String())
|
|
}
|
|
unreachable := verifier.runtime.GlobalObject().Get("foundUnreachable").ToObject(verifier.runtime).Get("current")
|
|
if !goja.IsUndefined(unreachable) {
|
|
t.Fatalf("AccountCard is not under LedgerScreen, expected undefined, got %v", unreachable)
|
|
}
|
|
allInHome := verifier.runtime.GlobalObject().Get("allInHome").ToObject(verifier.runtime).Get("current")
|
|
allObject := allInHome.ToObject(verifier.runtime)
|
|
length := allObject.Get("length").ToInteger()
|
|
if length != 1 {
|
|
t.Fatalf("findAll path length = %d, want 1 (Checking only, not Other in LedgerScreen)", length)
|
|
}
|
|
}
|
|
|
|
// TestSelector_BooleanValue ensures a native JS boolean in the selector
|
|
// (e.g. `find({ focused: true })`) matches the "true"/"false" string
|
|
// serialization the hierarchy uses for boolean state attributes.
|
|
func TestSelector_BooleanValue(t *testing.T) {
|
|
const treeJSON = `{
|
|
"attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"},
|
|
"children": [
|
|
{"attributes": {"testTag": "EmailField", "bounds": "[0,0,100,40]"}, "focused": true, "children": []},
|
|
{"attributes": {"testTag": "PasswordField", "bounds": "[0,40,100,80]"}, "focused": false, "children": []}
|
|
]
|
|
}`
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, `
|
|
globalThis.focusedTag = __sanderling__.extract(state =>
|
|
state.ax.find({ focused: true })?.attrs?.testTag ?? null
|
|
);
|
|
`)
|
|
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)
|
|
}
|
|
got := verifier.runtime.GlobalObject().Get("focusedTag").ToObject(verifier.runtime).Get("current")
|
|
if got == nil || got.String() != "EmailField" {
|
|
t.Fatalf("expected EmailField, got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestFrom_SeededReplayIsDeterministic guarantees `from()` over a per-step
|
|
// dynamic array picks the same element under the same seed across runs. The
|
|
// folio spec relies on this to replace Math.random() in account-card taps.
|
|
func TestFrom_SeededReplayIsDeterministic(t *testing.T) {
|
|
pickedSequence := func(seed uint64) []string {
|
|
verifier := newVerifier(t, WithRand(rand.New(rand.NewPCG(seed, 0))))
|
|
mustLoad(t, verifier, `
|
|
globalThis.actions = __sanderling__.actions(() => {
|
|
const cards = ["card_a", "card_b", "card_c", "card_d"];
|
|
return [__sanderling__.tap({ on: __sanderling__.from(cards).generate() })];
|
|
});
|
|
`)
|
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
|
var picks []string
|
|
for range 20 {
|
|
action, err := verifier.NextAction()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
picks = append(picks, action.On)
|
|
}
|
|
return picks
|
|
}
|
|
first := pickedSequence(1234)
|
|
second := pickedSequence(1234)
|
|
for i := range first {
|
|
if first[i] != second[i] {
|
|
t.Fatalf("step %d: %q != %q (replay not deterministic)", i, first[i], second[i])
|
|
}
|
|
}
|
|
other := pickedSequence(5678)
|
|
identical := true
|
|
for i := range first {
|
|
if first[i] != other[i] {
|
|
identical = false
|
|
break
|
|
}
|
|
}
|
|
if identical {
|
|
t.Fatal("expected different seeds to produce different pick sequences")
|
|
}
|
|
}
|
|
|
|
// PredicateError must reflect the most recent step's predicate result, not a
|
|
// latched first-step error. The runner logs PredicateError once per step; if it
|
|
// stays pinned to step 1 forever, downstream debugging looks frozen even though
|
|
// the underlying state is changing.
|
|
func TestPredicateError_ReflectsCurrentStepNotFirstStep(t *testing.T) {
|
|
const spec = `
|
|
globalThis.counter = __sanderling__.extract(state => state.snapshots["count"]);
|
|
globalThis.properties = {
|
|
reportsCounter: __sanderling__.always(() => { throw new Error("count=" + counter.current); }),
|
|
};
|
|
`
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, spec)
|
|
|
|
for step := 1; step <= 3; step++ {
|
|
raw := json.RawMessage([]byte{'"', byte('0' + step), '"'})
|
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"count": raw}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = verifier.EvaluateProperties()
|
|
|
|
got := verifier.PredicateError("reportsCounter")
|
|
if got == nil {
|
|
t.Fatalf("step %d: PredicateError = nil, want non-nil", step)
|
|
}
|
|
want := "count=" + string(rune('0'+step))
|
|
if !strings.Contains(got.Error(), want) {
|
|
t.Errorf("step %d: PredicateError = %q, want to contain %q", step, got.Error(), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOverrideExtractorValues_PreservesPrevious(t *testing.T) {
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, helloSpec)
|
|
|
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": json.RawMessage(`100`)}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := verifier.OverrideExtractorValues(map[int]json.RawMessage{1: json.RawMessage(`777`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
balance := verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime)
|
|
if balance.Get("current").ToInteger() != 777 {
|
|
t.Errorf("override didn't take: current=%v", balance.Get("current"))
|
|
}
|
|
|
|
// Next push: previous mirrors the *override*, not the snapshot value.
|
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": json.RawMessage(`200`)}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
balance = verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime)
|
|
if balance.Get("previous").ToInteger() != 777 {
|
|
t.Errorf("previous should reflect override, got %v", balance.Get("previous"))
|
|
}
|
|
}
|
|
|
|
func TestOverrideExtractorValues_NilIsNoop(t *testing.T) {
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, helloSpec)
|
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": json.RawMessage(`42`)}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := verifier.OverrideExtractorValues(nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := verifier.OverrideExtractorValues(map[int]json.RawMessage{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
balance := verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime)
|
|
if balance.Get("current").ToInteger() != 42 {
|
|
t.Errorf("expected snapshot-driven current to remain 42, got %v", balance.Get("current"))
|
|
}
|
|
}
|
|
|
|
func TestOverrideExtractorValues_UnknownIndexSkipped(t *testing.T) {
|
|
verifier := newVerifier(t)
|
|
mustLoad(t, verifier, helloSpec)
|
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": json.RawMessage(`42`)}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
skipped, err := verifier.OverrideExtractorValues(map[int]json.RawMessage{
|
|
1: json.RawMessage(`777`),
|
|
99: json.RawMessage(`1`),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if skipped != 1 {
|
|
t.Errorf("expected 1 skipped entry, got %d", skipped)
|
|
}
|
|
balance := verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime)
|
|
if balance.Get("current").ToInteger() != 777 {
|
|
t.Errorf("valid override should still apply alongside skipped one, got current=%v", balance.Get("current"))
|
|
}
|
|
}
|
|
|
|
const objectExtractorSpec = `
|
|
const card = __sanderling__.extract(state => ({attrs: {testTag: "default"}, balance: 0}));
|
|
globalThis.card = card;
|
|
|
|
globalThis.properties = {
|
|
hasTestTag: __sanderling__.always(() => typeof card.current.attrs.testTag === "string"),
|
|
};
|
|
|
|
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)
|
|
|
|
if err := verifier.PushSnapshot(SnapshotInput{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
override := json.RawMessage(`{"attrs": {"testTag": "account-card"}, "balance": 12345}`)
|
|
skipped, err := verifier.OverrideExtractorValues(map[int]json.RawMessage{0: override})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if skipped != 0 {
|
|
t.Errorf("unexpected skipped count: %d", skipped)
|
|
}
|
|
|
|
card := verifier.runtime.GlobalObject().Get("card").ToObject(verifier.runtime)
|
|
current := card.Get("current").ToObject(verifier.runtime)
|
|
attrs := current.Get("attrs").ToObject(verifier.runtime)
|
|
if got := attrs.Get("testTag").String(); got != "account-card" {
|
|
t.Errorf("nested override missing: card.current.attrs.testTag = %q, want %q", got, "account-card")
|
|
}
|
|
if got := current.Get("balance").ToInteger(); got != 12345 {
|
|
t.Errorf("scalar field missing: card.current.balance = %d, want 12345", got)
|
|
}
|
|
}
|