diff --git a/docs/research/layout-tree-source-names-spike.md b/docs/research/layout-tree-source-names-spike.md index c928c3f..e6be6a0 100644 --- a/docs/research/layout-tree-source-names-spike.md +++ b/docs/research/layout-tree-source-names-spike.md @@ -134,17 +134,17 @@ Avoid replacing existing `componentHint`. Add nullable, best-effort fields under "sourceLine": 42, "sourceColumn": null, "sourcePackageHash": 123456, - "sourceNameConfidence": "tooling-ancestor-node-identity" + "sourceHintKind": "tooling-nearest-app-ancestor" } ``` Recommended semantics: -- `sourceName`: nullable composable/tooling group name nearest to the layout node. +- `sourceName`: nullable composable/tooling group name correlated to the layout node, preferring app/source-file groups over Compose runtime internals. - `sourceFile`, `sourceLine`, `sourceColumn`, `sourcePackageHash`: nullable source location parts from `SourceLocation`. -- `sourceNameConfidence`: optional nullable diagnostic enum/string such as `tooling-node-identity`, `tooling-ancestor-node-identity`, `tooling-bounds-preorder`, or `absent`. +- `sourceHintKind`: optional nullable diagnostic enum/string such as `tooling-node-identity`, `tooling-nearest-app-ancestor`, `tooling-sibling-preorder-app`, `tooling-useful-framework-ancestor`, `tooling-framework-ancestor`, or `preview-entrypoint-fallback`. -Do not require these fields for consumers. Do not fail snapshot extraction when tooling data is unavailable. +Do not require these fields for consumers. Tooling hints may still fall back to framework/internal groups when no app group can be correlated within ancestry/preorder/bounds constraints; CMP Android-target captures may remain preview-entrypoint-fallback-only when composition tooling data is unavailable. Do not fail snapshot extraction when tooling data is unavailable. ## Production recommendation diff --git a/docs/snapshot-schema.md b/docs/snapshot-schema.md index ec4e8a6..91542d0 100644 --- a/docs/snapshot-schema.md +++ b/docs/snapshot-schema.md @@ -62,6 +62,10 @@ Version 2 adds preview parameter metadata, optional layout tree data, and option "boundsPx": { "x": 0, "y": 0, "width": 393, "height": 852 }, "boundsDp": { "x": 0.0, "y": 0.0, "width": 393.0, "height": 852.0 }, "componentHint": "androidx.compose.foundation.layout.Column", + "sourceName": "LoginCard", + "sourceFile": "LoginPreview.kt", + "sourceLine": 24, + "sourceHintKind": "tooling-nearest-app-ancestor", "modifierHint": "androidx.compose.ui.Modifier", "classHint": "androidx.compose.ui.node.LayoutNode", "semanticsId": "7", @@ -94,3 +98,7 @@ Future desktop and web renderers should use separate platform-specific viewport ## Semantics and Layout Tree Fake renderer snapshots emit an empty `nodes` list because no real Compose semantics tree is available. Production renderer snapshots populate `nodes` from Compose semantics when available and may include `layoutTree` entries derived from the rendered Compose layout hierarchy. + +`layoutTree` entries always keep `componentHint` as the implementation-level fallback. Production Android rendering may also add nullable best-effort source hints from Compose tooling data: `sourceName`, `sourceFile`, `sourceLine`, and `sourceHintKind`. The correlation prefers app/preview source files over Compose runtime internals such as `ReusableComposeNode`, `Layout.kt`, `Composer.kt`, and `Composables.kt`; if no app group can be correlated within ancestry/preorder/bounds constraints, the hint may be a useful framework composable or a framework/internal fallback. Current hint kinds include `tooling-node-identity`, `tooling-nearest-app-ancestor`, `tooling-sibling-preorder-app`, `tooling-useful-framework-ancestor`, `tooling-framework-node-identity`, `tooling-framework-ancestor`, `tooling-sibling-preorder-framework`, and `preview-entrypoint-fallback`. + +These fields are optional, depend on Compose tooling/source information being available at render time, and are omitted if enrichment fails. `sourceLine` is emitted only when an actual positive source line is available; preview-entrypoint fallback hints may include `sourceName` and `sourceFile` with `sourceLine` omitted. Compose Multiplatform Android-target captures may currently emit only `preview-entrypoint-fallback` layout source hints when tooling composition data is unavailable for the rendered common source. diff --git a/plugin/src/main/kotlin/dev/staticvar/agentpreview/AgentPreviewPlugin.kt b/plugin/src/main/kotlin/dev/staticvar/agentpreview/AgentPreviewPlugin.kt index f56dc28..82165f5 100644 --- a/plugin/src/main/kotlin/dev/staticvar/agentpreview/AgentPreviewPlugin.kt +++ b/plugin/src/main/kotlin/dev/staticvar/agentpreview/AgentPreviewPlugin.kt @@ -109,6 +109,7 @@ class AgentPreviewPlugin : Plugin { project, project.configurations.detachedConfiguration( project.dependencies.create("androidx.compose.ui:ui-tooling:1.11.2"), + project.dependencies.create("androidx.compose.ui:ui-tooling-data:1.11.2"), ), ) @@ -117,6 +118,7 @@ class AgentPreviewPlugin : Plugin { project, project.configurations.detachedConfiguration( project.dependencies.create("androidx.compose.ui:ui-tooling:1.11.2"), + project.dependencies.create("androidx.compose.ui:ui-tooling-data:1.11.2"), project.dependencies.create("androidx.test:core:1.7.0"), project.dependencies.create("androidx.test:monitor:1.8.0"), ), diff --git a/plugin/src/main/kotlin/dev/staticvar/agentpreview/model/SnapshotLayoutNode.kt b/plugin/src/main/kotlin/dev/staticvar/agentpreview/model/SnapshotLayoutNode.kt index d645140..b158a47 100644 --- a/plugin/src/main/kotlin/dev/staticvar/agentpreview/model/SnapshotLayoutNode.kt +++ b/plugin/src/main/kotlin/dev/staticvar/agentpreview/model/SnapshotLayoutNode.kt @@ -13,6 +13,10 @@ data class SnapshotLayoutNode( val boundsPx: Bounds, val boundsDp: DpBounds, val componentHint: String? = null, + val sourceName: String? = null, + val sourceFile: String? = null, + val sourceLine: Int? = null, + val sourceHintKind: String? = null, val modifierHint: String? = null, val classHint: String? = null, val semanticsId: String? = null, diff --git a/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt b/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt index 0dcf3f4..156d9bd 100644 --- a/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt +++ b/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt @@ -18,6 +18,7 @@ import java.io.OutputStream import java.util.Locale import kotlin.math.roundToInt +@Suppress("LargeClass") object AndroidComposeRendererInRobolectric { fun render( className: String, @@ -49,10 +50,17 @@ object AndroidComposeRendererInRobolectric { setNoActionBarTheme(activity) controller.javaClass.getMethod("setup").invoke(controller) applyConfiguration(activity, density, fontScale ?: DEFAULT_FONT_SCALE, locale, uiMode) - setContent(activity, className, methodName, previewParameterProviderClassName, previewParameterIndex) + val toolingRecord = ToolingCompositionRecord.createOrNull() + setContent(activity, className, methodName, previewParameterProviderClassName, previewParameterIndex, toolingRecord) val view = draw(activity, widthPx.coerceAtLeast(1), heightPx.coerceAtLeast(1), outputFile, showBackground, backgroundColor) writeSemantics(view, semanticsOutputFile, includeUnmergedSemantics) - writeLayoutTree(view, layoutTreeOutputFile, density) + writeLayoutTree( + view, + layoutTreeOutputFile, + density, + toolingRecord, + PreviewSourceFallback(className = className, methodName = methodName), + ) } private fun applyConfiguration( @@ -126,8 +134,10 @@ object AndroidComposeRendererInRobolectric { methodName: String, previewParameterProviderClassName: String?, previewParameterIndex: Int?, + toolingRecord: ToolingCompositionRecord?, ) { - val content = PreviewComposable(className, methodName, previewParameterProviderClassName, previewParameterIndex) + val previewContent = PreviewComposable(className, methodName, previewParameterProviderClassName, previewParameterIndex) + val content = toolingRecord?.wrap(previewContent) ?: previewContent val ownerClass = Class.forName("androidx.activity.ComponentActivity") val setContent = Class @@ -208,10 +218,12 @@ object AndroidComposeRendererInRobolectric { contentRoot: Any, outputFile: File, density: Float, + toolingRecord: ToolingCompositionRecord? = null, + previewSourceFallback: PreviewSourceFallback? = null, ) { runCatching { val composeView = checkNotNull(findComposeView(contentRoot)) { "Compose root view was not found." } - writeLayoutTreeFromComposeView(composeView, outputFile, density) + writeLayoutTreeFromComposeView(composeView, outputFile, density, toolingRecord, previewSourceFallback) }.getOrElse { throwable -> writeEmptyLayoutTreeWarning(outputFile, throwable) } @@ -221,6 +233,8 @@ object AndroidComposeRendererInRobolectric { composeView: Any, outputFile: File, density: Float, + toolingRecord: ToolingCompositionRecord? = null, + previewSourceFallback: PreviewSourceFallback? = null, ) { runCatching { val getRoot = @@ -232,7 +246,15 @@ object AndroidComposeRendererInRobolectric { } ?: error("Compose root view ${composeView.javaClass.name} does not expose getRoot().") val rootLayoutNode = getRoot.invoke(composeView) - val nodes = listOf(extractLayoutTree(rootLayoutNode, density)) + val fallbackSourceHint = previewSourceFallback?.toLayoutTreeSourceHint() + val sourceHints = toolingRecord?.sourceHintsOrEmpty(preferredAppSourceFile = fallbackSourceHint?.sourceFile).orEmpty() + val fallbackHints = + if (sourceHints.isEmpty() && fallbackSourceHint != null) { + mapOf(System.identityHashCode(rootLayoutNode) to fallbackSourceHint) + } else { + emptyMap() + } + val nodes = listOf(extractLayoutTree(rootLayoutNode, density, sourceHints + fallbackHints)) outputFile.writeText(Json.encodeToString(ListSerializer(SnapshotLayoutNode.serializer()), nodes)) }.getOrElse { throwable -> writeEmptyLayoutTreeWarning(outputFile, throwable) @@ -254,7 +276,28 @@ object AndroidComposeRendererInRobolectric { internal fun extractLayoutTree( rootLayoutNode: Any, density: Float, - ): SnapshotLayoutNode = toSnapshotLayoutNode(rootLayoutNode, density, nextLayoutId()) + sourceHints: Map = emptyMap(), + ): SnapshotLayoutNode = toSnapshotLayoutNode(rootLayoutNode, density, nextLayoutId(), sourceHints) + + internal data class LayoutTreeSourceHint( + val sourceName: String? = null, + val sourceFile: String? = null, + val sourceLine: Int? = null, + val sourceHintKind: String? = null, + ) + + internal data class PreviewSourceFallback( + val className: String, + val methodName: String, + ) { + fun toLayoutTreeSourceHint(): LayoutTreeSourceHint = + LayoutTreeSourceHint( + sourceName = methodName, + sourceFile = "${className.substringAfterLast('$').substringAfterLast('.').removeSuffix("Kt")}.kt", + sourceLine = null, + sourceHintKind = "preview-entrypoint-fallback", + ) + } private fun nextLayoutId(): Iterator = generateSequence(1) { it + 1 }.map { id -> "layout-$id" }.iterator() @@ -322,18 +365,24 @@ object AndroidComposeRendererInRobolectric { node: Any, density: Float, ids: Iterator, + sourceHints: Map, ): SnapshotLayoutNode { val id = ids.next() val boundsPx = layoutBounds(node) val semantics = semanticsProperties(method(node, "getSemanticsConfiguration")?.invoke(node)) - val children = layoutChildren(node).map { child -> toSnapshotLayoutNode(child, density, ids) } + val children = layoutChildren(node).map { child -> toSnapshotLayoutNode(child, density, ids, sourceHints) } val measurePolicy = method(node, "getMeasurePolicy")?.invoke(node) val modifier = method(node, "getModifier")?.invoke(node) + val sourceHint = sourceHints[System.identityHashCode(node)] return SnapshotLayoutNode( id = id, boundsPx = boundsPx, boundsDp = boundsPx.toDpBounds(density), componentHint = measurePolicy?.javaClass?.name ?: node.javaClass.name, + sourceName = sourceHint?.sourceName, + sourceFile = sourceHint?.sourceFile, + sourceLine = sourceHint?.sourceLine, + sourceHintKind = sourceHint?.sourceHintKind, modifierHint = modifier?.javaClass?.name ?: modifier?.toString(), classHint = node.javaClass.name, semanticsId = method(node, "getSemanticsId")?.invoke(node)?.toString(), @@ -393,10 +442,17 @@ object AndroidComposeRendererInRobolectric { } } + internal fun accessibleNoArgMethod( + target: Any, + name: String, + ) = target.javaClass.methods + .firstOrNull { it.name == name && it.parameterTypes.isEmpty() } + ?.apply { isAccessible = true } + private fun method( target: Any, name: String, - ) = target.javaClass.methods.firstOrNull { it.name == name && it.parameterTypes.isEmpty() } + ) = accessibleNoArgMethod(target, name) private fun semanticsProperties(config: Any?): Map { val nonNullConfig = config ?: return emptyMap() @@ -502,6 +558,264 @@ object AndroidComposeRendererInRobolectric { field.set(target, value) } + internal class ToolingCompositionRecord private constructor( + private val record: Any, + private val inspectableMethod: java.lang.reflect.Method, + ) { + fun wrap(content: Function2): Function2 = + object : Function2 { + private var warningLogged = false + + override fun invoke( + composer: Any?, + changed: Int, + ) { + runCatching { + inspectableMethod.invoke(null, record, content, composer, changed) + }.getOrElse { throwable -> + if (!warningLogged) { + warningLogged = true + warnSourceHintsDisabled("failed to invoke Compose tooling Inspectable wrapper", throwable) + } + content.invoke(composer, changed) + } + } + } + + fun sourceHintsOrEmpty(preferredAppSourceFile: String? = null): Map = + runCatching { sourceHints(preferredAppSourceFile) }.getOrElse { throwable -> + warnSourceHintsDisabled("failed to read Compose tooling composition data", throwable) + emptyMap() + } + + private fun sourceHints(preferredAppSourceFile: String?): Map { + @Suppress("UNCHECKED_CAST") + val store = method(record, "getStore")?.invoke(record) as? Iterable<*> ?: return emptyMap() + val asTree = Class.forName("androidx.compose.ui.tooling.data.SlotTreeKt").getMethod("asTree", compositionDataClass()) + return buildMap { + store.filterNotNull().forEach { compositionData -> + val rootGroup = asTree.invoke(null, compositionData) + collectGroupSourceHints(rootGroup, hints = this, preferredAppSourceFile = preferredAppSourceFile) + } + } + } + + companion object { + fun createOrNull(): ToolingCompositionRecord? = + runCatching { + val recordClass = Class.forName("androidx.compose.ui.tooling.CompositionDataRecord") + val companion = recordClass.getField("Companion").get(null) + val record = companion.javaClass.getMethod("create").invoke(companion) + val inspectableMethod = + Class + .forName("androidx.compose.ui.tooling.InspectableKt") + .getMethod( + "Inspectable", + recordClass, + Function2::class.java, + compositionComposerClass(), + Int::class.javaPrimitiveType, + ) + ToolingCompositionRecord(record, inspectableMethod) + }.getOrElse { throwable -> + warnSourceHintsDisabled("Compose tooling APIs are unavailable", throwable) + null + } + + private fun compositionDataClass(): Class<*> = Class.forName("androidx.compose.runtime.tooling.CompositionData") + + private fun compositionComposerClass(): Class<*> = Class.forName("androidx.compose.runtime.Composer") + } + } + + @Suppress("CyclomaticComplexMethod") + internal fun collectGroupSourceHints( + group: Any, + hints: MutableMap, + preferredAppSourceFile: String? = null, + ) { + val entries = mutableListOf() + collectToolingGroupEntries(group, parentPreorder = null, depth = 0, entries = entries) + val sourceEntries = entries.filter { it.hint != null } + entries.filter { it.node != null }.forEach { nodeEntry -> + val node = nodeEntry.node ?: return@forEach + val ancestorEntries = + sourceEntries + .filter { sourceEntry -> sourceEntry.preorder in nodeEntry.ancestorPreorders } + val siblingParentPreorders = + (nodeEntry.ancestorPreorders + nodeEntry.preorder) + .mapNotNull { preorder -> entries.getOrNull(preorder)?.parentPreorder } + .toSet() + val siblingEntries = + sourceEntries + .filter { sourceEntry -> + sourceEntry.preorder < nodeEntry.preorder && + sourceEntry.parentPreorder in siblingParentPreorders && + sourceEntry.box != null && + nodeEntry.box != null && + sourceEntry.box.contains(nodeEntry.box) + } + val hint = + nodeEntry.hint?.takeIf { it.isAppSourceHint() }?.copy(sourceHintKind = "tooling-node-identity") + ?: ancestorEntries.nearestAppSourceHint("tooling-nearest-app-ancestor", preferredAppSourceFile) + ?: siblingEntries.nearestAppSourceHint("tooling-sibling-preorder-app", preferredAppSourceFile) + ?: nodeEntry.hint?.takeIf { it.isUsefulFrameworkSourceHint() }?.copy(sourceHintKind = "tooling-framework-node-identity") + ?: ancestorEntries.nearestUsefulFrameworkSourceHint("tooling-useful-framework-ancestor") + ?: siblingEntries.nearestUsefulFrameworkSourceHint("tooling-sibling-preorder-framework") + ?: nodeEntry.hint?.copy(sourceHintKind = "tooling-framework-node-identity") + ?: ancestorEntries.nearestSourceHint("tooling-framework-ancestor") + ?: siblingEntries.nearestSourceHint("tooling-sibling-preorder-framework") + if (hint != null) hints[System.identityHashCode(node)] = hint + } + } + + private fun List.nearestAppSourceHint( + sourceHintKind: String, + preferredAppSourceFile: String?, + ): LayoutTreeSourceHint? = + filter { it.hint?.isAppSourceHint() == true } + .maxWithOrNull( + compareBy { it.depth } + .thenBy { entry -> entry.hint?.preferredAppSourceScore(preferredAppSourceFile) ?: 0 } + .thenBy { it.preorder }, + )?.hint + ?.copy(sourceHintKind = sourceHintKind) + + private fun List.nearestUsefulFrameworkSourceHint(sourceHintKind: String): LayoutTreeSourceHint? = + filter { it.hint?.isUsefulFrameworkSourceHint() == true } + .maxWithOrNull(compareBy { it.depth }.thenBy { it.preorder }) + ?.hint + ?.copy(sourceHintKind = sourceHintKind) + + private fun List.nearestSourceHint(sourceHintKind: String): LayoutTreeSourceHint? = + maxWithOrNull(compareBy { it.depth }.thenBy { it.preorder }) + ?.hint + ?.copy(sourceHintKind = sourceHintKind) + + private fun LayoutTreeSourceHint.isAppSourceHint(): Boolean { + val file = sourceFile.orEmpty() + val name = sourceName.orEmpty() + return (sourceFile != null || sourceName != null) && + !file.isFrameworkSourceFile() && + !file.isGeneratedSourceFile() && + !name.isFrameworkSourceName() + } + + private fun LayoutTreeSourceHint.preferredAppSourceScore(preferredAppSourceFile: String?): Int = + if (preferredAppSourceFile != null && sourceFile == preferredAppSourceFile) 1 else 0 + + private fun String.isFrameworkSourceFile(): Boolean = this in COMPOSE_INTERNAL_SOURCE_FILES || this in COMPOSE_PUBLIC_SOURCE_FILES + + private fun String.isGeneratedSourceFile(): Boolean = + this in GENERATED_SOURCE_FILES || + endsWith(".generated.kt") || + endsWith(".Generated.kt") || + contains("/build/generated/") || + contains("\\build\\generated\\") + + private fun String.isFrameworkSourceName(): Boolean = + this in COMPOSE_INTERNAL_SOURCE_NAMES || + FRAMEWORK_SOURCE_NAME_PREFIXES.any { startsWith(it) } + + private fun LayoutTreeSourceHint.isUsefulFrameworkSourceHint(): Boolean = sourceName in USEFUL_COMPOSE_SOURCE_NAMES + + private fun collectToolingGroupEntries( + group: Any, + parentPreorder: Int?, + depth: Int, + entries: MutableList, + ancestorPreorders: List = emptyList(), + ) { + val preorder = entries.size + val ownName = method(group, "getName")?.invoke(group) as? String + val ownLocation = method(group, "getLocation")?.invoke(group) + val ownHint = sourceHint(ownName, ownLocation, "tooling-node-identity") + val node = method(group, "getNode")?.invoke(group) + entries += + ToolingGroupEntry( + preorder = preorder, + parentPreorder = parentPreorder, + depth = depth, + ancestorPreorders = ancestorPreorders, + hint = ownHint, + node = node, + box = toolingGroupBox(method(group, "getBox")?.invoke(group)), + ) + @Suppress("UNCHECKED_CAST") + val children = method(group, "getChildren")?.invoke(group) as? Iterable<*> ?: return + children.filterNotNull().forEach { child -> + collectToolingGroupEntries( + group = child, + parentPreorder = preorder, + depth = depth + 1, + entries = entries, + ancestorPreorders = ancestorPreorders + preorder, + ) + } + } + + private data class ToolingGroupEntry( + val preorder: Int, + val parentPreorder: Int?, + val depth: Int, + val ancestorPreorders: List, + val hint: LayoutTreeSourceHint?, + val node: Any?, + val box: ToolingIntRect?, + ) + + private data class ToolingIntRect( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, + ) { + fun contains(other: ToolingIntRect): Boolean = + left <= other.left && top <= other.top && right >= other.right && bottom >= other.bottom + } + + private fun toolingGroupBox(box: Any?): ToolingIntRect? { + box ?: return null + val reflected = + ToolingIntRect( + left = method(box, "getLeft")?.invoke(box) as? Int ?: Int.MIN_VALUE, + top = method(box, "getTop")?.invoke(box) as? Int ?: Int.MIN_VALUE, + right = method(box, "getRight")?.invoke(box) as? Int ?: Int.MIN_VALUE, + bottom = method(box, "getBottom")?.invoke(box) as? Int ?: Int.MIN_VALUE, + ).takeIf { rect -> + rect.left != Int.MIN_VALUE && rect.top != Int.MIN_VALUE && rect.right != Int.MIN_VALUE && rect.bottom != Int.MIN_VALUE + } + if (reflected != null) return reflected + val values = INT_RECT_PATTERN.findAll(box.toString()).mapNotNull { it.value.toIntOrNull() }.toList() + return values.takeIf { it.size >= 4 }?.let { ToolingIntRect(it[0], it[1], it[2], it[3]) } + } + + private fun sourceHint( + sourceName: String?, + location: Any?, + sourceHintKind: String, + ): LayoutTreeSourceHint? { + val sourceFile = location?.let { method(it, "getSourceFile")?.invoke(it) as? String } + val sourceLine = location?.let { method(it, "getLineNumber")?.invoke(it) as? Int }?.takeIf { it > 0 } + return LayoutTreeSourceHint( + sourceName = sourceName, + sourceFile = sourceFile, + sourceLine = sourceLine, + sourceHintKind = sourceHintKind, + ).takeIf { it.sourceName != null || it.sourceFile != null || it.sourceLine != null } + } + + private fun warnSourceHintsDisabled( + message: String, + throwable: Throwable, + ) { + System.err.println( + "AgentPreview: optional Compose layout source hints disabled; $message. " + + "Layout tree extraction will continue without source hints. " + + "Cause: ${throwable.javaClass.name}: ${throwable.message}", + ) + } + private const val DENSITY_DEFAULT = 160 private const val PNG_QUALITY = 100 private const val DEFAULT_FONT_SCALE = 1.0f @@ -511,4 +825,65 @@ object AndroidComposeRendererInRobolectric { private const val UI_MODE_NIGHT_NO = 0x10 private const val UI_MODE_NIGHT_YES = 0x20 private const val DEFAULT_BACKGROUND_COLOR = -0x1 + private val INT_RECT_PATTERN = Regex("-?\\d+") + private val COMPOSE_INTERNAL_SOURCE_FILES = + setOf( + "Layout.kt", + "Composer.kt", + "Composables.kt", + "Effects.kt", + "Updater.kt", + ) + private val COMPOSE_PUBLIC_SOURCE_FILES = + setOf( + "BasicText.kt", + "Box.kt", + "Button.kt", + "Card.kt", + "Column.kt", + "Row.kt", + "Spacer.kt", + "Surface.kt", + "Text.kt", + "ProvideContentColorTextStyle.kt", + ) + private val COMPOSE_INTERNAL_SOURCE_NAMES = + setOf( + "ReusableComposeNode", + "ComposeNode", + "ReusableNode", + "Layout", + "CompositionLocalProvider", + "startRestartGroup", + "startReplaceableGroup", + "startReusableGroup", + "Updater", + ) + private val GENERATED_SOURCE_FILES = + setOf( + "R.kt", + "BuildConfig.kt", + ) + private val FRAMEWORK_SOURCE_NAME_PREFIXES = + listOf( + "android.", + "androidx.", + "com.android.", + "java.", + "javax.", + "kotlin.", + "kotlinx.", + "org.jetbrains.compose.", + ) + private val USEFUL_COMPOSE_SOURCE_NAMES = + setOf( + "BasicText", + "Box", + "Button", + "Card", + "Column", + "Row", + "Spacer", + "Text", + ) } diff --git a/plugin/src/test/kotlin/dev/staticvar/agentpreview/model/SnapshotSerializationTest.kt b/plugin/src/test/kotlin/dev/staticvar/agentpreview/model/SnapshotSerializationTest.kt index a75f663..f15b4a7 100644 --- a/plugin/src/test/kotlin/dev/staticvar/agentpreview/model/SnapshotSerializationTest.kt +++ b/plugin/src/test/kotlin/dev/staticvar/agentpreview/model/SnapshotSerializationTest.kt @@ -82,6 +82,10 @@ class SnapshotSerializationTest { boundsPx = Bounds(x = 8, y = 12, width = 40, height = 20), boundsDp = DpBounds(x = 4.0f, y = 6.0f, width = 20.0f, height = 10.0f), componentHint = "androidx.compose.foundation.layout.RowMeasurePolicy", + sourceName = "LoginButton", + sourceFile = "LoginPreview.kt", + sourceLine = 42, + sourceHintKind = "tooling-nearest-app-ancestor", modifierHint = "androidx.compose.ui.Modifier", classHint = "androidx.compose.ui.node.LayoutNode", semanticsId = "7", @@ -104,6 +108,10 @@ class SnapshotSerializationTest { assertTrue(encoded.contains("\"boundsPx\"")) assertTrue(encoded.contains("\"boundsDp\"")) assertTrue(encoded.contains("\"componentHint\"")) + assertTrue(encoded.contains("\"sourceName\"")) + assertTrue(encoded.contains("\"sourceFile\"")) + assertTrue(encoded.contains("\"sourceLine\": 42")) + assertTrue(encoded.contains("\"sourceHintKind\"")) assertEquals(snapshot, json.decodeFromString(PreviewSnapshot.serializer(), encoded)) } diff --git a/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt b/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt index d45f16c..65cc639 100644 --- a/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt +++ b/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt @@ -191,6 +191,288 @@ class AndroidComposeRendererInRobolectricTest { ) } + @Test + fun `extracts layout tree source hints when tooling identity matches runtime node`() { + val child = + FakeLayoutNode( + semanticsId = 7, + coordinates = FakeCoordinates(width = 40, height = 20, rootX = 10.0f, rootY = 6.0f), + measurePolicy = FakeRowMeasurePolicy(), + modifier = FakeModifier("padding"), + semanticsConfiguration = FakeSemanticsConfiguration(), + ) + val root = + FakeLayoutNode( + semanticsId = 1, + coordinates = FakeCoordinates(width = 100, height = 50, rootX = 0.0f, rootY = 0.0f), + measurePolicy = FakeColumnMeasurePolicy(), + modifier = FakeModifier("fillMaxSize"), + semanticsConfiguration = FakeSemanticsConfiguration(), + children = listOf(child), + ) + val sourceHints = + mapOf( + System.identityHashCode(child) to + AndroidComposeRendererInRobolectric.LayoutTreeSourceHint( + sourceName = "LoginButton", + sourceFile = "LoginPreview.kt", + sourceLine = 42, + sourceHintKind = "tooling-nearest-app-ancestor", + ), + ) + + val tree = AndroidComposeRendererInRobolectric.extractLayoutTree(root, density = 2.0f, sourceHints = sourceHints) + + val enriched = tree.children.single() + assertEquals("LoginButton", enriched.sourceName) + assertEquals("LoginPreview.kt", enriched.sourceFile) + assertEquals(42, enriched.sourceLine) + assertEquals("tooling-nearest-app-ancestor", enriched.sourceHintKind) + assertTrue(enriched.componentHint.orEmpty().contains("FakeRowMeasurePolicy")) + } + + @Test + fun `accessible no arg reflection invokes public method on non-public implementation`() { + val record = FakePackagePrivateRecord(setOf("composition-data")) + + val store = AndroidComposeRendererInRobolectric.accessibleNoArgMethod(record, "getStore")?.invoke(record) + + assertEquals(setOf("composition-data"), store) + } + + @Test + fun `correlates sibling source call group to following node group by preorder and bounds`() { + val node = Any() + val rootGroup = + FakeToolingGroup( + children = + listOf( + FakeToolingGroup( + name = "LoginCard", + location = FakeSourceLocation("LoginPreview.kt", 42), + box = FakeIntRect(16, 16, 304, 454), + ), + FakeToolingGroup( + node = node, + box = FakeIntRect(16, 16, 304, 454), + ), + ), + ) + val hints = mutableMapOf() + + AndroidComposeRendererInRobolectric.collectGroupSourceHints(rootGroup, hints) + + val hint = hints.getValue(System.identityHashCode(node)) + assertEquals("LoginCard", hint.sourceName) + assertEquals("LoginPreview.kt", hint.sourceFile) + assertEquals(42, hint.sourceLine) + assertEquals("tooling-sibling-preorder-app", hint.sourceHintKind) + } + + @Test + fun `does not correlate sibling source call group when bounds disagree`() { + val node = Any() + val rootGroup = + FakeToolingGroup( + children = + listOf( + FakeToolingGroup( + name = "UnrelatedHeader", + location = FakeSourceLocation("LoginPreview.kt", 12), + box = FakeIntRect(0, 0, 100, 40), + ), + FakeToolingGroup( + node = node, + box = FakeIntRect(16, 80, 304, 454), + ), + ), + ) + val hints = mutableMapOf() + + AndroidComposeRendererInRobolectric.collectGroupSourceHints(rootGroup, hints) + + assertEquals(null, hints[System.identityHashCode(node)]) + } + + @Test + fun `app source in non preferred file beats nearer compose runtime ancestor`() { + val node = Any() + val rootGroup = + FakeToolingGroup( + name = "LoginPreview", + location = FakeSourceLocation("LoginPreview.kt", 18), + box = FakeIntRect(0, 0, 393, 852), + children = + listOf( + FakeToolingGroup( + name = "OtherComposable", + location = FakeSourceLocation("OtherComposable.kt", 24), + box = FakeIntRect(0, 0, 393, 852), + children = + listOf( + FakeToolingGroup( + name = "ReusableComposeNode", + location = FakeSourceLocation("Layout.kt", 83), + box = FakeIntRect(0, 0, 393, 852), + children = + listOf( + FakeToolingGroup( + node = node, + box = FakeIntRect(0, 0, 393, 852), + ), + ), + ), + ), + ), + ), + ) + val hints = mutableMapOf() + + AndroidComposeRendererInRobolectric.collectGroupSourceHints(rootGroup, hints, preferredAppSourceFile = "LoginPreview.kt") + + val hint = hints.getValue(System.identityHashCode(node)) + assertEquals("OtherComposable", hint.sourceName) + assertEquals("OtherComposable.kt", hint.sourceFile) + assertEquals(24, hint.sourceLine) + assertEquals("tooling-nearest-app-ancestor", hint.sourceHintKind) + } + + @Test + fun `preferred preview source breaks ties among app siblings`() { + val node = Any() + val rootGroup = + FakeToolingGroup( + children = + listOf( + FakeToolingGroup( + name = "OtherComposable", + location = FakeSourceLocation("OtherComposable.kt", 24), + box = FakeIntRect(0, 0, 393, 852), + ), + FakeToolingGroup( + name = "LoginPreview", + location = FakeSourceLocation("LoginPreview.kt", 18), + box = FakeIntRect(0, 0, 393, 852), + ), + FakeToolingGroup( + node = node, + box = FakeIntRect(0, 0, 393, 852), + ), + ), + ) + val hints = mutableMapOf() + + AndroidComposeRendererInRobolectric.collectGroupSourceHints(rootGroup, hints, preferredAppSourceFile = "LoginPreview.kt") + + val hint = hints.getValue(System.identityHashCode(node)) + assertEquals("LoginPreview", hint.sourceName) + assertEquals("LoginPreview.kt", hint.sourceFile) + assertEquals(18, hint.sourceLine) + assertEquals("tooling-sibling-preorder-app", hint.sourceHintKind) + } + + @Test + fun `prefers app source ancestor over nearer compose runtime ancestor`() { + val node = Any() + val rootGroup = + FakeToolingGroup( + name = "LoginPreview", + location = FakeSourceLocation("LoginPreview.kt", 18), + box = FakeIntRect(0, 0, 393, 852), + children = + listOf( + FakeToolingGroup( + name = "ReusableComposeNode", + location = FakeSourceLocation("Layout.kt", 83), + box = FakeIntRect(0, 0, 393, 852), + children = + listOf( + FakeToolingGroup( + node = node, + box = FakeIntRect(0, 0, 393, 852), + ), + ), + ), + ), + ) + val hints = mutableMapOf() + + AndroidComposeRendererInRobolectric.collectGroupSourceHints(rootGroup, hints) + + val hint = hints.getValue(System.identityHashCode(node)) + assertEquals("LoginPreview", hint.sourceName) + assertEquals("LoginPreview.kt", hint.sourceFile) + assertEquals(18, hint.sourceLine) + assertEquals("tooling-nearest-app-ancestor", hint.sourceHintKind) + } + + @Test + fun `prefers app sibling over nearer compose runtime ancestor when bounds contain node`() { + val node = Any() + val rootGroup = + FakeToolingGroup( + children = + listOf( + FakeToolingGroup( + name = "LoginCard", + location = FakeSourceLocation("LoginPreview.kt", 42), + box = FakeIntRect(16, 16, 304, 454), + ), + FakeToolingGroup( + name = "ReusableComposeNode", + location = FakeSourceLocation("Layout.kt", 85), + box = FakeIntRect(16, 16, 304, 454), + children = + listOf( + FakeToolingGroup( + node = node, + box = FakeIntRect(16, 16, 304, 454), + ), + ), + ), + ), + ) + val hints = mutableMapOf() + + AndroidComposeRendererInRobolectric.collectGroupSourceHints(rootGroup, hints) + + val hint = hints.getValue(System.identityHashCode(node)) + assertEquals("LoginCard", hint.sourceName) + assertEquals("LoginPreview.kt", hint.sourceFile) + assertEquals(42, hint.sourceLine) + assertEquals("tooling-sibling-preorder-app", hint.sourceHintKind) + } + + @Test + fun `preview source fallback can enrich root layout node when tooling hints are absent`() { + val outputFile = tempDir.resolve("layout-tree.json") + val root = + FakeLayoutNode( + semanticsId = 1, + coordinates = FakeCoordinates(width = 100, height = 50, rootX = 0.0f, rootY = 0.0f), + measurePolicy = FakeColumnMeasurePolicy(), + modifier = FakeModifier("fillMaxSize"), + semanticsConfiguration = FakeSemanticsConfiguration(), + ) + + AndroidComposeRendererInRobolectric.writeLayoutTreeFromComposeView( + ComposeRoot(root), + outputFile, + density = 2.0f, + previewSourceFallback = + AndroidComposeRendererInRobolectric.PreviewSourceFallback( + className = "dev.example.LoginPreviewKt", + methodName = "LoginPreview", + ), + ) + + val tree = outputFile.readText() + assertTrue(tree.contains("\"sourceName\":\"LoginPreview\""), tree) + assertTrue(tree.contains("\"sourceFile\":\"LoginPreview.kt\""), tree) + assertTrue(!tree.contains("\"sourceLine\""), tree) + assertTrue(tree.contains("\"sourceHintKind\":\"preview-entrypoint-fallback\""), tree) + } + @Test fun `missing compose root writes empty layout tree sidecar and warns`() { val outputFile = tempDir.resolve("layout-tree.json") @@ -198,9 +480,15 @@ class AndroidComposeRendererInRobolectricTest { val warning = captureStderr { AndroidComposeRendererInRobolectric::class.java - .getDeclaredMethod("writeLayoutTree", Any::class.java, File::class.java, Float::class.javaPrimitiveType) - .apply { isAccessible = true } - .invoke(AndroidComposeRendererInRobolectric, ViewWithoutComposeRoot(), outputFile, 2.0f) + .getDeclaredMethod( + "writeLayoutTree", + Any::class.java, + File::class.java, + Float::class.javaPrimitiveType, + AndroidComposeRendererInRobolectric.ToolingCompositionRecord::class.java, + AndroidComposeRendererInRobolectric.PreviewSourceFallback::class.java, + ).apply { isAccessible = true } + .invoke(AndroidComposeRendererInRobolectric, ViewWithoutComposeRoot(), outputFile, 2.0f, null, null) } assertEquals("[]", outputFile.readText()) @@ -262,6 +550,12 @@ class AndroidComposeRendererInRobolectricTest { private class ViewWithoutComposeRoot + private class ComposeRoot( + private val root: Any, + ) { + fun getRoot(): Any = root + } + private class ComposeRootWithBrokenLayoutNode { fun getRoot(): Any = error("boom") } @@ -341,6 +635,54 @@ class AndroidComposeRendererInRobolectricTest { fun getName(): String = name } + private class FakePackagePrivateRecord( + private val store: Set, + ) { + fun getStore(): Set = store + } + + private class FakeToolingGroup( + private val name: String? = null, + private val location: FakeSourceLocation? = null, + private val node: Any? = null, + private val box: FakeIntRect? = null, + private val children: List = emptyList(), + ) { + fun getName(): String? = name + + fun getLocation(): FakeSourceLocation? = location + + fun getNode(): Any? = node + + fun getBox(): FakeIntRect? = box + + fun getChildren(): List = children + } + + private class FakeSourceLocation( + private val sourceFile: String, + private val lineNumber: Int, + ) { + fun getSourceFile(): String = sourceFile + + fun getLineNumber(): Int = lineNumber + } + + private class FakeIntRect( + private val left: Int, + private val top: Int, + private val right: Int, + private val bottom: Int, + ) { + fun getLeft(): Int = left + + fun getTop(): Int = top + + fun getRight(): Int = right + + fun getBottom(): Int = bottom + } + private class FakeSemanticsNode { fun getChildren( includeReplacedSemantics: Boolean,