leonard_native 0.6.1
leonard_native: ^0.6.1 copied to clipboard
A pure-Dart Leonard contract extension that perceives and drives a native mobile app over the OS accessibility tree (via Appium/XCUITest): a stateful, self-watching extension projecting the a11y tree [...]
0.6.1 #
Stable promotion of 0.6.1-dev.1: the same content, published on the stable rung on Nico's promotion call (2026-10-21), after the Pixel 7a and iPad mini live probes of both fixes passed. The prerelease entry below carries the details.
0.6.1-dev.1 #
Two fixes for backend-direct consumers of the Appium backends.
close()closes only a client the backend created (lenny#198). SincebackendForPlatform(client:)forwards a caller'shttp.Client(#187),XcuiTestBackend,UiAutomator2BackendandMac2Backendclosed it onclose(), so a caller that owns one client per session lost it after a best-effort close (for example after a failedconnect), and its next request failed withClient is already closed. A caller-supplied client now stays open and stays the caller's to close;close()still deletes the session over it. A backend built withoutclientcreates its own and closes it, as before.- An empty find value is a miss, not a
StateError(lenny#200). A find answering{"value": {}}madeXcuiTestBackendandUiAutomator2BackendthrowStateErrorout ofresolveandisPresent, past callers that catchNativeException. It now reads as no element, asMac2Backendalready did:resolvefalls through to its next tier andisPresentanswers false.
0.6.0 #
Stable promotion of 0.6.0-dev.1: the same content, published on the stable rung on Nico's promotion call (2026-10-20), after the iOS 27.0 simulator, Pixel 7a and iPad mini (iOS 26.6) live probes passed. The prerelease entry below carries the full list, including the breaking NativeBackend additions.
0.6.0-dev.1 #
Fills the gaps that kept a backend-direct iOS consumer on its own XCUITest transport (lenny#180 to #193). Verified live on an iOS 27.0 simulator (XCUITest driver 12.18.1) and a Pixel 7a (UiAutomator2 driver 8.2.2).
- Breaking for implementers —
NativeBackendgains methods and parameters. A class thatimplements NativeBackend, or overridesFakeNativeBackend.resolveorenterText, must add them. Callers are unaffected, except that Android's alert keys no longer throw (below).alertText()returns the open system alert's text, or null when none is open, so a caller can choose betweenconsent_acceptandalert_dismissinstead of answering blind (#180). iOS and Android readGET /session/{id}/alert/textand map W3Cno such alertto null; macOS throws aNativeExceptioncodedunsupported operation, because a macOS alert is an ordinary window in the snapshot.activateApp(appId)brings an installed app to the foreground, launching it if needed, for returning to the app under test after an out-of-app hop (#182). It issuesmobile: activateApp(iOS, Android) ormacos: activateApp.resolve(..., findTimeout:)sets each tier's find retry window, defaultNativeBackend.defaultFindTimeout(10 s).Duration.zeromakes one find request per populated tier, for presence probes and callers that poll in their own loop (#183). Measured on a miss: 42 ms on iOS and 190 ms on Android, against about 10.2 s at the default.
XcuiTestBackend.attachsendsappium:autoLaunch: false, asUiAutomator2Backend.attachalready did, so attaching no longer activates or launches the app (#181). A backgrounded app now stays in the background; callactivateAppto bring it forward. Migration: code that relied on attach bringing the app to the front callsactivateApp(appId)afterconnect().XcuiTestBackend.attach's running check now accepts a suspended background app (queryAppState2), which is what a backgrounded app reports once attach stops activating it; only 0 (not installed) and 1 (not running) fail, and the error names both causes of a 1: the app was never started, or the Xcode 27 DeviceHub reboot killed it.- Transport failures no longer leak grid credentials (#186). A request
that gets no HTTP response used to escape as
package:http'sClientException, whose message carries the full request URI, so a cloud grid'suser:key@access key reached any log that printed it. All three backends now throw aNativeExceptioncodedNativeException.transportErrorCodewith URL userinfo masked (https://***@host), and redact W3C error messages and quoted response bodies the same way. A transport failure is still not retried as a miss:resolvefails at once, as before. - A failed
connectreleases the session it opened (#185). When the attach check (or the iOS context switch) throws after session create, the backend now deletes the session and clears its state before rethrowing, so an Android session (newCommandTimeout: 0) no longer holds the device until Appium restarts, and a retriedconnectopens a fresh one. A transport failure on the attach probe now failsconnectinstead of passing as an unknown app state. backendForPlatform(client:)forwards anhttp.Clientto the backend it picks (#187);NativeLaunchandNativeAttachgain theclientparameter both constructors already took.- More
NativeBackendmembers for backend-direct consumers (#188 to #193), breaking for implementers like the ones above:isPresent(selector): one live find per populated tier, no retry and no rect fallback, so a gone element cannot read as present. A zero-findTimeoutresolvecan: it still returns rect-center from the selector's rect or a cached label match (review of #183). The label tier is a live query on the platform's label attributes. Measured live on a miss: 116 ms on iOS, 145 ms on Android.acceptAlertIfPresent()/dismissAlertIfPresent()answer whether an alert was handled instead of throwing on W3Cno such alert(#192);NativeException.noSuchAlertCodenames that code for the throwing form.- Android now answers system alerts:
press('consent_accept')/press('alert_dismiss')and the if-present forms post the W3C alert endpoint, refusing a runtime permission dialog (where accept grants and dismiss denies) asdismiss_overlaydoes, and answering nothing when the source cannot be read. They used to throw as iOS-only. Thepresstool's description says so. readAttribute(target, name)reads a resolved element's attribute in the driver's own names (#191).rawSource()returns the platform source document unparsed (#190).enterText(..., dismissKeyboard: false)leaves the keyboard up (#193).
NativeNode.parentIdlinks each node to its nearest kept ancestor, so the filtered nodes rebuild into a tree (#189). In-memory only, likedepth.- iOS and macOS nodes carry the
disabledstate forenabled="false", as Android nodes already did (#190). This reaches the model's observation. sessionIdonXcuiTestBackend,UiAutomator2BackendandMac2Backend(#188).FakeNativeBackendscriptsisPresent(presence, defaulting to a match against the snapshot payload), the alert answers,attributesandrawSourcePayload, and records eachenterText'sdismissKeyboard.FakeNativeBackendscriptsalertTextthroughalertTextPayload, recordsalertTextandactivateAppcalls, and records each resolve'sfindTimeoutinresolveFindTimeouts.
0.5.0 #
Stable promotion of 0.5.0-dev.1: the same content, published on the stable rung on Nico's promotion call (2026-10-15), after the Android (Pixel 7a), iOS 26.5 simulator and macOS live suites passed. The prerelease entries below carry the full list.
0.5.0-dev.1 #
- Breaking — the mobile backends' constructors now mean the same lifecycle
on iOS and Android (lenny#168). The unnamed constructor installs and
launches
app;.attachdrives an already-runningappId. Both tear off to the new shared typesNativeLaunchandNativeAttach.UiAutomator2Backend(udid:, app:)now installs and launchesapp(an.apkpath or a cloud-grid id such aslt://APP123), with optionalappPackage/appActivity. It previously attached to the foreground app and ignoredapp. Migration: code that relied on attach — anything driving besideflutter run— callsUiAutomator2Backend.attach(udid:, appId:)with the app's package.UiAutomator2Backend.attachchecks the app is running after session create (mobile: queryAppState) and throws aNativeExceptionwhen it is not.XcuiTestBackend.attach(bundleId:)is nowXcuiTestBackend.attach(appId:), and thebundleIdfield isappId.XcuiTestBackend'sosVersion(default'26', never sent to Appium) is nowplatformVersion, matching Android: it is sent asappium:platformVersionwhen set. The keyboard "Done" handling reads the version Appium reports for the session, and treats an unreported version as iOS 26.backendForPlatformtakesapp(launch) orappId(attach) — exactly one on a mobile platform — andappIdreplacesbundleIdfordarwin.leonard_native_hosttakes--app(launch) or--app-id(attach);--bundle-idstays as an alias of--app-id.
- Breaking — attach-critical capabilities are refused inside vendor option
maps too.
connect(extraCapabilities:)already refusedapp,appPackage,autoLaunch,noResetand the rest at the top level; it now also refuses them nested inside any*:optionsmap (lt:options,bstack:options, …), reported as<vendor>:options.<key>. Nestingappthere was never a supported way to install an app: use the launch constructor. - Fixed: every Appium request keeps the server URL's base path. A hub such as
https://…@mobile-hub.lambdatest.com/wd/hublost its/wd/hubprefix, so every request, including session creation, returned 404 (lenny#148, #151). - Added:
Mac2Backend, a macOS backend over Appium mac2 that attaches to a running app by bundle id (--platform darwin) (#150). - Added:
NativeNodecarries the raw platform element type and its tree depth (#152). - Fixed: an iOS attach fails loudly when the XCUITest driver rebooted the simulator on session create (Xcode 27 DeviceHub with driver 11.x or older) instead of leaving the session on the home screen (lenny#161, #165).
- Build cached native accessibility observations with canonical Genesis
Componenttypes. Appium polling and action refreshes remain outside build. - Require
genesis_perception ^0.4.0,leonard_contract ^0.3.0-dev.1, andleonard_host ^0.2.4-dev.1.
0.4.1 #
- Android UiAutomator2
checked,focused,disabledandobscuredattributes now project onto the existing native node state field, so the Android and iOS state vocabularies agree. Actions and iOS behavior are unchanged. - Raise the
leonard_contractfloor to^0.2.2. The declared^0.2.0floor was unsatisfiable —leonard_contract0.2.0 requiresgenesis_perception ^0.1.3, which this package's own>=0.2.0 <0.4.0range forbids — so a resolver silently heldleonard_contractback to 0.2.1 instead of taking 0.2.2. - Raise the
leonard_hostfloor to^0.2.2for the same reason.
0.4.0 #
- Breaking —
ObstructionResourceIdPolicyentry fields are orderedList<String>values instead ofSet<String>values. Constructor parameters now accept anyIterable<String>, so existing set-literal construction remains source-compatible; consumers that assigned a public entry field to aSet<String>must read it as aList<String>or call.toSet(). Matching now honors policy order rather than XML node order. Android permission defaults prefer the legacy allow button, then foreground location, then one-time location; denial prefers ordinary denial over deny-and-don't-ask-again. Missing-button errors list every attempted entry.
0.3.1 #
- Fixed: the
resourceIdtier can now resolve Chrome web-content fields (bare HTML ids). UiAutomator2'susing=idlocator only consults real view resource names (pkg:id/name); theresource-idyou see on a web<input>is synthesized from its HTMLidwhen the tree is serialized, so it was present in/sourceyet invisible to the live selector — and sinceresourceIdis tier 1 and often the only populated tier, the miss consumed the full retry window and read as a device timeout rather than a wrong locator. On ausing=idmiss, a BARE value (no:id/) now falls through to//*[@resource-id='…']over the serialized tree and reportsvia: 'resource-id-xpath'; a missedpkg:id/nameis a genuine miss and does not fall through. Verified live against real Chrome on device (SM-M225FV) alongside an unchanged native-id resolution. Reported on https://github.com/memento-engineering/lenny/issues/51.
0.3.0 #
-
Breaking —
NativeBackend.connect()takesextraCapabilities. The abstract contract is nowFuture<void> connect({Map<String, Object?> extraCapabilities = const {}})on bothUiAutomator2BackendandXcuiTestBackend: the map is merged over the orthogonal Appium defaults when a new session is created, and attach-critical capabilities are rejected withArgumentError. Migration: an externalNativeBackendimplementer adds the named parameter (a delegating implementation only changes its signature); existing call sites are source-compatible. -
Breaking —
XcuiTestBackendgains attach-by-bundle-id and stops forcingappium:app.XcuiTestBackend.attach(udid: …, bundleId: …)attaches to an already-running app instead of always launching from an.apppath; the default constructor keeps launch mode. Migration: code that relied onappium:appbeing unconditionally present must use launch mode explicitly. -
NativeSelectorgains aresourceIdtier — on Android it resolves first (resource-idis the strongest handle the platform offers); on iOS, which has no analogue, the tier is skipped. Additive. -
NativeSelector.flutterIdentifier('x')selects a FlutterSemantics(identifier:)widget through the Android channel. It expands to//*[@resource-id='x']/ancestor-or-self::*[@clickable="true"][1], which is correct on BOTH measured projection shapes — Pixel 7a / Android 16 puts the identifier on a non-clickable wrapper, an SM-M225FV / Android 13 projects it clickable-self; both captures are recorded in the README so neither gets hardcoded. Additive. -
Android permission dialogs are a first-class obstruction.
press('dismiss_overlay')REFUSES them, because Android Back denies the permission — denial persistently changes application behaviour, and granting was not requested either. Consumers state intent explicitly with the newpermission_allow/permission_denypress keys. Behaviour change with additive API; proven against a live device (android_permission_dialog_proof.dart). -
Docs — CORRECTION to 0.2.2's migration note, which was wrong for one consumer shape. 0.2.2 said, unqualified: "Remove consumer-owned Touch-To-Fill dismissal logic: it is redundant." That holds only for consumers driving the Leonard tool surface, where
enter_textowns recovery. A consumer holding aNativeBackenddirectly gets only a throw:UiAutomator2Backend.enterTextdetects the obstruction and raisesNativeExceptionwithNativeException.fieldObscuredCode, and never recovers — at that seam it holds an already-resolvedNativeTargetand cannot re-resolve the handle that dismissal invalidates. Recovery lives in_EnterTextTool, the layer that holds the selector. If you deleted your dismissal on 0.2.2's advice and drive the backend directly, reinstate it — the recipe is now in the dartdoc onNativeException.fieldObscuredCodeand in the README. Your reinstated version should be strictly better than what you deleted: branch onfieldObscuredCoderather than message text, and dismiss viapress('dismiss_overlay'), which is positively gated inside the backend, so the destructive bare-backhazard is handled upstream instead of in each consumer's gating discipline. No runtime behaviour changed in this release. Reported on https://github.com/memento-engineering/lenny/issues/26. -
NativeException.elementGoneAfterDismissalCode— a documented code for "the obstruction cleared, but the element is gone". The recovery recipe threw that outcome as a bare, code-lessNativeException, which pushed consumers who had just been taught to branch oncodeback to string-matching the message. Additive; nothing existing changes. -
Docs — the recovery recipe now preserves the original cause when dismissal fails. As first drafted, the recipe left
press('dismiss_overlay')unwrapped, so a failed dismissal replaced the obstruction error. That diverged from_EnterTextTool, which deliberately keeps the original. It matters becausedismiss_overlayis positively gated and throwsno dismissible platform overlay is presentwhen the overlay has already cleared — reachable as a race, since Chrome dismisses the sheet on its own timers and the sheet is once-per-page rather than once-per-field. The unwrapped form therefore reported broken-looking recovery instead of the real cause, precisely in the case where the cause is hardest to guess.overlay_recovery_recipe_test.dartnow pins this alongside the step order. Reported on https://github.com/memento-engineering/lenny/pull/32. -
Docs —
NativeBackend.resolvedocuments its internal retry. The cost is per populated tier, not per call: a tier whose selector field is null is skipped outright, and each tier that does issue an element find carries its own ~10 s retry window. So an xpath-only selector that matches nothing costs ~10 s, while one carrying an a11y-id, a label and an xpath that all miss costs ~30 s inside a single call — the worst case. Tier 2 only issues a find when the cached snapshot already has a label match, and tier 4 (rect-center) makes no device round-trip at all. An outer retry loop multiplies against whichever figure applies — 20 attempts against a fully-missing selector is ~10 minutes, which presents as a hang rather than a failure. Callers polling for a condition should detect it directly rather than discover it through a resolve failure, and keep outer counts small.
0.2.2 #
-
Behaviour change — Chrome credential sheets auto-recover:
enter_textnow detects a positively identified Chrome bottom sheet after an obstructed write, dismisses it, re-resolves the invalidated element handle, and retries once. Remove consumer-owned Touch-To-Fill dismissal logic: it is redundant. Any transitional consumer dismissal must remain positively gated because a second bare Androidbacknavigates the Custom Tab away. If detection or dismissal fails, the existing cause-bearing error remains the final result. The detection probe itself fails safe toward NOT acting: an unreadable or malformed/sourcereads as "nothing obstructing", so an obstructed write still falls through to the pre-existing click +mobile: typefallback rather than aborting on a transient read. -
Fix (destructive):
UiAutomator2Backend's keyboard dismiss no longer fires a barePOST /back. It now probesappium/device/is_keyboard_shownfirst and presses nothing when no soft keyboard is up.backis Android's dismiss gesture only while a keyboard holds focus; with none up it is plain navigation, and sincesetValue/mobile: typedo not raise the soft keyboard, the old unconditional press navigated a Chrome Custom Tab AWAY at the end of every successfulenterText— stranding the flow, because later field lookups then 404 against a page that is no longer showing. If the keyboard state cannot be read, nothing is pressed: a keyboard left up is recoverable, a spurious back is not. This brings Android in line with the iOS impl, which already probed for aDonekey before clicking. -
NativeNodegainsresourceId, the Androidresource-id(null on iOS, where XCUITest has no analogue — itsidentifieris already carried bya11yId). It is the only stable, non-localising way for a Dart consumer to identify a platform overlay: Chrome's Touch-To-Fill sheet is matched bytouch_to_fill_sheet_title/bottom_sheet, while its title text, content-desc and per-row summary all localise, so string matching breaks on a non-English device. Deliberately NOT added totoRecord(): that record is the canonical cross-host schema and stays byte-identical toleonard_flutter's semantics fragment, which has no resource-id to emit. The model addresses nodes byidentifier/label, so the wire shape is unchanged and no snapshot expectations move. -
Fix:
UiAutomator2Backend.enterTextnow types into Chrome web-content inputs. UiAutomator2 implementsPOST /element/{id}/valueasAccessibilityNodeInfo.performAction(ACTION_SET_TEXT), which Chrome's web<input>nodes refuse withinvalid element state— so any consumer driving an OAuth/Auth0 login through a Chrome Custom Tab was blocked at the first text field. On that errorenterTextfalls back toclick+mobile: type, which injects key events. (/keysis NOT usable as the fallback: for arbitrary text it shares the same ACTION_SET_TEXT handler and fails identically.) -
The fallback verifies
attribute/focusedafter its click and throws with the cause named rather than typing into the void.mobile: typereports HTTP 200 even when the element cannot receive input, and for a MASKED field an empty readback is indistinguishable from a correct write, so an obstructed field would otherwise surface as a silently empty password. The known obstruction is Chrome's Touch-To-Fill sheet, which both refusesACTION_SET_TEXTand swallows injected keystrokes — while it is up, NO write path succeeds, so the sheet must be dismissed beforeenterTextcan work at all. -
Behaviour change, relevant if you have failure-triggered recovery: an
enterTextthat previously FAILED on a Chrome web input now succeeds, so recovery paths keyed on that failure will stop firing. Specifically, the click that focuses the field can raise Chrome's Touch-To-Fill "Use saved password?" sheet, and because the write now succeeds that sheet can survive into the next step and obscure a following submit tap. Dismiss it on its own signal rather than on anenterTexterror. -
NativeExceptiongains an optionalcodecarrying the W3C WebDriver error code (invalid element state,no such element, ...) when the failure came from a remote error body;nullfor transport and decode failures. Both backends populate it.messageis unchanged and still carries the code as a prefix, so anything reading the message is unaffected.
0.2.0 #
- Breaking: the iOS
AppiumBackendpublic API is renamed toXcuiTestBackend, and itsplatform:constructor parameter is removed. - Migration: replace
AppiumBackend(..., platform: 'ios')withXcuiTestBackend(...). - Breaking: VM-service methods now use the unified
ext.leonard.*namespace supplied byleonard_contract.
0.1.2 #
- Android support:
UiAutomator2Backenddrives a native Android app over a local Appium server running the UiAutomator2 driver — the additive sibling of the iOSAppiumBackend. It shares the generic W3C transport (session, element find, the 4-tier resolve chain, pointer-action tap/swipe) and keeps every Android divergence inside the impl: a<hierarchy>/sourceparser, content-desc as the tier-1 identifier, resource-id-or-positional xpath synthesis, attribute/password-derived masking with attribute/text readback, and a press vocabulary wherebackis recognized and the iOS-only alert keys throw. - New
backendForPlatform(...)— the one place that maps a platform string to aNativeBackend, so the wiring is testable without a device. An unrecognized platform throws rather than silently falling back to iOS (which would parse an Android tree with the XCUITest parser and observe nothing). bin/leonard_native_host.dartselects throughbackendForPlatform, so--platform androidnow works; an unsupported platform is an exit-64 usage error rather than an uncaught throw.
0.1.1 #
- The per-node record now surfaces the OS accessibility identifier under the
canonical
identifierkey — the same stable, locale-proof addressing key as Flutter'sSemantics(identifier:)— for cross-host record parity with the Flutter semantics fragment. Previouslya11yIdwas selector-internal and never wired to the observation.
0.1.0 — initial native host #
- Initial
leonard_nativepackage:NativeExtension(a stateful, self-watchingLeonardExtension) projects the OS accessibility tree into a genesis_perception fragment and exposestap/enter_text/press/swipetools. - The
NativeBackendseam keeps all device I/O behind a synchronousbuildPerception().AppiumBackenddrives a local Appium server over W3C WebDriver against an iOS simulator (XCUITest);FakeNativeBackendis the shipped test impl. - Standalone VM-service host runner at
bin/leonard_native_host.dart.