GuideUI automation testsSpatial selectorsAccessibility tree

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.

T
Terminator
10 min read
4.9from Open-source, MIT
5 spatial selector variants: RightOf, LeftOf, Above, Below, Near
const NEAR_THRESHOLD: f64 = 50.0 (engine.rs line 1815)
24 total selector variants in selector.rs, composable with && || !
Works against Windows UIA and macOS AX, not just the browser DOM

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.

crates/terminator/src/selector.rs
0Spatial selector variants
0Total selector variants
0Near radius (pixels)
0Candidate search depth

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.

Selector::*
rightof:
leftof:
above:
below:
near: (50px)

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.

crates/terminator/src/platforms/windows/engine.rs

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

rightof:name:Email
Accessibility tree
Visible subtree
Geometry filter
Email input
.first() result
UI test assertion

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.

terminator locate rightof:name:Email

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.

crates/terminator/src/selector.rs

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. 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. 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. 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. 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. 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");
});
-21% fewer lines

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)
MIT

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.

FeatureWeb-only runners (Playwright, Selenium, Cypress)Terminator spatial selectors
Locate an input by the label next to itHand-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 appsNo. 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: / NearPlaywright 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 RightOfNot 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 logicNot 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 grepClosed-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.

Windows UIAmacOS Accessibility APIGoogle ChromeMicrosoft EdgeFirefoxElectron appsSlackMicrosoft WordExcelOutlookGmailLinearNotionVS CodeFigma desktopSpotifyDiscord

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

0px

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.

terminatorDesktop automation SDK
© 2026 terminator. All rights reserved.