A UI automation test you can write as a boolean expression

Most runners want one string, one id, one XPath. Terminator parses selectors as a three-precedence boolean expression with nested parentheses, flattened AND/OR, and spatial operators inside the grammar. One line can locate a control in Chrome, Excel, or Slack.

M
Matthew Diakonov
9 min read
4.9from developers shipping desktop automation
Shunting Yard parser, three precedences (OR=1, AND=2, NOT=3)
AND and OR are always kept flat by apply_operator
Twelve atomic selector prefixes, all composable
MIT-licensed source, 753 lines, crates/terminator/src/selector.rs

The SERP gap: selectors are not flat strings

Read the top results for "ui automation test" and you will see the same essay repeated: what UI automation is, why it matters, a list of tools. What none of them show is the thing that actually breaks tests in production. A real button on a real screen has more than one attribute you care about. It has a role, a name, a visibility flag, maybe a process scope, maybe a locale-specific label. The moment you try to match more than one of those with a single runner, you fall off the happy path and start chaining imperative locators or writing JavaScript predicates. That is where flake enters.

Terminator took the opposite route. Selectors are an expression grammar. You can write role:Button && (name:OK || name:Confirm) && !visible:false directly in the locator string and the engine will parse it, flatten it, and evaluate it as a single predicate. The rest of this page shows exactly how.

The anchor fact

Everything on this page traces back to one file: crates/terminator/src/selector.rs. It is 753 lines long. The first interesting bit is the Selector enum at line 5: about twenty variants, from Role and Name through the spatial family (RightOf, LeftOf, Above, Below, Near) to three boolean nodes (And(Vec), Or(Vec), Not(Box)). The fact that And and Or hold a vector, not a pair, is the whole trick.

crates/terminator/src/selector.rs
0Lines in selector.rs
0Atomic selector prefixes
0Operator precedences
0Pass per element

How the parser turns your string into a predicate

The pipeline has four stages. Each one maps to a specific function in selector.rs. Read them in order and you have the whole engine.

1

Tokenize

The input role:Button && (name:OK || name:Confirm) is walked character by character. Anything outside the operators && || ! ( ) becomes a Token::Selector. Strings inside text: are passed through verbatim so locale-aware names still work.

See tokenize() starting at line 94 of selector.rs.

2

Shunt

Each token is routed: selectors go to the output queue, operators go to the operator stack. When an incoming operator has lower or equal precedence than the one on top of the stack, the top one pops and applies to the queue. Parentheses do their usual grouping job.

parse_boolean_expression() at line 215, three precedences only: OR=1, AND=2, NOT=3.

3

Apply and flatten

When AND pops, apply_operator checks if either operand is already an And(vec) and, if so, appends into the existing vector. OR is flattened the same way. NOT wraps one operand. You end up with one top-level Selector node whose children are flat.

apply_operator() at line 275. The Selector enum variants And(Vec<Selector>) and Or(Vec<Selector>) are always kept flat.

4

Evaluate once per element

When the locator walks the accessibility tree, it runs the flattened predicate against each candidate. For And(vec) every atom must return true. For Or(vec) any atom can. For Not(inner) the inner is inverted. One descent through the tree, one pass through the vector, no recursion into a binary AST.

Why flattening matters

A naive boolean parser builds a binary tree: every AND has a left and a right, and long expressions become deeply nested. Terminator does not. When apply_operator sees an AND whose left operand is already Selector::And(vec), it appends the new right operand into that vec. The resulting tree stays shallow. Evaluation against an element is a single loop over the vector, not a recursion through a binary tree.

apply_operator collapses nested AND/OR

// Selector::And nested as a binary tree
And(
  Box(a),
  Box(And(
    Box(b),
    Box(And(
      Box(c),
      Box(d)
    ))
  ))
)
// evaluate(elem) recurses 4 levels deep
75% fewer lines

Where each piece lives

If you want to read the code yourself, here is the route from raw string to matched element. Every label below is a real identifier you can grep for.

selector.rs pipeline

role:Button
&& || !
( )
rightof:'Email'
selector.rs
Selector::And(vec)
predicate
locator.click()

Real expressions you can drop into a test

None of these require wrapper functions, chained calls, or JS predicates. They all live inside a single locator string.

Localized confirm button

role:Button && (name:Confirm || name:Bestätigen || name:Confirmer) && !visible:false

Either of two roles

role:MenuItem || role:Button

Exclude the hidden shadow copy

name:Submit && !classname:HiddenMirror

Spatial inside boolean

role:TextField && rightof:'Email'

Process-scoped search

process:chrome && role:Button && name:Download

Guard against ambiguity

!(name:Cancel || name:Close) && role:Button && visible:true

Using it from the SDK

The TypeScript binding takes the full expression verbatim. No tagged template literal, no imperative chaining required. The Rust parser sees the same bytes you wrote.

ui-automation-test.ts
O(n)

One selector, one predicate, one pass per element. The rest is just reading the tree.

crates/terminator/src/selector.rs, apply_operator

Watch the parser run

Here is what a real selector looks like when you enable verbose parser output. The expression is parsed once and cached by the locator.

cargo test selector_tests -- --nocapture

What this buys your UI automation test

Properties you get for free once the selector is an expression

  • Localization: one expression covers every language variant with a single OR group
  • Exclusion: NOT lets you rule out the hidden AT mirrors, offscreen copies, and disabled twins
  • Composition: spatial operators are first-class atoms, so rightof:'Email' nests inside any boolean
  • Portability: the same expression runs against Windows UI Automation and macOS Accessibility
  • Speed: flattened AND/OR means one vector walk per element, not a recursive binary tree
  • Readability: the test file reads like a sentence, not a chain of imperative calls

Versus what everyone else ships

Most guides compare frameworks by feature checkboxes. The relevant axis for a UI automation test is how the locator composes.

FeaturePlaywright, Selenium, WinAppDriverTerminator
Top-level && || ! in the locator stringNot supported, write JS wrappersFirst-class, parsed by Shunting Yard
Nested parentheses in the locatorRequires multiple chained calls(name:OK || name:Confirm) && !visible:false
Flattened AND/OR evaluationBinary tree recursionSingle vector, one pass per element
Spatial operators inside the boolean expressionNot availableabove:, below:, rightof:, leftof:, near:
Works across browser + native desktop in one expressionBrowser onlyUIA on Windows, AX on macOS, same grammar
MIT-licensed source you can readVariesselector.rs, 753 lines

A mental model in four steps

selector.rs in one picture

1

Raw string

role:Button && (name:OK || name:Confirm) && !visible:false

2

Tokens

Token::Selector, Token::And, Token::Or, Token::Not, LParen, RParen

3

Shunting Yard

Three precedences, two stacks, one output queue

4

Flattened AST

Selector::And(vec![...]) with no binary nesting

5

Predicate

Single pass over each candidate element

Twelve atomic selectors you can combine

Each of these can appear anywhere in a boolean expression.

role:
name:
text:
id:
nativeid:
classname:
visible:
process:
rightof:
leftof:
above:
below:
near:
:has()
..

Recap: what makes this uncopyable

Competing runners tokenize selectors into one atom and lean on the host language for composition. Terminator ships the expression grammar inside the selector string itself. There is a 0-line file with a 0-precedence Shunting Yard parser and an AND/OR flattener that keeps evaluation linear. You can read every line. You can grep for every identifier on this page. The file path, the precedences, and the flattening behavior are the concrete things that are checkable and that no competitor page names.

Want to see a boolean-selector UI automation test run on your app?

Bring a screenshot of the screen you want to automate. We will write the locator expression live and show it match in under a minute.

Frequently asked questions

Frequently asked questions

Why does treating a UI automation test selector as a boolean expression matter?

Because real UIs are not one attribute deep. The confirm control you want might be role:Button AND name:Confirm, but if the app is localized it could be role:Button AND (name:Confirm OR name:Bestaetigen OR name:Confirmer). If the page also has an invisible hidden-for-AT copy of the same button, you need to AND that against NOT visible:false. A flat-string selector forces you to write three separate tests and hope one matches. A boolean selector compiles all of that into a single predicate that is either true or false for each element the engine scans. selector.rs does exactly this.

What exactly does the Shunting Yard parser inside selector.rs do?

It converts a flat selector string into an AST without recursion. The tokenizer walks the input character by character, emitting Token::Selector, Token::And, Token::Or, Token::Not, Token::LParen, Token::RParen. The parser then processes each token with two stacks: an operator stack and an output queue. When an operator arrives, operators of higher or equal precedence pop off the stack and apply to the queue. When a right paren arrives, operators pop until a left paren appears. Precedences are hard-coded: OR=1, AND=2, NOT=3. The result is a single Selector node, either an atomic predicate or an And/Or/Not composition.

What does AND/OR flattening buy a UI automation test?

Speed and readability. When apply_operator sees AND between left and right, it checks if either operand is already an And(vec). If yes, it appends the inner vec into the outer one, so a && b && c ends up as Selector::And(vec![a, b, c]) rather than nested And(a, And(b, c)). Evaluation walks a flat vector once per element instead of recursing through a binary tree. OR is flattened the same way. You pay the cost of predicate evaluation, not AST traversal.

What selector prefixes can I actually use inside a boolean expression?

Twelve atoms, all defined in the Selector enum at the top of selector.rs: role, name, text, id, nativeid, classname, visible, process, window plus the spatial family rightof, leftof, above, below, near, plus :has() and the parent navigation operator '..'. Any of these can appear on either side of && or || or inside !, and you can nest with parentheses. The engine does not care how deep you nest; it is all one AST by the time any element is fetched.

Does this work the same way on macOS and Windows?

Yes. selector.rs sits above the platform layer. On Windows the final predicate runs against UI Automation elements built by tree_builder.rs; on macOS it runs against the Accessibility API. The boolean expression is platform-neutral text. A test that says role:Button && name:Confirm behaves identically against a WPF button and a Cocoa button because both adapters normalize role and name attributes before the selector engine sees them.

How is this different from Playwright's :has() or XPath's and/or?

Playwright's :has() is a CSS pseudo-class scoped to descendants; there is no top-level && or || combining locators without writing JavaScript. XPath has and/or/not but is fragile against role/name changes and has no concept of spatial selectors. Terminator's expression grammar is a first-class feature of the selector string itself: you can write role:Button && !visible:false && above:'Submit' inline, no imperative wrapper needed.

Where can I read the source for all of this?

/crates/terminator/src/selector.rs in the terminator repo. The Selector enum starts at line 5, the Token enum at line 64, the tokenizer at line 94, operator_precedence at line 205, parse_boolean_expression (the Shunting Yard) at line 215, and apply_operator (the flattening) at line 275. Total file length: 753 lines, all MIT-licensed.

terminatorDesktop automation SDK
© 2026 terminator. All rights reserved.