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)) + } +}