Skip to main content

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​

OperationRequirements
click, dblclick, tapattached, 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, collapseas 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, pressSequentiallyattached, 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
selectOptionattached, unique, visible, enabled; then the item, in rounds of the same wait
focus, waitFor() / waitFor({ state: "visible" }), toBeVisibleattached, unique, visible
scrollIntoViewattached; asked into view once, then waited for to show, its container paged toward it while it stays hidden (actions.md)
screenshot, toHaveScreenshotattached, unique, visible; the picture the same twice in a row before it is taken or compared
close, minimize, maximize, restoreattached, and a window
toBeEditableattached, 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
toBeSelectedattached, 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, toHaveValueattached, 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.toExistno 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, toContainRowsthe 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 showsWhat happens
the same picture at the same placeact where it was seen
the window moved, its content did notact where it is now
something else there, the window resized, or the control outside the windowdo 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 pointWhat 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, enabledthe 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 taprefused 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 sthe 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's Cursors.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.