Skip to content

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, waitForVisualIdle returns a different value every poll and times out rather than reporting fake stability.
  • pollInterval is a floor, not the real cadence. Each poll captures every tracked Compose surface and hashes the pixels. When spectre-recording is on the runtime classpath and the platform helper can actually run, surfaces are sampled with the same window-scoped native still path as screenshot(windowIndex) (occlusion-immune on platforms with true window capture). On Linux that helper needs gst-launch-1.0; if recording classes load but GStreamer is missing, Spectre falls back to java.awt.Robot region 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, with pollInterval only kicking in when the capture is faster than that floor. The default 16.milliseconds is 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, IdleTimeoutException reports 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 waitForVisualIdle is timing out or burning more CPU than you expect, lengthen pollInterval (e.g., to 100.milliseconds or 250.milliseconds) and / or drop stableFrames to 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 waitForIdle returns 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.