mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
* docs: add the apache 2.0 license text
package.json has declared Apache-2.0 since the first release and .goreleaser.yaml
globs LICENSE* into the archives, so that glob has been matching nothing. npm
only picks up a license from the package directory, hence the copy under
pkg/spec.
* fix(sidecar): close the soft keyboard after typing on android
* test(sidecar): pin the guarded ime dismissal
* fix(sidecar): treat a failed ime probe as no keyboard open
* ci(folio): let the ios leg clear state for itself
* ci(folio): drop the stale frontboard note from the ios job
* docs(ci): record what the ios calibration assumes and where it was measured
* fix(ios): replace the session when a launch blows its bound
a launch the simulator refuses is never reported: xctest records it as a test
failure the runner cannot see, then holds the session's main thread for about
four minutes on a diagnostic chain. so the only signal is the expired bound,
and every later call queues behind the same wedge. restart the session once and
launch again, bounded so the launch path stays inside testrun's backstop.
* test(ios): cover the session replacement a wedged launch needs
* fix(ios): share one deadline across the restart and the second launch
the recovery a blown bound triggers now costs at most launchRecoveryTimeout
whatever it spends it on, so the launch path tops out at 150s and testrun's
three minute backstop stays a backstop.
* test(ios): the restart a blown launch triggers has to be bounded
* docs(ci): the ios leg does not convict on the runner, and a seed cannot fix it
seed 28 reproduced its walk on macos-15 and reached the bug at the step it
convicts at locally. it still could not be judged: the run did not return
home between step 19 and step 136, so the counting invariant saw a rise of
15 against a window of 37 submits.
* docs(sidecar): record why the stale ime flag stays out of reach
* test(folio): add commonTest source sets to core and shared
* fix(folio): reject amounts parseCents cannot represent
* fix(folio): cap a transaction at one million dollars
* test(spec): give the fake dom a real tree and a walking querySelectorAll
* style(sidecar): make ktlint clean, formatting only
ktlint -F over every kotlin file except DriverBackend.kt, then hand
fixes where the reflow read worse and for the long lines ktlint cannot
break. No behaviour changes.
DriverBackend.kt is left untouched to avoid a conflict with concurrent
work; its three over-long lines still fail fmt-kotlin.
* fix(spec): deepQueryAll returns matches in document order
* ci(folio): say what the android gate found, not why
The gate proves only that AddTransactionScreen is absent from the trace.
Claiming the run never got past login was an inference it cannot make: the
run that produced it had logged in and was stuck on the new account screen.
Name the routes the trace does record instead.
* test(runner): a relaunch must not convict the submit counting property
* test(browser): compare ax.find across both hosts on one page
* test(spec): name the shadow match in the grammar both hosts parse
* ci: move every action off the node20 runtime
checkout v4->v7, setup-go v5->v7, setup-node v4->v7, setup-java v4->v5,
upload-artifact v4->v7, cache v4->v6, upload-pages-artifact v3->v5,
deploy-pages v4->v5, setup-chrome v1->v2, setup-android v3->v4,
goreleaser-action v6->v7. setup-bun and android-emulator-runner are
already node24; buf-setup-action stays on its deliberate SHA pin.
setup-chrome v2 resolves stable from Chrome for Testing rather than the
official installer, so the ci.yml comment about the action's default no
longer held.
* feat(verifier): report a relaunch on state.lastAction
* test(verifier): pin the relaunch field on both hosts
* fix(runner): keep the action the app was relaunched after
* test: pin that a nested undefined does not survive the wire
* fix(folio): uninstall before installing in just ios
folio's signed-in session lives in the data container, which an install
over the top keeps, so a local run started right after just ios opened on
the previous run's Home screen and diverged at step 1. On CI's fresh
simulator the uninstall is a no-op, so the ios leg is unchanged.
* style(sidecar): bring the last three lines under the line limit
* fix(ios): the runner must not answer ok for a launch that failed
XCTest records a refused launch as a test issue that never throws, so the
companion returned ok for an app that never started. Check the state the
app actually reached and report the refusal instead.
* test(ios): a refusal the runner names costs no session restart
The session restart is for a launch that never answers. A launch that
reports the app's state has already said what a fresh session would.
* fix(folio): attribute a created account by its whole key, not a suffix
createdAccountHasNonZeroBalance matched the created card with endsWith, so
an older account whose name ends with the typed one ("Emergency Fund" for a
typed "Fund") was judged instead whenever the new card was clipped out of
the reading. Build both keys the card can carry, the plain name and web's
initials + name, and compare them whole.
* fix(hierarchy): object selectors resolve by the same rule as string ones
* test(verifier): both ax.find selector forms resolve the same element
* test(browser): the cross-host fixture uses the object selector form
* fix(replay-ui): wrap the tab strip so its last tabs stay clickable
* test(replay-ui): drive the fuzzer onto a violating step with a panel
* feat(folio): judge a submit against the account's own balance
The counting invariant can only close its window on Home, and the iOS run
in #78 went 117 steps between two Home readings: 37 submits against a rise
of 15 transactions is no evidence about the double tap sitting inside it.
The ledger and the add-transaction screen both show the account's own
balance, and an accepted submit pops back to the ledger, so a window
bounded by those readings holds one action.
The bound is an upper one: a balance that has not moved is a commit still
in flight, a rejected submit or a tap that never landed, and none of those
is a violation. Moving by more than the one submit in the window typed is.
* test(runner): an overlay dismissal must not convict the counting property
* docs(runner): point the guard comment at the renamed test
* docs(replay-ui): name the viewport the tab overflow was measured at
* feat(folio): close the submit window on the account's own screens
submitCommitsOneTransactionPerAction now states its rule over two windows:
the Home counts it already compared, and the account balance the ledger and
the add-transaction screen redraw on nearly every frame of the transaction
flow. Same rule, and the second window is usually one action wide.
* fix(sidecar): reach the adb server the environment names
buildDadb hardcoded localhost:5037, so a serial-addressed device always
resolved through this machine's adb server and ADB_SERVER_SOCKET was
ignored. Read the endpoint the way the adb CLI does instead.
Fixes #79
* test(sidecar): pin the adb server endpoint parsing
* fix(sidecar): close a keyboard standing in the snapshot
A tap on a text field raises the keyboard and nothing closed it, so the
tree the picker chooses from was missing every app node underneath it,
the submit control included. Close it before the read rather than after
the tap: the picker only ever sees snapshots, and the keyboard is still
on its way up when the tap returns.
Fixes #78
* test(sidecar): pin the tree-guarded keyboard dismissal
* chore(make): a target that runs folio's unit tests
* chore(folio): a just recipe for the unit tests
* ci: run folio's unit tests on every pr
* ci: switch to jdk 21 only for the folio step
* docs(sidecar): put the measured read cost in the dismissal bound
* fix(folio): decline the two demanding properties across a relaunch
The runner now keeps lastAction and marks it relaunched: true where it used
to report nothing at all, so the two properties that demand an effect judge
a step whose process may have died before the write landed.
submitChangesBalanceByTypedAmount and createdAccountHasNonZeroBalance both
decline there. The counting bound does not: a relaunch cannot manufacture a
transaction, and the submit is counted, so declining would throw away the
detection the runner fix restored.
* docs(folio): say why the merged card key cannot be made injective
Folio rejects a duplicate account name, so the twin the drop rule guards
against is two names the web key cannot tell apart, not two accounts
sharing a name. State what closing the rest would cost and what the tree
would have to carry to close it properly.
* test(folio): pin that two accounts can render the same card text
The proof behind the comment: "Travel1" holding 25 transactions and
"Travel12" holding 5 merge to the same string, so no identity key read off
a web card can tell them apart.
* fix(sidecar): bound the diagnostic adb reads
adbOutput and readLogcat read to EOF and then waited with no timeout, so
a wedged adb held the step for as long as it liked; one stall over a
remote adb server measured ~100s. The bound has to sit on the read, not
on waitFor: a wedged adb never reaches EOF, so a bounded waitFor after
the read is a line that never runs.
* test(sidecar): pin the bound on a wedged adb read
* fix(sidecar): an unreadable animation count is not idle
Defaulting the count to zero made a dumpsys that said nothing mean
nothing is animating, so a degraded link broke out of the settle early
and handed the runner a frame caught mid-animation. Unknown now waits,
inside the deadline waitForIdle already holds.
* test(sidecar): unknown animation state must not read as idle
* fix(folio): stop spending the submit budget on taps the app refused
The window is an upper bound on the transactions an interval could hold, and
a bound inflated by taps that commit nothing is a bound the app can never
exceed: #78 read a rise of 15 transactions against 37 submits. TxnSubmit is
clickable(enabled = amount.isNotBlank()) and parseCents refuses anything its
regex misses, so a tap whose landing frame shows a refused amount cannot have
committed. Over four recorded android runs that is 19, 11, 25 and 25 of 35,
26, 42 and 42 submit taps.
A relaunch is excepted: a fresh process draws an empty field whatever was
submitted.
* feat(folio): read the amount field into every submit window
Each of the three windows asks whether the tap could have committed, off the
field as the landing frame shows it.
* fix(sidecar): a foreground read that fails degrades the typing guard
An unreadable dumpsys passed a null owner to typeChunks, which switches
the mid-type focus guard off outright and lets the rest of the string
spray into whatever holds the foreground. Fall back to the launched
bundle instead: the guard stays armed, typing still happens, and the
degradation is said out loud rather than assumed away.
* test(sidecar): pin the degraded typing guard both ways
* test(runner): answer Snapshot and Hierarchy off one tree in the fakes
* feat(runner): skip a step whose tree changed between two reads
* test(runner): cover the reread's cost to the existing snapshot rules
* fix(ios): clear app state before the automation session attaches
New performs the clear-state reset, so the uninstall and reinstall no
longer land underneath a live XCTest session that is already bound to
the app. Launch refuses a clear-state request the driver was not built
for rather than reinstalling under its own session.
* fix(ios): the device path clears before its runner session too
* fix(testrun): thread clear-data into the ios drivers
* test(runner): a skipped step must not swallow the action before it
* fix(runner): hold the action back on a step nothing verified
* refactor(runner): drop the empty branch from the hold path
* docs(runner): describe both modes of the composing test driver
* fix(sidecar): erase a field by selecting it, not one delete per character
maestro's eraseText sends one delete per character through its
instrumentation, measured 29.6 ms/char on the API 34 emulator. The
4096-character string the corpus types cost ~121s to clear, a fifth of a
20 minute run spent on one step, and it recurred every time that field
was typed into again.
Select the content and delete the selection instead: two key events at
any length, measured 0.15s to 1.16s for 4096 characters across API 34,
35 and 36. The result is read back off the tree, and a field that is not
empty, or that the tree cannot report on, is finished off per character
in batches rather than assumed clear.
Fixes #80
* test(sidecar): pin the constant-cost erase and its residue check
* fix(sidecar): find the erased field by class, past the keyboard's own focus
The check that decides whether the select-all worked looked for an
"editable" attribute maestro's tree does not carry, so it answered
"cannot tell" every time and every erase paid the per-character
fallback. Worse, an open keyboard puts a second focused node in the
tree, one of the IME's own keys, carrying no text: taking the first
focused node would read a field still holding 4096 characters as empty,
which is the one answer that stops the erase early.
Match the text field by class instead. Measured against the real
backend, 4096 characters now clear in 385ms on API 34, 409ms on API 35
and 870ms on API 36, verified empty, where the fallback took ~4s.
* test(sidecar): use the tree the device really returns
* docs(ci): the android step number describes a local emulator, not ci
the leg disables animations and the number was measured with them on. the
first real dispatch carries 4 transitional steps over 200, so the cross-fade
wait does still fire in ci, just far less often.
* fix(android): say what the sdk lookup checked, not just to set ANDROID_HOME
* fix(doctor): resolve adb and emulator the way a run does
* docs(cli): the android doctor checks are not path-only
* fix(testrun): preflight resolves adb through the sdk, not just PATH
* docs(skills): add a spec review skill and the skills index
* fix(testrun): report a sidecar that dies at startup as the exit it was
* test(testrun): cover the sidecar shutdown path after an early exit
* docs(skills): add a property patterns catalogue skill
* fix(folio): bound the total-balance move instead of demanding it exactly
The write finishes before AddTransactionViewModel navigates, but nothing
establishes that Home's total has re-rendered before the frame is read, and
an equality convicts a healthy app for a total one frame behind. A delta of
zero is exactly the shape nine of the eleven measured android false
convictions had. 2x still exceeds x, so all four recorded convictions
survive, checked against the traces.
The trade is real: a balance that moves by LESS than the amount typed is no
longer judged anywhere in this spec.
* docs(folio): say what property 2 demands now that it is a bound
* docs(skills): add a spec authoring skill
covers hooks, extractors, selectors, properties, actions and the order to write them in, with a complete sample spec that typechecks against the real export surface.
* docs(skills): ground the property patterns catalogue in the merged specs
* docs(skills): name the selector keys that still substring match
* docs(skills): add a setup skill for adopting sanderling
* docs(skills): add a run triage skill
* docs(skills): point the setup skill at its siblings
* docs(manual): correct the flags the cli reference gets wrong
--launcher-activity does not exist in cmd/sanderling/main.go. --device,
--android-app-path and --arm do and were undocumented. runs.md still listed
--max-steps and --exit-on-violation as unshipped, and described --clear-data
as opt-in when the default is already true, contradicting itself ten lines on.
* test(folio): pin that a commit stays in the window until Home reads it
The interaction that keeps a stale Home card list from ever banking counts
the budget has already forgotten: a submit lands on the ledger, so the
reading that resets the window is a whole action later and the submit is
still in it. Characterization, not a regression: no code changed and it
cannot go red first.
* docs(folio): record why a banked card reading can be trusted as current
The freshness rule rests on the app popping one entry back to the ledger,
not on anything the frame carries, so the assumption and the measurements
behind it belong next to it.
* refactor(folio): name the balance property for the bound it asserts
it stopped being an equality and became |delta| <= typed, so the old name
demanded more than the property does. renamed with the ci gate's
GATED_PROPERTIES in the same commit so the gate never sees a name it does
not know.
* fix(android): a refused uninstall must not pass for clear-state
adb uninstall answers Failure [DELETE_FAILED_INTERNAL_ERROR] both when the package was never installed and when it refuses to remove one, so the failure text cannot say which happened and the old code installed over the top either way, keeping the data clear-state was asked to drop. Ask pm path instead, and fall back to pm clear when the app is still there.
* fix(ios): a failed simctl uninstall must fail the reinstall
simctl install over an installed app carries its data container across, so discarding the uninstall error reported a clear-state that never happened. Uninstalling an app that is not installed exits 0 on a booted simulator, so every failure here is a real one.
* fix(ios): a failed devicectl uninstall must fail the reinstall
same hole as the simulator path: devicectl install over an app keeps its data, and the discarded uninstall error hid it. Uninstalling a bundle id that is not installed exits 0 with 'App uninstalled.' on a paired iPhone, so a failure here is always real.
* docs(android): say why the uninstall text cannot be read
* test(android): name the uninstall failure for what it says, not why
* docs(ci): the ios leg convicts on the runner now, and why it did not before
* docs(ci): the cross-fade wait does not fire on ci, say so
* fix(runner): a bounded hold puts the swallow back one step later
the hold carries one action; letting the runner act again while the verifier
is still skipped overwrites it, so the carried action reaches no spec. hold
for as long as the verifier is skipped, and settle on a held step so the
reread pair is not tighter than the window the detector was measured over.
* test(runner): pin what the two reads are compared on
structuralShape excluding text and bounds is the decision separating this
feature from a run that verifies nothing, and only prose held it. adding
either field back now turns a case red.
* fix(ci): close shell injection into the npm publish job
A refname is attacker-controlled and git permits backtick, $, (, ; and |
in it. Three sites substituted it into a run: block, and NODE_AUTH_TOKEN
sat at job level, so a pushed tag ran arbitrary commands with the publish
credential in reach.
The tag now goes through env:, is validated against an anchored version
pattern before anything consumes it, and reaches the other jobs as a job
output. The token is scoped to the publish step. release-npm declares
contents: read instead of inheriting the repo default.
* fix(ios): recognise every shape a blown launch bound arrives in
The runner transport reports a blown budget two ways, its own comment says
so: the context's error once cancellation has landed, and the connection's
i/o timeout when the deadline armed from that context fires first. The
legacy transport reports it as a gRPC status. errors.Is against
context.DeadlineExceeded only matches the first, so the session restart
never fired for the other two and a wedged session stayed wedged.
* test(ios): drive the launch recovery with what the transports return
The wedged-session fake answered with ctx.Err() raw, which is the one
shape the guard already matched. The recovery now runs against the error
each transport really produces for the same expiry, taken from a runner
and a legacy companion that never answer.
* test(runner): pin both guard writes to what the spec reads
deleting lastAction.Relaunched or lastAction.Applied left the whole suite
green, so the only producer of the two fields every spec-side guard reads
had nothing holding it. both now assert the value out of the trace.
* fix(testrun): a run that judged nothing is not a green run
every step skipped means no property ever evaluated, so no violations is the
absence of a verdict rather than a clean one. the hold makes that reachable
now, so the run says it instead of exiting 0.
* fix(ios): stop the app before clearing its state
Launch terminated and then cleared; the clear moved to construction and
left nothing stopping the app first. The container wipe deletes files a
live app still holds open, and the CI ios leg passes no app path so the
wipe is the path it takes. simctl stops it, since the clear now runs
before any automation session exists. On a device the uninstall that is
its only clear takes the running app with it.
* test(ios): pin the stop that has to precede a clear
The ordering probe now records the stop, and a scripted xcrun holds what
reaches the tool: terminate before get_app_container, with the previous
run's files gone after. A simctl terminate that finds nothing to stop
still leaves the clear a success.
* docs(spec): an unbounded eventually is violated at run end
* docs(skills): an unreached eventually convicts at run end
* docs(skills): noUncaughtExceptions only fires on web
* fix(ios): a device clear-state that cannot happen must fail
--clear-data on a physical device with no --ios-app-path warned and then
ran anyway, so the run started on the previous run's data while the flag
said it started clean. There is no data-container wipe on a device, so
there is nothing to fall back to.
* test(ios): a device clear-state without an app path ends the run
* docs(skills): the stock properties each cover one platform
* docs(ci): the balance property demands a bound, not an equality
* fix(ios): the clear-state guard checks the bundle that was cleared
A bool only said that something was cleared, so Launch(ctx, otherBundle,
clearState=true) passed the guard and reported a reset that had reached a
different app. Record what was cleared and compare against the bundle
being launched.
* test(ios): a clear-state launch for an uncleared bundle is refused
* docs(manual): the flagship property is a bound, and say what that costs
* fix(ios): one address picker for every bring-up
bringUpRunner reads the picker from a field, and NewDevice only ever set
the device one, so a device driver that reached bringUpRunner would call
nil. The two fields held the same function; keeping one leaves no path
that can be wired without it.
* test(ios): a device driver can bring a runner up
* docs(skills): both shipped balance forms are bounds now
* docs(skills): name the balance predicate that still exists
* docs(skills): quote the doctor the binary actually prints
* docs(skills): screen= is the chrome driver's url, web only
* docs(skills): substring selector matching is native only
* docs(skills): web selectors are exact, native ones are substrings
* fix(ci): a run that wrote no trace is not evidence about folio
run_dir is empty when the run produced no output directory, and the
fallback made trace ./trace.jsonl. A stray trace in the working directory
was then read as this run's, so a run that wrote nothing reported 'found
the submit bug' and exited 0, defeating the missing-trace check below it.
* fix(ci): fail folio when a gated property is not in the spec
Nothing tied GATED_PROPERTIES to the spec it gates. Renaming a property
left the classifier matching nothing: ios and web blamed the spec for
finding a different bug, and android silently reclassified a real
conviction as 'judging health only' and stayed green.
replay-ui-summary.sh already makes this check for its own list. The spec
path becomes SPEC-overridable the same way, so the check is testable.
* test(ci): cover the folio classifier's verdicts
21 cases through a stubbed sanderling: every exit path, the drift check,
a missing trace, a zero-byte trace, an empty glob and a truncated line.
Asserts the flags that reached the binary, not just the exit code.
Invoked as bash -eo pipefail -c, which is what a run: block does. Running
folio-run.sh itself under -e would kill it at the first non-zero
sanderling test, which is the exit code it exists to read.
* docs(skills): defaultActions bundles five of the eight generators
* test(ios): name the picker test for what it covers
* docs(skills): three of the replay-ui properties are cross-panel
* docs(manual): state.exceptions is web only and reportError does not exist
* fix(folio): the bound carries no unconfirmed-submit guard
deleting confirmedApplied here broke 0 of 355 tests: under a bound a submit
that may not have landed moves the balance by 0, which the bound already
permits, so the guard could only ever drop the double commit it exists to
catch. the relaunch guard stays for a reason the bound does not cover, and
both tests now assert a verdict that changes when their guard does.
* docs(ci): three of the replay-ui properties are cross-panel
* docs(manual): the starter property only fires on web
* test(folio): judge the conjunct on the landings a real run produces
three of the 18 frames the recorded ios run drove it down, each with the
second commit the bound is there to catch. neutering the comparison reddens
it: a second commit on 357900 went unjudged.
* test(folio): the walk drives the composition the spec runs
countSubmitsInWindow never saw an amountText here, so every walk test counted
submits the app must have refused. with the field passed, a refused submit no
longer buys a later double tap an alibi: without it the window reads 3, not 1.
* docs(folio): say which double submit the conjunct can see, and which it cannot
the home landing is the counting invariant's, three of three in the recorded
ios run; this one gets the interleaving whose second pop is cancelled. it is
still the only judge on the 18 ledger landings that run produced.
* docs(folio): the narrow window is not where the detection comes from
the double taps land on home, so the counting form convicts them; what turned
0 convictions into 4 on the recorded ios run is submitCouldCommit, which drops
the windows at those three steps from 5/4/7 to 2/1/2.
* docs(skills): folio drives three platforms from one spec
* fix(folio): an amount over the app's cap spends no window budget
the corpus reaches TxnSubmit with 999999999999999999999, AMOUNT_REGEX takes it
and AddTransactionViewModel refuses it against MAX_TRANSACTION_AMOUNT_CENTS, so
counting it was budget a double submit could hide behind.
* docs(folio): say which form judged one step, not which node was read once
* docs: a bound still needs the relaunch guard, and eventually does convict
* fix(sidecar): the hierarchy rpc serves the tree the snapshot reads
the runner compares the two per step, but snapshot settles and closes a
keyboard while hierarchy was a bare contentDescriptor. measured on emulator
-5556 (api 34) with an ime open: 489 nodes against the snapshot's 134. both
now come off snapshotTree under the same lock; the reread still costs ~75ms
when no keyboard is up.
* docs(runner): say what makes the two reads comparable
the reread's comment claimed the round trip was the only interval between
them; what it left out is that the two rpcs have to read the same way, which
the repo's own android backend did not do.
* refactor(runner): name the settle predicate for what it means
* ci: add a headless-chrome composite action
The setup-chrome / apparmor sysctl / launch-check trio is copied across
three jobs. The old comment described setup-chrome v1 semantics: under v2
stable is the default and the alternative is Chrome for Testing latest,
not a dev Chromium, so it is restated for what the pin actually does.
* ci(examples): add the folio setup actions
folio-app holds the per-platform toolchain and app build, so a caller
guards one step instead of eight. folio-simulator boots the simulator,
installs folio and leaves the app stopped.
* ci(examples): add the replay-ui fixture action
Records a trace and serves it with sanderling replay. The step page URL
is a composite output rather than GITHUB_ENV, so it is scoped to the one
step that drives it.
* ci(examples): one dispatch workflow for every example
folio.yml and replay-ui.yml ran the same operation: build sanderling for
a platform, bring a target up, run a spec against it, classify the trace,
upload the run. They are now one matrix over four examples, each naming
its own runner.
The job is named for what it fuzzes. 'dogfood' named why we run it, not
what runs, the same error as a diagnostic that reports a motivation
instead of an observation.
The matrix is computed by a plan job because jobs.<id>.if cannot read the
matrix context, so a static matrix has no way to leave a leg out. Seeds,
budgets, timeouts, runners and artifact names are unchanged.
* ci: reuse the headless-chrome action in the browser job
Same three steps the examples workflow needs, and the comment explaining
the AppArmor sysctl now lives in one place.
* ci: move the folio jdk step to setup-java v5
The only setup-java left on v4; every other one moved.
* ci: pin third-party actions to commit shas
buf-setup-action was already pinned with a comment saying why; the other
five rode mutable major tags, so a tag move is an unreviewed change to
what runs. Each major currently resolves to the release named in the
comment, so this freezes today's behaviour rather than changing it.
actions/* stay on major tags: they are first-party to the runner.
* ci(replay-ui): name the run directory for what it fuzzes
runs/dogfood and the '### replay-ui dogfood' heading carried the same
naming error as the job name: dogfooding is why the run exists, not what
it fuzzes.
* docs(driver): state the log level scale on LogEntry
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* docs(sidecar): name the device the node counts came off
* fix(chrome): keep a log entry the level scale cannot rank
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* fix(chrome): record console levels on the logcat scale
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* docs(ci): say why upload-pages-artifact needs no include-hidden-files
v3 to v5 crossed v4's change to exclude dot-files. build/site has none,
so nothing was dropped, and the underscore directory is not hidden.
* test(browser): drive a console error through to the spec
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* docs(ioscompanion): name the vacuity behind the empty log slice
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* ci: run the folio classifier's test in make test-ci-scripts
* fix(chrome): keep the message of an object console argument
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* test(browser): cover console.error with an error object
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* feat(spec): let the runner install state.logs in the page
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* feat(verifier): encode state.logs for the web host
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* feat(chrome): install the step's logs in the page
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* test(ci): pin three real folio traces from run 31902501859
ios convicted on submitCommitsOneTransactionPerAction, web on both gated
properties, android ran its full 200 steps healthy. Every step is kept;
of each step only step, violations, witnesses and residuals survive.
hierarchy is replaced by the quoted "...Screen" resource ids it held, in
order. It cannot just be dropped: it is 95% of the bytes and also the
only place the android route gate's grep can match, so dropping it flips
that leg from healthy to 'never reached'. 8.4MB to 59KB.
* test(ci): drive the classifier over the real traces
Four cases on real data: each leg's real verdict, plus the android trace
cut before it reached the transaction screen, which is what proves the
route gate reads a real hierarchy dump.
Also corrects the hand-written fixtures. They set is_error to false on a
plain violation; internal/trace/writer.go tags that field omitempty, so a
real trace omits it entirely. Harmless to the classifier, but a fixture
that does not look like reality is the thing that hides drift.
* test(runner): teach the web fakes to take the step's logs
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* fix(runner): install the step's logs before the page extracts
On web every extractor reading is replaced by the one the page computed,
and the page answered logs: [], so noLogcatErrors counted an empty array
however full of errors the console was.
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* test(runner): cover the logs reaching the page and failing to
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* test(spec): cover the host pushing state.logs into the page
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* test(browser): drive console.error through to a fired property
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* fix(runner): report a log fetch the driver could not make
The comment claimed the failure was warned about; nothing warned, so a
device whose log fetch failed every step held noLogcatErrors on evidence
nobody collected.
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* test(runner): cover the silently dropped log fetch
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* fix(ci): read the route the spec reports, not the hierarchy dump
The android health gate grepped the trace for "AddTransactionScreen",
which occurs in exactly one place: the hierarchy dump, as a resource-id.
That is a debug artifact standing in for a fact the spec already reports,
and it was wrong in both directions. Against the real 8.4MB trace with
hierarchy stripped, the old gate failed a healthy 200-step run; against a
trace carrying the marker on a transition frame without the route ever
being reported, it passed and called it healthy.
It reads extractor_changes.route now, whose values come from SCREENS in
the spec, so the gate and the app agree on what being on a screen means.
routeOf answers null on a frame showing two screens, which is exactly the
frame the marker was matching.
The drift check grows to cover both new names: extract("route") and the
SCREENS key. The fixtures are re-derived keeping the route entry and no
hierarchy at all; the full artifacts and the fixtures give byte-identical
verdicts, which is what proves the coupling is gone.
Script and fixtures move together: either alone leaves the suite red.
* ci: check that the workflow references resolve
actionlint reads a local action's inputs but never checks its path
exists: uses: ./.github/actions/typo lints clean and fails only when the
job runs. Covers composite action paths, make targets including the ones
the examples matrix builds from $SANDERLING, and the scripts a run: step
invokes plus their executable bit.
Fails when it parses fewer references out of a file than that file
mentions, because a checker that matches nothing reports a safety it
never looked for.
* ci: lint the workflows on every pr
The workflows that fuzz the examples are dispatch-only, and GitHub will
not dispatch a workflow that is not on the default branch, so their first
real run is after merge. actionlint and the reference checker are the
only things that can fail before that.
actionlint is pinned by commit, and its tool version is pinned too so a
new release cannot change what CI enforces.
* ci: collapse the four workflows into one
Nine jobs written out one by one, each with its own steps and its own
calibrated seed and budget as literals. Triggers are pull requests, master
and v* tags, and a dispatch with no inputs.
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* ci: inline the two composite actions with one caller each
Both existed to give the matrix a per-target hook. folio-app and
headless-chrome stay: three and three callers.
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* ci: check that no run: block interpolates an expression
A ${{ }} lands in the script text before bash reads the line, and
actionlint only flags the contexts it already knows are attacker
controlled. Nothing enforced the rule the workflow follows. Also drops the
matrix table lookup, which has no table to read now.
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* ci(folio): name the run, not the fuzzer, in the clean-run message
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* docs: point at the workflow that holds the release secrets now
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* ci: name every job Category (variant), and gate the lot on one check
Follows the convention in antithesishq/bombadil: the display name is what
groups a run in the Actions UI, so Check (tests), Check (browser),
Check (workflows), Folio (android), Folio (ios), Folio (web), Replay UI,
Release and Docs. Every job carries a name, so none of them falls back to
its kebab-case id.
All checks passed needs all nine and runs with if: always(), so branch
protection has one check to point at and a skipped job cannot read as a
pass. Release and docs now gate on startsWith(github.ref, 'refs/tags/v')
alongside master, which is the form the trigger filter already uses.
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* ci: split the release job back in two
Collapsing them left the npm publish steps in a job holding contents:
write, because GoReleaser needs it, so npm ci ran its dependency lifecycle
scripts with a write-capable GITHUB_TOKEN in reach of the same job as a
live NPM_TOKEN. Release (npm) is back on contents: read and Release (cli)
keeps contents: write, which is what they each had before.
Each validates the tag from its own copy of the pattern rather than
waiting on a job that exists only to pass a string. Release (cli) is tags
only: there is no CLI to cut on a merge.
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* ci: run folio on pull requests
folio was skipped on pull requests, so ios, android and web only ever ran
after a merge. The three legs are 3 to 19 minutes and run in parallel, and
a superseded pull request run already cancels itself.
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
* ci: draw each group as its own box in the run graph
The run graph boxes jobs together when they share the same dependencies and
the same dependents. All ten jobs fed only all-checks-passed, so all ten drew
as one pile. A gate per group gives each group a dependent that is exactly
that group.
Release and docs now need the checks, which they should have all along: npm
publish and the pages deploy ran on a merge without waiting for the test job.
Folio stays unblocked so a 20 minute leg does not wait on a 3 minute one.
Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
1358 lines
52 KiB
Go
1358 lines
52 KiB
Go
// Package runner drives the observe-decide-act loop that steps a spec against a device.
|
|
package runner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/driver"
|
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
|
"github.com/priyanshujain/sanderling/internal/ltl"
|
|
"github.com/priyanshujain/sanderling/internal/trace"
|
|
"github.com/priyanshujain/sanderling/internal/verifier"
|
|
)
|
|
|
|
type Options struct {
|
|
Duration time.Duration
|
|
IdleTimeout time.Duration
|
|
|
|
// MaxSteps caps the run at a fixed number of steps for reproducible
|
|
// bounded runs. 0 means unbounded (the duration deadline governs); a
|
|
// positive value stops the loop once that many steps have run.
|
|
MaxSteps int
|
|
|
|
// StopOnViolation ends the step loop as soon as a step records a
|
|
// violation, so a run that exists to find one bug stops at the evidence
|
|
// instead of spending the rest of its budget past it.
|
|
StopOnViolation bool
|
|
|
|
BundleID string
|
|
Driver driver.DeviceDriver
|
|
Verifier *verifier.Verifier
|
|
TraceWriter *trace.Writer
|
|
Logger *slog.Logger
|
|
// Generator selects the action picker: "llm" drives selection with the
|
|
// spec's generator = llm({...}) config; anything else (the default) uses the
|
|
// seeded weighted picker. Both draw from the same actionsRoot candidate set.
|
|
Generator string
|
|
}
|
|
|
|
type Summary struct {
|
|
StartTime time.Time
|
|
EndTime time.Time
|
|
Steps int
|
|
Violations []ViolationRecord
|
|
// SkippedVerification counts the steps whose tree was still moving when it
|
|
// was read, so no property judged them. A green run that skipped most of
|
|
// its steps checked almost nothing, and nothing else in the output would
|
|
// say so.
|
|
SkippedVerification int
|
|
// UnsupportedVerbs lists verbs the picker requested that the platform
|
|
// could not dispatch, deduped, so the report can flag a spec exercising
|
|
// gestures this target does not support.
|
|
UnsupportedVerbs []string
|
|
}
|
|
|
|
type ViolationRecord struct {
|
|
StepIndex int
|
|
Properties []string
|
|
}
|
|
|
|
// Run drives the evaluate/act loop until the duration elapses or the context
|
|
// is canceled. The caller is responsible for launching the app before Run is
|
|
// called and for terminating it afterwards.
|
|
func Run(ctx context.Context, options Options) (Summary, error) {
|
|
if err := validate(options); err != nil {
|
|
return Summary{}, err
|
|
}
|
|
logger := options.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
options.IdleTimeout = resolveIdleTimeout(options)
|
|
|
|
// 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)
|
|
|
|
// Pick the action and extractor sources once from the driver's
|
|
// capabilities so the step loop runs one uniform path with no per-step
|
|
// driver type assertion.
|
|
actionSource, extractorSource, err := pickSources(options)
|
|
if err != nil {
|
|
return Summary{}, err
|
|
}
|
|
_, pageExtractors := extractorSource.(webSource)
|
|
rereadHierarchy := driverIsAndroid(ctx, options, logger)
|
|
|
|
summary := Summary{StartTime: time.Now()}
|
|
deadline := summary.StartTime.Add(options.Duration)
|
|
stepIndex := 0
|
|
consecutiveApplyFailures := 0
|
|
var lastAction *verifier.Action
|
|
var lastLogTime time.Time
|
|
for time.Now().Before(deadline) {
|
|
if err := ctx.Err(); err != nil {
|
|
break
|
|
}
|
|
if options.MaxSteps > 0 && stepIndex >= options.MaxSteps {
|
|
break
|
|
}
|
|
stepIndex++
|
|
stepStart := time.Now()
|
|
|
|
// 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.
|
|
//
|
|
// What the guard did is reported to the spec on the action it followed,
|
|
// because dropping that action says "nothing ran between these two
|
|
// readings" and the runner has no business saying that: the action ran,
|
|
// and a property told otherwise convicts the app of an effect with no
|
|
// cause. See foreground_guard_last_action_test.go.
|
|
guard := ensureForeground(ctx, options, logger, stepIndex)
|
|
if lastAction != nil {
|
|
switch guard {
|
|
case foregroundRelaunched:
|
|
lastAction.Relaunched = true
|
|
case foregroundOverlayDismissed:
|
|
// A system window owned the focused window, so whether the app
|
|
// itself ever received this action is exactly the unknown
|
|
// Applied already has a state for.
|
|
lastAction.Applied = false
|
|
}
|
|
}
|
|
|
|
// 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 screenshotPNG []byte
|
|
var metrics *trace.Metrics
|
|
var logs []verifier.LogEntry
|
|
|
|
// gctx is bound to the errgroup so a returned error (or outer
|
|
// cancellation) propagates to every sibling read rather than leaving
|
|
// one blocked on a hung device.
|
|
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, screenshotPNG, transitional, hierarchyErr = fetchSyncedState(
|
|
gctx, options, logger, si, rereadHierarchy)
|
|
return nil
|
|
})
|
|
g.Go(func() error {
|
|
metrics = captureMetrics(gctx, options, logger, si)
|
|
return nil
|
|
})
|
|
logSince := lastLogTime
|
|
g.Go(func() error {
|
|
logs = collectLogs(gctx, options.Driver, logger, si, logSince)
|
|
return nil
|
|
})
|
|
// All goroutines write to local variables and return nil, so the Wait
|
|
// error is always nil; ignored intentionally.
|
|
_ = g.Wait()
|
|
|
|
if hierarchyErr != nil {
|
|
if isWDADrop(hierarchyErr) {
|
|
return summary, fmt.Errorf("WDA connection permanently lost at step %d - re-run the test: %w", stepIndex, hierarchyErr)
|
|
}
|
|
logger.Warn("hierarchy fetch failed", "step", stepIndex, "err", hierarchyErr)
|
|
}
|
|
treeSize := 0
|
|
if tree != nil {
|
|
treeSize = len(tree.Elements)
|
|
}
|
|
// 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
|
|
}
|
|
|
|
// A transitional tree is one nothing can vouch for: a NavHost mid
|
|
// cross-fade, a screen that changed shape between two reads, or a
|
|
// hierarchy that came back empty. 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 replay-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
|
|
var witnesses map[string]trace.Witness
|
|
skippedVerification := false
|
|
if !transitional {
|
|
// The page-side extractors evaluate only on steps the verifier will
|
|
// accept, which is why this read waits for the tree instead of
|
|
// racing it. A spec's extractor getters carry state across steps
|
|
// (folio's last-seen Home total, its submit counters) and that state
|
|
// advances every time they run: evaluating them on a step whose
|
|
// values are then thrown away leaves the page one window ahead of
|
|
// the verifier, so the next accepted pair brackets two committed
|
|
// transactions while having counted one submit, and the property
|
|
// convicts a healthy app. It costs the latency the read used to hide
|
|
// behind the hierarchy fetch; the fetch is what decides whether this
|
|
// step counts at all, so it has to go first.
|
|
//
|
|
// lastAction and logs are the same values PushSnapshot hands the
|
|
// goja state below: the two engines evaluate this step against one
|
|
// action and one set of log entries.
|
|
v8Overrides, overridesErr := extractorSource.ExtractorOverrides(ctx, lastAction, logs)
|
|
if overridesErr != nil {
|
|
// Not a warning. Without the page's values this step's
|
|
// extractors keep goja's dump-derived readings while the
|
|
// previous step holds the page's, and a delta property then
|
|
// compares two producers and fires on an app that did nothing
|
|
// wrong.
|
|
return summary, fmt.Errorf("step %d extractor overrides: %w", stepIndex, overridesErr)
|
|
}
|
|
if err := options.Verifier.PushSnapshot(verifier.SnapshotInput{
|
|
Tree: tree,
|
|
ScreenshotPNG: screenshotPNG,
|
|
LastAction: lastAction,
|
|
StepTime: stepStart,
|
|
StepIndex: stepIndex,
|
|
RunStart: summary.StartTime,
|
|
Logs: logs,
|
|
}); err != nil {
|
|
return summary, fmt.Errorf("step %d push: %w", stepIndex, err)
|
|
}
|
|
// Every failure below leaves some extractors holding the page's
|
|
// value and the rest holding goja's reading of the dump, and a
|
|
// property comparing previous to current across that split fires
|
|
// on a healthy app. Each also means the two engines loaded
|
|
// different bundles, which nothing downstream can reconcile.
|
|
if pageExtractors && len(v8Overrides) != options.Verifier.ExtractorCount() {
|
|
return summary, fmt.Errorf(
|
|
"step %d: the page reported values for %d of the spec's %d extractors; "+
|
|
"the page and the host are running different bundles",
|
|
stepIndex, len(v8Overrides), options.Verifier.ExtractorCount())
|
|
}
|
|
skipped, overrideErr := options.Verifier.OverrideExtractorValues(v8Overrides)
|
|
if overrideErr != nil {
|
|
return summary, fmt.Errorf("step %d apply extractor overrides: %w", stepIndex, overrideErr)
|
|
}
|
|
if skipped > 0 {
|
|
return summary, fmt.Errorf(
|
|
"step %d: %d of %d extractor overrides fell outside the spec's extractor list; "+
|
|
"the page and the host are running different bundles",
|
|
stepIndex, skipped, len(v8Overrides))
|
|
}
|
|
options.Verifier.EvaluateProperties()
|
|
violations = options.Verifier.NewlyViolatedProperties()
|
|
witnesses = collectWitnesses(options.Verifier, violations, logger, stepIndex)
|
|
extractorChanges = encodeExtractorChanges(options.Verifier.ChangedExtractors())
|
|
} else {
|
|
skippedVerification = true
|
|
summary.SkippedVerification++
|
|
logger.Warn("unsettled tree; skipping verifier",
|
|
"step", stepIndex, "screen", screen, "nodes", treeSize)
|
|
}
|
|
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
|
|
|
|
// A frame the verifier would not look at is not one to act on either.
|
|
// #75 is the fuzzer tapping into a screen that is still filling in, and
|
|
// holding the action back is also what keeps the spec's view of the run
|
|
// continuous: the action a step applies is reported on the NEXT step the
|
|
// verifier accepts, so acting here would leave the action applied last
|
|
// step unreported for good, and a property counting actions against
|
|
// their effects would then see an effect whose cause the runner
|
|
// swallowed. See TestRunner_ASkippedStepDoesNotSwallowTheActionBeforeIt.
|
|
//
|
|
// Unbounded, because lastAction holds exactly one action: any bound that
|
|
// let the runner act again while the verifier was still being skipped
|
|
// would overwrite the action the hold was carrying, and that is the same
|
|
// swallow arriving one step later. A screen that keeps moving therefore
|
|
// costs the run its actions rather than its soundness, and a run that
|
|
// verified nothing says so in its outcome (internal/testrun).
|
|
held := skippedVerification
|
|
if held {
|
|
logger.Warn("screen still moving; holding this step's action back",
|
|
"step", stepIndex)
|
|
}
|
|
|
|
var nextAction verifier.Action
|
|
nextErr := verifier.ErrNoAction
|
|
var traceAction *trace.Action
|
|
if !held {
|
|
nextAction, nextErr = actionSource.NextAction(ctx)
|
|
if nextErr == nil {
|
|
traceAction = traceActionFor(nextAction, tree)
|
|
stampActionSource(traceAction, actionSource)
|
|
} else if !errors.Is(nextErr, verifier.ErrNoAction) {
|
|
return summary, fmt.Errorf("step %d next action: %w", stepIndex, nextErr)
|
|
}
|
|
}
|
|
|
|
residuals, residualErr := encodeResiduals(options.Verifier.Residuals())
|
|
if residualErr != nil {
|
|
logger.Warn("residual encode failed", "step", stepIndex, "err", residualErr)
|
|
}
|
|
|
|
applySkipped := held
|
|
if nextErr == nil && !appIsForeground(ctx, options) {
|
|
// The app left the foreground between observe and apply (a prior
|
|
// action's gesture settling late, or an async navigation). The
|
|
// chosen action's coordinates reference a tree that no longer
|
|
// applies, so firing it would act on whatever screen is now up.
|
|
// Skip it and record the escape; the next step's guard relaunches.
|
|
logger.Warn("app not in foreground at action time; skipping (relaunch next step)",
|
|
"step", stepIndex, "action", nextAction.Kind)
|
|
applySkipped = true
|
|
lastAction = nil
|
|
} else if nextErr == nil {
|
|
if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil {
|
|
if isWDADrop(err) {
|
|
return summary, fmt.Errorf("step %d: the iOS XCTest runner could not be restarted - re-run the test: %w", stepIndex, err)
|
|
}
|
|
if ctx.Err() != nil {
|
|
return summary, fmt.Errorf("step %d apply: %w", stepIndex, err)
|
|
}
|
|
// Every apply error is a device-side condition (a dropped
|
|
// gesture, a typing request the runner's input handler choked
|
|
// on, an RPC deadline). None of them individually justify
|
|
// killing a fuzz run; what does is an unbroken streak, which
|
|
// means the device is wedged. The step is marked transitional
|
|
// so the verifier never sees a state the action did not reach.
|
|
consecutiveApplyFailures++
|
|
if consecutiveApplyFailures >= maxConsecutiveApplyFailures {
|
|
return summary, fmt.Errorf("step %d apply: %d consecutive failures; the device is not recovering: %w", stepIndex, consecutiveApplyFailures, err)
|
|
}
|
|
logger.Warn("apply error; marking step transitional", "step", stepIndex, "err", err)
|
|
transitional = true
|
|
applySkipped = true
|
|
// The error says the call failed, not that the gesture never
|
|
// reached the app: a deadline that fires after dispatch leaves
|
|
// the effect committed. Reporting no action here would let a
|
|
// property convict the app for an effect with no cause, so the
|
|
// action is reported with its fate unknown instead.
|
|
unconfirmed := nextAction
|
|
lastAction = &unconfirmed
|
|
} else {
|
|
consecutiveApplyFailures = 0
|
|
applied := nextAction
|
|
applied.Applied = true
|
|
lastAction = &applied
|
|
}
|
|
} else if !held {
|
|
lastAction = nil
|
|
}
|
|
// A held step leaves lastAction alone on purpose: nothing ran here, and
|
|
// the action it points at is still the one the next verified step has to
|
|
// be told about.
|
|
|
|
step := trace.Step{
|
|
Index: stepIndex,
|
|
Timestamp: stepStart,
|
|
Screen: screen,
|
|
NextAction: traceAction,
|
|
Violations: violations,
|
|
Hierarchy: tree,
|
|
Residuals: residuals,
|
|
Metrics: metrics,
|
|
ExtractorChanges: extractorChanges,
|
|
Transitional: transitional,
|
|
SkippedVerification: skippedVerification,
|
|
Witnesses: witnesses,
|
|
}
|
|
if err := options.TraceWriter.WriteStep(step); err != nil {
|
|
return summary, fmt.Errorf("step %d trace: %w", stepIndex, err)
|
|
}
|
|
summary.Steps = stepIndex
|
|
if len(violations) > 0 {
|
|
summary.Violations = append(summary.Violations, violationRecords(violations, witnesses, stepIndex)...)
|
|
// The step is already written, so the trace ends on the state that
|
|
// produced the violation. Finalize below still runs, so pending
|
|
// liveness obligations are reported alongside it.
|
|
if options.StopOnViolation {
|
|
break
|
|
}
|
|
}
|
|
// 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.
|
|
//
|
|
// A held step settles too, and it is the only case here that waits with
|
|
// nothing applied. The reread that held it takes its two reads a round
|
|
// trip apart, which is a tighter window than the one the detector was
|
|
// measured over (an action and a settle); looping straight back into it
|
|
// would compare two reads of a composing screen closer together still,
|
|
// so the screen that most needs to settle is the one given least room.
|
|
mutated := nextErr == nil && !applySkipped &&
|
|
nextAction.Kind != verifier.ActionKindWait
|
|
if held || mutated {
|
|
idleCtx, idleCancel := context.WithTimeout(ctx, options.IdleTimeout)
|
|
idleErr := options.Driver.WaitForIdle(idleCtx, options.IdleTimeout)
|
|
if idleErr != nil && idleCtx.Err() == nil {
|
|
logger.Warn("wait_for_idle failed", "step", stepIndex, "err", idleErr)
|
|
}
|
|
idleCancel()
|
|
}
|
|
}
|
|
|
|
// Finalize each evaluator once the loop ends so liveness obligations that
|
|
// never discharged (an eventually that never fired) are reported as
|
|
// violations rather than silently left pending. Properties already
|
|
// violated mid-run are not re-reported. The synthetic record gets its own
|
|
// step index so no two trace lines share one; witnesses still attribute
|
|
// the violation to the step that spawned the obligation.
|
|
if ended := options.Verifier.Finalize(); len(ended) > 0 {
|
|
finalIndex := stepIndex + 1
|
|
witnesses := collectWitnesses(options.Verifier, ended, logger, finalIndex)
|
|
summary.Violations = append(summary.Violations, violationRecords(ended, witnesses, finalIndex)...)
|
|
finalStep := trace.Step{
|
|
Index: finalIndex,
|
|
Timestamp: time.Now(),
|
|
Violations: ended,
|
|
Witnesses: witnesses,
|
|
}
|
|
if err := options.TraceWriter.WriteStep(finalStep); err != nil {
|
|
return summary, fmt.Errorf("finalize trace: %w", err)
|
|
}
|
|
}
|
|
|
|
summary.UnsupportedVerbs = options.Verifier.UnsupportedVerbs()
|
|
summary.EndTime = time.Now()
|
|
return summary, nil
|
|
}
|
|
|
|
// RenderSummary writes the human-facing run summary: step count, each violation
|
|
// record, and any unsupported verbs. The wall-clock duration is excluded so the
|
|
// output is deterministic and snapshot-testable; the CLI prints it separately.
|
|
func RenderSummary(w io.Writer, summary Summary, platform string) {
|
|
fmt.Fprintf(w, "\nrun complete: %d steps\n", summary.Steps)
|
|
if len(summary.Violations) == 0 {
|
|
fmt.Fprintln(w, "no violations.")
|
|
} else {
|
|
fmt.Fprintf(w, "%d violation record(s):\n", len(summary.Violations))
|
|
for _, violation := range summary.Violations {
|
|
fmt.Fprintf(w, " step %d: %v\n", violation.StepIndex, violation.Properties)
|
|
}
|
|
}
|
|
if summary.SkippedVerification > 0 {
|
|
fmt.Fprintf(w, "%d step(s) judged by nothing: the screen was still moving when it was read\n",
|
|
summary.SkippedVerification)
|
|
}
|
|
if len(summary.UnsupportedVerbs) > 0 {
|
|
fmt.Fprintf(w, "unsupported on %s: %s\n",
|
|
platform, strings.Join(summary.UnsupportedVerbs, ", "))
|
|
}
|
|
}
|
|
|
|
func validate(options Options) error {
|
|
if options.Driver == nil {
|
|
return errors.New("runner: Driver is required")
|
|
}
|
|
if options.Verifier == nil {
|
|
return errors.New("runner: Verifier is required")
|
|
}
|
|
if options.TraceWriter == nil {
|
|
return errors.New("runner: TraceWriter is required")
|
|
}
|
|
if options.Duration <= 0 {
|
|
return errors.New("runner: Duration must be positive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// defaultIdleTimeout is the settle budget a caller that names none gets.
|
|
const defaultIdleTimeout = 2 * time.Second
|
|
|
|
// idleTimeoutFloor is a driver that knows how long its own settle can take.
|
|
// Declared here rather than in the driver package (like lastActionInstaller in
|
|
// source.go) so the mobile drivers stay untouched.
|
|
type idleTimeoutFloor interface {
|
|
MinIdleTimeout() time.Duration
|
|
}
|
|
|
|
// resolveIdleTimeout settles the per-step settle budget: the caller's value,
|
|
// defaulted when unset, and raised to whatever the driver says its own settle
|
|
// needs. The chrome driver's settle waits for the DOM to go quiet and only then
|
|
// opens its route-transition window; handed less than their sum it is cut off
|
|
// mid-transition, and the step samples the screen the app is leaving. A driver
|
|
// that reports no floor keeps the caller's value exactly.
|
|
func resolveIdleTimeout(options Options) time.Duration {
|
|
timeout := options.IdleTimeout
|
|
if timeout <= 0 {
|
|
timeout = defaultIdleTimeout
|
|
}
|
|
if floor, ok := options.Driver.(idleTimeoutFloor); ok {
|
|
timeout = max(timeout, floor.MinIdleTimeout())
|
|
}
|
|
return timeout
|
|
}
|
|
|
|
// foregroundGuard is what ensureForeground had to do to put the app back in
|
|
// front. The two interventions are separate values because they are separate
|
|
// facts about the action they follow: a relaunch leaves it confirmed but
|
|
// straddling a restart, while a system window holding the focus leaves it
|
|
// dispatched with no way to tell whether the app received it.
|
|
type foregroundGuard int
|
|
|
|
const (
|
|
foregroundIntact foregroundGuard = iota
|
|
foregroundOverlayDismissed
|
|
foregroundRelaunched
|
|
)
|
|
|
|
// 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. Reports what it did so the caller can pass that on to
|
|
// the spec through the previous action. Drivers without ForegroundChecker (web,
|
|
// iOS) are a no-op.
|
|
func ensureForeground(
|
|
ctx context.Context,
|
|
options Options,
|
|
logger *slog.Logger,
|
|
stepIndex int,
|
|
) foregroundGuard {
|
|
checker, ok := options.Driver.(driver.ForegroundChecker)
|
|
if !ok || options.BundleID == "" {
|
|
return foregroundIntact
|
|
}
|
|
foreground, err := checker.ForegroundApp(ctx)
|
|
if err != nil {
|
|
logger.Warn("foreground check failed", "step", stepIndex, "err", err)
|
|
return foregroundIntact
|
|
}
|
|
if foreground != "" && foreground != options.BundleID {
|
|
logger.Warn("app left foreground; relaunching",
|
|
"step", stepIndex, "foreground", foreground, "want", options.BundleID)
|
|
// Relaunch and confirm the app is genuinely back on screen before the
|
|
// step observes or acts. A single relaunch returns before the window
|
|
// draws on a slow physical device, which would let the observe and the
|
|
// next action land on the launcher (its type-to-search swallows
|
|
// InputText). awaitForeground re-checks the foreground and focused
|
|
// window, so it never acts outside the app no matter how slow the
|
|
// relaunch settles.
|
|
awaitForeground(ctx, options, logger, stepIndex)
|
|
return foregroundRelaunched
|
|
}
|
|
// The app is the resumed activity, but a system overlay can still own the
|
|
// focused window while the app stays resumed: a fuzzer swipe starting in the
|
|
// status bar pulls the notification shade over the app. The resumed-activity
|
|
// signal misses this, so observing or acting would land on the shade.
|
|
// Dismiss it with back (which collapses the shade) so the next observe sees
|
|
// the app again.
|
|
focusChecker, hasFocus := options.Driver.(driver.FocusedWindowChecker)
|
|
if !hasFocus {
|
|
return foregroundIntact
|
|
}
|
|
focused, err := focusChecker.FocusedWindowApp(ctx)
|
|
if err != nil {
|
|
logger.Warn("focus check failed", "step", stepIndex, "err", err)
|
|
return foregroundIntact
|
|
}
|
|
if focused == "" || focused == options.BundleID {
|
|
return foregroundIntact
|
|
}
|
|
logger.Warn("system window obscuring app; dismissing",
|
|
"step", stepIndex, "focused", focused, "want", options.BundleID)
|
|
if err := options.Driver.PressKey(ctx, "back"); err != nil {
|
|
logger.Warn("dismiss overlay failed", "step", stepIndex, "err", err)
|
|
}
|
|
settleForForeground(ctx, options)
|
|
return foregroundOverlayDismissed
|
|
}
|
|
|
|
// appIsForeground reports whether the app under test currently owns the
|
|
// foreground. It is the apply-time half of the scope guard: ensureForeground
|
|
// runs before observe, but the app can leave between observe and apply (a prior
|
|
// gesture settling late, an async navigation), and swipes/keys carry stale
|
|
// coordinates with no selector to re-resolve. An absent capability or an unknown
|
|
// foreground returns true so the run is never blocked where the signal is
|
|
// unavailable (web, iOS, a transient read).
|
|
func appIsForeground(ctx context.Context, options Options) bool {
|
|
checker, ok := options.Driver.(driver.ForegroundChecker)
|
|
if !ok || options.BundleID == "" {
|
|
return true
|
|
}
|
|
foreground, err := checker.ForegroundApp(ctx)
|
|
if err != nil || foreground == "" {
|
|
return true
|
|
}
|
|
if foreground != options.BundleID {
|
|
return false
|
|
}
|
|
// A system overlay can own the focused window while the app stays resumed,
|
|
// so mirror ensureForeground's focus check rather than act on the overlay.
|
|
focusChecker, ok := options.Driver.(driver.FocusedWindowChecker)
|
|
if !ok {
|
|
return true
|
|
}
|
|
focused, err := focusChecker.FocusedWindowApp(ctx)
|
|
if err != nil || focused == "" {
|
|
return true
|
|
}
|
|
return focused == options.BundleID
|
|
}
|
|
|
|
// 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
|
|
|
|
// focusTapSettle is the pause after tapping a field to focus it, before typing.
|
|
// Long enough for focus to land, short enough to avoid the ~500ms-1s full
|
|
// settle the keyboard's open animation would otherwise cost every InputText
|
|
// step on a physical device.
|
|
var focusTapSettle = 250 * time.Millisecond
|
|
|
|
// 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) {
|
|
awaitForeground(ctx, options, logger, 0)
|
|
}
|
|
|
|
// awaitForeground brings the app under test forward when it is not already
|
|
// resumed and blocks until its window is actually drawn, bounded by
|
|
// foregroundReadyAttempts so a stuck system dialog can never hang the run. It
|
|
// re-checks the foreground each iteration and only presses back + relaunches
|
|
// while the app is genuinely absent, so once the app is resumed it polls the
|
|
// focused-window signal instead of mashing back (which would re-exit the app
|
|
// from its root screen). Shared by the pre-run startup gate (stepIndex 0) and
|
|
// the per-step scope guard so neither lets an observe or action land outside
|
|
// the app. Drivers without ForegroundChecker (web) and an unknown foreground
|
|
// both skip the gate.
|
|
func awaitForeground(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) {
|
|
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", "step", stepIndex, "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; bringing it forward",
|
|
"step", stepIndex, "foreground", foreground, "want", options.BundleID, "attempt", attempt)
|
|
bringToForeground(ctx, options, logger, stepIndex)
|
|
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", "step", stepIndex, "err", err)
|
|
return
|
|
}
|
|
if focused == options.BundleID {
|
|
return // window is drawn; safe to observe
|
|
}
|
|
logger.Warn("app resumed but window not yet drawn; waiting",
|
|
"step", stepIndex, "focused", focused, "want", options.BundleID, "attempt", attempt)
|
|
settleForForeground(ctx, options)
|
|
}
|
|
logger.Warn("app never reached foreground; proceeding anyway",
|
|
"step", stepIndex, "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.
|
|
func bringToForeground(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) {
|
|
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
|
|
}
|
|
settleForForeground(ctx, options)
|
|
}
|
|
|
|
// 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 {
|
|
switch action.Kind {
|
|
case verifier.ActionKindTap:
|
|
x, y, ok := resolveCoordinates(action, tree)
|
|
if !ok {
|
|
if action.On == "" {
|
|
return nil
|
|
}
|
|
return drv.TapSelector(ctx, action.On)
|
|
}
|
|
return drv.Tap(ctx, x, y)
|
|
case verifier.ActionKindDoubleTap:
|
|
x, y, ok := resolveCoordinates(action, tree)
|
|
if !ok {
|
|
if action.On == "" {
|
|
return nil
|
|
}
|
|
return drv.DoubleTapSelector(ctx, action.On)
|
|
}
|
|
return drv.DoubleTap(ctx, x, y)
|
|
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)
|
|
fromX, fromY, toX, toY = clampGestureToSafeArea(fromX, fromY, toX, toY, screenBounds(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:
|
|
tapped := false
|
|
if x, y, ok := resolveCoordinates(action, tree); ok {
|
|
if err := drv.Tap(ctx, x, y); err != nil {
|
|
return err
|
|
}
|
|
tapped = true
|
|
} else if action.On != "" {
|
|
if err := drv.TapSelector(ctx, action.On); err != nil {
|
|
return err
|
|
}
|
|
tapped = true
|
|
}
|
|
// The focus tap raises the keyboard. The tap registers focus
|
|
// immediately and the text is injected into the focused view (not typed
|
|
// on the visible keyboard), so a brief pause is enough for focus to land
|
|
// rather than a full settle, which costs ~500ms-1s per InputText step on
|
|
// a physical device while the keyboard animates in.
|
|
if tapped {
|
|
timer := time.NewTimer(focusTapSettle)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
// InputText replaces the field's content: erase what the target
|
|
// holds before typing. Appending instead lets repeated draws grow
|
|
// the field without bound (e.g. into a max-length validation error
|
|
// the fuzzer can never escape) and makes retried typing land twice.
|
|
// Drivers whose InputText already replaces skip the erase entirely.
|
|
if !inputReplacesText(drv) {
|
|
if count := existingTextLength(action, tree); count > 0 {
|
|
if err := drv.EraseText(ctx, count); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return drv.InputText(ctx, action.Text)
|
|
case verifier.ActionKindSwipe:
|
|
duration := time.Duration(action.DurationMillis) * time.Millisecond
|
|
if duration <= 0 {
|
|
duration = 250 * time.Millisecond
|
|
}
|
|
fromX, fromY, toX, toY := clampGestureToSafeArea(action.FromX, action.FromY, action.ToX, action.ToY, screenBounds(tree))
|
|
return drv.Swipe(ctx, fromX, fromY, toX, toY, duration)
|
|
case verifier.ActionKindPressKey:
|
|
if action.Key == "" {
|
|
return nil
|
|
}
|
|
return drv.PressKey(ctx, action.Key)
|
|
case verifier.ActionKindWait:
|
|
duration := time.Duration(action.DurationMillis) * time.Millisecond
|
|
if duration <= 0 {
|
|
return nil
|
|
}
|
|
timer := time.NewTimer(duration)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
return nil
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown action kind %q", action.Kind)
|
|
}
|
|
}
|
|
|
|
// collectLogs pulls recent error-level log entries from the driver since the
|
|
// previous fetch. A failure is warned-on but not fatal: one unreadable fetch on
|
|
// a flaky device should not end a run. It is not free either. This fetch is the
|
|
// whole evidence base for state.logs, so a step that could not make it leaves
|
|
// every log property (the default noLogcatErrors included) holding on an empty
|
|
// slice, and that has to be visible in the run's output rather than read as the
|
|
// app having logged nothing.
|
|
func collectLogs(
|
|
ctx context.Context,
|
|
drv driver.DeviceDriver,
|
|
logger *slog.Logger,
|
|
step int,
|
|
since time.Time,
|
|
) []verifier.LogEntry {
|
|
entries, err := drv.RecentLogs(ctx, since, "E")
|
|
if err != nil {
|
|
logger.Warn("log fetch failed; log properties hold vacuously this step",
|
|
"step", step, "err", err)
|
|
return nil
|
|
}
|
|
result := make([]verifier.LogEntry, 0, len(entries))
|
|
for _, entry := range entries {
|
|
result = append(result, verifier.LogEntry{
|
|
UnixMillis: entry.UnixMillis,
|
|
Level: entry.Level,
|
|
Tag: entry.Tag,
|
|
Message: entry.Message,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
// inputReplacesText reports whether the driver's InputText replaces existing
|
|
// content, making the runner's pre-erase redundant.
|
|
func inputReplacesText(drv driver.DeviceDriver) bool {
|
|
replacer, ok := drv.(driver.TextReplacer)
|
|
return ok && replacer.ReplacesTextOnInput()
|
|
}
|
|
|
|
// existingTextLength returns the character count of the InputText target's
|
|
// current text, so the runner can erase it before typing. Zero when the
|
|
// target cannot be resolved or holds no text.
|
|
func existingTextLength(action verifier.Action, tree *hierarchy.Tree) int {
|
|
if action.On == "" || tree == nil {
|
|
return 0
|
|
}
|
|
element := tree.Find(action.On)
|
|
if element == nil {
|
|
return 0
|
|
}
|
|
return len([]rune(element.Text))
|
|
}
|
|
|
|
func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, bool) {
|
|
// When On is empty, X/Y are authoritative (web V8 path emits coordinates
|
|
// directly from getBoundingClientRect; the runtime nullifies unresolved
|
|
// actions upstream so a non-null InputText here always has real coords,
|
|
// even at (0,0)). When On is set, prefer the tree lookup so stale coords
|
|
// don't leak from earlier ticks.
|
|
if action.On == "" {
|
|
if action.X >= 0 && action.Y >= 0 {
|
|
return action.X, action.Y, true
|
|
}
|
|
return 0, 0, false
|
|
}
|
|
if tree != nil {
|
|
if element := tree.Find(action.On); element != nil {
|
|
x, y := element.Bounds.Center()
|
|
if x > 0 && y > 0 {
|
|
return x, y, true
|
|
}
|
|
}
|
|
}
|
|
if action.X > 0 && action.Y > 0 {
|
|
return action.X, action.Y, true
|
|
}
|
|
return 0, 0, false
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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
|
|
}
|
|
|
|
// screenBounds returns the device screen rectangle as the maximum extent across
|
|
// all elements. The hierarchy root often reports zero bounds on Android, so the
|
|
// extent (driven by full-screen containers and the navigation bar) is the
|
|
// reliable screen size. Returns a zero rectangle when unknown.
|
|
func screenBounds(tree *hierarchy.Tree) hierarchy.Bounds {
|
|
if tree == nil {
|
|
return hierarchy.Bounds{}
|
|
}
|
|
var bounds hierarchy.Bounds
|
|
for _, element := range tree.Elements {
|
|
if element.Bounds.Right > bounds.Right {
|
|
bounds.Right = element.Bounds.Right
|
|
}
|
|
if element.Bounds.Bottom > bounds.Bottom {
|
|
bounds.Bottom = element.Bounds.Bottom
|
|
}
|
|
}
|
|
return bounds
|
|
}
|
|
|
|
// clampGestureToSafeArea keeps a swipe's origin below the top status strip,
|
|
// where a downward drag pulls the notification shade over the app. Runs force
|
|
// 3-button navigation (ForceThreeButtonNav), which disables the side back and
|
|
// bottom home gestures at the OS level; on-device probing confirmed side and
|
|
// bottom origins then no longer drift, so the shade is the only edge gesture a
|
|
// swipe can still trigger. Origin and destination are otherwise only kept on
|
|
// screen. With an unknown screen size the coordinates pass through unchanged.
|
|
func clampGestureToSafeArea(fromX, fromY, toX, toY int, screen hierarchy.Bounds) (int, int, int, int) {
|
|
width, height := screen.Width(), screen.Height()
|
|
if width <= 0 || height <= 0 {
|
|
return fromX, fromY, toX, toY
|
|
}
|
|
// Translate the whole segment when the origin is in the top margin, rather
|
|
// than clamping the origin alone, which could push it past the destination
|
|
// and reverse a near-top scroll.
|
|
marginY := height / 12
|
|
if shortfall := (screen.Top + marginY) - fromY; shortfall > 0 {
|
|
fromY += shortfall
|
|
toY += shortfall
|
|
}
|
|
clamp := func(value, low, high int) int {
|
|
if value < low {
|
|
return low
|
|
}
|
|
if value > high {
|
|
return high
|
|
}
|
|
return value
|
|
}
|
|
fromX = clamp(fromX, screen.Left, screen.Right)
|
|
fromY = clamp(fromY, screen.Top, screen.Bottom)
|
|
toX = clamp(toX, screen.Left, screen.Right)
|
|
toY = clamp(toY, screen.Top, screen.Bottom)
|
|
return fromX, fromY, 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, or (when reread is set) whether a second
|
|
// hierarchy read disagreed with the first. 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,
|
|
reread bool,
|
|
) (tree *hierarchy.Tree, png []byte, transitional bool, err error) {
|
|
var pngBytes []byte
|
|
var previousJSON string
|
|
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 || !tree.Transitional() {
|
|
break
|
|
}
|
|
// A tree unchanged since the previous attempt is a settled state
|
|
// that merely matches the heuristic (persistent overlay, both route
|
|
// ids alive at rest), not a cross-fade in flight: verify it instead
|
|
// of burning the retry budget and skipping the verifier forever.
|
|
if attempt > 0 && hierarchyJSON == previousJSON {
|
|
break
|
|
}
|
|
previousJSON = hierarchyJSON
|
|
if attempt == transitionalRetryAttempts-1 {
|
|
transitional = true
|
|
break
|
|
}
|
|
timer := time.NewTimer(transitionalRetrySleep)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
break retryLoop
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
if reread && err == nil && !transitional && changedOnReread(ctx, options, logger, stepIndex, tree) {
|
|
transitional = true
|
|
}
|
|
if len(pngBytes) > 0 {
|
|
if writeErr := options.TraceWriter.WriteScreenshot(stepIndex, pngBytes); writeErr != nil {
|
|
logger.Warn("screenshot write failed", "step", stepIndex, "err", writeErr)
|
|
}
|
|
}
|
|
return tree, pngBytes, transitional, err
|
|
}
|
|
|
|
// changedOnReread reads the hierarchy once more and reports whether the screen
|
|
// changed shape while we were looking at it. A Compose route can settle before
|
|
// its content composes (a lazy list mounts over several frames, a query lands a
|
|
// frame late), and a tree read in that window describes a screen that is still
|
|
// filling in. Two reads a read apart are the cheapest thing that can see it
|
|
// happening: the round trip IS the interval, so there is no sleep here.
|
|
//
|
|
// The comparison only means anything because the Hierarchy RPC serves the tree
|
|
// the snapshot's own read produces (see snapshotTree in the sidecar). Off the
|
|
// bare device read it does not: with an IME standing open, the snapshot answers
|
|
// with 134 nodes and the bare read with 489, and the pair then differs over
|
|
// whether the sidecar closed a keyboard between them rather than over anything
|
|
// the app did.
|
|
//
|
|
// Waiting for the change to stop was measured on an API 34 device and refused:
|
|
// a 750ms-quiet poll capped at 2s cost a median 1434ms against 76ms for one
|
|
// read, hit its cap on every frame it fired for, and still handed back a frame
|
|
// that might be filling. Detecting is what the runner can act on, because a
|
|
// step it declines to verify is at worst a missed conviction, never a false
|
|
// one.
|
|
//
|
|
// A read that fails reports no change. Nothing about a dropped RPC says the
|
|
// screen was moving, and skipping verification on it would quietly spend the
|
|
// run's evidence on a flaky link.
|
|
func changedOnReread(
|
|
ctx context.Context,
|
|
options Options,
|
|
logger *slog.Logger,
|
|
stepIndex int,
|
|
first *hierarchy.Tree,
|
|
) bool {
|
|
// An empty tree is skipped by the caller anyway, so the read buys nothing.
|
|
if first == nil || len(first.Elements) == 0 {
|
|
return false
|
|
}
|
|
hierarchyJSON, err := options.Driver.Hierarchy(ctx)
|
|
if err != nil {
|
|
logger.Warn("second hierarchy read failed", "step", stepIndex, "err", err)
|
|
return false
|
|
}
|
|
second, err := hierarchy.Parse(hierarchyJSON)
|
|
if err != nil || second == nil {
|
|
logger.Warn("second hierarchy parse failed", "step", stepIndex, "err", err)
|
|
return false
|
|
}
|
|
if structuralShape(first) == structuralShape(second) {
|
|
return false
|
|
}
|
|
logger.Warn("screen changed between two reads; skipping verifier",
|
|
"step", stepIndex, "nodes", len(first.Elements), "then", len(second.Elements))
|
|
return true
|
|
}
|
|
|
|
// structuralShape renders what is on screen as its nodes' identities in tree
|
|
// order: how many there are, and which ids and classes they carry.
|
|
//
|
|
// Text and bounds are deliberately absent. A measure pass that moves pixels is
|
|
// not a screen still composing, and neither is a value arriving into a node
|
|
// that already exists, which this cannot tell apart from a clock ticking. This
|
|
// decides whether a property gets to judge at all, so it reads only what a
|
|
// change in what is on screen can move: a detector that fires on every step of
|
|
// a screen with a timer on it would leave the run green and vacuous, which is
|
|
// worse than the composition it set out to catch. The trade is measured rather
|
|
// than assumed: over 100 folio steps on an API 35 emulator, text moved under
|
|
// an unchanged shape on 1 step, and the shape itself moved on 1 other.
|
|
//
|
|
// TestRunner_OnlyAChangeOfShapeCostsAStepItsVerdict is what holds the line:
|
|
// adding either field back to the shape turns one of its cases red.
|
|
func structuralShape(tree *hierarchy.Tree) string {
|
|
var shape strings.Builder
|
|
for _, element := range tree.Elements {
|
|
shape.WriteString(element.ResourceID)
|
|
shape.WriteByte(0x1f)
|
|
shape.WriteString(element.Class)
|
|
shape.WriteByte(0x1e)
|
|
}
|
|
return shape.String()
|
|
}
|
|
|
|
// driverIsAndroid asks the driver what it is, once per run, so the step loop
|
|
// never repeats the RPC. It gates the reread: #75 is about Compose composition,
|
|
// and web and iOS have their own settle paths and no measurement saying an
|
|
// extra hierarchy read there is cheap. An unreadable answer is not android.
|
|
func driverIsAndroid(ctx context.Context, options Options, logger *slog.Logger) bool {
|
|
health, err := options.Driver.Health(ctx)
|
|
if err != nil {
|
|
logger.Warn("health read failed; not rereading the hierarchy", "err", err)
|
|
return false
|
|
}
|
|
return health.Platform == "android"
|
|
}
|
|
|
|
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, verifier.ActionKindDoubleTap, verifier.ActionKindLongPress:
|
|
traceAction.Selector = action.On
|
|
stampSelectorTarget(traceAction, action, tree)
|
|
case verifier.ActionKindInputText:
|
|
traceAction.Text = action.Text
|
|
traceAction.Selector = action.On
|
|
stampSelectorTarget(traceAction, action, tree)
|
|
case verifier.ActionKindSwipe:
|
|
traceAction.FromX = action.FromX
|
|
traceAction.FromY = action.FromY
|
|
traceAction.ToX = action.ToX
|
|
traceAction.ToY = action.ToY
|
|
traceAction.DurationMillis = action.DurationMillis
|
|
traceAction.X = 0
|
|
traceAction.Y = 0
|
|
case verifier.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:
|
|
traceAction.DurationMillis = action.DurationMillis
|
|
}
|
|
return traceAction
|
|
}
|
|
|
|
// stampSelectorTarget records the element bounds the selector resolved to and
|
|
// derives the tap point through resolveCoordinates, the same rule applyAction
|
|
// dispatches with, so the trace can never record a different point than the
|
|
// one tapped.
|
|
func stampSelectorTarget(traceAction *trace.Action, action verifier.Action, tree *hierarchy.Tree) {
|
|
if action.On != "" && tree != nil {
|
|
if element := tree.Find(action.On); element != nil {
|
|
bounds := element.Bounds
|
|
traceAction.ResolvedBounds = &trace.BoundsRecord{
|
|
X: bounds.Left,
|
|
Y: bounds.Top,
|
|
Width: bounds.Width(),
|
|
Height: bounds.Height(),
|
|
}
|
|
}
|
|
}
|
|
if x, y, ok := resolveCoordinates(action, tree); ok {
|
|
traceAction.TapPoint = &trace.PointRecord{X: x, Y: y}
|
|
}
|
|
}
|
|
|
|
func captureMetrics(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) *trace.Metrics {
|
|
if options.BundleID == "" {
|
|
return nil
|
|
}
|
|
sample, err := options.Driver.Metrics(ctx, options.BundleID)
|
|
if err != nil {
|
|
logger.Warn("metrics capture failed", "step", stepIndex, "err", err)
|
|
return nil
|
|
}
|
|
if sample.CPUPercent == 0 && sample.HeapBytes == 0 && sample.TotalMemoryBytes == 0 {
|
|
return nil
|
|
}
|
|
return &trace.Metrics{
|
|
CPUPercent: sample.CPUPercent,
|
|
HeapBytes: sample.HeapBytes,
|
|
TotalMemoryBytes: sample.TotalMemoryBytes,
|
|
}
|
|
}
|
|
|
|
// violationRecords groups newly-violated properties by the step their witness
|
|
// attributes the violation to (the causing step), falling back to the
|
|
// detection step for properties without a witness. Records are ordered by
|
|
// step; properties keep the sorted order NewlyViolatedProperties produced.
|
|
func violationRecords(properties []string, witnesses map[string]trace.Witness, detectionStep int) []ViolationRecord {
|
|
byStep := map[int][]string{}
|
|
for _, name := range properties {
|
|
step := detectionStep
|
|
if witness, ok := witnesses[name]; ok && witness.Step > 0 {
|
|
step = witness.Step
|
|
}
|
|
byStep[step] = append(byStep[step], name)
|
|
}
|
|
records := make([]ViolationRecord, 0, len(byStep))
|
|
for _, step := range slices.Sorted(maps.Keys(byStep)) {
|
|
records = append(records, ViolationRecord{StepIndex: step, Properties: byStep[step]})
|
|
}
|
|
return records
|
|
}
|
|
|
|
// collectWitnesses gathers the violation witness for each newly-violated
|
|
// property, logs its cause, and returns them keyed by property name for the
|
|
// trace. Properties without a captured witness are skipped. stepIndex is the
|
|
// trace line the witness lands on, and stands in as the detection step for a
|
|
// verifier that observed no labeled step (a run-end finalize).
|
|
func collectWitnesses(verifierInstance *verifier.Verifier, properties []string, logger *slog.Logger, stepIndex int) map[string]trace.Witness {
|
|
if len(properties) == 0 {
|
|
return nil
|
|
}
|
|
witnesses := map[string]trace.Witness{}
|
|
for _, name := range properties {
|
|
witness := verifierInstance.Witness(name)
|
|
if witness == nil {
|
|
continue
|
|
}
|
|
detectedStep := witness.DetectedStep
|
|
if detectedStep == 0 {
|
|
detectedStep = stepIndex
|
|
}
|
|
logger.Warn("property violated",
|
|
"step", witness.Step, "detected_step", detectedStep,
|
|
"property", name, "reason", witness.Reason, "error", witness.IsError)
|
|
witnesses[name] = trace.Witness{
|
|
Reason: witness.Reason,
|
|
IsError: witness.IsError,
|
|
Step: witness.Step,
|
|
DetectedStep: detectedStep,
|
|
Extractors: witness.Extractors,
|
|
}
|
|
}
|
|
if len(witnesses) == 0 {
|
|
return nil
|
|
}
|
|
return witnesses
|
|
}
|
|
|
|
func encodeExtractorChanges(changes map[string]verifier.ExtractorChange) map[string]trace.ExtractorChange {
|
|
if len(changes) == 0 {
|
|
return nil
|
|
}
|
|
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),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func encodeResiduals(residuals map[string]ltl.Formula) (map[string]json.RawMessage, error) {
|
|
if len(residuals) == 0 {
|
|
return nil, nil
|
|
}
|
|
encoded := make(map[string]json.RawMessage, len(residuals))
|
|
var firstErr error
|
|
for name, formula := range residuals {
|
|
body, err := json.Marshal(formula)
|
|
if err != nil {
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
encoded[name] = body
|
|
}
|
|
return encoded, firstErr
|
|
}
|
|
|
|
// maxConsecutiveApplyFailures bounds how many transient apply failures in a
|
|
// row the run tolerates before aborting. One or two absorb a runner restart;
|
|
// an unbroken streak means the device is wedged and the rest of the budget
|
|
// would be spent doing nothing.
|
|
const maxConsecutiveApplyFailures = 3
|
|
|
|
// isWDADrop reports that the sidecar could not restart the iOS XCTest
|
|
// runner: the channel is gone for good and the run must abort. Transient
|
|
// drops are classified by the sidecar itself (it reconnects and surfaces
|
|
// UNAVAILABLE), so matching on raw exception text like "ConnectException"
|
|
// here would kill runs the sidecar already recovered.
|
|
func isWDADrop(err error) bool {
|
|
return strings.Contains(err.Error(), "WDA reconnect failed")
|
|
}
|