协作头脑风暴
brainstorming
在实现前澄清需求、探索设计选择,并形成清晰的开发方向。
printing-the-semantics-tree【开发与工程】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
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
name: printing-the-semantics-tree
description: 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 #<id> at (l, t, r, b)px, Tag: '...', alphabetically sorted config, Actions = [...], [FlagKey] for Unit-valued semantics, MergeDescendants, ClearAndSetSemantics), the framework hint that suggests useUnmergedTree = true when matches are found only in the unmerged tree, and fetchSemanticsNode / fetchSemanticsNodes for custom matchers. Use when the developer reports "tag not found", "multiple nodes matched", "merged tree vs unmerged tree", "test fails with assertion error and a long error message", or wants to debug a finder without adding Thread.sleep.
license: Apache-2.0. See LICENSE for complete terms.
metadata:
author: Jaewoong Eum (skydoves)
keywords:
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.
"Failed: assertExists. Reason: Expected exactly '1' node but could not find any node that satisfies: …"."Reason: Expected exactly '1' node but found 'N' nodes that satisfy: …"."…were found in the unmerged tree. If you really wanted to match against merged tree, use useUnmergedTree = true." and the developer is confused.SemanticsNode.Thread.sleep — the underlying problem is a wrong tag, not a timing issue. Replace with this skill's pattern. See skydoves directive #7.../../synchronization/synchronizing-with-idle/SKILL.md or ../../synchronization/testing-animations-deterministically/SKILL.md.../../finders/finding-nodes-by-tag-text-content/SKILL.md (skydoves directive #1).../enabling-accessibility-checks/SKILL.md.androidx.compose.ui:ui-test on androidTestImplementation or testImplementation (the API lives in commonMain so it is available in both source sets).ComposeTestRule or ComposeUiTest (no extra setup beyond the standard skeleton).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.
rule.onRoot(useUnmergedTree = true).printToLog("DEBUG")
Inner descendants of merging composables (e.g. an Icon inside a Button) only appear in the unmerged tree.
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>.
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.
val tree = rule.onRoot(useUnmergedTree = true).printToString()
println(tree) // or attach to a test report
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()
}
// 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()
}
// 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()
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")
}
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."…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.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.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.useUnmergedTree = true only if the expected node is collapsed. Skydoves directive #2: keep useUnmergedTree = false by default.fetchSemanticsNode(errorMessageOnFail = "…") so the failure message identifies the test, not just the assertion line.printToLog("DEBUG") line for the relevant subtree.Thread.sleep was added to fix a finder problem.useUnmergedTree = true, the finder has been updated to pass that flag.filterToOne, hasAnyAncestor, a parent testTag, etc.) — not by adding sleep or assert-attempts.fetchSemanticsNode(errorMessageOnFail = "…") with a meaningful diagnostic message.printToLog / printToString source: compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Output.kt:41-116compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Output.kt:135-296compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt:178-194fetchSemanticsNode(s) signature: compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt