Auto-waiting
No sleeps. Every single-target operation and assertion runs the same loop until it succeeds, the timeout elapses or the signal aborts; this page says what each operation waits for, how timeouts, intervals and cancellation work, and how a failed wait explains itself.
resolve (the tree, or the screen where the locator names it) → exists? → unique? → visible? → enabled? → stable? → still there? → act / observe
Each round is one resolution — nothing is re-queried for the individual
checks — and the loop pauses between rounds following intervals:
[100, 250, 500, 1000] by default, the last entry repeating, never longer
than the remaining budget.
What each operation waits for
| Operation | Requirements |
|---|---|
click, dblclick, tap | attached, unique, visible, enabled, stable (bounds unchanged between two observations); real input also finds the control under its point, where it was observed and still enabled, the instant before it is sent (below) |
check, uncheck, setChecked, expand, collapse | as click, plus a reported toggle/expansion state; after the one click, the loop keeps observing until the backend reports the wanted state |
fill, clear, press, pressSequentially | attached, unique, visible, enabled, editable (a field that refuses as read-only is tried again until it takes the text) |
hover, scroll, dragTo (the control pressed on) | attached, unique, visible, stable: each aims real input at a place, so a control still moving is waited for as click waits for it; none needs the control enabled. A drag's target is one control on the screen, waited for within the same timeout |
selectOption | attached, unique, visible, enabled; then the item, in rounds of the same wait |
focus, waitFor() / waitFor({ state: "visible" }), toBeVisible | attached, unique, visible |
scrollIntoView | attached; asked into view once, then waited for to show, its container paged toward it while it stays hidden (actions.md) |
screenshot, toHaveScreenshot | attached, unique, visible; the picture the same twice in a row before it is taken or compared |
close, minimize, maximize, restore | attached, and a window |
toBeEditable | attached, unique, enabled and not read-only (the Value or RangeValue pattern, asked each round as details() asks it); a control with neither pattern reports no editable state and satisfies neither form |
toBeSelected | attached, unique, reporting a selection state (as isSelected() reads it); a control without one satisfies neither form |
textContent, boundingBox, isEnabled, isChecked, isFocused, waitFor({ state: "attached" }), toExist, toBeChecked, toBeExpanded, toBeFocused, toHaveText, toContainText, toHaveValue | attached, unique; isChecked, isFocused, toBeChecked and toBeFocused also need the control to report that state (isChecked/isFocused refuse with not_supported otherwise) |
waitFor({ state: "hidden" | "detached" }), toBeHidden, not.toBeVisible, not.toExist | no match, or (hidden) a single hidden match; strict: several matches that persist are an ambiguity, not an absence |
toHaveCount(n) | exactly n matches, zero included; never strict |
a table's row()...find() | attached, unique; pages the grid for a row it does not show (scroll: false looks only at the rows shown). See tables.md. |
a table's read() | the table attached and unique, then one reading of the rows it shows; scroll: true pages the grid to its end |
expectDesk(table).toHaveRows, toContainRows | the rows read again on every attempt match; never page |
isVisible(), count(), all(), allTextContents(), a table's columns() and its row()...all() / count() | do not wait; isVisible is strict immediately, the others never |
A control that is actionable at first sight is used right away; stability only costs a second observation when the control was seen moving. Real input is checked once more the instant before it is sent, by the helper, in the request that sends it (below): a control that slid on since the observation is refused there and looked at again, so it is clicked where it is, once it rests.
Several matches
Several matches are waited out while the set of them changes: a dialog
replaced by its successor overlaps it for most of a second, a list refreshes.
Once the same controls have matched on three attempts in a row spanning at
least a second, the wait fails with StrictModeViolationError at once, about
two seconds after the first sighting with the default intervals, rather than
at the timeout. This holds for every operation and assertion that needs one
control, and for waitFor({ state: "hidden" | "detached" }), toBeHidden,
not.toBeVisible and not.toExist, where several persisting matches are an
ambiguity too. toHaveCount, count(), all() and allTextContents() are
never strict; isVisible() is strict at once.
A control found on screen is checked before its position is used
A control found in the accessibility tree is acted on through the tree: the
backend finds it again by its address at the moment it acts, so a click can
never land where it used to be. A control found only in pixels — text read
off the screen, a picture, a grounded description, when the tree has nothing
under it — is acted on at a position that was true when the screenshot was
taken. So every operation that acts (click, check, fill, press,
focus, hover, …) first takes one more screenshot and compares the place
where the control was seen:
| What the second look shows | What happens |
|---|---|
| the same picture at the same place | act where it was seen |
| the window moved, its content did not | act where it is now |
| something else there, the window resized, or the control outside the window | do not act; the next round finds the control again, starting from this screenshot |
The comparison uses the same measure picture matching does, so a control that lights up under the pointer, or a blinking caret, is still the same control. The two looks are also the stability check for a control found on screen: one that is still sliding in is not the same picture twice, and is clicked once it has settled, with no sleeps. It costs one screenshot per action, and only when the action would move the pointer; operations that read or measure a control are not checked.
The instant before real input
A control the tree reported is acted on through the tree wherever a pattern
or its own window serves (uia.*, window.*): the helper finds it again by
its identity, at its observed place, the moment it acts, and refuses one that
moved as not found, which the wait observes again. Real input
(input.*: a real click or double click, click({ mouse: true }), a click
with modifiers or at a position, a tap, a drag's press, a hover, a wheel
turn) aims at a place, and the helper checks that place in the request that
sends the input, right before the cursor moves:
| What lies at the point | What happens |
|---|---|
| the control, one of its children, or what it draws, where it was observed (within 3 px) and, for a click or a tap, enabled | the input is sent |
| something else of the window's (a loading overlay, a panel, an in-window popup) | refused as occluded, naming it; waited out, and the action's error at the deadline |
| the control, elsewhere than it was observed (still sliding in, a layout that shifted) | refused as element_moved; the wait reads it as moving, looks again, and acts once two looks agree |
| the control, disabled since it was observed, under a click or a tap | refused as element_not_enabled; the wait observes it again and reports disabled. A hover, a wheel turn or a drag's press reaches a disabled control, as a user's does |
| no answer within 1.5 s | the input goes, with the window check alone, and the record says the control under the point was not checked |
A position is checked at that place, as the middle is: what the control
holds or draws there passes, a sibling or a cover with an element of its own
refuses. Where UI Automation finds nothing but the window at such a place (a
button's margin, as Avalonia hit-tests it a few pixels in from the edge), the
window's word is taken, as it was before places were checked: a cover without
an element of its own passes a position click, and refuses one at the
middle. Nothing was sent when the helper refuses, so each refusal is safe to
wait out. Before any
action, through any strategy, the control's window is also asked whether a
modal dialog blocks it or the application is busy (window_blocked,
app_busy, below); a tap and a drag ask as a click
does. A control read off the screen is checked against a fresh capture
instead (above). The keyboard focus another control keeps when a key is
about to be sent is a refusal of the same kind (focus_not_moved): no key
goes, the wait tries again, and at the deadline the action fails naming the
control that has it.
Timeouts, intervals, cancellation
const main = app.window();
const desktop = await Desktop.create({ timeout: 10_000, expect: { timeout: 15_000 }, intervals: [100, 250, 500, 1000] }); // or in spart-desk.config.ts
await main.getByRole("dialog", { name: "Done" }).waitFor({ timeout: 60_000 }); // per call
await main.getByRole("button", { name: "Save" }).click({ intervals: [50, 200] }); // 50 ms, then 200 ms between observations
const controller = new AbortController();
await main.getByText("Ready").waitFor({ signal: controller.signal }); // cancel with controller.abort()
timeout is the budget of actions and waitFor; expect.timeout (defaults
to timeout) that of expectDesk assertions, poll and toPass.
intervals is the pause schedule of every wait (expect.toPass.intervals
narrows it for toPass); every auto-waiting method, assertion, poll and
toPass accepts a per-call intervals, and expectDesk.configure sets it
for a group of assertions. The same settings live in spart-desk.config.ts
(configuration.md). An assertion on a locator takes the
desktop's expect defaults from the locator's desktop; poll and toPass
take no locator, so through the plain expectDesk they read the
configuration file, and a desktop's own, those given to
Desktop.create({ expect }), reach them through
expectDesk.configure({ ...desktop.config.expect, intervals: desktop.config.intervals }).
Aborting raises CancelledError between rounds, during the pause, or inside
an in-flight backend call (the signal is handed to the backend). A signal
that is already aborted never touches the backend.
The timeout holds inside a round too. Every round after the first is cut off
at the deadline: a resolution that would run past it, such as a deep tree
observed once more at 4.9 s of 5, is abandoned. The wait then fails with the
last observed state still resolving when the timeout elapsed, instead of
finishing seconds late. The first round always completes, so every wait makes
at least one whole observation however short its timeout. When that message
appears, one observation takes longer than the time that was left, and
timeout is the setting to raise.
Backend failures during a wait
Retryable failures keep the loop going within the same budget and become the
"last observed state" if they never recover: the application is still
starting (application_not_found); a control vanished between resolve and
act (element_not_found), or became disabled meanwhile
(element_not_enabled: the control is observed again and reported
disabled, or acted on once it is enabled again), or moved meanwhile
(element_moved, from the check the instant before real input: the state is
moving, and the control is acted on once two looks find it in one place);
the helper's UI Automation call did not answer in time. A control is never
clicked at its old place after such a refusal.
A field that refuses a fill as read-only (read_only: its Value pattern is
read-only, or a Win32 edit has ES_READONLY) received nothing, and is tried
again until it becomes editable. Still read-only at the timeout, the action
fails with BackendError read_only "(still so after N ms)".
Non-retryable failures (ambiguous selector on the backend, unsupported
operation, foreground_required, screen provider errors) are thrown at once.
An application that ended is not waited for. When an application whose
window was observed shows no window any more, the wait asks its state; once
none of its processes runs, it fails at once with BackendError
application_ended rather than timing out on the locator. Between two
dialogs an application lists no window for most of a second while it runs
on, so the state decides, never the census alone.
A busy application
An application is busy while it loads or computes after its window shows. An action waits for it, as it waits for a dialog to close, and is sent once the application is done. Just before it sends anything, an action checks the control's window:
- It answers. The window takes a message within half a second. One whose user interface thread is loading or computing does not, and what an action sent now would wait for it, or be lost.
- It shows no wait cursor. The pointer shows neither Windows' wait cursor
nor its app-starting cursor over the application's window, which an
application sets while it loads (Windows Forms'
UseWaitCursor, WPF'sCursors.Wait). The pointer belongs to the whole session, so where it lies elsewhere the cursor says nothing either way.
While either says the application is busy, the action waits and checks again.
If the application is still busy at the action's timeout, the action fails with
BackendError app_busy, naming which. A click on a window that shows the
wait cursor while it loads, or whose user interface thread is held by a
computation, goes once the load is over, however long that takes within the
timeout.
WaitForInputIdle cannot do this. Windows answers it once per process, and
every later call at once:
- While loading: it says "idle" while the user interface thread is blocked, where the half-second message check says the window does not answer.
- At launch: it returns before an application's own load has begun.
app.waitForIdle() waits for what an action cannot see: an application
computing on a thread of its own, with its window answering and no wait cursor.
It waits until the application's windows answer, the pointer shows no wait
cursor over them, and its processes use less than cpuBelow percent of one
processor (5) for quietMs in a row (500). Use it after a launch or a step
that starts work in the background, before the next step or assertion:
const app = await desktop.launch({ executable: "C:\\Apps\\Orders.exe" });
const main = app.window();
await app.waitForIdle();
await main.getByRole("button", { name: "Recalculate" }).click();
await app.waitForIdle({ cpuBelow: 2, quietMs: 1_000, timeout: 60_000 });
The screen inside the loop
The accessibility tree is polled every round. A locator that names the
screen looks at it at most once per its interval: a picture (getByImage)
once per images.interval (250 ms by default), text and grounding
(experimental) once per resolution.screen.interval (1 s by default). The
last observation that included the screen is what a timeout
reports, so a failure explains what was read or detected and why it was
rejected (confidence, ambiguity). See
resolution.md.
Failures explain themselves
TimeoutError: Timeout 5000ms exceeded waiting for getByRole("button", { name: "Save" }) to be visible, enabled, stable.
Last observed state: disabled
Waited: 0 ms not found ×3 → 1230 ms hidden → 1760 ms disabled ×6
1) button "Save" [automationId=btnSave] (615,591 113x33) disabled
Application: pid=39144 process="charmap" · windows: "Character Map" (hwnd 3935002)
Backend: windows
Attachments: …\failure-….png, …\failure-….ui-tree.json
Waited: is what the wait saw from its start, round by round: each stretch
says when it began, what the rounds saw, and how many saw it in a row. It
shows that the control was there and disabled for most of the wait, which
the last state alone cannot; a wait of one round says nothing more. At most
24 stretches are kept, the first and the latest.
TimeoutError carries locator, expectation, timeout, lastState,
history (the stretches, as WaitHistoryEntry), candidates, windows,
backend, resolver, visionBackend and attachments. When the locator
kept matching several controls the failure is a StrictModeViolationError
instead (locators.md).
A failure also says what may be meant instead.
A locator that found nothing names a control of the same name under another
role, or a name a typo away under the same role, each as the locator that
finds it; a strict failure says how many of its candidates are hidden and
gives a locator of its own for each (StrictModeViolationError.hints,
printed as Hint: lines):
Last observed state: not found; a menuitem named "Save" is there: getByRole("menuitem", { name: "Save" })
Cost model
On the real backend one resolution is one whole read of the window by the
helper, which takes longer the more controls the window holds, so the loop
samples at that rate plus the current interval; shortening intervals below
the resolution time buys nothing. Under load, raise timeout instead.