Windows automation software that throws out unstable selector text before it writes the file

Every piece of record-and-replay Windows automation software captures whatever the UIA tree returns. The tree returns BSTR() sometimes. It returns 3 minutes ago on a Slack panel. It returns Chrome_WidgetWin_1 as a parent. Terminator's workflow recorder drops all three classes of text at capture time so the selector chain it serializes does not break the next time the UI repaints.

M
Matthew Diakonov
9 min read
4.9from developers running it on Windows daily
20 null-like strings rejected at capture, not replay
10 relative-time substrings pruned before serialize
3 Chromium compositor container names collapsed out of parent chains

Why recorded Windows workflows break on replay

Open any dashboard in a browser on Windows. Open Accessibility Insights or inspect.exe. Click an element and read its Name property. Nine times out of ten you get a clean string like Export CSV. The tenth time you get variant(empty), or (null), or a Slack message header that reads Matthew · 3 minutes ago. That tenth read is where the consumer-grade recorders fail.

Power Automate Desktop, UiPath Studio, and every other piece of Windows automation software built on record-and-replay captures that text verbatim and serializes it into the workflow. On replay the element reports a different placeholder or a different timestamp, the selector fails to match, and the unattended run either halts on a hard error or, worse, finds a weaker match somewhere else in the tree and silently clicks the wrong thing.

Terminator sits in the same record-and-replay shape but draws a hard line at the serialization boundary. Before a selector ever reaches the YAML file the recorder applies three filters, all of which live in a single file under 2000 lines long. What lands on disk is only the text that survives.

The anchor fact, in one line

20 / 10 / 3

The recorder ships a static HashSet of 20 null-like strings, a function that rejects 10 relative-time substrings, and a parent-hierarchy walker that explicitly skips 3 Chromium compositor container names. Everything else about the filter is implementation detail.

crates/terminator-workflow-recorder/src/events.rs, lines 8 through 716

Filter one: the NULL_LIKE_VALUES HashSet

A LazyLock that builds the set exactly once. Lookups are O(1). Every field in the recorded event carries an #[serde(skip_serializing_if = "is_empty_string")] annotation, so the serializer asks the filter before writing each field, and fields that fail the check simply do not appear in the output. No empty strings in the YAML. No “null” values as text. No COM markers.

crates/terminator-workflow-recorder/src/events.rs

every string the filter treats as “no text”

nullnilundefined(null)<null>n/anaunknown<unknown>(unknown)none<none>(none)empty<empty>(empty)BSTR()variant()variant(empty)

Filter two: relative-time substrings

If a UIA element's name contains a rendered timestamp, the text will be different on the next read. A recorder that saves role:Text && name:posted 3 minutes ago is a recorder that guarantees a replay failure. The check is ten substrings, all lowercase, tested against a lowercase version of the field.

crates/terminator-workflow-recorder/src/events.rs

time phrases the parent-hierarchy walker rejects

3 hours ago5 minutes agojust nowyesterdaytodaylast weeklast month2 min15 mins1 hr4 hrs

Filter three: Chromium and D3D compositor containers

Chromium browsers leak three render-layer container names into the UIA tree: the native window class Chrome_WidgetWin_1, the Windows HWND compatibility wrapper Legacy Window, and the Direct3D compositor surface Intermediate D3D Window. Matching on them is matching on Chrome's internals, which shift with every Chrome release. The recorder collapses them out of the parent chain, along with Pane elements whose name just mirrors the browser tab title.

crates/terminator-workflow-recorder/src/events.rs

Before and after: one click in the same Chrome tab

What the selector chain looks like with and without the filter

process:chrome.exe >> role:Pane && text:Legacy Window >> role:Pane && text:Chrome_WidgetWin_1 >> role:Pane && text:Intermediate D3D Window >> role:Pane && text:Dashboard - 3 minutes ago >> role:Button && text:Export CSV

  • Chrome_WidgetWin_1 depends on Chromium build, changes between versions
  • Intermediate D3D Window depends on GPU compositor state
  • 3 minutes ago will read 4 minutes ago thirty seconds later
  • Selector will fail to match on the very next page refresh

What happens at record time, step by step

From UIA event to stable selector chain

1

UIA event arrives on the recorder channel

The target element's name, role, automation_id, class_name, help_text, and parent chain are all ThreadSafeWinUIElement references pointing back into the live UIA tree.

2

Read the raw text of every candidate field

Name, text, value, help_text, automation_id. Each is an Option<String> straight from the Windows accessibility API, with no filtering yet applied.

3

serde skip_serializing_if runs is_empty_string on every field

events.rs line 195 onward: any field whose text is in NULL_LIKE_VALUES or evaluates to empty/whitespace disappears from the serialized event. The field is not emitted, not set to the empty string; it is absent.

4

build_parent_hierarchy walks the tree upward

events.rs line 685. For each ancestor, it applies the same null filter, the relative-time filter, and the browser-internal filter. Failing parents are skipped; the walk continues toward the top-level Window.

5

build_chained_selector stitches the survivors

events.rs line 750. It emits 'role:X && text:Y' for each named non-internal parent and joins with ' >> '. The first Window is skipped because 'process:' already targets it.

6

YAML serialization writes only the stable bits

What lands on disk is a selector chain referencing semantic containers and concrete text. No null markers. No timestamps. No compositor layers. Replays load it, locate the element, and click without drama.

How the pieces actually fit together

The recorder is a pipeline. Raw UIA properties come in on the left, three filters run in the middle, and what comes out on the right is a serialized selector chain plus the screenshot and timing metadata the replayer needs.

Record-time pipeline, crates/terminator-workflow-recorder

element.name
element.automation_id
parent chain
class / help_text
filter stack
stable selector chain
screenshot + bounds
YAML workflow file
0null-like strings filtered
0relative-time substrings
0Chromium container names skipped
0lines in events.rs

The full capture-time rulebook

Every rule is in the source. Every rule is unit-tested. This is the complete list of things the Terminator recorder rejects before it writes a selector to disk.

Rejected at record time

  • An empty string, whitespace-only string, or tab/newline-only string
  • Any case variant of null, nil, undefined, n/a, na, none, empty, unknown
  • Bracketed variants: (null), <null>, (unknown), <empty> and so on
  • COM leakage markers: BSTR(), variant(), variant(empty), in any case
  • Any substring match for ' ago', 'just now', 'yesterday', 'today'
  • Strings ending in ' min', ' mins', ' hr', ' hrs'
  • Chromium compositor container names: legacy window, chrome_widgetwin, intermediate d3d window
  • Pane names that end in the browser chrome suffix (' - Google Chrome', ' - Mozilla Firefox', ' - Microsoft Edge')

the uncopyable line

0 null-like strings. 0 timestamp substrings. 0 Chromium container names. One file, 0 lines, one filter run on every UIA event before the selector chain is serialized.

Grep the repo for NULL_LIKE_VALUES, contains_relative_time, and is_internal_element. You will land on the same three blocks in under a minute.

Terminator vs other Windows automation software recorders

Every row is a concrete capture-time behavior. Terminator filters at the moment the selector is captured; the rest filter (or do not) only when the replay already failed.

FeaturePower Automate / UiPath / WinAutomationTerminator
Filters null-like selector text (null, BSTR(), variant(empty)) at captureCaptures whatever UIA returns, including placeholdersNULL_LIKE_VALUES set of 20 strings, events.rs:8
Rejects relative timestamps (3 hours ago, yesterday, last week) in parent chainStores literal text, replay fails when clock movescontains_relative_time, 10 substrings, events.rs:69
Skips browser compositor containers in the hierarchyIncludes Chrome_WidgetWin_1 and D3D surfaces as parentsis_internal_element, events.rs:698-716
Selector chain reflects semantic UI, not compositor layersChain depth grows with GPU and browser internalsbuild_chained_selector joins only named non-internal parents
Filter is unit-tested against every literal in the setNot exposed; cannot be auditedtest_empty_string_helper, events.rs:1764, 60+ assertions
Open-source MIT, grep-verifiable in under 30 secondsClosed-source recorder binarygithub.com/mediar-ai/terminator, events.rs is 1831 lines

Verify every number in this page

The source is MIT-licensed and the filter lives in a single file. You can reproduce every count above in under a minute. Clone the repo, grep the three function names, run the one relevant test, done.

verify.sh

Who should care about this level of detail

Engineers running unattended Windows automations that touch real production systems. The tenth replay of a weekly workflow is where recorded selectors go brittle, and the failure mode almost always traces back to a parent container whose rendered text drifted. If the recorder removed that drift at capture time you would not be paged.

QA teams building desktop regression suites on Windows. If a test matches on Panel > 3 hours ago > Button, the test is already broken the day you save it. The filter means the test never matches on that text in the first place.

Have a Windows workflow that keeps breaking at the same step every replay?

Bring the recording. We open it next to events.rs, point at which of the three filters caught (or should have caught) the drifting text, and leave you with a stable selector chain.

FAQ

Why do recorded Windows automation workflows break on replay so often?

Because Windows UIA lets elements return text that looks like content but is actually a placeholder, a COM variant marker, or a rendered timestamp. A recorder that captures 'Button with Name unknown' or 'Pane with Name Dashboard - 3 minutes ago' will fail on replay when that same element reports 'BSTR()' or 'Dashboard - 5 minutes ago'. Most consumer automation software for Windows (Power Automate Desktop, UiPath, WinAutomation, AutoHotkey-backed recorders) treats whatever UIA returns as ground truth and serializes it into the workflow. The stability problem is a capture-time problem, and almost no product solves it at capture time.

What exactly does Terminator's recorder filter out?

Three things, all at crates/terminator-workflow-recorder/src/events.rs. First, the NULL_LIKE_VALUES HashSet at lines 8 through 36 holds 20 strings: 'null', 'nil', 'undefined', '(null)', '<null>', 'n/a', 'na', '', 'unknown', '<unknown>', '(unknown)', 'none', '<none>', '(none)', 'empty', '<empty>', '(empty)', 'bstr()', 'variant()', 'variant(empty)'. Second, contains_relative_time at lines 69 through 81 rejects any element whose name contains ' ago', 'just now', 'yesterday', 'today', 'last week', 'last month', or ends with ' min', ' mins', ' hr', or ' hrs'. Third, is_internal_element at lines 696 through 716 skips parents whose name contains 'legacy window', 'chrome_widgetwin', 'intermediate d3d window', or Panes whose title ends with ' - Google Chrome', ' - Mozilla Firefox', or ' - Microsoft Edge'.

Why are those three Chrome container names specifically filtered?

They are render-layer implementation details that leak into the UIA tree but have no semantic meaning for locating UI elements. 'Chrome_WidgetWin_1' is Chromium's top-level native window class; 'Legacy Window' is Windows' compatibility shim around HWND-backed controls; 'Intermediate D3D Window' is the Direct3D compositor surface. If the recorder keeps them in the selector chain, the chain will include container roles that depend on GPU state, window-manager compositor behavior, and Chrome version. Filter them out and the selector chain collapses to the meaningful semantic parents, which is what the UIA-based locator actually wants to match on.

Where can I see the full filter in the source?

crates/terminator-workflow-recorder/src/events.rs. The imports and static block are at lines 1 through 36. The is_empty_string helper is at lines 39 through 65. contains_relative_time is at lines 69 through 81. Every serialized event field that carries user-facing text is marked with #[serde(skip_serializing_if = "is_empty_string")], starting at line 195 and appearing roughly 40 times through the file. The parent hierarchy walker that rejects internal elements is at build_parent_hierarchy, lines 685 through 744. The unit test battery that pins down every filtered string is test_empty_string_helper at line 1764.

How is this different from Power Automate Desktop's selector repair?

Power Automate Desktop's 'selector repair' is a replay-time recovery feature. It waits until the selector fails to match, then opens the Selector Builder and asks the human to manually repair it inside the designer. By the time the repair dialog opens, the unattended run has already failed. Terminator's filter runs at capture time, before the workflow file is written, so the stored selector never contains the unstable text in the first place. Nothing to repair later because the volatile bits never made it into the YAML.

Does this slow down the recorder?

No. NULL_LIKE_VALUES is a precomputed LazyLock<HashSet<&'static str>> built once on first use (line 8). Lookups are O(1). is_empty_string short-circuits on empty strings and whitespace-only strings (lines 41-51) before it even considers allocating a lowercase copy. It also caps the lowercase allocation at strings of 20 characters or less (line 55), on the observation that null-like markers are always short. The relative-time check is a straight series of str::contains calls, no regex. On a reasonable workflow the whole filter costs under a millisecond per event.

What happens to an element that has no usable name after filtering?

It is dropped from the parent hierarchy, not the recording. build_parent_hierarchy at events.rs line 685 walks up from the target element; for each parent it checks has_name (non-empty after filter) and is_internal_element (not on the skip list). If a parent fails either check, it is omitted from the hierarchy and the walk continues up to the next parent. The target element itself is still captured; only the path to it is trimmed. Result: a compact selector chain that only references semantically meaningful ancestors, which is what build_chained_selector (line 750) then stitches together with the '>>' operator.

Where does the selector chain actually get built from this filtered hierarchy?

build_chained_selector at events.rs line 750. It iterates parent_hierarchy, skipping the first Window element because the 'process:' prefix already targets that window, then emits 'role:X && text:Y' for each named parent and joins with ' >> '. A chain like 'role:Pane && text:Toolbar >> role:Button && text:Export CSV' is the product of that walker plus the filter. Skip the filter and the same chain would read 'role:Pane && text:Chrome_WidgetWin_1 >> role:Pane && text:Intermediate D3D Window >> role:Pane && text:Dashboard - 3 hours ago >> role:Button && text:Export CSV', which is the kind of string that breaks one refresh later.

Can I extend the filter with my own null-like patterns?

The set is a static LazyLock<HashSet<&'static str>>, so you cannot add to it at runtime, but you can fork the crate and add entries at the top of events.rs. The CONTRIBUTING.md in the repo welcomes PRs for new null-like strings that show up in specific application UI. If you find your team's internal app emits 'NO_VALUE' or '[empty]' in UIA tree nodes, open a PR that adds those literals to the set and the CI tests will verify the existing behavior keeps passing. NULL_LIKE_VALUES.contains is a single-line check, so adding entries is additive and safe.

Does the same filter apply when Terminator is used without the recorder, just the SDK?

The filter is scoped to the recorder crate (terminator-workflow-recorder) because that is where selector text gets serialized to disk. When you call the SDK directly, you pass selectors as strings you chose, so there is nothing to filter. The value of the filter shows up specifically for the record-and-replay workflow: a human clicks through a task in a real Windows session, the recorder captures UIA events, and the stored YAML needs to replay reliably on the next run. That is where stripping unstable text at capture time matters.

terminatorDesktop automation SDK
© 2026 terminator. All rights reserved.