--- title: Spec language reference --- # Spec language reference Lookup reference for everything importable from `@sanderling/spec`. For a worked example, read the [case study](../case-study/) first. ## Module structure A spec is a TypeScript module evaluated by the Go runner each step. It exports `properties` and `actionsRoot`, plus an optional `setup` and `generator`: ```ts import { ... } from "@sanderling/spec"; export const properties = { ... }; export const actionsRoot = weighted(...); export const setup = login; // optional export const generator = llm(...); // optional, see below ``` `setup` is an `ActionGenerator` the runner consults before `actionsRoot` each step. While it returns actions, they run; when it returns an empty list, the runner falls through to `actionsRoot`. Use it for preconditions like login and onboarding. If the app later regresses across the precondition (a logout mid-run), `setup` re-engages on its own. ## State Every extractor callback receives a `State`: ```ts interface State { ax: AccessibilityTree; snapshots: Record; lastAction: (Action & { applied: true | null }) | null; logs: readonly LogEntry[]; exceptions: readonly ExceptionRecord[]; time: number; // ms since run start } ``` | Field | Description | |---|---| | `ax` | Live UI hierarchy for this step | | `snapshots` | Key-value data pushed by the app SDK (empty if SDK not integrated) | | `lastAction` | The action dispatched in the previous step, or `null` on the first step and on any step that dispatched nothing | | `logs` | Log entries collected since the previous step | | `exceptions` | Uncaught exceptions and unhandled promise rejections captured in the page. Web only: nothing fills this on Android or iOS, where it is always empty | | `time` | Milliseconds elapsed since the run started | `lastAction.applied` is `true` when the runner saw the dispatch succeed and `null` when the apply call failed with the action possibly already delivered: an RPC deadline can fire after the tap reached the app, and nothing can find out afterwards. So there are three states, not two. `state.lastAction === null` means no action ran; `applied === null` means one ran whose fate is unknown. A property that attributes an effect to the action ("this submit must move the balance by the typed amount") has to decline unless `applied` is `true`, or a timeout convicts a healthy app. A property that counts what the app COULD have done should include it: an unconfirmed submit belongs in an upper bound on how many submits a window holds. `lastAction.text` carries the value as the record renders it, not the value the app received. A typed value reads `[redacted]` wherever the target may be a credential entry, which on iOS and web means the fields that report themselves secure and on Android means all of them, since Android reports nothing either way. A spec extracting `state.lastAction` writes it to the trace, so it is redacted for the same reason the trace action is; see [typed values in the record](../runs/#typed-values-in-the-record). A property that has to reason about what a field holds should read the field off `ax`. ## Selectors Selectors are passed to `ax.find()`, `ax.findAll()`, and element-scoped `.find()` / `.findAll()`. A tree-level lookup scans the whole hierarchy, the root element included; an element-scoped one scans that element's descendants. Both selector forms scan the same set, so `ax.find("id:page")` and `ax.find({ id: "page" })` return the same element. ### String selectors | Form | Matches | |---|---| | `id:` | Exact match on resource-id, or element whose resource-id ends with `:id/` (Android) | | `idPrefix:` | Starts-with match on resource-id, matched against the whole id and against the local name after `:id/` (Android) | | `text:` | Substring match on text content, innermost match only | | `desc:` | Exact match on accessibility description; also matches when description starts with `, ` (iOS merged labels) | | `descPrefix:` | Starts-with match on accessibility description | | `tag:` | Exact match on the element's tag name (web) | | `:` | Substring match on any raw attribute by name | Boolean attributes (`"true"` / `"false"`) use exact match rather than substring. `text:` names the innermost match. An element's text is its whole subtree's text on web and on iOS, so a badge reading "Sent ✓" makes every ancestor of it read as a match too, up to the root. A match a descendant of it also makes is dropped, which leaves the deepest element carrying the value: `ax.find("text:Sent")` lands on the badge, and `ax.findAll("text:Sent")` returns the badges without their ancestors. An ancestor whose own text carries the value where no descendant of it does keeps its match. `{ text: "Sent" }` means the same thing. ### Object selectors Pass an object to apply multiple attribute filters with AND semantics: ```ts s.ax.find({ accessibilityText: "LoginScreen" }) s.ax.find({ testTag: "AccountCard", clickable: true }) ``` Every key-value pair must match. A key means the same thing here as in the string form: `id`, `desc`, `idPrefix`, `descPrefix` and `tag` keep their matching rules, and every other key is an attribute name, with substring and boolean rules per attribute. Known attribute names are typed; you get autocomplete on `testTag`, `text`, `content-desc`, the boolean states (`clickable`, `enabled`, `focused`, `checked`, `selected`, `secure`), and the cross-platform aliases (`identifier`, `accessibilityIdentifier`, `accessibilityText`, `accessibilityLabel`, `label`, `resource-id`, `class`, `elementType`, `package`, `placeholderValue`, `hintText`). Boolean state attributes accept a native `true` / `false`. Other attribute keys still type-check as a string-valued fallback so raw driver attributes remain reachable. A boolean state matches only where the platform reports it. `{secure: true}` names the password entry and `{secure: false}` names an editable field that is not one, so neither value names an element that is no field at all, and neither matches anything on Android, which reports the fact for nothing. A key that names neither an accepted selector key nor an attribute some element on screen carries fails the run, naming the key and the accepted list. Such a key can never match, and an empty result is indistinguishable from a screen with no matching element: the generator declines to act, the runner waits out the step, and the run ends clean having explored nothing. The string form keeps its open kind space, since `:` is the documented way to reach a raw driver attribute. ### Path selectors An array of object selectors matches a path: each segment is matched within the subtree of the previous match. Arrays work on the tree root and on element-scoped `.find`/`.findAll`. ```ts s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginEmail" }]) s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]) ``` String selectors chain the same way with ` > `, but only on the tree root (`ax.find`, `ax.findAll`): ```ts s.ax.find("id:HomeScreen > descPrefix:account_card:") s.ax.find("id:LedgerScreen > desc:ledger_balance_display") ``` ### Cross-platform aliases These key aliases are resolved automatically so selectors work across platforms without changes: | Write this | Also checks | |---|---| | `content-desc` | `accessibilityText` | | `accessibilityText` | `content-desc` | | `label` | `accessibilityText` | | `accessibilityLabel` | `accessibilityText` | | `identifier` | `resource-id` | | `accessibilityIdentifier` | `resource-id` | ## AccessibilityElement fields Fields available on every element returned by `find` / `findAll`: | Field | Type | Description | |---|---|---| | `id` | `string` | resource-id (Android) or accessibility identifier (iOS) | | `text` | `string` | Visible text content | | `desc` | `string` | Accessibility description (`content-desc` / `accessibilityText`) | | `class` | `string` | View class (Android), element type (iOS), or HTML tag (web) | | `clickable` | `boolean` | Element is interactive | | `enabled` | `boolean` | Element is enabled | | `checked` | `boolean` | Checkbox or toggle state | | `focused` | `boolean` | Element has input focus | | `selected` | `boolean` | Selection state | | `secure` | `boolean \| null` | Field masks what is typed into it; `null` where the platform does not report it (Android never does) | | `bounds` | `{ left, top, right, bottom }` | Bounding box in device pixels | | `x` | `number` | Center X (derived from bounds) | | `y` | `number` | Center Y (derived from bounds) | | `attrs` | `Record` | All raw attributes from the driver | ## Platform notes ### Android - `id` maps to the Android resource-id (e.g., `com.example:id/button`). The `id:` selector matches by suffix after `:id/`, so `id:button` matches `com.example:id/button`. - `desc` maps to `content-desc`. - `class` is the Java view class name (e.g., `android.widget.TextView`). - `attrs` contains raw UIAutomator attributes: `package`, `scrollable`, `checkable`, etc. ### iOS - `id` maps to the `accessibilityIdentifier` set via `.accessibilityIdentifier` in SwiftUI/UIKit. - `desc` maps to `accessibilityText`, which the iOS sidecar builds by merging `accessibilityLabel` and the element's value (e.g., `"Close, icon description"`). The `desc:` selector handles this by also matching when the description starts with `, `. - `class` is the XCUITest element type (e.g., `XCUIElementTypeButton`). - `attrs` contains raw XCUITest attributes: `title`, `placeholderValue`, `hasFocus`, etc. ### Web (Chrome) - `id` maps to the HTML `id` attribute. - `desc` is derived from `aria-label`, `alt`, or `title`. - `class` is the lowercase HTML tag name (e.g., `button`, `input`). - `attrs` contains all HTML attributes available to CDP, keyed by the name the markup writes (`attrs["data-cents"]`, not `attrs.cents`). - `secure` is `type="password"` on a field the driver calls editable, which is what `{secure: true}` and `{secure: false}` resolve against here. - `attrs.hintText` names an editable field the way a user reads it: its `aria-label`, the `