Composables

Printing The Semantics Tree

Community

skydoves

Use this skill to diagnose "no node matched", "found N nodes", and "useUnmergedTree" failures by dumping the actual semantics tree with `printToLog(tag, maxDepth)` and `printToString(maxDepth)`. Covers the output grammar (`Node

Overview

Repository
skydoves/android-testing-skills
Publisher
skydoves
Stars
347
Forks
14
Bundled files
0
License
Apache-2.0
Updated
May 25, 2026

Installation

tmp=$(mktemp -d)
git clone --depth 1 https://github.com/skydoves/android-testing-skills.git "$tmp"
mkdir -p ~/.claude/skills
cp -R "$tmp/compose/debug/printing-the-semantics-tree" ~/.claude/skills/printing-the-semantics-tree
rm -rf "$tmp"

Instructions

Printing the Semantics Tree — When a Finder Fails, Dump the Tree

onNodeWithTag failures are structural — the tag is wrong, the matcher is on the merged tree but the tag is on a leaf, or the node is genuinely not composed yet. None of those are timing problems, so Thread.sleep does nothing. This skill teaches the agent to diagnose finder failures by dumping the semantics tree with printToLog / printToString and reading the framework's built-in "found in the unmerged tree" hint.

When to use this skill

  • The test fails with "Failed: assertExists. Reason: Expected exactly '1' node but could not find any node that satisfies: …".
  • The test fails with "Reason: Expected exactly '1' node but found 'N' nodes that satisfy: …".
  • The error message ends with "…were found in the unmerged tree. If you really wanted to match against merged tree, use useUnmergedTree = true." and the developer is confused.
  • The developer is writing a custom matcher and needs direct access to a SemanticsNode.
  • A test was fixed by adding Thread.sleep — the underlying problem is a wrong tag, not a timing issue. Replace with this skill's pattern. See skydoves directive #7.

When NOT to use this skill

  • The symptom is timing (animation has not finished, an idling resource is busy). Use ../../synchronization/synchronizing-with-idle/SKILL.md or ../../synchronization/testing-animations-deterministically/SKILL.md.
  • The finder is wrong because the test searches by text instead of by tag. Fix with ../../finders/finding-nodes-by-tag-text-content/SKILL.md (skydoves directive #1).
  • The diagnosis involves accessibility violations rather than node lookup. Use ../enabling-accessibility-checks/SKILL.md.

Prerequisites

  • androidx.compose.ui:ui-test on androidTestImplementation or testImplementation (the API lives in commonMain so it is available in both source sets).
  • An active ComposeTestRule or ComposeUiTest (no extra setup beyond the standard skeleton).

Workflow

  • 1. When a finder fails, dump the merged tree first. Add the line right after rule.setContent { … } (or right before the failing finder).
rule.onRoot().printToLog("DEBUG")

The signature is fun SemanticsNodeInteraction.printToLog(tag: String, maxDepth: Int = Int.MAX_VALUE) — full subtree by default for a single-node receiver. Cited at compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Output.kt:62-69. View the output via adb logcat -s DEBUG.

  • 2. If the merged dump does not show the expected node, dump the unmerged tree.
rule.onRoot(useUnmergedTree = true).printToLog("DEBUG")

Inner descendants of merging composables (e.g. an Icon inside a Button) only appear in the unmerged tree.

  • 3. Read the output grammar. The format is fixed by Output.kt:135-296:
Node #<id> at (l=<L>, t=<T>, r=<R>, b=<B>)px, Tag: '<tag>'
  <Sorted ConfigKey> = '<value>'
  ...
  [<FlagKey1>, <FlagKey2>]            // keys whose value is Unit
  Actions = [<ActionKey1>, ...]        // AccessibilityActions and Function values
  MergeDescendants = 'true'            // when isMergingSemanticsOfDescendants
  ClearAndSetSemantics = 'true'        // when isClearingSemantics
   |-Node #<childId> at (...)px        // children indented with " |-"
  • Top line: Node # + id + bounds + (optionally) Tag: '<testTag>'.

  • Config entries are alphabetically sorted by key name.

  • AccessibilityAction and Function<*> values are summarized as Actions = [keyName, …].

  • Unit values (e.g. Disabled, IsTraversalGroup) are summarized as [Disabled, IsTraversalGroup, …].

  • MergeDescendants = 'true' and ClearAndSetSemantics = 'true' flags appear at the bottom of a node's block.

  • 4. Read the framework's built-in hint. When onNode(...) finds zero matches in the merged tree but the same matcher would have matched in the unmerged tree, the error message embeds a "These nodes were found in the unmerged tree" block. Cited at compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt:184-194. When the developer sees that, the fix is to flip useUnmergedTree = true on the failing finder. See ../../finders/finding-nodes-by-tag-text-content/SKILL.md for the merged/unmerged decision.

  • 5. For custom matchers, use fetchSemanticsNode / fetchSemanticsNodes directly. These return the underlying SemanticsNode(s) for direct introspection.

val node = rule.onNodeWithTag("title").fetchSemanticsNode()
assertThat(node.config[SemanticsProperties.Text]).isNotEmpty()

val all = rule.onAllNodesWithTag("row").fetchSemanticsNodes(
    atLeastOneRootRequired = true,
    errorMessageOnFail = "expected at least one row",
)

Single-node signature: fun fetchSemanticsNode(errorMessageOnFail: String? = null): SemanticsNode. Collection signature: fun fetchSemanticsNodes(atLeastOneRootRequired: Boolean = true, errorMessageOnFail: String? = null): List<SemanticsNode>.

  • 6. For collection finders, default maxDepth is 0 (no children). Override when the developer wants the full subtree per match.
rule.onAllNodesWithTag("row").printToLog("DEBUG", maxDepth = Int.MAX_VALUE)

Cited at Output.kt:83-116.

  • 7. Capture the dump as a String for assertion-based debugging. Useful in CI logs.
val tree = rule.onRoot(useUnmergedTree = true).printToString()
println(tree) // or attach to a test report

Patterns

Pattern: Thread.sleep to "wait for the node" instead of dumping the tree
// WRONG
@Test
fun appears() {
    rule.setContent { MyScreen() }
    Thread.sleep(2000)                                    // hide the symptom
    rule.onNodeWithTag("submit").assertIsDisplayed()      // still fails
}
// WRONG because: Thread.sleep desyncs from MainTestClock and does not advance composition.
// If the node is missing, more wall time will not produce it. The cause is structural.
// Skydoves directive #7: Thread.sleep is a smell.
// RIGHT
@Test
fun appears() {
    rule.setContent { MyScreen() }
    rule.onRoot(useUnmergedTree = true).printToLog("DEBUG")   // see what's actually present
    rule.onNodeWithTag("submit").assertIsDisplayed()
}
Pattern: "found in the unmerged tree" hint
// WRONG — the test ignores the framework hint
@Test
fun iconExists() {
    rule.setContent { Button(onClick = {}) { Icon(Icons.Default.Add, modifier = Modifier.testTag("add-icon")) } }
    rule.onNodeWithTag("add-icon").assertExists()
    // AssertionError: ... 0 matches.
    // These nodes were found in the unmerged tree:
    //   Node #4 ... Tag: 'add-icon'
    // If you really wanted to match against merged tree, use useUnmergedTree = true.
}
// WRONG because: a Button merges descendants. The Icon's tag is collapsed into the Button
// node in the merged tree. The error message names the fix in plain English.
// RIGHT
@Test
fun iconExists() {
    rule.setContent { Button(onClick = {}) { Icon(Icons.Default.Add, modifier = Modifier.testTag("add-icon")) } }
    rule.onNodeWithTag("add-icon", useUnmergedTree = true).assertExists()
}
Pattern: ambiguous "found 3 nodes" — disambiguate via dump
// WRONG
rule.onNodeWithTag("row").assertIsDisplayed()
// AssertionError: Reason: Expected exactly '1' node but found '3' nodes that satisfy:
// (TestTag = 'row')
// RIGHT — dump first, then narrow the matcher
rule.onAllNodesWithTag("row").printToLog("DEBUG", maxDepth = Int.MAX_VALUE)
// Read the dump to find a stable disambiguator (e.g. parent tag, contentDescription),
// then narrow:
rule.onAllNodesWithTag("row")
    .filterToOne(hasAnyAncestor(hasTestTag("section-completed")))
    .assertIsDisplayed()
Pattern: fetchSemanticsNode for custom assertions
// RIGHT
@Test
fun rowConfigContainsCustomKey() {
    rule.setContent { /* … */ }
    val node = rule.onNodeWithTag("row").fetchSemanticsNode("missing 'row'")
    val custom: String? = node.config.getOrNull(MyCustomKey)
    assertThat(custom).isEqualTo("expected")
}

Mandatory rules

  • MUST dump the semantics tree with rule.onRoot().printToLog("DEBUG") (or the unmerged variant) the moment a finder fails. The default maxDepth for a single-node receiver is Int.MAX_VALUE, so the full subtree is printed without further configuration. Cited at Output.kt:43, 65.
  • MUST read the framework hint "…were found in the unmerged tree. If you really wanted to match against merged tree, use useUnmergedTree = true." as a directive — flip useUnmergedTree = true on the failing finder. Cited at SemanticsNodeInteraction.kt:184-194.
  • MUST NOT add Thread.sleep(…) to "give the tree time to load". A finder failure is structural; more wall time does not produce the node. Skydoves directive #7. The single legitimate use is screenshot/RenderThread waits.
  • MUST override maxDepth to a non-zero value when calling printToLog on a SemanticsNodeInteractionCollection — the collection default is 0 (no children). Cited at Output.kt:85, 112.
  • PREFERRED: start the diagnosis with the merged tree, then escalate to useUnmergedTree = true only if the expected node is collapsed. Skydoves directive #2: keep useUnmergedTree = false by default.
  • PREFERRED: for custom matchers, prefer fetchSemanticsNode(errorMessageOnFail = "…") so the failure message identifies the test, not just the assertion line.

Verification

  • When a finder fails, the test (or the agent's interactive debugging) emits at least one printToLog("DEBUG") line for the relevant subtree.
  • No Thread.sleep was added to fix a finder problem.
  • If the dump shows the node only under useUnmergedTree = true, the finder has been updated to pass that flag.
  • If multiple matches were found, the finder has been narrowed (via filterToOne, hasAnyAncestor, a parent testTag, etc.) — not by adding sleep or assert-attempts.
  • Custom matchers use fetchSemanticsNode(errorMessageOnFail = "…") with a meaningful diagnostic message.
  • In CI, the test logs include the dump output (developer collects logcat for the test process).

References

FAQ

What does Printing The Semantics Tree do?

Use this skill to diagnose "no node matched", "found N nodes", and "useUnmergedTree" failures by dumping the actual semantics tree with `printToLog(tag, maxDepth)` and `printToString(maxDepth)`. Covers the output grammar (`Node

Who made Printing The Semantics Tree?

skydoves (a community member) publishes it in the skydoves/android-testing-skills repository on GitHub.

Is Printing The Semantics Tree free?

Yes. Printing The Semantics Tree is open source, published by skydoves under the Apache-2.0 license.

What comes with Printing The Semantics Tree?

Just its instructions. Everything the agent needs is in one SKILL.md file.

Which agents can use Printing The Semantics Tree?

Any coding agent that reads SKILL.md files, including Claude Code, Codex, Cursor, GitHub Copilot and Gemini CLI. The agent loads the skill when a task matches its description.

What are agent skills?

An agent skill is a folder with a SKILL.md file: instructions that teach a coding agent how to do one kind of task, and optionally files it can read while it works. The agent keeps only the skill's name and description in mind and reads the rest when a task calls for it.

How are agent skills different from MCP servers?

An MCP server gives an agent new tools to call, code that runs somewhere and returns results. A skill gives it knowledge: how to approach a task with the tools it already has. Many agents use both.

How can my agent test my Android app?

Robomori lets coding agents create, start and control Android emulators and devices from one command, so they can install your app, take screenshots and check dark mode, font sizes and languages.

You're subscribed

The next lesson lands in on Wednesday.