mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
* feat(trace): extend Step/Action/Meta schema for inspect UI
Add Step.Hierarchy, Step.Residuals, Action.Selector/ResolvedBounds/TapPoint,
Meta.EndedAt and JSON tags on hierarchy.Element/Bounds/Tree so trace.jsonl
can drive the upcoming uatu inspect web UI.
* test(trace): cover EndedAt + new step fields round-trip
* feat(ltl): MarshalJSON for Formula AST + Evaluator.Residual()
Each Formula concrete type now serializes to a closed-set residual node
(true/false/not/and/or/implies/always/now/next/eventually/predicate/error)
that mirrors the TS spec API surface. Evaluator.Residual() folds pending
obligations into a single Formula so the runner can stamp one residual
per property per step into trace.jsonl.
* feat(runner): stamp residuals, hierarchy, selector targets, ended_at
Each Step now carries the captured hierarchy, per-property residual ASTs,
and (for Tap/InputText) the selector + resolved bounds + tap point. The
test_run command writes meta.ended_at on graceful shutdown so the inspect
UI can distinguish completed runs from in-progress ones.
* feat(inspect): scaffold embed dist for SPA assets
Stage 2 stub for the inspect server. Real web bundle gets wired in
Stage 4 (Makefile copies web/dist into internal/inspect/dist).
* chore(web): ignore web/ build output in root .gitignore
* chore(web): add bun + vite + vitest scaffold config
* feat(web): monochrome design tokens, typography, app shell CSS
* chore(web): placeholder for self-hosted JetBrains Mono fonts
* feat(web): index.html entry with style links and root mount
* feat(web): typescript types mirroring run/step trace schema
* feat(web): typed fetchers for runs/steps/screenshots
* feat(web): App shell with router and run/step routes
* feat(inspect): runs scan, lazy step parse, mtime-aware cache
* feat(web): RunList route with table, loading, and error states
* feat(web): RunDetail route shell with three placeholder panels
* feat(inspect): fsnotify-backed runs watcher with debounce
* fix(web): use jest-dom/vitest entry so matchers register
* test(web): cover listRuns happy path and error response
* test(web): render RunList with mocked fetch and assert row
* chore(web): commit bun lockfile
* feat(inspect): http handlers for runs/steps/screenshots/SSE
* test(inspect): cover handlers, screenshot whitelist, SSE, dev proxy
* feat(cmd): add 'uatu inspect' subcommand
* fix(web): align TS types with snake_case wire format
Go inspect server serializes RunSummary, StepSummary, Step, Meta with
snake_case JSON tags (matching the on-disk trace.jsonl/meta.json). Update
the TS types and consumers to match so API responses parse without
runtime undefined fields. Action keeps resolvedBounds/tapPoint as camelCase
because those keys were defined that way in the trace schema.
* feat(web): add ActionList panel for run-detail step navigation
* feat(web): add SnapshotTable panel with diff highlighting
Renders snapshots dictionary as a flat sorted dotted-path tree.
Changed leaves get data-changed plus a hover title with the previous value.
* test(web): cover SnapshotTable rendering and diff behavior
Eight cases: empty state, sort order, dotted-path expansion,
changed/unchanged/missing-previous flagging, and inline-vs-expanded arrays.
* feat(web): add Screenshot panel with bounds and tap overlays
Center column of run-detail page. Renders the device screenshot
scaled to fit, with an SVG overlay drawing resolvedBounds as a
violation-colored rect, tapPoint as a contrast ring, and swipes
as an arrow. Falls back to a placeholder when src is missing or
the image fails to load.
* fix(web): guard scrollIntoView call for jsdom compatibility
* test(web): cover Screenshot panel rendering and overlays
* test(web): cover ActionList rendering, selection, keyboard, and markers
* feat(web): add ExceptionsPanel component
* test(web): add ExceptionsPanel tests
* feat(web): add Timeline panel with property swimlanes
Renders SVG swimlanes per property with violated/pending/holds cells,
action-marker dots, click-to-seek, and a selected-step highlight bar.
* test(web): cover Timeline empty state, cells, status, click, highlight
* feat(web): add ResidualNode recursive AST renderer
* test(web): cover ResidualNode operators, predicate, and error chip
* feat(web): add ViolationsPanel with status badges and jump button
* test(web): cover ViolationsPanel rows, status grouping, and jump button
* test(web): register testing-library cleanup globally
All six panel test files added local afterEach(cleanup); centralize it in
the shared setup so future tests inherit DOM isolation by default.
* feat(web): hooks for url/keyboard/theme/sse
* feat(web): wire all panels into run-detail with phone-dominant grid
ActionList left, Screenshot center, Snapshots/Properties/Exceptions
stacked right, Timeline bottom. URL-synced step index (useStep), keyboard
shortcuts (j/k/arrows/g/G/.), light+dark theme toggle stored in
localStorage, SSE auto-refresh on the run index.
* test(web): add three reference run fixtures (clean, violation, exception)
* build: web targets in Makefile + bun in CI; docs(inspect)
- Makefile: web-build/web-dev/inspect-dev/test-web targets; uatu and
install now depend on web-build so the binary embeds the latest SPA.
- ci.yml: setup-bun + cache; existing make test now runs web typecheck +
vitest as part of the full suite.
- docs/manual/inspect.md: panel reference, keyboard shortcuts, URLs.
- docs/manual/cli.md: document uatu inspect.
- README: link to inspect docs.
* feat(runner): capture a screenshot per step
The driver already exposes Screenshot(ctx), but the runner never called
it. Each step now writes <run>/screenshots/step-NNNNN.png right after
the trace line, using the same failure-is-a-warning posture as other
best-effort observability hooks. Makes the inspect UI's center panel
actually useful.
* feat(inspect): include action_label in StepSummary
Tap/InputText/Swipe/PressKey/Wait each get a short human-readable
label (selector, quoted text, swipe direction, key name, duration) so
the action list panel can render readable rows instead of just 'Tap'
with no target.
* test(inspect): accept either #app or #root in SPA shell fallback
* feat(web): render action_label and screen in ActionList rows
Step rows now show 'Tap id:save', 'InputText "alice"', 'Swipe up',
'PressKey back', etc. Steps with no action fall back to
'observe @ <screen>' so the list reads as a flow instead of a wall
of '--' placeholders.
* feat(sidecar): implement screencap for android driver backend
Was stubbed to return an empty byte array, which made the runner's
per-step screenshot capture a no-op. Shell out to 'adb exec-out
screencap -p' and stream the PNG bytes back. Width/height stay zero
because the PNG header carries them; the Go side can parse if needed.
* feat(proto): add Metrics RPC for per-step CPU and memory capture
* feat(driver): Metrics(bundleID) returns cpu_percent + heap/total bytes
* feat(sidecar): implement Metrics RPC via adb top + /proc/<pid>/status
* feat(runner): capture metrics + before/after screenshots per step
Each step now writes step-NNNNN.png (before applyAction) and
step-NNNNN-after.png (after the action + wait-for-idle). The runner
samples Driver.Metrics(bundleID) before writing the trace line and
stamps Step.Metrics with cpu_percent, heap_bytes, total_memory_bytes
so the inspect UI can chart CPU and heap over the run.
* fix(runner,sidecar): measure CPU across step via /proc stat delta
'top -d 0.3 -n 2' measures CPU in a 300ms window that coincides with
the SDK-paused app, always reporting 0%. Switch to reading
/proc/<pid>/stat utime+stime and computing the delta between successive
calls; the natural step cadence gives a 2-5s measurement window that
captures the action response and render cycle. Also moved the sample
to before snapshotStep so the delta starts before the SDK pause.
* feat(web): add Metrics type for per-step cpu and memory
* refactor(web): replace --accent-change with --accent-positive token
* refactor(web): recolor chip-progress as neutral outlined chip
* refactor(web): use neutral border for changed snapshot rows
* feat(web): add MetricsChart panel with HEAP and CPU lanes
SVG-based time-series chart rendering heap bytes and CPU percent per
step across two stacked lanes, with a shared step axis below. Lines are
monochrome; a vertical highlight marks the selected step; per-step hit
rects make any click seek to that step.
* feat(web): revamp ActionList with tag targets, elapsed time, and expandable rows
Render selector-based Tap actions as <tag/> markup, show zero-padded MM:SS.mmm
elapsed time per row, and expand the active row with Position/Content sub-rows
when a full Step is available. Adds formatActionRow/formatElapsed helpers and
covers both with unit tests.
* fix(runner): stop copying Tap selector into action.text
The 'Content' inspect row should show the user-supplied text for
InputText actions and stay empty for Taps. Previously the runner copied
action.On into traceAction.Text for both, so the inspect UI showed the
selector as the tap's 'Content'.
* fix(web): use text-muted for swipe arrow after accent-change removal
* fix(web): snapshot values truncate with ellipsis + title tooltip
Long JSON values were breaking one character per line due to
overflow-wrap:anywhere in a narrow column. Switch to single-line ellipsis
with the full value exposed via the title attribute on hover.
* feat(web): state-before/after columns + metrics chart at bottom
RunDetail now renders a four-column grid:
actions | state-before | state-after | side (exceptions + timeline)
with MetricsChart spanning the bottom row. Each state column shows its
own screenshot (step-NNNNN.png vs step-NNNNN-after.png), snapshot table,
and violations panel. ActionList now receives runStartMillis and the
selected Step so the active row can expand Position/Content sub-rows.
* fix(web): skip zero-value ticks + add exception markers to metrics
HEAP '0B' and CPU '100%' labels overlapped at the lane boundary. Drop
the bottom-of-range tick on both lanes (baseline is implied) and widen
LANE_GAP so the remaining labels have breathing room. Accept an
exceptionStepIndices prop and draw a dashed red vertical line at each
to surface exception spikes directly on the CPU/heap chart.
* fix(web): let action body column shrink below its content
Required minmax(0, 1fr) so the row grid honours the column's min-size of
0 instead of the implicit 'auto', preventing the action-list from
overflowing its parent when the target string is long.
* feat(web): bigger state screenshots + single properties row
Collapse snapshots into a summary chip ('SNAPSHOTS · N violations') so
the screenshot fills its state card. Deduplicate ViolationsPanel —
show it once in a new full-width 'properties' row between the state
cards and the timeline. Drop the right sidebar; exceptions now surface
as dashed markers on the metrics chart with the ExceptionsPanel only
rendering when there are actual exceptions to report.
* feat(web): add minimal Tabs component
Monochrome tab strip with underline-on-active. Used by state-before
and state-after cards to swap between Screenshot, Snapshots, Properties.
Pane scrolls internally so the outer grid stays fixed-height.
* feat(web): fold timeline into MetricsChart as STEPS lane
Adds a thin per-step status row above HEAP showing violated (red),
pending (dim gray) or holds (green-tinted). Extends highlight +
exception markers to span the status lane. Frees a whole row in the
detail grid so the page can fit in 100vh.
* refactor(web): tabbed state cards, drop standalone Timeline panel
State-before/after now use Tabs (Screenshot / Snapshots / Properties,
default Screenshot). Removes the dedicated timeline row; status lane
lives on the metrics chart. Banner is gone from the shell.
* feat(web): lock app shell to 100vh with no page scroll
html/body/#root fill the viewport, body gets overflow:hidden, and the
detail grid uses minmax(0, 1fr) rows so inner panels own their scroll.
Tightens toolbar + panel padding for a denser feel.
* feat(web): arrow-key nav + badges on Tabs (WAI-ARIA tablist)
Roving tabindex, ArrowLeft/Right/Up/Down/Home/End navigation, explicit
aria-selected/aria-controls/id wiring, and support for an optional
badge inside each tab (used for violation counts).
* feat(web): ViolationsPanel supports violationsOnly filter
* feat(web): ActionList arrow-key nav + listbox semantics + smaller font
Promote the list to role=listbox with role=option rows; roving tabindex
lets ArrowUp/Down (and Home/End) seek between steps with focus. Font
size dropped to 11px and padding tightened so long selector-tag labels
fit in the 340px actions column.
* fix(web): useKeyboardNav yields arrow keys to tablist/listbox targets
Previously pressing ArrowRight on a focused tab switched tabs AND
advanced the step. Skip arrow handling when the event target is inside
an element with an arrow-owning ARIA role.
* feat(web): fourth 'Violations' tab + wider actions + shorter metrics
Adds a Violations tab to each state card showing only violated properties
(with count badge on the tab label when > 0). Actions column widened
from 280px to 340px, bottom metrics strip trimmed from 220px to 140px
with tighter lane heights, so the whole page still fits in 100vh with
no scrollbar.
* feat(web): compact RunDetail layout using 1px borders instead of panel padding
* refactor(inspect): simplify MetricsChart to HEAP+CPU with time axis
Drop the STEPS status lane and per-sample circle markers, switch the
x-axis from step indices to mm:ss clock time, trim y-axis ticks to
min/max with compact units, rotate lane labels into the left gutter,
and replace the thin playhead line with a wider dotted red band.
Traces stay grayscale; red appears only on the playhead pattern.
* fix(web): RunList rows no longer stretch to fill viewport height
Tables inherited flex: 1 1 auto from .app-main > * and distributed extra
vertical space across rows. Override with flex: 0 0 auto + align-self.
* misc changes
* fix(web): hoist useState above early return in MetricsChart
Calling useState after an unconditional early return violates React's
Rules of Hooks: the empty-samples branch renders 0 hooks while the
populated branch calls 1. On the initial null->loaded transition of
history the hook count changes and React throws.
* fix(web): subscribe to named SSE event instead of 'message'
Server emits 'event: runs.changed' frames; the WHATWG EventSource spec
dispatches those as events of type 'runs.changed', not 'message'. The
listener registered on 'message' was never fired, so RunList never
auto-refreshed on run create/finish/delete.
* fix(inspect): unsubscribe SSE clients on disconnect
Watcher.Subscribe appended to a slice with no matching removal path,
so every closed EventSource connection leaked its channel. Over a
long-running server the slice grew unbounded and every fs event paid
O(N) iterating dead channels. Add Unsubscribe + defer it in
handleEvents.
Unsubscribe does not close the channel: broadcast snapshots the
slice without holding the mutex, so a concurrent close would race
with its non-blocking send.
* fix(trace): rename resolvedBounds/tapPoint to snake_case
Every other json tag in the trace schema (from_x, duration_millis,
bundle_sha256, etc.) uses snake_case. The two new Action fields
introduced with the inspect UI broke that pattern. Rename them
before the format ships to external consumers.
* chore(web): drop vitest and remove UI tests from CI
No UI tests wanted in web. Removes vitest, jsdom, testing-library
devDeps and the vitest.setup.ts + vite.config.ts test block.
Makefile test-web becomes web-typecheck (typecheck only).
Fixes CI failure where `vitest run` exits 1 with no test files.
* chore(make): dedupe sidecar embed and drop recursive make
Make $(SIDECAR_JAR) the real recipe and $(SIDECAR_EMBED) a file
target, so uatu/install/inspect-dev share one copy step and
sidecar/release-cli just depend on the jar instead of re-invoking make.
536 lines
15 KiB
Go
536 lines
15 KiB
Go
package verifier
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
|
|
"github.com/priyanshujain/uatu/internal/hierarchy"
|
|
"github.com/priyanshujain/uatu/internal/ltl"
|
|
)
|
|
|
|
type Verifier struct {
|
|
runtime *goja.Runtime
|
|
extractors []*extractorState
|
|
formulas []*formulaState
|
|
formulaSpecs []formulaSpec
|
|
|
|
properties map[string]int // property name -> formula-spec index
|
|
actionGenerator goja.Value
|
|
|
|
evaluators map[string]*ltl.Evaluator
|
|
|
|
lastTree *hierarchy.Tree
|
|
lastAction *Action
|
|
lastLogs []LogEntry
|
|
lastExceptions []Exception
|
|
stepTime time.Time
|
|
runStart time.Time
|
|
|
|
rng *rand.Rand
|
|
}
|
|
|
|
type Option func(*Verifier)
|
|
|
|
func WithRand(rng *rand.Rand) Option {
|
|
return func(v *Verifier) { v.rng = rng }
|
|
}
|
|
|
|
func New(options ...Option) (*Verifier, error) {
|
|
verifier := &Verifier{
|
|
runtime: goja.New(),
|
|
properties: map[string]int{},
|
|
evaluators: map[string]*ltl.Evaluator{},
|
|
rng: rand.New(rand.NewPCG(0, 0)),
|
|
}
|
|
for _, option := range options {
|
|
option(verifier)
|
|
}
|
|
if err := verifier.installRuntimeBindings(); err != nil {
|
|
return nil, fmt.Errorf("install bindings: %w", err)
|
|
}
|
|
return verifier, nil
|
|
}
|
|
|
|
// Load executes the bundled spec source. The spec is expected to assign its
|
|
// property formulas to globalThis.properties and its root action generator
|
|
// to globalThis.actions.
|
|
func (v *Verifier) Load(source string) error {
|
|
if _, err := v.runtime.RunString(source); err != nil {
|
|
return fmt.Errorf("run spec: %w", err)
|
|
}
|
|
|
|
propertiesValue := v.runtime.GlobalObject().Get("properties")
|
|
if propertiesValue != nil && !goja.IsUndefined(propertiesValue) && !goja.IsNull(propertiesValue) {
|
|
propertiesObject := propertiesValue.ToObject(v.runtime)
|
|
for _, name := range propertiesObject.Keys() {
|
|
handle := propertiesObject.Get(name).ToObject(v.runtime)
|
|
if handle == nil {
|
|
return fmt.Errorf("property %q is not an object", name)
|
|
}
|
|
specIndex, ok := v.extractSpecIndex(handle)
|
|
if !ok {
|
|
return fmt.Errorf("property %q was not produced by always()", name)
|
|
}
|
|
formula, err := v.buildFormula(specIndex)
|
|
if err != nil {
|
|
return fmt.Errorf("property %q: %w", name, err)
|
|
}
|
|
v.properties[name] = specIndex
|
|
v.evaluators[name] = ltl.NewEvaluator(formula)
|
|
}
|
|
}
|
|
|
|
if actionsValue := v.runtime.GlobalObject().Get("actions"); actionsValue != nil && !goja.IsUndefined(actionsValue) && !goja.IsNull(actionsValue) {
|
|
v.actionGenerator = actionsValue
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// buildFormula walks the formula-spec registry and produces a Go ltl.Formula
|
|
// tree rooted at the given spec index. Specs built at the top level are
|
|
// always wrapped in Always unless the top-level spec is already an Always.
|
|
func (v *Verifier) buildFormula(rootIndex int) (ltl.Formula, error) {
|
|
inner, err := v.buildFormulaNode(rootIndex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, ok := inner.(ltl.AlwaysFormula); ok {
|
|
return inner, nil
|
|
}
|
|
return ltl.Always(inner), nil
|
|
}
|
|
|
|
func (v *Verifier) buildFormulaNode(index int) (ltl.Formula, error) {
|
|
if index < 0 || index >= len(v.formulaSpecs) {
|
|
return nil, fmt.Errorf("formula spec index %d out of range", index)
|
|
}
|
|
spec := v.formulaSpecs[index]
|
|
switch spec.kind {
|
|
case specKindPure:
|
|
return ltl.Pure(spec.pureValue), nil
|
|
case specKindThunk:
|
|
return ltl.Thunk(v.formulaThunk(spec.predicateIndex)), nil
|
|
case specKindNow:
|
|
child, err := v.buildFormulaNode(spec.childA)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ltl.Now(child), nil
|
|
case specKindNext:
|
|
child, err := v.buildFormulaNode(spec.childA)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ltl.Next(child), nil
|
|
case specKindEventually:
|
|
child, err := v.buildFormulaNode(spec.childA)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
formula := ltl.EventuallyFormula{Inner: child}
|
|
if spec.hasStepBound {
|
|
formula.StepBound = spec.stepBound
|
|
formula.HasStepBound = true
|
|
}
|
|
if spec.duration > 0 {
|
|
formula.Duration = spec.duration
|
|
}
|
|
return formula, nil
|
|
case specKindImplies:
|
|
left, err := v.buildFormulaNode(spec.childA)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
right, err := v.buildFormulaNode(spec.childB)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ltl.Implies(left, right), nil
|
|
case specKindOr:
|
|
left, err := v.buildFormulaNode(spec.childA)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
right, err := v.buildFormulaNode(spec.childB)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ltl.Or(left, right), nil
|
|
case specKindAnd:
|
|
left, err := v.buildFormulaNode(spec.childA)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
right, err := v.buildFormulaNode(spec.childB)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ltl.And(left, right), nil
|
|
case specKindNot:
|
|
child, err := v.buildFormulaNode(spec.childA)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ltl.Not(child), nil
|
|
case specKindAlways:
|
|
child, err := v.buildFormulaNode(spec.childA)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ltl.Always(child), nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown formula spec kind %d", spec.kind)
|
|
}
|
|
}
|
|
|
|
// PushSnapshot updates the JS-side state and refreshes every extractor's
|
|
// current/previous values in registration order. Passing a nil tree is
|
|
// allowed and yields an empty ax scope.
|
|
func (v *Verifier) PushSnapshot(input SnapshotInput) error {
|
|
v.lastTree = input.Tree
|
|
v.lastAction = input.LastAction
|
|
v.lastLogs = input.Logs
|
|
v.lastExceptions = input.Exceptions
|
|
v.stepTime = input.StepTime
|
|
if v.runStart.IsZero() {
|
|
v.runStart = input.RunStart
|
|
}
|
|
|
|
state, err := stateObject(v.runtime, stateInput{
|
|
snapshots: input.Snapshots,
|
|
tree: input.Tree,
|
|
lastAction: input.LastAction,
|
|
stepTime: input.StepTime,
|
|
runStart: v.runStart,
|
|
logs: input.Logs,
|
|
exceptions: input.Exceptions,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("build state: %w", err)
|
|
}
|
|
if err := v.runtime.GlobalObject().Set("state", state); err != nil {
|
|
return fmt.Errorf("set state: %w", err)
|
|
}
|
|
for index, extractor := range v.extractors {
|
|
previous := extractor.handle.Get("current")
|
|
_ = extractor.handle.Set("previous", previous)
|
|
newValue, err := extractor.getter(goja.Undefined(), state)
|
|
if err != nil {
|
|
return fmt.Errorf("extractor %d: %w", index, err)
|
|
}
|
|
_ = extractor.handle.Set("current", newValue)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SnapshotInput bundles everything a step feeds into the verifier. Fields
|
|
// other than Snapshots are optional; callers that only have snapshots can
|
|
// populate Snapshots alone and leave the rest zero.
|
|
type SnapshotInput struct {
|
|
Snapshots Snapshots
|
|
Tree *hierarchy.Tree
|
|
LastAction *Action
|
|
StepTime time.Time
|
|
RunStart time.Time
|
|
Logs []LogEntry
|
|
Exceptions []Exception
|
|
}
|
|
|
|
// LogEntry mirrors a logcat line captured between steps.
|
|
type LogEntry struct {
|
|
UnixMillis int64
|
|
Level string
|
|
Tag string
|
|
Message string
|
|
}
|
|
|
|
// Exception mirrors an SDK-captured uncaught throwable.
|
|
type Exception struct {
|
|
Class string
|
|
Message string
|
|
StackTrace string
|
|
UnixMillis int64
|
|
}
|
|
|
|
// EvaluateProperties returns each registered property's running verdict
|
|
// after the most recent PushSnapshot. The step time passed in PushSnapshot is
|
|
// forwarded to each evaluator so deadline-bound operators see the snapshot's
|
|
// wall clock rather than time.Now().
|
|
func (v *Verifier) EvaluateProperties() map[string]ltl.Verdict {
|
|
verdicts := map[string]ltl.Verdict{}
|
|
stepTime := v.stepTime
|
|
if stepTime.IsZero() {
|
|
stepTime = time.Now()
|
|
}
|
|
for name, evaluator := range v.evaluators {
|
|
verdicts[name] = evaluator.ObserveAt(stepTime)
|
|
}
|
|
return verdicts
|
|
}
|
|
|
|
// Residuals returns the residual formula for each registered property after
|
|
// the most recent EvaluateProperties call. Properties that errored during
|
|
// predicate evaluation surface as ErrorFormula so the inspect UI can render
|
|
// "predicate threw" inline.
|
|
func (v *Verifier) Residuals() map[string]ltl.Formula {
|
|
residuals := map[string]ltl.Formula{}
|
|
for name, evaluator := range v.evaluators {
|
|
if predicateErr := v.PredicateError(name); predicateErr != nil {
|
|
residuals[name] = ltl.ErrorFormula{Message: predicateErr.Error()}
|
|
continue
|
|
}
|
|
residuals[name] = evaluator.Residual()
|
|
}
|
|
return residuals
|
|
}
|
|
|
|
// NextAction resolves the root action generator into a single Action.
|
|
// Returns ErrNoAction when no branch of the generator produces one after a
|
|
// small number of retries. Retrying avoids wedging when most branches of a
|
|
// weighted generator produce no action on the current screen (e.g. a gated
|
|
// login-phone generator when the app is already past login).
|
|
func (v *Verifier) NextAction() (Action, error) {
|
|
if v.actionGenerator == nil {
|
|
return Action{}, ErrNoAction
|
|
}
|
|
const maxRetries = 16
|
|
for range maxRetries {
|
|
action, err := v.resolveGenerator(v.actionGenerator)
|
|
if err == nil {
|
|
return action, nil
|
|
}
|
|
if !errors.Is(err, ErrNoAction) {
|
|
return Action{}, err
|
|
}
|
|
}
|
|
return Action{}, ErrNoAction
|
|
}
|
|
|
|
var ErrNoAction = errors.New("verifier: no action available")
|
|
|
|
func (v *Verifier) formulaThunk(index int) func() bool {
|
|
return func() bool {
|
|
formula := v.formulas[index]
|
|
result, err := formula.predicate(goja.Undefined())
|
|
if err != nil {
|
|
if formula.err == nil {
|
|
formula.err = err
|
|
}
|
|
return false
|
|
}
|
|
return result.ToBoolean()
|
|
}
|
|
}
|
|
|
|
// PredicateError returns the first goja error raised by any thunk in the
|
|
// named property's formula tree, or nil if none fired. Callers typically
|
|
// consult this after EvaluateProperties reports a violation to distinguish
|
|
// a genuine predicate-false verdict from a malformed spec.
|
|
func (v *Verifier) PredicateError(name string) error {
|
|
rootIndex, ok := v.properties[name]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return v.firstThunkError(rootIndex)
|
|
}
|
|
|
|
func (v *Verifier) firstThunkError(index int) error {
|
|
if index < 0 || index >= len(v.formulaSpecs) {
|
|
return nil
|
|
}
|
|
spec := v.formulaSpecs[index]
|
|
switch spec.kind {
|
|
case specKindThunk:
|
|
return v.formulas[spec.predicateIndex].err
|
|
case specKindImplies, specKindOr, specKindAnd:
|
|
if err := v.firstThunkError(spec.childA); err != nil {
|
|
return err
|
|
}
|
|
return v.firstThunkError(spec.childB)
|
|
case specKindNow, specKindNext, specKindEventually, specKindNot, specKindAlways:
|
|
return v.firstThunkError(spec.childA)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (v *Verifier) resolveGenerator(generator goja.Value) (Action, error) {
|
|
object := generator.ToObject(v.runtime)
|
|
if object == nil {
|
|
return Action{}, fmt.Errorf("generator is not an object")
|
|
}
|
|
kindValue := object.Get(tagInternalKind)
|
|
if kindValue == nil {
|
|
return Action{}, fmt.Errorf("generator missing internal kind tag")
|
|
}
|
|
switch kindValue.String() {
|
|
case internalKindActions:
|
|
generateValue := object.Get("generate")
|
|
generate, ok := goja.AssertFunction(generateValue)
|
|
if !ok {
|
|
return Action{}, fmt.Errorf("actions handle missing generate function")
|
|
}
|
|
result, err := generate(goja.Undefined())
|
|
if err != nil {
|
|
return Action{}, fmt.Errorf("generate: %w", err)
|
|
}
|
|
return v.pickFromResult(result)
|
|
case internalKindWeighted:
|
|
entries := object.Get("entries").ToObject(v.runtime)
|
|
if entries == nil {
|
|
return Action{}, fmt.Errorf("weighted handle missing entries")
|
|
}
|
|
picked, err := v.pickWeighted(entries)
|
|
if err != nil {
|
|
return Action{}, err
|
|
}
|
|
return v.resolveGenerator(picked)
|
|
case internalKindBuiltinTaps:
|
|
return v.generateRandomTap()
|
|
case internalKindBuiltinSwipes:
|
|
return v.generateRandomSwipe()
|
|
case internalKindBuiltinWaitOnce:
|
|
return Action{Kind: ActionKindWait, DurationMillis: 500}, nil
|
|
case internalKindBuiltinPressKey:
|
|
return v.generateRandomPressKey()
|
|
default:
|
|
return Action{}, fmt.Errorf("unknown generator kind %q", kindValue.String())
|
|
}
|
|
}
|
|
|
|
// generateRandomTap picks a visible, tappable element from the last
|
|
// hierarchy snapshot and returns a Tap action targeting its center.
|
|
func (v *Verifier) generateRandomTap() (Action, error) {
|
|
if v.lastTree == nil {
|
|
return Action{}, ErrNoAction
|
|
}
|
|
candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements))
|
|
for _, element := range v.lastTree.Elements {
|
|
if !element.Clickable || !element.Enabled {
|
|
continue
|
|
}
|
|
if element.Bounds.Right-element.Bounds.Left <= 0 || element.Bounds.Bottom-element.Bounds.Top <= 0 {
|
|
continue
|
|
}
|
|
candidates = append(candidates, element)
|
|
}
|
|
if len(candidates) == 0 {
|
|
return Action{}, ErrNoAction
|
|
}
|
|
picked := candidates[v.rng.IntN(len(candidates))]
|
|
x, y := picked.Bounds.Center()
|
|
return Action{Kind: ActionKindTap, X: x, Y: y}, nil
|
|
}
|
|
|
|
// generateRandomSwipe emits a swipe over a random enabled element or the
|
|
// whole screen, in a random direction. Returns ErrNoAction only when we have
|
|
// no tree to size a gesture off of.
|
|
func (v *Verifier) generateRandomSwipe() (Action, error) {
|
|
if v.lastTree == nil || len(v.lastTree.Elements) == 0 {
|
|
return Action{}, ErrNoAction
|
|
}
|
|
element := v.lastTree.Elements[v.rng.IntN(len(v.lastTree.Elements))]
|
|
cx, cy := element.Bounds.Center()
|
|
if cx <= 0 || cy <= 0 {
|
|
return Action{}, ErrNoAction
|
|
}
|
|
// Pick a direction: 0=up 1=down 2=left 3=right; magnitude 200-600 px.
|
|
magnitude := 200 + v.rng.IntN(401)
|
|
toX, toY := cx, cy
|
|
switch v.rng.IntN(4) {
|
|
case 0:
|
|
toY = cy - magnitude
|
|
case 1:
|
|
toY = cy + magnitude
|
|
case 2:
|
|
toX = cx - magnitude
|
|
case 3:
|
|
toX = cx + magnitude
|
|
}
|
|
if toX < 0 {
|
|
toX = 0
|
|
}
|
|
if toY < 0 {
|
|
toY = 0
|
|
}
|
|
return Action{
|
|
Kind: ActionKindSwipe,
|
|
FromX: cx,
|
|
FromY: cy,
|
|
ToX: toX,
|
|
ToY: toY,
|
|
DurationMillis: 250,
|
|
}, nil
|
|
}
|
|
|
|
func (v *Verifier) generateRandomPressKey() (Action, error) {
|
|
// Keep exploration gentle: only "back" for now. Home/menu would navigate
|
|
// away from the app under test.
|
|
return Action{Kind: ActionKindPressKey, Key: "back"}, nil
|
|
}
|
|
|
|
func (v *Verifier) pickFromResult(result goja.Value) (Action, error) {
|
|
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
|
return Action{}, ErrNoAction
|
|
}
|
|
object := result.ToObject(v.runtime)
|
|
if object == nil {
|
|
return Action{}, ErrNoAction
|
|
}
|
|
lengthValue := object.Get("length")
|
|
if lengthValue == nil {
|
|
return jsValueToAction(v.runtime, result)
|
|
}
|
|
length := int(lengthValue.ToInteger())
|
|
if length == 0 {
|
|
return Action{}, ErrNoAction
|
|
}
|
|
pick := v.rng.IntN(length)
|
|
return jsValueToAction(v.runtime, object.Get(fmt.Sprintf("%d", pick)))
|
|
}
|
|
|
|
func (v *Verifier) pickWeighted(entries *goja.Object) (goja.Value, error) {
|
|
lengthValue := entries.Get("length")
|
|
if lengthValue == nil {
|
|
return nil, fmt.Errorf("weighted entries missing length")
|
|
}
|
|
length := int(lengthValue.ToInteger())
|
|
if length == 0 {
|
|
return nil, ErrNoAction
|
|
}
|
|
|
|
weights := make([]float64, length)
|
|
generators := make([]goja.Value, length)
|
|
totalWeight := 0.0
|
|
for index := range length {
|
|
entry := entries.Get(fmt.Sprintf("%d", index)).ToObject(v.runtime)
|
|
if entry == nil {
|
|
return nil, fmt.Errorf("weighted entry %d not an array", index)
|
|
}
|
|
weight := entry.Get("0").ToFloat()
|
|
generator := entry.Get("1")
|
|
if weight < 0 {
|
|
weight = 0
|
|
}
|
|
weights[index] = weight
|
|
generators[index] = generator
|
|
totalWeight += weight
|
|
}
|
|
if totalWeight == 0 {
|
|
return nil, ErrNoAction
|
|
}
|
|
pick := v.rng.Float64() * totalWeight
|
|
cumulative := 0.0
|
|
for index := range length {
|
|
cumulative += weights[index]
|
|
if pick < cumulative {
|
|
return generators[index], nil
|
|
}
|
|
}
|
|
return generators[length-1], nil
|
|
}
|