From 88db9653e5db72ec0a6b873e5c06d536e3654398 Mon Sep 17 00:00:00 2001 From: pjay Date: Mon, 1 Jun 2026 12:48:51 +0530 Subject: [PATCH] refactoring default action layer (#51) * feat(hierarchy): add editable signal with native derivation * feat(chrome): emit editable flag in hierarchy dump * feat(verifier): expose editable on ax element objects * feat(spec): add editable to selector and element types * feat(verifier): register typing builtin generator * feat(verifier): typing builtin types edge-case corpus into editable fields * feat(spec): export typing builtin generator * feat(spec): add defaultActions bundle * feat(spec): export @sanderling/spec/defaults subpath * feat(folio): layer defaultActions breadth over targeted flows * test(verifier): typing builtin targets editable fields, declines otherwise * test(hierarchy): editable derivation and selector matching * test(spec): defaultActions, typing, and defaults barrel resolve * fix(testrun): alias @sanderling/spec/defaults for the bundler * test(chrome): editable flag for inputs, textarea, contenteditable * feat(spec): typing builtin for the web (V8) action path * chore(folio): auto-boot a bootable AVD in just test/install when none connected * feat(driver): add ForegroundChecker optional capability * feat(android): detect foreground package via adb dumpsys * feat(sidecar): implement ForegroundApp via adb for android * feat(runner): relaunch app when foreground escapes during exploration * fix(spec): drop hardware back from defaultActions to stay in-app * feat(spec): add DoubleTap action type and constructor * feat(spec): wire DoubleTap through web-runtime serializer * feat(verifier): bind doubleTap and decode DoubleTap actions * feat(runner): dispatch DoubleTap as two taps inside one step * test(doubleTap): cover constructor, verifier round-trip, and runner dispatch * feat(folio): add noDuplicateTxnPerStep invariant and doubleSubmitTxn action * fix(folio): track ledger row count across non-ledger steps; pin reproducer seed * feat(spec): add doubleTaps random-target builtin to defaultActions * feat(verifier): add doubleTaps random-target generator * refactor(folio): drop doubleSubmitTxn; fuzzer surfaces double-submit via defaultActions * fix(folio): make ledgerRowsSeen monotonic to suppress transient-render false positives * feat(verifier): track newly-violated property set per step Sticky `always(P)` violations re-surfaced on every step after onset, flooding traces and summaries with duplicate records. EvaluateProperties now diffs against the prior verdict map and records the onset set; a new NewlyViolatedProperties accessor exposes it so callers can emit each violation exactly once at its onset step. The verdict-map return is preserved for residual / current-verdict consumers. * refactor(runner): emit onset-only violations to trace and summary Switch the per-step violation list from the sticky verdict map to the verifier's onset set. Each property now appears exactly once across a run: at the step it first violates, not on every subsequent step where the residual stays false. Removes the dead violationNames helper. * style(verifier): use maps.Copy for verdict snapshot * fix(folio): make login spec content-driven (idempotent across re-entries) * fix(verifier): canonicalize selector strings Object/chain JS selectors used to fall through to goja's default stringification, producing "[object Object]" tags that surfaced as garbage in trace.action.selector. Emit canonical "k:v" / " > "-joined strings instead so the tag round-trips back through the hierarchy selector grammar. * refactor(folio): replace txn invariants with balanceMatchesAddedTxn Collapse noDuplicateTxnPerStep and newTxnChangesBalance into a single per-row property: every newly-appearing ledger row's signed amount must match the ledger balance delta. A double-submit lands two rows whose individual amounts cannot both equal the aggregate delta, so each row fires the property, catching both the row-count and balance-math classes of bug under one semantic invariant. * refactor(trace): drop WriteScreenshotAfter Only one screenshot per step is captured now (concurrently with hierarchy after settle), so the -after.png variant is unused. * refactor(runner): one concurrent screenshot per step Move screenshot capture into the post-action errgroup so it observes the same UI moment as the hierarchy fetch. Drop the pre-action and deferred -after captures. Skip WaitForIdle when the action is Wait since the wait itself provides settling time. * refactor(inspect-ui): use next step's screenshot for state after Each step now has one screenshot (the moment of observation). The "state after" view of step N is the same moment as step (N+1)'s observation, so reuse that file rather than expecting a separate -after.png. * feat(sidecar): structural-hash settle poll Add pollUntilStable and structuralHash helpers; wire them into the Stub, Maestro, and iOS backends' waitForIdle. The structural hash ignores bounds-only flicker (measure passes) but trips on any change in resource-id/class/content-desc/text, so a Compose cross-fade where both source and destination composables are momentarily alive no longer slips through Maestro's waitForAppToSettle and contaminates the next hierarchy fetch. * test(sidecar): cover pollUntilStable and structuralHash Verify the poll returns on two equal snapshots, after transient churn, and at the cap when never stable; assert the hash ignores bounds-only flicker and detects content changes. * feat(spec): accept optional name on extract() Add an (name, getter) overload so each extractor handle carries a debuggable label that future trace fields (per-step diffs) can key off. The web-runtime falls back to extractor_\${index} when none is supplied so existing call sites keep working unchanged. * test(spec): cover extract name overload Verify the runtime receives an undefined name in the legacy shape, the supplied name in the (name, getter) shape, and that extract("name") with no getter throws. * feat(verifier): name extractors for diff surfacing bindExtract accepts an optional name argument; falls back to extractor_N when omitted. The name is stored on extractorState alongside prev/curr value caches that the next change will use to emit per-step diffs. * chore(folio): name every extract() call Give each extractor in the Folio spec a debuggable label so the inspect UI can render extractor-value diffs at violation steps keyed by intent (ledgerRows, route, ledgerBalance, ...) rather than by registration index. * feat(verifier): track extractor value transitions Cache each extractor's prior and current JSON-encoded value during PushSnapshot; expose ChangedExtractors to surface per-step diffs the runner can emit into the trace. The first observation flushes every non-null extractor as a change so the inspect UI shows initial state breadcrumbs alongside later transitions. * test(verifier): cover ChangedExtractors diffs Verify initial snapshot reports both named and fallback-named extractors, a subsequent change surfaces prev/curr, and a no-op snapshot leaves the diff empty. * feat(trace): emit extractor_changes per step Add ExtractorChanges to trace.Step and a runner helper that converts the verifier's diff map into the trace shape. The inspect UI keys its violation breadcrumbs off this field. * feat(inspect-ui): render extractor-change breadcrumbs at violations Show prev -> curr for each extractor whose value changed on the selected step, anchored under the violation row in ActionList. Long values collapse into
so the inline diff stays readable while the full payload is one click away. * fix(sidecar): cap stability poll independently of settle budget The previous shape halved durationMillis between waitForAppToSettle and the structural poll, then hammered hierarchy() at 80ms intervals - on Maestro this stacked enough RPCs that hierarchy fetches began timing out under load and the run stalled. Pass the full budget to waitForAppToSettle and cap the follow-up structural poll at 600ms with a 120ms interval, so the device sees at most a handful of extra hierarchy reads per step. * feat(cli): default --clear-data on so runs start fresh * feat(sidecar): streak-based settle with route-transition detection Two changes layered into the stability poll: 1. stabilitySnapshot returns null while the tree carries more than one route-level Screen tag (resource-id / testTag / identifier ending in "Screen"), so the poll cannot declare a NavHost cross-fade stable. Apps following the Compose route convention get this detection for free; apps that don't fall through to the generic signal below. 2. pollUntilStable now requires an uninterrupted stable streak of at least MIN_STABLE_STREAK_MILLIS rather than just N consecutive matches. A late transition that fires after a brief calm window breaks the streak instead of slipping past. Interval widened to 250ms so UiAutomation isn't hammered under fuzz load. * test(sidecar): cover streak reset and route-transition rejection Verify the poll honors MIN_STABLE_STREAK_MILLIS, that a transient mid-stream change resets the streak, that null returns block streak progress through a NavHost cross-fade, and that stabilitySnapshot counts only route-level attribute keys when summing Screen tags. * feat(runner): re-fetch on transitional hierarchy capture Some actions trigger async work (DB write, ViewModel coroutine) whose navigation transition begins after the sidecar settle poll has already exited. Without intervention, the next iteration's hierarchy fetch lands mid cross-fade and the verifier observes a partial extractor state which then surfaces as a false-positive violation at the step where the transition completes. fetchSyncedState pairs hierarchy + screenshot in one goroutine and retries the pair (up to 4 times, 200ms apart) while the captured tree contains more than one route-level *Screen tag. Steps that observe no transition get no added cost; steps that catch a transition pay up to ~600ms extra wall time but record a tree that matches the post-transition state the property language expects to compare. * feat(runner): gate first action on app reaching foreground * test(runner): cover startup foreground gate and back-press * feat(verifier): scope random-action targets to app package Random tap/doubleTap/type/swipe candidates now exclude nodes whose package differs from the app under test, so exploration never fuzzes the soft keyboard, system UI, or permission dialogs. An unset app package or an element with no package stays in scope, preserving behavior on iOS. * feat(testrun): pass app package into verifier scope filter * test(verifier): cover package-scoped target selection * feat(hierarchy): derive package from resource-id prefix The Android sidecar omits an explicit package attribute, so the verifier's package scope filter was a no-op and the keyboard still leaked into targets. Native nodes carry their package as the resource-id prefix; derive it there when the attribute is absent. Compose testTags are colon-less and stay empty, keeping them in scope. * test(hierarchy): cover package derivation from resource-id * chore: stop tracking inspect-ui/dist build artifacts * feat(android): detect focused-window package via dumpsys window * feat(driver): add FocusedWindowChecker capability * fix(runner): gate first observe on the app window being drawn, not just resumed * test(mock): add FocusedWindowApp with foreground mirroring * test(runner): cover startup gate waiting for app window to draw * feat(proto): add Snapshot RPC for atomic hierarchy+screenshot Pairs hierarchy and screenshot in a single response so the runner can capture both under a backend mutex, avoiding the cross-fade race where the two reads describe different frames. * feat(sidecar): add snapshot default on DriverBackend Default impl calls hierarchy() then screenshot(). The service layer wraps the call in a mutex so concurrent runners observe a serialized pair. * feat(sidecar): wire Snapshot handler with serialization lock Synchronizes backend.snapshot() so concurrent runners observe a serialized hierarchy+screenshot pair, eliminating the cross-fade race where two parallel reads describe different frames. * test(sidecar): cover Snapshot wire path and serialization lock SnapshotHandlerTest asserts both fields are populated, concurrent calls are serialized, and the default impl runs hierarchy then screenshot. * feat(driver): expose Snapshot on DeviceDriver and sidecar client Snapshot wraps the new atomic-snapshot gRPC: the runner gets hierarchy and screenshot from one round-trip whose two reads are serialized on the sidecar side. * feat(driver): add Snapshot to chrome and mock drivers The chrome tab is single-threaded so its Snapshot pairs the two reads without extra locking. The mock records ActionSnapshot so tests can assert the runner reaches for the paired RPC. * refactor(runner): observe each step via the atomic Snapshot RPC fetchSyncedState now issues one Snapshot per attempt so hierarchy and screenshot describe the same on-device frame. The transitional retry stays: that case handles a fully-captured but mid cross-fade frame, which atomic capture cannot fix. * test(runner): assert step uses Snapshot, not raw hierarchy/screenshot TestRunner_UsesAtomicSnapshot catches regressions to the two-goroutine race, and the existing parallel-fetch test now keys off ActionSnapshot. * test(driver): cover Snapshot in proto descriptor and sidecar client Adds Snapshot to the descriptor allowlist and a sidecar-client test that asserts both fields come back over the wire. * feat(trace): add Transitional flag to Step * fix(runner): skip verifier for transitional trees after retry budget When fetchSyncedState exits its retry loop with a tree that still shows a NavHost cross-fade, the runner now marks the step transitional, writes the step + screenshot to the trace, and skips Verifier.PushSnapshot / EvaluateProperties / ChangedExtractors so the previous-to-current extractor advance is not poisoned by transient state. The next clean step's previous still references the prior clean state. NextAction continues to run so the loop never deadlocks on a never-stabilizing screen. * test(runner): cover transitional step skips verifier and clean control * refactor(trace): rename Step.Action to Step.NextAction The trace step's action field is the action chosen FOR THE NEXT iteration based on observing this step's hierarchy, not the action that produced this step. Rename Step.Action to Step.NextAction and the JSON tag to next_action to make causality explicit at the data level. * refactor(runner): assign trace action to Step.NextAction field Follows the rename of trace.Step.Action to Step.NextAction. The runner already computed the next iteration's action here; only the field name changes. * refactor(inspect): decode trace step's next_action JSON field Mirrors the trace schema rename of action to next_action. The summary shape exposed to the SPA (action_kind/action_label) keeps its current JSON tags since these are derived labels, not the raw next-action. * test(inspect): update fixtures to use next_action trace field Aligns inspect tests with the trace schema rename. Step constructors now set NextAction and the JSONL fixtures use the next_action tag. * refactor(inspect-ui): rename Step.action to Step.next_action Aligns the SPA type and consumers with the trace schema rename. The StepSummary.action_kind/action_label labels stay unchanged since they are derived labels, not the raw next-action. * fix(folio): extract balanceMatchesAddedSum predicate as testable helper Move the ledger-balance-vs-added-rows predicate into a pure helper module so the property's logic is unit-testable in isolation. Marks the sanderling example as an ES module so cross-package ESM imports resolve under node. * fix(folio): use sum-of-added-rows in balanceMatchesAddedTxn The old predicate (every row's signed amount equals delta) silently passed the double-submit bug because two same-amount rows each match the delta in isolation. Switching to the sum check (addedSum === delta) catches both the double-submit case and any future multi-row append whose total drifts from the balance change. * test(spec): cover balanceMatchesAddedSum single, sum-match, over, under cases Pins the sum-based predicate: a single new row matching delta and two new rows summing to delta both hold; two-row over-sum (double-submit) and under-sum cases both violate. * fix(build): rebuild sidecar JAR when Kotlin sources change Without source-file deps on $(SIDECAR_JAR), make never re-ran shadowJar after a Kotlin edit, so a stale embedded JAR shipped on every install and the new sidecar code was silently absent at runtime. * fix(chrome): launch with no-sandbox so headless Chrome starts in CI * fix(sidecar): type text at cursor instead of clearing the field InputText now appends at the focus caret, matching the native driver and the standard mobile-input contract, instead of deleting existing content first. Adds an injectable command runner so the behavior is testable without a device. * test(sidecar): assert InputText types at cursor without clearing Captures the adb command stream and verifies a single input-text call with no preceding delete keyevents, plus the adb escaping cases. * feat(proto): add LongPress RPC * chore(proto): regenerate Go stubs for LongPress * feat(driver): add LongPress to DeviceDriver interface * feat(sidecar): add LongPress client method * feat(mock): record LongPress action * feat(chrome): implement LongPress as press-and-hold * feat(sidecar): implement longPress across backends * feat(sidecar): dispatch LongPress RPC to backend * test(sidecar): cover LongPress dispatch * test(sidecar): implement longPress in snapshot test backend * feat(verifier): add LongPress and Scroll action kinds * feat(folio-spec): predicate that gates balance check on TxnSubmit tap Replaces the row-sum predicate (which always held by construction since balance is derived from rows in Folio) with one that compares the typed amount to the actual balance delta after a tap on TxnSubmit. Catches the planted double-submit bug. * feat(folio-spec): wire submitMovesBalanceByTypedAmount property Adds lastAction and totalBalance extractors and uses them in the new property. Drops ledgerRows/ledgerBalance extractors since nothing else referenced them. * feat(verifier): wire longPresses and scrolls generators * test(verifier): cover longPresses and scrolls generators * test(folio-spec): unit tests for submitChangesBalanceByTypedAmount Covers single vs double submit, the DoubleTap variant, vacuous cases (null action, wrong kind, wrong target, zero typed), and selector-as- object coercion. * feat(spec): add LongPress and Scroll authoring surface * feat(spec): no-op LongPress and Scroll in web runtime * feat(spec): re-export longPresses and scrolls as opt-in generators * test(spec): cover LongPress and Scroll runtime members * test(proto): expect LongPress in service descriptor * feat(runner): dispatch LongPress and Scroll actions * test(runner): cover LongPress and Scroll dispatch * docs(action-space): move LongPress, Scroll, DoubleTap to current actions * fix(runner): mark nil/empty hierarchy as transitional A failed or empty sidecar hierarchy fetch was pushed straight to the verifier, letting spec extractors crash with "Cannot read property 'map' of undefined" when findAll returned null. Treat that case like a transitional capture: skip the verifier push, still record the step, and keep the loop progressing. * fix(verifier): populate Action.On when tap chooser picks an element Coordinate-targeted Taps/DoubleTaps left On empty, so action-gated properties reading lastAction.on couldn't tell which target was hit and were vacuously skipped. Resolve the picked element to a stable key:value selector (resource-id, testTag, text, desc) and validate it resolves back to the same element so we don't accidentally redirect the tap to a sibling that shares the identifier. * fix(folio): add parseTypedAmount helper matching app's parseCents Raw user input like "50" must become 5000 cents, not 50. The existing parseDollarCents helper strips non-digits and so reads "50" as 50 cents, which is correct for formatted balance text but off by 100x for raw input from the amount field. * fix(folio): parse raw amount input as cents in submit predicate txnAmountField holds raw user keystrokes, not formatted balance text. Route it through parseTypedAmount so "50" reads as $50, matching how the app commits the transaction. * fix(folio): carry forward total balance across off-screen transitions AddTransactionScreen shows neither AccountCard nor LedgerBalance, so the extractor used to report 0 at the step before submit. That made every non-zero current balance look like the full delta and tripped the typed amount property on every honest submit. Remember the last-seen sum and return it whenever the current snapshot has no balance signal. * test(folio): cover submit predicate with raw typed-amount inputs Pipes realistic raw keystrokes through parseTypedAmount + the predicate so single submits clear and double submits fire as expected. * feat(folio): add computeHomeTotalBalance helper Pure helper that tracks Home multi-account total only and carries the last Home sum across off-Home steps. Ledger's single-account balance is excluded because mixing it would corrupt cross-screen scale comparisons. * fix(folio): totalBalance carrier tracks only Home, not Ledger Home cardSum is a multi-account total; Ledger's LedgerBalance is a single account on a different scale. Blending them in the carrier produced bogus cross-screen deltas (prev from Ledger, curr from Home), triggering false positives in submitMovesBalanceByTypedAmount. Restrict the carrier to Home AccountCard totals via the computeHomeTotalBalance helper. * test(spec): cover computeHomeTotalBalance carrier behaviour Tests Home sums, carrier passthrough on off-Home steps, the Ledger scale-mismatch case, and a Home > off-Home > Home sequence. * feat(runner): treat transient apply errors as transitional steps Sidecar input RPCs occasionally hang with DEADLINE_EXCEEDED or UNAVAILABLE on long fuzzing runs. The per-step loop previously propagated any applyAction error and killed the run after a single flake. Detect transient gRPC failures via status.FromError, mark the step transitional, skip the post-action idle poll, and continue to the next step. Fatal errors (outer ctx cancellation, non-transient codes, verifier crashes) still propagate. * test(runner): cover transient apply error resilience TestRunner_TransientApplyErrorMarksTransitional drives the runner through a wrapper that fails the first TapSelector with a gRPC DeadlineExceeded then succeeds. Asserts the run does not exit, the failed step is marked transitional with no violations, and the next step runs cleanly. TestIsTransientApplyError_Classification covers the helper's matching rules directly so future code changes don't quietly drop a transient case. * fix(folio): gate submit-balance property on Home route landing totalBalance is only freshly computed when AccountCards are visible on Home; off-Home landings return the carrier and would false-fire the property, latching always(next(F)) to false and masking the real double-submit bug. Skip vacuously when route is not "home". * test(spec): cover route gate in submit-balance predicate Adds route arg to existing cases (all use "home") and adds five new cases: ledger landing with stale carrier, add-transaction with double-insert delta, null route, plus home-landing positive and double-insert negative cases anchoring the gate's allow path. --- Makefile | 3 +- cmd/sanderling/main.go | 2 +- cmd/sanderling/main_test.go | 12 +- docs/development/action-space.md | 49 ++ examples/folio/justfile | 46 +- examples/folio/sanderling/package.json | 1 + examples/folio/sanderling/predicates.ts | 62 ++ examples/folio/sanderling/spec.ts | 127 ++- inspect-ui/dist/assets/index-BV6z-_9-.css | 1 - inspect-ui/dist/assets/index-CEStFXHZ.js | 67 -- inspect-ui/dist/fonts/.gitkeep | 0 inspect-ui/dist/index.html | 13 - inspect-ui/src/panels/ActionList.css | 51 ++ inspect-ui/src/panels/ActionList.tsx | 72 +- inspect-ui/src/routes/RunDetail.tsx | 11 +- inspect-ui/src/types.ts | 9 +- internal/android/android.go | 66 ++ internal/android/android_test.go | 72 ++ internal/driver/chrome/driver.go | 40 +- internal/driver/chrome/driver_test.go | 61 ++ internal/driver/driver.go | 30 + internal/driver/mock/mock.go | 77 ++ internal/driver/sidecar/client.go | 48 ++ internal/driver/sidecar/client_test.go | 26 + internal/hierarchy/hierarchy.go | 20 + internal/hierarchy/hierarchy_test.go | 103 +++ internal/inspect/runs_decode.go | 36 +- internal/inspect/runs_test.go | 16 +- internal/inspect/server_test.go | 2 +- internal/runner/runner.go | 532 +++++++++--- internal/runner/runner_test.go | 781 +++++++++++++++++- internal/testrun/driver.go | 1 + internal/testrun/testrun.go | 6 +- internal/trace/writer.go | 43 +- internal/trace/writer_test.go | 18 +- internal/verifier/bindings.go | 93 ++- internal/verifier/marshal.go | 93 ++- internal/verifier/scope_test.go | 232 ++++++ internal/verifier/types.go | 14 + internal/verifier/verifier_test.go | 384 +++++++++ internal/verifier/worker.go | 309 ++++++- pkg/spec/package.json | 4 + pkg/spec/src/actions.ts | 35 +- pkg/spec/src/defaults/actions.ts | 24 + pkg/spec/src/defaults/index.ts | 2 + pkg/spec/src/extract.ts | 15 +- pkg/spec/src/index.ts | 4 + pkg/spec/src/types.ts | 26 +- pkg/spec/src/web-runtime.ts | 105 ++- pkg/spec/test/api.test.ts | 37 +- pkg/spec/test/defaults.test.ts | 34 + .../folio-submit-balance-predicate.test.ts | 243 ++++++ pkg/spec/test/folio-total-balance.test.ts | 97 +++ pkg/spec/test/folio-typed-amount.test.ts | 56 ++ proto/driverpb/driver.pb.go | 222 +++-- proto/driverpb/driver.proto | 10 + proto/driverpb/driver_grpc.pb.go | 82 ++ proto/driverpb/driver_test.go | 2 + .../dev/sanderling/sidecar/DriverBackend.kt | 266 ++++-- .../dev/sanderling/sidecar/DriverService.kt | 26 + .../sanderling/sidecar/DriverServiceTest.kt | 13 + .../sanderling/sidecar/InputTextClearTest.kt | 90 -- .../dev/sanderling/sidecar/InputTextTest.kt | 38 + .../sanderling/sidecar/SnapshotHandlerTest.kt | 135 +++ .../sanderling/sidecar/StabilityPollTest.kt | 149 ++++ 65 files changed, 4748 insertions(+), 596 deletions(-) create mode 100644 docs/development/action-space.md create mode 100644 examples/folio/sanderling/predicates.ts delete mode 100644 inspect-ui/dist/assets/index-BV6z-_9-.css delete mode 100644 inspect-ui/dist/assets/index-CEStFXHZ.js delete mode 100644 inspect-ui/dist/fonts/.gitkeep delete mode 100644 inspect-ui/dist/index.html create mode 100644 internal/verifier/scope_test.go create mode 100644 pkg/spec/src/defaults/actions.ts create mode 100644 pkg/spec/src/defaults/index.ts create mode 100644 pkg/spec/test/folio-submit-balance-predicate.test.ts create mode 100644 pkg/spec/test/folio-total-balance.test.ts create mode 100644 pkg/spec/test/folio-typed-amount.test.ts delete mode 100644 sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextClearTest.kt create mode 100644 sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt create mode 100644 sidecar/src/test/kotlin/dev/sanderling/sidecar/SnapshotHandlerTest.kt create mode 100644 sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt diff --git a/Makefile b/Makefile index 60388b9..69b345f 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ BUF := buf GO_PACKAGES := ./... SIDECAR_JAR := sidecar/build/libs/sidecar-all.jar SIDECAR_EMBED := internal/sidecar/assets/sidecar-all.jar +SIDECAR_SRC := $(shell find sidecar/src -type f \( -name '*.kt' -o -name '*.kts' \) 2>/dev/null) build.gradle.kts settings.gradle.kts SANDERLING_BIN := bin/sanderling DOCS_SRC := $(shell find docs -type f -name '*.md' -not -path 'docs/_*') @@ -61,7 +62,7 @@ inspect-dev: $(SIDECAR_EMBED) web-typecheck: cd inspect-ui && bun install --frozen-lockfile && bun run typecheck -$(SIDECAR_JAR): +$(SIDECAR_JAR): $(SIDECAR_SRC) ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:shadowJar $(SIDECAR_EMBED): $(SIDECAR_JAR) diff --git a/cmd/sanderling/main.go b/cmd/sanderling/main.go index e14fe37..557ddc1 100644 --- a/cmd/sanderling/main.go +++ b/cmd/sanderling/main.go @@ -52,7 +52,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) { flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration") flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)") flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces") - flagSet.BoolVar(&options.clearData, "clear-data", false, "clear app data before launching so the run starts from a fresh install") + flagSet.BoolVar(&options.clearData, "clear-data", true, "clear app data before launching so each run starts from a fresh install; pass --clear-data=false to resume prior state") if err := flagSet.Parse(args); err != nil { return testOptions{}, err } diff --git a/cmd/sanderling/main_test.go b/cmd/sanderling/main_test.go index 3bcfc82..e09af34 100644 --- a/cmd/sanderling/main_test.go +++ b/cmd/sanderling/main_test.go @@ -32,22 +32,22 @@ func TestParseTestArgs_Defaults(t *testing.T) { if options.seed != 0 { t.Errorf("seed default: got %d, want 0", options.seed) } - if options.clearData { - t.Errorf("clearData default: got true, want false") + if !options.clearData { + t.Errorf("clearData default: got false, want true") } } -func TestParseTestArgs_ClearDataFlag(t *testing.T) { +func TestParseTestArgs_ClearDataDisabled(t *testing.T) { options, err := parseTestArgs([]string{ "--spec", "s.ts", "--bundle-id", "com.example", - "--clear-data", + "--clear-data=false", }, io.Discard) if err != nil { t.Fatalf("unexpected error: %v", err) } - if !options.clearData { - t.Errorf("expected clearData=true, got false") + if options.clearData { + t.Errorf("expected clearData=false, got true") } } diff --git a/docs/development/action-space.md b/docs/development/action-space.md new file mode 100644 index 0000000..79968be --- /dev/null +++ b/docs/development/action-space.md @@ -0,0 +1,49 @@ +# Action Space + +Inventory of user interaction primitives Sanderling currently supports, compared against the full set available through the Maestro driver layer. + +## Current Actions + +| Action | Parameters | Notes | +|--------|-----------|-------| +| `Tap` | `on` (selector or element) | Single tap on element or coordinates | +| `DoubleTap` | `on` (selector or element) | Two taps within a sub-100 ms window | +| `LongPress` | `on` (selector or element) | Press and hold; resolves to coordinates | +| `InputText` | `into`, `text` | Type into focused field | +| `Swipe` | `from`, `to`, `durationMillis?` | Point-to-point or element-to-element | +| `Scroll` | `direction`, `in?`, `durationMillis?` | Scroll a container up, down, left, or right | +| `PressKey` | `key` | back, home, enter, tab, up, down, left, right | +| `Wait` | `durationMillis` | Sleep for N ms | + +## Gaps vs Maestro Driver + +The Maestro driver layer (both `AndroidDriver` and `IOSDriver`) already implements these actions. They are not yet surfaced in Sanderling's action model. + +### User Interaction Gaps + +| Action | Priority | Notes | +|--------|----------|-------| +| `EraseText` | Medium | Clear N characters from focused field | +| `HideKeyboard` | Medium | Dismiss software keyboard after text input | +| `PasteText` | Low | Paste clipboard content into focused field | + +### Device/Environment Gaps + +These are less about user gesture modeling and more about test environment setup. Separate decision on whether they belong in the action model. + +| Action | Notes | +|--------|-------| +| `SetLocation` | GPS coordinate mocking | +| `SetOrientation` | Portrait, landscape-left, landscape-right | +| `SetAirplaneMode` | Network condition simulation | +| `OpenLink` | Deep links and URLs | +| `AddMedia` | Inject photos or videos into device gallery | + +### Out of Scope + +These Maestro commands don't fit Sanderling's interaction model: + +- `LaunchApp`, `StopApp`, `KillApp`, `ClearState` - app lifecycle, handled outside the action stream +- `AssertVisible`, `AssertWithAI` - Sanderling has its own property and assertion model +- `RunScript`, `EvalScript`, `RunFlow`, `Repeat` - Sanderling has its own flow and generator model +- `InputRandomText/Number/Email/...` - Sanderling's `from()` and `weighted()` generators cover this diff --git a/examples/folio/justfile b/examples/folio/justfile index 98f8321..856a3bd 100644 --- a/examples/folio/justfile +++ b/examples/folio/justfile @@ -35,8 +35,48 @@ _android-home: echo "could not locate Android SDK (set ANDROID_HOME)" >&2 exit 1 +# Ensure an Android device is online. If none is connected and no AVD is +# provided, boot the first AVD whose system image is actually installed +# (headless) and wait for it to finish booting. +_ensure-device: + #!/usr/bin/env bash + set -euo pipefail + sdk="$(just _android-home)" + adb="$sdk/platform-tools/adb" + if "$adb" devices | awk 'NR>1 && $2=="device"{f=1} END{exit !f}'; then + exit 0 + fi + if [[ -n "{{avd}}" ]]; then + exit 0 # sanderling boots the named AVD itself + fi + emulator="$sdk/emulator/emulator" + avd="" + for candidate in $("$emulator" -list-avds); do + # The AVD directory is named in .ini's path= field, which is not + # always ".avd" (e.g. Medium_Phone_API_36.0 -> Medium_Phone.avd). + avddir="$(sed -n 's/^path=//p' "$HOME/.android/avd/$candidate.ini" 2>/dev/null || true)" + [[ -n "$avddir" ]] || avddir="$HOME/.android/avd/$candidate.avd" + sysdir="$(sed -n 's/^image\.sysdir\.1=//p' "$avddir/config.ini" 2>/dev/null || true)" + if [[ -n "$sysdir" && -d "$sdk/$sysdir" ]]; then avd="$candidate"; break; fi + done + if [[ -z "$avd" ]]; then + echo "no bootable AVD found (no installed system image); create one or set AVD=" >&2 + exit 1 + fi + echo "booting emulator: $avd" + ANDROID_SDK_ROOT="$sdk" ANDROID_HOME="$sdk" nohup "$emulator" -avd "$avd" \ + -no-window -no-snapshot -gpu swiftshader_indirect -no-audio >/tmp/folio-emulator.log 2>&1 & + for _ in $(seq 1 150); do + if [[ "$("$adb" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; then + echo "emulator ready"; exit 0 + fi + sleep 2 + done + echo "emulator did not finish booting in time (see /tmp/folio-emulator.log)" >&2 + exit 1 + # Build and install the folio APK on a running emulator/device. -install: +install: _ensure-device #!/usr/bin/env bash set -euo pipefail export ANDROID_HOME="$(just _android-home)" @@ -82,8 +122,8 @@ ios: xcrun simctl launch booted app.folio # Run 'sanderling test' against the folio app. Uses a connected device if one is -# online, otherwise boots AVD= if provided. -test: +# online; otherwise boots AVD= when provided, or auto-boots a bootable AVD. +test: _ensure-device #!/usr/bin/env bash set -euo pipefail avd_flag=() diff --git a/examples/folio/sanderling/package.json b/examples/folio/sanderling/package.json index 73c9a4a..32dc35f 100644 --- a/examples/folio/sanderling/package.json +++ b/examples/folio/sanderling/package.json @@ -1,6 +1,7 @@ { "name": "folio", "private": true, + "type": "module", "description": "Minimal sanderling integration example.", "devDependencies": { "@sanderling/spec": "file:../../../pkg/spec" diff --git a/examples/folio/sanderling/predicates.ts b/examples/folio/sanderling/predicates.ts new file mode 100644 index 0000000..13149b5 --- /dev/null +++ b/examples/folio/sanderling/predicates.ts @@ -0,0 +1,62 @@ +// Computes the Home-screen total balance from the visible AccountCard balances. +// When no Home cards are visible (Ledger, AddTransaction, etc.) the carrier +// value is returned so the property compares apples to apples across screen +// transitions. The Ledger LedgerBalance is intentionally ignored because it is +// a single-account number on a different scale than the Home multi-account sum. +export function computeHomeTotalBalance(args: { + cardBalanceTexts: (string | undefined)[]; + previousCarrier: number; +}): number { + const { cardBalanceTexts, previousCarrier } = args; + if (cardBalanceTexts.length === 0) return previousCarrier; + return cardBalanceTexts.reduce((sum, text) => sum + parseDollarCents(text), 0); +} + +// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents. +function parseDollarCents(text: string | undefined): number { + if (!text) return 0; + const sign = text.startsWith("-") ? -1 : 1; + const digits = text.replace(/[^0-9]/g, ""); + return digits ? sign * parseInt(digits, 10) : 0; +} + +// Parses raw user input in the transaction amount field into integer cents. +// Mirrors the Folio app's parseCents: whole numbers like "50" become 5000 +// cents, decimals like "5.50" become 550, more than 2 decimals or non-numeric +// input return 0. Leading +/- signs are tolerated and treated as positive. +export function parseTypedAmount(text: string | undefined | null): number { + if (!text) return 0; + let trimmed = text.trim().replace(/,/g, ""); + if (trimmed.startsWith("+") || trimmed.startsWith("-")) trimmed = trimmed.slice(1); + if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return 0; + const dot = trimmed.indexOf("."); + const whole = dot < 0 ? trimmed : trimmed.slice(0, dot); + const frac = dot < 0 ? "" : trimmed.slice(dot + 1); + const fracPadded = (frac + "00").slice(0, 2); + return parseInt(whole, 10) * 100 + parseInt(fracPadded, 10); +} + +// When the last action is a tap (or double-tap) on the transaction Submit +// button, the absolute change in total balance must equal the amount the +// user typed. A double-submit lands two transactions and shifts the balance +// by 2x the typed amount, tripping this check. The route gate skips steps +// whose landing screen is not Home: totalBalance is only freshly computed +// from visible AccountCards on Home, so off-Home comparisons would read a +// stale carrier value and false-fire. +export function submitChangesBalanceByTypedAmount(args: { + route: string | null; + lastAction: { kind?: string; on?: string | object } | null; + typedAmount: number; + prevTotalBalance: number; + currTotalBalance: number; +}): boolean { + const { route, lastAction, typedAmount, prevTotalBalance, currTotalBalance } = args; + if (route !== "home") return true; + if (lastAction == null) return true; + if (lastAction.kind !== "Tap" && lastAction.kind !== "DoubleTap") return true; + const on = lastAction.on; + const onString = typeof on === "string" ? on : on != null ? JSON.stringify(on) : ""; + if (!onString.includes("TxnSubmit")) return true; + if (typedAmount === 0) return true; + return Math.abs(currTotalBalance - prevTotalBalance) === typedAmount; +} diff --git a/examples/folio/sanderling/spec.ts b/examples/folio/sanderling/spec.ts index 43314ca..a323b6c 100644 --- a/examples/folio/sanderling/spec.ts +++ b/examples/folio/sanderling/spec.ts @@ -5,23 +5,22 @@ import { always, extract, from, - keyedBy, next, - now, weighted, whenRoute, } from "@sanderling/spec"; +import { defaultActions } from "@sanderling/spec/defaults"; +import { + computeHomeTotalBalance, + parseTypedAmount, + submitChangesBalanceByTypedAmount, +} from "./predicates"; interface Account { name: string; balance: number; } -interface LedgerRow { - key: string; - signed: number; -} - // Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents. function parseDollarCents(text: string | undefined): number { if (!text) return 0; @@ -31,8 +30,8 @@ function parseDollarCents(text: string | undefined): number { } // Route detection via testTag (resource-id on Android, accessibilityIdentifier on iOS) -const loggedIn = extract(s => s.ax.find({ testTag: "LoginScreen" }) == null); -const route = extract(s => { +const loggedIn = extract("loggedIn", s => s.ax.find({ testTag: "LoginScreen" }) == null); +const route = extract("route", s => { if (s.ax.find({ testTag: "LoginScreen" })) return "login"; if (s.ax.find({ testTag: "AddAccountScreen" })) return "add-account"; if (s.ax.find({ testTag: "AddTransactionScreen" })) return "add-transaction"; @@ -42,45 +41,47 @@ const route = extract(s => { }); // Account cards on Home: identity is the AccountName text; balance comes from AccountBalance. -const accounts = extract(s => +const accounts = extract("accounts", s => s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]).map(card => ({ name: card.find({ testTag: "AccountName" })?.text ?? "", balance: parseDollarCents(card.find({ testTag: "AccountBalance" })?.text), }))); -// Ledger rows: identity composed from the row's stable testTag'd cells. -const ledgerRows = extract(s => - s.ax.findAll([{ testTag: "LedgerScreen" }, { testTag: "LedgerRow" }]).map(row => ({ - key: keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]), - signed: parseDollarCents(row.find({ testTag: "TxnAmount" })?.text), - }))); +// Total balance: sum of AccountCard balances visible on Home. The carrier +// deliberately tracks only the Home multi-account total. Ledger's +// LedgerBalance is a single-account number on a different scale and would +// corrupt cross-screen comparisons if mixed in. Off-Home steps carry forward +// the last-seen Home sum so `previous` and `current` stay on the same scale. +let lastHomeTotal = 0; +const totalBalance = extract("totalBalance", s => { + const cards = s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]); + const cardBalanceTexts = cards.map(c => c.find({ testTag: "AccountBalance" })?.text); + lastHomeTotal = computeHomeTotalBalance({ cardBalanceTexts, previousCarrier: lastHomeTotal }); + return lastHomeTotal; +}); -const ledgerBalance = extract(s => - parseDollarCents(s.ax.find({ testTag: "LedgerBalance" })?.text)); +const lastAction = extract("lastAction", s => s.lastAction); -const focusedFieldTag = extract(s => s.ax.find({ focused: true })?.id ?? null); - -const loginEmailField = extract(s => +const loginEmailField = extract("loginEmailField", s => s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginEmail" }])); -const loginPasswordField = extract(s => +const loginPasswordField = extract("loginPasswordField", s => s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginPassword" }])); -const loginSubmit = extract(s => +const loginSubmit = extract("loginSubmit", s => s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginSubmit" }])); -const addAccountButton = extract(s => +const addAccountButton = extract("addAccountButton", s => s.ax.find([{ testTag: "HomeScreen" }, { testTag: "AddAccountButton" }])); -const accountNameField = extract(s => +const accountNameField = extract("accountNameField", s => s.ax.find([{ testTag: "AddAccountScreen" }, { testTag: "AccountNameField" }])); -const addAccountSubmit = extract(s => +const addAccountSubmit = extract("addAccountSubmit", s => s.ax.find([{ testTag: "AddAccountScreen" }, { testTag: "AddAccountSubmit" }])); -const addTxnButton = extract(s => +const addTxnButton = extract("addTxnButton", s => s.ax.find([{ testTag: "LedgerScreen" }, { testTag: "AddTransactionButton" }])); -const txnAmountField = extract(s => +const txnAmountField = extract("txnAmountField", s => s.ax.find([{ testTag: "AddTransactionScreen" }, { testTag: "TxnAmountField" }])); -const txnSubmit = extract(s => +const txnSubmit = extract("txnSubmit", s => s.ax.find([{ testTag: "AddTransactionScreen" }, { testTag: "TxnSubmit" }])); -const accountCards = extract(s => +const accountCards = extract("accountCards", s => s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }])); -const backButton = extract(s => s.ax.find({ testTag: "BackButton" })); // Property 1: every newly-appearing account starts with balance === 0. // Identity is by visible name. Guard against navigation transitions where @@ -95,39 +96,39 @@ const newAccountBalanceIsZero = always( }) ); -// Property 2: a newly-added ledger row changes the ledger balance by exactly its signed amount. -const newTxnChangesBalance = always( - now(() => route.current === "ledger").implies( - next(() => { - const prev = ledgerRows.previous ?? []; - const curr = ledgerRows.current; - if (curr.length !== prev.length + 1) return true; - const prevKeys = new Set(prev.map(r => r.key)); - const added = curr.find(r => !prevKeys.has(r.key)); - if (!added) return true; - const delta = ledgerBalance.current - (ledgerBalance.previous ?? 0); - return delta === added.signed && delta !== 0; - }) - ) +// Property 2: a tap on TxnSubmit must move the total balance by exactly the +// typed amount. A double-submit lands two transactions, so the balance shifts +// by twice the typed amount and the check fires. The route gate inside the +// predicate skips off-Home landings where totalBalance.current is the carrier. +const submitMovesBalanceByTypedAmount = always( + next(() => + submitChangesBalanceByTypedAmount({ + route: route.current, + lastAction: lastAction.current, + typedAmount: parseTypedAmount(txnAmountField.previous?.text), + prevTotalBalance: totalBalance.previous ?? 0, + currTotalBalance: totalBalance.current, + }), + ), ); const DEMO_EMAIL = "demo@folio.app"; const DEMO_PASSWORD = "ledger123"; -// Login: drive the form via focus state read from the native focused="true" attr. +// Login: drive the form by reading what's currently in each field, not by +// inferring intent from which one happens to be focused. A focus-driven +// approach loops forever if we re-enter the login screen with the password +// field already focused (e.g. after an exception bounces us back from +// another screen): it would type the password then tap submit with an +// empty email field, see no progress, and repeat indefinitely. const login = actions(() => { if (loggedIn.current) return []; - const focus = focusedFieldTag.current; - if (focus === "LoginPassword") { - const submit = loginSubmit.current; - return submit ? [Tap({ on: submit })] : []; - } - if (focus === "LoginEmail") { - const pwd = loginPasswordField.current; - return pwd ? [InputText({ into: pwd, text: DEMO_PASSWORD })] : []; - } const email = loginEmailField.current; - return email ? [InputText({ into: email, text: DEMO_EMAIL })] : []; + const pwd = loginPasswordField.current; + if (email && !email.text) return [InputText({ into: email, text: DEMO_EMAIL })]; + if (pwd && !pwd.text) return [InputText({ into: pwd, text: DEMO_PASSWORD })]; + const submit = loginSubmit.current; + return submit ? [Tap({ on: submit })] : []; }); const accountNames = from(["Checking", "Savings", "Travel", "Emergency Fund", "Investments"]); @@ -165,22 +166,20 @@ const addTxn = whenRoute(route, ["home", "ledger", "add-transaction"], () => { return opts; }); -const back = actions(() => { - const btn = backButton.current; - return btn ? [Tap({ on: btn })] : []; -}); - export const properties = { newAccountBalanceIsZero, - newTxnChangesBalance, + submitMovesBalanceByTypedAmount, }; export const setup = login; +// Targeted depth (addAccount / addTxn) drives the deep flows; defaultActions +// adds breadth so the fuzzer wanders the whole app and types edge-case values +// into every field, stressing the balance invariants above. export const actionsRoot = weighted( [50, addAccount], - [40, addTxn], - [10, back], + [30, addTxn], + [20, defaultActions], ); (globalThis as { actions?: unknown; properties?: unknown; setup?: unknown }).actions = actionsRoot; diff --git a/inspect-ui/dist/assets/index-BV6z-_9-.css b/inspect-ui/dist/assets/index-BV6z-_9-.css deleted file mode 100644 index 1b4d912..0000000 --- a/inspect-ui/dist/assets/index-BV6z-_9-.css +++ /dev/null @@ -1 +0,0 @@ -@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/JetBrainsMono-Regular.woff2) format("woff2")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/JetBrainsMono-Medium.woff2) format("woff2")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/JetBrainsMono-Bold.woff2) format("woff2")}body{font-family:var(--font-mono);font-size:13px;line-height:1.5;color:var(--text-primary);background:var(--background);margin:0}:root{--background: #ffffff;--surface: #f7f7f7;--surface-elevated: #ffffff;--border: #e4e4e4;--border-strong: #c8c8c8;--text-primary: #111111;--text-muted: #6b6b6b;--accent-violation: #d32f2f;--accent-positive: #2e7d32;--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}:root[data-theme=dark],:root[data-theme=dark] body{--background: #0e0e0e;--surface: #161616;--surface-elevated: #1d1d1d;--border: #2a2a2a;--border-strong: #3a3a3a;--text-primary: #f0f0f0;--text-muted: #9a9a9a;--accent-violation: #f06b6b;--accent-positive: #7cc47c}@media (prefers-color-scheme: dark){:root:not([data-theme=light]){--background: #0e0e0e;--surface: #161616;--surface-elevated: #1d1d1d;--border: #2a2a2a;--border-strong: #3a3a3a;--text-primary: #f0f0f0;--text-muted: #9a9a9a;--accent-violation: #f06b6b;--accent-positive: #7cc47c}}body{background:var(--background);color:var(--text-primary);margin:0;font-family:var(--font-mono)}*,*:before,*:after{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{overflow:hidden}a{color:inherit;text-decoration:none}a:hover{text-decoration:underline}button{font:inherit;color:inherit;background:var(--surface);border:1px solid var(--border);padding:4px 10px;cursor:pointer}button:hover{border-color:var(--border-strong)}.app-shell{height:100vh;display:flex;flex-direction:column;overflow:hidden}.app-main{flex:1 1 auto;min-height:0;padding:0;display:flex;flex-direction:column}.app-main>*{flex:1 1 auto;min-height:0}.run-table{width:100%;border-collapse:collapse;align-self:flex-start;flex:0 0 auto}.run-table th,.run-table td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--border)}.run-table th{color:var(--text-muted);font-weight:500;font-size:12px;text-transform:uppercase;letter-spacing:.04em}.run-table tr:hover td{background:var(--surface)}.chip{display:inline-block;padding:2px 8px;border:1px solid var(--border-strong);border-radius:999px;font-size:11px}.chip-violation{border-color:var(--accent-violation);color:var(--accent-violation)}.chip-progress{border-color:var(--border-strong);color:var(--text-muted)}.detail-root{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden}.detail-toolbar{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;border-bottom:1px solid var(--border);flex:0 0 auto}.detail-toolbar-meta{display:flex;gap:16px;align-items:baseline;font-size:12px;color:var(--text-muted)}.detail-toolbar-meta strong{color:var(--text-primary);font-weight:500}.theme-toggle{display:inline-flex;align-items:center;justify-content:center;width:28px;height:24px;padding:0;color:var(--text-muted)}.theme-toggle:hover{color:var(--text-primary)}.theme-toggle svg{display:block}.detail-grid{flex:1 1 auto;display:grid;grid-template-columns:340px minmax(0,1fr) minmax(0,1fr);grid-template-rows:minmax(0,1fr) minmax(0,130px);grid-template-areas:"actions before after" "metrics metrics metrics";gap:0;padding:0;min-height:0;overflow:hidden}.detail-grid>.detail-actions{grid-area:actions;min-height:0;overflow:hidden}.detail-grid>.detail-state-before,.detail-grid>.detail-state-after{display:flex;flex-direction:column;gap:0;min-height:0;min-width:0;overflow:hidden}.detail-grid>.detail-state-before{grid-area:before}.detail-grid>.detail-state-after{grid-area:after}.detail-grid>.detail-actions,.detail-grid>.detail-state-before{border-right:1px solid var(--border)}.detail-grid>.detail-state-before .screenshot-frame,.detail-grid>.detail-state-after .screenshot-frame,.detail-grid>.detail-state-before .screenshot-placeholder,.detail-grid>.detail-state-after .screenshot-placeholder{flex:1 1 auto;max-height:100%;max-width:100%;margin:0 auto;min-height:0}.detail-grid>.detail-metrics{grid-area:metrics;overflow:hidden;display:flex;flex-direction:column;min-height:0;border-top:1px solid var(--border)}.detail-grid>.detail-metrics .metrics-chart{flex:1 1 auto;min-height:0;max-height:100%}.detail-metrics .detail-panel-body{overflow:hidden}.detail-metrics-exceptions{padding:4px 8px;border-top:1px solid var(--border);overflow:auto;max-height:80px}.detail-panel{border:none;background:transparent;padding:0;min-height:0;display:flex;flex-direction:column}.detail-panel h2{margin:0;padding:4px 8px;font-size:10px;font-weight:500;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid var(--border);flex:0 0 auto}.detail-panel-body{flex:1 1 auto;min-height:0;overflow:auto;display:flex;flex-direction:column}.detail-actions .detail-panel-body{padding:0}.status-block{padding:12px;color:var(--text-muted)}.status-error{color:var(--accent-violation)}.action-list{list-style:none;padding:0;margin:0;font-family:var(--font-mono);font-size:11px}.action-list-item{display:block;padding:3px 6px;border-left:2px solid transparent;cursor:pointer;color:var(--text-primary);outline:none}.action-list-item:focus-visible{outline:1px solid var(--text-primary);outline-offset:-1px}.action-list-row{display:grid;grid-template-columns:20px minmax(0,1fr) auto auto;align-items:baseline;column-gap:4px}.action-list-index{text-align:left;color:var(--text-muted);font-variant-numeric:tabular-nums}.action-list-body{display:inline-flex;gap:6px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.action-list-verb{color:var(--text-primary);font-weight:500}.action-list-target{color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.action-list-target-tag{color:var(--text-primary)}.action-list-markers{display:inline-flex;align-items:center;gap:6px;min-width:0}.action-list-elapsed{color:var(--text-muted);font-variant-numeric:tabular-nums;text-align:right;padding-left:8px}.action-list-item:hover{background:color-mix(in srgb,currentColor 6%,transparent)}.action-list-item:focus-visible{background:color-mix(in srgb,currentColor 8%,transparent)}.action-list-item[data-active=true]{background:color-mix(in srgb,currentColor 10%,transparent);border-left-color:currentColor}.action-list-item[data-active=true][data-violations=true]{border-left-color:var(--accent-violation)}.action-list-marker-violation{width:8px;height:8px;border-radius:50%;background:var(--accent-violation)}.action-list-marker-exception{width:8px;height:8px;border-radius:50%;border:1px solid var(--text-muted);background:transparent}.action-list-details{display:grid;grid-template-columns:20px 1fr;column-gap:4px;padding:2px 0;font-size:10px}.action-list-detail-row{grid-column:2;display:grid;grid-template-columns:70px 1fr;gap:6px;color:var(--text-muted)}.action-list-detail-label{color:var(--text-muted)}.action-list-detail-value{color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.hierarchy-panel{display:flex;flex-direction:column;gap:8px;font-family:var(--font-mono);font-size:12px;color:var(--text-primary)}.hierarchy-filter{font-family:var(--font-mono);font-size:12px;padding:4px 8px;border:1px solid var(--border);background:var(--surface);color:var(--text-primary)}.hierarchy-table-wrap{overflow:auto;max-height:480px;border:1px solid var(--border)}.hierarchy-table{width:100%;border-collapse:collapse}.hierarchy-table th{position:sticky;top:0;background:var(--surface);text-align:left;padding:4px 8px;font-weight:400;color:var(--text-muted);border-bottom:1px solid var(--border)}.hierarchy-table td{padding:2px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-bottom:1px solid var(--border)}.hierarchy-table tr:hover td{background:var(--surface-elevated)}.hierarchy-tag{color:var(--text-muted);max-width:140px}.hierarchy-id{color:var(--text-primary);max-width:160px}.hierarchy-text{max-width:320px;color:var(--text-primary)}.hierarchy-bounds{color:var(--text-muted);text-align:right}.hierarchy-empty{font-family:var(--font-mono);font-size:12px;color:var(--text-muted);padding:16px}.hierarchy-count{color:var(--text-muted);font-size:11px}.screenshot-frame{position:relative;width:100%;max-width:100%;max-height:100%;margin:0 auto;background:var(--surface);overflow:hidden}.screenshot-image{display:block;width:100%;height:100%;object-fit:contain}.screenshot-overlay{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;pointer-events:none}.screenshot-placeholder{display:flex;align-items:center;justify-content:center;width:100%;height:100%;min-height:240px;background:var(--surface);border:1px dashed var(--border-strong);color:var(--text-muted);font-family:var(--font-mono);font-size:12px;text-transform:uppercase;letter-spacing:.06em}.snapshot-table{margin:0;padding:0;font-family:var(--font-mono);font-size:12px;color:var(--text-primary)}.snapshot-row{display:grid;grid-template-columns:minmax(0,160px) minmax(0,1fr);gap:12px;align-items:baseline;padding:4px 8px;border-left:2px solid transparent}.snapshot-row[data-changed=true]{border-left-color:var(--border-strong)}.snapshot-path{margin:0;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.snapshot-value{margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;font-variant-numeric:tabular-nums}.snapshot-row[data-long-value=true] .snapshot-value{white-space:pre-wrap;max-height:6em;overflow-y:auto;text-align:left}.residual{font-family:var(--font-mono);font-size:12px;color:var(--text-primary);line-height:1.6;word-break:break-word}.residual-leaf,.residual-unary,.residual-binary,.residual-group{display:inline}.residual-unary,.residual-binary{display:inline-flex;flex-wrap:wrap;align-items:baseline;gap:4px}.op-label{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);padding:1px 4px;border:1px solid var(--border);border-radius:3px;background:var(--surface)}.residual-bound{color:var(--text-muted);font-size:11px}.residual-error{display:inline-flex;align-items:center;gap:6px;padding:2px 6px;border:1px solid var(--accent-violation);border-radius:3px;color:var(--accent-violation)}.residual-error .op-label{color:var(--accent-violation);border-color:var(--accent-violation);background:transparent}.residual-error-message{font-size:12px}.violations-panel{display:flex;flex-direction:column;gap:8px;font-family:var(--font-mono);color:var(--text-primary)}.violations-panel-header{display:flex;align-items:center;justify-content:space-between;gap:8px}.violations-panel-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);margin:0}.violations-panel-jump{font-family:var(--font-mono);font-size:11px;text-transform:lowercase;padding:4px 8px;background:var(--surface);color:var(--text-primary);border:1px solid var(--border);border-radius:3px;cursor:pointer}.violations-panel-jump:hover:not(:disabled){border-color:var(--border-strong)}.violations-panel-jump:disabled{color:var(--text-muted);cursor:not-allowed;opacity:.6}.violations-panel-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}.violations-panel-row{border:1px solid var(--border);border-radius:3px;padding:8px;background:var(--surface-elevated)}.violations-panel-row[data-status=violated]{border-color:var(--accent-violation)}.violations-panel-row-head{display:flex;align-items:center;gap:8px}.violations-panel-badge{font-size:10px;text-transform:uppercase;letter-spacing:.06em;padding:2px 6px;border-radius:3px;border:1px solid var(--border);background:var(--surface);color:var(--text-muted)}.violations-panel-badge[data-status=violated]{color:var(--accent-violation);border-color:var(--accent-violation);background:transparent}.violations-panel-badge[data-status=holds]{color:var(--text-muted);opacity:.7}.violations-panel-name{font-weight:700;font-size:13px}.violations-panel-residual{margin-top:6px;font-size:12px}.violations-panel-residual summary{cursor:pointer;color:var(--text-muted);font-size:11px;text-transform:uppercase;letter-spacing:.06em;padding:2px 0;-webkit-user-select:none;user-select:none}.violations-panel-residual[open] summary{margin-bottom:6px}.exceptions-panel{display:flex;flex-direction:column;gap:8px;font-family:var(--font-mono);font-size:12px;color:var(--text-primary)}.exceptions-panel-toolbar{display:flex;justify-content:flex-end}.exceptions-panel-jump{font-family:var(--font-mono);font-size:11px}.exceptions-panel-jump:disabled{opacity:.5;cursor:not-allowed}.exceptions-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:6px}.exceptions-row details{border:1px dashed var(--border-strong);background:var(--surface);padding:6px 8px}.exceptions-row details[open]{border-color:var(--text-primary)}.exceptions-summary{display:grid;grid-template-columns:8px auto auto minmax(0,1fr) auto;align-items:baseline;gap:6px;cursor:pointer;list-style:none;outline:none}.exceptions-summary::-webkit-details-marker{display:none}.exceptions-marker{width:8px;height:8px;border:1px dashed var(--text-primary);background:transparent;align-self:center}.exceptions-class{font-weight:700;white-space:nowrap}.exceptions-colon{color:var(--text-muted)}.exceptions-message{color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.exceptions-time{color:var(--text-muted);font-variant-numeric:tabular-nums;margin-left:auto}.exceptions-stack{margin:8px 0 0;padding:8px;font-family:var(--font-mono);font-size:11px;background:var(--background);border:1px solid var(--border);max-height:220px;overflow-y:auto;white-space:pre}.metrics-chart{display:flex;flex-direction:column;width:100%;height:100%;min-height:0;font-family:var(--font-mono);font-size:9px;color:var(--text-muted)}.metrics-lanes{flex:1 1 auto;position:relative;display:flex;flex-direction:column;min-height:0}.metrics-lane{flex:1 1 0;display:grid;grid-template-columns:18px minmax(0,1fr) 32px;align-items:stretch;min-height:0;border-bottom:1px solid var(--border)}.metrics-lane:last-of-type{border-bottom:none}.metrics-lane-label{display:flex;align-items:center;justify-content:center;writing-mode:vertical-rl;transform:rotate(180deg);font-size:8px;font-weight:600;letter-spacing:.12em;border-right:1px solid var(--border)}.metrics-lane-plot{position:relative;min-width:0;min-height:0}.metrics-lane-canvas{position:absolute;top:4px;right:0;bottom:4px;left:0}.metrics-lane-svg{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;display:block}.metrics-lane-path{fill:none;stroke:var(--text-primary);stroke-width:1;vector-effect:non-scaling-stroke}.metrics-lane-dot{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--text-primary);transform:translate(-50%,-50%);pointer-events:none}.metrics-lane-dot[data-active=true]{width:7px;height:7px}.metrics-lane-tooltip{position:absolute;padding:2px 6px;border:1px solid var(--border-strong);background:var(--surface);color:var(--text-primary);font-variant-numeric:tabular-nums;font-size:10px;white-space:nowrap;pointer-events:none;z-index:2}.metrics-lane-tooltip[data-placement=above]{transform:translate(-50%,calc(-100% - 6px))}.metrics-lane-tooltip[data-placement=below]{transform:translate(-50%,6px)}.metrics-lane-highlight{position:absolute;top:0;bottom:0;width:1px;background:var(--text-primary);opacity:.7;pointer-events:none}.metrics-lane-ticks{display:flex;flex-direction:column;justify-content:space-between;align-items:flex-start;padding:2px 6px;border-left:1px solid var(--border);font-variant-numeric:tabular-nums;font-size:9px}.metrics-hits{position:absolute;top:0;right:32px;bottom:0;left:18px;display:block;pointer-events:none}.metrics-hit{position:absolute;top:0;bottom:0;background:transparent;border:0;padding:0;margin:0;cursor:pointer;pointer-events:auto}.metrics-hit:hover{background:color-mix(in srgb,var(--text-primary) 5%,transparent)}.metrics-axis{flex:0 0 auto;position:relative;height:20px;margin:0 32px 0 18px;border-top:1px solid var(--border)}.metrics-axis-label{position:absolute;top:2px;transform:translate(-50%);font-variant-numeric:tabular-nums;font-size:9px;white-space:nowrap}.tabs{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;font-family:var(--font-mono)}.tabs-header{display:flex;gap:0;border-bottom:1px solid var(--border);flex:0 0 auto;padding:0 4px}.tabs-tab{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:.08em;padding:4px 10px;background:transparent;border:none;border-bottom:1px solid transparent;color:var(--text-muted);cursor:pointer;margin-bottom:-1px;display:inline-flex;align-items:center;gap:6px;outline:none}.tabs-tab:focus-visible{outline:1px solid var(--text-primary);outline-offset:-1px}.tabs-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;padding:0 4px;border:1px solid var(--border-strong);border-radius:999px;font-size:9px;line-height:1;letter-spacing:0}.tabs-tab[data-active=true] .tabs-badge{border-color:var(--text-primary)}.tabs-badge[data-kind=violation]{border-color:var(--accent-violation);color:var(--accent-violation)}.tabs-tab:hover{color:var(--text-primary)}.tabs-tab[data-active=true]{color:var(--text-primary);border-bottom-color:var(--text-primary)}.tabs-panel{flex:1 1 auto;min-height:0;overflow:auto;display:flex;flex-direction:column} diff --git a/inspect-ui/dist/assets/index-CEStFXHZ.js b/inspect-ui/dist/assets/index-CEStFXHZ.js deleted file mode 100644 index dd0b054..0000000 --- a/inspect-ui/dist/assets/index-CEStFXHZ.js +++ /dev/null @@ -1,67 +0,0 @@ -function wf(e,t){for(var n=0;nr[l]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();function Sf(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Vu={exports:{}},yl={},Bu={exports:{}},O={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var sr=Symbol.for("react.element"),kf=Symbol.for("react.portal"),Ef=Symbol.for("react.fragment"),Nf=Symbol.for("react.strict_mode"),Cf=Symbol.for("react.profiler"),jf=Symbol.for("react.provider"),_f=Symbol.for("react.context"),Pf=Symbol.for("react.forward_ref"),Tf=Symbol.for("react.suspense"),Lf=Symbol.for("react.memo"),Rf=Symbol.for("react.lazy"),cs=Symbol.iterator;function zf(e){return e===null||typeof e!="object"?null:(e=cs&&e[cs]||e["@@iterator"],typeof e=="function"?e:null)}var Wu={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Hu=Object.assign,Qu={};function mn(e,t,n){this.props=e,this.context=t,this.refs=Qu,this.updater=n||Wu}mn.prototype.isReactComponent={};mn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};mn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Ku(){}Ku.prototype=mn.prototype;function uo(e,t,n){this.props=e,this.context=t,this.refs=Qu,this.updater=n||Wu}var ao=uo.prototype=new Ku;ao.constructor=uo;Hu(ao,mn.prototype);ao.isPureReactComponent=!0;var fs=Array.isArray,Gu=Object.prototype.hasOwnProperty,co={current:null},Ju={key:!0,ref:!0,__self:!0,__source:!0};function Yu(e,t,n){var r,l={},i=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(i=""+t.key),t)Gu.call(t,r)&&!Ju.hasOwnProperty(r)&&(l[r]=t[r]);var s=arguments.length-2;if(s===1)l.children=n;else if(1>>1,ee=T[K];if(0>>1;Kl(Dl,I))Ntl(mr,Dl)?(T[K]=mr,T[Nt]=I,K=Nt):(T[K]=Dl,T[Et]=I,K=Et);else if(Ntl(mr,I))T[K]=mr,T[Nt]=I,K=Nt;else break e}}return z}function l(T,z){var I=T.sortIndex-z.sortIndex;return I!==0?I:T.id-z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var u=[],a=[],h=1,f=null,d=3,y=!1,g=!1,w=!1,C=typeof setTimeout=="function"?setTimeout:null,m=typeof clearTimeout=="function"?clearTimeout:null,p=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function v(T){for(var z=n(a);z!==null;){if(z.callback===null)r(a);else if(z.startTime<=T)r(a),z.sortIndex=z.expirationTime,t(u,z);else break;z=n(a)}}function x(T){if(w=!1,v(T),!g)if(n(u)!==null)g=!0,Ml(E);else{var z=n(a);z!==null&&Fl(x,z.startTime-T)}}function E(T,z){g=!1,w&&(w=!1,m(N),N=-1),y=!0;var I=d;try{for(v(z),f=n(u);f!==null&&(!(f.expirationTime>z)||T&&!X());){var K=f.callback;if(typeof K=="function"){f.callback=null,d=f.priorityLevel;var ee=K(f.expirationTime<=z);z=e.unstable_now(),typeof ee=="function"?f.callback=ee:f===n(u)&&r(u),v(z)}else r(u);f=n(u)}if(f!==null)var hr=!0;else{var Et=n(a);Et!==null&&Fl(x,Et.startTime-z),hr=!1}return hr}finally{f=null,d=I,y=!1}}var _=!1,P=null,N=-1,R=5,L=-1;function X(){return!(e.unstable_now()-LT||125K?(T.sortIndex=I,t(a,T),n(u)===null&&T===n(a)&&(w?(m(N),N=-1):w=!0,Fl(x,I-K))):(T.sortIndex=ee,t(u,T),g||y||(g=!0,Ml(E))),T},e.unstable_shouldYield=X,e.unstable_wrapCallback=function(T){var z=d;return function(){var I=d;d=z;try{return T.apply(this,arguments)}finally{d=I}}}})(ta);ea.exports=ta;var Hf=ea.exports;/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Qf=S,ke=Hf;function k(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),fi=Object.prototype.hasOwnProperty,Kf=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,ps={},hs={};function Gf(e){return fi.call(hs,e)?!0:fi.call(ps,e)?!1:Kf.test(e)?hs[e]=!0:(ps[e]=!0,!1)}function Jf(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Yf(e,t,n,r){if(t===null||typeof t>"u"||Jf(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function pe(e,t,n,r,l,i,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var ie={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ie[e]=new pe(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ie[t]=new pe(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ie[e]=new pe(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ie[e]=new pe(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ie[e]=new pe(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ie[e]=new pe(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ie[e]=new pe(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ie[e]=new pe(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ie[e]=new pe(e,5,!1,e.toLowerCase(),null,!1,!1)});var po=/[\-:]([a-z])/g;function ho(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(po,ho);ie[t]=new pe(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(po,ho);ie[t]=new pe(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(po,ho);ie[t]=new pe(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ie[e]=new pe(e,1,!1,e.toLowerCase(),null,!1,!1)});ie.xlinkHref=new pe("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ie[e]=new pe(e,1,!1,e.toLowerCase(),null,!0,!0)});function mo(e,t,n,r){var l=ie.hasOwnProperty(t)?ie[t]:null;(l!==null?l.type!==0:r||!(2s||l[o]!==i[s]){var u=` -`+l[o].replace(" at new "," at ");return e.displayName&&u.includes("")&&(u=u.replace("",e.displayName)),u}while(1<=o&&0<=s);break}}}finally{Al=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Pn(e):""}function Xf(e){switch(e.tag){case 5:return Pn(e.type);case 16:return Pn("Lazy");case 13:return Pn("Suspense");case 19:return Pn("SuspenseList");case 0:case 2:case 15:return e=Vl(e.type,!1),e;case 11:return e=Vl(e.type.render,!1),e;case 1:return e=Vl(e.type,!0),e;default:return""}}function mi(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Wt:return"Fragment";case Bt:return"Portal";case di:return"Profiler";case vo:return"StrictMode";case pi:return"Suspense";case hi:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case la:return(e.displayName||"Context")+".Consumer";case ra:return(e._context.displayName||"Context")+".Provider";case go:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case yo:return t=e.displayName||null,t!==null?t:mi(e.type)||"Memo";case tt:t=e._payload,e=e._init;try{return mi(e(t))}catch{}}return null}function Zf(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return mi(t);case 8:return t===vo?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function gt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function oa(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function qf(e){var t=oa(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,i.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function yr(e){e._valueTracker||(e._valueTracker=qf(e))}function sa(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=oa(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Gr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function vi(e,t){var n=t.checked;return W({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function vs(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=gt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function ua(e,t){t=t.checked,t!=null&&mo(e,"checked",t,!1)}function gi(e,t){ua(e,t);var n=gt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?yi(e,t.type,n):t.hasOwnProperty("defaultValue")&&yi(e,t.type,gt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function gs(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function yi(e,t,n){(t!=="number"||Gr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Tn=Array.isArray;function tn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=xr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Wn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var In={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},bf=["Webkit","ms","Moz","O"];Object.keys(In).forEach(function(e){bf.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),In[t]=In[e]})});function da(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||In.hasOwnProperty(e)&&In[e]?(""+t).trim():t+"px"}function pa(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=da(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var ed=W({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Si(e,t){if(t){if(ed[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(k(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(k(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(k(61))}if(t.style!=null&&typeof t.style!="object")throw Error(k(62))}}function ki(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Ei=null;function xo(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Ni=null,nn=null,rn=null;function ws(e){if(e=cr(e)){if(typeof Ni!="function")throw Error(k(280));var t=e.stateNode;t&&(t=El(t),Ni(e.stateNode,e.type,t))}}function ha(e){nn?rn?rn.push(e):rn=[e]:nn=e}function ma(){if(nn){var e=nn,t=rn;if(rn=nn=null,ws(e),t)for(e=0;e>>=0,e===0?32:31-(fd(e)/dd|0)|0}var wr=64,Sr=4194304;function Ln(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Zr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~l;s!==0?r=Ln(s):(i&=o,i!==0&&(r=Ln(i)))}else o=n&~l,o!==0?r=Ln(o):i!==0&&(r=Ln(i));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function ur(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Me(t),e[t]=n}function vd(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Mn),Ts=" ",Ls=!1;function Ma(e,t){switch(e){case"keyup":return Hd.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Fa(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Ht=!1;function Kd(e,t){switch(e){case"compositionend":return Fa(t);case"keypress":return t.which!==32?null:(Ls=!0,Ts);case"textInput":return e=t.data,e===Ts&&Ls?null:e;default:return null}}function Gd(e,t){if(Ht)return e==="compositionend"||!_o&&Ma(e,t)?(e=Ia(),$r=No=it=null,Ht=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Os(n)}}function Aa(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Aa(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Va(){for(var e=window,t=Gr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Gr(e.document)}return t}function Po(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function np(e){var t=Va(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Aa(n.ownerDocument.documentElement,n)){if(r!==null&&Po(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=Ms(n,i);var o=Ms(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Qt=null,Li=null,Dn=null,Ri=!1;function Fs(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Ri||Qt==null||Qt!==Gr(r)||(r=Qt,"selectionStart"in r&&Po(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Dn&&Yn(Dn,r)||(Dn=r,r=el(Li,"onSelect"),0Jt||(e.current=Di[Jt],Di[Jt]=null,Jt--)}function D(e,t){Jt++,Di[Jt]=e.current,e.current=t}var yt={},ae=wt(yt),ve=wt(!1),zt=yt;function an(e,t){var n=e.type.contextTypes;if(!n)return yt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function ge(e){return e=e.childContextTypes,e!=null}function nl(){U(ve),U(ae)}function Ws(e,t,n){if(ae.current!==yt)throw Error(k(168));D(ae,t),D(ve,n)}function Xa(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(k(108,Zf(e)||"Unknown",l));return W({},n,r)}function rl(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||yt,zt=ae.current,D(ae,e),D(ve,ve.current),!0}function Hs(e,t,n){var r=e.stateNode;if(!r)throw Error(k(169));n?(e=Xa(e,t,zt),r.__reactInternalMemoizedMergedChildContext=e,U(ve),U(ae),D(ae,e)):U(ve),D(ve,n)}var He=null,Nl=!1,ti=!1;function Za(e){He===null?He=[e]:He.push(e)}function hp(e){Nl=!0,Za(e)}function St(){if(!ti&&He!==null){ti=!0;var e=0,t=F;try{var n=He;for(F=1;e>=o,l-=o,Qe=1<<32-Me(t)+l|n<N?(R=P,P=null):R=P.sibling;var L=d(m,P,v[N],x);if(L===null){P===null&&(P=R);break}e&&P&&L.alternate===null&&t(m,P),p=i(L,p,N),_===null?E=L:_.sibling=L,_=L,P=R}if(N===v.length)return n(m,P),A&&Ct(m,N),E;if(P===null){for(;NN?(R=P,P=null):R=P.sibling;var X=d(m,P,L.value,x);if(X===null){P===null&&(P=R);break}e&&P&&X.alternate===null&&t(m,P),p=i(X,p,N),_===null?E=X:_.sibling=X,_=X,P=R}if(L.done)return n(m,P),A&&Ct(m,N),E;if(P===null){for(;!L.done;N++,L=v.next())L=f(m,L.value,x),L!==null&&(p=i(L,p,N),_===null?E=L:_.sibling=L,_=L);return A&&Ct(m,N),E}for(P=r(m,P);!L.done;N++,L=v.next())L=y(P,m,N,L.value,x),L!==null&&(e&&L.alternate!==null&&P.delete(L.key===null?N:L.key),p=i(L,p,N),_===null?E=L:_.sibling=L,_=L);return e&&P.forEach(function(Q){return t(m,Q)}),A&&Ct(m,N),E}function C(m,p,v,x){if(typeof v=="object"&&v!==null&&v.type===Wt&&v.key===null&&(v=v.props.children),typeof v=="object"&&v!==null){switch(v.$$typeof){case gr:e:{for(var E=v.key,_=p;_!==null;){if(_.key===E){if(E=v.type,E===Wt){if(_.tag===7){n(m,_.sibling),p=l(_,v.props.children),p.return=m,m=p;break e}}else if(_.elementType===E||typeof E=="object"&&E!==null&&E.$$typeof===tt&&Gs(E)===_.type){n(m,_.sibling),p=l(_,v.props),p.ref=Cn(m,_,v),p.return=m,m=p;break e}n(m,_);break}else t(m,_);_=_.sibling}v.type===Wt?(p=Rt(v.props.children,m.mode,x,v.key),p.return=m,m=p):(x=Kr(v.type,v.key,v.props,null,m.mode,x),x.ref=Cn(m,p,v),x.return=m,m=x)}return o(m);case Bt:e:{for(_=v.key;p!==null;){if(p.key===_)if(p.tag===4&&p.stateNode.containerInfo===v.containerInfo&&p.stateNode.implementation===v.implementation){n(m,p.sibling),p=l(p,v.children||[]),p.return=m,m=p;break e}else{n(m,p);break}else t(m,p);p=p.sibling}p=ai(v,m.mode,x),p.return=m,m=p}return o(m);case tt:return _=v._init,C(m,p,_(v._payload),x)}if(Tn(v))return g(m,p,v,x);if(wn(v))return w(m,p,v,x);Pr(m,v)}return typeof v=="string"&&v!==""||typeof v=="number"?(v=""+v,p!==null&&p.tag===6?(n(m,p.sibling),p=l(p,v),p.return=m,m=p):(n(m,p),p=ui(v,m.mode,x),p.return=m,m=p),o(m)):n(m,p)}return C}var fn=tc(!0),nc=tc(!1),ol=wt(null),sl=null,Zt=null,zo=null;function Io(){zo=Zt=sl=null}function Oo(e){var t=ol.current;U(ol),e._currentValue=t}function Ai(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function on(e,t){sl=e,zo=Zt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(me=!0),e.firstContext=null)}function Te(e){var t=e._currentValue;if(zo!==e)if(e={context:e,memoizedValue:t,next:null},Zt===null){if(sl===null)throw Error(k(308));Zt=e,sl.dependencies={lanes:0,firstContext:e}}else Zt=Zt.next=e;return t}var Pt=null;function Mo(e){Pt===null?Pt=[e]:Pt.push(e)}function rc(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Mo(t)):(n.next=l.next,l.next=n),t.interleaved=n,Xe(e,r)}function Xe(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var nt=!1;function Fo(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function lc(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Ge(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function dt(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,M&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Xe(e,n)}return l=r.interleaved,l===null?(t.next=t,Mo(r)):(t.next=l.next,l.next=t),r.interleaved=t,Xe(e,n)}function Ar(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,So(e,n)}}function Js(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?l=i=o:i=i.next=o,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function ul(e,t,n,r){var l=e.updateQueue;nt=!1;var i=l.firstBaseUpdate,o=l.lastBaseUpdate,s=l.shared.pending;if(s!==null){l.shared.pending=null;var u=s,a=u.next;u.next=null,o===null?i=a:o.next=a,o=u;var h=e.alternate;h!==null&&(h=h.updateQueue,s=h.lastBaseUpdate,s!==o&&(s===null?h.firstBaseUpdate=a:s.next=a,h.lastBaseUpdate=u))}if(i!==null){var f=l.baseState;o=0,h=a=u=null,s=i;do{var d=s.lane,y=s.eventTime;if((r&d)===d){h!==null&&(h=h.next={eventTime:y,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});e:{var g=e,w=s;switch(d=t,y=n,w.tag){case 1:if(g=w.payload,typeof g=="function"){f=g.call(y,f,d);break e}f=g;break e;case 3:g.flags=g.flags&-65537|128;case 0:if(g=w.payload,d=typeof g=="function"?g.call(y,f,d):g,d==null)break e;f=W({},f,d);break e;case 2:nt=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,d=l.effects,d===null?l.effects=[s]:d.push(s))}else y={eventTime:y,lane:d,tag:s.tag,payload:s.payload,callback:s.callback,next:null},h===null?(a=h=y,u=f):h=h.next=y,o|=d;if(s=s.next,s===null){if(s=l.shared.pending,s===null)break;d=s,s=d.next,d.next=null,l.lastBaseUpdate=d,l.shared.pending=null}}while(!0);if(h===null&&(u=f),l.baseState=u,l.firstBaseUpdate=a,l.lastBaseUpdate=h,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);Mt|=o,e.lanes=o,e.memoizedState=f}}function Ys(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=ri.transition;ri.transition={};try{e(!1),t()}finally{F=n,ri.transition=r}}function Sc(){return Le().memoizedState}function yp(e,t,n){var r=ht(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},kc(e))Ec(t,n);else if(n=rc(e,t,n,r),n!==null){var l=fe();Fe(n,e,r,l),Nc(n,t,r)}}function xp(e,t,n){var r=ht(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(kc(e))Ec(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var o=t.lastRenderedState,s=i(o,n);if(l.hasEagerState=!0,l.eagerState=s,De(s,o)){var u=t.interleaved;u===null?(l.next=l,Mo(t)):(l.next=u.next,u.next=l),t.interleaved=l;return}}catch{}finally{}n=rc(e,t,l,r),n!==null&&(l=fe(),Fe(n,e,r,l),Nc(n,t,r))}}function kc(e){var t=e.alternate;return e===B||t!==null&&t===B}function Ec(e,t){$n=cl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Nc(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,So(e,n)}}var fl={readContext:Te,useCallback:oe,useContext:oe,useEffect:oe,useImperativeHandle:oe,useInsertionEffect:oe,useLayoutEffect:oe,useMemo:oe,useReducer:oe,useRef:oe,useState:oe,useDebugValue:oe,useDeferredValue:oe,useTransition:oe,useMutableSource:oe,useSyncExternalStore:oe,useId:oe,unstable_isNewReconciler:!1},wp={readContext:Te,useCallback:function(e,t){return Ue().memoizedState=[e,t===void 0?null:t],e},useContext:Te,useEffect:Zs,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Br(4194308,4,vc.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Br(4194308,4,e,t)},useInsertionEffect:function(e,t){return Br(4,2,e,t)},useMemo:function(e,t){var n=Ue();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ue();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=yp.bind(null,B,e),[r.memoizedState,e]},useRef:function(e){var t=Ue();return e={current:e},t.memoizedState=e},useState:Xs,useDebugValue:Ho,useDeferredValue:function(e){return Ue().memoizedState=e},useTransition:function(){var e=Xs(!1),t=e[0];return e=gp.bind(null,e[1]),Ue().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=B,l=Ue();if(A){if(n===void 0)throw Error(k(407));n=n()}else{if(n=t(),ne===null)throw Error(k(349));Ot&30||uc(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,Zs(cc.bind(null,r,i,e),[e]),r.flags|=2048,rr(9,ac.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=Ue(),t=ne.identifierPrefix;if(A){var n=Ke,r=Qe;n=(r&~(1<<32-Me(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=tr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[Ae]=t,e[qn]=r,Oc(e,t,!1,!1),t.stateNode=e;e:{switch(o=ki(n,r),n){case"dialog":$("cancel",e),$("close",e),l=r;break;case"iframe":case"object":case"embed":$("load",e),l=r;break;case"video":case"audio":for(l=0;lhn&&(t.flags|=128,r=!0,jn(i,!1),t.lanes=4194304)}else{if(!r)if(e=al(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),jn(i,!0),i.tail===null&&i.tailMode==="hidden"&&!o.alternate&&!A)return se(t),null}else 2*G()-i.renderingStartTime>hn&&n!==1073741824&&(t.flags|=128,r=!0,jn(i,!1),t.lanes=4194304);i.isBackwards?(o.sibling=t.child,t.child=o):(n=i.last,n!==null?n.sibling=o:t.child=o,i.last=o)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=G(),t.sibling=null,n=V.current,D(V,r?n&1|2:n&1),t):(se(t),null);case 22:case 23:return Xo(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?xe&1073741824&&(se(t),t.subtreeFlags&6&&(t.flags|=8192)):se(t),null;case 24:return null;case 25:return null}throw Error(k(156,t.tag))}function Pp(e,t){switch(Lo(t),t.tag){case 1:return ge(t.type)&&nl(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return dn(),U(ve),U(ae),Uo(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return $o(t),null;case 13:if(U(V),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(k(340));cn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return U(V),null;case 4:return dn(),null;case 10:return Oo(t.type._context),null;case 22:case 23:return Xo(),null;case 24:return null;default:return null}}var Lr=!1,ue=!1,Tp=typeof WeakSet=="function"?WeakSet:Set,j=null;function qt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){H(e,t,r)}else n.current=null}function Yi(e,t,n){try{n()}catch(r){H(e,t,r)}}var uu=!1;function Lp(e,t){if(zi=qr,e=Va(),Po(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var o=0,s=-1,u=-1,a=0,h=0,f=e,d=null;t:for(;;){for(var y;f!==n||l!==0&&f.nodeType!==3||(s=o+l),f!==i||r!==0&&f.nodeType!==3||(u=o+r),f.nodeType===3&&(o+=f.nodeValue.length),(y=f.firstChild)!==null;)d=f,f=y;for(;;){if(f===e)break t;if(d===n&&++a===l&&(s=o),d===i&&++h===r&&(u=o),(y=f.nextSibling)!==null)break;f=d,d=f.parentNode}f=y}n=s===-1||u===-1?null:{start:s,end:u}}else n=null}n=n||{start:0,end:0}}else n=null;for(Ii={focusedElem:e,selectionRange:n},qr=!1,j=t;j!==null;)if(t=j,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,j=e;else for(;j!==null;){t=j;try{var g=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(g!==null){var w=g.memoizedProps,C=g.memoizedState,m=t.stateNode,p=m.getSnapshotBeforeUpdate(t.elementType===t.type?w:ze(t.type,w),C);m.__reactInternalSnapshotBeforeUpdate=p}break;case 3:var v=t.stateNode.containerInfo;v.nodeType===1?v.textContent="":v.nodeType===9&&v.documentElement&&v.removeChild(v.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(k(163))}}catch(x){H(t,t.return,x)}if(e=t.sibling,e!==null){e.return=t.return,j=e;break}j=t.return}return g=uu,uu=!1,g}function Un(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&Yi(t,n,i)}l=l.next}while(l!==r)}}function _l(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Xi(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Dc(e){var t=e.alternate;t!==null&&(e.alternate=null,Dc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ae],delete t[qn],delete t[Fi],delete t[dp],delete t[pp])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function $c(e){return e.tag===5||e.tag===3||e.tag===4}function au(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||$c(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Zi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=tl));else if(r!==4&&(e=e.child,e!==null))for(Zi(e,t,n),e=e.sibling;e!==null;)Zi(e,t,n),e=e.sibling}function qi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(qi(e,t,n),e=e.sibling;e!==null;)qi(e,t,n),e=e.sibling}var re=null,Ie=!1;function et(e,t,n){for(n=n.child;n!==null;)Uc(e,t,n),n=n.sibling}function Uc(e,t,n){if(Ve&&typeof Ve.onCommitFiberUnmount=="function")try{Ve.onCommitFiberUnmount(xl,n)}catch{}switch(n.tag){case 5:ue||qt(n,t);case 6:var r=re,l=Ie;re=null,et(e,t,n),re=r,Ie=l,re!==null&&(Ie?(e=re,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):re.removeChild(n.stateNode));break;case 18:re!==null&&(Ie?(e=re,n=n.stateNode,e.nodeType===8?ei(e.parentNode,n):e.nodeType===1&&ei(e,n),Gn(e)):ei(re,n.stateNode));break;case 4:r=re,l=Ie,re=n.stateNode.containerInfo,Ie=!0,et(e,t,n),re=r,Ie=l;break;case 0:case 11:case 14:case 15:if(!ue&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,o=i.destroy;i=i.tag,o!==void 0&&(i&2||i&4)&&Yi(n,t,o),l=l.next}while(l!==r)}et(e,t,n);break;case 1:if(!ue&&(qt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(s){H(n,t,s)}et(e,t,n);break;case 21:et(e,t,n);break;case 22:n.mode&1?(ue=(r=ue)||n.memoizedState!==null,et(e,t,n),ue=r):et(e,t,n);break;default:et(e,t,n)}}function cu(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Tp),t.forEach(function(r){var l=Up.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Re(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~i}if(r=l,r=G()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*zp(r/1960))-r,10e?16:e,ot===null)var r=!1;else{if(e=ot,ot=null,hl=0,M&6)throw Error(k(331));var l=M;for(M|=4,j=e.current;j!==null;){var i=j,o=i.child;if(j.flags&16){var s=i.deletions;if(s!==null){for(var u=0;uG()-Jo?Lt(e,0):Go|=n),ye(e,t)}function Gc(e,t){t===0&&(e.mode&1?(t=Sr,Sr<<=1,!(Sr&130023424)&&(Sr=4194304)):t=1);var n=fe();e=Xe(e,t),e!==null&&(ur(e,t,n),ye(e,n))}function $p(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Gc(e,n)}function Up(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(k(314))}r!==null&&r.delete(t),Gc(e,n)}var Jc;Jc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ve.current)me=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return me=!1,jp(e,t,n);me=!!(e.flags&131072)}else me=!1,A&&t.flags&1048576&&qa(t,il,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Wr(e,t),e=t.pendingProps;var l=an(t,ae.current);on(t,n),l=Vo(null,t,r,e,l,n);var i=Bo();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,ge(r)?(i=!0,rl(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Fo(t),l.updater=jl,t.stateNode=l,l._reactInternals=t,Bi(t,r,e,n),t=Qi(null,t,r,!0,i,n)):(t.tag=0,A&&i&&To(t),ce(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Wr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Vp(r),e=ze(r,e),l){case 0:t=Hi(null,t,r,e,n);break e;case 1:t=iu(null,t,r,e,n);break e;case 11:t=ru(null,t,r,e,n);break e;case 14:t=lu(null,t,r,ze(r.type,e),n);break e}throw Error(k(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Hi(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),iu(e,t,r,l,n);case 3:e:{if(Rc(t),e===null)throw Error(k(387));r=t.pendingProps,i=t.memoizedState,l=i.element,lc(e,t),ul(t,r,null,n);var o=t.memoizedState;if(r=o.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=pn(Error(k(423)),t),t=ou(e,t,r,n,l);break e}else if(r!==l){l=pn(Error(k(424)),t),t=ou(e,t,r,n,l);break e}else for(we=ft(t.stateNode.containerInfo.firstChild),Se=t,A=!0,Oe=null,n=nc(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(cn(),r===l){t=Ze(e,t,n);break e}ce(e,t,r,n)}t=t.child}return t;case 5:return ic(t),e===null&&Ui(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,o=l.children,Oi(r,l)?o=null:i!==null&&Oi(r,i)&&(t.flags|=32),Lc(e,t),ce(e,t,o,n),t.child;case 6:return e===null&&Ui(t),null;case 13:return zc(e,t,n);case 4:return Do(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=fn(t,null,r,n):ce(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),ru(e,t,r,l,n);case 7:return ce(e,t,t.pendingProps,n),t.child;case 8:return ce(e,t,t.pendingProps.children,n),t.child;case 12:return ce(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,o=l.value,D(ol,r._currentValue),r._currentValue=o,i!==null)if(De(i.value,o)){if(i.children===l.children&&!ve.current){t=Ze(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var s=i.dependencies;if(s!==null){o=i.child;for(var u=s.firstContext;u!==null;){if(u.context===r){if(i.tag===1){u=Ge(-1,n&-n),u.tag=2;var a=i.updateQueue;if(a!==null){a=a.shared;var h=a.pending;h===null?u.next=u:(u.next=h.next,h.next=u),a.pending=u}}i.lanes|=n,u=i.alternate,u!==null&&(u.lanes|=n),Ai(i.return,n,t),s.lanes|=n;break}u=u.next}}else if(i.tag===10)o=i.type===t.type?null:i.child;else if(i.tag===18){if(o=i.return,o===null)throw Error(k(341));o.lanes|=n,s=o.alternate,s!==null&&(s.lanes|=n),Ai(o,n,t),o=i.sibling}else o=i.child;if(o!==null)o.return=i;else for(o=i;o!==null;){if(o===t){o=null;break}if(i=o.sibling,i!==null){i.return=o.return,o=i;break}o=o.return}i=o}ce(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,on(t,n),l=Te(l),r=r(l),t.flags|=1,ce(e,t,r,n),t.child;case 14:return r=t.type,l=ze(r,t.pendingProps),l=ze(r.type,l),lu(e,t,r,l,n);case 15:return Pc(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Wr(e,t),t.tag=1,ge(r)?(e=!0,rl(t)):e=!1,on(t,n),Cc(t,r,l),Bi(t,r,l,n),Qi(null,t,r,!0,e,n);case 19:return Ic(e,t,n);case 22:return Tc(e,t,n)}throw Error(k(156,t.tag))};function Yc(e,t){return ka(e,t)}function Ap(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function _e(e,t,n,r){return new Ap(e,t,n,r)}function qo(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Vp(e){if(typeof e=="function")return qo(e)?1:0;if(e!=null){if(e=e.$$typeof,e===go)return 11;if(e===yo)return 14}return 2}function mt(e,t){var n=e.alternate;return n===null?(n=_e(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Kr(e,t,n,r,l,i){var o=2;if(r=e,typeof e=="function")qo(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case Wt:return Rt(n.children,l,i,t);case vo:o=8,l|=8;break;case di:return e=_e(12,n,t,l|2),e.elementType=di,e.lanes=i,e;case pi:return e=_e(13,n,t,l),e.elementType=pi,e.lanes=i,e;case hi:return e=_e(19,n,t,l),e.elementType=hi,e.lanes=i,e;case ia:return Tl(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case ra:o=10;break e;case la:o=9;break e;case go:o=11;break e;case yo:o=14;break e;case tt:o=16,r=null;break e}throw Error(k(130,e==null?e:typeof e,""))}return t=_e(o,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function Rt(e,t,n,r){return e=_e(7,e,r,t),e.lanes=n,e}function Tl(e,t,n,r){return e=_e(22,e,r,t),e.elementType=ia,e.lanes=n,e.stateNode={isHidden:!1},e}function ui(e,t,n){return e=_e(6,e,null,t),e.lanes=n,e}function ai(e,t,n){return t=_e(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Bp(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Wl(0),this.expirationTimes=Wl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Wl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function bo(e,t,n,r,l,i,o,s,u){return e=new Bp(e,t,n,s,u),t===1?(t=1,i===!0&&(t|=8)):t=0,i=_e(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Fo(i),e}function Wp(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(bc)}catch(e){console.error(e)}}bc(),bu.exports=Ee;var Jp=bu.exports,ef,yu=Jp;ef=yu.createRoot,yu.hydrateRoot;/** - * @remix-run/router v1.23.2 - * - * Copyright (c) Remix Software Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE.md file in the root directory of this source tree. - * - * @license MIT - */function ir(){return ir=Object.assign?Object.assign.bind():function(e){for(var t=1;t"u")throw new Error(t)}function rs(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function Xp(){return Math.random().toString(36).substr(2,8)}function wu(e,t){return{usr:e.state,key:e.key,idx:t}}function ro(e,t,n,r){return n===void 0&&(n=null),ir({pathname:typeof e=="string"?e:e.pathname,search:"",hash:""},typeof t=="string"?yn(t):t,{state:n,key:t&&t.key||r||Xp()})}function gl(e){let{pathname:t="/",search:n="",hash:r=""}=e;return n&&n!=="?"&&(t+=n.charAt(0)==="?"?n:"?"+n),r&&r!=="#"&&(t+=r.charAt(0)==="#"?r:"#"+r),t}function yn(e){let t={};if(e){let n=e.indexOf("#");n>=0&&(t.hash=e.substr(n),e=e.substr(0,n));let r=e.indexOf("?");r>=0&&(t.search=e.substr(r),e=e.substr(0,r)),e&&(t.pathname=e)}return t}function Zp(e,t,n,r){r===void 0&&(r={});let{window:l=document.defaultView,v5Compat:i=!1}=r,o=l.history,s=st.Pop,u=null,a=h();a==null&&(a=0,o.replaceState(ir({},o.state,{idx:a}),""));function h(){return(o.state||{idx:null}).idx}function f(){s=st.Pop;let C=h(),m=C==null?null:C-a;a=C,u&&u({action:s,location:w.location,delta:m})}function d(C,m){s=st.Push;let p=ro(w.location,C,m);a=h()+1;let v=wu(p,a),x=w.createHref(p);try{o.pushState(v,"",x)}catch(E){if(E instanceof DOMException&&E.name==="DataCloneError")throw E;l.location.assign(x)}i&&u&&u({action:s,location:w.location,delta:1})}function y(C,m){s=st.Replace;let p=ro(w.location,C,m);a=h();let v=wu(p,a),x=w.createHref(p);o.replaceState(v,"",x),i&&u&&u({action:s,location:w.location,delta:0})}function g(C){let m=l.location.origin!=="null"?l.location.origin:l.location.href,p=typeof C=="string"?C:gl(C);return p=p.replace(/ $/,"%20"),J(m,"No window.location.(origin|href) available to create URL for href: "+p),new URL(p,m)}let w={get action(){return s},get location(){return e(l,o)},listen(C){if(u)throw new Error("A history only accepts one active listener");return l.addEventListener(xu,f),u=C,()=>{l.removeEventListener(xu,f),u=null}},createHref(C){return t(l,C)},createURL:g,encodeLocation(C){let m=g(C);return{pathname:m.pathname,search:m.search,hash:m.hash}},push:d,replace:y,go(C){return o.go(C)}};return w}var Su;(function(e){e.data="data",e.deferred="deferred",e.redirect="redirect",e.error="error"})(Su||(Su={}));function qp(e,t,n){return n===void 0&&(n="/"),bp(e,t,n)}function bp(e,t,n,r){let l=typeof t=="string"?yn(t):t,i=ls(l.pathname||"/",n);if(i==null)return null;let o=tf(e);eh(o);let s=null;for(let u=0;s==null&&u{let u={relativePath:s===void 0?i.path||"":s,caseSensitive:i.caseSensitive===!0,childrenIndex:o,route:i};u.relativePath.startsWith("/")&&(J(u.relativePath.startsWith(r),'Absolute route path "'+u.relativePath+'" nested under path '+('"'+r+'" is not valid. An absolute child route path ')+"must start with the combined path of all its parent routes."),u.relativePath=u.relativePath.slice(r.length));let a=vt([r,u.relativePath]),h=n.concat(u);i.children&&i.children.length>0&&(J(i.index!==!0,"Index routes must not have child routes. Please remove "+('all child routes from route path "'+a+'".')),tf(i.children,t,h,a)),!(i.path==null&&!i.index)&&t.push({path:a,score:sh(a,i.index),routesMeta:h})};return e.forEach((i,o)=>{var s;if(i.path===""||!((s=i.path)!=null&&s.includes("?")))l(i,o);else for(let u of nf(i.path))l(i,o,u)}),t}function nf(e){let t=e.split("/");if(t.length===0)return[];let[n,...r]=t,l=n.endsWith("?"),i=n.replace(/\?$/,"");if(r.length===0)return l?[i,""]:[i];let o=nf(r.join("/")),s=[];return s.push(...o.map(u=>u===""?i:[i,u].join("/"))),l&&s.push(...o),s.map(u=>e.startsWith("/")&&u===""?"/":u)}function eh(e){e.sort((t,n)=>t.score!==n.score?n.score-t.score:uh(t.routesMeta.map(r=>r.childrenIndex),n.routesMeta.map(r=>r.childrenIndex)))}const th=/^:[\w-]+$/,nh=3,rh=2,lh=1,ih=10,oh=-2,ku=e=>e==="*";function sh(e,t){let n=e.split("/"),r=n.length;return n.some(ku)&&(r+=oh),t&&(r+=rh),n.filter(l=>!ku(l)).reduce((l,i)=>l+(th.test(i)?nh:i===""?lh:ih),r)}function uh(e,t){return e.length===t.length&&e.slice(0,-1).every((r,l)=>r===t[l])?e[e.length-1]-t[t.length-1]:0}function ah(e,t,n){let{routesMeta:r}=e,l={},i="/",o=[];for(let s=0;s{let{paramName:d,isOptional:y}=h;if(d==="*"){let w=s[f]||"";o=i.slice(0,i.length-w.length).replace(/(.)\/+$/,"$1")}const g=s[f];return y&&!g?a[d]=void 0:a[d]=(g||"").replace(/%2F/g,"/"),a},{}),pathname:i,pathnameBase:o,pattern:e}}function fh(e,t,n){t===void 0&&(t=!1),n===void 0&&(n=!0),rs(e==="*"||!e.endsWith("*")||e.endsWith("/*"),'Route path "'+e+'" will be treated as if it were '+('"'+e.replace(/\*$/,"/*")+'" because the `*` character must ')+"always follow a `/` in the pattern. To get rid of this warning, "+('please change the route path to "'+e.replace(/\*$/,"/*")+'".'));let r=[],l="^"+e.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(o,s,u)=>(r.push({paramName:s,isOptional:u!=null}),u?"/?([^\\/]+)?":"/([^\\/]+)"));return e.endsWith("*")?(r.push({paramName:"*"}),l+=e==="*"||e==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):n?l+="\\/*$":e!==""&&e!=="/"&&(l+="(?:(?=\\/|$))"),[new RegExp(l,t?void 0:"i"),r]}function dh(e){try{return e.split("/").map(t=>decodeURIComponent(t).replace(/\//g,"%2F")).join("/")}catch(t){return rs(!1,'The URL path "'+e+'" could not be decoded because it is is a malformed URL segment. This is probably due to a bad percent '+("encoding ("+t+").")),e}}function ls(e,t){if(t==="/")return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith("/")?t.length-1:t.length,r=e.charAt(n);return r&&r!=="/"?null:e.slice(n)||"/"}const ph=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,hh=e=>ph.test(e);function mh(e,t){t===void 0&&(t="/");let{pathname:n,search:r="",hash:l=""}=typeof e=="string"?yn(e):e,i;if(n)if(hh(n))i=n;else{if(n.includes("//")){let o=n;n=n.replace(/\/\/+/g,"/"),rs(!1,"Pathnames cannot have embedded double slashes - normalizing "+(o+" -> "+n))}n.startsWith("/")?i=Eu(n.substring(1),"/"):i=Eu(n,t)}else i=t;return{pathname:i,search:yh(r),hash:xh(l)}}function Eu(e,t){let n=t.replace(/\/+$/,"").split("/");return e.split("/").forEach(l=>{l===".."?n.length>1&&n.pop():l!=="."&&n.push(l)}),n.length>1?n.join("/"):"/"}function ci(e,t,n,r){return"Cannot include a '"+e+"' character in a manually specified "+("`to."+t+"` field ["+JSON.stringify(r)+"]. Please separate it out to the ")+("`to."+n+"` field. Alternatively you may provide the full path as ")+'a string in and the router will parse it for you.'}function vh(e){return e.filter((t,n)=>n===0||t.route.path&&t.route.path.length>0)}function is(e,t){let n=vh(e);return t?n.map((r,l)=>l===n.length-1?r.pathname:r.pathnameBase):n.map(r=>r.pathnameBase)}function os(e,t,n,r){r===void 0&&(r=!1);let l;typeof e=="string"?l=yn(e):(l=ir({},e),J(!l.pathname||!l.pathname.includes("?"),ci("?","pathname","search",l)),J(!l.pathname||!l.pathname.includes("#"),ci("#","pathname","hash",l)),J(!l.search||!l.search.includes("#"),ci("#","search","hash",l)));let i=e===""||l.pathname==="",o=i?"/":l.pathname,s;if(o==null)s=n;else{let f=t.length-1;if(!r&&o.startsWith("..")){let d=o.split("/");for(;d[0]==="..";)d.shift(),f-=1;l.pathname=d.join("/")}s=f>=0?t[f]:"/"}let u=mh(l,s),a=o&&o!=="/"&&o.endsWith("/"),h=(i||o===".")&&n.endsWith("/");return!u.pathname.endsWith("/")&&(a||h)&&(u.pathname+="/"),u}const vt=e=>e.join("/").replace(/\/\/+/g,"/"),gh=e=>e.replace(/\/+$/,"").replace(/^\/*/,"/"),yh=e=>!e||e==="?"?"":e.startsWith("?")?e:"?"+e,xh=e=>!e||e==="#"?"":e.startsWith("#")?e:"#"+e;function wh(e){return e!=null&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.internal=="boolean"&&"data"in e}const rf=["post","put","patch","delete"];new Set(rf);const Sh=["get",...rf];new Set(Sh);/** - * React Router v6.30.3 - * - * Copyright (c) Remix Software Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE.md file in the root directory of this source tree. - * - * @license MIT - */function or(){return or=Object.assign?Object.assign.bind():function(e){for(var t=1;t{s.current=!0}),S.useCallback(function(a,h){if(h===void 0&&(h={}),!s.current)return;if(typeof a=="number"){r.go(a);return}let f=os(a,JSON.parse(o),i,h.relative==="path");e==null&&t!=="/"&&(f.pathname=f.pathname==="/"?t:vt([t,f.pathname])),(h.replace?r.replace:r.push)(f,h.state,h)},[t,r,o,i,e])}function sf(){let{matches:e}=S.useContext(be),t=e[e.length-1];return t?t.params:{}}function uf(e,t){let{relative:n}=t===void 0?{}:t,{future:r}=S.useContext(kt),{matches:l}=S.useContext(be),{pathname:i}=dr(),o=JSON.stringify(is(l,r.v7_relativeSplatPath));return S.useMemo(()=>os(e,JSON.parse(o),i,n==="path"),[e,o,i,n])}function Ch(e,t){return jh(e,t)}function jh(e,t,n,r){xn()||J(!1);let{navigator:l}=S.useContext(kt),{matches:i}=S.useContext(be),o=i[i.length-1],s=o?o.params:{};o&&o.pathname;let u=o?o.pathnameBase:"/";o&&o.route;let a=dr(),h;if(t){var f;let C=typeof t=="string"?yn(t):t;u==="/"||(f=C.pathname)!=null&&f.startsWith(u)||J(!1),h=C}else h=a;let d=h.pathname||"/",y=d;if(u!=="/"){let C=u.replace(/^\//,"").split("/");y="/"+d.replace(/^\//,"").split("/").slice(C.length).join("/")}let g=qp(e,{pathname:y}),w=Rh(g&&g.map(C=>Object.assign({},C,{params:Object.assign({},s,C.params),pathname:vt([u,l.encodeLocation?l.encodeLocation(C.pathname).pathname:C.pathname]),pathnameBase:C.pathnameBase==="/"?u:vt([u,l.encodeLocation?l.encodeLocation(C.pathnameBase).pathname:C.pathnameBase])})),i,n,r);return t&&w?S.createElement(Ol.Provider,{value:{location:or({pathname:"/",search:"",hash:"",state:null,key:"default"},h),navigationType:st.Pop}},w):w}function _h(){let e=Mh(),t=wh(e)?e.status+" "+e.statusText:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,l={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"};return S.createElement(S.Fragment,null,S.createElement("h2",null,"Unexpected Application Error!"),S.createElement("h3",{style:{fontStyle:"italic"}},t),n?S.createElement("pre",{style:l},n):null,null)}const Ph=S.createElement(_h,null);class Th extends S.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,n){return n.location!==t.location||n.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error!==void 0?t.error:n.error,location:n.location,revalidation:t.revalidation||n.revalidation}}componentDidCatch(t,n){console.error("React Router caught the following error during render",t,n)}render(){return this.state.error!==void 0?S.createElement(be.Provider,{value:this.props.routeContext},S.createElement(lf.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function Lh(e){let{routeContext:t,match:n,children:r}=e,l=S.useContext(ss);return l&&l.static&&l.staticContext&&(n.route.errorElement||n.route.ErrorBoundary)&&(l.staticContext._deepestRenderedBoundaryId=n.route.id),S.createElement(be.Provider,{value:t},r)}function Rh(e,t,n,r){var l;if(t===void 0&&(t=[]),n===void 0&&(n=null),r===void 0&&(r=null),e==null){var i;if(!n)return null;if(n.errors)e=n.matches;else if((i=r)!=null&&i.v7_partialHydration&&t.length===0&&!n.initialized&&n.matches.length>0)e=n.matches;else return null}let o=e,s=(l=n)==null?void 0:l.errors;if(s!=null){let h=o.findIndex(f=>f.route.id&&(s==null?void 0:s[f.route.id])!==void 0);h>=0||J(!1),o=o.slice(0,Math.min(o.length,h+1))}let u=!1,a=-1;if(n&&r&&r.v7_partialHydration)for(let h=0;h=0?o=o.slice(0,a+1):o=[o[0]];break}}}return o.reduceRight((h,f,d)=>{let y,g=!1,w=null,C=null;n&&(y=s&&f.route.id?s[f.route.id]:void 0,w=f.route.errorElement||Ph,u&&(a<0&&d===0?(Dh("route-fallback"),g=!0,C=null):a===d&&(g=!0,C=f.route.hydrateFallbackElement||null)));let m=t.concat(o.slice(0,d+1)),p=()=>{let v;return y?v=w:g?v=C:f.route.Component?v=S.createElement(f.route.Component,null):f.route.element?v=f.route.element:v=h,S.createElement(Lh,{match:f,routeContext:{outlet:h,matches:m,isDataRoute:n!=null},children:v})};return n&&(f.route.ErrorBoundary||f.route.errorElement||d===0)?S.createElement(Th,{location:n.location,revalidation:n.revalidation,component:w,error:y,children:p(),routeContext:{outlet:null,matches:m,isDataRoute:!0}}):p()},null)}var af=function(e){return e.UseBlocker="useBlocker",e.UseRevalidator="useRevalidator",e.UseNavigateStable="useNavigate",e}(af||{}),cf=function(e){return e.UseBlocker="useBlocker",e.UseLoaderData="useLoaderData",e.UseActionData="useActionData",e.UseRouteError="useRouteError",e.UseNavigation="useNavigation",e.UseRouteLoaderData="useRouteLoaderData",e.UseMatches="useMatches",e.UseRevalidator="useRevalidator",e.UseNavigateStable="useNavigate",e.UseRouteId="useRouteId",e}(cf||{});function zh(e){let t=S.useContext(ss);return t||J(!1),t}function Ih(e){let t=S.useContext(kh);return t||J(!1),t}function Oh(e){let t=S.useContext(be);return t||J(!1),t}function ff(e){let t=Oh(),n=t.matches[t.matches.length-1];return n.route.id||J(!1),n.route.id}function Mh(){var e;let t=S.useContext(lf),n=Ih(),r=ff();return t!==void 0?t:(e=n.errors)==null?void 0:e[r]}function Fh(){let{router:e}=zh(af.UseNavigateStable),t=ff(cf.UseNavigateStable),n=S.useRef(!1);return of(()=>{n.current=!0}),S.useCallback(function(l,i){i===void 0&&(i={}),n.current&&(typeof l=="number"?e.navigate(l):e.navigate(l,or({fromRouteId:t},i)))},[e,t])}const Nu={};function Dh(e,t,n){Nu[e]||(Nu[e]=!0)}function $h(e,t){e==null||e.v7_startTransition,e==null||e.v7_relativeSplatPath}function Uh(e){let{to:t,replace:n,state:r,relative:l}=e;xn()||J(!1);let{future:i,static:o}=S.useContext(kt),{matches:s}=S.useContext(be),{pathname:u}=dr(),a=us(),h=os(t,is(s,i.v7_relativeSplatPath),u,l==="path"),f=JSON.stringify(h);return S.useEffect(()=>a(JSON.parse(f),{replace:n,state:r,relative:l}),[a,f,l,n,r]),null}function zn(e){J(!1)}function Ah(e){let{basename:t="/",children:n=null,location:r,navigationType:l=st.Pop,navigator:i,static:o=!1,future:s}=e;xn()&&J(!1);let u=t.replace(/^\/*/,"/"),a=S.useMemo(()=>({basename:u,navigator:i,static:o,future:or({v7_relativeSplatPath:!1},s)}),[u,s,i,o]);typeof r=="string"&&(r=yn(r));let{pathname:h="/",search:f="",hash:d="",state:y=null,key:g="default"}=r,w=S.useMemo(()=>{let C=ls(h,u);return C==null?null:{location:{pathname:C,search:f,hash:d,state:y,key:g},navigationType:l}},[u,h,f,d,y,g,l]);return w==null?null:S.createElement(kt.Provider,{value:a},S.createElement(Ol.Provider,{children:n,value:w}))}function Vh(e){let{children:t,location:n}=e;return Ch(lo(t),n)}new Promise(()=>{});function lo(e,t){t===void 0&&(t=[]);let n=[];return S.Children.forEach(e,(r,l)=>{if(!S.isValidElement(r))return;let i=[...t,l];if(r.type===S.Fragment){n.push.apply(n,lo(r.props.children,i));return}r.type!==zn&&J(!1),!r.props.index||!r.props.children||J(!1);let o={id:r.props.id||i.join("-"),caseSensitive:r.props.caseSensitive,element:r.props.element,Component:r.props.Component,index:r.props.index,path:r.props.path,loader:r.props.loader,action:r.props.action,errorElement:r.props.errorElement,ErrorBoundary:r.props.ErrorBoundary,hasErrorBoundary:r.props.ErrorBoundary!=null||r.props.errorElement!=null,shouldRevalidate:r.props.shouldRevalidate,handle:r.props.handle,lazy:r.props.lazy};r.props.children&&(o.children=lo(r.props.children,i)),n.push(o)}),n}/** - * React Router DOM v6.30.3 - * - * Copyright (c) Remix Software Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE.md file in the root directory of this source tree. - * - * @license MIT - */function io(){return io=Object.assign?Object.assign.bind():function(e){for(var t=1;t=0)&&(n[l]=e[l]);return n}function Wh(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function Hh(e,t){return e.button===0&&(!t||t==="_self")&&!Wh(e)}const Qh=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],Kh="6";try{window.__reactRouterVersion=Kh}catch{}const Gh="startTransition",Cu=Df[Gh];function Jh(e){let{basename:t,children:n,future:r,window:l}=e,i=S.useRef();i.current==null&&(i.current=Yp({window:l,v5Compat:!0}));let o=i.current,[s,u]=S.useState({action:o.action,location:o.location}),{v7_startTransition:a}=r||{},h=S.useCallback(f=>{a&&Cu?Cu(()=>u(f)):u(f)},[u,a]);return S.useLayoutEffect(()=>o.listen(h),[o,h]),S.useEffect(()=>$h(r),[r]),S.createElement(Ah,{basename:t,children:n,location:s.location,navigationType:s.action,navigator:o,future:r})}const Yh=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",Xh=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,df=S.forwardRef(function(t,n){let{onClick:r,relative:l,reloadDocument:i,replace:o,state:s,target:u,to:a,preventScrollReset:h,viewTransition:f}=t,d=Bh(t,Qh),{basename:y}=S.useContext(kt),g,w=!1;if(typeof a=="string"&&Xh.test(a)&&(g=a,Yh))try{let v=new URL(window.location.href),x=a.startsWith("//")?new URL(v.protocol+a):new URL(a),E=ls(x.pathname,y);x.origin===v.origin&&E!=null?a=E+x.search+x.hash:w=!0}catch{}let C=Eh(a,{relative:l}),m=Zh(a,{replace:o,state:s,target:u,preventScrollReset:h,relative:l,viewTransition:f});function p(v){r&&r(v),v.defaultPrevented||m(v)}return S.createElement("a",io({},d,{href:g||C,onClick:w||i?r:p,ref:n,target:u}))});var ju;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(ju||(ju={}));var _u;(function(e){e.UseFetcher="useFetcher",e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"})(_u||(_u={}));function Zh(e,t){let{target:n,replace:r,state:l,preventScrollReset:i,relative:o,viewTransition:s}=t===void 0?{}:t,u=us(),a=dr(),h=uf(e,{relative:o});return S.useCallback(f=>{if(Hh(f,n)){f.preventDefault();let d=r!==void 0?r:gl(a)===gl(h);u(e,{replace:d,state:l,preventScrollReset:i,relative:o,viewTransition:s})}},[a,u,h,r,l,n,e,i,o,s])}async function as(e){const t=await fetch(e,{headers:{Accept:"application/json"}});if(!t.ok)throw new Error(`request failed: ${t.status} ${t.statusText} (${e})`);return await t.json()}function qh(){return as("/api/runs")}function bh(e){return as(`/api/runs/${encodeURIComponent(e)}`)}function em(e,t){return as(`/api/runs/${encodeURIComponent(e)}/steps/${t}`)}function Pu(e,t){return`/api/runs/${encodeURIComponent(e)}/screenshots/${encodeURIComponent(t)}`}function tm(e,t){S.useEffect(()=>{if(typeof EventSource>"u")return;const n=new EventSource("/api/events"),r=()=>{t()};return n.addEventListener(e,r),()=>{n.removeEventListener(e,r),n.close()}},[e,t])}function nm(e){if(!e)return"-";if(e<1e3)return`${e}ms`;const t=e/1e3;if(t<60)return`${t.toFixed(1)}s`;const n=Math.floor(t/60),r=Math.round(t-n*60);return`${n}m${r}s`}function rm(e){const t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString()}function lm(){const[e,t]=S.useState(null),[n,r]=S.useState(null),l=S.useCallback(()=>{qh().then(t).catch(i=>{r(i instanceof Error?i.message:String(i))})},[]);return S.useEffect(()=>{l()},[l]),tm("runs.changed",l),n?c.jsxs("div",{className:"status-block status-error",children:["failed to load runs: ",n]}):e?e.length===0?c.jsx("div",{className:"status-block",children:"no runs yet. run `sanderling test` to produce some."}):c.jsxs("table",{className:"run-table",children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"started"}),c.jsx("th",{children:"spec"}),c.jsx("th",{children:"seed"}),c.jsx("th",{children:"platform"}),c.jsx("th",{children:"duration"}),c.jsx("th",{children:"steps"}),c.jsx("th",{children:"violations"}),c.jsx("th",{})]})}),c.jsx("tbody",{children:e.map(i=>c.jsxs("tr",{children:[c.jsx("td",{children:c.jsx(df,{to:`/runs/${i.id}`,children:rm(i.started_at)})}),c.jsx("td",{children:i.spec_path}),c.jsx("td",{children:i.seed}),c.jsx("td",{children:i.platform}),c.jsx("td",{children:nm(i.duration_millis)}),c.jsx("td",{children:i.step_count}),c.jsx("td",{children:i.violation_count>0?c.jsx("span",{className:"chip chip-violation",children:i.violation_count}):"0"}),c.jsx("td",{children:i.in_progress?c.jsx("span",{className:"chip chip-progress",children:"in progress"}):null})]},i.id))})]}):c.jsx("div",{className:"status-block",children:"loading runs..."})}const im=["id","text","textPrefix","desc","descPrefix","class","classPrefix","package"];function pf(e){const t=e.indexOf(":");if(t<=0)return null;const n=e.slice(0,t),r=e.slice(t+1);return im.includes(n)?{kind:n,value:r}:null}function om(e){const t=pf(e);return t?t.kind.endsWith("Prefix")?`${t.value}...`:t.value:e}function sm(e){const t=e.action_kind,n=e.action_label??"";if(!t)return e.screen?{verb:"Observe",target:`@ ${e.screen}`,targetIsTag:!1}:{verb:"Observe",target:"",targetIsTag:!1};switch(t){case"Tap":return n?n.startsWith("(")&&n.endsWith(")")?{verb:"Click",target:n,targetIsTag:!1}:pf(n)?{verb:"Click",target:om(n),targetIsTag:!0}:{verb:"Click",target:n,targetIsTag:!1}:{verb:"Click",target:"",targetIsTag:!1};case"InputText":return{verb:"Type",target:n,targetIsTag:!1};case"Swipe":return{verb:"Swipe",target:n,targetIsTag:!0};case"PressKey":return{verb:"Press",target:n,targetIsTag:!0};case"Wait":return{verb:"Wait",target:n,targetIsTag:!0};default:return{verb:t,target:n,targetIsTag:!1}}}function um(e){const t=Math.max(0,Math.floor(e)),n=Math.floor(t/1e3),r=Math.floor(n/60),l=n%60,i=t%1e3,o=u=>String(u).padStart(2,"0"),s=u=>String(u).padStart(3,"0");return`${o(r)}:${o(l)}.${s(i)}`}function am(e,t){return e?t?c.jsx("span",{className:"action-list-target action-list-target-tag",children:`<${e}/>`}):c.jsx("span",{className:"action-list-target",children:e}):null}function cm(e){const t=e.action;return t&&typeof t.text=="string"?t.text:""}function fm(e){const t=e.action;return t?t.tap_point?`${t.tap_point.x.toFixed(1)}, ${t.tap_point.y.toFixed(1)}`:typeof t.x=="number"&&typeof t.y=="number"?`${t.x.toFixed(1)}, ${t.y.toFixed(1)}`:typeof t.from_x=="number"&&typeof t.from_y=="number"?`${t.from_x.toFixed(1)}, ${t.from_y.toFixed(1)}`:null:null}function dm({steps:e,selectedIndex:t,onSelect:n,runStartMillis:r,selectedStep:l}){const i=S.useRef(new Map),o=r??(e[0]?new Date(e[0].timestamp).getTime():0);S.useEffect(()=>{const a=i.current.get(t);a&&typeof a.scrollIntoView=="function"&&a.scrollIntoView({block:"nearest"})},[t]);const s=a=>{const h=i.current.get(a);h&&typeof h.focus=="function"&&h.focus()},u=(a,h)=>{var d,y;const f=e.findIndex(g=>g.index===h);switch(a.key){case"Enter":case" ":a.preventDefault(),n(h);return;case"ArrowDown":{if(f<0||f>=e.length-1)return;a.preventDefault();const g=e[f+1].index;n(g),s(g);return}case"ArrowUp":{if(f<=0)return;a.preventDefault();const g=e[f-1].index;n(g),s(g);return}case"Home":{a.preventDefault();const g=(d=e[0])==null?void 0:d.index;g!==void 0&&(n(g),s(g));return}case"End":{a.preventDefault();const g=(y=e[e.length-1])==null?void 0:y.index;g!==void 0&&(n(g),s(g));return}}};return c.jsx("ol",{className:"action-list",role:"listbox","aria-label":"Steps",children:e.map(a=>{const h=a.index===t,{verb:f,target:d,targetIsTag:y}=sm(a),g=new Date(a.timestamp).getTime()-o,w=um(g),C=d?y?`<${d}/>`:d:"",m=C?`Step ${a.index} ${f} ${C}`:`Step ${a.index} ${f}`,p=h&&l&&l.step===a.index,v=p?fm(l):null,x=p?cm(l):"";return c.jsxs("li",{ref:E=>{E?i.current.set(a.index,E):i.current.delete(a.index)},className:"action-list-item",role:"option",tabIndex:h?0:-1,"aria-selected":h,"aria-label":m,"data-active":h?"true":"false","data-violations":a.has_violations?"true":"false","data-exceptions":a.has_exceptions?"true":"false",onClick:()=>n(a.index),onKeyDown:E=>u(E,a.index),title:d||void 0,children:[c.jsxs("div",{className:"action-list-row",children:[c.jsxs("span",{className:"action-list-index",children:[a.index,"."]}),c.jsxs("span",{className:"action-list-body",children:[c.jsx("span",{className:"action-list-verb",children:f}),am(d,y)]}),c.jsxs("span",{className:"action-list-markers",children:[a.has_exceptions?c.jsx("span",{className:"action-list-marker-exception","aria-label":"exceptions",role:"img"}):null,a.has_violations?c.jsx("span",{className:"action-list-marker-violation","aria-label":"violations",role:"img"}):null]}),c.jsx("span",{className:"action-list-elapsed",children:w})]}),p?c.jsxs("div",{className:"action-list-details",children:[c.jsxs("div",{className:"action-list-detail-row",children:[c.jsx("span",{className:"action-list-detail-label",children:"Position"}),c.jsx("span",{className:"action-list-detail-value",children:v??""})]}),c.jsxs("div",{className:"action-list-detail-row",children:[c.jsx("span",{className:"action-list-detail-label",children:"Content"}),c.jsx("span",{className:"action-list-detail-value",children:x||'""'})]})]}):null]},a.index)})})}function Tu({hierarchy:e}){const[t,n]=S.useState(""),r=(e==null?void 0:e.elements)??[],l=S.useMemo(()=>{const i=t.trim().toLowerCase();return i?r.filter(o=>hm(o,i)):r},[r,t]);return r.length===0?c.jsx("div",{className:"hierarchy-empty",children:"no hierarchy captured"}):c.jsxs("div",{className:"hierarchy-panel",children:[c.jsx("input",{type:"text",className:"hierarchy-filter",placeholder:"filter by id, text, desc, class, tag...",value:t,onChange:i=>n(i.target.value)}),c.jsx("div",{className:"hierarchy-table-wrap",children:c.jsxs("table",{className:"hierarchy-table",children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"tag/class"}),c.jsx("th",{children:"id"}),c.jsx("th",{children:"text/desc"}),c.jsx("th",{children:"bounds"})]})}),c.jsx("tbody",{children:l.map((i,o)=>c.jsx(pm,{element:i},o))})]})}),c.jsxs("div",{className:"hierarchy-count",children:[l.length," of ",r.length]})]})}function pm({element:e}){const t=e.tag??e.class??"",n=(e.text??e.description??"").slice(0,80),r=e.bounds;return c.jsxs("tr",{children:[c.jsx("td",{className:"hierarchy-tag",children:t}),c.jsx("td",{className:"hierarchy-id",children:e.resourceId??""}),c.jsx("td",{className:"hierarchy-text",children:n}),c.jsxs("td",{className:"hierarchy-bounds",children:["[",r.left,",",r.top,",",r.right,",",r.bottom,"]"]})]})}function hm(e,t){const n=[e.resourceId,e.text,e.description,e.class,e.tag];for(const r of n)if(r&&r.toLowerCase().includes(t))return!0;if(e.attributes){for(const r of Object.values(e.attributes))if(r.toLowerCase().includes(t))return!0}return!1}const mm=1080,vm=1920;function Lu({src:e,action:t,deviceWidth:n,deviceHeight:r}){const[l,i]=S.useState(null),[o,s]=S.useState(!1),u=S.useRef(e),h=`screenshot-spotlight-${S.useId().replace(/:/g,"")}`;if(S.useEffect(()=>{u.current!==e&&(u.current=e,s(!1),i(null))},[e]),!e||o)return c.jsx("div",{className:"screenshot-placeholder","data-testid":"screenshot-placeholder",children:"no screenshot"});const f=n??(l==null?void 0:l.width)??mm,d=r??(l==null?void 0:l.height)??vm,y=Math.max(2,f/200),g=Math.max(12,f/60),w=Math.max(18,f/40),C=g*3,m=t==null?void 0:t.resolved_bounds,p=t==null?void 0:t.tap_point,v=(t==null?void 0:t.kind)==="Swipe"&&t.from_x!==void 0&&t.from_y!==void 0&&t.to_x!==void 0&&t.to_y!==void 0;return c.jsxs("div",{className:"screenshot-frame",style:{aspectRatio:`${f} / ${d}`},children:[c.jsx("img",{className:"screenshot-image",src:e,alt:"device screenshot",onLoad:x=>{const E=x.currentTarget;E.naturalWidth>0&&E.naturalHeight>0&&i({width:E.naturalWidth,height:E.naturalHeight})},onError:()=>s(!0)}),c.jsxs("svg",{className:"screenshot-overlay",viewBox:`0 0 ${f} ${d}`,preserveAspectRatio:"xMidYMid meet","aria-hidden":"true",children:[p&&c.jsxs(c.Fragment,{children:[c.jsxs("defs",{children:[c.jsxs("radialGradient",{id:`${h}-gradient`,cx:p.x,cy:p.y,r:C,gradientUnits:"userSpaceOnUse",children:[c.jsx("stop",{offset:"0%",stopColor:"black"}),c.jsx("stop",{offset:"55%",stopColor:"black"}),c.jsx("stop",{offset:"100%",stopColor:"white"})]}),c.jsx("mask",{id:h,maskUnits:"userSpaceOnUse",x:"0",y:"0",width:f,height:d,children:c.jsx("rect",{x:"0",y:"0",width:f,height:d,fill:`url(#${h}-gradient)`})})]}),c.jsx("rect",{"data-testid":"screenshot-spotlight",x:"0",y:"0",width:f,height:d,fill:"black",opacity:"0.5",mask:`url(#${h})`})]}),m&&c.jsx("rect",{x:m.x,y:m.y,width:m.width,height:m.height,fill:"none",stroke:"var(--accent-violation)",strokeWidth:y}),v&&c.jsx(gm,{fromX:t.from_x,fromY:t.from_y,toX:t.to_x,toY:t.to_y,stroke:y,headSize:w})]})]})}function gm({fromX:e,fromY:t,toX:n,toY:r,stroke:l,headSize:i}){const o=Math.atan2(r-t,n-e)*180/Math.PI,s=i/2,u=`${n},${r} ${n-i},${r-s} ${n-i},${r+s}`;return c.jsxs("g",{children:[c.jsx("line",{x1:e,y1:t,x2:n,y2:r,stroke:"var(--text-muted)",strokeWidth:l,strokeLinecap:"round"}),c.jsx("polygon",{points:u,fill:"var(--text-muted)",transform:`rotate(${o} ${n} ${r})`})]})}const ym=2;function hf(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)&&Object.getPrototypeOf(e)===Object.prototype}function Ru(e){const t=[],n=(r,l)=>{if(hf(r)){const i=Object.keys(r).sort();if(i.length===0){t.push({path:l,value:{}});return}for(const o of i){const s=l===""?o:`${l}.${o}`;n(r[o],s)}return}if(Array.isArray(r)){if(r.length<=ym){t.push({path:l,value:r});return}for(let i=0;ir.pathl.path?1:0)}function xm(e,t){if(!e)return;if(Object.prototype.hasOwnProperty.call(e,t))return e[t];const n=t.split(/\.|\[(\d+)\]/).filter(l=>l!==void 0&&l!=="");let r=e;for(const l of n){if(r==null)return;if(Array.isArray(r)){const i=Number(l);if(Number.isNaN(i))return;r=r[i];continue}if(typeof r=="object"){r=r[l];continue}return}return r}function oo(e){if(Array.isArray(e))return e.map(oo);if(hf(e)){const t={};for(const n of Object.keys(e).sort())t[n]=oo(e[n]);return t}return e}function so(e){return JSON.stringify(oo(e))}function zu(e){return e===null?"null":e===void 0?"undefined":typeof e=="string"?JSON.stringify(e):typeof e=="number"||typeof e=="boolean"?String(e):so(e)}function Iu({snapshots:e,previousSnapshots:t}){if(!e||Object.keys(e).length===0)return c.jsx("div",{className:"status-block",children:"no snapshots"});const n=Ru(e),r=t!==void 0,l=r?Ru(t):[],i=new Map(l.map(o=>[o.path,o.value]));return c.jsx("dl",{className:"snapshot-table",children:n.map(o=>{const s=zu(o.value);let u=!1,a;if(r){const f=i.has(o.path)?i.get(o.path):xm(t,o.path);so(f)!==so(o.value)&&(u=!0,a=zu(f))}const h={};return u&&(h["data-changed"]="true",h.title=`was: ${a}`),c.jsxs("div",{className:"snapshot-row",...h,children:[c.jsx("dt",{className:"snapshot-path",title:o.path,children:o.path}),c.jsx("dd",{className:"snapshot-value",title:s,children:s})]},o.path)})})}function At({children:e}){return c.jsx("span",{className:"op-label",children:e})}function Vt({node:e}){return c.jsxs("span",{className:"residual-group",children:["(",c.jsx(mf,{node:e}),")"]})}function mf({node:e}){switch(e.op){case"true":return c.jsx("span",{className:"residual-leaf",children:"true"});case"false":return c.jsx("span",{className:"residual-leaf",children:"false"});case"predicate":return c.jsxs("span",{className:"residual-leaf",children:[c.jsx(At,{children:"pred"}),e.name?c.jsxs(c.Fragment,{children:["(",e.name,")"]}):null]});case"error":return c.jsxs("span",{className:"residual-error",role:"status",children:[c.jsx(At,{children:"error"}),c.jsx("span",{className:"residual-error-message",children:e.message})]});case"always":case"now":case"next":case"not":return c.jsxs("span",{className:"residual-unary",children:[c.jsx(At,{children:e.op}),c.jsx(Vt,{node:e.arg})]});case"eventually":return c.jsxs("span",{className:"residual-unary",children:[c.jsx(At,{children:"eventually"}),e.within?c.jsxs("span",{className:"residual-bound",children:["within ",e.within.amount," ",e.within.unit]}):null,c.jsx(Vt,{node:e.arg})]});case"and":case"or":return c.jsxs("span",{className:"residual-binary",children:[c.jsx(Vt,{node:e.left}),c.jsx(At,{children:e.op}),c.jsx(Vt,{node:e.right})]});case"implies":return c.jsxs("span",{className:"residual-binary",children:[c.jsx(Vt,{node:e.left}),c.jsx(At,{children:"=>"}),c.jsx(Vt,{node:e.right})]})}}function wm({node:e}){return c.jsx("span",{className:"residual",children:c.jsx(mf,{node:e})})}const Ou={violated:0,pending:1,holds:2};function Sm(e,t,n){if(t.has(e))return"violated";const r=n==null?void 0:n[e];return r&&r.op==="true"?"holds":"pending"}function Ir({propertyNames:e,violations:t,residuals:n,onJumpToFirstViolation:r,hasFirstViolation:l,violationsOnly:i=!1}){const o=S.useMemo(()=>new Set(t),[t]),s=S.useMemo(()=>{const a=[...e].sort((f,d)=>f.localeCompare(d)).map(f=>({name:f,status:Sm(f,o,n)}));return(i?a.filter(f=>f.status==="violated"):a).sort((f,d)=>{const y=Ou[f.status]-Ou[d.status];return y!==0?y:f.name.localeCompare(d.name)})},[e,o,n,i]);return i&&s.length===0?c.jsx("div",{className:"status-block",children:"no violations"}):c.jsxs("section",{className:"violations-panel",children:[i?null:c.jsxs("header",{className:"violations-panel-header",children:[c.jsx("h2",{className:"violations-panel-title",children:"properties"}),c.jsx("button",{type:"button",className:"violations-panel-jump",onClick:r,disabled:!l,children:"jump to first violation"})]}),c.jsx("ul",{className:"violations-panel-list",children:s.map(({name:u,status:a})=>{const h=n==null?void 0:n[u];return c.jsxs("li",{className:"violations-panel-row","data-status":a,children:[c.jsxs("div",{className:"violations-panel-row-head",children:[c.jsx("span",{className:"violations-panel-badge","data-status":a,"aria-label":`status ${a}`,children:a}),c.jsx("span",{className:"violations-panel-name",children:u})]}),h?c.jsxs("details",{className:"violations-panel-residual",children:[c.jsx("summary",{children:"residual"}),c.jsx(wm,{node:h})]}):null]},u)})})]})}function km(e){const t=new Date(e),n=String(t.getHours()).padStart(2,"0"),r=String(t.getMinutes()).padStart(2,"0"),l=String(t.getSeconds()).padStart(2,"0"),i=String(t.getMilliseconds()).padStart(3,"0");return`${n}:${r}:${l}.${i}`}function Em({exceptions:e,onJumpToFirstException:t,hasFirstException:n}){return c.jsxs("div",{className:"exceptions-panel",children:[c.jsx("div",{className:"exceptions-panel-toolbar",children:c.jsx("button",{type:"button",className:"exceptions-panel-jump",onClick:t,disabled:!n,children:"jump to first exception"})}),!e||e.length===0?c.jsx("div",{className:"status-block",children:"no exceptions"}):c.jsx("ol",{className:"exceptions-list",children:e.map((r,l)=>c.jsx("li",{className:"exceptions-row",children:c.jsxs("details",{open:l===0,children:[c.jsxs("summary",{className:"exceptions-summary",children:[c.jsx("span",{className:"exceptions-marker","aria-hidden":"true"}),c.jsx("span",{className:"exceptions-class",children:r.class}),r.message?c.jsxs(c.Fragment,{children:[c.jsx("span",{className:"exceptions-colon",children:":"}),c.jsx("span",{className:"exceptions-message",children:r.message})]}):null,r.unix_millis!==void 0?c.jsx("span",{className:"exceptions-time",children:km(r.unix_millis)}):null]}),r.stack_trace?c.jsx("pre",{className:"exceptions-stack",children:r.stack_trace}):null]})},l))})]})}const Or=1024*1024;function Mu(e){return e<=0?"0B":e{const s=t(i);if(s===void 0){l="M";return}const u=en(o,e.length),h=1-(n===0?0:Math.min(s/n,1));r.push(`${l}${u.toFixed(4)},${h.toFixed(4)}`),l="L"}),r.join(" ")}function Du({label:e,path:t,dots:n,topTickLabel:r,bottomTickLabel:l,selectedFraction:i,hover:o}){return c.jsxs("div",{className:"metrics-lane",children:[c.jsx("div",{className:"metrics-lane-label",children:e}),c.jsxs("div",{className:"metrics-lane-plot",children:[i!==null?c.jsx("div",{className:"metrics-lane-highlight",style:{left:`${i*100}%`}}):null,c.jsxs("div",{className:"metrics-lane-canvas",children:[c.jsx("svg",{className:"metrics-lane-svg",viewBox:"0 0 1 1",preserveAspectRatio:"none","aria-hidden":"true",children:t?c.jsx("path",{d:t,className:"metrics-lane-path"}):null}),n.map(s=>c.jsx("div",{className:"metrics-lane-dot",style:{left:`${s.x*100}%`,top:`${s.y*100}%`},"data-active":o!==null&&Math.abs(o.fraction-s.x)<1e-6?"true":void 0},s.key)),o?c.jsx("div",{className:"metrics-lane-tooltip","data-placement":o.y<.35?"below":"above",style:{left:`${o.fraction*100}%`,top:`${o.y*100}%`},children:o.label}):null]})]}),c.jsxs("div",{className:"metrics-lane-ticks",children:[c.jsx("span",{children:r}),c.jsx("span",{children:l})]})]})}function $u(e,t,n){const r=[];return e.forEach((l,i)=>{const o=t(l);if(o===void 0)return;const s=n===0?0:Math.min(o/n,1);r.push({key:l.stepIndex,x:en(i,e.length),y:1-s})}),r}function Cm({samples:e,selectedIndex:t,onSelect:n,runStartMillis:r}){var _,P;const[l,i]=S.useState(null);if(e.length===0||!e.some(N=>N.metrics!==void 0))return c.jsx("div",{className:"status-block",children:"no metrics"});const o=e.reduce((N,R)=>{var L;return Math.max(N,((L=R.metrics)==null?void 0:L.heap_bytes)??0)},0),s=e.reduce((N,R)=>{var L;return Math.max(N,((L=R.metrics)==null?void 0:L.cpu_percent)??0)},0),u=Math.max(100,s),a=Fu(e,N=>{var R;return(R=N.metrics)==null?void 0:R.heap_bytes},o),h=Fu(e,N=>{var R;return(R=N.metrics)==null?void 0:R.cpu_percent},u),f=$u(e,N=>{var R;return(R=N.metrics)==null?void 0:R.heap_bytes},o),d=$u(e,N=>{var R;return(R=N.metrics)==null?void 0:R.cpu_percent},u),y=r??new Date(e[0].timestamp).getTime(),g=Math.min(5,e.length),w=Array.from({length:g},(N,R)=>{const L=g===1?0:Math.round(R/(g-1)*(e.length-1)),X=e[L],Q=new Date(X.timestamp).getTime(),b=Number.isFinite(Q)&&Number.isFinite(y)?Q-y:NaN;return{key:X.stepIndex,fraction:en(L,e.length),label:Number.isFinite(b)?Nm(b):String(X.stepIndex)}}),C=e.findIndex(N=>N.stepIndex===t),m=C>=0?en(C,e.length):null,p=l!==null&&l>=0&&l{const L=(()=>{if(e.length===1)return 0;const Q=.5/(e.length-1);return Math.max(0,en(R,e.length)-Q)})(),X=e.length===1?1:1/(e.length-1);return c.jsx("button",{type:"button",className:"metrics-hit",style:{left:`${L*100}%`,width:`${X*100}%`},onClick:()=>n(N.stepIndex),onPointerEnter:()=>i(R),onPointerLeave:()=>i(Q=>Q===R?null:Q),"aria-label":`select step ${N.stepIndex}`},N.stepIndex)})})]}),c.jsx("div",{className:"metrics-axis",children:w.map(N=>c.jsx("span",{className:"metrics-axis-label",style:{left:`${N.fraction*100}%`},children:N.label},N.key))})]})}function Uu({tabs:e,defaultTabId:t,ariaLabel:n}){var f;const r=t&&e.some(d=>d.id===t)?t:(f=e[0])==null?void 0:f.id,[l,i]=S.useState(r),o=S.useRef(new Map);if(e.length===0)return null;const s=e.find(d=>d.id===l)??e[0],u=d=>{i(d);const y=o.current.get(d);y&&y.focus()},a=d=>{const y=e.findIndex(g=>g.id===s.id);if(!(y<0))switch(d.key){case"ArrowRight":case"ArrowDown":{d.preventDefault();const g=e[(y+1)%e.length];u(g.id);return}case"ArrowLeft":case"ArrowUp":{d.preventDefault();const g=e[(y-1+e.length)%e.length];u(g.id);return}case"Home":{d.preventDefault(),u(e[0].id);return}case"End":{d.preventDefault(),u(e[e.length-1].id);return}}},h=`${n??"tabs"}-panel`.replace(/\s+/g,"-");return c.jsxs("div",{className:"tabs",children:[c.jsx("div",{className:"tabs-header",role:"tablist","aria-label":n,onKeyDown:a,children:e.map(d=>{const y=d.id===s.id;return c.jsxs("button",{ref:g=>{g?o.current.set(d.id,g):o.current.delete(d.id)},type:"button",role:"tab",id:`${h}-tab-${d.id}`,className:"tabs-tab","data-active":y?"true":"false","aria-selected":y,"aria-controls":h,tabIndex:y?0:-1,onClick:()=>i(d.id),children:[c.jsx("span",{children:d.label}),d.badge!==void 0?c.jsx("span",{className:"tabs-badge",children:d.badge}):null]},d.id)})}),c.jsx("div",{className:"tabs-panel",role:"tabpanel",id:h,"aria-labelledby":`${h}-tab-${s.id}`,children:s.content})]})}function jm(e){const t=us(),{id:n,step:r}=sf(),l=n??"",i=Number(r??"1"),o=Number.isFinite(i)&&i>0?i:1,s=Au(o,e);return S.useEffect(()=>{s!==o&&l!==""&&t(`/runs/${l}/steps/${s}`,{replace:!0})},[s,o,l,t]),{runId:l,stepIndex:s,goTo:a=>{const h=Au(a,e);h!==o&&l!==""&&t(`/runs/${l}/steps/${h}`)}}}function Au(e,t){return e<1?1:t!==void 0&&e>t?t:e}const _m=new Set(["INPUT","TEXTAREA","SELECT"]),Pm=new Set(["tab","tablist","option","listbox","menuitem","menu"]);function Tm(e){var n;let t=e;for(;t;){const r=(n=t.getAttribute)==null?void 0:n.call(t,"role");if(r&&Pm.has(r))return!0;t=t.parentElement}return!1}function Lm(e){S.useEffect(()=>{const t=n=>{const r=n.target;if(!(r&&(_m.has(r.tagName)||r.isContentEditable)||n.key.startsWith("Arrow")&&Tm(r))&&!(n.metaKey||n.ctrlKey||n.altKey))switch(n.key){case"ArrowLeft":case"k":n.shiftKey?e.onJumpPrev10():e.onPrev(),n.preventDefault();return;case"ArrowRight":case"j":n.shiftKey?e.onJumpNext10():e.onNext(),n.preventDefault();return;case"g":e.onJumpStart(),n.preventDefault();return;case"G":e.onJumpEnd(),n.preventDefault();return;case".":e.onJumpNextViolation(),n.preventDefault();return}};return window.addEventListener("keydown",t),()=>window.removeEventListener("keydown",t)},[e])}const vf="sanderling-inspect-theme";function Rm(){if(typeof window>"u")return"light";const e=window.localStorage.getItem(vf);return e==="light"||e==="dark"?e:window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function zm(){const[e,t]=S.useState(()=>Rm());return S.useEffect(()=>{if(!(typeof document>"u")){document.documentElement.dataset.theme=e;try{window.localStorage.setItem(vf,e)}catch{}}},[e]),{theme:e,toggle:()=>t(n=>n==="dark"?"light":"dark")}}function Im(){const[e,t]=S.useState(null),[n,r]=S.useState(null),[l,i]=S.useState(null),{theme:o,toggle:s}=zm(),u=e==null?void 0:e.steps.length,{runId:a,stepIndex:h,goTo:f}=jm(u);S.useEffect(()=>{if(!a)return;let Q=!1;return t(null),r(null),i(null),bh(a).then(async b=>{if(Q)return;t(b);const pr=await Om(b);Q||r(pr)}).catch(b=>{Q||i(b instanceof Error?b.message:String(b))}),()=>{Q=!0}},[a]);const d=(n==null?void 0:n.steps[h-1])??null,y=h>1?(n==null?void 0:n.steps[h-2])??null:null,g=n&&h{n!=null&&n.firstViolationStep&&f(n.firstViolationStep)},[n,f]),C=S.useCallback(()=>{n!=null&&n.firstExceptionStep&&f(n.firstExceptionStep)},[n,f]),m=S.useCallback(()=>{if(!e)return;const Q=e.steps.find(b=>b.index>h&&b.has_violations);Q&&f(Q.index)},[e,h,f]);Lm({onPrev:()=>f(h-1),onNext:()=>f(h+1),onJumpStart:()=>f(1),onJumpEnd:()=>u&&f(u),onJumpPrev10:()=>f(h-10),onJumpNext10:()=>f(h+10),onJumpNextViolation:m});const p=S.useMemo(()=>{if(!(!a||!d))return Pu(a,`step-${String(d.step).padStart(5,"0")}.png`)},[a,d]),v=S.useMemo(()=>{if(!(!a||!d))return Pu(a,`step-${String(d.step).padStart(5,"0")}-after.png`)},[a,d]),x=S.useMemo(()=>e!=null&&e.steps.length?new Date(e.steps[0].timestamp).getTime():0,[e]);if(l)return c.jsxs("div",{className:"status-block status-error",children:["failed: ",l]});if(!e)return c.jsx("div",{className:"status-block",children:"loading run..."});const E=(d==null?void 0:d.violations)??[],_=(g==null?void 0:g.violations)??E,P=d==null?void 0:d.residuals,N=(g==null?void 0:g.residuals)??P,R=d==null?void 0:d.exceptions,L=[{id:"screenshot",label:"Screenshot",content:c.jsx(Lu,{src:p,action:d==null?void 0:d.action})},{id:"snapshots",label:"Snapshots",content:c.jsx(Iu,{snapshots:d==null?void 0:d.snapshots,previousSnapshots:(y==null?void 0:y.snapshots)??void 0})},{id:"hierarchy",label:"Hierarchy",content:c.jsx(Tu,{hierarchy:d==null?void 0:d.hierarchy})},{id:"properties",label:"Properties",content:c.jsx(Ir,{propertyNames:(n==null?void 0:n.names)??[],violations:E,residuals:P,onJumpToFirstViolation:w,hasFirstViolation:(n==null?void 0:n.firstViolationStep)!==void 0})},{id:"violations",label:"Violations",badge:E.length>0?c.jsx("span",{className:"tabs-badge","data-kind":"violation",children:E.length}):void 0,content:c.jsx(Ir,{propertyNames:(n==null?void 0:n.names)??[],violations:E,residuals:P,onJumpToFirstViolation:w,hasFirstViolation:(n==null?void 0:n.firstViolationStep)!==void 0,violationsOnly:!0})}],X=[{id:"screenshot",label:"Screenshot",content:c.jsx(Lu,{src:v,action:void 0})},{id:"snapshots",label:"Snapshots",content:c.jsx(Iu,{snapshots:(g==null?void 0:g.snapshots)??(d==null?void 0:d.snapshots),previousSnapshots:(d==null?void 0:d.snapshots)??void 0})},{id:"hierarchy",label:"Hierarchy",content:c.jsx(Tu,{hierarchy:(g==null?void 0:g.hierarchy)??(d==null?void 0:d.hierarchy)})},{id:"properties",label:"Properties",content:c.jsx(Ir,{propertyNames:(n==null?void 0:n.names)??[],violations:_,residuals:N,onJumpToFirstViolation:w,hasFirstViolation:(n==null?void 0:n.firstViolationStep)!==void 0})},{id:"violations",label:"Violations",badge:_.length>0?c.jsx("span",{className:"tabs-badge","data-kind":"violation",children:_.length}):void 0,content:c.jsx(Ir,{propertyNames:(n==null?void 0:n.names)??[],violations:_,residuals:N,onJumpToFirstViolation:w,hasFirstViolation:(n==null?void 0:n.firstViolationStep)!==void 0,violationsOnly:!0})}];return c.jsxs("div",{className:"detail-root",children:[c.jsxs("div",{className:"detail-toolbar",children:[c.jsxs("div",{className:"detail-toolbar-meta",children:[c.jsx(df,{to:"/",children:"runs"}),c.jsx("span",{children:e.id}),c.jsxs("span",{children:[c.jsx("strong",{children:e.spec_path})," seed=",e.seed]}),c.jsxs("span",{children:["step ",h," / ",u??0]})]}),c.jsx("button",{type:"button",className:"theme-toggle",onClick:s,"aria-label":o==="dark"?"switch to light theme":"switch to dark theme",title:o==="dark"?"switch to light theme":"switch to dark theme",children:o==="dark"?c.jsxs("svg",{viewBox:"0 0 16 16",width:"14",height:"14","aria-hidden":"true",focusable:"false",children:[c.jsx("circle",{cx:"8",cy:"8",r:"3",fill:"none",stroke:"currentColor",strokeWidth:"1.25"}),c.jsxs("g",{stroke:"currentColor",strokeWidth:"1.25",strokeLinecap:"round",children:[c.jsx("line",{x1:"8",y1:"1.5",x2:"8",y2:"3.5"}),c.jsx("line",{x1:"8",y1:"12.5",x2:"8",y2:"14.5"}),c.jsx("line",{x1:"1.5",y1:"8",x2:"3.5",y2:"8"}),c.jsx("line",{x1:"12.5",y1:"8",x2:"14.5",y2:"8"}),c.jsx("line",{x1:"3.4",y1:"3.4",x2:"4.8",y2:"4.8"}),c.jsx("line",{x1:"11.2",y1:"11.2",x2:"12.6",y2:"12.6"}),c.jsx("line",{x1:"3.4",y1:"12.6",x2:"4.8",y2:"11.2"}),c.jsx("line",{x1:"11.2",y1:"4.8",x2:"12.6",y2:"3.4"})]})]}):c.jsx("svg",{viewBox:"0 0 16 16",width:"14",height:"14","aria-hidden":"true",focusable:"false",children:c.jsx("path",{d:"M13.2 9.8A5.2 5.2 0 0 1 6.2 2.8a5.2 5.2 0 1 0 7 7Z",fill:"none",stroke:"currentColor",strokeWidth:"1.25",strokeLinejoin:"round"})})})]}),c.jsxs("div",{className:"detail-grid",children:[c.jsxs("aside",{className:"detail-actions detail-panel",children:[c.jsx("h2",{children:"actions"}),c.jsx("div",{className:"detail-panel-body",children:c.jsx(dm,{steps:e.steps,selectedIndex:h,onSelect:f,runStartMillis:x,selectedStep:d??void 0})})]}),c.jsxs("section",{className:"detail-state-before detail-panel",children:[c.jsx("h2",{children:"state before"}),c.jsx("div",{className:"detail-panel-body",children:c.jsx(Uu,{tabs:L,defaultTabId:"screenshot",ariaLabel:"state before"})})]}),c.jsxs("section",{className:"detail-state-after detail-panel",children:[c.jsx("h2",{children:"state after"}),c.jsx("div",{className:"detail-panel-body",children:c.jsx(Uu,{tabs:X,defaultTabId:"screenshot",ariaLabel:"state after"})})]}),c.jsx("section",{className:"detail-metrics detail-panel",children:c.jsxs("div",{className:"detail-panel-body",children:[c.jsx(Cm,{samples:(n==null?void 0:n.metricsSamples)??[],selectedIndex:h,onSelect:f,runStartMillis:x}),R&&R.length>0||(n==null?void 0:n.firstExceptionStep)!==void 0?c.jsx("div",{className:"detail-metrics-exceptions",children:c.jsx(Em,{exceptions:R,onJumpToFirstException:C,hasFirstException:(n==null?void 0:n.firstExceptionStep)!==void 0})}):null]})})]})]})}async function Om(e){var a,h;const t=await Promise.all(e.steps.map(f=>em(e.id,f.index).catch(()=>null))),n=Mm(t),r=n.map(f=>({name:f,statuses:t.map(d=>Fm(f,d))})),l=(a=e.steps.find(f=>f.has_violations))==null?void 0:a.index,i=(h=e.steps.find(f=>f.has_exceptions))==null?void 0:h.index,o=e.steps.filter(f=>f.has_exceptions).map(f=>f.index),s=e.steps.filter(f=>f.has_violations).map(f=>f.index),u=e.steps.map((f,d)=>{var y;return{stepIndex:f.index,timestamp:f.timestamp,metrics:(y=t[d])==null?void 0:y.metrics}});return{names:n,lanes:Dm(r),firstViolationStep:l,firstExceptionStep:i,exceptionStepIndices:o,violationStepIndices:s,metricsSamples:u,steps:t}}function Mm(e){const t=new Set;for(const n of e)if(n!=null&&n.residuals)for(const r of Object.keys(n.residuals))t.add(r);return[...t].sort()}function Fm(e,t){var r,l;if(!t)return"pending";if((r=t.violations)!=null&&r.includes(e))return"violated";const n=(l=t.residuals)==null?void 0:l[e];return n&&n.op==="true"?"holds":"pending"}function Dm(e){const t=n=>{const r=n.statuses[n.statuses.length-1];return n.statuses.includes("violated")?0:r==="pending"?1:2};return[...e].sort((n,r)=>{const l=t(n)-t(r);return l!==0?l:n.name.localeCompare(r.name)})}function $m(){const{id:e}=sf();return e?c.jsx(Uh,{to:`/runs/${e}/steps/1`,replace:!0}):c.jsx(gf,{})}function gf(){return c.jsx("div",{className:"status-block",children:"not found"})}function Um(){return c.jsx(Jh,{children:c.jsx("div",{className:"app-shell",children:c.jsx("main",{className:"app-main",children:c.jsxs(Vh,{children:[c.jsx(zn,{path:"/",element:c.jsx(lm,{})}),c.jsx(zn,{path:"/runs/:id",element:c.jsx($m,{})}),c.jsx(zn,{path:"/runs/:id/steps/:step",element:c.jsx(Im,{})}),c.jsx(zn,{path:"*",element:c.jsx(gf,{})})]})})})})}const yf=document.getElementById("root");if(!yf)throw new Error("missing #root element");ef(yf).render(c.jsx(Zu.StrictMode,{children:c.jsx(Um,{})})); diff --git a/inspect-ui/dist/fonts/.gitkeep b/inspect-ui/dist/fonts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/inspect-ui/dist/index.html b/inspect-ui/dist/index.html deleted file mode 100644 index 7ceb97e..0000000 --- a/inspect-ui/dist/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - sanderling inspect - - - - -
- - diff --git a/inspect-ui/src/panels/ActionList.css b/inspect-ui/src/panels/ActionList.css index c322f97..b9a1925 100644 --- a/inspect-ui/src/panels/ActionList.css +++ b/inspect-ui/src/panels/ActionList.css @@ -130,3 +130,54 @@ text-overflow: ellipsis; white-space: nowrap; } + +.action-list-extractor-changes { + grid-column: 2; + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid var(--surface-border); +} + +.action-list-extractor-changes-heading { + color: var(--text-muted); + text-transform: lowercase; + letter-spacing: 0.5px; +} + +.action-list-extractor-change-row { + display: grid; + grid-template-columns: minmax(80px, max-content) 1fr; + gap: 6px; + color: var(--text-primary); +} + +.action-list-extractor-change-name { + color: var(--text-muted); +} + +.action-list-extractor-change-diff { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.action-list-extractor-change-row-collapsed > summary { + display: grid; + grid-template-columns: minmax(80px, max-content) 1fr; + gap: 6px; + cursor: pointer; +} + +.action-list-extractor-change-full { + grid-column: 1 / -1; + margin: 4px 0 0; + white-space: pre-wrap; + word-break: break-word; + background: var(--surface-base); + padding: 4px 6px; + border-radius: 2px; + font-size: 10px; +} diff --git a/inspect-ui/src/panels/ActionList.tsx b/inspect-ui/src/panels/ActionList.tsx index 75b03bd..66d2e14 100644 --- a/inspect-ui/src/panels/ActionList.tsx +++ b/inspect-ui/src/panels/ActionList.tsx @@ -111,7 +111,7 @@ function renderTarget(target: string, isTag: boolean) { } function contentTextForStep(step: Step): string { - const action = step.action; + const action = step.next_action; if (!action) { return ""; } @@ -121,8 +121,26 @@ function contentTextForStep(step: Step): string { return ""; } +const MAX_INLINE_VALUE = 80; + +function formatExtractorValue(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (typeof value === "string") return JSON.stringify(value); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function truncateForInline(text: string): string { + if (text.length <= MAX_INLINE_VALUE) return text; + return `${text.slice(0, MAX_INLINE_VALUE - 1)}...`; +} + function positionTextForStep(step: Step): string | null { - const action = step.action; + const action = step.next_action; if (!action) { return null; } @@ -289,6 +307,56 @@ export default function ActionList({ {contentText ? contentText : `""`} + {step.has_violations && selectedStep?.extractor_changes + ? (() => { + const entries = Object.entries(selectedStep.extractor_changes); + if (entries.length === 0) return null; + entries.sort(([a], [b]) => a.localeCompare(b)); + return ( +
+
+ extractor changes +
+ {entries.map(([name, change]) => { + const prev = formatExtractorValue(change.prev); + const curr = formatExtractorValue(change.curr); + const inlineFits = + prev.length + curr.length <= MAX_INLINE_VALUE * 2; + if (inlineFits) { + return ( +
+ + {name} + + + {truncateForInline(prev)} → {truncateForInline(curr)} + +
+ ); + } + return ( +
+ + + {name} + + + {truncateForInline(prev)} → {truncateForInline(curr)} + + +
+                                  {`prev: ${prev}\ncurr: ${curr}`}
+                                
+
+ ); + })} +
+ ); + })() + : null} ) : null} diff --git a/inspect-ui/src/routes/RunDetail.tsx b/inspect-ui/src/routes/RunDetail.tsx index e9c001c..4028ee8 100644 --- a/inspect-ui/src/routes/RunDetail.tsx +++ b/inspect-ui/src/routes/RunDetail.tsx @@ -92,15 +92,18 @@ export default function RunDetail() { onJumpNextViolation: jumpToNextViolation, }); + // One screenshot per step depicts the UI at observation time (post-prior- + // action, post-settle). The "after" view for step N is therefore step + // (N+1)'s screenshot: the same moment, observed from the next iteration. const beforeScreenshot = useMemo(() => { if (!runId || !currentStep) return undefined; return screenshotUrl(runId, `step-${String(currentStep.step).padStart(5, "0")}.png`); }, [runId, currentStep]); const afterScreenshot = useMemo(() => { - if (!runId || !currentStep) return undefined; - return screenshotUrl(runId, `step-${String(currentStep.step).padStart(5, "0")}-after.png`); - }, [runId, currentStep]); + if (!runId || !nextStep) return undefined; + return screenshotUrl(runId, `step-${String(nextStep.step).padStart(5, "0")}.png`); + }, [runId, nextStep]); const runStartMillis = useMemo(() => { if (!run?.steps.length) return 0; @@ -124,7 +127,7 @@ export default function RunDetail() { { id: "screenshot", label: "Screenshot", - content: , + content: , }, { id: "snapshots", diff --git a/inspect-ui/src/types.ts b/inspect-ui/src/types.ts index 107f069..bab0ade 100644 --- a/inspect-ui/src/types.ts +++ b/inspect-ui/src/types.ts @@ -115,15 +115,22 @@ export interface Metrics { total_memory_bytes?: number; } +export interface ExtractorChange { + prev: unknown; + curr: unknown; +} + export interface Step { step: number; timestamp: string; screen?: string; snapshots?: Record; - action?: Action; + // next_action is the action chosen for the next iteration based on observing this step. + next_action?: Action; exceptions?: Exception[]; violations?: string[]; hierarchy?: Hierarchy; residuals?: Record; metrics?: Metrics; + extractor_changes?: Record; } diff --git a/internal/android/android.go b/internal/android/android.go index cc47fae..0c7aa26 100644 --- a/internal/android/android.go +++ b/internal/android/android.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "slices" "strings" "time" @@ -236,6 +237,71 @@ func waitForBoot(ctx context.Context, timeout time.Duration) error { } } +// ForegroundPackage returns the package of the currently resumed activity on +// the connected device, or "" when it cannot be determined. +func ForegroundPackage(ctx context.Context) (string, error) { + adb, err := AdbBinary() + if err != nil { + return "", err + } + output, err := exec.CommandContext(ctx, adb, "shell", "dumpsys", "activity", "activities").Output() + if err != nil { + return "", err + } + return parseForegroundPackage(string(output)), nil +} + +// FocusedWindowPackage returns the package owning the currently focused window, +// or "" when no window is focused (e.g. mid-launch, before the app has drawn). +// Unlike ForegroundPackage, this reflects what is actually on screen: +// ResumedActivity flips to a newly launched app before its first frame renders, +// while mCurrentFocus only names the app once its window is up. +func FocusedWindowPackage(ctx context.Context) (string, error) { + adb, err := AdbBinary() + if err != nil { + return "", err + } + output, err := exec.CommandContext(ctx, adb, "shell", "dumpsys", "window").Output() + if err != nil { + return "", err + } + return parseFocusedWindowPackage(string(output)), nil +} + +// resumedActivityPackage matches the "/" component name that +// dumpsys prints on ResumedActivity and mCurrentFocus lines, capturing the +// package. +var resumedActivityPackage = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9_.]*)/[a-zA-Z0-9_.$]+`) + +// parseForegroundPackage extracts the foreground package from `dumpsys activity +// activities` output by reading the first ResumedActivity component name. +func parseForegroundPackage(dumpsys string) string { + for line := range strings.SplitSeq(dumpsys, "\n") { + if !strings.Contains(line, "ResumedActivity") { + continue + } + if match := resumedActivityPackage.FindStringSubmatch(line); match != nil { + return match[1] + } + } + return "" +} + +// parseFocusedWindowPackage extracts the focused-window package from +// `dumpsys window` output by reading the mCurrentFocus component name. A +// "mCurrentFocus=null" line (no focused window) yields "". +func parseFocusedWindowPackage(dumpsys string) string { + for line := range strings.SplitSeq(dumpsys, "\n") { + if !strings.Contains(line, "mCurrentFocus") { + continue + } + if match := resumedActivityPackage.FindStringSubmatch(line); match != nil { + return match[1] + } + } + return "" +} + func bootCompleted(ctx context.Context) (bool, error) { adb, err := AdbBinary() if err != nil { diff --git a/internal/android/android_test.go b/internal/android/android_test.go index c551667..70f950d 100644 --- a/internal/android/android_test.go +++ b/internal/android/android_test.go @@ -91,3 +91,75 @@ func TestPathContains(t *testing.T) { t.Error("did not expect /nope in PATH") } } + +func TestParseForegroundPackage(t *testing.T) { + cases := []struct { + name string + dumpsys string + want string + }{ + { + name: "mResumedActivity folio", + dumpsys: " Stack #0:\n mResumedActivity: ActivityRecord{a1b2c3 u0 app.folio/.MainActivity t42}\n", + want: "app.folio", + }, + { + name: "topResumedActivity chrome", + dumpsys: "ResumedActivity: ActivityRecord{ff u0 com.android.chrome/com.google.android.apps.chrome.Main t9}\n topResumedActivity=ActivityRecord{ff u0 com.android.chrome/com.google.android.apps.chrome.Main}", + want: "com.android.chrome", + }, + { + name: "launcher", + dumpsys: " mResumedActivity: ActivityRecord{x u0 com.google.android.apps.nexuslauncher/.NexusLauncherActivity t1}", + want: "com.google.android.apps.nexuslauncher", + }, + { + name: "no resumed activity", + dumpsys: " some unrelated dumpsys output\n with no resumed line\n", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := parseForegroundPackage(tc.dumpsys); got != tc.want { + t.Errorf("parseForegroundPackage = %q, want %q", got, tc.want) + } + }) + } +} + +func TestParseFocusedWindowPackage(t *testing.T) { + cases := []struct { + name string + dumpsys string + want string + }{ + { + name: "folio focused", + dumpsys: " mCurrentFocus=Window{e00f63a u0 app.folio/app.folio.MainActivity}\n mFocusedApp=ActivityRecord{c0 u0 app.folio/.MainActivity t202}", + want: "app.folio", + }, + { + name: "settings focused", + dumpsys: " mCurrentFocus=Window{709 u0 com.android.settings/com.android.settings.SubSettings}", + want: "com.android.settings", + }, + { + name: "no focused window mid-launch", + dumpsys: " mCurrentFocus=null\n mFocusedApp=null", + want: "", + }, + { + name: "no focus line", + dumpsys: " some unrelated dumpsys window output\n", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := parseFocusedWindowPackage(tc.dumpsys); got != tc.want { + t.Errorf("parseFocusedWindowPackage = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index d4b8dd6..2b736be 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -36,6 +36,7 @@ func New() *Driver { append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", true), chromedp.Flag("disable-gpu", true), + chromedp.NoSandbox, )..., ) tabCtx, tabCancel := chromedp.NewContext(allocCtx) @@ -190,6 +191,23 @@ func (d *Driver) PressKey(_ context.Context, key string) error { return chromedp.Run(d.tabCtx, chromedp.KeyEvent(k)) } +func (d *Driver) LongPress(_ context.Context, x, y int) error { + script := fmt.Sprintf(` +(function() { + const el = document.elementFromPoint(%d, %d); + if (!el) return; + el.dispatchEvent(new PointerEvent('pointerdown', {clientX: %d, clientY: %d, bubbles: true})); + setTimeout(function() { + el.dispatchEvent(new PointerEvent('pointerup', {clientX: %d, clientY: %d, bubbles: true})); + }, 600); +})();`, + x, y, + x, y, + x, y, + ) + return chromedp.Run(d.tabCtx, chromedp.Evaluate(script, nil)) +} + // keyMap covers the keys web specs may emit (enter/tab/escape/arrows). // "back"/"home" are intentionally absent: backspace/NUL have no navigation // semantics in a browser, and the V8 action mix already excludes them. @@ -218,7 +236,8 @@ func (d *Driver) Hierarchy(_ context.Context) (string, error) { if (el.id) attrs['resource-id'] = el.id; const label = el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') || ''; if (label) attrs['content-desc'] = label; - if (el.tagName) attrs['tag'] = el.tagName.toLowerCase(); + const tag = (el.tagName || '').toLowerCase(); + if (tag) attrs['tag'] = tag; if (el.className && typeof el.className === 'string' && el.className.trim()) { attrs['class'] = el.className.trim(); } @@ -226,6 +245,9 @@ func (d *Driver) Hierarchy(_ context.Context) (string, error) { const isClickable = !!(el.onclick || el.tagName === 'A' || el.tagName === 'BUTTON' || el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.getAttribute('role') === 'button' || el.getAttribute('onclick')); + const isEditable = el.isContentEditable || tag === 'textarea' || + (tag === 'input' && !['button','submit','checkbox','radio','range','color','file','image','reset'] + .includes((el.type || '').toLowerCase())); const children = []; for (const child of el.children) { children.push(buildTree(child, false)); @@ -238,6 +260,7 @@ func (d *Driver) Hierarchy(_ context.Context) (string, error) { focused: document.activeElement === el || null, checked: el.checked || null, selected: el.selected || null, + editable: isEditable || null, }; } return buildTree(document.body, true); @@ -263,6 +286,21 @@ func (d *Driver) Screenshot(_ context.Context) (driver.Image, error) { return driver.Image{PNG: buf, Width: w, Height: h}, nil } +// Snapshot pairs hierarchy and screenshot back-to-back. The chromedp tab +// is single-threaded so the two CDP round-trips are already serialized: +// pairing them here matches the DeviceDriver contract without extra locking. +func (d *Driver) Snapshot(ctx context.Context) (string, driver.Image, error) { + hierarchy, err := d.Hierarchy(ctx) + if err != nil { + return "", driver.Image{}, err + } + image, err := d.Screenshot(ctx) + if err != nil { + return hierarchy, driver.Image{}, err + } + return hierarchy, image, nil +} + func (d *Driver) RecentLogs(_ context.Context, since time.Time, minLevel string) ([]driver.LogEntry, error) { sinceMillis := since.UnixMilli() d.logsMu.Lock() diff --git a/internal/driver/chrome/driver_test.go b/internal/driver/chrome/driver_test.go index a554bf0..1997544 100644 --- a/internal/driver/chrome/driver_test.go +++ b/internal/driver/chrome/driver_test.go @@ -2,10 +2,71 @@ package chrome import ( "context" + "encoding/json" "testing" "time" ) +// TestHierarchy_EditableFlag confirms the injected hierarchy script marks text +// inputs, textareas, and contenteditable elements editable while leaving +// buttons and non-text inputs alone. +func TestHierarchy_EditableFlag(t *testing.T) { + const html = `` + + `` + + `` + + `` + + `
x
` + + `` + + `` + + d := New() + defer d.Terminate(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := d.Launch(ctx, "data:text/html,"+html, false, nil); err != nil { + t.Fatalf("Launch: %v", err) + } + dump, err := d.Hierarchy(ctx) + if err != nil { + t.Fatalf("Hierarchy: %v", err) + } + + type node struct { + Attributes map[string]string `json:"attributes"` + Children []node `json:"children"` + Editable *bool `json:"editable"` + } + var root node + if err := json.Unmarshal([]byte(dump), &root); err != nil { + t.Fatalf("unmarshal hierarchy: %v", err) + } + editableByID := map[string]*bool{} + var walk func(n node) + walk = func(n node) { + if id := n.Attributes["resource-id"]; id != "" { + editableByID[id] = n.Editable + } + for _, c := range n.Children { + walk(c) + } + } + walk(root) + + isEditable := func(id string) bool { + return editableByID[id] != nil && *editableByID[id] + } + for _, id := range []string{"name", "bio", "rich"} { + if !isEditable(id) { + t.Errorf("%q: editable = %v, want true", id, editableByID[id]) + } + } + for _, id := range []string{"go", "chk"} { + if isEditable(id) { + t.Errorf("%q: editable = true, want false/absent", id) + } + } +} + // TestRunCtx_CallerCancelPropagates confirms that cancelling the caller's // context cancels the chromedp-bound context returned by runCtx. This is the // channel by which step deadlines and Ctrl-C reach in-flight CDP calls. diff --git a/internal/driver/driver.go b/internal/driver/driver.go index ef5d589..fb61368 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -19,9 +19,15 @@ type DeviceDriver interface { InputText(ctx context.Context, text string) error Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error PressKey(ctx context.Context, key string) error + LongPress(ctx context.Context, x, y int) error Hierarchy(ctx context.Context) (string, error) Screenshot(ctx context.Context) (Image, error) + // Snapshot returns the hierarchy and screenshot captured back-to-back + // under a backend-side mutex, so the pair describes the same on-device + // frame. Prefer this over calling Hierarchy and Screenshot separately: + // independent reads can land on different frames during transitions. + Snapshot(ctx context.Context) (string, Image, error) // RecentLogs returns log entries at or after `since`, filtered to // `minLevel` or above. An empty minLevel defaults to "E". RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]LogEntry, error) @@ -35,6 +41,30 @@ type DeviceDriver interface { Metrics(ctx context.Context, bundleID string) (Metrics, error) } +// ForegroundChecker is the optional capability for reporting which app is +// currently in the foreground. The runner uses it to keep exploration scoped +// to the app under test: when an action backs out of (or otherwise leaves) the +// app, the runner relaunches it before acting again. Drivers that cannot +// determine the foreground app simply do not implement this interface. +type ForegroundChecker interface { + // ForegroundApp returns the bundle id / package of the app currently in + // the foreground. An empty string means "unknown" and the runner skips + // enforcement for that step rather than relaunching blindly. + ForegroundApp(ctx context.Context) (string, error) +} + +// FocusedWindowChecker is the optional capability for reporting which app owns +// the focused (on-screen) window. The startup gate prefers it over +// ForegroundChecker: the resumed-activity signal flips to a freshly launched +// app before its first frame draws, so observing on it alone can capture the +// previous app's screen. The focused window only names the app once its window +// is actually up. +type FocusedWindowChecker interface { + // FocusedWindowApp returns the package owning the focused window, or "" + // when no window is focused yet (e.g. mid-launch transition). + FocusedWindowApp(ctx context.Context) (string, error) +} + type LogEntry struct { UnixMillis int64 Level string diff --git a/internal/driver/mock/mock.go b/internal/driver/mock/mock.go index 07adadc..731148e 100644 --- a/internal/driver/mock/mock.go +++ b/internal/driver/mock/mock.go @@ -18,8 +18,10 @@ const ( ActionInputText ActionKind = "input_text" ActionSwipe ActionKind = "swipe" ActionPressKey ActionKind = "press_key" + ActionLongPress ActionKind = "long_press" ActionHierarchy ActionKind = "hierarchy" ActionScreenshot ActionKind = "screenshot" + ActionSnapshot ActionKind = "snapshot" ActionRecentLogs ActionKind = "recent_logs" ActionWaitForIdle ActionKind = "wait_for_idle" ActionHealth ActionKind = "health" @@ -55,6 +57,21 @@ type Driver struct { LogEntries []driver.LogEntry MetricsData driver.Metrics Failures map[ActionKind]error + + // ForegroundResults is consumed one entry per ForegroundApp call (the + // last entry repeats). Empty yields "", which disables the runner's + // app-scope guard so tests that don't care are unaffected. + ForegroundResults []string + foregroundIndex int + + // FocusedWindowResults is consumed one entry per FocusedWindowApp call + // (the last entry repeats). When empty, FocusedWindowApp mirrors the + // last ForegroundApp result, so the startup gate treats the window as + // already drawn and tests that don't care are unaffected. + FocusedWindowResults []string + focusedWindowIndex int + focusedWindowCalls int + lastForeground string } func New() *Driver { @@ -96,6 +113,44 @@ func (d *Driver) Launch(_ context.Context, bundleID string, clearState bool, _ m return nil } +func (d *Driver) ForegroundApp(_ context.Context) (string, error) { + d.mutex.Lock() + defer d.mutex.Unlock() + if len(d.ForegroundResults) == 0 { + d.lastForeground = "" + return "", nil + } + index := d.foregroundIndex + if index >= len(d.ForegroundResults) { + index = len(d.ForegroundResults) - 1 + } + d.foregroundIndex++ + d.lastForeground = d.ForegroundResults[index] + return d.lastForeground, nil +} + +func (d *Driver) FocusedWindowApp(_ context.Context) (string, error) { + d.mutex.Lock() + defer d.mutex.Unlock() + d.focusedWindowCalls++ + if len(d.FocusedWindowResults) == 0 { + return d.lastForeground, nil + } + index := d.focusedWindowIndex + if index >= len(d.FocusedWindowResults) { + index = len(d.FocusedWindowResults) - 1 + } + d.focusedWindowIndex++ + return d.FocusedWindowResults[index], nil +} + +// FocusedWindowCalls reports how many times FocusedWindowApp has been called. +func (d *Driver) FocusedWindowCalls() int { + d.mutex.Lock() + defer d.mutex.Unlock() + return d.focusedWindowCalls +} + func (d *Driver) Terminate(ctx context.Context) error { if err := d.failure(ActionTerminate); err != nil { return err @@ -112,6 +167,14 @@ func (d *Driver) Tap(ctx context.Context, x, y int) error { return nil } +func (d *Driver) LongPress(ctx context.Context, x, y int) error { + if err := d.failure(ActionLongPress); err != nil { + return err + } + d.record(Action{Kind: ActionLongPress, X: x, Y: y}) + return nil +} + func (d *Driver) TapSelector(ctx context.Context, selector string) error { if err := d.failure(ActionTapSelector); err != nil { return err @@ -181,6 +244,20 @@ func (d *Driver) Screenshot(ctx context.Context) (driver.Image, error) { return d.ImageData, nil } +// Snapshot returns the hierarchy + screenshot pair atomically, mirroring +// the real driver's contract. It records a single ActionSnapshot so tests +// can assert the runner reached for the paired RPC instead of racing the +// two reads. +func (d *Driver) Snapshot(ctx context.Context) (string, driver.Image, error) { + if err := d.failure(ActionSnapshot); err != nil { + return "", driver.Image{}, err + } + d.record(Action{Kind: ActionSnapshot}) + d.mutex.Lock() + defer d.mutex.Unlock() + return d.HierarchyJSON, d.ImageData, nil +} + func (d *Driver) WaitForIdle(ctx context.Context, duration time.Duration) error { if err := d.failure(ActionWaitForIdle); err != nil { return err diff --git a/internal/driver/sidecar/client.go b/internal/driver/sidecar/client.go index 5dc2a47..f0b4e86 100644 --- a/internal/driver/sidecar/client.go +++ b/internal/driver/sidecar/client.go @@ -8,6 +8,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "github.com/priyanshujain/sanderling/internal/android" "github.com/priyanshujain/sanderling/internal/driver" driverpb "github.com/priyanshujain/sanderling/proto/driverpb" ) @@ -15,6 +16,31 @@ import ( type Client struct { connection *grpc.ClientConn stub driverpb.DriverClient + platform string +} + +// SetPlatform records the target platform so capability methods (e.g. +// ForegroundApp) can pick the right backend. The caller sets this right after +// Dial. +func (c *Client) SetPlatform(platform string) { c.platform = platform } + +// ForegroundApp reports the foreground package. Only Android is supported (via +// adb); other platforms return "" so the runner skips app-scope enforcement. +func (c *Client) ForegroundApp(ctx context.Context) (string, error) { + if c.platform != "android" { + return "", nil + } + return android.ForegroundPackage(ctx) +} + +// FocusedWindowApp reports the package owning the focused window. Only Android +// is supported (via adb); other platforms return "" so the startup gate falls +// back to the foreground-app signal. +func (c *Client) FocusedWindowApp(ctx context.Context) (string, error) { + if c.platform != "android" { + return "", nil + } + return android.FocusedWindowPackage(ctx) } // Dial connects to the sidecar gRPC server at the given address. @@ -67,6 +93,11 @@ func (c *Client) Tap(ctx context.Context, x, y int) error { return err } +func (c *Client) LongPress(ctx context.Context, x, y int) error { + _, err := c.stub.LongPress(ctx, &driverpb.Point{X: int32(x), Y: int32(y)}) + return err +} + func (c *Client) TapSelector(ctx context.Context, selector string) error { _, err := c.stub.TapSelector(ctx, &driverpb.Selector{Value: selector}) return err @@ -136,6 +167,23 @@ func (c *Client) Screenshot(ctx context.Context) (driver.Image, error) { }, nil } +// Snapshot fetches hierarchy and screenshot in a single sidecar round-trip. +// The sidecar serializes the two reads behind a mutex so the returned pair +// describes the same on-device frame, removing the cross-fade race the +// runner used to see when fetching them as independent goroutines. +func (c *Client) Snapshot(ctx context.Context) (string, driver.Image, error) { + response, err := c.stub.Snapshot(ctx, &driverpb.Empty{}) + if err != nil { + return "", driver.Image{}, err + } + image := response.GetScreenshot() + return response.GetHierarchy().GetJson(), driver.Image{ + PNG: image.GetPng(), + Width: int(image.GetWidth()), + Height: int(image.GetHeight()), + }, nil +} + func (c *Client) WaitForIdle(ctx context.Context, duration time.Duration) error { _, err := c.stub.WaitForIdle(ctx, &driverpb.Duration{Millis: duration.Milliseconds()}) return err diff --git a/internal/driver/sidecar/client_test.go b/internal/driver/sidecar/client_test.go index 00799b7..d64d868 100644 --- a/internal/driver/sidecar/client_test.go +++ b/internal/driver/sidecar/client_test.go @@ -105,6 +105,15 @@ func (s *fakeServer) Screenshot(_ context.Context, _ *driverpb.Empty) (*driverpb return &driverpb.Image{Png: s.imagePNG, Width: s.imageWidth, Height: s.imageHeight}, nil } +func (s *fakeServer) Snapshot(_ context.Context, _ *driverpb.Empty) (*driverpb.SnapshotResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + return &driverpb.SnapshotResponse{ + Hierarchy: &driverpb.HierarchyJSON{Json: s.hierarchy}, + Screenshot: &driverpb.Image{Png: s.imagePNG, Width: s.imageWidth, Height: s.imageHeight}, + }, nil +} + type harness struct { server *grpc.Server fake *fakeServer @@ -260,6 +269,23 @@ func TestClient_HierarchyAndScreenshot(t *testing.T) { } } +func TestClient_SnapshotPairsHierarchyAndScreenshot(t *testing.T) { + state := newHarness(t) + client, _ := Dial(state.address) + defer client.Close() + + hierarchyJSON, image, err := client.Snapshot(context.Background()) + if err != nil { + t.Fatal(err) + } + if hierarchyJSON != `{"x":1}` { + t.Errorf("hierarchy wrong: %q", hierarchyJSON) + } + if image.Width != 1080 || image.Height != 2340 || len(image.PNG) != 1 { + t.Errorf("image wrong: %+v", image) + } +} + func TestClient_WaitForIdleForwardsMillis(t *testing.T) { state := newHarness(t) client, _ := Dial(state.address) diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index 56c1511..65b142f 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -66,6 +66,7 @@ type Element struct { Checked bool `json:"checked,omitempty"` Focused bool `json:"focused,omitempty"` Selected bool `json:"selected,omitempty"` + Editable bool `json:"editable,omitempty"` Bounds Bounds `json:"bounds"` Attributes map[string]string `json:"attrs,omitempty"` } @@ -91,6 +92,7 @@ type treeNodeJSON struct { Focused *bool `json:"focused"` Checked *bool `json:"checked"` Selected *bool `json:"selected"` + Editable *bool `json:"editable"` } // Selector describes a multi-attribute AND match. @@ -204,6 +206,18 @@ func elementFromNode(node *treeNodeJSON) *Element { } element.Class = attrs["class"] element.Package = attrs["package"] + if element.Package == "" { + // Android omits an explicit package attribute, but native views carry + // it as the resource-id prefix (`com.android.systemui:id/...`). Compose + // testTags are colon-less and leave the package empty, which keeps them + // in scope. This lets target selection tell the app apart from the soft + // keyboard and system UI. + if resourceID := attrs["resource-id"]; resourceID != "" { + if colon := strings.IndexByte(resourceID, ':'); colon > 0 { + element.Package = resourceID[:colon] + } + } + } element.Screen = attrs["sanderling-screen"] if node.Clickable != nil { @@ -221,6 +235,11 @@ func elementFromNode(node *treeNodeJSON) *Element { if node.Selected != nil { element.Selected = *node.Selected } + if node.Editable != nil { + element.Editable = *node.Editable + } else { + element.Editable = strings.Contains(element.Class, "EditText") || attrs["hintText"] != "" + } if b, ok := attrs["bounds"]; ok && b != "" { bounds, err := parseBounds(b) @@ -246,6 +265,7 @@ func elementFromNode(node *treeNodeJSON) *Element { if node.Selected != nil { element.Attributes["selected"] = strconv.FormatBool(*node.Selected) } + element.Attributes["editable"] = strconv.FormatBool(element.Editable) return element } diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index e016a30..e59b1e8 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -141,6 +141,61 @@ func TestBoolFieldsFromNode(t *testing.T) { } } +func TestEditableDerivation(t *testing.T) { + cases := []struct { + name string + node string + want bool + }{ + {"driver flag", `{"attributes": {"bounds": "[0,0,10,10]"}, "editable": true}`, true}, + {"driver flag false", `{"attributes": {"bounds": "[0,0,10,10]"}, "editable": false}`, false}, + {"native EditText class", `{"attributes": {"class": "android.widget.EditText", "bounds": "[0,0,10,10]"}}`, true}, + {"hintText attr", `{"attributes": {"hintText": "Enter amount", "bounds": "[0,0,10,10]"}}`, true}, + {"plain button", `{"attributes": {"class": "android.widget.Button", "bounds": "[0,0,10,10]"}}`, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tree, err := Parse(tc.node) + if err != nil { + t.Fatalf("Parse: %v", err) + } + el := tree.Elements[0] + if el.Editable != tc.want { + t.Errorf("Editable = %v, want %v", el.Editable, tc.want) + } + if got := el.Attributes["editable"]; got != boolString(tc.want) { + t.Errorf("attrs[editable] = %q, want %q", got, boolString(tc.want)) + } + }) + } +} + +func boolString(b bool) string { + if b { + return "true" + } + return "false" +} + +// TestFindByEditableSelector confirms find({editable:true}) matches via the +// mirrored attrs entry, the same path clickable uses. +func TestFindByEditableSelector(t *testing.T) { + input := `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"}, + "children": [ + {"attributes": {"class": "android.widget.EditText", "bounds": "[0,0,100,40]"}, "children": []}, + {"attributes": {"class": "android.widget.Button", "bounds": "[0,40,100,80]"}, "children": []} + ] + }` + tree, _ := Parse(input) + if el := tree.Find("editable:true"); el == nil { + t.Fatal("expected to find the EditText via editable:true") + } + if matches := tree.FindAll("editable:true"); len(matches) != 1 { + t.Fatalf("editable:true matched %d elements, want 1", len(matches)) + } +} + func TestIdentifierFallback(t *testing.T) { input := `{ "attributes": {"identifier": "my-button", "bounds": "[0,0,100,100]"}, @@ -627,3 +682,51 @@ func TestNodeFindDoesNotReturnSiblings(t *testing.T) { t.Fatal("Node.Find should not return nodes from sibling subtrees") } } + +// TestPackageDerivedFromResourceIDPrefix verifies that when the sidecar omits an +// explicit package attribute, native nodes pick it up from the resource-id +// prefix while colon-less Compose testTags stay empty (in scope for the app). +func TestPackageDerivedFromResourceIDPrefix(t *testing.T) { + const dump = `{ + "attributes": {"class": "android.widget.FrameLayout", "bounds": "[0,0,320,640]"}, + "children": [ + {"attributes": {"resource-id": "AddAccountScreen", "bounds": "[0,0,320,400]"}, "clickable": true, "enabled": true, "children": []}, + {"attributes": {"resource-id": "com.google.android.inputmethod.latin:id/key_pos_0_0", "bounds": "[0,400,40,440]"}, "clickable": true, "enabled": true, "children": []}, + {"attributes": {"resource-id": "android:id/content", "bounds": "[0,0,320,640]"}, "children": []} + ] + }` + tree, err := Parse(dump) + if err != nil { + t.Fatalf("Parse: %v", err) + } + want := map[string]string{ + "AddAccountScreen": "", + "com.google.android.inputmethod.latin:id/key_pos_0_0": "com.google.android.inputmethod.latin", + "android:id/content": "android", + } + for _, element := range tree.Elements { + expected, ok := want[element.ResourceID] + if !ok { + continue + } + if element.Package != expected { + t.Errorf("resource-id %q: package = %q, want %q", element.ResourceID, element.Package, expected) + } + } +} + +// TestExplicitPackageAttributeWins verifies an explicit package attribute is not +// overridden by the resource-id prefix fallback. +func TestExplicitPackageAttributeWins(t *testing.T) { + const dump = `{ + "attributes": {"resource-id": "android:id/content", "package": "app.folio", "bounds": "[0,0,320,640]"}, + "children": [] + }` + tree, err := Parse(dump) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got := tree.Elements[0].Package; got != "app.folio" { + t.Errorf("package = %q, want app.folio (explicit attr should win)", got) + } +} diff --git a/internal/inspect/runs_decode.go b/internal/inspect/runs_decode.go index 946a327..4b3e0de 100644 --- a/internal/inspect/runs_decode.go +++ b/internal/inspect/runs_decode.go @@ -61,7 +61,7 @@ func decodeStepSummary(line []byte) (StepSummary, int, error) { Index int `json:"step"` Timestamp time.Time `json:"timestamp"` Screen string `json:"screen,omitempty"` - Action *struct { + NextAction *struct { Kind string `json:"kind"` X int `json:"x,omitempty"` Y int `json:"y,omitempty"` @@ -73,7 +73,7 @@ func decodeStepSummary(line []byte) (StepSummary, int, error) { Text string `json:"text,omitempty"` Selector string `json:"selector,omitempty"` DurationMillis int `json:"duration_millis,omitempty"` - } `json:"action,omitempty"` + } `json:"next_action,omitempty"` Exceptions []json.RawMessage `json:"exceptions,omitempty"` Violations []string `json:"violations,omitempty"` } @@ -87,29 +87,29 @@ func decodeStepSummary(line []byte) (StepSummary, int, error) { HasViolations: len(partial.Violations) > 0, HasExceptions: len(partial.Exceptions) > 0, } - if partial.Action != nil { - summary.ActionKind = partial.Action.Kind - switch partial.Action.Kind { - case "Tap": - if partial.Action.Selector != "" { - summary.ActionLabel = partial.Action.Selector - } else if partial.Action.Text != "" { - summary.ActionLabel = partial.Action.Text - } else if partial.Action.X != 0 || partial.Action.Y != 0 { - summary.ActionLabel = fmt.Sprintf("(%d,%d)", partial.Action.X, partial.Action.Y) + if partial.NextAction != nil { + summary.ActionKind = partial.NextAction.Kind + switch partial.NextAction.Kind { + case "Tap", "DoubleTap": + if partial.NextAction.Selector != "" { + summary.ActionLabel = partial.NextAction.Selector + } else if partial.NextAction.Text != "" { + summary.ActionLabel = partial.NextAction.Text + } else if partial.NextAction.X != 0 || partial.NextAction.Y != 0 { + summary.ActionLabel = fmt.Sprintf("(%d,%d)", partial.NextAction.X, partial.NextAction.Y) } case "InputText": - summary.ActionLabel = fmt.Sprintf("%q", partial.Action.Text) + summary.ActionLabel = fmt.Sprintf("%q", partial.NextAction.Text) case "Swipe": summary.ActionLabel = swipeDirectionLabel( - partial.Action.FromX, partial.Action.FromY, - partial.Action.ToX, partial.Action.ToY, + partial.NextAction.FromX, partial.NextAction.FromY, + partial.NextAction.ToX, partial.NextAction.ToY, ) case "PressKey": - summary.ActionLabel = partial.Action.Key + summary.ActionLabel = partial.NextAction.Key case "Wait": - if partial.Action.DurationMillis > 0 { - summary.ActionLabel = fmt.Sprintf("%dms", partial.Action.DurationMillis) + if partial.NextAction.DurationMillis > 0 { + summary.ActionLabel = fmt.Sprintf("%dms", partial.NextAction.DurationMillis) } } } diff --git a/internal/inspect/runs_test.go b/internal/inspect/runs_test.go index f3a8b92..62de640 100644 --- a/internal/inspect/runs_test.go +++ b/internal/inspect/runs_test.go @@ -104,7 +104,7 @@ func TestCacheStep_LazyDecodeReturnsFullStep(t *testing.T) { startedAt := time.Now().UTC() steps := []trace.Step{ {Index: 1, Timestamp: startedAt, Screen: "A"}, - {Index: 2, Timestamp: startedAt.Add(time.Second), Screen: "B", Action: &trace.Action{Kind: "tap"}}, + {Index: 2, Timestamp: startedAt.Add(time.Second), Screen: "B", NextAction: &trace.Action{Kind: "tap"}}, {Index: 3, Timestamp: startedAt.Add(2 * time.Second), Screen: "C", Violations: []string{"prop1"}}, } writeRun(t, root, "r1", trace.Meta{StartedAt: startedAt, EndedAt: timePointer(startedAt.Add(3 * time.Second))}, steps) @@ -144,13 +144,13 @@ func TestDecodeStepSummary_ActionLabelPerKind(t *testing.T) { wantKind string wantLabel string }{ - {`{"step":1,"timestamp":"2026-04-20T10:00:00Z","action":{"kind":"Tap","selector":"id:save"}}`, "Tap", "id:save"}, - {`{"step":2,"timestamp":"2026-04-20T10:00:01Z","action":{"kind":"Tap","x":140,"y":220}}`, "Tap", "(140,220)"}, - {`{"step":3,"timestamp":"2026-04-20T10:00:02Z","action":{"kind":"InputText","text":"alice"}}`, "InputText", `"alice"`}, - {`{"step":4,"timestamp":"2026-04-20T10:00:03Z","action":{"kind":"Swipe","from_x":10,"from_y":500,"to_x":10,"to_y":50}}`, "Swipe", "up"}, - {`{"step":5,"timestamp":"2026-04-20T10:00:04Z","action":{"kind":"Swipe","from_x":100,"from_y":50,"to_x":600,"to_y":50}}`, "Swipe", "right"}, - {`{"step":6,"timestamp":"2026-04-20T10:00:05Z","action":{"kind":"PressKey","key":"back"}}`, "PressKey", "back"}, - {`{"step":7,"timestamp":"2026-04-20T10:00:06Z","action":{"kind":"Wait","duration_millis":500}}`, "Wait", "500ms"}, + {`{"step":1,"timestamp":"2026-04-20T10:00:00Z","next_action":{"kind":"Tap","selector":"id:save"}}`, "Tap", "id:save"}, + {`{"step":2,"timestamp":"2026-04-20T10:00:01Z","next_action":{"kind":"Tap","x":140,"y":220}}`, "Tap", "(140,220)"}, + {`{"step":3,"timestamp":"2026-04-20T10:00:02Z","next_action":{"kind":"InputText","text":"alice"}}`, "InputText", `"alice"`}, + {`{"step":4,"timestamp":"2026-04-20T10:00:03Z","next_action":{"kind":"Swipe","from_x":10,"from_y":500,"to_x":10,"to_y":50}}`, "Swipe", "up"}, + {`{"step":5,"timestamp":"2026-04-20T10:00:04Z","next_action":{"kind":"Swipe","from_x":100,"from_y":50,"to_x":600,"to_y":50}}`, "Swipe", "right"}, + {`{"step":6,"timestamp":"2026-04-20T10:00:05Z","next_action":{"kind":"PressKey","key":"back"}}`, "PressKey", "back"}, + {`{"step":7,"timestamp":"2026-04-20T10:00:06Z","next_action":{"kind":"Wait","duration_millis":500}}`, "Wait", "500ms"}, } for _, tc := range cases { summary, _, err := decodeStepSummary([]byte(tc.line)) diff --git a/internal/inspect/server_test.go b/internal/inspect/server_test.go index 13a4f99..d952f1c 100644 --- a/internal/inspect/server_test.go +++ b/internal/inspect/server_test.go @@ -38,7 +38,7 @@ func newFixtureServer(t *testing.T) (*Server, string) { BundleID: "com.example", }, []trace.Step{ {Index: 1, Timestamp: startedAt, Screen: "Home"}, - {Index: 2, Timestamp: startedAt.Add(time.Second), Screen: "Home", Action: &trace.Action{Kind: "tap"}}, + {Index: 2, Timestamp: startedAt.Add(time.Second), Screen: "Home", NextAction: &trace.Action{Kind: "tap"}}, {Index: 3, Timestamp: startedAt.Add(2 * time.Second), Screen: "Cart", Violations: []string{"propA"}}, }) writeRun(t, root, "run-b", trace.Meta{ diff --git a/internal/runner/runner.go b/internal/runner/runner.go index bb0cb6a..b92baad 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -10,6 +10,8 @@ import ( "time" "golang.org/x/sync/errgroup" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/priyanshujain/sanderling/internal/driver" "github.com/priyanshujain/sanderling/internal/hierarchy" @@ -18,6 +20,11 @@ import ( "github.com/priyanshujain/sanderling/internal/verifier" ) +// doubleTapGap is the inter-tap delay for ActionKindDoubleTap: short enough to +// land both events inside a sub-100 ms race window, long enough for adb +// `input tap` to serialize two MotionEvent streams. +const doubleTapGap = 50 * time.Millisecond + type Options struct { Duration time.Duration IdleTimeout time.Duration @@ -53,13 +60,16 @@ func Run(ctx context.Context, options Options) (Summary, error) { logger = slog.Default() } + // Gate on the app actually being on top before acting, so the first + // action never fires against a leftover screen or a system dialog. Done + // before the deadline is set so the settle time does not eat the run. + waitForForeground(ctx, options, logger) + summary := Summary{StartTime: time.Now()} deadline := summary.StartTime.Add(options.Duration) stepIndex := 0 var lastAction *verifier.Action var lastLogTime time.Time - var pendingPostScreenshotStep int - pendingPostScreenshot := false for time.Now().Before(deadline) { if err := ctx.Err(); err != nil { break @@ -67,10 +77,19 @@ func Run(ctx context.Context, options Options) (Summary, error) { stepIndex++ stepStart := time.Now() - // Hierarchy, metrics, and logs are independent device reads — run + // Keep exploration scoped to the app under test. If a prior action + // backed out of (or otherwise left) the app, relaunch it before we + // observe or act, so properties never evaluate against a foreign app + // and actions never land outside the app. + if ensureForeground(ctx, options, logger, stepIndex) { + lastAction = nil + } + + // Hierarchy, metrics, and logs are independent device reads. Run // them concurrently so metrics+logs hide behind the hierarchy fetch. var tree *hierarchy.Tree var hierarchyErr error + var transitional bool var metrics *trace.Metrics var logs []verifier.LogEntry @@ -79,11 +98,14 @@ func Run(ctx context.Context, options Options) (Summary, error) { // goroutine, whose CDP round-trip can otherwise outrun the step // budget on a hung tab. g, gctx := errgroup.WithContext(ctx) + si := stepIndex + // fetchSyncedState issues a single Snapshot RPC so hierarchy and + // screenshot describe the same frame, then re-fetches the pair + // while the tree still looks transitional. g.Go(func() error { - tree, hierarchyErr = fetchHierarchy(gctx, options.Driver) + tree, transitional, hierarchyErr = fetchSyncedState(gctx, options, logger, si) return nil }) - si := stepIndex g.Go(func() error { metrics = captureMetrics(gctx, options, logger, si) return nil @@ -105,14 +127,6 @@ func Run(ctx context.Context, options Options) (Summary, error) { return nil }) } - if pendingPostScreenshot { - postStep := pendingPostScreenshotStep - g.Go(func() error { - captureScreenshot(gctx, options, logger, postStep, true) - return nil - }) - pendingPostScreenshot = false - } // All goroutines write to local variables and return nil, so the Wait // error is always nil; ignored intentionally. _ = g.Wait() @@ -127,38 +141,62 @@ func Run(ctx context.Context, options Options) (Summary, error) { if tree != nil { treeSize = len(tree.Elements) } + // A nil or empty tree means the sidecar's hierarchy fetch failed or + // returned nothing (e.g. transient device-side timeout). Pushing it + // would let spec extractors call findAll() and chain .map() on a null + // result; treat it like a transitional capture so the verifier is + // skipped, the step is still recorded, and the loop progresses. + if treeSize == 0 { + transitional = true + } lastLogTime = stepStart - if err := options.Verifier.PushSnapshot(verifier.SnapshotInput{ - Tree: tree, - LastAction: lastAction, - StepTime: stepStart, - RunStart: summary.StartTime, - Logs: logs, - }); err != nil { - return summary, fmt.Errorf("step %d push: %w", stepIndex, err) - } - skipped, overrideErr := options.Verifier.OverrideExtractorValues(v8Overrides) - if overrideErr != nil { - logger.Warn("v8 override apply failed", "step", stepIndex, "err", overrideErr) - } - if skipped > 0 { - logger.Warn("v8 override skipped out-of-range entries", - "step", stepIndex, "skipped", skipped, "have", len(v8Overrides)) - } - screen := "" if tree != nil && len(tree.Elements) > 0 { screen = tree.Elements[0].Screen } - logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize) - verdicts := options.Verifier.EvaluateProperties() - violations := violationNames(verdicts) - for _, name := range violations { - if predicateErr := options.Verifier.PredicateError(name); predicateErr != nil { - logger.Warn("predicate error", "step", stepIndex, "property", name, "err", predicateErr) + + // Transitional trees describe a NavHost mid cross-fade. Pushing + // one would poison the verifier's previous/current extractor + // advance, so the next clean step would compare against this + // transient state and emit false-positive violations. We still + // record the step (hierarchy + screenshot) for inspect-side + // debugging, but skip the verifier entirely and pick the next + // action against the unchanged prior state to keep the loop + // progressing. + var violations []string + var extractorChanges map[string]trace.ExtractorChange + if !transitional { + if err := options.Verifier.PushSnapshot(verifier.SnapshotInput{ + Tree: tree, + LastAction: lastAction, + StepTime: stepStart, + RunStart: summary.StartTime, + Logs: logs, + }); err != nil { + return summary, fmt.Errorf("step %d push: %w", stepIndex, err) } + skipped, overrideErr := options.Verifier.OverrideExtractorValues(v8Overrides) + if overrideErr != nil { + logger.Warn("v8 override apply failed", "step", stepIndex, "err", overrideErr) + } + if skipped > 0 { + logger.Warn("v8 override skipped out-of-range entries", + "step", stepIndex, "skipped", skipped, "have", len(v8Overrides)) + } + options.Verifier.EvaluateProperties() + violations = options.Verifier.NewlyViolatedProperties() + for _, name := range violations { + if predicateErr := options.Verifier.PredicateError(name); predicateErr != nil { + logger.Warn("predicate error", "step", stepIndex, "property", name, "err", predicateErr) + } + } + extractorChanges = encodeExtractorChanges(options.Verifier.ChangedExtractors()) + } else { + logger.Warn("transitional tree after retry budget; skipping verifier", + "step", stepIndex, "screen", screen, "nodes", treeSize) } + logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize) var nextAction verifier.Action var nextErr error @@ -179,20 +217,43 @@ func Run(ctx context.Context, options Options) (Summary, error) { logger.Warn("residual encode failed", "step", stepIndex, "err", residualErr) } + applySkipped := false + if nextErr == nil { + if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil { + if isWDADrop(err) { + return summary, fmt.Errorf("step %d: iOS XCTest runner lost connection - known WDA startup flake, re-run the test: %w", stepIndex, err) + } + if isTransientApplyError(ctx, err) { + logger.Warn("transient apply error; marking step transitional", "step", stepIndex, "err", err) + transitional = true + applySkipped = true + lastAction = nil + } else { + return summary, fmt.Errorf("step %d apply: %w", stepIndex, err) + } + } else { + actionCopy := nextAction + lastAction = &actionCopy + } + } else { + lastAction = nil + } + step := trace.Step{ - Index: stepIndex, - Timestamp: stepStart, - Screen: screen, - Action: traceAction, - Violations: violations, - Hierarchy: tree, - Residuals: residuals, - Metrics: metrics, + Index: stepIndex, + Timestamp: stepStart, + Screen: screen, + NextAction: traceAction, + Violations: violations, + Hierarchy: tree, + Residuals: residuals, + Metrics: metrics, + ExtractorChanges: extractorChanges, + Transitional: transitional, } if err := options.TraceWriter.WriteStep(step); err != nil { return summary, fmt.Errorf("step %d trace: %w", stepIndex, err) } - captureScreenshot(ctx, options, logger, stepIndex, false) summary.Steps = stepIndex if len(violations) > 0 { summary.Violations = append(summary.Violations, ViolationRecord{ @@ -200,34 +261,19 @@ func Run(ctx context.Context, options Options) (Summary, error) { Properties: violations, }) } - - if nextErr == nil { - if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil { - if isWDADrop(err) { - return summary, fmt.Errorf("step %d: iOS XCTest runner lost connection - known WDA startup flake, re-run the test: %w", stepIndex, err) - } - return summary, fmt.Errorf("step %d apply: %w", stepIndex, err) + // Wait actions are themselves a settling: skip the idle poll. Actions + // that mutate the UI fall through to WaitForIdle so the next step's + // concurrent fetches observe a stable post-action state. A transient + // apply error means nothing landed, so the idle poll has nothing to + // settle and may itself hang on the same device condition. + if nextErr == nil && !applySkipped && nextAction.Kind != verifier.ActionKindWait { + idleCtx, idleCancel := context.WithTimeout(ctx, options.IdleTimeout) + idleErr := options.Driver.WaitForIdle(idleCtx, options.IdleTimeout) + if idleErr != nil && idleCtx.Err() == nil { + logger.Warn("wait_for_idle failed", "step", stepIndex, "err", idleErr) } - actionCopy := nextAction - lastAction = &actionCopy - } else { - lastAction = nil + idleCancel() } - - idleCtx, idleCancel := context.WithTimeout(ctx, options.IdleTimeout) - idleErr := options.Driver.WaitForIdle(idleCtx, options.IdleTimeout) - if nextErr == nil { - pendingPostScreenshot = true - pendingPostScreenshotStep = stepIndex - } - if idleErr != nil && idleCtx.Err() == nil { - logger.Warn("wait_for_idle failed", "step", stepIndex, "err", idleErr) - } - idleCancel() - } - - if pendingPostScreenshot { - captureScreenshot(ctx, options, logger, pendingPostScreenshotStep, true) } summary.EndTime = time.Now() @@ -253,14 +299,109 @@ func validate(options Options) error { return nil } -func violationNames(verdicts map[string]ltl.Verdict) []string { - var names []string - for name, verdict := range verdicts { - if verdict == ltl.VerdictViolated { - names = append(names, name) - } +// ensureForeground keeps the app under test in the foreground. When the driver +// can report the foreground app and it no longer matches the bundle under test, +// the app is relaunched. Returns true when a relaunch happened so the caller +// can drop the now-stale lastAction. Drivers without ForegroundChecker (web, +// iOS) are a no-op. +func ensureForeground(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) bool { + checker, ok := options.Driver.(driver.ForegroundChecker) + if !ok || options.BundleID == "" { + return false } - return names + foreground, err := checker.ForegroundApp(ctx) + if err != nil { + logger.Warn("foreground check failed", "step", stepIndex, "err", err) + return false + } + if foreground == "" || foreground == options.BundleID { + return false + } + logger.Warn("app left foreground; relaunching", + "step", stepIndex, "foreground", foreground, "want", options.BundleID) + return bringToForeground(ctx, options, logger, stepIndex) +} + +// foregroundReadyAttempts bounds how many times waitForForeground tries to +// bring the app forward before the first step, so a stuck system dialog can +// never hang the run. +const foregroundReadyAttempts = 8 + +// waitForForeground blocks until the app under test is actually on screen, so +// the first observe never captures a leftover screen or a freshly-booted +// device's system dialog (e.g. Android's "set a screen lock" prompt). Drivers +// without ForegroundChecker (web) and an unknown foreground both skip the gate. +// +// It is not enough that the app is the resumed activity: ResumedActivity flips +// to a freshly launched app ~before its first frame draws, so gating on it +// alone lets the first observe read the outgoing app. When the driver can also +// report the focused window, the gate additionally waits for that window to +// name the app, which only happens once it is genuinely drawn. +func waitForForeground(ctx context.Context, options Options, logger *slog.Logger) { + checker, ok := options.Driver.(driver.ForegroundChecker) + if !ok || options.BundleID == "" { + return + } + focusChecker, hasFocus := options.Driver.(driver.FocusedWindowChecker) + for attempt := range foregroundReadyAttempts { + if err := ctx.Err(); err != nil { + return + } + foreground, err := checker.ForegroundApp(ctx) + if err != nil { + logger.Warn("foreground check failed before first step", "err", err) + return + } + if foreground == "" { + return // foreground unknowable (e.g. iOS); don't block the run + } + if foreground != options.BundleID { + logger.Warn("app not in foreground at start; bringing it forward", + "foreground", foreground, "want", options.BundleID, "attempt", attempt) + bringToForeground(ctx, options, logger, 0) + continue + } + if !hasFocus { + return // resumed is the app and no finer signal exists + } + focused, err := focusChecker.FocusedWindowApp(ctx) + if err != nil { + logger.Warn("focus check failed before first step", "err", err) + return + } + if focused == options.BundleID { + return // window is drawn; safe to observe + } + logger.Warn("app resumed but window not yet drawn; waiting", + "focused", focused, "want", options.BundleID, "attempt", attempt) + settleForForeground(ctx, options) + } + logger.Warn("app never reached foreground before first step; proceeding anyway", + "want", options.BundleID) +} + +// bringToForeground returns the app under test to the foreground. It first +// presses BACK to dismiss any modal system dialog (a relaunch alone does not +// close one), then relaunches and waits for the UI to settle. Returns true +// when the relaunch itself succeeded. +func bringToForeground(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) bool { + if err := options.Driver.PressKey(ctx, "back"); err != nil { + logger.Warn("dismiss key before relaunch failed", "step", stepIndex, "err", err) + } + if err := options.Driver.Launch(ctx, options.BundleID, false, nil); err != nil { + logger.Warn("relaunch failed", "step", stepIndex, "err", err) + return false + } + settleForForeground(ctx, options) + return true +} + +// settleForForeground waits one idle window for the UI to settle, bounding the +// wait by the driver's idle timeout. +func settleForForeground(ctx context.Context, options Options) { + idleCtx, cancel := context.WithTimeout(ctx, options.IdleTimeout) + _ = options.Driver.WaitForIdle(idleCtx, options.IdleTimeout) + cancel() } func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) error { @@ -274,6 +415,43 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A return drv.TapSelector(ctx, action.On) } return drv.Tap(ctx, x, y) + case verifier.ActionKindDoubleTap: + x, y, ok := resolveCoordinates(action, tree) + tap := func() error { + if !ok { + if action.On == "" { + return nil + } + return drv.TapSelector(ctx, action.On) + } + return drv.Tap(ctx, x, y) + } + if err := tap(); err != nil { + return err + } + timer := time.NewTimer(doubleTapGap) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + } + return tap() + case verifier.ActionKindLongPress: + x, y, ok := resolveCoordinates(action, tree) + if !ok { + // No long-press-by-selector RPC exists, so an unresolved target is + // nothing we can dispatch; skip rather than error. + return nil + } + return drv.LongPress(ctx, x, y) + case verifier.ActionKindScroll: + fromX, fromY, toX, toY := scrollEndpoints(action, tree) + duration := time.Duration(action.DurationMillis) * time.Millisecond + if duration <= 0 { + duration = 300 * time.Millisecond + } + return drv.Swipe(ctx, fromX, fromY, toX, toY, duration) case verifier.ActionKindInputText: if x, y, ok := resolveCoordinates(action, tree); ok { if err := drv.Tap(ctx, x, y); err != nil { @@ -360,18 +538,139 @@ func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, return 0, 0, false } -func fetchHierarchy(ctx context.Context, drv driver.DeviceDriver) (*hierarchy.Tree, error) { - xmlText, err := drv.Hierarchy(ctx) - if err != nil { - return nil, err +// scrollEndpoints lowers a Scroll to a swipe's from/to points. Pre-computed +// endpoints (from the generator) win. Otherwise it derives them from the +// container bounds: the named node when On resolves, else the whole screen. +func scrollEndpoints(action verifier.Action, tree *hierarchy.Tree) (fromX, fromY, toX, toY int) { + if action.FromX != 0 || action.FromY != 0 || action.ToX != 0 || action.ToY != 0 { + return action.FromX, action.FromY, action.ToX, action.ToY } - return hierarchy.Parse(xmlText) + bounds := scrollBounds(action, tree) + cx, cy := bounds.Center() + width := bounds.Width() + height := bounds.Height() + toX, toY = cx, cy + // Scroll direction names content motion; the gesture swipes the opposite + // way. Revealing lower content ("down") drags the finger up, so toY drops. + switch action.Direction { + case "down": + toY = cy - (4*height)/10 + case "up": + toY = cy + (4*height)/10 + case "left": + toX = cx + (4*width)/10 + case "right": + toX = cx - (4*width)/10 + } + if toX < 0 { + toX = 0 + } + if toY < 0 { + toY = 0 + } + return cx, cy, toX, toY +} + +// scrollBounds returns the container bounds for an authored Scroll: the node +// named by On when it resolves, otherwise the root (whole-screen) bounds. +func scrollBounds(action verifier.Action, tree *hierarchy.Tree) hierarchy.Bounds { + if tree == nil { + return hierarchy.Bounds{} + } + if action.On != "" { + if element := tree.Find(action.On); element != nil { + return element.Bounds + } + } + if tree.Root != nil { + return tree.Root.Bounds + } + return hierarchy.Bounds{} +} + +// transitionalRetryAttempts caps how many times we re-fetch hierarchy when a +// tree carries more than one route-level Screen tag (NavHost cross-fade in +// flight). Each retry pauses transitionalRetrySleep before the next fetch. +const ( + transitionalRetryAttempts = 4 + transitionalRetrySleep = 200 * time.Millisecond +) + +// fetchSyncedState fetches hierarchy and screenshot together so the recorded +// pair shows the same UI moment. If the hierarchy looks like a NavHost +// cross-fade (multiple route-level *Screen tags), the function waits briefly +// and re-fetches the pair, up to transitionalRetryAttempts times. This +// handles transitions whose async work begins after the sidecar's settle +// poll has already exited. +// +// The driver's Snapshot RPC captures both reads under a backend-side mutex +// so they describe the same on-device frame; the retry exists for the +// orthogonal case where the frame itself is transitional. +// +// The transitional return reports whether the retry budget was exhausted +// on a still-transitional tree. Callers use it to skip the verifier for +// that step so the previous/current extractor advance does not absorb +// transient state. +func fetchSyncedState(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) (tree *hierarchy.Tree, transitional bool, err error) { + var pngBytes []byte +retryLoop: + for attempt := range transitionalRetryAttempts { + hierarchyJSON, image, snapshotErr := options.Driver.Snapshot(ctx) + if snapshotErr != nil { + err = snapshotErr + tree = nil + } else { + tree, err = hierarchy.Parse(hierarchyJSON) + pngBytes = image.PNG + } + if err != nil || !isTransitionalHierarchy(tree) { + break + } + if attempt == transitionalRetryAttempts-1 { + transitional = true + break + } + timer := time.NewTimer(transitionalRetrySleep) + select { + case <-ctx.Done(): + timer.Stop() + break retryLoop + case <-timer.C: + } + } + if len(pngBytes) > 0 { + if writeErr := options.TraceWriter.WriteScreenshot(stepIndex, pngBytes); writeErr != nil { + logger.Warn("screenshot write failed", "step", stepIndex, "err", writeErr) + } + } + return tree, transitional, err +} + +// isTransitionalHierarchy returns true when the tree carries more than one +// resource-id ending in "Screen" - the marker of a Compose NavHost mid +// cross-fade where both source and destination route composables are alive. +// Mirrors the sidecar's stabilitySnapshot heuristic so runner-side rejection +// stays consistent with the settle poll. +func isTransitionalHierarchy(tree *hierarchy.Tree) bool { + if tree == nil { + return false + } + screens := 0 + for _, element := range tree.Elements { + if strings.HasSuffix(element.ResourceID, "Screen") { + screens++ + if screens > 1 { + return true + } + } + } + return false } func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action { traceAction := &trace.Action{Kind: string(action.Kind), X: action.X, Y: action.Y} switch action.Kind { - case verifier.ActionKindTap: + case verifier.ActionKindTap, verifier.ActionKindDoubleTap, verifier.ActionKindLongPress: traceAction.Selector = action.On stampSelectorTarget(traceAction, action, tree) case verifier.ActionKindInputText: @@ -386,6 +685,15 @@ func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action traceAction.DurationMillis = action.DurationMillis traceAction.X = 0 traceAction.Y = 0 + case verifier.ActionKindScroll: + fromX, fromY, toX, toY := scrollEndpoints(action, tree) + traceAction.FromX = fromX + traceAction.FromY = fromY + traceAction.ToX = toX + traceAction.ToY = toY + traceAction.DurationMillis = action.DurationMillis + traceAction.X = 0 + traceAction.Y = 0 case verifier.ActionKindPressKey: traceAction.Key = action.Key case verifier.ActionKindWait: @@ -469,6 +777,8 @@ func nextActionFromV8(ctx context.Context, web driver.WebDriver) (verifier.Actio switch decoded.Kind { case "Tap": return verifier.Action{Kind: verifier.ActionKindTap, X: decoded.X, Y: decoded.Y}, nil + case "DoubleTap": + return verifier.Action{Kind: verifier.ActionKindDoubleTap, X: decoded.X, Y: decoded.Y}, nil case "InputText": return verifier.Action{ Kind: verifier.ActionKindInputText, @@ -493,24 +803,18 @@ func nextActionFromV8(ctx context.Context, web driver.WebDriver) (verifier.Actio } } -func captureScreenshot(ctx context.Context, options Options, logger *slog.Logger, stepIndex int, after bool) { - image, err := options.Driver.Screenshot(ctx) - if err != nil { - logger.Warn("screenshot capture failed", "step", stepIndex, "after", after, "err", err) - return +func encodeExtractorChanges(changes map[string]verifier.ExtractorChange) map[string]trace.ExtractorChange { + if len(changes) == 0 { + return nil } - if len(image.PNG) == 0 { - return - } - var writeErr error - if after { - writeErr = options.TraceWriter.WriteScreenshotAfter(stepIndex, image.PNG) - } else { - writeErr = options.TraceWriter.WriteScreenshot(stepIndex, image.PNG) - } - if writeErr != nil { - logger.Warn("screenshot write failed", "step", stepIndex, "after", after, "err", writeErr) + 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) { @@ -537,3 +841,29 @@ func isWDADrop(err error) bool { return strings.Contains(msg, "ConnectException") || (strings.Contains(msg, "code = Internal") && strings.Contains(msg, "SocketException")) } + +// isTransientApplyError reports whether an applyAction failure is a transient +// device-side hang (sidecar RPC deadline, momentary unavailability) rather than +// a fatal condition. Such steps are recorded as transitional and the loop +// continues. The run context being cancelled is never transient: it means the +// caller wants to stop. +func isTransientApplyError(runCtx context.Context, err error) bool { + if err == nil || runCtx.Err() != nil { + return false + } + if s, ok := status.FromError(err); ok { + switch s.Code() { + case codes.DeadlineExceeded, codes.Unavailable: + return true + case codes.Internal: + message := s.Message() + if strings.Contains(message, "DEADLINE_EXCEEDED") || strings.Contains(message, "UNAVAILABLE") { + return true + } + } + } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + return false +} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 2f7c54c..e904f38 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1,8 +1,10 @@ package runner import ( + "bufio" "bytes" "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -13,8 +15,12 @@ import ( "testing" "time" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "github.com/priyanshujain/sanderling/internal/driver" mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock" + "github.com/priyanshujain/sanderling/internal/hierarchy" "github.com/priyanshujain/sanderling/internal/trace" "github.com/priyanshujain/sanderling/internal/verifier" ) @@ -118,6 +124,76 @@ func TestRunner_ViolationSurfacesInSummary(t *testing.T) { } } +func TestRunner_ViolationSurfacesOnlyOnOnsetStep(t *testing.T) { + // violationSpec uses always(() => false): onset fires on step 1 and the + // residual stays violated forever. The runner must record the violation + // exactly once (at the onset step) in both summary.Violations and trace + // lines, not on every subsequent step. + state := newHarnessWithSpec(t, violationSpec) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + summary, err := Run(ctx, Options{ + Duration: 200 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if summary.Steps < 2 { + t.Fatalf("need at least 2 steps to prove onset-only behavior, got %d", summary.Steps) + } + if len(summary.Violations) != 1 { + t.Fatalf("expected exactly one ViolationRecord (onset only), got %d: %v", + len(summary.Violations), summary.Violations) + } + if summary.Violations[0].StepIndex != 1 { + t.Errorf("onset step: got %d, want 1 (always(()=>false) violates immediately)", + summary.Violations[0].StepIndex) + } + if !slices.Equal(summary.Violations[0].Properties, []string{"balanceNonNegative"}) { + t.Errorf("onset properties: got %v, want [balanceNonNegative]", + summary.Violations[0].Properties) + } + + file, err := os.Open(filepath.Join(state.writer.Directory(), "trace.jsonl")) + if err != nil { + t.Fatal(err) + } + defer file.Close() + + type traceLine struct { + Step int `json:"step"` + Violations []string `json:"violations"` + } + linesWithViolations := 0 + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for scanner.Scan() { + var line traceLine + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + t.Fatalf("trace line decode: %v", err) + } + if len(line.Violations) == 0 { + continue + } + linesWithViolations++ + if line.Step != 1 { + t.Errorf("step %d unexpectedly emitted violations %v (should be onset-only at step 1)", + line.Step, line.Violations) + } + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan trace: %v", err) + } + if linesWithViolations != 1 { + t.Errorf("expected exactly 1 trace line with violations, got %d", linesWithViolations) + } +} + func TestRunner_ThrowingPredicateIsLoggedNotPanic(t *testing.T) { const throwingSpec = ` globalThis.properties = { @@ -294,6 +370,151 @@ func TestApplyAction_V8InputTextAtOriginStillTaps(t *testing.T) { } } +func TestApplyAction_DoubleTapDispatchesTwoTapsAtCoordinates(t *testing.T) { + driverMock := mockdriver.New() + action := verifier.Action{Kind: verifier.ActionKindDoubleTap, X: 100, Y: 200} + + start := time.Now() + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { + t.Fatalf("apply action: %v", err) + } + elapsed := time.Since(start) + if elapsed < 40*time.Millisecond { + t.Errorf("expected >= 40ms gap between taps, elapsed %v", elapsed) + } + taps := 0 + for _, a := range driverMock.Actions() { + if a.Kind == mockdriver.ActionTap && a.X == 100 && a.Y == 200 { + taps++ + } + } + if taps != 2 { + t.Errorf("expected 2 Tap calls at (100,200), got %d in %v", taps, driverMock.Actions()) + } +} + +func TestApplyAction_DoubleTapDispatchesTwoSelectorTaps(t *testing.T) { + driverMock := mockdriver.New() + action := verifier.Action{Kind: verifier.ActionKindDoubleTap, On: "id:save"} + + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { + t.Fatalf("apply action: %v", err) + } + taps := 0 + for _, a := range driverMock.Actions() { + if a.Kind == mockdriver.ActionTapSelector && a.Selector == "id:save" { + taps++ + } + } + if taps != 2 { + t.Errorf("expected 2 TapSelector calls with id:save, got %d in %v", taps, driverMock.Actions()) + } +} + +func TestApplyAction_LongPressDispatchesAtResolvedCoordinates(t *testing.T) { + driverMock := mockdriver.New() + action := verifier.Action{Kind: verifier.ActionKindLongPress, X: 120, Y: 240} + + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { + t.Fatalf("apply action: %v", err) + } + found := false + for _, a := range driverMock.Actions() { + if a.Kind == mockdriver.ActionLongPress && a.X == 120 && a.Y == 240 { + found = true + } + } + if !found { + t.Errorf("expected LongPress at (120,240), got %v", driverMock.Actions()) + } +} + +func TestApplyAction_ScrollWithPrecomputedEndpointsSwipes(t *testing.T) { + driverMock := mockdriver.New() + action := verifier.Action{ + Kind: verifier.ActionKindScroll, + Direction: "down", + FromX: 100, + FromY: 500, + ToX: 100, + ToY: 300, + DurationMillis: 300, + } + + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { + t.Fatalf("apply action: %v", err) + } + found := false + for _, a := range driverMock.Actions() { + if a.Kind == mockdriver.ActionSwipe && a.FromX == 100 && a.FromY == 500 && a.ToX == 100 && a.ToY == 300 { + found = true + } + } + if !found { + t.Errorf("expected Swipe with precomputed endpoints, got %v", driverMock.Actions()) + } +} + +func TestApplyAction_ScrollDirectionUsesInversion(t *testing.T) { + driverMock := mockdriver.New() + treeJSON := `{"attributes":{"resource-id":"com.fixture:id/list","bounds":"[0,0,400,800]"},"children":[],"enabled":true}` + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatalf("parse tree: %v", err) + } + action := verifier.Action{Kind: verifier.ActionKindScroll, Direction: "down", On: "id:list"} + + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { + t.Fatalf("apply action: %v", err) + } + var swipe *mockdriver.Action + for i := range driverMock.Actions() { + if driverMock.Actions()[i].Kind == mockdriver.ActionSwipe { + a := driverMock.Actions()[i] + swipe = &a + } + } + if swipe == nil { + t.Fatalf("expected a Swipe, got %v", driverMock.Actions()) + } + // "down" reveals lower content by dragging the finger up, so toY < fromY. + if swipe.ToY >= swipe.FromY { + t.Errorf("expected toY < fromY for scroll down, got from=%d to=%d", swipe.FromY, swipe.ToY) + } +} + +func TestApplyAction_ScrollScreenFallback(t *testing.T) { + driverMock := mockdriver.New() + treeJSON := `{"attributes":{"bounds":"[0,0,400,800]"},"children":[],"enabled":true}` + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatalf("parse tree: %v", err) + } + // On unset: container falls back to whole-screen (root) bounds. + action := verifier.Action{Kind: verifier.ActionKindScroll, Direction: "up"} + + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { + t.Fatalf("apply action: %v", err) + } + var swipe *mockdriver.Action + for i := range driverMock.Actions() { + if driverMock.Actions()[i].Kind == mockdriver.ActionSwipe { + a := driverMock.Actions()[i] + swipe = &a + } + } + if swipe == nil { + t.Fatalf("expected a Swipe, got %v", driverMock.Actions()) + } + if swipe.FromX != 200 || swipe.FromY != 400 { + t.Errorf("expected swipe to start at screen center (200,400), got (%d,%d)", swipe.FromX, swipe.FromY) + } + // "up" reveals upper content by dragging the finger down, so toY > fromY. + if swipe.ToY <= swipe.FromY { + t.Errorf("expected toY > fromY for scroll up, got from=%d to=%d", swipe.FromY, swipe.ToY) + } +} + func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) { state := newHarness(t) state.mock.MetricsData = driver.Metrics{CPUPercent: 5.0, HeapBytes: 1024, TotalMemoryBytes: 4096} @@ -316,19 +537,19 @@ func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) { } actions := state.mock.Actions() - var hasHierarchy, hasMetrics, hasLogs bool + var hasSnapshot, hasMetrics, hasLogs bool for _, a := range actions { switch a.Kind { - case mockdriver.ActionHierarchy: - hasHierarchy = true + case mockdriver.ActionSnapshot: + hasSnapshot = true case mockdriver.ActionMetrics: hasMetrics = true case mockdriver.ActionRecentLogs: hasLogs = true } } - if !hasHierarchy { - t.Error("expected Hierarchy call in mock actions") + if !hasSnapshot { + t.Error("expected Snapshot call in mock actions") } if !hasMetrics { t.Error("expected Metrics call in mock actions") @@ -338,7 +559,56 @@ func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) { } } -func TestRunner_PipelinedPostScreenshotWritten(t *testing.T) { +// TestRunner_UsesAtomicSnapshot ensures the runner observes a step's UI +// through the paired Snapshot RPC instead of racing two independent +// hierarchy + screenshot reads. The pair must come from one on-device +// frame; a regression to separate calls is what this test catches. +func TestRunner_UsesAtomicSnapshot(t *testing.T) { + state := newHarness(t) + state.mock.ImageData = driver.Image{PNG: []byte("png"), Width: 1, Height: 1} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + summary, err := Run(ctx, Options{ + Duration: 100 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if summary.Steps == 0 { + t.Fatal("expected at least one step") + } + + var snapshotCalls, hierarchyCalls, screenshotCalls int + for _, action := range state.mock.Actions() { + switch action.Kind { + case mockdriver.ActionSnapshot: + snapshotCalls++ + case mockdriver.ActionHierarchy: + hierarchyCalls++ + case mockdriver.ActionScreenshot: + screenshotCalls++ + } + } + if snapshotCalls == 0 { + t.Errorf("expected at least one Snapshot call, got %d", snapshotCalls) + } + if hierarchyCalls != 0 { + t.Errorf("expected zero standalone Hierarchy calls (runner must use Snapshot), got %d", hierarchyCalls) + } + if screenshotCalls != 0 { + t.Errorf("expected zero standalone Screenshot calls (runner must use Snapshot), got %d", screenshotCalls) + } +} + +// TestRunner_OneScreenshotPerStep verifies the runner writes a single +// screenshot per step, captured concurrently with hierarchy so the two +// observations describe the same UI moment. +func TestRunner_OneScreenshotPerStep(t *testing.T) { state := newHarness(t) state.mock.ImageData = driver.Image{PNG: []byte("fakepng"), Width: 100, Height: 200} @@ -355,24 +625,362 @@ func TestRunner_PipelinedPostScreenshotWritten(t *testing.T) { t.Fatalf("Run: %v", err) } if summary.Steps < 2 { - t.Fatalf("need at least 2 steps for pipelining test, got %d", summary.Steps) + t.Fatalf("need at least 2 steps for screenshot test, got %d", summary.Steps) } screenshotDir := filepath.Join(state.writer.Directory(), "screenshots") - - preFile := filepath.Join(screenshotDir, "step-00001.png") - if _, err := os.Stat(preFile); os.IsNotExist(err) { - t.Errorf("expected pre-screenshot for step 1: %s", preFile) + for step := 1; step <= summary.Steps; step++ { + path := filepath.Join(screenshotDir, fmt.Sprintf("step-%05d.png", step)) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected screenshot for step %d at %s", step, path) + } } - postFile := filepath.Join(screenshotDir, "step-00001-after.png") - if _, err := os.Stat(postFile); os.IsNotExist(err) { - t.Errorf("expected pipelined post-screenshot for step 1: %s", postFile) + entries, err := os.ReadDir(screenshotDir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.Contains(entry.Name(), "-after") { + t.Errorf("unexpected -after screenshot remains: %s", entry.Name()) + } + } +} + +// TestIsTransitionalHierarchy_DetectsMultipleScreens covers the runner-side +// guard that re-fetches when the hierarchy still carries two route-level +// *Screen ids - the NavHost cross-fade signature. +func TestIsTransitionalHierarchy_DetectsMultipleScreens(t *testing.T) { + multi, err := hierarchy.Parse(`{"attributes":{"resource-id":"root"},"children":[ + {"attributes":{"resource-id":"AddAccountScreen"},"children":[]}, + {"attributes":{"resource-id":"HomeScreen"},"children":[]} + ]}`) + if err != nil { + t.Fatal(err) + } + if !isTransitionalHierarchy(multi) { + t.Error("expected multi-screen tree to be flagged as transitional") } - lastAfter := filepath.Join(screenshotDir, fmt.Sprintf("step-%05d-after.png", summary.Steps)) - if _, err := os.Stat(lastAfter); os.IsNotExist(err) { - t.Errorf("expected flushed post-screenshot for last step %d: %s", summary.Steps, lastAfter) + single, err := hierarchy.Parse(`{"attributes":{"resource-id":"HomeScreen"},"children":[]}`) + if err != nil { + t.Fatal(err) + } + if isTransitionalHierarchy(single) { + t.Error("single-screen tree must not be flagged as transitional") + } + + if isTransitionalHierarchy(nil) { + t.Error("nil tree must not be flagged as transitional") + } +} + +// TestRunner_TransitionalSkipsVerifier feeds a driver whose hierarchy stays +// transitional (multiple route-level *Screen ids) on every Snapshot call. +// Every step must be marked transitional in the trace, no violations may be +// emitted (the verifier never ran), and the summary must stay clean even +// though the spec is a guaranteed always-false predicate. +func TestRunner_TransitionalSkipsVerifier(t *testing.T) { + state := newHarnessWithSpec(t, violationSpec) + state.mock.HierarchyJSON = `{"attributes":{"resource-id":"root"},"children":[ + {"attributes":{"resource-id":"AddAccountScreen"},"children":[]}, + {"attributes":{"resource-id":"HomeScreen"},"children":[]} + ]}` + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + summary, err := Run(ctx, Options{ + Duration: 200 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if summary.Steps == 0 { + t.Fatal("expected at least one step") + } + if len(summary.Violations) != 0 { + t.Fatalf("verifier must be skipped on transitional steps; got %v", summary.Violations) + } + + type traceLine struct { + Step int `json:"step"` + Transitional bool `json:"transitional"` + Violations []string `json:"violations"` + } + body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl")) + if err != nil { + t.Fatal(err) + } + lines := 0 + for _, raw := range bytes.Split(bytes.TrimSpace(body), []byte("\n")) { + var line traceLine + if err := json.Unmarshal(raw, &line); err != nil { + t.Fatalf("decode trace line: %v", err) + } + lines++ + if !line.Transitional { + t.Errorf("step %d: expected transitional=true on every step, got false", line.Step) + } + if len(line.Violations) != 0 { + t.Errorf("step %d: verifier must be skipped, got violations %v", line.Step, line.Violations) + } + } + if lines == 0 { + t.Fatal("expected trace lines, got none") + } +} + +// TestRunner_CleanTreeStillVerified is the control: a single-screen hierarchy +// must not be marked transitional and the verifier must still run, surfacing +// the always-false predicate's violation on the onset step. +func TestRunner_CleanTreeStillVerified(t *testing.T) { + state := newHarnessWithSpec(t, violationSpec) + state.mock.HierarchyJSON = `{"attributes":{"resource-id":"HomeScreen"},"children":[]}` + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + summary, err := Run(ctx, Options{ + Duration: 200 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !containsProperty(summary.Violations, "balanceNonNegative") { + t.Fatalf("expected verifier to surface balanceNonNegative on a clean tree, got %v", summary.Violations) + } + + type traceLine struct { + Step int `json:"step"` + Transitional bool `json:"transitional"` + } + body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl")) + if err != nil { + t.Fatal(err) + } + for _, raw := range bytes.Split(bytes.TrimSpace(body), []byte("\n")) { + var line traceLine + if err := json.Unmarshal(raw, &line); err != nil { + t.Fatalf("decode trace line: %v", err) + } + if line.Transitional { + t.Errorf("step %d: clean tree must not be marked transitional", line.Step) + } + } +} + +// snapshotFailFirst wraps a mock driver so the first Snapshot call returns an +// error (mimicking a sidecar timeout while fetching view hierarchy), then +// delegates every subsequent call back to the mock. +type snapshotFailFirst struct { + *mockdriver.Driver + calls int +} + +func (d *snapshotFailFirst) Snapshot(ctx context.Context) (string, driver.Image, error) { + d.calls++ + if d.calls == 1 { + return "", driver.Image{}, errors.New("Timeout while fetching view hierarchy") + } + return d.Driver.Snapshot(ctx) +} + +// TestRunner_NilHierarchyMarksTransitional verifies that when the sidecar's +// hierarchy fetch fails (nil tree), the runner marks the step transitional and +// skips the verifier instead of pushing a nil tree that would crash the spec. +// Subsequent steps with a clean tree still drive the verifier normally. +func TestRunner_NilHierarchyMarksTransitional(t *testing.T) { + state := newHarnessWithSpec(t, violationSpec) + state.mock.HierarchyJSON = `{"attributes":{"resource-id":"HomeScreen"},"children":[]}` + wrapped := &snapshotFailFirst{Driver: state.mock} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + summary, err := Run(ctx, Options{ + Duration: 200 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + Driver: wrapped, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if summary.Steps < 2 { + t.Fatalf("need at least 2 steps to verify the first is skipped and the second runs, got %d", summary.Steps) + } + // violationSpec always() => false fires on the first verifier push. With + // step 1's verifier skipped, onset moves to step 2. + if len(summary.Violations) != 1 { + t.Fatalf("expected exactly one onset record, got %d: %v", len(summary.Violations), summary.Violations) + } + if summary.Violations[0].StepIndex != 2 { + t.Errorf("onset step: got %d, want 2 (step 1 verifier skipped due to nil tree)", summary.Violations[0].StepIndex) + } + + type traceLine struct { + Step int `json:"step"` + Transitional bool `json:"transitional"` + Violations []string `json:"violations"` + } + body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl")) + if err != nil { + t.Fatal(err) + } + var first traceLine + if err := json.Unmarshal(bytes.SplitN(bytes.TrimSpace(body), []byte("\n"), 2)[0], &first); err != nil { + t.Fatalf("decode first trace line: %v", err) + } + if first.Step != 1 { + t.Fatalf("first trace line step: got %d, want 1", first.Step) + } + if !first.Transitional { + t.Error("first step must be marked transitional when the hierarchy fetch failed") + } + if len(first.Violations) != 0 { + t.Errorf("step 1 must skip the verifier; got violations %v", first.Violations) + } +} + +// tapSelectorFailFirst wraps a mock driver so the first TapSelector call +// returns a gRPC DeadlineExceeded error (mimicking a sidecar-side RPC hang), +// then delegates every subsequent call back to the mock. +type tapSelectorFailFirst struct { + *mockdriver.Driver + calls int +} + +func (d *tapSelectorFailFirst) TapSelector(ctx context.Context, selector string) error { + d.calls++ + if d.calls == 1 { + return status.Error(codes.DeadlineExceeded, "boom") + } + return d.Driver.TapSelector(ctx, selector) +} + +// TestRunner_TransientApplyErrorMarksTransitional verifies that a transient +// gRPC error from applyAction (e.g. sidecar RPC deadline) does not kill the +// run: the step is marked transitional, the verifier is skipped for it, and +// the loop continues with the next step running cleanly. +func TestRunner_TransientApplyErrorMarksTransitional(t *testing.T) { + state := newHarness(t) + wrapped := &tapSelectorFailFirst{Driver: state.mock} + + var logBuf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn})) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + summary, err := Run(ctx, Options{ + Duration: 300 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + Driver: wrapped, + Verifier: state.verifier, + TraceWriter: state.writer, + Logger: logger, + }) + if err != nil { + t.Fatalf("Run must not return on transient apply error, got %v", err) + } + if summary.Steps < 2 { + t.Fatalf("need at least 2 steps to prove the loop continued past the failed apply, got %d", summary.Steps) + } + if len(summary.Violations) != 0 { + t.Errorf("transient apply error must not surface as a violation, got %v", summary.Violations) + } + if !strings.Contains(logBuf.String(), "transient apply error") { + t.Errorf("expected transient-apply WARN log, got %q", logBuf.String()) + } + + type traceLine struct { + Step int `json:"step"` + Transitional bool `json:"transitional"` + Violations []string `json:"violations"` + } + body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl")) + if err != nil { + t.Fatal(err) + } + lines := bytes.Split(bytes.TrimSpace(body), []byte("\n")) + var first, second traceLine + if err := json.Unmarshal(lines[0], &first); err != nil { + t.Fatalf("decode first trace line: %v", err) + } + if err := json.Unmarshal(lines[1], &second); err != nil { + t.Fatalf("decode second trace line: %v", err) + } + if first.Step != 1 || !first.Transitional { + t.Errorf("step 1 must be transitional after transient apply error, got step=%d transitional=%v", first.Step, first.Transitional) + } + if len(first.Violations) != 0 { + t.Errorf("transient apply step must have no violations, got %v", first.Violations) + } + if second.Step != 2 || second.Transitional { + t.Errorf("step 2 must run cleanly after the transient step, got step=%d transitional=%v", second.Step, second.Transitional) + } +} + +// TestIsTransientApplyError_Classification covers the helper's matching rules +// directly so future code changes don't quietly drop a transient case. +func TestIsTransientApplyError_Classification(t *testing.T) { + cleanCtx := context.Background() + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + cases := []struct { + name string + ctx context.Context + err error + want bool + }{ + {"nil error", cleanCtx, nil, false}, + {"deadline exceeded", cleanCtx, status.Error(codes.DeadlineExceeded, "boom"), true}, + {"unavailable", cleanCtx, status.Error(codes.Unavailable, "boom"), true}, + {"internal wrapping deadline", cleanCtx, status.Error(codes.Internal, "io.grpc.StatusRuntimeException: DEADLINE_EXCEEDED: ..."), true}, + {"internal wrapping unavailable", cleanCtx, status.Error(codes.Internal, "io.grpc.StatusRuntimeException: UNAVAILABLE: ..."), true}, + {"internal generic", cleanCtx, status.Error(codes.Internal, "boom"), false}, + {"raw context deadline", cleanCtx, context.DeadlineExceeded, true}, + {"run context cancelled overrides", cancelledCtx, status.Error(codes.DeadlineExceeded, "boom"), false}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := isTransientApplyError(testCase.ctx, testCase.err); got != testCase.want { + t.Errorf("got %v, want %v", got, testCase.want) + } + }) + } +} + +// TestRunner_WaitActionSkipsIdle ensures the runner does not call WaitForIdle +// after a Wait action - the action already provides settling time. +func TestRunner_WaitActionSkipsIdle(t *testing.T) { + const waitSpec = ` +globalThis.actions = __sanderling__.actions(() => [__sanderling__.wait({ durationMillis: 5 })]); +` + state := newHarnessWithSpec(t, waitSpec) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := Run(ctx, Options{ + Duration: 150 * time.Millisecond, + IdleTimeout: 50 * time.Millisecond, + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + for _, action := range state.mock.Actions() { + if action.Kind == mockdriver.ActionWaitForIdle { + t.Fatalf("Wait action must skip WaitForIdle, got: %v", action) + } } } @@ -426,3 +1034,142 @@ func containsProperty(records []ViolationRecord, property string) bool { } return false } + +func TestRunner_RelaunchesWhenAppLeavesForeground(t *testing.T) { + state := newHarness(t) + // Always report a foreign app, so every step's guard must relaunch. + state.mock.ForegroundResults = []string{"com.android.chrome"} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := Run(ctx, Options{ + Duration: 100 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + BundleID: "app.folio", + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + relaunches := 0 + for _, a := range state.mock.Actions() { + if a.Kind == mockdriver.ActionLaunch && a.BundleID == "app.folio" && !a.ClearState { + relaunches++ + } + } + if relaunches == 0 { + t.Fatal("expected runner to relaunch app.folio when foreground escaped, got none") + } +} + +func TestRunner_NoRelaunchWhenAppInForeground(t *testing.T) { + state := newHarness(t) + state.mock.ForegroundResults = []string{"app.folio"} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := Run(ctx, Options{ + Duration: 100 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + BundleID: "app.folio", + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + for _, a := range state.mock.Actions() { + if a.Kind == mockdriver.ActionLaunch { + t.Fatalf("expected no relaunch while app in foreground, got %v", a) + } + } +} + +// TestRunner_WaitsForForegroundBeforeFirstAction verifies the startup gate +// brings the app forward (back-press + relaunch) before any tap fires when the +// device boots showing a system dialog. +func TestRunner_WaitsForForegroundBeforeFirstAction(t *testing.T) { + state := newHarness(t) + // First the device shows a system setup screen, then the app is on top. + state.mock.ForegroundResults = []string{"com.google.android.setupwizard", "app.folio"} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := Run(ctx, Options{ + Duration: 100 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + BundleID: "app.folio", + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + + actions := state.mock.Actions() + firstLaunch, firstTap := -1, -1 + backPressed := false + for i, a := range actions { + switch { + case a.Kind == mockdriver.ActionLaunch && firstLaunch < 0: + firstLaunch = i + case a.Kind == mockdriver.ActionTap && firstTap < 0: + firstTap = i + case a.Kind == mockdriver.ActionPressKey && a.Key == "back": + backPressed = true + } + } + if firstLaunch < 0 { + t.Fatal("expected a relaunch to bring the app forward, got none") + } + if !backPressed { + t.Fatal("expected a back-press to dismiss the system dialog, got none") + } + if firstTap >= 0 && firstLaunch > firstTap { + t.Fatalf("expected the foreground gate (launch at %d) before the first tap (at %d)", firstLaunch, firstTap) + } +} + +// TestRunner_WaitsForWindowDrawnBeforeFirstAction verifies the startup gate +// keeps waiting while the app is the resumed activity but its window has not +// drawn yet (a leftover screen still focused). It must poll the focused-window +// signal rather than relaunching, and only proceed once the window names the +// app. +func TestRunner_WaitsForWindowDrawnBeforeFirstAction(t *testing.T) { + state := newHarness(t) + // The app is resumed immediately, but its window lags: the outgoing + // settings screen stays focused for two checks before the app draws. + state.mock.ForegroundResults = []string{"app.folio"} + state.mock.FocusedWindowResults = []string{"com.android.settings", "com.android.settings", "app.folio"} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := Run(ctx, Options{ + Duration: 100 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + BundleID: "app.folio", + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + + // The gate must have polled the focused window until it named the app, + // i.e. at least the three queued results were consumed. + if calls := state.mock.FocusedWindowCalls(); calls < 3 { + t.Fatalf("expected the gate to poll the focused window until drawn (>=3 calls), got %d", calls) + } + // The resumed app was never a foreign app, so the gate must not relaunch + // or back-press to "fix" a window that simply had not drawn yet. + for _, a := range state.mock.Actions() { + if a.Kind == mockdriver.ActionPressKey && a.Key == "back" { + t.Fatal("expected no back-press while waiting for the window to draw") + } + } +} diff --git a/internal/testrun/driver.go b/internal/testrun/driver.go index 935dc4f..33505d8 100644 --- a/internal/testrun/driver.go +++ b/internal/testrun/driver.go @@ -63,6 +63,7 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver _ = sidecarCommand.Process.Kill() return nil, nil, fmt.Errorf("dial sidecar: %w", err) } + driverClient.SetPlatform(options.Platform) // WaitForHealth confirms the gRPC sidecar is up. For iOS, the WDA warmup // (absorbing the XCUITest startup race) runs inside IosDriverBackend.init // in the sidecar - no additional sleep needed here. diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go index 7fb2ac4..d77e948 100644 --- a/internal/testrun/testrun.go +++ b/internal/testrun/testrun.go @@ -50,6 +50,7 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { if specAPIPath != "" { aliases["@sanderling/spec"] = specAPIPath base := filepath.Dir(specAPIPath) + aliases["@sanderling/spec/defaults"] = filepath.Join(base, "defaults/index.ts") aliases["@sanderling/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts") } defines := map[string]string{ @@ -104,7 +105,10 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { if seed == 0 { seed = time.Now().UnixNano() } - verifierInstance, err := verifier.New(verifier.WithRand(rand.New(rand.NewPCG(uint64(seed), 0)))) + verifierInstance, err := verifier.New( + verifier.WithRand(rand.New(rand.NewPCG(uint64(seed), 0))), + verifier.WithAppPackage(options.BundleID), + ) if err != nil { return fmt.Errorf("verifier: %w", err) } diff --git a/internal/trace/writer.go b/internal/trace/writer.go index bb17c5f..da7e28d 100644 --- a/internal/trace/writer.go +++ b/internal/trace/writer.go @@ -13,16 +13,32 @@ import ( ) type Step struct { - Index int `json:"step"` - Timestamp time.Time `json:"timestamp"` - Screen string `json:"screen,omitempty"` - Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"` - Action *Action `json:"action,omitempty"` - Exceptions []Exception `json:"exceptions,omitempty"` - Violations []string `json:"violations,omitempty"` - Hierarchy *hierarchy.Tree `json:"hierarchy,omitempty"` - Residuals map[string]json.RawMessage `json:"residuals,omitempty"` - Metrics *Metrics `json:"metrics,omitempty"` + Index int `json:"step"` + Timestamp time.Time `json:"timestamp"` + Screen string `json:"screen,omitempty"` + Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"` + // NextAction is the action chosen for the next iteration based on observing this step. + NextAction *Action `json:"next_action,omitempty"` + Exceptions []Exception `json:"exceptions,omitempty"` + Violations []string `json:"violations,omitempty"` + Hierarchy *hierarchy.Tree `json:"hierarchy,omitempty"` + Residuals map[string]json.RawMessage `json:"residuals,omitempty"` + Metrics *Metrics `json:"metrics,omitempty"` + ExtractorChanges map[string]ExtractorChange `json:"extractor_changes,omitempty"` + // Transitional marks a step whose hierarchy still showed a NavHost + // cross-fade (multiple route-level *Screen ids) after the runner's + // retry budget. The verifier is skipped for these steps so transient + // state does not poison the previous/current extractor advance. + Transitional bool `json:"transitional,omitempty"` +} + +// ExtractorChange records the prev/curr JSON values of an extractor whose +// observation differed between two consecutive steps. Surfaced under +// violation rows in the inspect UI as a "what changed at this step" +// breadcrumb. +type ExtractorChange struct { + Prev json.RawMessage `json:"prev"` + Curr json.RawMessage `json:"curr"` } type Metrics struct { @@ -125,13 +141,6 @@ func (w *Writer) WriteScreenshot(stepIndex int, png []byte) error { return w.writePNG(fmt.Sprintf("step-%05d.png", stepIndex), png) } -// WriteScreenshotAfter writes the post-action screenshot for a step. -// Callers use this after applyAction + waitForIdle so the UI can show a -// before/after pair. -func (w *Writer) WriteScreenshotAfter(stepIndex int, png []byte) error { - return w.writePNG(fmt.Sprintf("step-%05d-after.png", stepIndex), png) -} - func (w *Writer) writePNG(name string, png []byte) error { if len(png) == 0 { return nil diff --git a/internal/trace/writer_test.go b/internal/trace/writer_test.go index 454656c..24b846d 100644 --- a/internal/trace/writer_test.go +++ b/internal/trace/writer_test.go @@ -102,7 +102,7 @@ func TestWriteStep_HierarchyAndResidualsRoundTrip(t *testing.T) { step := Step{ Index: 1, Timestamp: time.Now().UTC(), - Action: &Action{ + NextAction: &Action{ Kind: "tap", Selector: "id:next", ResolvedBounds: &BoundsRecord{X: 10, Y: 20, Width: 100, Height: 50}, @@ -120,14 +120,14 @@ func TestWriteStep_HierarchyAndResidualsRoundTrip(t *testing.T) { if err := json.Unmarshal(body, &got); err != nil { t.Fatalf("bad jsonl: %v\n%s", err, body) } - if got.Action.Selector != "id:next" { - t.Errorf("selector = %q", got.Action.Selector) + if got.NextAction.Selector != "id:next" { + t.Errorf("selector = %q", got.NextAction.Selector) } - if got.Action.ResolvedBounds == nil || got.Action.ResolvedBounds.Width != 100 { - t.Errorf("resolved_bounds round-trip wrong: %+v", got.Action.ResolvedBounds) + if got.NextAction.ResolvedBounds == nil || got.NextAction.ResolvedBounds.Width != 100 { + t.Errorf("resolved_bounds round-trip wrong: %+v", got.NextAction.ResolvedBounds) } - if got.Action.TapPoint == nil || got.Action.TapPoint.X != 60 { - t.Errorf("tap_point round-trip wrong: %+v", got.Action.TapPoint) + if got.NextAction.TapPoint == nil || got.NextAction.TapPoint.X != 60 { + t.Errorf("tap_point round-trip wrong: %+v", got.NextAction.TapPoint) } if string(got.Residuals["prop1"]) != `{"op":"true"}` { t.Errorf("residuals round-trip wrong: %s", got.Residuals["prop1"]) @@ -162,7 +162,7 @@ func TestWriteStep_AppendsOneJsonLine(t *testing.T) { Snapshots: map[string]json.RawMessage{ "ledger.balance": json.RawMessage(`1500`), }, - Action: &Action{Kind: "tap", X: 100, Y: 200}, + NextAction: &Action{Kind: "tap", X: 100, Y: 200}, Violations: []string{"ledgerBalanceMatchesTxns"}, } if err := writer.WriteStep(step); err != nil { @@ -177,7 +177,7 @@ func TestWriteStep_AppendsOneJsonLine(t *testing.T) { if err := json.Unmarshal([]byte(lines[0]), &got); err != nil { t.Fatalf("invalid JSONL line: %v\n%s", err, lines[0]) } - if got.Index != 1 || got.Screen != "customer_ledger" || got.Action.X != 100 || got.Violations[0] != "ledgerBalanceMatchesTxns" { + if got.Index != 1 || got.Screen != "customer_ledger" || got.NextAction.X != 100 || got.Violations[0] != "ledgerBalanceMatchesTxns" { t.Errorf("step round-trip wrong: %+v", got) } } diff --git a/internal/verifier/bindings.go b/internal/verifier/bindings.go index 6d67b0e..eaae32a 100644 --- a/internal/verifier/bindings.go +++ b/internal/verifier/bindings.go @@ -10,6 +10,12 @@ import ( type extractorState struct { getter goja.Callable handle *goja.Object + name string + // prev/curr cache the JSON-encoded extractor values from the prior and + // current PushSnapshot, used by ChangedExtractors to surface per-step + // diffs in the trace. + prev []byte + curr []byte } type formulaState struct { @@ -60,12 +66,16 @@ const ( tagInternalKind = "__sanderlingKind" tagSelector = "__sanderlingSelector" - internalKindActions = "actions" - internalKindWeighted = "weighted" - internalKindBuiltinTaps = "taps" - internalKindBuiltinSwipes = "swipes" - internalKindBuiltinWaitOnce = "waitOnce" - internalKindBuiltinPressKey = "pressKey" + internalKindActions = "actions" + internalKindWeighted = "weighted" + internalKindBuiltinTaps = "taps" + internalKindBuiltinDoubleTaps = "doubleTaps" + internalKindBuiltinTyping = "typing" + internalKindBuiltinSwipes = "swipes" + internalKindBuiltinWaitOnce = "waitOnce" + internalKindBuiltinPressKey = "pressKey" + internalKindBuiltinLongPresses = "longPresses" + internalKindBuiltinScrolls = "scrolls" ) // installRuntimeBindings exposes globalThis.__sanderling__ to the loaded spec. @@ -99,6 +109,15 @@ func (v *Verifier) installRuntimeBindings() error { if err := sanderling.Set("tap", v.bindTap); err != nil { return err } + if err := sanderling.Set("doubleTap", v.bindDoubleTap); err != nil { + return err + } + if err := sanderling.Set("longPress", v.bindLongPress); err != nil { + return err + } + if err := sanderling.Set("scroll", v.bindScroll); err != nil { + return err + } if err := sanderling.Set("inputText", v.bindInputText); err != nil { return err } @@ -114,6 +133,12 @@ func (v *Verifier) installRuntimeBindings() error { if err := sanderling.Set("taps", v.builtinGenerator(internalKindBuiltinTaps)); err != nil { return err } + if err := sanderling.Set("doubleTaps", v.builtinGenerator(internalKindBuiltinDoubleTaps)); err != nil { + return err + } + if err := sanderling.Set("typing", v.builtinGenerator(internalKindBuiltinTyping)); err != nil { + return err + } if err := sanderling.Set("swipes", v.builtinGenerator(internalKindBuiltinSwipes)); err != nil { return err } @@ -123,24 +148,40 @@ func (v *Verifier) installRuntimeBindings() error { if err := sanderling.Set("pressKeys", v.builtinGenerator(internalKindBuiltinPressKey)); err != nil { return err } + if err := sanderling.Set("longPresses", v.builtinGenerator(internalKindBuiltinLongPresses)); err != nil { + return err + } + if err := sanderling.Set("scrolls", v.builtinGenerator(internalKindBuiltinScrolls)); err != nil { + return err + } return v.runtime.GlobalObject().Set("__sanderling__", sanderling) } func (v *Verifier) bindExtract(call goja.FunctionCall) goja.Value { - if len(call.Arguments) != 1 { - panic(v.runtime.NewTypeError("extract requires exactly one argument")) + if len(call.Arguments) < 1 || len(call.Arguments) > 2 { + panic(v.runtime.NewTypeError("extract requires (getter) or (getter, name)")) } getter, ok := goja.AssertFunction(call.Arguments[0]) if !ok { panic(v.runtime.NewTypeError("extract argument must be a function")) } + name := "" + if len(call.Arguments) == 2 { + arg := call.Arguments[1] + if !goja.IsUndefined(arg) && !goja.IsNull(arg) { + name = arg.String() + } + } + if name == "" { + name = fmt.Sprintf("extractor_%d", len(v.extractors)) + } handle := v.runtime.NewObject() _ = handle.Set("current", goja.Undefined()) _ = handle.Set("previous", goja.Undefined()) - v.extractors = append(v.extractors, &extractorState{getter: getter, handle: handle}) + v.extractors = append(v.extractors, &extractorState{getter: getter, handle: handle, name: name}) return handle } @@ -387,6 +428,40 @@ func (v *Verifier) bindTap(call goja.FunctionCall) goja.Value { return handle } +func (v *Verifier) bindDoubleTap(call goja.FunctionCall) goja.Value { + parameters := call.Argument(0).ToObject(v.runtime) + if parameters == nil { + panic(v.runtime.NewTypeError("DoubleTap requires {on}")) + } + handle := v.runtime.NewObject() + _ = handle.Set("kind", "DoubleTap") + _ = handle.Set("on", parameters.Get("on")) + return handle +} + +func (v *Verifier) bindLongPress(call goja.FunctionCall) goja.Value { + parameters := call.Argument(0).ToObject(v.runtime) + if parameters == nil { + panic(v.runtime.NewTypeError("LongPress requires {on}")) + } + handle := v.runtime.NewObject() + _ = handle.Set("kind", "LongPress") + _ = handle.Set("on", parameters.Get("on")) + return handle +} + +func (v *Verifier) bindScroll(call goja.FunctionCall) goja.Value { + parameters := call.Argument(0).ToObject(v.runtime) + if parameters == nil { + panic(v.runtime.NewTypeError("Scroll requires {direction}")) + } + handle := v.runtime.NewObject() + _ = handle.Set("kind", "Scroll") + _ = handle.Set("direction", parameters.Get("direction")) + _ = handle.Set("in", parameters.Get("in")) + return handle +} + func (v *Verifier) bindInputText(call goja.FunctionCall) goja.Value { parameters := call.Argument(0).ToObject(v.runtime) if parameters == nil { diff --git a/internal/verifier/marshal.go b/internal/verifier/marshal.go index 7055531..defee8f 100644 --- a/internal/verifier/marshal.go +++ b/internal/verifier/marshal.go @@ -3,6 +3,7 @@ package verifier import ( "encoding/json" "fmt" + "strings" "time" "github.com/dop251/goja" @@ -69,7 +70,7 @@ func accessibilityObject(runtime *goja.Runtime, tree *hierarchy.Tree) *goja.Obje if node == nil { return goja.Undefined() } - return nodeObject(runtime, node, selectorStringFromJS(call.Argument(0))) + return nodeObject(runtime, node, selectorStringFromJS(runtime, call.Argument(0))) } findAll := func(call goja.FunctionCall) goja.Value { if tree == nil { @@ -78,7 +79,7 @@ func accessibilityObject(runtime *goja.Runtime, tree *hierarchy.Tree) *goja.Obje nodes := findAllNodesFromJS(runtime, tree, call.Argument(0)) array := runtime.NewArray() for i, n := range nodes { - _ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(call.Argument(0)))) + _ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(runtime, call.Argument(0)))) } return array } @@ -100,6 +101,7 @@ func nodeObject(runtime *goja.Runtime, node *hierarchy.Node, selector string) go _ = object.Set("checked", element.Checked) _ = object.Set("focused", element.Focused) _ = object.Set("selected", element.Selected) + _ = object.Set("editable", element.Editable) _ = object.Set("x", centerX) _ = object.Set("y", centerY) _ = object.Set(tagSelector, selector) @@ -120,14 +122,14 @@ func nodeObject(runtime *goja.Runtime, node *hierarchy.Node, selector string) go if childNode == nil { return goja.Undefined() } - return nodeObject(runtime, childNode, selectorStringFromJS(arg)) + return nodeObject(runtime, childNode, selectorStringFromJS(runtime, arg)) } childFindAll := func(call goja.FunctionCall) goja.Value { arg := call.Argument(0) childNodes := findAllNodesInSubtreeFromJS(runtime, node, arg) array := runtime.NewArray() for i, n := range childNodes { - _ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(arg))) + _ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(runtime, arg))) } return array } @@ -265,15 +267,62 @@ func selectorPathFromJS(runtime *goja.Runtime, arg goja.Value) ([]hierarchy.Sele // selectorStringFromJS returns a string representation of the selector argument // for tagging returned element objects (used by selectorOf to reconstruct the -// selector when the element is passed back as an action target). -func selectorStringFromJS(arg goja.Value) string { - if goja.IsUndefined(arg) || goja.IsNull(arg) { +// selector when the element is passed back as an action target). Output +// follows the canonical hierarchy selector grammar: "k:v" pairs space-joined +// per object, chains joined by " > ". +func selectorStringFromJS(runtime *goja.Runtime, arg goja.Value) string { + if arg == nil || goja.IsUndefined(arg) || goja.IsNull(arg) { return "" } if s, ok := arg.Export().(string); ok { return s } - return arg.String() + exported := arg.Export() + if slice, ok := exported.([]any); ok { + object := arg.ToObject(runtime) + if object == nil { + return "" + } + parts := make([]string, 0, len(slice)) + for index := range slice { + entry := object.Get(fmt.Sprintf("%d", index)) + if entry == nil || goja.IsUndefined(entry) || goja.IsNull(entry) { + continue + } + segment := selectorObjectToString(runtime, entry) + if segment == "" { + continue + } + parts = append(parts, segment) + } + return strings.Join(parts, " > ") + } + return selectorObjectToString(runtime, arg) +} + +// selectorObjectToString formats a single JS object as a space-joined sequence +// of "k:v" pairs, mirroring the hierarchy package's predicate grammar. +func selectorObjectToString(runtime *goja.Runtime, arg goja.Value) string { + if arg == nil || goja.IsUndefined(arg) || goja.IsNull(arg) { + return "" + } + object := arg.ToObject(runtime) + if object == nil { + return "" + } + keys := object.Keys() + parts := make([]string, 0, len(keys)) + for _, key := range keys { + if key == tagSelector { + continue + } + value := object.Get(key) + if value == nil || goja.IsUndefined(value) || goja.IsNull(value) { + continue + } + parts = append(parts, fmt.Sprintf("%s:%s", key, value.String())) + } + return strings.Join(parts, " ") } func lastActionObject(runtime *goja.Runtime, action *Action) goja.Value { @@ -301,6 +350,16 @@ func lastActionObject(runtime *goja.Runtime, action *Action) goja.Value { if action.DurationMillis > 0 { _ = object.Set("durationMillis", action.DurationMillis) } + case ActionKindScroll: + _ = object.Set("direction", action.Direction) + from := runtime.NewObject() + _ = from.Set("x", action.FromX) + _ = from.Set("y", action.FromY) + to := runtime.NewObject() + _ = to.Set("x", action.ToX) + _ = to.Set("y", action.ToY) + _ = object.Set("from", from) + _ = object.Set("to", to) case ActionKindPressKey: _ = object.Set("key", action.Key) case ActionKindWait: @@ -371,6 +430,10 @@ func jsValueToAction(runtime *goja.Runtime, value goja.Value) (Action, error) { on := object.Get("on") x, y := coordinatesOf(runtime, on) return Action{Kind: ActionKindTap, On: selectorOf(runtime, on), X: x, Y: y}, nil + case "DoubleTap": + on := object.Get("on") + x, y := coordinatesOf(runtime, on) + return Action{Kind: ActionKindDoubleTap, On: selectorOf(runtime, on), X: x, Y: y}, nil case "InputText": into := object.Get("into") text := object.Get("text") @@ -395,6 +458,20 @@ func jsValueToAction(runtime *goja.Runtime, value goja.Value) (Action, error) { ToY: toY, DurationMillis: intField(object, "durationMillis"), }, nil + case "LongPress": + on := object.Get("on") + x, y := coordinatesOf(runtime, on) + return Action{Kind: ActionKindLongPress, On: selectorOf(runtime, on), X: x, Y: y}, nil + case "Scroll": + in := object.Get("in") + x, y := coordinatesOf(runtime, in) + return Action{ + Kind: ActionKindScroll, + Direction: stringOf(object.Get("direction")), + On: selectorOf(runtime, in), + X: x, + Y: y, + }, nil case "PressKey": return Action{Kind: ActionKindPressKey, Key: stringOf(object.Get("key"))}, nil case "Wait": diff --git a/internal/verifier/scope_test.go b/internal/verifier/scope_test.go new file mode 100644 index 0000000..265d5c9 --- /dev/null +++ b/internal/verifier/scope_test.go @@ -0,0 +1,232 @@ +package verifier + +import ( + "errors" + "testing" + + "github.com/priyanshujain/sanderling/internal/hierarchy" +) + +// A clickable app button and a clickable soft-keyboard key sharing the screen. +// The keyboard key sits where a random tap would otherwise insert a glyph. +const scopedTreeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,100,500]", "package": "com.folio"}, + "children": [ + {"attributes": {"testTag": "SubmitButton", "bounds": "[0,40,100,80]", "package": "com.folio"}, "clickable": true, "editable": false, "enabled": true, "children": []}, + {"attributes": {"testTag": "Emoticon", "bounds": "[0,400,100,440]", "package": "com.google.android.inputmethod.latin"}, "clickable": true, "enabled": true, "children": []} + ] +}` + +func pushTree(t *testing.T, v *Verifier, treeJSON string) { + t.Helper() + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatal(err) + } + if err := v.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil { + t.Fatal(err) + } +} + +// TestTaps_ExcludeOffAppPackage proves the tap generator never targets the soft +// keyboard: with the app package set, only the in-app button is a candidate, so +// the result lands on its center regardless of seed. +func TestTaps_ExcludeOffAppPackage(t *testing.T) { + verifier := newVerifier(t, WithAppPackage("com.folio")) + mustLoad(t, verifier, `globalThis.actions = __sanderling__.taps;`) + pushTree(t, verifier, scopedTreeJSON) + + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.X != 50 || action.Y != 60 { + t.Errorf("coords = (%d,%d), want (50,60) at SubmitButton; keyboard key leaked into targets", action.X, action.Y) + } +} + +// TestTyping_ExcludeOffAppPackage proves keyboard glyph buttons that report as +// editable never become typing targets once the app package is set. +func TestTyping_ExcludeOffAppPackage(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,100,500]", "package": "com.folio"}, + "children": [ + {"attributes": {"testTag": "NameField", "bounds": "[0,0,100,40]", "package": "com.folio"}, "editable": true, "enabled": true, "children": []}, + {"attributes": {"testTag": "SearchBox", "bounds": "[0,400,100,440]", "package": "com.google.android.inputmethod.latin"}, "editable": true, "enabled": true, "children": []} + ] + }` + verifier := newVerifier(t, WithAppPackage("com.folio")) + mustLoad(t, verifier, `globalThis.actions = __sanderling__.typing;`) + pushTree(t, verifier, treeJSON) + + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.X != 50 || action.Y != 20 { + t.Errorf("coords = (%d,%d), want (50,20) at NameField; off-app field leaked into targets", action.X, action.Y) + } +} + +// TestSwipes_ExcludeOffAppPackage proves swipes anchor on app nodes only, so +// exploration never scrolls the keyboard's emoji list instead of the app. +func TestSwipes_ExcludeOffAppPackage(t *testing.T) { + verifier := newVerifier(t, WithAppPackage("com.folio")) + mustLoad(t, verifier, `globalThis.actions = __sanderling__.swipes;`) + pushTree(t, verifier, scopedTreeJSON) + + // Both the root and SubmitButton (com.folio) are valid anchors; only the + // keyboard key at center (50,420) must be excluded. Draw many times so the + // invariant is not satisfied by a lucky seed. + for i := range 200 { + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.Kind != ActionKindSwipe { + t.Fatalf("kind = %v, want Swipe", action.Kind) + } + if action.FromX == 50 && action.FromY == 420 { + t.Fatalf("draw %d anchored on the keyboard key (50,420); off-app node leaked into swipe targets", i) + } + } +} + +// TestTaps_AllOffAppYieldsErrNoAction proves the generator declines when every +// clickable node belongs to another package, so a weighted layer falls through +// instead of fuzzing the keyboard. +func TestTaps_AllOffAppYieldsErrNoAction(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,100,500]", "package": "com.folio"}, + "children": [ + {"attributes": {"testTag": "Emoticon", "bounds": "[0,400,100,440]", "package": "com.google.android.inputmethod.latin"}, "clickable": true, "enabled": true, "children": []} + ] + }` + verifier := newVerifier(t, WithAppPackage("com.folio")) + mustLoad(t, verifier, `globalThis.actions = __sanderling__.taps;`) + pushTree(t, verifier, treeJSON) + + if _, err := verifier.NextAction(); !errors.Is(err, ErrNoAction) { + t.Fatalf("err = %v, want ErrNoAction", err) + } +} + +// TestTaps_UnsetAppPackageKeepsAllNodes proves the filter is opt-in: with no app +// package configured, an off-app node is still a valid target (prior behavior). +func TestTaps_UnsetAppPackageKeepsAllNodes(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,100,500]", "package": "com.folio"}, + "children": [ + {"attributes": {"testTag": "Emoticon", "bounds": "[0,400,100,440]", "package": "com.google.android.inputmethod.latin"}, "clickable": true, "enabled": true, "children": []} + ] + }` + verifier := newVerifier(t) + mustLoad(t, verifier, `globalThis.actions = __sanderling__.taps;`) + pushTree(t, verifier, treeJSON) + + action, err := verifier.NextAction() + if err != nil { + t.Fatalf("err = %v, want a tap on the only node when unscoped", err) + } + if action.X != 50 || action.Y != 420 { + t.Errorf("coords = (%d,%d), want (50,420); unscoped run should target any node", action.X, action.Y) + } +} + +// TestLongPresses_TargetsClickableElement proves the longPresses generator +// mirrors taps: it yields a LongPress on the only clickable in-app node. +func TestLongPresses_TargetsClickableElement(t *testing.T) { + verifier := newVerifier(t, WithAppPackage("com.folio")) + mustLoad(t, verifier, `globalThis.actions = __sanderling__.longPresses;`) + pushTree(t, verifier, scopedTreeJSON) + + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.Kind != ActionKindLongPress { + t.Fatalf("kind = %v, want LongPress", action.Kind) + } + if action.X != 50 || action.Y != 60 { + t.Errorf("coords = (%d,%d), want (50,60) at SubmitButton", action.X, action.Y) + } +} + +// TestScrolls_TargetsScrollableContainer proves the scrolls generator anchors on +// a scrollable container and pre-computes swipe endpoints inside its bounds. +func TestScrolls_TargetsScrollableContainer(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,100,500]", "package": "com.folio"}, + "children": [ + {"attributes": {"testTag": "Feed", "bounds": "[0,0,100,400]", "scrollable": "true", "package": "com.folio"}, "enabled": true, "children": []}, + {"attributes": {"testTag": "Header", "bounds": "[0,400,100,440]", "package": "com.folio"}, "clickable": true, "enabled": true, "children": []} + ] + }` + verifier := newVerifier(t, WithAppPackage("com.folio")) + mustLoad(t, verifier, `globalThis.actions = __sanderling__.scrolls;`) + pushTree(t, verifier, treeJSON) + + for range 50 { + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.Kind != ActionKindScroll { + t.Fatalf("kind = %v, want Scroll", action.Kind) + } + // Feed center is (50,200); both endpoints must stay anchored there. + if action.FromX != 50 || action.FromY != 200 { + t.Fatalf("from = (%d,%d), want (50,200) at Feed center", action.FromX, action.FromY) + } + switch action.Direction { + case "up", "down": + if action.ToX != 50 { + t.Fatalf("vertical scroll moved x: to = (%d,%d)", action.ToX, action.ToY) + } + case "left", "right": + if action.ToY != 200 { + t.Fatalf("horizontal scroll moved y: to = (%d,%d)", action.ToX, action.ToY) + } + default: + t.Fatalf("unexpected direction %q", action.Direction) + } + if action.DurationMillis != 300 { + t.Fatalf("durationMillis = %d, want 300", action.DurationMillis) + } + } +} + +// TestScrolls_NoScrollableYieldsErrNoAction proves the generator declines when +// no scrollable container is present. +func TestScrolls_NoScrollableYieldsErrNoAction(t *testing.T) { + verifier := newVerifier(t, WithAppPackage("com.folio")) + mustLoad(t, verifier, `globalThis.actions = __sanderling__.scrolls;`) + pushTree(t, verifier, scopedTreeJSON) + + if _, err := verifier.NextAction(); !errors.Is(err, ErrNoAction) { + t.Fatalf("err = %v, want ErrNoAction", err) + } +} + +// TestTaps_EmptyPackageNodeStaysInScope proves nodes that omit the package +// attribute (e.g. iOS, decor views) are kept, so the filter never empties a +// legitimate app screen. +func TestTaps_EmptyPackageNodeStaysInScope(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,100,500]"}, + "children": [ + {"attributes": {"testTag": "SubmitButton", "bounds": "[0,40,100,80]"}, "clickable": true, "enabled": true, "children": []} + ] + }` + verifier := newVerifier(t, WithAppPackage("com.folio")) + mustLoad(t, verifier, `globalThis.actions = __sanderling__.taps;`) + pushTree(t, verifier, treeJSON) + + action, err := verifier.NextAction() + if err != nil { + t.Fatalf("err = %v, want the empty-package node kept in scope", err) + } + if action.X != 50 || action.Y != 60 { + t.Errorf("coords = (%d,%d), want (50,60)", action.X, action.Y) + } +} diff --git a/internal/verifier/types.go b/internal/verifier/types.go index a55bd68..a69aaa6 100644 --- a/internal/verifier/types.go +++ b/internal/verifier/types.go @@ -5,10 +5,13 @@ type ActionKind string const ( ActionKindTap ActionKind = "Tap" + ActionKindDoubleTap ActionKind = "DoubleTap" ActionKindInputText ActionKind = "InputText" ActionKindSwipe ActionKind = "Swipe" ActionKindPressKey ActionKind = "PressKey" ActionKindWait ActionKind = "Wait" + ActionKindLongPress ActionKind = "LongPress" + ActionKindScroll ActionKind = "Scroll" ) // Action is a single UI interaction produced by the spec's action generator. @@ -27,6 +30,9 @@ type Action struct { DurationMillis int // Key is the logical key name for ActionKindPressKey. Key string + // Direction is the scroll direction for ActionKindScroll: one of "up", + // "down", "left", "right". Empty for every other kind. + Direction string } // LogEntry mirrors a logcat line captured between steps. @@ -44,3 +50,11 @@ type Exception struct { StackTrace string UnixMillis int64 } + +// ExtractorChange records a single extractor's value transition across one +// step. Used to surface "what changed at this step" breadcrumbs at violation +// markers in the inspect UI. +type ExtractorChange struct { + Prev []byte + Curr []byte +} diff --git a/internal/verifier/verifier_test.go b/internal/verifier/verifier_test.go index 449d59a..fa2b0a4 100644 --- a/internal/verifier/verifier_test.go +++ b/internal/verifier/verifier_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "math/rand/v2" + "slices" "strings" "testing" @@ -118,6 +119,117 @@ func TestEvaluateProperties_HoldsThenViolates(t *testing.T) { } } +func TestNewlyViolatedProperties_OnsetOnly(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, helloSpec) + + // Balance trajectory: holds, holds, violates, stays violated, stays violated. + // Onset must appear only on step 3 even though the residual stays false + // through steps 4 and 5 (LTL `always` sticky semantics). + balances := []int{1500, 1500, -1, 500, 500} + for index, balance := range balances { + raw, _ := json.Marshal(balance) + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": raw}}); err != nil { + t.Fatal(err) + } + _ = verifier.EvaluateProperties() + got := verifier.NewlyViolatedProperties() + step := index + 1 + if step == 3 { + want := []string{"balanceNonNegative"} + if !slices.Equal(got, want) { + t.Errorf("step %d (onset): got %v, want %v", step, got, want) + } + } else if len(got) != 0 { + t.Errorf("step %d: expected empty onset set, got %v", step, got) + } + } +} + +func TestNewlyViolatedProperties_FirstStepViolation(t *testing.T) { + const spec = ` +globalThis.properties = { + alwaysFalse: __sanderling__.always(() => false), +}; +` + verifier := newVerifier(t) + mustLoad(t, verifier, spec) + + for step := 1; step <= 3; step++ { + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}); err != nil { + t.Fatal(err) + } + _ = verifier.EvaluateProperties() + got := verifier.NewlyViolatedProperties() + if step == 1 { + want := []string{"alwaysFalse"} + if !slices.Equal(got, want) { + t.Errorf("step 1 (onset): got %v, want %v", got, want) + } + } else if len(got) != 0 { + t.Errorf("step %d: expected empty onset set after first-step violation, got %v", step, got) + } + } +} + +func TestNewlyViolatedProperties_MultipleProperties(t *testing.T) { + const spec = ` +const a = __sanderling__.extract(state => state.snapshots["a"] ?? 0); +const b = __sanderling__.extract(state => state.snapshots["b"] ?? 0); +globalThis.properties = { + propA: __sanderling__.always(() => a.current >= 0), + propB: __sanderling__.always(() => b.current >= 0), +}; +` + verifier := newVerifier(t) + mustLoad(t, verifier, spec) + + // propA violates at step 2, propB violates at step 4. Each must surface + // only on its own onset step. + aValues := []int{1, -1, -1, -1} + bValues := []int{1, 1, 1, -1} + expectOnset := map[int][]string{ + 2: {"propA"}, + 4: {"propB"}, + } + for index := range aValues { + aRaw, _ := json.Marshal(aValues[index]) + bRaw, _ := json.Marshal(bValues[index]) + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"a": aRaw, "b": bRaw}}); err != nil { + t.Fatal(err) + } + _ = verifier.EvaluateProperties() + got := verifier.NewlyViolatedProperties() + step := index + 1 + want := expectOnset[step] + if !slices.Equal(got, want) { + t.Errorf("step %d: got %v, want %v", step, got, want) + } + } +} + +func TestNewlyViolatedProperties_DeterministicOrder(t *testing.T) { + const spec = ` +globalThis.properties = { + zebra: __sanderling__.always(() => false), + apple: __sanderling__.always(() => false), + mango: __sanderling__.always(() => false), +}; +` + verifier := newVerifier(t) + mustLoad(t, verifier, spec) + + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}); err != nil { + t.Fatal(err) + } + _ = verifier.EvaluateProperties() + got := verifier.NewlyViolatedProperties() + want := []string{"apple", "mango", "zebra"} + if !slices.Equal(got, want) { + t.Errorf("onset order: got %v, want %v (sorted lexicographically)", got, want) + } +} + func TestNextAction_FromActionsGenerator(t *testing.T) { verifier := newVerifier(t) mustLoad(t, verifier, helloSpec) @@ -281,6 +393,75 @@ func TestNextAction_NoSetupRegistered(t *testing.T) { } } +// TestDoubleTapsBuiltin_TargetsClickable verifies the doubleTaps builtin emits +// a DoubleTap action targeting a clickable, enabled element's center. +func TestDoubleTapsBuiltin_TargetsClickable(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"}, + "children": [ + {"attributes": {"testTag": "SubmitButton", "bounds": "[0,40,100,80]"}, "clickable": true, "enabled": true, "children": []} + ] + }` + verifier := newVerifier(t) + mustLoad(t, verifier, `globalThis.actions = __sanderling__.doubleTaps;`) + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatal(err) + } + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil { + t.Fatal(err) + } + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.Kind != ActionKindDoubleTap { + t.Fatalf("kind = %v, want DoubleTap", action.Kind) + } + if action.X != 50 || action.Y != 60 { + t.Errorf("coords = (%d,%d), want (50,60) at SubmitButton center", action.X, action.Y) + } + // Action-gated properties read lastAction.on to tell which target the + // chooser hit. An empty On reduces those properties to vacuously-true and + // they never fire on the real tap event. + if action.On == "" { + t.Fatal("On must be populated so action-gated properties can identify the target") + } + if !strings.Contains(action.On, "SubmitButton") { + t.Errorf("On = %q, want a selector containing SubmitButton", action.On) + } + resolved := tree.Find(action.On) + if resolved == nil { + t.Fatalf("On = %q does not resolve in the same tree", action.On) + } + rx, ry := resolved.Bounds.Center() + if rx != action.X || ry != action.Y { + t.Errorf("On %q resolves to (%d,%d), want the picked element's center (%d,%d)", + action.On, rx, ry, action.X, action.Y) + } +} + +func TestDoubleTap_RoundTrip(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.actions = __sanderling__.actions(() => [ + __sanderling__.doubleTap({ on: "id:save" }), + ]); + `) + _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}) + + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.Kind != ActionKindDoubleTap { + t.Errorf("kind: got %v, want DoubleTap", action.Kind) + } + if action.On != "id:save" { + t.Errorf("selector: got %q, want id:save", action.On) + } +} + func TestInputText_RoundTrip(t *testing.T) { verifier := newVerifier(t) mustLoad(t, verifier, ` @@ -302,6 +483,65 @@ func TestInputText_RoundTrip(t *testing.T) { } } +// TestTypingBuiltin_TargetsEditableField verifies the typing generator emits an +// InputText action aimed at an editable, enabled element's center and fills it +// with a corpus value, ignoring clickable-but-not-editable elements. +func TestTypingBuiltin_TargetsEditableField(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"}, + "children": [ + {"attributes": {"testTag": "EmailField", "bounds": "[0,0,100,40]"}, "editable": true, "enabled": true, "children": []}, + {"attributes": {"testTag": "SubmitButton", "bounds": "[0,40,100,80]"}, "clickable": true, "enabled": true, "children": []} + ] + }` + verifier := newVerifier(t) + mustLoad(t, verifier, `globalThis.actions = __sanderling__.typing;`) + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatal(err) + } + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil { + t.Fatal(err) + } + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.Kind != ActionKindInputText { + t.Fatalf("kind = %v, want InputText", action.Kind) + } + if action.X != 50 || action.Y != 20 { + t.Errorf("coords = (%d,%d), want (50,20) at EmailField center", action.X, action.Y) + } + if !slices.Contains(inputCorpus, action.Text) { + t.Errorf("text %q not drawn from inputCorpus", action.Text) + } +} + +// TestTypingBuiltin_NoEditableYieldsErrNoAction verifies the typing generator +// declines (ErrNoAction) when no editable element is present, so a weighted +// layer falls through to another generator. +func TestTypingBuiltin_NoEditableYieldsErrNoAction(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"}, + "children": [ + {"attributes": {"testTag": "SubmitButton", "bounds": "[0,0,100,40]"}, "clickable": true, "enabled": true, "children": []} + ] + }` + verifier := newVerifier(t) + mustLoad(t, verifier, `globalThis.actions = __sanderling__.typing;`) + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatal(err) + } + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil { + t.Fatal(err) + } + if _, err := verifier.NextAction(); !errors.Is(err, ErrNoAction) { + t.Fatalf("err = %v, want ErrNoAction", err) + } +} + func TestPushSnapshot_FeedsSnapshotsToExtractorState(t *testing.T) { verifier := newVerifier(t) mustLoad(t, verifier, ` @@ -609,6 +849,150 @@ globalThis.properties = { globalThis.actions = __sanderling__.actions(() => []); ` +// TestSelectorStringFromJS_CanonicalGrammar guarantees the selector tag stamped +// on returned AX nodes round-trips back to a parseable selector string when the +// node is later used as an action target. Without this, an action emitted from +// `tap({ on: state.ax.find({ testTag: "LoginEmail" }) })` ends up with +// `action.selector = "[object Object]"` in the trace. +func TestSelectorStringFromJS_CanonicalGrammar(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"}, + "children": [ + {"attributes": {"testTag": "LoginScreen", "bounds": "[0,0,100,40]"}, + "children": [ + {"attributes": {"testTag": "LoginEmail", "bounds": "[0,0,100,20]"}, "editable": true, "enabled": true, "children": []} + ]} + ] + }` + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.objectSelector = __sanderling__.extract(state => + state.ax.find({ testTag: "LoginScreen" }) + ); + globalThis.chainSelector = __sanderling__.extract(state => + state.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginEmail" }]) + ); + globalThis.stringSelector = __sanderling__.extract(state => + state.ax.find("testTag:LoginScreen") + ); + `) + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatal(err) + } + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil { + t.Fatal(err) + } + + read := func(name string) string { + handle := verifier.runtime.GlobalObject().Get(name).ToObject(verifier.runtime) + current := handle.Get("current") + if goja.IsUndefined(current) || goja.IsNull(current) { + return "" + } + object := current.ToObject(verifier.runtime) + return object.Get(tagSelector).String() + } + cases := []struct { + name string + want string + }{ + {"objectSelector", "testTag:LoginScreen"}, + {"chainSelector", "testTag:LoginScreen > testTag:LoginEmail"}, + {"stringSelector", "testTag:LoginScreen"}, + } + for _, testCase := range cases { + got := read(testCase.name) + if got != testCase.want { + t.Errorf("%s: got %q, want %q", testCase.name, got, testCase.want) + } + if strings.Contains(got, "[object") { + t.Errorf("%s: selector contains garbage %q", testCase.name, got) + } + } +} + +// TestChangedExtractors_DiffsBetweenSnapshots verifies the per-step diff +// surfaces only extractors whose value actually changed, keyed by name (or +// extractor_N fallback when unnamed). +func TestChangedExtractors_DiffsBetweenSnapshots(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + __sanderling__.extract(state => state.snapshots["a"] ?? 0, "alpha"); + __sanderling__.extract(state => state.snapshots["b"] ?? 0); + `) + + push := func(a, b int) { + raw := func(n int) json.RawMessage { + body, _ := json.Marshal(n) + return body + } + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"a": raw(a), "b": raw(b)}}); err != nil { + t.Fatal(err) + } + } + + push(1, 1) + first := verifier.ChangedExtractors() + if _, ok := first["alpha"]; !ok { + t.Errorf("step 1: expected alpha in diff (initial value), got %+v", first) + } + if _, ok := first["extractor_1"]; !ok { + t.Errorf("step 1: expected extractor_1 fallback name in diff, got %+v", first) + } + + push(1, 2) + second := verifier.ChangedExtractors() + if _, ok := second["alpha"]; ok { + t.Errorf("step 2: alpha did not change, should not appear: %+v", second) + } + change, ok := second["extractor_1"] + if !ok { + t.Fatalf("step 2: expected extractor_1 in diff, got %+v", second) + } + if string(change.Prev) != "1" || string(change.Curr) != "2" { + t.Errorf("step 2: extractor_1 diff prev=%s curr=%s, want 1 -> 2", change.Prev, change.Curr) + } + + push(1, 2) + third := verifier.ChangedExtractors() + if len(third) != 0 { + t.Errorf("step 3: nothing changed, diff should be empty, got %+v", third) + } +} + +// TestExtract_DefaultsAndNamedNames verifies bindExtract assigns a fallback +// `extractor_N` name when no name is supplied and respects an explicit one. +func TestExtract_DefaultsAndNamedNames(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + __sanderling__.extract(state => 1); + __sanderling__.extract(state => 2, "ledgerRows"); + __sanderling__.extract(state => 3); + `) + if len(verifier.extractors) != 3 { + t.Fatalf("extractors registered: got %d, want 3", len(verifier.extractors)) + } + want := []string{"extractor_0", "ledgerRows", "extractor_2"} + for i, name := range want { + if got := verifier.extractors[i].name; got != name { + t.Errorf("extractor %d name: got %q, want %q", i, got, name) + } + } +} + +// TestSelectorStringFromJS_NullEmpty verifies that nil/undefined args produce +// an empty string instead of "null"/"undefined" garbage. +func TestSelectorStringFromJS_NullEmpty(t *testing.T) { + verifier := newVerifier(t) + if got := selectorStringFromJS(verifier.runtime, goja.Undefined()); got != "" { + t.Errorf("undefined: got %q, want empty", got) + } + if got := selectorStringFromJS(verifier.runtime, goja.Null()); got != "" { + t.Errorf("null: got %q, want empty", got) + } +} + func TestOverrideExtractorValues_PropagatesNestedObjectFields(t *testing.T) { verifier := newVerifier(t) mustLoad(t, verifier, objectExtractorSpec) diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index d588339..0d68f55 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -1,10 +1,14 @@ package verifier import ( + "bytes" "encoding/json" "errors" "fmt" + "maps" "math/rand/v2" + "sort" + "strings" "time" "github.com/dop251/goja" @@ -25,6 +29,9 @@ type Verifier struct { evaluators map[string]*ltl.Evaluator + priorVerdicts map[string]ltl.Verdict + newlyViolated []string + lastTree *hierarchy.Tree lastAction *Action lastLogs []LogEntry @@ -32,6 +39,8 @@ type Verifier struct { stepTime time.Time runStart time.Time + appPackage string + rng *rand.Rand } @@ -41,12 +50,21 @@ func WithRand(rng *rand.Rand) Option { return func(v *Verifier) { v.rng = rng } } +// WithAppPackage scopes random-action target selection to the app under test. +// Nodes belonging to another package (the soft keyboard, system UI, permission +// dialogs) are excluded so exploration never spends steps fuzzing the IME or +// inserting keyboard glyphs into fields. Empty package keeps current behavior. +func WithAppPackage(appPackage string) Option { + return func(v *Verifier) { v.appPackage = appPackage } +} + func New(options ...Option) (*Verifier, error) { verifier := &Verifier{ - runtime: goja.New(), - properties: map[string]int{}, - evaluators: map[string]*ltl.Evaluator{}, - rng: rand.New(rand.NewPCG(0, 0)), + runtime: goja.New(), + properties: map[string]int{}, + evaluators: map[string]*ltl.Evaluator{}, + priorVerdicts: map[string]ltl.Verdict{}, + rng: rand.New(rand.NewPCG(0, 0)), } for _, option := range options { option(verifier) @@ -235,10 +253,55 @@ func (v *Verifier) PushSnapshot(input SnapshotInput) error { return fmt.Errorf("extractor %d: %w", index, err) } _ = extractor.handle.Set("current", newValue) + extractor.prev = extractor.curr + extractor.curr = encodeExtractorValue(newValue) } return nil } +// encodeExtractorValue produces a stable JSON encoding of an extractor's +// current value for diff comparison. goja values that don't survive Export +// (e.g. wrapped host functions) yield nil; callers treat nil as "unknown" and +// emit no diff entry. +func encodeExtractorValue(value goja.Value) []byte { + if value == nil || goja.IsUndefined(value) || goja.IsNull(value) { + return []byte("null") + } + exported := value.Export() + body, err := json.Marshal(exported) + if err != nil { + return nil + } + return body +} + +// ChangedExtractors returns the named extractors whose value changed between +// the prior PushSnapshot and the current one. The map is keyed by extractor +// name; unnamed extractors (extractor_N fallback) are included so the inspect +// UI can still display them under a numeric label. The very first snapshot +// emits every non-null extractor as a change (Prev=null, Curr=current) since +// the runner can otherwise misread "no diff yet" as "nothing initialized". +func (v *Verifier) ChangedExtractors() map[string]ExtractorChange { + changes := map[string]ExtractorChange{} + for _, extractor := range v.extractors { + if extractor.curr == nil { + continue + } + prev := extractor.prev + if prev == nil { + prev = []byte("null") + } + if bytes.Equal(prev, extractor.curr) { + continue + } + changes[extractor.name] = ExtractorChange{ + Prev: append([]byte(nil), prev...), + Curr: append([]byte(nil), extractor.curr...), + } + } + return changes +} + // OverrideExtractorValues replaces each extractor's `current` slot with a // caller-supplied value, keyed by registration index. Used by the web tick // path so extractor bodies that ran in V8 (against the real DOM) drive the @@ -287,6 +350,9 @@ type SnapshotInput struct { // after the most recent PushSnapshot. The step time passed in PushSnapshot is // forwarded to each evaluator so deadline-bound operators see the snapshot's // wall clock rather than time.Now(). +// +// As a side effect, the set of properties that newly transitioned to violated +// on this call is recorded; see NewlyViolatedProperties. func (v *Verifier) EvaluateProperties() map[string]ltl.Verdict { verdicts := map[string]ltl.Verdict{} stepTime := v.stepTime @@ -297,9 +363,38 @@ func (v *Verifier) EvaluateProperties() map[string]ltl.Verdict { verdicts[name] = evaluator.ObserveAt(stepTime) } v.refreshPredicateErrors() + + var onset []string + for name, verdict := range verdicts { + if verdict == ltl.VerdictViolated && v.priorVerdicts[name] != ltl.VerdictViolated { + onset = append(onset, name) + } + } + sort.Strings(onset) + v.newlyViolated = onset + + next := make(map[string]ltl.Verdict, len(verdicts)) + maps.Copy(next, verdicts) + v.priorVerdicts = next + return verdicts } +// NewlyViolatedProperties returns the names of properties whose verdict +// transitioned from non-Violated to Violated on the most recent +// EvaluateProperties call, sorted lexicographically. Returns nil if no +// transition occurred or EvaluateProperties has not been called. +// +// This is the onset set: each property name appears at most once across a +// run's traces, at the step where the violation first fired. Subsequent +// steps where the property remains violated (LTL `always` sticky semantics) +// will not list it. Use this for trace emission and summary reporting so the +// onset is the only step that surfaces the violation event; use +// EvaluateProperties for residual / current-verdict needs. +func (v *Verifier) NewlyViolatedProperties() []string { + return append([]string(nil), v.newlyViolated...) +} + // Residuals returns the residual formula for each registered property after // the most recent EvaluateProperties call. Properties that errored during // predicate evaluation surface as ErrorFormula so the inspect UI can render @@ -451,12 +546,20 @@ func (v *Verifier) resolveGenerator(generator goja.Value) (Action, error) { return v.resolveGenerator(picked) case internalKindBuiltinTaps: return v.generateRandomTap() + case internalKindBuiltinDoubleTaps: + return v.generateRandomDoubleTap() + case internalKindBuiltinTyping: + return v.generateRandomInput() case internalKindBuiltinSwipes: return v.generateRandomSwipe() case internalKindBuiltinWaitOnce: return Action{Kind: ActionKindWait, DurationMillis: 500}, nil case internalKindBuiltinPressKey: return v.generateRandomPressKey() + case internalKindBuiltinLongPresses: + return v.generateRandomLongPress() + case internalKindBuiltinScrolls: + return v.generateRandomScroll() default: return Action{}, fmt.Errorf("unknown generator kind %q", kindValue.String()) } @@ -465,6 +568,29 @@ func (v *Verifier) resolveGenerator(generator goja.Value) (Action, error) { // generateRandomTap picks a visible, tappable element from the last // hierarchy snapshot and returns a Tap action targeting its center. func (v *Verifier) generateRandomTap() (Action, error) { + return v.generateRandomTapKind(ActionKindTap) +} + +// generateRandomDoubleTap is the DoubleTap counterpart of generateRandomTap. +// Real user gestures include double-tap (image zoom, like-to-favorite, +// play/pause); a fuzzer that never emits one cannot exercise either those +// features or the sub-100ms race windows that single-step Tap cadence misses. +func (v *Verifier) generateRandomDoubleTap() (Action, error) { + return v.generateRandomTapKind(ActionKindDoubleTap) +} + +// inScope reports whether an element belongs to the app under test. Nodes from +// another package (the soft keyboard, system UI, permission dialogs) are out of +// scope. An unset app package or an element with no package falls through to in +// scope, preserving behavior on platforms that omit the attribute (e.g. iOS). +func (v *Verifier) inScope(element *hierarchy.Element) bool { + if v.appPackage == "" || element.Package == "" { + return true + } + return element.Package == v.appPackage +} + +func (v *Verifier) generateRandomTapKind(kind ActionKind) (Action, error) { if v.lastTree == nil { return Action{}, ErrNoAction } @@ -473,6 +599,9 @@ func (v *Verifier) generateRandomTap() (Action, error) { if !element.Clickable || !element.Enabled { continue } + if !v.inScope(element) { + continue + } if element.Bounds.Right-element.Bounds.Left <= 0 || element.Bounds.Bottom-element.Bounds.Top <= 0 { continue } @@ -483,7 +612,99 @@ func (v *Verifier) generateRandomTap() (Action, error) { } picked := candidates[v.rng.IntN(len(candidates))] x, y := picked.Bounds.Center() - return Action{Kind: ActionKindTap, X: x, Y: y}, nil + return Action{Kind: kind, On: selectorForElement(v.lastTree, picked), X: x, Y: y}, nil +} + +// selectorForElement builds a canonical "key:value" selector that resolves +// back to the given element via hierarchy.Tree.Find. Prefers resource-id (the +// testTag carrier on Android / accessibilityIdentifier on iOS), falling back +// to text and content-description so action-gated properties can still tell +// what was tapped even on legacy nodes without a testTag. Returns "" when no +// candidate selector uniquely resolves to the picked element so the runner +// keeps using the action's coordinates without re-routing to a sibling that +// shares the same id/text. +func selectorForElement(tree *hierarchy.Tree, element *hierarchy.Element) string { + if element == nil || tree == nil { + return "" + } + candidates := make([]string, 0, 4) + if element.ResourceID != "" { + candidates = append(candidates, "id:"+element.ResourceID) + } + // Some platforms surface the Compose testTag only in the attributes map + // (the sidecar doesn't always promote it to resource-id). Try the raw + // attribute keys before falling back to text-based selectors so an + // element with a unique testTag still gets identified. + for _, key := range []string{"testTag", "identifier", "accessibilityIdentifier"} { + if value := element.Attributes[key]; value != "" { + candidates = append(candidates, key+":"+value) + } + } + if element.Text != "" { + candidates = append(candidates, "text:"+element.Text) + } + if element.Description != "" { + candidates = append(candidates, "desc:"+element.Description) + } + for _, selector := range candidates { + resolved := tree.Find(selector) + if resolved == nil || resolved != element { + continue + } + return selector + } + return "" +} + +// inputCorpus is the edge-case string pool the typing builtin draws from to +// stress field parsing: empty, whitespace, overflow length, unicode, numeric +// boundaries, and common injection payloads. +var inputCorpus = []string{ + "", + "a", + strings.Repeat("a", 4096), + "🙂🔥💸", + " ", + "\t\n", + "-1", + "999999999999999999999", + "0.0000001", + "1e10", + "'; DROP TABLE--", + "", + "../../etc/passwd", + "%s%n", + "NaN", +} + +// generateRandomInput picks a visible, editable, enabled element from the last +// hierarchy snapshot and types a random edge-case value into it. The runner +// taps the target coordinates to focus before typing, so this works on both +// native and web with no driver-side dispatch change. +func (v *Verifier) generateRandomInput() (Action, error) { + if v.lastTree == nil { + return Action{}, ErrNoAction + } + candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements)) + for _, element := range v.lastTree.Elements { + if !element.Editable || !element.Enabled { + continue + } + if !v.inScope(element) { + continue + } + if element.Bounds.Right-element.Bounds.Left <= 0 || element.Bounds.Bottom-element.Bounds.Top <= 0 { + continue + } + candidates = append(candidates, element) + } + if len(candidates) == 0 { + return Action{}, ErrNoAction + } + picked := candidates[v.rng.IntN(len(candidates))] + x, y := picked.Bounds.Center() + value := inputCorpus[v.rng.IntN(len(inputCorpus))] + return Action{Kind: ActionKindInputText, X: x, Y: y, Text: value}, nil } // generateRandomSwipe emits a swipe over a random enabled element or the @@ -493,7 +714,16 @@ func (v *Verifier) generateRandomSwipe() (Action, error) { if v.lastTree == nil || len(v.lastTree.Elements) == 0 { return Action{}, ErrNoAction } - element := v.lastTree.Elements[v.rng.IntN(len(v.lastTree.Elements))] + candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements)) + for _, element := range v.lastTree.Elements { + if v.inScope(element) { + candidates = append(candidates, element) + } + } + if len(candidates) == 0 { + return Action{}, ErrNoAction + } + element := candidates[v.rng.IntN(len(candidates))] cx, cy := element.Bounds.Center() if cx <= 0 || cy <= 0 { return Action{}, ErrNoAction @@ -527,6 +757,73 @@ func (v *Verifier) generateRandomSwipe() (Action, error) { }, nil } +// generateRandomLongPress mirrors generateRandomTap: it picks a visible, +// clickable, enabled, in-scope element and targets its center. Real users +// long-press (context menus, reorder handles, multi-select), so a fuzzer that +// never emits one cannot reach those affordances. +func (v *Verifier) generateRandomLongPress() (Action, error) { + return v.generateRandomTapKind(ActionKindLongPress) +} + +// generateRandomScroll picks a scrollable, in-scope container and emits a swipe +// across it in a random direction. +func (v *Verifier) generateRandomScroll() (Action, error) { + if v.lastTree == nil { + return Action{}, ErrNoAction + } + candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements)) + for _, element := range v.lastTree.Elements { + if element.Attributes["scrollable"] != "true" { + continue + } + if !v.inScope(element) { + continue + } + if element.Bounds.Width() <= 0 || element.Bounds.Height() <= 0 { + continue + } + candidates = append(candidates, element) + } + if len(candidates) == 0 { + return Action{}, ErrNoAction + } + picked := candidates[v.rng.IntN(len(candidates))] + cx, cy := picked.Bounds.Center() + width := picked.Bounds.Width() + height := picked.Bounds.Height() + directions := []string{"up", "down", "left", "right"} + dir := directions[v.rng.IntN(len(directions))] + toX, toY := cx, cy + // Scroll direction names the content motion; the gesture swipes the + // opposite way. Revealing content below ("down") means dragging the + // finger up, so toY decreases, and likewise for the other directions. + switch dir { + case "down": + toY = cy - (4*height)/10 + case "up": + toY = cy + (4*height)/10 + case "left": + toX = cx + (4*width)/10 + case "right": + toX = cx - (4*width)/10 + } + if toX < 0 { + toX = 0 + } + if toY < 0 { + toY = 0 + } + return Action{ + Kind: ActionKindScroll, + Direction: dir, + FromX: cx, + FromY: cy, + ToX: toX, + ToY: toY, + DurationMillis: 300, + }, nil +} + func (v *Verifier) generateRandomPressKey() (Action, error) { // Keep exploration gentle: only "back" for now. Home/menu would navigate // away from the app under test. diff --git a/pkg/spec/package.json b/pkg/spec/package.json index 4af4aca..37cc58f 100644 --- a/pkg/spec/package.json +++ b/pkg/spec/package.json @@ -10,6 +10,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./defaults": { + "types": "./dist/defaults/index.d.ts", + "import": "./dist/defaults/index.js" + }, "./defaults/properties": { "types": "./dist/defaults/properties.d.ts", "import": "./dist/defaults/properties.js" diff --git a/pkg/spec/src/actions.ts b/pkg/spec/src/actions.ts index 1010ea3..7ced33e 100644 --- a/pkg/spec/src/actions.ts +++ b/pkg/spec/src/actions.ts @@ -2,11 +2,15 @@ import type { AccessibilityElement, Action, ActionGenerator, + Direction, + DoubleTapAction, InputTextAction, Key, + LongPressAction, Point, PressKeyAction, Sampler, + ScrollAction, SwipeAction, TapAction, WaitAction, @@ -42,6 +46,21 @@ export function Tap(parameters: { on: string | AccessibilityElement }): TapActio return globalThis.__sanderling__.tap(parameters); } +export function DoubleTap(parameters: { on: string | AccessibilityElement }): DoubleTapAction { + return globalThis.__sanderling__.doubleTap(parameters); +} + +export function LongPress(parameters: { on: string | AccessibilityElement }): LongPressAction { + return globalThis.__sanderling__.longPress(parameters); +} + +export function Scroll(parameters: { + direction: Direction; + in?: string | AccessibilityElement; +}): ScrollAction { + return globalThis.__sanderling__.scroll(parameters); +} + export function InputText(parameters: { into: string | AccessibilityElement; text: string; @@ -65,7 +84,17 @@ export function Wait(parameters: { durationMillis: number }): WaitAction { return globalThis.__sanderling__.wait(parameters); } -function builtinGenerator(name: "taps" | "swipes" | "waitOnce" | "pressKeys"): ActionGenerator { +function builtinGenerator( + name: + | "taps" + | "doubleTaps" + | "longPresses" + | "scrolls" + | "typing" + | "swipes" + | "waitOnce" + | "pressKeys", +): ActionGenerator { return new Proxy({} as ActionGenerator, { get(_target, property) { const runtime = globalThis.__sanderling__[name] as unknown as Record< @@ -78,6 +107,10 @@ function builtinGenerator(name: "taps" | "swipes" | "waitOnce" | "pressKeys"): A } export const taps: ActionGenerator = builtinGenerator("taps"); +export const doubleTaps: ActionGenerator = builtinGenerator("doubleTaps"); +export const longPresses: ActionGenerator = builtinGenerator("longPresses"); +export const scrolls: ActionGenerator = builtinGenerator("scrolls"); +export const typing: ActionGenerator = builtinGenerator("typing"); export const swipes: ActionGenerator = builtinGenerator("swipes"); export const waitOnce: ActionGenerator = builtinGenerator("waitOnce"); export const pressKey: ActionGenerator = builtinGenerator("pressKeys"); diff --git a/pkg/spec/src/defaults/actions.ts b/pkg/spec/src/defaults/actions.ts new file mode 100644 index 0000000..cd7fb15 --- /dev/null +++ b/pkg/spec/src/defaults/actions.ts @@ -0,0 +1,24 @@ +import { doubleTaps, swipes, taps, typing, weighted } from "../actions.ts"; +import type { ActionGenerator } from "../types.ts"; + +export { doubleTaps, pressKey, swipes, taps, typing, waitOnce } from "../actions.ts"; +// longPresses and scrolls are opt-in generators: not part of defaultActions, +// but authors can include them in their own weighted() set. +export { longPresses, scrolls } from "../actions.ts"; + +// A broad exploration generator: tap things, type edge-case values into fields, +// and swipe. Layer it under targeted depth flows so the fuzzer wanders the whole +// app while still driving the paths an author wrote. It deliberately omits the +// hardware back key: backing out past the app's root screen exits the app under +// test, and exploration must stay within the app. +// +// doubleTaps fires the same target twice ~50ms apart inside one step. Real users +// double-tap (image zoom, like-to-favorite, play/pause); without this the fuzzer +// can never produce sub-100ms event spacing, so race-window bugs (debounce gaps, +// in-flight guards, init races) stay unreachable. +export const defaultActions: ActionGenerator = weighted( + [45, taps], + [15, doubleTaps], + [25, typing], + [15, swipes], +); diff --git a/pkg/spec/src/defaults/index.ts b/pkg/spec/src/defaults/index.ts new file mode 100644 index 0000000..a292e69 --- /dev/null +++ b/pkg/spec/src/defaults/index.ts @@ -0,0 +1,2 @@ +export * from "./properties.ts"; +export * from "./actions.ts"; diff --git a/pkg/spec/src/extract.ts b/pkg/spec/src/extract.ts index 4c08087..d0edd8f 100644 --- a/pkg/spec/src/extract.ts +++ b/pkg/spec/src/extract.ts @@ -1,7 +1,18 @@ import type { AccessibilityElement, Extracted, State } from "./types.ts"; -export function extract(getter: (state: State) => T): Extracted { - return globalThis.__sanderling__.extract(getter); +export function extract(getter: (state: State) => T): Extracted; +export function extract(name: string, getter: (state: State) => T): Extracted; +export function extract( + nameOrGetter: string | ((state: State) => T), + maybeGetter?: (state: State) => T, +): Extracted { + if (typeof nameOrGetter === "string") { + if (maybeGetter == null) { + throw new TypeError("extract(name, getter): getter is required when a name is supplied"); + } + return globalThis.__sanderling__.extract(maybeGetter, nameOrGetter); + } + return globalThis.__sanderling__.extract(nameOrGetter); } const KEY_DELIMITER = "\x1f"; diff --git a/pkg/spec/src/index.ts b/pkg/spec/src/index.ts index 4bbb54e..969cf2d 100644 --- a/pkg/spec/src/index.ts +++ b/pkg/spec/src/index.ts @@ -4,6 +4,7 @@ export type { Action, ActionGenerator, AttrSelector, + DoubleTapAction, EventuallyFormula, ExceptionRecord, Extracted, @@ -30,16 +31,19 @@ export type { export { extract, keyedBy } from "./extract.ts"; export { always, eventually, next, now } from "./ltl.ts"; export { + DoubleTap, InputText, PressKey, Swipe, Tap, Wait, actions, + doubleTaps, from, pressKey, swipes, taps, + typing, waitOnce, weighted, whenRoute, diff --git a/pkg/spec/src/types.ts b/pkg/spec/src/types.ts index cd61b62..384be42 100644 --- a/pkg/spec/src/types.ts +++ b/pkg/spec/src/types.ts @@ -41,6 +41,7 @@ export interface KnownAttrSelectors { focused?: boolean; checked?: boolean; selected?: boolean; + editable?: boolean; } /** @@ -73,6 +74,7 @@ export interface AccessibilityElement { checked?: boolean; focused?: boolean; selected?: boolean; + editable?: boolean; bounds?: { left: number; top: number; right: number; bottom: number }; x?: number; y?: number; @@ -130,7 +132,16 @@ export interface Point { y: number; } +export type Direction = "up" | "down" | "left" | "right"; + export type TapAction = { kind: "Tap"; on: string | AccessibilityElement }; +export type DoubleTapAction = { kind: "DoubleTap"; on: string | AccessibilityElement }; +export type LongPressAction = { kind: "LongPress"; on: string | AccessibilityElement }; +export type ScrollAction = { + kind: "Scroll"; + direction: Direction; + in?: string | AccessibilityElement; +}; export type InputTextAction = { kind: "InputText"; into: string | AccessibilityElement; @@ -146,6 +157,9 @@ export type PressKeyAction = { kind: "PressKey"; key: Key }; export type WaitAction = { kind: "Wait"; durationMillis: number }; export type Action = | TapAction + | DoubleTapAction + | LongPressAction + | ScrollAction | InputTextAction | SwipeAction | PressKeyAction @@ -183,7 +197,7 @@ export interface Sampler { } export interface SanderlingRuntime { - extract: (getter: (state: State) => T) => Extracted; + extract: (getter: (state: State) => T, name?: string) => Extracted; always: (predicateOrFormula: (() => boolean) | Formula) => Formula; now: (predicate: () => boolean) => Formula; next: (predicate: () => boolean) => Formula; @@ -192,6 +206,12 @@ export interface SanderlingRuntime { weighted: (...entries: WeightedEntry[]) => ActionGenerator; from: (items: readonly T[]) => Sampler; tap: (parameters: { on: string | AccessibilityElement }) => TapAction; + doubleTap: (parameters: { on: string | AccessibilityElement }) => DoubleTapAction; + longPress: (parameters: { on: string | AccessibilityElement }) => LongPressAction; + scroll: (parameters: { + direction: Direction; + in?: string | AccessibilityElement; + }) => ScrollAction; inputText: (parameters: { into: string | AccessibilityElement; text: string; @@ -204,6 +224,10 @@ export interface SanderlingRuntime { pressKey: (parameters: { key: Key }) => PressKeyAction; wait: (parameters: { durationMillis: number }) => WaitAction; taps: ActionGenerator; + doubleTaps: ActionGenerator; + longPresses: ActionGenerator; + scrolls: ActionGenerator; + typing: ActionGenerator; swipes: ActionGenerator; waitOnce: ActionGenerator; pressKeys: ActionGenerator; diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index ed9a24a..9e06dbc 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -20,6 +20,7 @@ interface Handle { interface ExtractorEntry { getter: (state: unknown) => unknown; handle: Handle; + name: string; } interface ActionGeneratorHandle { @@ -290,9 +291,14 @@ function buildState(): unknown { } const runtime = { - extract(getter: (state: unknown) => T): Handle { + extract(getter: (state: unknown) => T, name?: string): Handle { const handle: Handle = { current: undefined, previous: undefined }; - extractors.push({ getter: getter as (s: unknown) => unknown, handle }); + const resolvedName = name && name.length > 0 ? name : `extractor_${extractors.length}`; + extractors.push({ + getter: getter as (s: unknown) => unknown, + handle, + name: resolvedName, + }); return handle; }, always: noopFormula, @@ -328,6 +334,17 @@ const runtime = { tap(p: { on: unknown }): unknown { return { kind: "Tap", on: p.on }; }, + doubleTap(p: { on: unknown }): unknown { + return { kind: "DoubleTap", on: p.on }; + }, + longPress(): unknown { + // Why: web has no long-press gesture for v1; the factory returns null so LongPress() calls no-op. + return null; + }, + scroll(): unknown { + // Why: web has no native scroll gesture for v1; the factory returns null so Scroll() calls no-op. + return null; + }, inputText(p: { into: unknown; text: string }): unknown { return { kind: "InputText", into: p.into, text: p.text }; }, @@ -347,6 +364,10 @@ const runtime = { return { kind: "Wait", durationMillis: p.durationMillis }; }, taps: { __sanderlingActionGenerator: true, __sanderlingKind: "taps" } as ActionGeneratorHandle, + doubleTaps: { __sanderlingActionGenerator: true, __sanderlingKind: "doubleTaps" } as ActionGeneratorHandle, + longPresses: { __sanderlingActionGenerator: true, __sanderlingKind: "longPresses" } as ActionGeneratorHandle, + scrolls: { __sanderlingActionGenerator: true, __sanderlingKind: "scrolls" } as ActionGeneratorHandle, + typing: { __sanderlingActionGenerator: true, __sanderlingKind: "typing" } as ActionGeneratorHandle, swipes: { __sanderlingActionGenerator: true, __sanderlingKind: "swipes" } as ActionGeneratorHandle, waitOnce: { __sanderlingActionGenerator: true, __sanderlingKind: "waitOnce" } as ActionGeneratorHandle, pressKeys: { __sanderlingActionGenerator: true, __sanderlingKind: "pressKey" } as ActionGeneratorHandle, @@ -440,9 +461,17 @@ function resolveGenerator(handle: ActionGeneratorHandle): unknown { return resolveGenerator(inner); } case "taps": - return randomTap(); + return randomTap("Tap"); + case "doubleTaps": + return randomTap("DoubleTap"); + case "typing": + return randomInput(); case "swipes": return randomSwipe(); + case "longPresses": + case "scrolls": + // Why: web has no long-press or native scroll gesture for v1; both no-op. + return null; case "waitOnce": return { kind: "Wait", durationMillis: 500 }; case "pressKey": @@ -456,7 +485,7 @@ function resolveGenerator(handle: ActionGeneratorHandle): unknown { // doesn't re-walk the DOM and re-flush layout on every iteration. let randomTapCandidates: HTMLElement[] | null = null; -function randomTap(): unknown { +function randomTap(kind: "Tap" | "DoubleTap"): unknown { if (!randomTapCandidates) { randomTapCandidates = Array.from( document.querySelectorAll( @@ -474,7 +503,7 @@ function randomTap(): unknown { if (!picked) return null; const rect = picked.getBoundingClientRect(); return { - kind: "Tap", + kind, on: { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2), @@ -482,6 +511,59 @@ function randomTap(): unknown { }; } +// Per-tick cache mirroring randomTapCandidates, reset each NextAction tick. +let randomInputCandidates: HTMLElement[] | null = null; + +const NON_TEXT_INPUT_TYPES = [ + "button", "submit", "checkbox", "radio", "range", "color", "file", "image", "reset", +]; + +// Edge-case input pool mirroring the goja-side inputCorpus: empty, whitespace, +// overflow length, unicode, numeric boundaries, and common injection payloads. +const WEB_INPUT_CORPUS = [ + "", "a", "a".repeat(4096), "🙂🔥💸", " ", "\t\n", "-1", + "999999999999999999999", "0.0000001", "1e10", "'; DROP TABLE--", + "", "../../etc/passwd", "%s%n", "NaN", +]; + +function isEditableElement(element: HTMLElement): boolean { + if (element.isContentEditable) return true; + const tag = element.tagName.toLowerCase(); + if (tag === "textarea") return true; + if (tag === "input") { + const type = ((element as HTMLInputElement).type || "").toLowerCase(); + return !NON_TEXT_INPUT_TYPES.includes(type); + } + return false; +} + +function randomInput(): unknown { + if (!randomInputCandidates) { + randomInputCandidates = Array.from( + document.querySelectorAll("input, textarea, [contenteditable]"), + ).filter((element) => { + if (!isEditableElement(element)) return false; + if ((element as HTMLInputElement).disabled) return false; + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + } + const candidates = randomInputCandidates; + if (candidates.length === 0) return null; + const picked = candidates[Math.floor(Math.random() * candidates.length)]; + if (!picked) return null; + const rect = picked.getBoundingClientRect(); + const text = WEB_INPUT_CORPUS[Math.floor(Math.random() * WEB_INPUT_CORPUS.length)]; + return { + kind: "InputText", + into: { + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + }, + text, + }; +} + function randomSwipe(): unknown { // Why: web has no swipe gesture; pointer drags into empty divs are noise. return null; @@ -514,6 +596,14 @@ function serializeAction(action: unknown): unknown { } return { kind: "Tap", x: point.x, y: point.y }; } + case "DoubleTap": { + const point = pointOf(obj.on); + if (!point) { + console.warn("[sanderling] DoubleTap target did not resolve to coordinates"); + return null; + } + return { kind: "DoubleTap", x: point.x, y: point.y }; + } case "InputText": { const point = pointOf(obj.into); if (!point) { @@ -543,6 +633,10 @@ function serializeAction(action: unknown): unknown { duration_millis: obj.durationMillis ?? 250, }; } + case "LongPress": + case "Scroll": + // Why: web has no long-press or native scroll gesture for v1; drop the action. + return null; case "PressKey": return { kind: "PressKey", key: obj.key }; case "Wait": @@ -569,6 +663,7 @@ defineLockedGlobal("__sanderlingNextAction__", function (): unknown { if (!actionsRoot) return null; // Reset per-tick caches so each invocation gets a fresh DOM scan. randomTapCandidates = null; + randomInputCandidates = null; // Match the goja runtime: retry up to 16 times when a weighted entry's // generator returns []. Otherwise on routes where most generators are // gated to other pages, ~80% of ticks would emit no action. diff --git a/pkg/spec/test/api.test.ts b/pkg/spec/test/api.test.ts index e97502a..565683e 100644 --- a/pkg/spec/test/api.test.ts +++ b/pkg/spec/test/api.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { + DoubleTap, InputText, PressKey, Swipe, @@ -37,6 +38,7 @@ import type { interface RecordedRuntime extends SanderlingRuntime { extracts: Array<(state: State) => unknown>; + extractNames: Array; alwaysArgs: Array<(() => boolean) | Formula>; nowPredicates: Array<() => boolean>; nextPredicates: Array<() => boolean>; @@ -91,6 +93,7 @@ function makeChainableEventually(record: RecordedRuntime): EventuallyFormula { function installFakeRuntime(): RecordedRuntime { const calls = { extracts: [] as Array<(state: State) => unknown>, + extractNames: [] as Array, alwaysArgs: [] as Array<(() => boolean) | Formula>, nowPredicates: [] as Array<() => boolean>, nextPredicates: [] as Array<() => boolean>, @@ -105,8 +108,9 @@ function installFakeRuntime(): RecordedRuntime { fromCalls: [] as unknown[][], }; const runtime = { - extract: (getter: (state: State) => T): Extracted => { + extract: (getter: (state: State) => T, name?: string): Extracted => { calls.extracts.push(getter as (state: State) => unknown); + calls.extractNames.push(name); return { current: undefined as unknown as T, previous: undefined }; }, always: (predicateOrFormula: (() => boolean) | Formula): Formula => { @@ -138,6 +142,9 @@ function installFakeRuntime(): RecordedRuntime { return { generate: () => items[0] as T }; }, tap: ({ on }) => ({ kind: "Tap", on }), + doubleTap: ({ on }) => ({ kind: "DoubleTap", on }), + longPress: ({ on }) => ({ kind: "LongPress", on }), + scroll: ({ direction, in: container }) => ({ kind: "Scroll", direction, in: container }), inputText: ({ into, text }) => ({ kind: "InputText", into, text }), swipe: ({ from: fromPoint, to, durationMillis }) => ({ kind: "Swipe", @@ -148,6 +155,10 @@ function installFakeRuntime(): RecordedRuntime { pressKey: ({ key }) => ({ kind: "PressKey", key }), wait: ({ durationMillis }) => ({ kind: "Wait", durationMillis }), taps: { __sanderlingActionGenerator: true, generate: () => [] }, + doubleTaps: { __sanderlingActionGenerator: true, generate: () => [] }, + longPresses: { __sanderlingActionGenerator: true, generate: () => [] }, + scrolls: { __sanderlingActionGenerator: true, generate: () => [] }, + typing: { __sanderlingActionGenerator: true, generate: () => [] }, swipes: { __sanderlingActionGenerator: true, generate: () => [] }, waitOnce: { __sanderlingActionGenerator: true, generate: () => [] }, pressKeys: { __sanderlingActionGenerator: true, generate: () => [] }, @@ -163,6 +174,24 @@ test("extract forwards the getter to the runtime", () => { extract(getter); assert.equal(runtime.extracts.length, 1); assert.equal(runtime.extracts[0], getter); + assert.equal(runtime.extractNames[0], undefined); +}); + +test("extract accepts an explicit name", () => { + const runtime = installFakeRuntime(); + const getter = (state: State) => state.snapshots["balance"]; + extract("ledgerBalance", getter); + assert.equal(runtime.extracts.length, 1); + assert.equal(runtime.extracts[0], getter); + assert.equal(runtime.extractNames[0], "ledgerBalance"); +}); + +test("extract throws when given a name with no getter", () => { + installFakeRuntime(); + assert.throws( + () => (extract as unknown as (n: string) => unknown)("orphan"), + /getter is required/, + ); }); test("always wraps a predicate into a formula via the runtime", () => { @@ -217,6 +246,12 @@ test("Tap returns a TapAction with the supplied selector", () => { assert.deepEqual(action, { kind: "Tap", on: "id:login_continue" }); }); +test("DoubleTap returns a DoubleTapAction with the supplied selector", () => { + installFakeRuntime(); + const action = DoubleTap({ on: "id:save" }); + assert.deepEqual(action, { kind: "DoubleTap", on: "id:save" }); +}); + test("Tap accepts an AccessibilityElement", () => { installFakeRuntime(); const element: AccessibilityElement = { diff --git a/pkg/spec/test/defaults.test.ts b/pkg/spec/test/defaults.test.ts index 4fcb6e2..9c471b6 100644 --- a/pkg/spec/test/defaults.test.ts +++ b/pkg/spec/test/defaults.test.ts @@ -58,11 +58,18 @@ function installRuntime(initialState: State): RecordedRuntime { }), from: (_items: readonly T[]): Sampler => ({ generate: () => _items[0] as T }), tap: ({ on }) => ({ kind: "Tap", on }), + doubleTap: ({ on }) => ({ kind: "DoubleTap", on }), + longPress: ({ on }) => ({ kind: "LongPress", on }), + scroll: ({ direction, in: container }) => ({ kind: "Scroll", direction, in: container }), inputText: ({ into, text }) => ({ kind: "InputText", into, text }), swipe: (p) => ({ kind: "Swipe", from: p.from, to: p.to, durationMillis: p.durationMillis }), pressKey: ({ key }) => ({ kind: "PressKey", key }), wait: ({ durationMillis }) => ({ kind: "Wait", durationMillis }), taps: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator, + doubleTaps: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator, + longPresses: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator, + scrolls: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator, + typing: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator, swipes: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator, waitOnce: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator, pressKeys: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator, @@ -108,3 +115,30 @@ test("defaults bundle exports formulas tagged as LTL properties", async () => { assert.equal(defaults.noUncaughtExceptions.__sanderlingFormula, true); assert.equal(defaults.noLogcatErrors.__sanderlingFormula, true); }); + +test("defaults bundle exports defaultActions as a valid ActionGenerator", async () => { + installRuntime(emptyState); + const defaults = await import("../src/defaults/actions.ts"); + assert.equal(defaults.defaultActions.__sanderlingActionGenerator, true); + assert.equal(typeof defaults.defaultActions.generate, "function"); +}); + +test("typing resolves as a valid ActionGenerator", async () => { + installRuntime(emptyState); + const { typing } = await import("../src/defaults/actions.ts"); + assert.equal(typing.__sanderlingActionGenerator, true); +}); + +test("longPresses and scrolls resolve as valid ActionGenerators", async () => { + installRuntime(emptyState); + const { longPresses, scrolls } = await import("../src/defaults/actions.ts"); + assert.equal(longPresses.__sanderlingActionGenerator, true); + assert.equal(scrolls.__sanderlingActionGenerator, true); +}); + +test("defaults barrel re-exports both properties and actions", async () => { + installRuntime(emptyState); + const barrel = await import("../src/defaults/index.ts"); + assert.equal(barrel.defaultActions.__sanderlingActionGenerator, true); + assert.equal(barrel.noUncaughtExceptions.__sanderlingFormula, true); +}); diff --git a/pkg/spec/test/folio-submit-balance-predicate.test.ts b/pkg/spec/test/folio-submit-balance-predicate.test.ts new file mode 100644 index 0000000..90c0885 --- /dev/null +++ b/pkg/spec/test/folio-submit-balance-predicate.test.ts @@ -0,0 +1,243 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + parseTypedAmount, + submitChangesBalanceByTypedAmount, +} from "../../../examples/folio/sanderling/predicates.ts"; + +const submitOn = "testTag:LedgerScreen > testTag:TxnSubmit"; + +test("single submit: delta matches typed amount", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 500, + prevTotalBalance: 1000, + currTotalBalance: 1500, + }), + true, + ); +}); + +test("double submit: delta is twice the typed amount, fires", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 500, + prevTotalBalance: 1000, + currTotalBalance: 2000, + }), + false, + ); +}); + +test("DoubleTap kind also caught when delta exceeds typed amount", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "DoubleTap", on: submitOn }, + typedAmount: 500, + prevTotalBalance: 0, + currTotalBalance: 1000, + }), + false, + ); +}); + +test("wrong action kind: vacuous true even with mismatch", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "InputText", on: submitOn }, + typedAmount: 500, + prevTotalBalance: 1000, + currTotalBalance: 1000, + }), + true, + ); +}); + +test("wrong target: vacuous true even with mismatch", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit" }, + typedAmount: 500, + prevTotalBalance: 1000, + currTotalBalance: 1000, + }), + true, + ); +}); + +test("null lastAction: vacuous true", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: null, + typedAmount: 500, + prevTotalBalance: 1000, + currTotalBalance: 2000, + }), + true, + ); +}); + +test("zero typedAmount: vacuous true", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 0, + prevTotalBalance: 1000, + currTotalBalance: 1500, + }), + true, + ); +}); + +test("selector as object: coerced safely and TxnSubmit detected", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" } }, + typedAmount: 500, + prevTotalBalance: 0, + currTotalBalance: 1000, + }), + false, + ); +}); + +test("selector as object without TxnSubmit: vacuous true", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" } }, + typedAmount: 500, + prevTotalBalance: 0, + currTotalBalance: 1000, + }), + true, + ); +}); + +test("raw whole-dollar input: single submit clears", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: parseTypedAmount("50"), + prevTotalBalance: 5000, + currTotalBalance: 10000, + }), + true, + ); +}); + +test("raw whole-dollar input: double submit fires", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: parseTypedAmount("50"), + prevTotalBalance: 5000, + currTotalBalance: 15000, + }), + false, + ); +}); + +test("decimal input from empty prior balance clears", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: parseTypedAmount("5.50"), + prevTotalBalance: 0, + currTotalBalance: 550, + }), + true, + ); +}); + +test("DoubleTap kind with raw whole-dollar input fires", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "DoubleTap", on: submitOn }, + typedAmount: parseTypedAmount("100"), + prevTotalBalance: 0, + currTotalBalance: 20000, + }), + false, + ); +}); + +test("route gate: ledger landing with stale carrier is skipped", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "ledger", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 5000, + prevTotalBalance: 0, + currTotalBalance: 0, + }), + true, + ); +}); + +test("route gate: add-transaction landing with double-submit delta is skipped", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "add-transaction", + lastAction: { kind: "DoubleTap", on: submitOn }, + typedAmount: 5000, + prevTotalBalance: 0, + currTotalBalance: 10000, + }), + true, + ); +}); + +test("route gate: null route is skipped", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: null, + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 5000, + prevTotalBalance: 0, + currTotalBalance: 0, + }), + true, + ); +}); + +test("route gate: home landing with matching delta passes", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 5000, + prevTotalBalance: 0, + currTotalBalance: 5000, + }), + true, + ); +}); + +test("route gate: home landing with double-insert delta fires", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 5000, + prevTotalBalance: 0, + currTotalBalance: 10000, + }), + false, + ); +}); diff --git a/pkg/spec/test/folio-total-balance.test.ts b/pkg/spec/test/folio-total-balance.test.ts new file mode 100644 index 0000000..759b608 --- /dev/null +++ b/pkg/spec/test/folio-total-balance.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { computeHomeTotalBalance } from "../../../examples/folio/sanderling/predicates.ts"; + +test("on Home with two cards ($10, $20): returns $30", () => { + assert.equal( + computeHomeTotalBalance({ + cardBalanceTexts: ["$10.00", "$20.00"], + previousCarrier: 0, + }), + 3000, + ); +}); + +test("off Home (no cards) after a Home visit of $30: returns carrier $30", () => { + assert.equal( + computeHomeTotalBalance({ + cardBalanceTexts: [], + previousCarrier: 3000, + }), + 3000, + ); +}); + +test("off Home (no cards) with carrier still 0: returns 0", () => { + assert.equal( + computeHomeTotalBalance({ + cardBalanceTexts: [], + previousCarrier: 0, + }), + 0, + ); +}); + +test("sequence: Home $30, off-Home, Home $50 tracks new Home totals", () => { + let carrier = 0; + carrier = computeHomeTotalBalance({ + cardBalanceTexts: ["$10.00", "$20.00"], + previousCarrier: carrier, + }); + assert.equal(carrier, 3000); + carrier = computeHomeTotalBalance({ + cardBalanceTexts: [], + previousCarrier: carrier, + }); + assert.equal(carrier, 3000); + carrier = computeHomeTotalBalance({ + cardBalanceTexts: ["$20.00", "$30.00"], + previousCarrier: carrier, + }); + assert.equal(carrier, 5000); +}); + +test("Ledger step (no Home cards) holds the carrier, ignores Ledger balance", () => { + let carrier = 0; + carrier = computeHomeTotalBalance({ + cardBalanceTexts: ["$10.00", "$20.00"], + previousCarrier: carrier, + }); + assert.equal(carrier, 3000); + carrier = computeHomeTotalBalance({ + cardBalanceTexts: [], + previousCarrier: carrier, + }); + assert.equal(carrier, 3000); +}); + +test("negative card balance parses with sign and sums correctly", () => { + assert.equal( + computeHomeTotalBalance({ + cardBalanceTexts: ["-$5.00", "$10.00"], + previousCarrier: 0, + }), + 500, + ); +}); + +test("single card on Home overrides any previous carrier", () => { + assert.equal( + computeHomeTotalBalance({ + cardBalanceTexts: ["$7.50"], + previousCarrier: 9999, + }), + 750, + ); +}); + +test("undefined card balance text is treated as 0", () => { + assert.equal( + computeHomeTotalBalance({ + cardBalanceTexts: [undefined, "$10.00"], + previousCarrier: 0, + }), + 1000, + ); +}); diff --git a/pkg/spec/test/folio-typed-amount.test.ts b/pkg/spec/test/folio-typed-amount.test.ts new file mode 100644 index 0000000..d0a387a --- /dev/null +++ b/pkg/spec/test/folio-typed-amount.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { parseTypedAmount } from "../../../examples/folio/sanderling/predicates.ts"; + +test("empty string returns 0", () => { + assert.equal(parseTypedAmount(""), 0); +}); + +test("undefined returns 0", () => { + assert.equal(parseTypedAmount(undefined), 0); +}); + +test("null returns 0", () => { + assert.equal(parseTypedAmount(null), 0); +}); + +test("whole number 50 parses as 5000 cents", () => { + assert.equal(parseTypedAmount("50"), 5000); +}); + +test("decimal 5.50 parses as 550 cents", () => { + assert.equal(parseTypedAmount("5.50"), 550); +}); + +test("decimal 5.05 parses as 505 cents", () => { + assert.equal(parseTypedAmount("5.05"), 505); +}); + +test("one-decimal 0.5 parses as 50 cents", () => { + assert.equal(parseTypedAmount("0.5"), 50); +}); + +test("non-numeric returns 0", () => { + assert.equal(parseTypedAmount("abc"), 0); +}); + +test("too many decimals returns 0", () => { + assert.equal(parseTypedAmount("5.123"), 0); +}); + +test("zero returns 0", () => { + assert.equal(parseTypedAmount("0"), 0); +}); + +test("leading plus sign tolerated as positive", () => { + assert.equal(parseTypedAmount("+50"), 5000); +}); + +test("leading minus sign tolerated as positive", () => { + assert.equal(parseTypedAmount("-50"), 5000); +}); + +test("comma-separated thousands accepted", () => { + assert.equal(parseTypedAmount("1,234.56"), 123456); +}); diff --git a/proto/driverpb/driver.pb.go b/proto/driverpb/driver.pb.go index a1a07f4..1fb9719 100644 --- a/proto/driverpb/driver.pb.go +++ b/proto/driverpb/driver.pb.go @@ -361,6 +361,58 @@ func (x *HierarchyJSON) GetJson() string { return "" } +type SnapshotResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Hierarchy *HierarchyJSON `protobuf:"bytes,1,opt,name=hierarchy,proto3" json:"hierarchy,omitempty"` + Screenshot *Image `protobuf:"bytes,2,opt,name=screenshot,proto3" json:"screenshot,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SnapshotResponse) Reset() { + *x = SnapshotResponse{} + mi := &file_driverpb_driver_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SnapshotResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SnapshotResponse) ProtoMessage() {} + +func (x *SnapshotResponse) ProtoReflect() protoreflect.Message { + mi := &file_driverpb_driver_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SnapshotResponse.ProtoReflect.Descriptor instead. +func (*SnapshotResponse) Descriptor() ([]byte, []int) { + return file_driverpb_driver_proto_rawDescGZIP(), []int{7} +} + +func (x *SnapshotResponse) GetHierarchy() *HierarchyJSON { + if x != nil { + return x.Hierarchy + } + return nil +} + +func (x *SnapshotResponse) GetScreenshot() *Image { + if x != nil { + return x.Screenshot + } + return nil +} + type Duration struct { state protoimpl.MessageState `protogen:"open.v1"` Millis int64 `protobuf:"varint,1,opt,name=millis,proto3" json:"millis,omitempty"` @@ -370,7 +422,7 @@ type Duration struct { func (x *Duration) Reset() { *x = Duration{} - mi := &file_driverpb_driver_proto_msgTypes[7] + mi := &file_driverpb_driver_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -382,7 +434,7 @@ func (x *Duration) String() string { func (*Duration) ProtoMessage() {} func (x *Duration) ProtoReflect() protoreflect.Message { - mi := &file_driverpb_driver_proto_msgTypes[7] + mi := &file_driverpb_driver_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -395,7 +447,7 @@ func (x *Duration) ProtoReflect() protoreflect.Message { // Deprecated: Use Duration.ProtoReflect.Descriptor instead. func (*Duration) Descriptor() ([]byte, []int) { - return file_driverpb_driver_proto_rawDescGZIP(), []int{7} + return file_driverpb_driver_proto_rawDescGZIP(), []int{8} } func (x *Duration) GetMillis() int64 { @@ -416,7 +468,7 @@ type HealthStatus struct { func (x *HealthStatus) Reset() { *x = HealthStatus{} - mi := &file_driverpb_driver_proto_msgTypes[8] + mi := &file_driverpb_driver_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -428,7 +480,7 @@ func (x *HealthStatus) String() string { func (*HealthStatus) ProtoMessage() {} func (x *HealthStatus) ProtoReflect() protoreflect.Message { - mi := &file_driverpb_driver_proto_msgTypes[8] + mi := &file_driverpb_driver_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -441,7 +493,7 @@ func (x *HealthStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use HealthStatus.ProtoReflect.Descriptor instead. func (*HealthStatus) Descriptor() ([]byte, []int) { - return file_driverpb_driver_proto_rawDescGZIP(), []int{8} + return file_driverpb_driver_proto_rawDescGZIP(), []int{9} } func (x *HealthStatus) GetReady() bool { @@ -476,7 +528,7 @@ type SwipeRequest struct { func (x *SwipeRequest) Reset() { *x = SwipeRequest{} - mi := &file_driverpb_driver_proto_msgTypes[9] + mi := &file_driverpb_driver_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -488,7 +540,7 @@ func (x *SwipeRequest) String() string { func (*SwipeRequest) ProtoMessage() {} func (x *SwipeRequest) ProtoReflect() protoreflect.Message { - mi := &file_driverpb_driver_proto_msgTypes[9] + mi := &file_driverpb_driver_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -501,7 +553,7 @@ func (x *SwipeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SwipeRequest.ProtoReflect.Descriptor instead. func (*SwipeRequest) Descriptor() ([]byte, []int) { - return file_driverpb_driver_proto_rawDescGZIP(), []int{9} + return file_driverpb_driver_proto_rawDescGZIP(), []int{10} } func (x *SwipeRequest) GetFrom() *Point { @@ -536,7 +588,7 @@ type PressKeyRequest struct { func (x *PressKeyRequest) Reset() { *x = PressKeyRequest{} - mi := &file_driverpb_driver_proto_msgTypes[10] + mi := &file_driverpb_driver_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -548,7 +600,7 @@ func (x *PressKeyRequest) String() string { func (*PressKeyRequest) ProtoMessage() {} func (x *PressKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_driverpb_driver_proto_msgTypes[10] + mi := &file_driverpb_driver_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -561,7 +613,7 @@ func (x *PressKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PressKeyRequest.ProtoReflect.Descriptor instead. func (*PressKeyRequest) Descriptor() ([]byte, []int) { - return file_driverpb_driver_proto_rawDescGZIP(), []int{10} + return file_driverpb_driver_proto_rawDescGZIP(), []int{11} } func (x *PressKeyRequest) GetKey() string { @@ -584,7 +636,7 @@ type RecentLogsRequest struct { func (x *RecentLogsRequest) Reset() { *x = RecentLogsRequest{} - mi := &file_driverpb_driver_proto_msgTypes[11] + mi := &file_driverpb_driver_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -596,7 +648,7 @@ func (x *RecentLogsRequest) String() string { func (*RecentLogsRequest) ProtoMessage() {} func (x *RecentLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_driverpb_driver_proto_msgTypes[11] + mi := &file_driverpb_driver_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -609,7 +661,7 @@ func (x *RecentLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RecentLogsRequest.ProtoReflect.Descriptor instead. func (*RecentLogsRequest) Descriptor() ([]byte, []int) { - return file_driverpb_driver_proto_rawDescGZIP(), []int{11} + return file_driverpb_driver_proto_rawDescGZIP(), []int{12} } func (x *RecentLogsRequest) GetSinceUnixMillis() int64 { @@ -635,7 +687,7 @@ type LogEntries struct { func (x *LogEntries) Reset() { *x = LogEntries{} - mi := &file_driverpb_driver_proto_msgTypes[12] + mi := &file_driverpb_driver_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -647,7 +699,7 @@ func (x *LogEntries) String() string { func (*LogEntries) ProtoMessage() {} func (x *LogEntries) ProtoReflect() protoreflect.Message { - mi := &file_driverpb_driver_proto_msgTypes[12] + mi := &file_driverpb_driver_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -660,7 +712,7 @@ func (x *LogEntries) ProtoReflect() protoreflect.Message { // Deprecated: Use LogEntries.ProtoReflect.Descriptor instead. func (*LogEntries) Descriptor() ([]byte, []int) { - return file_driverpb_driver_proto_rawDescGZIP(), []int{12} + return file_driverpb_driver_proto_rawDescGZIP(), []int{13} } func (x *LogEntries) GetEntries() []*LogEntry { @@ -682,7 +734,7 @@ type LogEntry struct { func (x *LogEntry) Reset() { *x = LogEntry{} - mi := &file_driverpb_driver_proto_msgTypes[13] + mi := &file_driverpb_driver_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -694,7 +746,7 @@ func (x *LogEntry) String() string { func (*LogEntry) ProtoMessage() {} func (x *LogEntry) ProtoReflect() protoreflect.Message { - mi := &file_driverpb_driver_proto_msgTypes[13] + mi := &file_driverpb_driver_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -707,7 +759,7 @@ func (x *LogEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use LogEntry.ProtoReflect.Descriptor instead. func (*LogEntry) Descriptor() ([]byte, []int) { - return file_driverpb_driver_proto_rawDescGZIP(), []int{13} + return file_driverpb_driver_proto_rawDescGZIP(), []int{14} } func (x *LogEntry) GetUnixMillis() int64 { @@ -747,7 +799,7 @@ type MetricsRequest struct { func (x *MetricsRequest) Reset() { *x = MetricsRequest{} - mi := &file_driverpb_driver_proto_msgTypes[14] + mi := &file_driverpb_driver_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -759,7 +811,7 @@ func (x *MetricsRequest) String() string { func (*MetricsRequest) ProtoMessage() {} func (x *MetricsRequest) ProtoReflect() protoreflect.Message { - mi := &file_driverpb_driver_proto_msgTypes[14] + mi := &file_driverpb_driver_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -772,7 +824,7 @@ func (x *MetricsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MetricsRequest.ProtoReflect.Descriptor instead. func (*MetricsRequest) Descriptor() ([]byte, []int) { - return file_driverpb_driver_proto_rawDescGZIP(), []int{14} + return file_driverpb_driver_proto_rawDescGZIP(), []int{15} } func (x *MetricsRequest) GetBundleId() string { @@ -793,7 +845,7 @@ type MetricsResponse struct { func (x *MetricsResponse) Reset() { *x = MetricsResponse{} - mi := &file_driverpb_driver_proto_msgTypes[15] + mi := &file_driverpb_driver_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -805,7 +857,7 @@ func (x *MetricsResponse) String() string { func (*MetricsResponse) ProtoMessage() {} func (x *MetricsResponse) ProtoReflect() protoreflect.Message { - mi := &file_driverpb_driver_proto_msgTypes[15] + mi := &file_driverpb_driver_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -818,7 +870,7 @@ func (x *MetricsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MetricsResponse.ProtoReflect.Descriptor instead. func (*MetricsResponse) Descriptor() ([]byte, []int) { - return file_driverpb_driver_proto_rawDescGZIP(), []int{15} + return file_driverpb_driver_proto_rawDescGZIP(), []int{16} } func (x *MetricsResponse) GetCpuPercent() float64 { @@ -868,7 +920,12 @@ const file_driverpb_driver_proto_rawDesc = "" + "\x05width\x18\x02 \x01(\x05R\x05width\x12\x16\n" + "\x06height\x18\x03 \x01(\x05R\x06height\"#\n" + "\rHierarchyJSON\x12\x12\n" + - "\x04json\x18\x01 \x01(\tR\x04json\"\"\n" + + "\x04json\x18\x01 \x01(\tR\x04json\"\x92\x01\n" + + "\x10SnapshotResponse\x12A\n" + + "\thierarchy\x18\x01 \x01(\v2#.sanderling.driver.v1.HierarchyJSONR\thierarchy\x12;\n" + + "\n" + + "screenshot\x18\x02 \x01(\v2\x1b.sanderling.driver.v1.ImageR\n" + + "screenshot\"\"\n" + "\bDuration\x12\x16\n" + "\x06millis\x18\x01 \x01(\x03R\x06millis\"Z\n" + "\fHealthStatus\x12\x14\n" + @@ -900,7 +957,7 @@ const file_driverpb_driver_proto_rawDesc = "" + "cpuPercent\x12\x1d\n" + "\n" + "heap_bytes\x18\x02 \x01(\x03R\theapBytes\x12,\n" + - "\x12total_memory_bytes\x18\x03 \x01(\x03R\x10totalMemoryBytes2\xe7\a\n" + + "\x12total_memory_bytes\x18\x03 \x01(\x03R\x10totalMemoryBytes2\xff\b\n" + "\x06Driver\x12J\n" + "\x06Launch\x12#.sanderling.driver.v1.LaunchRequest\x1a\x1b.sanderling.driver.v1.Empty\x12E\n" + "\tTerminate\x12\x1b.sanderling.driver.v1.Empty\x1a\x1b.sanderling.driver.v1.Empty\x12?\n" + @@ -908,10 +965,12 @@ const file_driverpb_driver_proto_rawDesc = "" + "\vTapSelector\x12\x1e.sanderling.driver.v1.Selector\x1a\x1b.sanderling.driver.v1.Empty\x12D\n" + "\tInputText\x12\x1a.sanderling.driver.v1.Text\x1a\x1b.sanderling.driver.v1.Empty\x12H\n" + "\x05Swipe\x12\".sanderling.driver.v1.SwipeRequest\x1a\x1b.sanderling.driver.v1.Empty\x12N\n" + - "\bPressKey\x12%.sanderling.driver.v1.PressKeyRequest\x1a\x1b.sanderling.driver.v1.Empty\x12F\n" + + "\bPressKey\x12%.sanderling.driver.v1.PressKeyRequest\x1a\x1b.sanderling.driver.v1.Empty\x12E\n" + + "\tLongPress\x12\x1b.sanderling.driver.v1.Point\x1a\x1b.sanderling.driver.v1.Empty\x12F\n" + "\n" + "Screenshot\x12\x1b.sanderling.driver.v1.Empty\x1a\x1b.sanderling.driver.v1.Image\x12M\n" + - "\tHierarchy\x12\x1b.sanderling.driver.v1.Empty\x1a#.sanderling.driver.v1.HierarchyJSON\x12W\n" + + "\tHierarchy\x12\x1b.sanderling.driver.v1.Empty\x1a#.sanderling.driver.v1.HierarchyJSON\x12O\n" + + "\bSnapshot\x12\x1b.sanderling.driver.v1.Empty\x1a&.sanderling.driver.v1.SnapshotResponse\x12W\n" + "\n" + "RecentLogs\x12'.sanderling.driver.v1.RecentLogsRequest\x1a .sanderling.driver.v1.LogEntries\x12J\n" + "\vWaitForIdle\x12\x1e.sanderling.driver.v1.Duration\x1a\x1b.sanderling.driver.v1.Empty\x12I\n" + @@ -931,7 +990,7 @@ func file_driverpb_driver_proto_rawDescGZIP() []byte { return file_driverpb_driver_proto_rawDescData } -var file_driverpb_driver_proto_msgTypes = make([]protoimpl.MessageInfo, 17) +var file_driverpb_driver_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_driverpb_driver_proto_goTypes = []any{ (*Empty)(nil), // 0: sanderling.driver.v1.Empty (*LaunchRequest)(nil), // 1: sanderling.driver.v1.LaunchRequest @@ -940,53 +999,60 @@ var file_driverpb_driver_proto_goTypes = []any{ (*Selector)(nil), // 4: sanderling.driver.v1.Selector (*Image)(nil), // 5: sanderling.driver.v1.Image (*HierarchyJSON)(nil), // 6: sanderling.driver.v1.HierarchyJSON - (*Duration)(nil), // 7: sanderling.driver.v1.Duration - (*HealthStatus)(nil), // 8: sanderling.driver.v1.HealthStatus - (*SwipeRequest)(nil), // 9: sanderling.driver.v1.SwipeRequest - (*PressKeyRequest)(nil), // 10: sanderling.driver.v1.PressKeyRequest - (*RecentLogsRequest)(nil), // 11: sanderling.driver.v1.RecentLogsRequest - (*LogEntries)(nil), // 12: sanderling.driver.v1.LogEntries - (*LogEntry)(nil), // 13: sanderling.driver.v1.LogEntry - (*MetricsRequest)(nil), // 14: sanderling.driver.v1.MetricsRequest - (*MetricsResponse)(nil), // 15: sanderling.driver.v1.MetricsResponse - nil, // 16: sanderling.driver.v1.LaunchRequest.EnvEntry + (*SnapshotResponse)(nil), // 7: sanderling.driver.v1.SnapshotResponse + (*Duration)(nil), // 8: sanderling.driver.v1.Duration + (*HealthStatus)(nil), // 9: sanderling.driver.v1.HealthStatus + (*SwipeRequest)(nil), // 10: sanderling.driver.v1.SwipeRequest + (*PressKeyRequest)(nil), // 11: sanderling.driver.v1.PressKeyRequest + (*RecentLogsRequest)(nil), // 12: sanderling.driver.v1.RecentLogsRequest + (*LogEntries)(nil), // 13: sanderling.driver.v1.LogEntries + (*LogEntry)(nil), // 14: sanderling.driver.v1.LogEntry + (*MetricsRequest)(nil), // 15: sanderling.driver.v1.MetricsRequest + (*MetricsResponse)(nil), // 16: sanderling.driver.v1.MetricsResponse + nil, // 17: sanderling.driver.v1.LaunchRequest.EnvEntry } var file_driverpb_driver_proto_depIdxs = []int32{ - 16, // 0: sanderling.driver.v1.LaunchRequest.env:type_name -> sanderling.driver.v1.LaunchRequest.EnvEntry - 2, // 1: sanderling.driver.v1.SwipeRequest.from:type_name -> sanderling.driver.v1.Point - 2, // 2: sanderling.driver.v1.SwipeRequest.to:type_name -> sanderling.driver.v1.Point - 13, // 3: sanderling.driver.v1.LogEntries.entries:type_name -> sanderling.driver.v1.LogEntry - 1, // 4: sanderling.driver.v1.Driver.Launch:input_type -> sanderling.driver.v1.LaunchRequest - 0, // 5: sanderling.driver.v1.Driver.Terminate:input_type -> sanderling.driver.v1.Empty - 2, // 6: sanderling.driver.v1.Driver.Tap:input_type -> sanderling.driver.v1.Point - 4, // 7: sanderling.driver.v1.Driver.TapSelector:input_type -> sanderling.driver.v1.Selector - 3, // 8: sanderling.driver.v1.Driver.InputText:input_type -> sanderling.driver.v1.Text - 9, // 9: sanderling.driver.v1.Driver.Swipe:input_type -> sanderling.driver.v1.SwipeRequest - 10, // 10: sanderling.driver.v1.Driver.PressKey:input_type -> sanderling.driver.v1.PressKeyRequest - 0, // 11: sanderling.driver.v1.Driver.Screenshot:input_type -> sanderling.driver.v1.Empty - 0, // 12: sanderling.driver.v1.Driver.Hierarchy:input_type -> sanderling.driver.v1.Empty - 11, // 13: sanderling.driver.v1.Driver.RecentLogs:input_type -> sanderling.driver.v1.RecentLogsRequest - 7, // 14: sanderling.driver.v1.Driver.WaitForIdle:input_type -> sanderling.driver.v1.Duration - 0, // 15: sanderling.driver.v1.Driver.Health:input_type -> sanderling.driver.v1.Empty - 14, // 16: sanderling.driver.v1.Driver.Metrics:input_type -> sanderling.driver.v1.MetricsRequest - 0, // 17: sanderling.driver.v1.Driver.Launch:output_type -> sanderling.driver.v1.Empty - 0, // 18: sanderling.driver.v1.Driver.Terminate:output_type -> sanderling.driver.v1.Empty - 0, // 19: sanderling.driver.v1.Driver.Tap:output_type -> sanderling.driver.v1.Empty - 0, // 20: sanderling.driver.v1.Driver.TapSelector:output_type -> sanderling.driver.v1.Empty - 0, // 21: sanderling.driver.v1.Driver.InputText:output_type -> sanderling.driver.v1.Empty - 0, // 22: sanderling.driver.v1.Driver.Swipe:output_type -> sanderling.driver.v1.Empty - 0, // 23: sanderling.driver.v1.Driver.PressKey:output_type -> sanderling.driver.v1.Empty - 5, // 24: sanderling.driver.v1.Driver.Screenshot:output_type -> sanderling.driver.v1.Image - 6, // 25: sanderling.driver.v1.Driver.Hierarchy:output_type -> sanderling.driver.v1.HierarchyJSON - 12, // 26: sanderling.driver.v1.Driver.RecentLogs:output_type -> sanderling.driver.v1.LogEntries - 0, // 27: sanderling.driver.v1.Driver.WaitForIdle:output_type -> sanderling.driver.v1.Empty - 8, // 28: sanderling.driver.v1.Driver.Health:output_type -> sanderling.driver.v1.HealthStatus - 15, // 29: sanderling.driver.v1.Driver.Metrics:output_type -> sanderling.driver.v1.MetricsResponse - 17, // [17:30] is the sub-list for method output_type - 4, // [4:17] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name + 17, // 0: sanderling.driver.v1.LaunchRequest.env:type_name -> sanderling.driver.v1.LaunchRequest.EnvEntry + 6, // 1: sanderling.driver.v1.SnapshotResponse.hierarchy:type_name -> sanderling.driver.v1.HierarchyJSON + 5, // 2: sanderling.driver.v1.SnapshotResponse.screenshot:type_name -> sanderling.driver.v1.Image + 2, // 3: sanderling.driver.v1.SwipeRequest.from:type_name -> sanderling.driver.v1.Point + 2, // 4: sanderling.driver.v1.SwipeRequest.to:type_name -> sanderling.driver.v1.Point + 14, // 5: sanderling.driver.v1.LogEntries.entries:type_name -> sanderling.driver.v1.LogEntry + 1, // 6: sanderling.driver.v1.Driver.Launch:input_type -> sanderling.driver.v1.LaunchRequest + 0, // 7: sanderling.driver.v1.Driver.Terminate:input_type -> sanderling.driver.v1.Empty + 2, // 8: sanderling.driver.v1.Driver.Tap:input_type -> sanderling.driver.v1.Point + 4, // 9: sanderling.driver.v1.Driver.TapSelector:input_type -> sanderling.driver.v1.Selector + 3, // 10: sanderling.driver.v1.Driver.InputText:input_type -> sanderling.driver.v1.Text + 10, // 11: sanderling.driver.v1.Driver.Swipe:input_type -> sanderling.driver.v1.SwipeRequest + 11, // 12: sanderling.driver.v1.Driver.PressKey:input_type -> sanderling.driver.v1.PressKeyRequest + 2, // 13: sanderling.driver.v1.Driver.LongPress:input_type -> sanderling.driver.v1.Point + 0, // 14: sanderling.driver.v1.Driver.Screenshot:input_type -> sanderling.driver.v1.Empty + 0, // 15: sanderling.driver.v1.Driver.Hierarchy:input_type -> sanderling.driver.v1.Empty + 0, // 16: sanderling.driver.v1.Driver.Snapshot:input_type -> sanderling.driver.v1.Empty + 12, // 17: sanderling.driver.v1.Driver.RecentLogs:input_type -> sanderling.driver.v1.RecentLogsRequest + 8, // 18: sanderling.driver.v1.Driver.WaitForIdle:input_type -> sanderling.driver.v1.Duration + 0, // 19: sanderling.driver.v1.Driver.Health:input_type -> sanderling.driver.v1.Empty + 15, // 20: sanderling.driver.v1.Driver.Metrics:input_type -> sanderling.driver.v1.MetricsRequest + 0, // 21: sanderling.driver.v1.Driver.Launch:output_type -> sanderling.driver.v1.Empty + 0, // 22: sanderling.driver.v1.Driver.Terminate:output_type -> sanderling.driver.v1.Empty + 0, // 23: sanderling.driver.v1.Driver.Tap:output_type -> sanderling.driver.v1.Empty + 0, // 24: sanderling.driver.v1.Driver.TapSelector:output_type -> sanderling.driver.v1.Empty + 0, // 25: sanderling.driver.v1.Driver.InputText:output_type -> sanderling.driver.v1.Empty + 0, // 26: sanderling.driver.v1.Driver.Swipe:output_type -> sanderling.driver.v1.Empty + 0, // 27: sanderling.driver.v1.Driver.PressKey:output_type -> sanderling.driver.v1.Empty + 0, // 28: sanderling.driver.v1.Driver.LongPress:output_type -> sanderling.driver.v1.Empty + 5, // 29: sanderling.driver.v1.Driver.Screenshot:output_type -> sanderling.driver.v1.Image + 6, // 30: sanderling.driver.v1.Driver.Hierarchy:output_type -> sanderling.driver.v1.HierarchyJSON + 7, // 31: sanderling.driver.v1.Driver.Snapshot:output_type -> sanderling.driver.v1.SnapshotResponse + 13, // 32: sanderling.driver.v1.Driver.RecentLogs:output_type -> sanderling.driver.v1.LogEntries + 0, // 33: sanderling.driver.v1.Driver.WaitForIdle:output_type -> sanderling.driver.v1.Empty + 9, // 34: sanderling.driver.v1.Driver.Health:output_type -> sanderling.driver.v1.HealthStatus + 16, // 35: sanderling.driver.v1.Driver.Metrics:output_type -> sanderling.driver.v1.MetricsResponse + 21, // [21:36] is the sub-list for method output_type + 6, // [6:21] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_driverpb_driver_proto_init() } @@ -1000,7 +1066,7 @@ func file_driverpb_driver_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_driverpb_driver_proto_rawDesc), len(file_driverpb_driver_proto_rawDesc)), NumEnums: 0, - NumMessages: 17, + NumMessages: 18, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/driverpb/driver.proto b/proto/driverpb/driver.proto index 8fae2f9..d1a7918 100644 --- a/proto/driverpb/driver.proto +++ b/proto/driverpb/driver.proto @@ -15,9 +15,14 @@ service Driver { rpc InputText(Text) returns (Empty); rpc Swipe(SwipeRequest) returns (Empty); rpc PressKey(PressKeyRequest) returns (Empty); + rpc LongPress(Point) returns (Empty); rpc Screenshot(Empty) returns (Image); rpc Hierarchy(Empty) returns (HierarchyJSON); + // Snapshot captures hierarchy and screenshot back-to-back under a + // backend-side mutex so the pair describes the same on-device frame. + // Use this instead of racing Hierarchy and Screenshot from the runner. + rpc Snapshot(Empty) returns (SnapshotResponse); rpc RecentLogs(RecentLogsRequest) returns (LogEntries); rpc WaitForIdle(Duration) returns (Empty); @@ -56,6 +61,11 @@ message HierarchyJSON { string json = 1; } +message SnapshotResponse { + HierarchyJSON hierarchy = 1; + Image screenshot = 2; +} + message Duration { int64 millis = 1; } diff --git a/proto/driverpb/driver_grpc.pb.go b/proto/driverpb/driver_grpc.pb.go index 088da2d..09e4aad 100644 --- a/proto/driverpb/driver_grpc.pb.go +++ b/proto/driverpb/driver_grpc.pb.go @@ -26,8 +26,10 @@ const ( Driver_InputText_FullMethodName = "/sanderling.driver.v1.Driver/InputText" Driver_Swipe_FullMethodName = "/sanderling.driver.v1.Driver/Swipe" Driver_PressKey_FullMethodName = "/sanderling.driver.v1.Driver/PressKey" + Driver_LongPress_FullMethodName = "/sanderling.driver.v1.Driver/LongPress" Driver_Screenshot_FullMethodName = "/sanderling.driver.v1.Driver/Screenshot" Driver_Hierarchy_FullMethodName = "/sanderling.driver.v1.Driver/Hierarchy" + Driver_Snapshot_FullMethodName = "/sanderling.driver.v1.Driver/Snapshot" Driver_RecentLogs_FullMethodName = "/sanderling.driver.v1.Driver/RecentLogs" Driver_WaitForIdle_FullMethodName = "/sanderling.driver.v1.Driver/WaitForIdle" Driver_Health_FullMethodName = "/sanderling.driver.v1.Driver/Health" @@ -45,8 +47,13 @@ type DriverClient interface { InputText(ctx context.Context, in *Text, opts ...grpc.CallOption) (*Empty, error) Swipe(ctx context.Context, in *SwipeRequest, opts ...grpc.CallOption) (*Empty, error) PressKey(ctx context.Context, in *PressKeyRequest, opts ...grpc.CallOption) (*Empty, error) + LongPress(ctx context.Context, in *Point, opts ...grpc.CallOption) (*Empty, error) Screenshot(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Image, error) Hierarchy(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*HierarchyJSON, error) + // Snapshot captures hierarchy and screenshot back-to-back under a + // backend-side mutex so the pair describes the same on-device frame. + // Use this instead of racing Hierarchy and Screenshot from the runner. + Snapshot(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*SnapshotResponse, error) RecentLogs(ctx context.Context, in *RecentLogsRequest, opts ...grpc.CallOption) (*LogEntries, error) WaitForIdle(ctx context.Context, in *Duration, opts ...grpc.CallOption) (*Empty, error) Health(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*HealthStatus, error) @@ -131,6 +138,16 @@ func (c *driverClient) PressKey(ctx context.Context, in *PressKeyRequest, opts . return out, nil } +func (c *driverClient) LongPress(ctx context.Context, in *Point, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Empty) + err := c.cc.Invoke(ctx, Driver_LongPress_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *driverClient) Screenshot(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Image, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Image) @@ -151,6 +168,16 @@ func (c *driverClient) Hierarchy(ctx context.Context, in *Empty, opts ...grpc.Ca return out, nil } +func (c *driverClient) Snapshot(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*SnapshotResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SnapshotResponse) + err := c.cc.Invoke(ctx, Driver_Snapshot_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *driverClient) RecentLogs(ctx context.Context, in *RecentLogsRequest, opts ...grpc.CallOption) (*LogEntries, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(LogEntries) @@ -202,8 +229,13 @@ type DriverServer interface { InputText(context.Context, *Text) (*Empty, error) Swipe(context.Context, *SwipeRequest) (*Empty, error) PressKey(context.Context, *PressKeyRequest) (*Empty, error) + LongPress(context.Context, *Point) (*Empty, error) Screenshot(context.Context, *Empty) (*Image, error) Hierarchy(context.Context, *Empty) (*HierarchyJSON, error) + // Snapshot captures hierarchy and screenshot back-to-back under a + // backend-side mutex so the pair describes the same on-device frame. + // Use this instead of racing Hierarchy and Screenshot from the runner. + Snapshot(context.Context, *Empty) (*SnapshotResponse, error) RecentLogs(context.Context, *RecentLogsRequest) (*LogEntries, error) WaitForIdle(context.Context, *Duration) (*Empty, error) Health(context.Context, *Empty) (*HealthStatus, error) @@ -239,12 +271,18 @@ func (UnimplementedDriverServer) Swipe(context.Context, *SwipeRequest) (*Empty, func (UnimplementedDriverServer) PressKey(context.Context, *PressKeyRequest) (*Empty, error) { return nil, status.Error(codes.Unimplemented, "method PressKey not implemented") } +func (UnimplementedDriverServer) LongPress(context.Context, *Point) (*Empty, error) { + return nil, status.Error(codes.Unimplemented, "method LongPress not implemented") +} func (UnimplementedDriverServer) Screenshot(context.Context, *Empty) (*Image, error) { return nil, status.Error(codes.Unimplemented, "method Screenshot not implemented") } func (UnimplementedDriverServer) Hierarchy(context.Context, *Empty) (*HierarchyJSON, error) { return nil, status.Error(codes.Unimplemented, "method Hierarchy not implemented") } +func (UnimplementedDriverServer) Snapshot(context.Context, *Empty) (*SnapshotResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Snapshot not implemented") +} func (UnimplementedDriverServer) RecentLogs(context.Context, *RecentLogsRequest) (*LogEntries, error) { return nil, status.Error(codes.Unimplemented, "method RecentLogs not implemented") } @@ -404,6 +442,24 @@ func _Driver_PressKey_Handler(srv interface{}, ctx context.Context, dec func(int return interceptor(ctx, in, info, handler) } +func _Driver_LongPress_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Point) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DriverServer).LongPress(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Driver_LongPress_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DriverServer).LongPress(ctx, req.(*Point)) + } + return interceptor(ctx, in, info, handler) +} + func _Driver_Screenshot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(Empty) if err := dec(in); err != nil { @@ -440,6 +496,24 @@ func _Driver_Hierarchy_Handler(srv interface{}, ctx context.Context, dec func(in return interceptor(ctx, in, info, handler) } +func _Driver_Snapshot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DriverServer).Snapshot(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Driver_Snapshot_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DriverServer).Snapshot(ctx, req.(*Empty)) + } + return interceptor(ctx, in, info, handler) +} + func _Driver_RecentLogs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(RecentLogsRequest) if err := dec(in); err != nil { @@ -547,6 +621,10 @@ var Driver_ServiceDesc = grpc.ServiceDesc{ MethodName: "PressKey", Handler: _Driver_PressKey_Handler, }, + { + MethodName: "LongPress", + Handler: _Driver_LongPress_Handler, + }, { MethodName: "Screenshot", Handler: _Driver_Screenshot_Handler, @@ -555,6 +633,10 @@ var Driver_ServiceDesc = grpc.ServiceDesc{ MethodName: "Hierarchy", Handler: _Driver_Hierarchy_Handler, }, + { + MethodName: "Snapshot", + Handler: _Driver_Snapshot_Handler, + }, { MethodName: "RecentLogs", Handler: _Driver_RecentLogs_Handler, diff --git a/proto/driverpb/driver_test.go b/proto/driverpb/driver_test.go index 6ca1bd0..294e61f 100644 --- a/proto/driverpb/driver_test.go +++ b/proto/driverpb/driver_test.go @@ -20,8 +20,10 @@ func TestDriverServiceDescriptor(t *testing.T) { "InputText": true, "Swipe": true, "PressKey": true, + "LongPress": true, "Screenshot": true, "Hierarchy": true, + "Snapshot": true, "RecentLogs": true, "WaitForIdle": true, "Health": true, diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index 580e03a..a10e58c 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -8,12 +8,165 @@ interface DriverBackend { fun inputText(text: String) fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) fun pressKey(key: String) + fun longPress(x: Int, y: Int) fun screenshot(): Triple fun hierarchy(): String fun recentLogs(sinceUnixMillis: Long, minLevel: String): List fun waitForIdle(durationMillis: Long) fun healthy(): Boolean fun metrics(bundleId: String): MetricsSample + + // snapshot captures hierarchy then screenshot back-to-back. The service + // layer holds a mutex around the call so concurrent callers observe a + // serialized pair from the same on-device frame. Backends may override + // to fuse the two reads more tightly when their native API allows. + fun snapshot(): SnapshotSample = SnapshotSample(hierarchy(), screenshot()) +} + +data class SnapshotSample( + val hierarchyJson: String, + val screenshot: Triple, +) + +// STABILITY_POLL_INTERVAL_MILLIS is set wide enough that UiAutomation / +// Maestro's contentDescriptor doesn't get hammered: tighter intervals were +// observed to back the sidecar gRPC stream up under fuzz load to the point +// of triggering 120s inputText deadlines. +internal const val STABILITY_POLL_INTERVAL_MILLIS = 250L +internal const val STABILITY_POLL_CAP_MILLIS = 2000L + +// MIN_STABLE_STREAK_MILLIS is the duration the tree must stay structurally +// identical (and non-transitional) before the poll declares settle. Actions +// whose effect is async (e.g. tap-submit fires a DB write that later calls +// popBackStack) leave the UI momentarily stable before the navigation +// transition fires; requiring an uninterrupted streak of this length means +// any churn that starts during the window resets the clock instead of being +// missed. +internal const val MIN_STABLE_STREAK_MILLIS = 750L + +// pollUntilStable returns when the snapshot has been non-null and equal to +// itself for an uninterrupted stretch of at least MIN_STABLE_STREAK_MILLIS, +// capped at timeoutMillis. snapshot must omit transient attributes (e.g. +// measure-pass bounds) so layout-only flicker doesn't extend the wait, and +// must return null when the snapshot looks transitional (e.g. mid NavHost +// cross-fade) so the streak resets and the loop keeps polling instead of +// declaring a partial state stable. +internal fun pollUntilStable(timeoutMillis: Long, snapshot: () -> String?) { + if (timeoutMillis <= 0) return + val deadline = System.currentTimeMillis() + timeoutMillis + var prior = try { + snapshot() + } catch (_: Exception) { + null + } + var streakStart = 0L + while (System.currentTimeMillis() < deadline) { + Thread.sleep(STABILITY_POLL_INTERVAL_MILLIS) + val current = try { + snapshot() + } catch (_: Exception) { + null + } + val now = System.currentTimeMillis() + if (prior != null && current != null && prior == current) { + if (streakStart == 0L) streakStart = now + if (now - streakStart >= MIN_STABLE_STREAK_MILLIS) return + } else { + streakStart = 0L + } + prior = current + } +} + +// stabilitySnapshot probes the current hierarchy for both structural shape +// and route-transition state. Returns null while more than one route-level +// destination tag (resource-id/testTag/identifier ending in "Screen") is +// present, because NavHost keeps both source and destination composables +// alive during a cross-fade and a snapshot taken in that window is +// unreliable (lazy lists in the incoming screen mount over multiple frames). +internal fun stabilitySnapshot(treeJson: String): String? { + if (treeJson.isBlank()) return "" + if (countRouteScreens(treeJson) > 1) return null + return structuralHash(treeJson) +} + +private val ROUTE_TAG_KEYS = setOf( + "resource-id", "resourceId", "testTag", + "identifier", "accessibilityIdentifier", +) + +internal fun countRouteScreens(treeJson: String): Int { + if (treeJson.isBlank()) return 0 + return try { + val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() + val root = mapper.readTree(treeJson) + countRouteScreens(root) + } catch (_: Exception) { + 0 + } +} + +private fun countRouteScreens(node: com.fasterxml.jackson.databind.JsonNode): Int { + var count = 0 + val attributes = node.get("attributes") + if (attributes != null && attributes.isObject) { + for (key in ROUTE_TAG_KEYS) { + val value = attributes.get(key) ?: continue + if (value.isNull) continue + if (value.asText().endsWith("Screen")) { + count++ + break + } + } + } + val children = node.get("children") + if (children != null && children.isArray) { + for (child in children) count += countRouteScreens(child) + } + return count +} + +// structuralHash hashes a Maestro TreeNode-shaped JSON string by walking it +// and concatenating only the stable identity attributes (resource-id, class, +// content-desc, text), in tree order. The transient `bounds` and per-frame +// layout coordinates are excluded so a measure-pass that shifts pixels +// without changing what's on screen does not extend the wait. +internal fun structuralHash(treeJson: String): String { + if (treeJson.isBlank()) return "" + return try { + val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() + val root = mapper.readTree(treeJson) + val builder = StringBuilder() + walkForStructuralHash(root, builder) + builder.toString() + } catch (_: Exception) { + treeJson + } +} + +private val STABLE_ATTRIBUTE_KEYS = listOf( + "resource-id", "resourceId", + "class", "className", + "content-desc", "contentDescription", "accessibilityText", + "text", + "testTag", "identifier", "accessibilityIdentifier", +) + +private fun walkForStructuralHash(node: com.fasterxml.jackson.databind.JsonNode, out: StringBuilder) { + out.append('(') + val attributes = node.get("attributes") + if (attributes != null && attributes.isObject) { + for (key in STABLE_ATTRIBUTE_KEYS) { + val value = attributes.get(key) ?: continue + if (value.isNull) continue + out.append(key).append(':').append(value.asText()).append('|') + } + } + val children = node.get("children") + if (children != null && children.isArray) { + for (child in children) walkForStructuralHash(child, out) + } + out.append(')') } data class MetricsSample( @@ -119,7 +272,20 @@ private fun parseKb(line: String): Long? { return parts[1].toLongOrNull() } -class StubDriverBackend(private val platform: String) : DriverBackend { +private fun execAdb(arguments: List) { + try { + val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start() + command.inputStream.bufferedReader().readText() + command.waitFor() + } catch (cause: Exception) { + println("adb ${arguments.joinToString(" ")} failed: $cause") + } +} + +class StubDriverBackend( + private val platform: String, + private val commandRunner: (List) -> Unit = ::execAdb, +) : DriverBackend { @Volatile var launchCount: Int = 0 private set @Volatile var lastBundleId: String? = null @@ -157,16 +323,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend { return null } - internal const val MAX_CLEAR_DELETES: Int = 1024 - - internal fun buildClearKeyevents(textLength: Int): List { - if (textLength <= 0) return emptyList() - val deletes = minOf(textLength, MAX_CLEAR_DELETES) - val args = mutableListOf("shell", "input", "keyevent", "KEYCODE_MOVE_END") - repeat(deletes) { args.add("KEYCODE_DEL") } - return args - } - internal fun escapeForAdbInputText(text: String): String { val sb = StringBuilder(text.length) for (ch in text) { @@ -180,24 +336,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend { return sb.toString() } - private val FOCUSED_NODE = Regex( - "]*\\bfocused=\"true\"[^>]*)/?>", - ) - private val TEXT_ATTRIBUTE = Regex("\\btext=\"([^\"]*)\"") - - internal fun parseFocusedText(xml: String): String? { - val node = FOCUSED_NODE.find(xml) ?: return null - val match = TEXT_ATTRIBUTE.find(node.groupValues[1]) ?: return "" - return decodeXmlAttribute(match.groupValues[1]) - } - - private fun decodeXmlAttribute(value: String): String = value - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace(""", "\"") - .replace("'", "'") - internal val KEY_MAP: Map = mapOf( "back" to "KEYCODE_BACK", "home" to "KEYCODE_HOME", @@ -261,31 +399,15 @@ class StubDriverBackend(private val platform: String) : DriverBackend { override fun inputText(text: String) { lastInputText = text - clearFocusedField() runAdb(listOf("shell", "input", "text", escapeForAdbInputText(text))) } - private fun clearFocusedField() { - val current = focusedFieldText() ?: return - if (current.isEmpty()) return - runAdb(buildClearKeyevents(current.length)) - } - - private fun focusedFieldText(): String? { - val xml = try { - hierarchy() - } catch (cause: Exception) { - println("inputText: hierarchy dump failed: $cause") - return null - } - if (xml.isBlank() || xml == "") return null - return parseFocusedText(xml) - } - @Volatile var lastSwipe: SwipeRecord? = null private set @Volatile var lastKey: String? = null private set + @Volatile var lastLongPress: Pair? = null + private set override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) { lastSwipe = SwipeRecord(fromX, fromY, toX, toY, durationMillis) @@ -307,20 +429,17 @@ class StubDriverBackend(private val platform: String) : DriverBackend { runAdb(listOf("shell", "input", "keyevent", keyCode)) } + override fun longPress(x: Int, y: Int) { + lastLongPress = x to y + runAdb(listOf("shell", "input", "swipe", x.toString(), y.toString(), x.toString(), y.toString(), "600")) + } + override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List = readLogcat(null, sinceUnixMillis, minLevel) data class SwipeRecord(val fromX: Int, val fromY: Int, val toX: Int, val toY: Int, val durationMillis: Long) - private fun runAdb(arguments: List) { - try { - val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start() - command.inputStream.bufferedReader().readText() - command.waitFor() - } catch (cause: Exception) { - println("adb ${arguments.joinToString(" ")} failed: $cause") - } - } + private fun runAdb(arguments: List) = commandRunner(arguments) override fun screenshot(): Triple { return try { @@ -354,12 +473,18 @@ class StubDriverBackend(private val platform: String) : DriverBackend { } override fun waitForIdle(durationMillis: Long) { + // Two-stage settle: an mAnimating poll catches View-system animations, + // then a short structural-hash poll catches Compose cross-fades where + // two composables are simultaneously alive but mAnimating is already + // false. The structural poll is hard-capped so we don't hammer the + // device with repeat hierarchy fetches when the UI never stabilizes. if (durationMillis <= 0) return - val deadline = System.currentTimeMillis() + durationMillis - while (System.currentTimeMillis() < deadline) { - if (isDeviceIdle()) return + val animationDeadline = System.currentTimeMillis() + durationMillis + while (System.currentTimeMillis() < animationDeadline) { + if (isDeviceIdle()) break Thread.sleep(IDLE_POLL_INTERVAL_MILLIS) } + pollUntilStable(STABILITY_POLL_CAP_MILLIS) { stabilitySnapshot(hierarchy()) } } private fun isDeviceIdle(): Boolean { @@ -396,6 +521,8 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend { override fun tap(x: Int, y: Int) = driver.tap(maestro.Point(x, y)) + override fun longPress(x: Int, y: Int) = driver.longPress(maestro.Point(x, y)) + override fun tapSelector(selector: String) { val root = driver.contentDescriptor(false) val bounds = findBoundsBySelector(root, selector) ?: return @@ -427,7 +554,20 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend { readLogcat(serial, sinceUnixMillis, minLevel) override fun waitForIdle(durationMillis: Long) { + // waitForAppToSettle returns early on Compose cross-fade transitions + // where both source and destination composables are semantically + // alive; a follow-up short structural-hash poll lands on a single + // stable frame before the runner reads hierarchy + screenshot + // concurrently. The structural poll is hard-capped independently of + // durationMillis so we don't pile on hierarchy fetches when settle + // never converges. driver.waitForAppToSettle(null, null, durationMillis.toInt()) + pollUntilStable(STABILITY_POLL_CAP_MILLIS) { + stabilitySnapshot( + com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() + .writeValueAsString(driver.contentDescriptor(false)), + ) + } } override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false } @@ -580,6 +720,8 @@ class IosDriverBackend(private val udid: String) : DriverBackend { override fun tap(x: Int, y: Int) = withReconnect { driver.tap(maestro.Point(x, y)) } + override fun longPress(x: Int, y: Int) = withReconnect { driver.longPress(maestro.Point(x, y)) } + override fun tapSelector(selector: String) = withReconnect { val root = driver.contentDescriptor(false) val bounds = findBoundsBySelector(root, selector) ?: return@withReconnect @@ -615,6 +757,12 @@ class IosDriverBackend(private val udid: String) : DriverBackend { override fun waitForIdle(durationMillis: Long) = withReconnect { driver.waitForAppToSettle(null, null, durationMillis.toInt()) + pollUntilStable(STABILITY_POLL_CAP_MILLIS) { + stabilitySnapshot( + com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() + .writeValueAsString(driver.contentDescriptor(false)), + ) + } Unit } diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt index 30f4989..e2e86b7 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt @@ -16,6 +16,7 @@ import dev.sanderling.driver.v1.Point import dev.sanderling.driver.v1.PressKeyRequest import dev.sanderling.driver.v1.RecentLogsRequest import dev.sanderling.driver.v1.Selector +import dev.sanderling.driver.v1.SnapshotResponse import dev.sanderling.driver.v1.SwipeRequest import dev.sanderling.driver.v1.Text import io.grpc.stub.StreamObserver @@ -27,6 +28,7 @@ class DriverService( ) : DriverGrpc.DriverImplBase() { private val launchedBundleId = AtomicReference(null) + private val snapshotLock = Any() override fun launch(request: LaunchRequest, responseObserver: StreamObserver) { runRpc(responseObserver) { @@ -51,6 +53,13 @@ class DriverService( } } + override fun longPress(request: Point, responseObserver: StreamObserver) { + runRpc(responseObserver) { + backend.longPress(request.x, request.y) + Empty.getDefaultInstance() + } + } + override fun tapSelector(request: Selector, responseObserver: StreamObserver) { runRpc(responseObserver) { backend.tapSelector(request.value) @@ -116,6 +125,23 @@ class DriverService( } } + override fun snapshot(request: Empty, responseObserver: StreamObserver) { + runRpc(responseObserver) { + val sample = synchronized(snapshotLock) { backend.snapshot() } + val (png, width, height) = sample.screenshot + SnapshotResponse.newBuilder() + .setHierarchy(HierarchyJSON.newBuilder().setJson(sample.hierarchyJson).build()) + .setScreenshot( + Image.newBuilder() + .setPng(ByteString.copyFrom(png)) + .setWidth(width) + .setHeight(height) + .build(), + ) + .build() + } + } + override fun waitForIdle(request: Duration, responseObserver: StreamObserver) { runRpc(responseObserver) { backend.waitForIdle(request.millis) diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt index 03faf9e..36e92e4 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt @@ -61,6 +61,19 @@ class DriverServiceTest { assertEquals(100 to 250, backend.lastTap) } + @Test fun longPressForwardsCoordinates() { + var observed: Pair? = null + val backend = object : DriverBackend by StubDriverBackend("android") { + override fun longPress(x: Int, y: Int) { + observed = x to y + } + } + val client = newClient(backend) + + client.longPress(Point.newBuilder().setX(400).setY(900).build()) + assertEquals(400 to 900, observed) + } + @Test fun inputTextForwardsValue() { val backend = StubDriverBackend("android") val client = newClient(backend) diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextClearTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextClearTest.kt deleted file mode 100644 index 5efbce3..0000000 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextClearTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package dev.sanderling.sidecar - -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class InputTextClearTest { - - @Test fun buildClearKeyeventsEmptyWhenNothingToDelete() { - assertEquals(emptyList(), StubDriverBackend.buildClearKeyevents(0)) - assertEquals(emptyList(), StubDriverBackend.buildClearKeyevents(-3)) - } - - @Test fun buildClearKeyeventsPrefixesMoveEndThenOneDelPerChar() { - val args = StubDriverBackend.buildClearKeyevents(3) - assertEquals(listOf("shell", "input", "keyevent", "KEYCODE_MOVE_END", - "KEYCODE_DEL", "KEYCODE_DEL", "KEYCODE_DEL"), args) - } - - @Test fun buildClearKeyeventsCapsAtMaxClearDeletes() { - val huge = StubDriverBackend.MAX_CLEAR_DELETES * 10 - val args = StubDriverBackend.buildClearKeyevents(huge) - val deletes = args.count { it == "KEYCODE_DEL" } - assertEquals(StubDriverBackend.MAX_CLEAR_DELETES, deletes) - assertEquals("KEYCODE_MOVE_END", args[3]) - } - - @Test fun escapeForAdbInputTextSubstitutesSpaces() { - assertEquals("hello%sworld", StubDriverBackend.escapeForAdbInputText("hello world")) - } - - @Test fun escapeForAdbInputTextEscapesShellMetacharacters() { - val escaped = StubDriverBackend.escapeForAdbInputText("a&b|c;d\$e`f") - assertEquals("a\\&b\\|c\\;d\\\$e\\`f", escaped) - } - - @Test fun escapeForAdbInputTextEscapesQuotesAndBackslash() { - assertEquals("\\'", StubDriverBackend.escapeForAdbInputText("'")) - assertEquals("\\\"", StubDriverBackend.escapeForAdbInputText("\"")) - assertEquals("\\\\", StubDriverBackend.escapeForAdbInputText("\\")) - } - - @Test fun escapeForAdbInputTextLeavesSimpleTextAlone() { - assertEquals("12.34", StubDriverBackend.escapeForAdbInputText("12.34")) - assertEquals("Coffee", StubDriverBackend.escapeForAdbInputText("Coffee")) - assertTrue("-5" == StubDriverBackend.escapeForAdbInputText("-5")) - } - - - @Test fun parsesTextFromFocusedNode() { - val xml = """ - - - - - """.trimIndent() - - assertEquals("old@value", StubDriverBackend.parseFocusedText(xml)) - } - - @Test fun returnsEmptyWhenFocusedNodeHasNoTextAttribute() { - val xml = """""" - - assertEquals("", StubDriverBackend.parseFocusedText(xml)) - } - - @Test fun returnsNullWhenNoFocusedNode() { - val xml = """""" - - assertNull(StubDriverBackend.parseFocusedText(xml)) - } - - @Test fun decodesXmlEntitiesInAttribute() { - val xml = """""" - - assertEquals("a&b - - - - """.trimIndent() - - assertEquals("first", StubDriverBackend.parseFocusedText(xml)) - } -} diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt new file mode 100644 index 0000000..a9b2409 --- /dev/null +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt @@ -0,0 +1,38 @@ +package dev.sanderling.sidecar + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class InputTextTest { + + @Test fun inputTextTypesAtCursorWithoutClearing() { + val commands = mutableListOf>() + val backend = StubDriverBackend("android") { commands.add(it) } + + backend.inputText("Emergency Fund") + + assertEquals(listOf(listOf("shell", "input", "text", "Emergency%sFund")), commands) + } + + @Test fun escapeForAdbInputTextSubstitutesSpaces() { + assertEquals("hello%sworld", StubDriverBackend.escapeForAdbInputText("hello world")) + } + + @Test fun escapeForAdbInputTextEscapesShellMetacharacters() { + val escaped = StubDriverBackend.escapeForAdbInputText("a&b|c;d\$e`f") + assertEquals("a\\&b\\|c\\;d\\\$e\\`f", escaped) + } + + @Test fun escapeForAdbInputTextEscapesQuotesAndBackslash() { + assertEquals("\\'", StubDriverBackend.escapeForAdbInputText("'")) + assertEquals("\\\"", StubDriverBackend.escapeForAdbInputText("\"")) + assertEquals("\\\\", StubDriverBackend.escapeForAdbInputText("\\")) + } + + @Test fun escapeForAdbInputTextLeavesSimpleTextAlone() { + assertEquals("12.34", StubDriverBackend.escapeForAdbInputText("12.34")) + assertEquals("Coffee", StubDriverBackend.escapeForAdbInputText("Coffee")) + assertTrue("-5" == StubDriverBackend.escapeForAdbInputText("-5")) + } +} diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/SnapshotHandlerTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/SnapshotHandlerTest.kt new file mode 100644 index 0000000..417896d --- /dev/null +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/SnapshotHandlerTest.kt @@ -0,0 +1,135 @@ +package dev.sanderling.sidecar + +import dev.sanderling.driver.v1.DriverGrpc +import dev.sanderling.driver.v1.Empty +import io.grpc.ManagedChannel +import io.grpc.inprocess.InProcessChannelBuilder +import io.grpc.inprocess.InProcessServerBuilder +import io.grpc.testing.GrpcCleanupRule +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.locks.ReentrantLock +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SnapshotHandlerTest { + + @get:Rule val grpcCleanup: GrpcCleanupRule = GrpcCleanupRule() + + private fun newClient(backend: DriverBackend): DriverGrpc.DriverBlockingStub { + val serverName = InProcessServerBuilder.generateName() + val service = DriverService(platform = "android", backend = backend) + grpcCleanup.register( + InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start(), + ) + val channel: ManagedChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(serverName).directExecutor().build(), + ) + return DriverGrpc.newBlockingStub(channel) + } + + @Test fun snapshotReturnsBothHierarchyAndScreenshot() { + // The default snapshot() impl calls hierarchy() then screenshot() on + // the same backend instance; using interface delegation here would + // forward those calls to the delegate, not these overrides. Override + // snapshot() directly so the test exercises the wire path end-to-end. + val backend = object : DriverBackend by StubDriverBackend("android") { + override fun snapshot(): SnapshotSample = + SnapshotSample("{\"x\":1}", Triple(byteArrayOf(7, 8, 9), 1080, 2340)) + } + val client = newClient(backend) + + val response = client.snapshot(Empty.getDefaultInstance()) + assertEquals("{\"x\":1}", response.hierarchy.json) + assertEquals(1080, response.screenshot.width) + assertEquals(2340, response.screenshot.height) + assertEquals(3, response.screenshot.png.size()) + } + + @Test fun defaultSnapshotPairsHierarchyAndScreenshotSequentially() { + // Covers the DriverBackend default impl: hierarchy() runs before + // screenshot() so a transitional re-fetch keeps the screenshot + // aligned with the final hierarchy snapshot the runner accepts. + val callOrder = mutableListOf() + val backend = object : DriverBackend { + override fun launch(bundleId: String, clearState: Boolean, env: Map) {} + override fun terminate(bundleId: String) {} + override fun tap(x: Int, y: Int) {} + override fun tapSelector(selector: String) {} + override fun inputText(text: String) {} + override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) {} + override fun pressKey(key: String) {} + override fun longPress(x: Int, y: Int) {} + override fun screenshot(): Triple { + callOrder.add("screenshot") + return Triple(byteArrayOf(1), 0, 0) + } + override fun hierarchy(): String { + callOrder.add("hierarchy") + return "{}" + } + override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List = emptyList() + override fun waitForIdle(durationMillis: Long) {} + override fun healthy(): Boolean = true + override fun metrics(bundleId: String): MetricsSample = MetricsSample(0.0, 0L, 0L) + } + backend.snapshot() + assertEquals(listOf("hierarchy", "screenshot"), callOrder) + } + + @Test fun concurrentSnapshotsAreSerialized() { + // The recording backend asserts no overlap: a sleep-bearing snapshot() + // makes any race observable as inFlight > 1. + val inFlight = AtomicInteger(0) + val maxObserved = AtomicInteger(0) + val callCount = AtomicInteger(0) + val lock = ReentrantLock() + val recordingBackend = object : DriverBackend by StubDriverBackend("android") { + override fun snapshot(): SnapshotSample { + val now = inFlight.incrementAndGet() + try { + lock.lock() + try { + if (now > maxObserved.get()) maxObserved.set(now) + } finally { + lock.unlock() + } + Thread.sleep(50) + callCount.incrementAndGet() + return SnapshotSample("{}", Triple(ByteArray(0), 0, 0)) + } finally { + inFlight.decrementAndGet() + } + } + } + // Use a real (multi-threaded) executor on the server side so the service + // is not artificially serialized by directExecutor. + val serverName = InProcessServerBuilder.generateName() + val service = DriverService(platform = "android", backend = recordingBackend) + grpcCleanup.register( + InProcessServerBuilder.forName(serverName).addService(service).build().start(), + ) + val channel: ManagedChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(serverName).build(), + ) + val stub = DriverGrpc.newBlockingStub(channel) + + val pool = Executors.newFixedThreadPool(4) + try { + val tasks = (1..4).map { + pool.submit { stub.snapshot(Empty.getDefaultInstance()) } + } + for (task in tasks) task.get(5, TimeUnit.SECONDS) + } finally { + pool.shutdownNow() + } + assertEquals(4, callCount.get()) + assertTrue( + maxObserved.get() <= 1, + "snapshot calls overlapped (maxObserved=${maxObserved.get()}); the service lock did not serialize them", + ) + } +} diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt new file mode 100644 index 0000000..7e38534 --- /dev/null +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt @@ -0,0 +1,149 @@ +package dev.sanderling.sidecar + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class StabilityPollTest { + @Test fun returnsAfterUninterruptedStableStreak() { + val start = System.currentTimeMillis() + pollUntilStable(3000L) { "stable" } + val elapsed = System.currentTimeMillis() - start + assertTrue( + elapsed >= MIN_STABLE_STREAK_MILLIS, + "must observe a stable streak of at least ${MIN_STABLE_STREAK_MILLIS}ms, elapsed=${elapsed}ms", + ) + assertTrue(elapsed < 3000L, "should not run to cap when stable, elapsed=${elapsed}ms") + } + + @Test fun streakResetsOnAnyChange() { + // A late transition that fires after the prior streak has already + // begun must reset the clock: the post-transition stable window has + // to start over and meet MIN_STABLE_STREAK_MILLIS from scratch. + var calls = 0 + val start = System.currentTimeMillis() + // First 4 samples are "calm", then 1 transient change, then "stable" + // forever - the calm prefix is meaningless because of the transition. + pollUntilStable(3000L) { + calls++ + when { + calls <= 4 -> "calm" + calls == 5 -> "transient" + else -> "stable" + } + } + val elapsed = System.currentTimeMillis() - start + assertTrue( + elapsed >= MIN_STABLE_STREAK_MILLIS, + "post-transition streak must reach ${MIN_STABLE_STREAK_MILLIS}ms, elapsed=${elapsed}ms", + ) + assertTrue(calls >= 10, "expected enough samples to span calm + transient + stable streak, got $calls") + } + + @Test fun transitionalNullsForceLoopToKeepWaiting() { + // Simulates a NavHost cross-fade where stabilitySnapshot returns null + // for several samples (multi-screen detected) before the destination + // screen settles. Streak must not start while null returns persist. + var calls = 0 + val transitionalCount = 5 + pollUntilStable(3000L) { + calls++ + if (calls <= transitionalCount) null else "stable" + } + assertTrue( + calls >= transitionalCount + 2, + "must consume the null prefix before starting the streak, calls=$calls", + ) + } + + @Test fun stopsAtDeadlineWhenNeverStable() { + var calls = 0 + val budget = 240L + val start = System.currentTimeMillis() + pollUntilStable(budget) { + calls++ + "frame-$calls" + } + val elapsed = System.currentTimeMillis() - start + assertTrue(elapsed in budget..(budget + 200L), "expected to hit cap, elapsed=$elapsed") + } + + @Test fun zeroBudgetReturnsImmediately() { + var calls = 0 + pollUntilStable(0L) { + calls++ + "x" + } + assertEquals(0, calls) + } + + @Test fun structuralHashIgnoresBoundsAndIdenticalForSemanticallyEqualTrees() { + val a = """ + {"attributes":{"resource-id":"LoginScreen","bounds":"[0,0,1080,2340]"}, + "children":[ + {"attributes":{"resource-id":"LoginEmail","bounds":"[10,10,1070,100]","text":"a@b"},"children":[]} + ]} + """.trimIndent() + val b = """ + {"attributes":{"resource-id":"LoginScreen","bounds":"[0,0,1080,2342]"}, + "children":[ + {"attributes":{"resource-id":"LoginEmail","bounds":"[10,11,1070,101]","text":"a@b"},"children":[]} + ]} + """.trimIndent() + assertEquals(structuralHash(a), structuralHash(b), "bounds-only flicker must not change hash") + } + + @Test fun structuralHashDiffersWhenContentChanges() { + val a = """{"attributes":{"resource-id":"LoginEmail","text":"a@b"},"children":[]}""" + val b = """{"attributes":{"resource-id":"LoginEmail","text":"c@d"},"children":[]}""" + assertTrue(structuralHash(a) != structuralHash(b), "text change must alter hash") + } + + @Test fun stabilitySnapshotReturnsNullDuringNavHostCrossFade() { + val midTransition = """ + {"attributes":{"resource-id":"root"}, + "children":[ + {"attributes":{"resource-id":"AddAccountScreen"},"children":[]}, + {"attributes":{"resource-id":"HomeScreen"},"children":[ + {"attributes":{"resource-id":"AccountCard","text":"a"},"children":[]} + ]} + ]} + """.trimIndent() + assertEquals(null, stabilitySnapshot(midTransition)) + } + + @Test fun stabilitySnapshotReturnsHashOnSingleScreen() { + val singleScreen = """ + {"attributes":{"resource-id":"HomeScreen"}, + "children":[ + {"attributes":{"resource-id":"AccountCard","text":"a"},"children":[]} + ]} + """.trimIndent() + val hash = stabilitySnapshot(singleScreen) + assertTrue(hash != null && hash.isNotBlank(), "single-screen tree must yield a hash, got $hash") + } + + @Test fun stabilitySnapshotIgnoresNonRouteAttributeValues() { + // Random text content ending in "Screen" must not trip the detector; + // only route-level attribute keys (resource-id, testTag, identifier) + // are considered for the route-tag count. + val tree = """ + {"attributes":{"resource-id":"HomeScreen"}, + "children":[ + {"attributes":{"text":"Welcome to MyScreen"},"children":[]} + ]} + """.trimIndent() + assertTrue(stabilitySnapshot(tree) != null, "non-route attribute must not be counted as a screen") + } + + @Test fun countRouteScreensCountsTestTagAndIdentifier() { + val tree = """ + {"attributes":{"resource-id":"root"}, + "children":[ + {"attributes":{"testTag":"HomeScreen"},"children":[]}, + {"attributes":{"accessibilityIdentifier":"LedgerScreen"},"children":[]} + ]} + """.trimIndent() + assertEquals(2, countRouteScreens(tree)) + } +}