UI automation tests that ask what is right of the Email label
Most UI automation tests are written around brittle anchors: autogenerated IDs, CSS paths that assume a specific DOM layout, XPath axes that break the moment a designer moves a field. Terminator takes a different path. It exposes five spatial selectors, one for each geometric relationship, and resolves them against the operating system accessibility tree. You stop writing locators that describe where an element was on Tuesday and start writing locators that describe what it sits next to.
The five variants, straight from the enum
Every selector expression Terminator accepts comes from one Rust enum with twenty-four variants. Five of those describe spatial relationships. Each wraps a Box<Selector> that identifies the anchor element the candidate is compared against. The recursion is what lets rightof:above:name:Header compose without any special parser case.
One selector enum, five geometric orbits
The spatial selectors are peers. None is a subclass of another. You pick the one that matches the visual relationship you are describing, the parser wraps it, and the engine resolves it the same way every time.
Before and after: the same test, two selector strategies
The before side is what the top SERP results recommend by default: a Page Object with a hand-maintained CSS or ID selector, plus a self-healing fallback bolted on later. The after side is how the same test reads when the anchor is the label a human wrote, not a path through the DOM.
Anchor the locator to something humans wrote
Locate the email input by its CSS path or autogenerated ID. Hope the form layout, the id hash, and the class naming all stay stable through the next redesign, CMS migration, and A/B test.
- Selector breaks when the form goes to a two-column layout
- Selector breaks when ids are regenerated per deploy
- Selector breaks when a designer adds a wrapper div
- Self-healing runs after failure, not before it
The geometry, in twenty-six lines of Rust
This is the only code that decides whether a candidate matches. It is pure math on the bounds rectangles, no heuristics, no scoring. Notice two things: the directional selectors require a perpendicular overlap (vertical for left/right, horizontal for above/below), and Near uses plain Euclidean distance against a hard-coded 50 pixel threshold.
From selector string to matched element
Three inputs, one filter, three outputs. Every spatial query follows the same pipeline: parse the string into an AST, walk the accessibility tree for visible candidates, then run the geometry match. Nothing else is stateful.
One filter, three clean outputs
What a single spatial resolve actually logs
Shape of the trace from a real RightOf resolve against a Chrome login form. The interesting numbers are the ones in brackets: anchor bounds, the 137 initial candidates, and the 8 survivors after the y-overlap and left-edge filters run.
The string form, parsed one prefix at a time
The string prefix is the only thing the test writer types. Each prefix strips itself off and recurses into Selector::from(inner_selector_str) on whatever is left. That is why rightof:role:Button && name:Submit parses cleanly: after the rightof: prefix is stripped, the remaining string goes back through the same parser and resolves to a boolean AND.
One card per spatial variant, one geometry sentence each
Every rule below is a single match arm. No fuzzy logic, no ranking. The predicate either fires or it does not.
rightof: / RightOf
Matches candidates whose left edge is at or past the anchor's right edge, and whose y-range overlaps the anchor's y-range. Typical use: find the input field next to a label.
leftof: / LeftOf
Mirror of RightOf: candidate_right <= anchor_left with the same vertical_overlap check. Useful for finding the checkbox to the left of its description, or the row-expand caret beside an item.
above: / Above
candidate_bottom <= anchor_top with horizontal_overlap (candidate_left < anchor_right && candidate_right > anchor_left). Good for finding the header that sits on top of a button or a grid column title above a cell.
below: / Below
candidate_top >= anchor_bottom with horizontal_overlap. Good for finding the error message rendered below an input, or the first row of a table under its header.
near: / Near
Euclidean distance between bounds centers, compared against const NEAR_THRESHOLD: f64 = 50.0 (engine.rs line 1815). The only spatial selector that is direction-agnostic; it matches regardless of which side of the anchor the candidate sits on, within 50 pixels.
Recursive inner selectors
Every spatial variant is RightOf(Box<Selector>), LeftOf(Box<Selector>), and so on. The inner selector can be any Selector: Role, Text, Has, And, Or, Not, even another spatial wrapper. rightof:above:name:Header is a legal, parseable selector.
How a spatial selector resolves, in five steps
The same five steps run for every spatial variant. Only the final predicate (which lives inside step 4) differs.
- 1
Parse the selector string
Lowercase the prefix, slice off 'rightof:', recursively parse the inner string into its own Selector. The wrapper becomes Selector::RightOf(Box<inner>).
- 2
Resolve the anchor
Run find_element on the inner selector. It must return exactly one element. The anchor's bounds (x, y, width, height) are the geometry budget for the next step.
- 3
Collect candidates
Run find_elements with Selector::Visible(true), depth 100, 500ms timeout. This broad sweep returns every visible UI element inside the same root subtree the anchor came from.
- 4
Filter by geometry
For each candidate, compute anchor_left/top/right/bottom and candidate_left/top/right/bottom. Check vertical_overlap or horizontal_overlap, then apply the directional inequality. For Near, compute (dx*dx + dy*dy).sqrt() and compare to 50.0.
- 5
Return the surviving set
Hand back the filtered Vec<UIElement>. .first() picks one, .all() keeps all. The test code stays unchanged whether the UI is re-laid-out, translated, or rendered on a different monitor.
The same test, written with two different anchors
// The fragile test that every top SERP article recommends.
// Works on the day it is written. Rots the moment the DOM is reshuffled.
import { test } from "@playwright/test";
test("login fills email", async ({ page }) => {
await page.goto("/login");
// Page Object with a hand-maintained CSS selector. Breaks when
// the form gets a two-column layout, or an A/B test reorders fields,
// or a designer renames the input to "work-email".
await page
.locator("#signup-form > div:nth-child(2) > input.email-field")
.fill("test@example.com");
// The ID-based fallback. Breaks when the team ships a CMS-driven
// form where input ids are regenerated on every deploy.
await page.locator("#email_0xBADF00D").fill("test@example.com");
});Candidates the geometry filter rejects
Everything the filter throws away. For a typical Chrome form the initial candidate sweep returns around 130 visible elements; a single RightOf call usually ends with fewer than ten survivors.
Filter rejections, per candidate
- candidate_left < anchor_right: the candidate starts before the anchor ends
- candidate_top >= anchor_bottom: the candidate is below the anchor's y-range
- candidate_bottom <= anchor_top: the candidate is above the anchor's y-range
- candidate is the anchor itself (filtered by id comparison)
- candidate has no resolvable bounds (offscreen, detached, or aria-hidden)
- candidate is not visible (Selector::Visible(true) gate runs first)
“Every line number, variant name, and constant on this page is grep-able in a fresh clone of mediar-ai/terminator. Five enum variants in selector.rs, five parser arms, one twenty-six-line match block in engine.rs. No self-healing model, no probabilistic fallback.”
github.com/mediar-ai/terminator
Spatial selectors vs the shapes other UI automation tests use
The SERP for this keyword is saturated with web-only, ID-first advice. This is where Terminator fits in the landscape.
| Feature | Web-only runners (Playwright, Selenium, Cypress) | Terminator spatial selectors |
|---|---|---|
| Locate an input by the label next to it | Hand-maintained CSS selector, XPath with sibling axes, or a :has() workaround. Rewrites every layout change. | rightof:name:Email. One selector, reads like English, resolves against the accessibility tree. |
| Works on native desktop apps | No. Playwright, Selenium, and Cypress all stop at the browser DOM. | Yes. The same selector grammar resolves against Windows UIA and the macOS Accessibility API. |
| Behavior of near: / Near | Playwright has locator.near(locator) with a configurable maxDistance in pixels (Chromium only, experimental). | const NEAR_THRESHOLD: f64 = 50.0 in engine.rs, Euclidean center-to-center distance, grep-able and forkable. |
| Vertical overlap requirement for RightOf | Not modeled. A CSS sibling selector does not care whether the sibling visually overlaps in y. | Enforced: candidate_top < anchor_bottom && candidate_bottom > anchor_top. Prevents matching a button three rows down. |
| Combining spatial with boolean logic | Not supported. You compose XPath or use locator.filter() chains, which do not express 'not to the right of X'. | rightof:name:Email && !name:Password resolves cleanly. The selector parser also handles ||, parentheses, and 'near:' wrapping an 'and' expression. |
| Source you can grep | Closed-source for commercial runners, spread across repos and language bindings for open-source ones. | Five enum variants in selector.rs lines 32-41, five parser arms lines 419-438, one match block in engine.rs lines 1801-1826. |
Same selector grammar, every app the OS can describe
The selector parser and the geometry filter are platform-neutral. The accessibility adapter is not. Terminator ships one for Windows UI Automation and one for the macOS Accessibility API, so the same rightof:name:Email expression works in Gmail, in the native Mail app, in Slack, and in a pure Win32 form.
Want a UI automation test suite that survives a redesign?
Book a 30 minute call to walk through your current test flake and see how rightof:, leftof:, above:, below:, and near: land on your app.
Frequently asked questions
What do the top search results for 'ui automation tests' actually cover?
They converge on web-first advice: adopt Page Object Model, prefer Playwright or Cypress, add AI-powered self-healing locators, run in CI/CD, keep UI tests at 5 to 10 percent of the suite. All of that is reasonable. None of it explains what to do when the UI is not a browser or when an ID-less layout is specifically reshuffled by the designer every sprint. Spatial selectors are the uncovered move.
What exactly are Terminator's five spatial selectors?
RightOf, LeftOf, Above, Below, and Near. All five are enum variants in crates/terminator/src/selector.rs lines 32 through 41. Each wraps a Box<Selector> that identifies the visual anchor. String form for a test writer is rightof:, leftof:, above:, below:, near: followed by any other selector expression.
How is 'near' defined in pixels?
const NEAR_THRESHOLD: f64 = 50.0 on line 1815 of crates/terminator/src/platforms/windows/engine.rs. The check computes the Euclidean distance between the anchor's center and the candidate's center, using sqrt(dx*dx + dy*dy), then accepts the candidate only if that distance is strictly less than 50.0 pixels. It is a hard constant today. Forking the crate to tune it is a one-line change.
Does RightOf really match anything to the right of the anchor?
No, and that is important. The predicate is candidate_left >= anchor_right AND vertical_overlap, where vertical_overlap means the candidate's y-range overlaps the anchor's y-range. A button three rows down and to the right does not match; the bounds fail the y-overlap test. This is what makes 'the input right of the Email label' reliably pick the input on the same row, not the 'Forgot password' link below it.
Do these selectors work on native desktop apps or only on browsers?
Both. Terminator is a cross-platform desktop automation framework; the selector engine resolves against Windows UI Automation and the macOS Accessibility API, not just the browser DOM. The same rightof:name:Email expression that finds an input next to a Gmail label also finds the Format menu to the right of File in Microsoft Word, or the 'Send' button to the right of the composer in Slack.
Can spatial selectors be combined with boolean logic?
Yes. The selector parser handles &&, ||, ! and parentheses across all 24 selector variants. rightof:name:Email && role:Edit && visible:true is a single parsed Selector::And containing a RightOf, a Role, and a Visible. The tokenizer lives in selector.rs starting at the tokenize function; the shunting-yard style parser assembles the AST before find_element ever runs.
How does this compare to Playwright's locator.near()?
Playwright has an experimental locator.near(locator, options) API that filters by distance. It only runs inside Chromium, only against the DOM, and it does not expose LeftOf, RightOf, Above, or Below as first-class primitives; you emulate them by combining near with bounding-box math. Terminator exposes all five as explicit variants at the selector-grammar level, works against both browser DOM and native accessibility trees, and the 50-pixel constant is in one grep-able file instead of an internal Chromium protocol.
What are the search depth and timeout values for the broad candidate sweep?
Depth 100, timeout 500 milliseconds. engine.rs calls find_elements with Selector::Visible(true), Some(Duration::from_millis(500)), Some(100). The 500ms matters: spatial resolution is meant to be fast, not exhaustive. If an anchor is ambiguous or absent, the call fails quickly rather than hanging waiting for an element that will never appear.
What happens if I write rightof:above:name:Header? Is that valid?
Yes. The inner selector parameter of Selector::RightOf is Box<Selector>, so it recursively parses anything, including another spatial wrapper. The resolution runs bottom-up: first Above resolves name:Header to find the header, then everything above it; then RightOf takes one of those as the anchor. You can nest as deep as you want. The limit is practical, not syntactic.
Where do I see the full list of all 24 selector variants?
crates/terminator/src/selector.rs, the Selector enum starting on line 5. Role, Id, Name, Text, Path, NativeId, Attributes, Filter, Chain, ClassName, Visible, LocalizedRole, Process, the five spatial variants, Nth, Has, Parent, And, Or, Not, Invalid, and a numbered variant for localized roles. Spatial selectors are five of the twenty-four.
Near threshold
The Euclidean radius inside Selector::Near, compared against the center-to-center distance between anchor and candidate bounds. One line. One constant. engine.rs:1815.