Synchronization¶
Spectre deliberately doesn't wrap reads and actions in an implicit idle barrier. The flip side is that you have a small but explicit set of wait helpers, and you decide where to put them. This page covers all five.
Interactive-only: Compose Hot Reload settle¶
JUnit / :testing has no hot-reload wait. For the CLI and MCP attach path only, Spectre
exposes wait --reload-settled / wait_for_reload_settled when the target process is already
running under Compose Hot Reload. That wait is optional, fails closed without HR, and is
documented under Compose Hot Reload awareness. Prefer
waitForNode / waitForIdle / waitForVisualIdle in automated tests.
The EDT rule¶
All five wait helpers — waitForNode, waitUntilGone, waitUntil, waitForIdle,
and waitForVisualIdle — refuse to run on the AWT event dispatch thread:
waitForNode must not be called from the AWT event dispatch thread;
wrap the call with withContext(Dispatchers.Default) or similar.
This is enforced because their loops snapshot semantics via invokeAndWait/readOnEdt
— running on the EDT would either deadlock or skip the bounded worker that enforces the
timeout. None of the five are exempt.
The standard pattern in tests — JUnit runs tests off the EDT, so no withContext is
needed:
@Test
fun mySpec(): Unit = runSpectreTest {
launchApp()
automator.waitForNode(tag = "Root")
// ...your test body
}
If you call any wait helper from the EDT you'll get a clear IllegalStateException
rather than a deadlock. The fix in that case is withContext(Dispatchers.Default)
around the offending call — see
Troubleshooting.
JUnit expression-body return types¶
When you write Spectre tests as fun mySpec(): Unit = runSpectreTest { ... }, the explicit
: Unit matters. JUnit 5.14 and newer reject @Test methods whose JVM return type is
not void, and Kotlin expression-body functions infer their return type from the last
expression in the body. Some assertion helpers return the asserted value, not Unit, so
omitting : Unit can make a test compile but disappear at discovery time.
waitForNode¶
The "the UI is on screen" barrier:
val node = automator.waitForNode(
tag = "CounterValue",
timeout = 5.seconds,
pollInterval = 100.milliseconds,
)
Polls the semantics tree until a node matches every non-null criterion you pass, then
returns it; throws on timeout. You must pass at least one of tag or text. If you
pass both, the helper waits for a node whose test tag matches and whose text (or
editable text) matches — tag and text together describe a single node, not a choice
between two.
Use it once after launching the UI to know your test can start touching things, and optionally after each interaction that introduces new content (a dialog opening, a list item appearing).
waitUntilGone¶
The "it has left the screen" barrier — waitForNode's counterpart for absence:
automator.click(dismissButton)
automator.waitUntilGone(
tag = "popup.body",
timeout = 5.seconds,
pollInterval = 100.milliseconds,
)
Polls until no node in any tracked window matches every non-null criterion you pass,
then returns. As with waitForNode, you must pass at least one of tag or text, and
passing both means "no single node carrying this tag and this text".
Every poll calls refreshWindows() first, and that is the point of the helper. Compose
Desktop popups — menus, combo boxes, speed search — can render in their own Window, so
dismissing one takes the whole window, and every node in it, out of the tracked-window
set. There is no node left to query; only its absence across all tracked windows is
observable, and a window vanishing between two polls is seen rather than answered from a
stale snapshot.
On timeout it throws an IllegalStateException that names the selector, the timeout, and
what was still on screen:
waitUntilGone timed out after 5000ms: 1 node(s) matching tag="popup.body" still present in tracked windows
Use it after dismissing a popup, menu, or dialog — before asserting on whatever the dismissal revealed, or before the next click that would otherwise land on the closing surface.
It is reachable over every transport waitForNode reaches: CLI
wait-until-gone, MCP wait_until_gone, and agent attach
(AttachedAutomator.waitUntilGone). The timeout diagnostics above cross those boundaries
intact — as do the other waits' — so a remote caller still learns which selector stayed on
screen and how many nodes matched. See the capability matrix for the
per-transport cell states and agent attach for how the wire deadline is sized to
let those messages through.
waitUntil¶
The open-ended barrier, for conditions a tag/text selector cannot express — a node count, a comparison, a combination:
automator.waitUntil(
description = "the lazy list has realised at least five rows",
timeout = 5.seconds,
pollInterval = 100.milliseconds,
) {
allNodes().count { it.testTag?.startsWith("row.") == true } >= 5
}
The lambda is a predicate on
AutomatorTree — the same snapshot
tree() returns, so windows(), allNodes(), and roots() are what you phrase the
condition against. Every poll re-reads that tree (and refreshes windows
first, exactly like waitUntilGone), so a window or popup that appeared between two
polls is seen rather than answered from a stale snapshot. Returns as soon as the
condition holds.
description is required and must not be blank: it is the only thing the failure can
say about your condition, so phrase it as the state you were waiting for, not as an
action.
Keep the condition cheap and side-effect free — read the tree and decide, nothing more.
timeout bounds the polling loop rather than a single poll: each poll runs to completion
before the deadline is re-checked, so a condition that blocks will overrun it.
waitUntil timed out after 5000ms: condition "the lazy list has realised at least five rows" never held in tracked windows
Reach for it when the barrier is about the shape of the UI rather than one node:
// A combination — "the popup is gone AND its trigger is back" is one barrier, not two.
automator.click(dismissButton)
automator.waitUntil(description = "the popup is dismissed and its trigger is usable") {
val tags = allNodes().mapNotNull { it.testTag }.toSet()
"popup.body" !in tags && "popup.toggleButton" in tags
}
// The tracked-window set itself.
automator.waitUntil(description = "the secondary window is being tracked") {
windows().size > 1
}
For a single node appearing or disappearing, waitForNode and waitUntilGone say what
you mean with less ceremony — use those.
Unlike those two, waitUntil is in-process only. Its condition is a Kotlin lambda, and
a lambda does not cross a process boundary, so there is no CLI, MCP, or agent-attach
equivalent — see the capability matrix for what each transport
reaches. Over a transport, phrase the barrier as a selector wait instead.
It waits on the UI, not on your program¶
waitUntil is scoped to what Spectre can see: the semantics tree it hands your
predicate. Kotlin closures can capture anything in scope, so nothing stops you polling a
service flag, a file, or an HTTP response from inside the lambda — but that is a
boundary this helper declines to invite rather than one it can enforce, and crossing it
buys you nothing except a timeout message that describes the wrong thing.
Wait for non-UI state with the tool that owns it: withTimeout around your own
suspending call, your test framework's polling helper, or an
AutomatorIdlingResource if that state should also gate
waitForIdle. Then use waitUntil for the UI barrier that follows.
waitForIdle¶
The "everything has settled" barrier:
automator.waitForIdle(
timeout = 5.seconds,
quietPeriod = 64.milliseconds,
pollInterval = 16.milliseconds,
)
waitForIdle returns when:
- The UI semantics fingerprint has been stable for at least
quietPeriod. - All registered
AutomatorIdlingResources report idle. - The EDT has been drained.
The fingerprint covers tracked windows, node identities, layout bounds, role, focus,
disabled/selected flags, text, content descriptions, and editable text. If your
animation only changes pixels (e.g., an indeterminate spinner that doesn't tick the
semantics tree), the fingerprint will report idle even while the spinner spins — that's
where waitForVisualIdle comes in.
Idling resources¶
Use AutomatorIdlingResource to teach waitForIdle about background work the
fingerprint can't see:
import dev.sebastiano.spectre.core.AutomatorIdlingResource
class NetworkIdlingResource(private val client: MyClient) : AutomatorIdlingResource {
override val isIdleNow: Boolean
get() = client.inflightRequests == 0
override fun diagnosticMessage(): String? =
"${client.inflightRequests} request(s) in flight"
}
val networkIdling = NetworkIdlingResource(client)
automator.registerIdlingResource(networkIdling)
try {
// ...test body
} finally {
automator.unregisterIdlingResource(networkIdling)
}
waitForIdle will keep waiting until every registered resource reports isIdleNow ==
true alongside its own checks. The optional diagnosticMessage() shows up in
IdleTimeoutException, so use it to describe what was still in flight when the wait
ran out of time. Register and unregister the same instance — unregister is
identity-based.
waitForVisualIdle¶
The "the pixels have stopped changing" barrier:
automator.waitForVisualIdle(
timeout = 5.seconds,
stableFrames = 3,
pollInterval = 16.milliseconds,
)
Hashes each tracked Compose surface independently and waits for stableFrames
consecutive identical hashes per surface.
A few details worth knowing:
- Per-surface, not full screen. Each tracked Compose surface is hashed on its own, then the per-surface hashes are combined. Pixel churn outside the app (notifications, cursor movement on another monitor) doesn't reset the streak.
- No surfaces tracked → never idle. If no Compose surfaces are tracked, or all of
them have empty bounds,
waitForVisualIdlereturns a different value every poll and times out rather than reporting fake stability. pollIntervalis a floor, not the real cadence. Each poll captures every tracked Compose surface and hashes the pixels. Whenspectre-recordingis on the runtime classpath and the platform helper can actually run, surfaces are sampled with the same window-scoped native still path asscreenshot(windowIndex)(occlusion-immune on platforms with true window capture). On Linux that helper needsgst-launch-1.0; if recording classes load but GStreamer is missing, Spectre falls back tojava.awt.Robotregion capture instead of treating every sample as unsampleable. Without the recording backend, it also uses region capture of the surface rectangle. Capture cost dominates the poll cadence — typically tens to a few hundred milliseconds per surface (one-shot native helper startup is larger on the first sample), more on Wayland, large displays, or software-rendered VMs. In practice the gap between completed polls is whatever the capture takes, withpollIntervalonly kicking in when the capture is faster than that floor. The default16.millisecondsis a 60Hz target, not a guarantee of 60 polls per second.- Bounded sampling budget. The first completed frame hash may use the wait's remaining
timeout: native capture paths can have a one-off cold-start cost. Later frame hashes run on
a worker thread capped at 2s so a warm one-shot window still can complete without treating a
static UI as permanently unstable. If a steady-state capture or hash exceeds that cap — or no
Compose surface is available — that poll is treated as unsampleable: the stable-frame streak
resets and the wait times out rather than silently succeeding. When that happens,
IdleTimeoutExceptionreports how many samples were unsampleable (capture budget or missing surface) instead of only saying the frames did not stabilise. - Pixel hashing isn't free. Multiple large surfaces, full-screen windows on a 4K /
Retina monitor, or running under a software-rendered virtual GPU all push the
per-poll cost up. If
waitForVisualIdleis timing out or burning more CPU than you expect, lengthenpollInterval(e.g., to100.millisecondsor250.milliseconds) and / or dropstableFramesto 2. There's no information loss — you're just sampling less often.
Reach for waitForVisualIdle after:
- An animation that doesn't change semantics (a fade, an indeterminate spinner stopping).
- A long-running compose operation where you only care that the UI has stopped twitching.
- Anything where
waitForIdlereturns too early because the semantics tree is already stable but the GPU is still flushing frames.
Combining the helpers¶
A common pattern after a state-changing interaction:
automator.click(submit)
automator.waitForIdle() // semantics + idling resources
automator.waitForVisualIdle() // pixels actually settled too
val result = automator.findOneByTestTag("Result")
For tests that are slow or flaky, lengthen quietPeriod (semantics) and stableFrames
(visual) before reaching for sleeps. The wait helpers are deliberately tunable so you
don't have to fall back to Thread.sleep.
Defaults at a glance¶
| Parameter | Default |
|---|---|
waitForIdle.timeout |
5 s |
waitForIdle.quietPeriod |
64 ms |
waitForIdle.pollInterval |
16 ms (~60 FPS) |
waitForVisualIdle.timeout |
5 s |
waitForVisualIdle.stableFrames |
3 |
waitForVisualIdle.pollInterval |
16 ms (~60 FPS) |
waitForNode.timeout |
5 s |
waitForNode.pollInterval |
100 ms |
waitUntilGone.timeout |
5 s |
waitUntilGone.pollInterval |
100 ms |
waitUntil.timeout |
5 s |
waitUntil.pollInterval |
100 ms |
These are tuned for desktop; bump them up freely if your scenarios are heavier.