Skip to content

Finding nodes

Selectors are how you locate a Compose node in the semantics tree. The ComposeAutomator exposes four families of selectors plus a handful of convenience overloads.

All selectors are non-waiting

Every findBy…/findOneBy… call is a single read against the current semantics state. If you need to wait for a node to appear, use waitForNode(...) instead.

By test tag

val nodes: List<AutomatorNode> = automator.findByTestTag("Submit")
val node: AutomatorNode? = automator.findOneByTestTag("Submit")

This is the most reliable selector — it relies on a deliberate Modifier.testTag("...") on the composable. Use it as your default.

@Composable
fun MyButton() {
    Button(
        onClick = { /* … */ },
        modifier = Modifier.testTag("Submit"),
    ) {
        Text("Submit")
    }
}

By visible text

val nodes = automator.findByText("Submit")            // exact, case-sensitive
val nodes = automator.findByText("Sub", exact = false) // substring, case-insensitive
val node  = automator.findOneByText("Submit")

Or with the structured TextQuery:

import dev.sebastiano.spectre.core.TextQuery

val nodes = automator.findByText(
    TextQuery.exact("Submit", ignoreCase = true)
)

TextQuery covers exact, substring, and case-insensitive variants. Prefer test tags when the UI is yours — text matchers are brittle to copy changes and localisation.

By content description

val nodes = automator.findByContentDescription("Send message")

Matches Modifier.semantics { contentDescription = "..." } — the Compose accessibility hook. Use this for icon-only buttons that have no visible text but should be announced to assistive tech.

By role

import androidx.compose.ui.semantics.Role

val buttons = automator.findByRole(Role.Button)
val checkboxes = automator.findByRole(Role.Checkbox)

Roles come from Modifier.semantics { role = Role.Button } (or, more often, are set implicitly by the standard Material/Compose components). Use this for "all buttons in this dialog" style assertions, not as a primary selector.

Working with the result

AutomatorNode exposes the bits you usually want:

Property Meaning
node.testTag The Modifier.testTag value, or null.
node.text First text on the node, or null.
node.texts All text strings on the node.
node.contentDescriptions All content-description strings.
node.role The semantics role, or null.
node.isFocused Focused state.
node.isDisabled Disabled state.
node.isSelected Selected state (toggleable/radio).
node.editableText Current text-field value.
node.boundsInWindow Layout bounds within the window's Compose surface.
node.boundsOnScreen Bounds in screen coordinates (HiDPI-corrected).
node.centerOnScreen Centre point in screen coordinates — what input helpers click.
node.children Child nodes in the semantics tree.

Use boundsOnScreen and centerOnScreen for screenshot regions or custom input. The HiDpiMapper handles macOS Retina, Windows DPI scaling (100/125/150/200%), and mixed-DPI multi-monitor setups.

Walking the tree by hand

When you need more than the canonical selectors, drop down to automator.tree() and walk it directly:

val secondWindow = automator.tree(windowIndex = 1)
val matching = secondWindow.allNodes()
    .filter { it.isFocused && it.role == Role.Button }

AutomatorWindow.allNodes() returns every node in the window in tree order. AutomatorWindow.roots() returns the top-level nodes only; from there you can recurse through node.children to walk the tree manually.

Debugging

If a selector doesn't return what you expect, dump the tree:

println(automator.printTree())

You'll see every window, every node, and the test tags, text, and roles attached to them. This is usually the fastest way to find out whether the node simply isn't where you think it is, or hasn't been composed yet.