From 0d9cf52094d168382e0f3ae4822b31d2b7791fb6 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Mon, 10 Aug 2020 19:26:12 -0700 Subject: [PATCH] Introduce support for rendering Compose hierarchies. --- build.gradle.kts | 2 + buildSrc/src/main/java/Dependencies.kt | 13 +- compose-tests/README.md | 5 + compose-tests/build.gradle.kts | 58 ++++ .../src/androidTest/AndroidManifest.xml | 1 + .../radiography/test/compose/ComposeUiTest.kt | 275 ++++++++++++++++++ compose-tests/src/main/AndroidManifest.xml | 1 + compose-unsupported-tests/README.md | 3 + compose-unsupported-tests/build.gradle.kts | 53 ++++ .../src/androidTest/AndroidManifest.xml | 1 + .../test/compose/ComposeUnsupportedTest.kt | 33 +++ .../src/main/AndroidManifest.xml | 1 + radiography/api/radiography.api | 26 ++ radiography/build.gradle.kts | 15 + .../src/main/java/radiography/Strings.kt | 9 + .../java/radiography/ViewStateRenderers.kt | 13 +- .../radiography/ViewTreeRenderingVisitor.kt | 9 + .../compose/ComposeLayoutFilters.kt | 41 +++ .../radiography/compose/ComposeLayoutInfo.kt | 57 ++++ .../compose/ComposeLayoutRenderers.kt | 132 +++++++++ .../java/radiography/compose/ComposeViews.kt | 154 ++++++++++ .../ExperimentalRadiographyComposeApi.kt | 7 + .../radiography/compose/LayoutInfoVisitor.kt | 71 +++++ .../java/radiography/compose/SlotTables.kt | 31 ++ .../sample/compose/ComposeSampleSmokeTest.kt | 17 -- .../sample/compose/ComposeSampleUiTest.kt | 37 +++ .../sample/compose/ComposeSampleApp.kt | 50 +++- sample/build.gradle.kts | 2 +- settings.gradle.kts | 2 + 29 files changed, 1086 insertions(+), 33 deletions(-) create mode 100644 compose-tests/README.md create mode 100644 compose-tests/build.gradle.kts create mode 100644 compose-tests/src/androidTest/AndroidManifest.xml create mode 100644 compose-tests/src/androidTest/java/radiography/test/compose/ComposeUiTest.kt create mode 100644 compose-tests/src/main/AndroidManifest.xml create mode 100644 compose-unsupported-tests/README.md create mode 100644 compose-unsupported-tests/build.gradle.kts create mode 100644 compose-unsupported-tests/src/androidTest/AndroidManifest.xml create mode 100644 compose-unsupported-tests/src/androidTest/java/radiography/test/compose/ComposeUnsupportedTest.kt create mode 100644 compose-unsupported-tests/src/main/AndroidManifest.xml create mode 100644 radiography/src/main/java/radiography/Strings.kt create mode 100644 radiography/src/main/java/radiography/compose/ComposeLayoutFilters.kt create mode 100644 radiography/src/main/java/radiography/compose/ComposeLayoutInfo.kt create mode 100644 radiography/src/main/java/radiography/compose/ComposeLayoutRenderers.kt create mode 100644 radiography/src/main/java/radiography/compose/ComposeViews.kt create mode 100644 radiography/src/main/java/radiography/compose/ExperimentalRadiographyComposeApi.kt create mode 100644 radiography/src/main/java/radiography/compose/LayoutInfoVisitor.kt create mode 100644 radiography/src/main/java/radiography/compose/SlotTables.kt delete mode 100644 sample-compose/src/androidTest/java/com/squareup/radiography/sample/compose/ComposeSampleSmokeTest.kt create mode 100644 sample-compose/src/androidTest/java/com/squareup/radiography/sample/compose/ComposeSampleUiTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 2f330b5..d78c112 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,6 +51,8 @@ extensions.configure { // Only leaf project name is valid configuration, and every project must be individually ignored. // See https://github.com/Kotlin/binary-compatibility-validator/issues/3 ignoredProjects = mutableSetOf( + "compose-tests", + "compose-unsupported-tests", "sample", "sample-compose" ) diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 44b839e..518c74e 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -1,14 +1,19 @@ object Versions { /** - * To change this in the IDE, use `systemProp.square.kotlinVersion=x.y.z` in your - * `~/.gradle/gradle.properties` file. - */ + * To change this in the IDE, use `systemProp.square.kotlinVersion=x.y.z` in your + * `~/.gradle/gradle.properties` file. + */ val KotlinCompiler = System.getProperty("square.kotlinVersion") ?: "1.4.0" /** Use a lower version of the stdlib so the library can be consumed by lower kotlin versions. */ val KotlinStdlib = System.getProperty("square.kotlinStdlibVersion") ?: "1.3.72" const val Compose = "0.1.0-dev17" + + // Allows using a different version of Compose to validate that we degrade gracefully on apps + // built with unsupported Compose versions. + const val OldCompose = "0.1.0-dev12" + const val OldComposeCompiler = "1.3.70-dev-withExperimentalGoogleExtensions-20200424" } object Dependencies { @@ -29,6 +34,8 @@ object Dependencies { object Compose { const val Material = "androidx.compose.material:material:${Versions.Compose}" const val Testing = "androidx.ui:ui-test:${Versions.Compose}" + const val OldMaterial = "androidx.ui:ui-material:${Versions.OldCompose}" + const val OldTesting = "androidx.ui:ui-test:${Versions.OldCompose}" const val Tooling = "androidx.ui:ui-tooling:${Versions.Compose}" } diff --git a/compose-tests/README.md b/compose-tests/README.md new file mode 100644 index 0000000..df374c1 --- /dev/null +++ b/compose-tests/README.md @@ -0,0 +1,5 @@ +# compose-tests + +Contains the UI tests for Compose support. These can't live in the main radiography module since +they require the Compose compiler to be turned on. No non-test source code should be placed in this +module. diff --git a/compose-tests/build.gradle.kts b/compose-tests/build.gradle.kts new file mode 100644 index 0000000..e84c458 --- /dev/null +++ b/compose-tests/build.gradle.kts @@ -0,0 +1,58 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("com.android.library") + kotlin("android") +} + +android { + compileSdkVersion(30) + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion(21) + targetSdkVersion(28) + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + buildConfig = false + compose = true + } + + composeOptions { + kotlinCompilerVersion = Versions.KotlinCompiler + kotlinCompilerExtensionVersion = Versions.Compose + } +} + +tasks.withType { + kotlinOptions { + jvmTarget = "1.8" + freeCompilerArgs = listOf( + "-Xallow-jvm-ir-dependencies", + "-Xskip-prerelease-check", + "-Xopt-in=kotlin.RequiresOptIn" + ) + } +} + +dependencies { + // Don't use Versions.KotlinStdlib for Kotlin stdlib, since this module actually uses the Compose + // compiler and needs the latest stdlib. + + androidTestImplementation(project(":radiography")) + androidTestImplementation(Dependencies.AppCompat) + androidTestImplementation(Dependencies.Compose.Material) + androidTestImplementation(Dependencies.Compose.Testing) + androidTestImplementation(Dependencies.Compose.Tooling) + androidTestImplementation(Dependencies.InstrumentationTests.Rules) + androidTestImplementation(Dependencies.InstrumentationTests.Runner) + androidTestImplementation(Dependencies.Truth) +} diff --git a/compose-tests/src/androidTest/AndroidManifest.xml b/compose-tests/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..a7a07c0 --- /dev/null +++ b/compose-tests/src/androidTest/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/compose-tests/src/androidTest/java/radiography/test/compose/ComposeUiTest.kt b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeUiTest.kt new file mode 100644 index 0000000..6ee0a8a --- /dev/null +++ b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeUiTest.kt @@ -0,0 +1,275 @@ +package radiography.test.compose + +import android.view.ViewGroup.LayoutParams +import android.widget.TextView +import androidx.compose.foundation.Box +import androidx.compose.foundation.Text +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Stack +import androidx.compose.foundation.layout.size +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.runtime.SlotTable +import androidx.compose.runtime.currentComposer +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.node.Ref +import androidx.compose.ui.platform.DensityAmbient +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsProperties.AccessibilityLabel +import androidx.compose.ui.semantics.SemanticsProperties.AccessibilityValue +import androidx.compose.ui.semantics.SemanticsProperties.Disabled +import androidx.compose.ui.semantics.SemanticsProperties.Focused +import androidx.compose.ui.semantics.SemanticsProperties.Hidden +import androidx.compose.ui.semantics.SemanticsProperties.IsDialog +import androidx.compose.ui.semantics.SemanticsProperties.IsPopup +import androidx.compose.ui.semantics.SemanticsProperties.TestTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.viewinterop.AndroidView +import androidx.ui.test.createComposeRule +import androidx.ui.test.runOnIdle +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import radiography.Radiography +import radiography.ViewStateRenderers.DefaultsIncludingPii +import radiography.ViewStateRenderers.DefaultsNoPii +import radiography.compose.ComposeLayoutFilters.skipLayoutIdsFilter +import radiography.compose.ComposeLayoutFilters.skipTestTagsFilter +import radiography.compose.ComposeLayoutRenderers.StandardSemanticsRenderer +import radiography.compose.ComposeLayoutRenderers.composeTextRenderer +import radiography.compose.ExperimentalRadiographyComposeApi +import radiography.compose.scan + +@OptIn(ExperimentalRadiographyComposeApi::class) +class ComposeUiTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test fun when_includingPii_then_hierarchyContainsText() { + composeRule.setContent { + Text("FooBar") + } + + runOnIdle { + val hierarchy = Radiography.scan(viewStateRenderers = DefaultsIncludingPii) + assertThat(hierarchy).contains("FooBar") + assertThat(hierarchy).contains("text-length:6") + } + } + + @Test fun when_noPii_then_hierarchyExcludesText() { + composeRule.setContent { + Text("FooBar") + } + + runOnIdle { + val hierarchy = Radiography.scan(viewStateRenderers = DefaultsNoPii) + assertThat(hierarchy).doesNotContain("FooBar") + assertThat(hierarchy).contains("text-length:6") + } + } + + @Test fun viewSizeReported() { + composeRule.setContent { + val (width, height) = with(DensityAmbient.current) { + Pair(30.toDp(), 40.toDp()) + } + Box(modifier = Modifier.size(width, height)) + } + + val hierarchy = runOnIdle { + Radiography.scan(viewStateRenderers = emptyList()) + } + + assertThat(hierarchy).contains("Box { 30×40px }") + } + + @Test fun zeroSizeViewReported() { + composeRule.setContent { + Box() + } + + val hierarchy = runOnIdle { + Radiography.scan(viewStateRenderers = emptyList()) + } + + assertThat(hierarchy).contains("Box { }") + } + + @Test fun semanticsAreReported() { + composeRule.setContent { + Box(Modifier.semantics { set(TestTag, "test tag") }) + Box(Modifier.semantics { set(AccessibilityLabel, "acc label") }) + Box(Modifier.semantics { set(AccessibilityValue, "acc value") }) + Box(Modifier.semantics { set(Disabled, Unit) }) + Box(Modifier.semantics { set(Focused, true) }) + Box(Modifier.semantics { set(Focused, false) }) + Box(Modifier.semantics { set(Hidden, Unit) }) + Box(Modifier.semantics { set(IsDialog, Unit) }) + Box(Modifier.semantics { set(IsPopup, Unit) }) + } + + val hierarchy = runOnIdle { + Radiography.scan() + } + + assertThat(hierarchy).contains("Box { test-tag:\"test tag\" }") + assertThat(hierarchy).contains("Box { label:\"acc label\" }") + assertThat(hierarchy).contains("Box { value:\"acc value\" }") + assertThat(hierarchy).contains("Box { DISABLED }") + assertThat(hierarchy).contains("Box { FOCUSED }") + assertThat(hierarchy).contains("Box { }") + assertThat(hierarchy).contains("Box { HIDDEN }") + assertThat(hierarchy).contains("Box { DIALOG }") + assertThat(hierarchy).contains("Box { POPUP }") + } + + @Test fun checkableChecked() { + composeRule.setContent { + Checkbox(checked = true, onCheckedChange = {}) + } + + val hierarchy = runOnIdle { + Radiography.scan(viewStateRenderers = listOf(StandardSemanticsRenderer)) + } + + assertThat(hierarchy).contains("Checkbox") + assertThat(hierarchy).contains("Checked") + } + + @Test fun textViewContents() { + composeRule.setContent { + Text("Baguette Avec Fromage") + } + + val hierarchy = runOnIdle { + Radiography.scan(viewStateRenderers = listOf(composeTextRenderer(includeText = true))) + } + + assertThat(hierarchy).contains("text-length:21") + assertThat(hierarchy).contains("text:\"Baguette Avec Fromage\"") + } + + @Test fun textViewContentsEllipsized() { + composeRule.setContent { + Text("Baguette Avec Fromage") + } + + val hierarchy = runOnIdle { + Radiography.scan( + viewStateRenderers = listOf( + composeTextRenderer( + includeText = true, + maxTextLength = 11 + ) + ) + ) + } + + assertThat(hierarchy).contains("text-length:21") + assertThat(hierarchy).contains("text:\"Baguette A…\"") + } + + @Test fun skipTestTags() { + composeRule.setContent { + Box { + Button(modifier = Modifier.testTag("42"), onClick = {}, content = {}) + } + } + + val hierarchy = runOnIdle { + Radiography.scan(viewFilter = skipTestTagsFilter("42")) + } + + assertThat(hierarchy).contains("Box") + assertThat(hierarchy).doesNotContain("Button") + } + + @Test fun skipLayoutIds() { + val layoutId = Any() + + composeRule.setContent { + Box { + Button(modifier = Modifier.layoutId(layoutId), onClick = {}, content = {}) + } + } + + val hierarchy = runOnIdle { + Radiography.scan(viewFilter = skipLayoutIdsFilter { it === layoutId }) + } + + assertThat(hierarchy).contains("Box") + assertThat(hierarchy).doesNotContain("Button") + } + + @Test fun nestedLayouts() { + val slotTable = Ref() + composeRule.setContent { + slotTable.value = currentComposer.slotTable + + Stack { + Box() + Column { + Box() + Box() + } + Row { + Box() + Box() + } + } + } + + val hierarchy = runOnIdle { + slotTable.value!!.scan() + } + + assertThat(hierarchy).contains( + """ +  Stack { } +  $BLANK +-Box { } +  $BLANK +-Column { } +  $BLANK | +-Box { } +  $BLANK | `-Box { } +  $BLANK `-Row { } +  $BLANK +-Box { } +  $BLANK `-Box { } + """.trimIndent() + ) + } + + @Test fun nestedViewsInsideLayouts() { + val slotTable = Ref() + composeRule.setContent { + slotTable.value = currentComposer.slotTable + + Box { + AndroidView(::TextView) { + it.layoutParams = LayoutParams(0, 0) + } + } + } + + val hierarchy = runOnIdle { + slotTable.value!!.scan() + } + + // The stuff below the AndroidView is implementation details, testing it is brittle and + // pointless. + assertThat(hierarchy).contains( + """ +  Box { } +  $BLANK `-AndroidView { } + """.trimIndent() + ) + // But this view description should show up at some point. + assertThat(hierarchy).contains("`-TextView { 0×0px, text-length:0 }") + } + + companion object { + private const val BLANK = '\u00a0' + } +} diff --git a/compose-tests/src/main/AndroidManifest.xml b/compose-tests/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cee04d2 --- /dev/null +++ b/compose-tests/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/compose-unsupported-tests/README.md b/compose-unsupported-tests/README.md new file mode 100644 index 0000000..21976b5 --- /dev/null +++ b/compose-unsupported-tests/README.md @@ -0,0 +1,3 @@ +# compose-unsupported-tests + +Tests that the library degrades gracefully in an app that's using an unsupported Compose version. diff --git a/compose-unsupported-tests/build.gradle.kts b/compose-unsupported-tests/build.gradle.kts new file mode 100644 index 0000000..938de0c --- /dev/null +++ b/compose-unsupported-tests/build.gradle.kts @@ -0,0 +1,53 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("com.android.library") + kotlin("android") +} + +android { + compileSdkVersion(30) + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion(21) + targetSdkVersion(28) + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + buildConfig = false + compose = true + } + + composeOptions { + kotlinCompilerVersion = Versions.OldComposeCompiler + kotlinCompilerExtensionVersion = Versions.OldCompose + } +} + +tasks.withType { + kotlinOptions { + jvmTarget = "1.8" + freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn") + } +} + +dependencies { + // Don't use Versions.KotlinStdlib for Kotlin stdlib, since this module actually uses the Compose + // compiler and needs the latest stdlib. + + androidTestImplementation(project(":radiography")) + androidTestImplementation(Dependencies.AppCompat) + androidTestImplementation(Dependencies.Compose.OldMaterial) + androidTestImplementation(Dependencies.Compose.OldTesting) + androidTestImplementation(Dependencies.InstrumentationTests.Rules) + androidTestImplementation(Dependencies.InstrumentationTests.Runner) + androidTestImplementation(Dependencies.Truth) +} diff --git a/compose-unsupported-tests/src/androidTest/AndroidManifest.xml b/compose-unsupported-tests/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..9f87da8 --- /dev/null +++ b/compose-unsupported-tests/src/androidTest/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/compose-unsupported-tests/src/androidTest/java/radiography/test/compose/ComposeUnsupportedTest.kt b/compose-unsupported-tests/src/androidTest/java/radiography/test/compose/ComposeUnsupportedTest.kt new file mode 100644 index 0000000..1423d28 --- /dev/null +++ b/compose-unsupported-tests/src/androidTest/java/radiography/test/compose/ComposeUnsupportedTest.kt @@ -0,0 +1,33 @@ +package radiography.test.compose + +import androidx.ui.foundation.Text +import androidx.ui.test.createComposeRule +import androidx.ui.test.runOnIdleCompose +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import radiography.Radiography +import radiography.ViewStateRenderers.DefaultsIncludingPii + +class ComposeUnsupportedTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test fun when_composeVersionNotSupported_then_failsGracefully() { + composeRule.setContent { + Text("FooBar") + } + + runOnIdleCompose { + val hierarchy = Radiography.scan(viewStateRenderers = DefaultsIncludingPii) + assertThat(hierarchy).doesNotContain("FooBar") + assertThat(hierarchy).contains( + "Composition was found, but either Compose Tooling artifact is missing or the Compose " + + "version is not supported. Please ensure you have a dependency on " + + "androidx.ui:ui-tooling or check https://github.com/square/radiography for a new " + + "release." + ) + } + } +} diff --git a/compose-unsupported-tests/src/main/AndroidManifest.xml b/compose-unsupported-tests/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4ae2014 --- /dev/null +++ b/compose-unsupported-tests/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/radiography/api/radiography.api b/radiography/api/radiography.api index 27eb7c7..12a8dcc 100644 --- a/radiography/api/radiography.api +++ b/radiography/api/radiography.api @@ -47,3 +47,29 @@ public final class radiography/ViewsKt { public static synthetic fun scan$default (Landroid/view/View;Ljava/util/List;Lradiography/ViewFilter;ILjava/lang/Object;)Ljava/lang/String; } +public final class radiography/compose/ComposeLayoutFilters { + public static final field INSTANCE Lradiography/compose/ComposeLayoutFilters; + public static final fun skipLayoutIdsFilter (Lkotlin/jvm/functions/Function1;)Lradiography/ViewFilter; + public static final fun skipTestTagsFilter ([Ljava/lang/String;)Lradiography/ViewFilter; +} + +public final class radiography/compose/ComposeLayoutRenderers { + public static final field DefaultsIncludingPii Ljava/util/List; + public static final field DefaultsNoPii Ljava/util/List; + public static final field INSTANCE Lradiography/compose/ComposeLayoutRenderers; + public static final field LayoutIdRenderer Lradiography/ViewStateRenderer; + public static final field StandardSemanticsRenderer Lradiography/ViewStateRenderer; + public static final fun composeTextRenderer ()Lradiography/ViewStateRenderer; + public static final fun composeTextRenderer (Z)Lradiography/ViewStateRenderer; + public static final fun composeTextRenderer (ZI)Lradiography/ViewStateRenderer; + public static synthetic fun composeTextRenderer$default (ZIILjava/lang/Object;)Lradiography/ViewStateRenderer; +} + +public abstract interface annotation class radiography/compose/ExperimentalRadiographyComposeApi : java/lang/annotation/Annotation { +} + +public final class radiography/compose/SlotTables { + public static final synthetic fun scan (Landroidx/compose/runtime/SlotTable;Ljava/util/List;Lradiography/ViewFilter;)Ljava/lang/String; + public static synthetic fun scan$default (Landroidx/compose/runtime/SlotTable;Ljava/util/List;Lradiography/ViewFilter;ILjava/lang/Object;)Ljava/lang/String; +} + diff --git a/radiography/build.gradle.kts b/radiography/build.gradle.kts index 13bdd0a..2ed45cd 100644 --- a/radiography/build.gradle.kts +++ b/radiography/build.gradle.kts @@ -56,7 +56,22 @@ tasks.withType { } } +tasks.withType { + kotlinOptions { + freeCompilerArgs = listOf( + // allow-jvm-ir-dependencies is required to consume binaries built with the IR backend. + // It doesn't change the bytecode that gets generated for this module. + "-Xallow-jvm-ir-dependencies", + "-Xopt-in=kotlin.RequiresOptIn" + ) + } +} + dependencies { + // We don't want to bring any Compose dependencies in unless the consumer of this library is + // bringing them in itself. + compileOnly(Dependencies.Compose.Tooling) + implementation(kotlin("stdlib", Versions.KotlinStdlib)) testImplementation(Dependencies.JUnit) diff --git a/radiography/src/main/java/radiography/Strings.kt b/radiography/src/main/java/radiography/Strings.kt new file mode 100644 index 0000000..bdd0933 --- /dev/null +++ b/radiography/src/main/java/radiography/Strings.kt @@ -0,0 +1,9 @@ +package radiography + +internal fun CharSequence.ellipsize(maxLength: Int): CharSequence = + if (length > maxLength) "${subSequence(0, maxLength - 1)}…" else this + +internal fun formatPixelDimensions( + width: Int, + height: Int +): String = "$width×${height}px" diff --git a/radiography/src/main/java/radiography/ViewStateRenderers.kt b/radiography/src/main/java/radiography/ViewStateRenderers.kt index 6f95403..9ad75b7 100644 --- a/radiography/src/main/java/radiography/ViewStateRenderers.kt +++ b/radiography/src/main/java/radiography/ViewStateRenderers.kt @@ -4,7 +4,10 @@ import android.content.res.Resources.NotFoundException import android.view.View import android.widget.Checkable import android.widget.TextView +import radiography.compose.ComposeLayoutRenderers +import radiography.compose.ExperimentalRadiographyComposeApi +@OptIn(ExperimentalRadiographyComposeApi::class) public object ViewStateRenderers { @JvmField @@ -23,7 +26,7 @@ public object ViewStateRenderers { View.INVISIBLE -> append("INVISIBLE") } - append("${view.width}×${view.height}px") + append(formatPixelDimensions(view.width, view.height)) if (view.isFocused) { append("focused") @@ -50,14 +53,14 @@ public object ViewStateRenderers { ViewRenderer, textViewRenderer(includeTextViewText = false, textViewTextMaxLength = 0), CheckableRenderer - ) + ) + ComposeLayoutRenderers.DefaultsNoPii @JvmField public val DefaultsIncludingPii: List = listOf( ViewRenderer, textViewRenderer(includeTextViewText = true), CheckableRenderer - ) + ) + ComposeLayoutRenderers.DefaultsIncludingPii /** * @param includeTextViewText whether to include the string content of TextView instances in @@ -83,9 +86,7 @@ public object ViewStateRenderers { if (text != null) { append("text-length:${text.length}") if (includeTextViewText) { - if (text.length > textViewTextMaxLength) { - text = "${text.subSequence(0, textViewTextMaxLength - 1)}…" - } + text = text.ellipsize(textViewTextMaxLength) append("text:\"$text\"") } } diff --git a/radiography/src/main/java/radiography/ViewTreeRenderingVisitor.kt b/radiography/src/main/java/radiography/ViewTreeRenderingVisitor.kt index e804a96..99a9b4e 100644 --- a/radiography/src/main/java/radiography/ViewTreeRenderingVisitor.kt +++ b/radiography/src/main/java/radiography/ViewTreeRenderingVisitor.kt @@ -4,6 +4,7 @@ import android.annotation.TargetApi import android.os.Build.VERSION_CODES import android.view.View import android.view.ViewGroup +import radiography.compose.tryVisitComposeView /** * A [TreeRenderingVisitor] that renders [View]s and their children which match [viewFilter] using @@ -17,6 +18,14 @@ internal class ViewTreeRenderingVisitor( override fun RenderingScope.visitNode(node: View) { description.viewToString(node) + val isComposeView = tryVisitComposeView( + this, node, viewStateRenderers, viewFilter, this@ViewTreeRenderingVisitor + ) + if (isComposeView) { + // Don't visit children ourselves, the compose renderer will have done that. + return + } + if (node !is ViewGroup) return // Capture this value, since it might change while we're iterating. diff --git a/radiography/src/main/java/radiography/compose/ComposeLayoutFilters.kt b/radiography/src/main/java/radiography/compose/ComposeLayoutFilters.kt new file mode 100644 index 0000000..f00a58e --- /dev/null +++ b/radiography/src/main/java/radiography/compose/ComposeLayoutFilters.kt @@ -0,0 +1,41 @@ +package radiography.compose + +import androidx.compose.ui.layout.LayoutIdParentData +import androidx.compose.ui.semantics.SemanticsModifier +import androidx.compose.ui.semantics.SemanticsProperties.TestTag +import radiography.ViewFilter +import radiography.ViewFilters.viewFilterFor + +@ExperimentalRadiographyComposeApi +object ComposeLayoutFilters { + + /** + * Filters out Composables with [`testTag`][androidx.compose.ui.platform.testTag] modifiers + * matching [skippedTestTags]. + */ + @ExperimentalRadiographyComposeApi + @JvmStatic + fun skipTestTagsFilter(vararg skippedTestTags: String): ViewFilter = + viewFilterFor { layoutInfo -> + layoutInfo.modifiers.asSequence() + .filterIsInstance() + .flatMap { semantics -> + semantics.semanticsConfiguration.asSequence() + .filter { it.key == TestTag } + } + .none { it.value in skippedTestTags } + } + + /** + * Filters out Composables with [`layoutId`][androidx.compose.ui.layout.layoutId] modifiers for + * which [skipLayoutId] returns true. + */ + @ExperimentalRadiographyComposeApi + @JvmStatic + fun skipLayoutIdsFilter(skipLayoutId: (Any) -> Boolean): ViewFilter = + viewFilterFor { layoutInfo -> + layoutInfo.modifiers.asSequence() + .filterIsInstance() + .none { skipLayoutId(it.id) } + } +} diff --git a/radiography/src/main/java/radiography/compose/ComposeLayoutInfo.kt b/radiography/src/main/java/radiography/compose/ComposeLayoutInfo.kt new file mode 100644 index 0000000..de37dff --- /dev/null +++ b/radiography/src/main/java/radiography/compose/ComposeLayoutInfo.kt @@ -0,0 +1,57 @@ +package radiography.compose + +import android.view.View +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntBounds +import androidx.ui.tooling.Group +import androidx.ui.tooling.NodeGroup + +/** + * Information about a Compose `LayoutNode`, extracted from a [Group] tree via [Group.layoutInfos]. + */ +internal class ComposeLayoutInfo( + val name: String, + val bounds: IntBounds, + val modifiers: List, + val children: Sequence, + val view: View? +) + +/** + * A sequence that lazily parses [ComposeLayoutInfo]s from a [Group] tree. + */ +internal val Group.layoutInfos: Sequence get() = computeLayoutInfos() + +/** + * Recursively parses [ComposeLayoutInfo]s from a [Group]. Groups form a tree and can contain different + * type of nodes which represent function calls, arbitrary data stored directly in the slot table, + * or just subtrees. + * + * This function walks the tree and collects only Groups which represent emitted values + * ([NodeGroup]s). These either represent `LayoutNode`s (Compose's internal primitive for layout + * algorithms) or classic Android views that the composition emitted. This function collapses all + * the groups in between each of these nodes, but uses the top-most Group under the previous node + * to derive the "name" of the [ComposeLayoutInfo]. The other [ComposeLayoutInfo] properties come directly off + * [NodeGroup] values. + */ +private fun Group.computeLayoutInfos(parentName: String = ""): Sequence { + val name = parentName.ifBlank { this.name }.orEmpty() + + if (this !is NodeGroup) { + return children.asSequence() + .flatMap { it.computeLayoutInfos(name) } + } + + val children = children.asSequence() + // This node will "consume" the name, so reset it name to empty for children. + .flatMap { it.computeLayoutInfos() } + + val layoutInfo = ComposeLayoutInfo( + name = name, + bounds = box, + modifiers = modifierInfo.map { it.modifier }, + children = children, + view = node as? View + ) + return sequenceOf(layoutInfo) +} diff --git a/radiography/src/main/java/radiography/compose/ComposeLayoutRenderers.kt b/radiography/src/main/java/radiography/compose/ComposeLayoutRenderers.kt new file mode 100644 index 0000000..fb7a6a5 --- /dev/null +++ b/radiography/src/main/java/radiography/compose/ComposeLayoutRenderers.kt @@ -0,0 +1,132 @@ +package radiography.compose + +import androidx.compose.ui.layout.LayoutIdParentData +import androidx.compose.ui.platform.InspectableParameter +import androidx.compose.ui.semantics.SemanticsModifier +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.SemanticsProperties.Text +import androidx.compose.ui.semantics.SemanticsPropertyKey +import radiography.AttributeAppendable +import radiography.TypedViewStateRenderer +import radiography.ViewStateRenderer +import radiography.ellipsize + +// Note that this class can't use viewStateRendererFor, since that function is defined in the +// ViewStateRenderers object, whose class initializer may initialize _this_ class, which will cause +// NoClassDefFoundExceptions. This can happen when a debugger is attached. +@ExperimentalRadiographyComposeApi +public object ComposeLayoutRenderers { + + /** + * Renders [layoutId][androidx.compose.ui.layout.layoutId] modifiers. + */ + @ExperimentalRadiographyComposeApi + @JvmField + val LayoutIdRenderer: ViewStateRenderer = if (!isComposeAvailable) NoRenderer else { + object : TypedViewStateRenderer(InspectableParameter::class.java) { + override fun AttributeAppendable.renderTyped(rendered: InspectableParameter) { + if (rendered is LayoutIdParentData) { + val idValue = if (rendered.id is CharSequence) { + "\"${rendered.id}\"" + } else { + rendered.id.toString() + } + append("layout-id:$idValue") + } + } + } + } + + /** + * Renderer for standard semantics properties defined in [SemanticsProperties]. + */ + @ExperimentalRadiographyComposeApi + @JvmField + val StandardSemanticsRenderer: ViewStateRenderer = + if (!isComposeAvailable) NoRenderer else { + object : TypedViewStateRenderer(SemanticsModifier::class.java) { + override fun AttributeAppendable.renderTyped(rendered: SemanticsModifier) { + rendered.semanticsConfiguration.forEach { (key, value) -> + when (key) { + SemanticsProperties.TestTag -> append("test-tag:\"$value\"") + SemanticsProperties.AccessibilityLabel -> append("label:\"$value\"") + SemanticsProperties.AccessibilityValue -> append("value:\"$value\"") + SemanticsProperties.Disabled -> append("DISABLED") + SemanticsProperties.Focused -> if (value == true) append("FOCUSED") + SemanticsProperties.Hidden -> append("HIDDEN") + SemanticsProperties.IsDialog -> append("DIALOG") + SemanticsProperties.IsPopup -> append("POPUP") + } + } + } + } + } + + @ExperimentalRadiographyComposeApi + @JvmField + val DefaultsNoPii: List = + if (!isComposeAvailable) emptyList() else listOf( + LayoutIdRenderer, + composeTextRenderer(includeText = false, maxTextLength = 0), + StandardSemanticsRenderer + ) + + @ExperimentalRadiographyComposeApi + @JvmField + val DefaultsIncludingPii: List = + if (!isComposeAvailable) emptyList() else listOf( + LayoutIdRenderer, + composeTextRenderer(includeText = true), + StandardSemanticsRenderer + ) + + /** + * Renders composables that expose a text value through the [Text] semantics property. + * + * @param includeText whether to include the string value of the property in the rendered view + * hierarchy. Defaults to false to avoid including any PII. + * + * @param maxTextLength the max size of the string value of the property when [includeText] is + * true. When the max size is reached, the text is trimmed to a [maxTextLength] - 1 length and + * ellipsized with a '…' character. + */ + @ExperimentalRadiographyComposeApi + @JvmStatic + @JvmOverloads + fun composeTextRenderer( + includeText: Boolean = false, + maxTextLength: Int = Int.MAX_VALUE + ): ViewStateRenderer = + if (!isComposeAvailable) NoRenderer else semanticsRendererFor(Text) { text -> + append("text-length:${text.text.length}") + if (includeText) { + append("text:\"${text.text.ellipsize(maxTextLength)}\"") + } + } + + /** + * Renders a [SemanticsPropertyKey]. + */ + @ExperimentalRadiographyComposeApi + @JvmStatic + internal fun semanticsRendererFor( + key: SemanticsPropertyKey, + render: AttributeAppendable.(T) -> Unit + ): ViewStateRenderer = + object : TypedViewStateRenderer(SemanticsModifier::class.java) { + override fun AttributeAppendable.renderTyped(rendered: SemanticsModifier) { + rendered.semanticsConfiguration.forEach { (k, value) -> + if (key == k) { + @Suppress("UNCHECKED_CAST") + render(value as T) + } + } + } + } + + private object NoRenderer : ViewStateRenderer { + override fun AttributeAppendable.render(rendered: Any) { + // Noop. + } + } +} diff --git a/radiography/src/main/java/radiography/compose/ComposeViews.kt b/radiography/src/main/java/radiography/compose/ComposeViews.kt new file mode 100644 index 0000000..1b1616c --- /dev/null +++ b/radiography/src/main/java/radiography/compose/ComposeViews.kt @@ -0,0 +1,154 @@ +package radiography.compose + +import android.util.SparseArray +import android.view.View +import androidx.compose.runtime.Composer +import androidx.compose.runtime.Composition +import androidx.ui.tooling.asTree +import radiography.TreeRenderingVisitor +import radiography.TreeRenderingVisitor.RenderingScope +import radiography.ViewFilter +import radiography.ViewStateRenderer +import kotlin.LazyThreadSafetyMode.PUBLICATION + +private val VIEW_KEYED_TAGS_FIELD = View::class.java.getDeclaredField("mKeyedTags") + .apply { isAccessible = true } +private const val WRAPPED_COMPOSITION_CLASS_NAME = "androidx.compose.ui.platform.WrappedComposition" +private const val COMPOSITION_IMPL_CLASS_NAME = "androidx.compose.runtime.CompositionImpl" +private const val ANDROID_COMPOSE_VIEW_CLASS_NAME = + "androidx.compose.ui.platform.AndroidComposeView" + +internal const val COMPOSE_UNSUPPORTED_MESSAGE = + "Composition was found, but either Compose Tooling artifact is missing or the Compose version " + + "is not supported. Please ensure you have a dependency on androidx.ui:ui-tooling or check " + + "https://github.com/square/radiography for a new release." + +/** Reflectively tries to determine if Compose is on the classpath. */ +internal val isComposeAvailable by lazy(PUBLICATION) { + try { + Class.forName(ANDROID_COMPOSE_VIEW_CLASS_NAME) + true + } catch (e: Throwable) { + false + } +} + +/** + * True if this view looks like the private view type that Compose uses to host compositions. + * It does a fuzzy match to try to detect unsupported Compose versions, which will not be rendered + * but will at least warn that the version is unsupported. + */ +internal val View.mightBeComposeView: Boolean + get() = "AndroidComposeView" in this::class.java.name + +/** + * Uses [renderingScope] to visit and render an entire composition. This is the entry point into + * compose support from [radiography.ViewTreeRenderingVisitor]. This function is a no-op if + * [maybeComposeView] is not an instance of the special private View that Compose uses to host + * compositions. The view's [Composer]'s `SlotTable` is parsed using the Compose Tooling library's + * [asTree] function, and then further distilled into [ComposeLayoutInfo]s. + * + * @param modifierRenderers A list of [ViewStateRenderer]s which will be passed + * [Modifier][androidx.compose.ui.Modifier] instances to render. + * @param viewFilter A [ViewFilter] which will be passed [ComposeLayoutInfo]s to filter. + * @param classicViewVisitor The [TreeRenderingVisitor] that will be used to render any Android + * views emitted by the composition. + * @return True if the Compose view was visited successfully, false if the runtime version of + * Compose is not supported by this library and we couldn't render the Compose view. + * @see LayoutInfoVisitor + */ +internal fun tryVisitComposeView( + renderingScope: RenderingScope, + maybeComposeView: View, + modifierRenderers: List, + viewFilter: ViewFilter, + classicViewVisitor: TreeRenderingVisitor +): Boolean { + if (!maybeComposeView.mightBeComposeView) return false + + var linkageError: String? = null + val visited = try { + visitComposeView( + renderingScope, maybeComposeView, modifierRenderers, viewFilter, classicViewVisitor + ) + } catch (e: LinkageError) { + // The view looks like an AndroidComposeView, but the Compose code on the classpath is + // not what we expected – the app is probably using a newer (or older) version of Compose than + // we support. + linkageError = e.message + false + } + + if (!visited) { + // Compose version is unsupported, include a warning but then continue rendering Android + // views. + renderingScope.description.append("\n$COMPOSE_UNSUPPORTED_MESSAGE") + linkageError?.let { + renderingScope.description.append("\nError: $linkageError") + } + } + + return visited +} + +/** + * Uses reflection to try to pull a `SlotTable` out of [composeView] and render it. If any of the + * reflection fails, returns false. + */ +private fun visitComposeView( + renderingScope: RenderingScope, + composeView: View, + modifierRenderers: List, + viewFilter: ViewFilter, + classicViewVisitor: TreeRenderingVisitor +): Boolean { + val keyedTags = composeView.getKeyedTags() + val composition = keyedTags.first { it is Composition } as Composition? ?: return false + val composer = composition.unwrap() + .getComposerOrNull() ?: return false + val rootGroup = composer.slotTable.asTree() + val visitor = LayoutInfoVisitor(modifierRenderers, viewFilter, classicViewVisitor) + + rootGroup.layoutInfos.forEach { + renderingScope.addChildToVisit(it, visitor) + } + // At this point we will not have actually visited any LayoutInfos (addChildToVisit doesn't + // immediately visit), but if we were able to successfully construct the LayoutInfos, then we + // assume the Compose version is supported. LayoutInfoVisitor will also try to detect unsupported + // Compose versions. + return true +} + +private fun View.getKeyedTags(): SparseArray<*> { + return VIEW_KEYED_TAGS_FIELD.get(this) as SparseArray<*> +} + +private inline fun SparseArray<*>.first(predicate: (Any?) -> Boolean): Any? { + for (i in 0 until size()) { + val item = valueAt(i) + if (predicate(item)) return item + } + return null +} + +/** + * If this is a `WrappedComposition`, returns the original composition, else returns this. + */ +private fun Composition.unwrap(): Composition { + if (this::class.java.name != WRAPPED_COMPOSITION_CLASS_NAME) return this + val wrappedClass = Class.forName(WRAPPED_COMPOSITION_CLASS_NAME) + val originalField = wrappedClass.getDeclaredField("original") + .apply { isAccessible = true } + return originalField.get(this) as Composition +} + +/** + * Tries to pull a [Composer] out of this [Composition], or returns null if it can't find one. + */ +private fun Composition.getComposerOrNull(): Composer<*>? { + if (this::class.java.name != COMPOSITION_IMPL_CLASS_NAME) return null + val compositionImplClass = Class.forName(COMPOSITION_IMPL_CLASS_NAME) + val composerField = compositionImplClass.getDeclaredField("composer") + .apply { isAccessible = true } + return composerField.get(this) as? Composer<*> +} diff --git a/radiography/src/main/java/radiography/compose/ExperimentalRadiographyComposeApi.kt b/radiography/src/main/java/radiography/compose/ExperimentalRadiographyComposeApi.kt new file mode 100644 index 0000000..059eac4 --- /dev/null +++ b/radiography/src/main/java/radiography/compose/ExperimentalRadiographyComposeApi.kt @@ -0,0 +1,7 @@ +package radiography.compose + +@RequiresOptIn( + message = "This API is experimental, may only work with a specific version of Compose, " + + "and may change or break at any time. Use with caution." +) +annotation class ExperimentalRadiographyComposeApi diff --git a/radiography/src/main/java/radiography/compose/LayoutInfoVisitor.kt b/radiography/src/main/java/radiography/compose/LayoutInfoVisitor.kt new file mode 100644 index 0000000..9b4844d --- /dev/null +++ b/radiography/src/main/java/radiography/compose/LayoutInfoVisitor.kt @@ -0,0 +1,71 @@ +package radiography.compose + +import android.view.View +import androidx.compose.ui.unit.IntBounds +import radiography.AttributeAppendable +import radiography.TreeRenderingVisitor +import radiography.ViewFilter +import radiography.ViewStateRenderer +import radiography.formatPixelDimensions + +/** + * A [TreeRenderingVisitor] that recursively renders a tree of [ComposeLayoutInfo]s. It is the + * Compose analog to [radiography.ViewTreeRenderingVisitor]. + */ +internal class LayoutInfoVisitor( + private val modifierRenderers: List, + private val viewFilter: ViewFilter, + private val classicViewVisitor: TreeRenderingVisitor +) : TreeRenderingVisitor() { + + override fun RenderingScope.visitNode(node: ComposeLayoutInfo) { + try { + visitNodeAssumingComposeSupported(node) + } catch (e: LinkageError) { + // The Compose code on the classpath is not what we expected – the app is probably using a + // newer (or older) version of Compose than we support. + description.appendln(COMPOSE_UNSUPPORTED_MESSAGE) + description.append("Error: ${e.message}") + } + } + + private fun RenderingScope.visitNodeAssumingComposeSupported(node: ComposeLayoutInfo) { + with(description) { + append(node.name) + + append(" { ") + val appendable = AttributeAppendable(description) + node.bounds.describeSize()?.let(appendable::append) + + node.modifiers.forEach { modifier -> + modifierRenderers.forEach { renderer -> + with(renderer) { + appendable.render(modifier) + } + } + } + append(" }") + } + + // Visit LayoutNode children. View nodes don't seem to have children, but they theoretically + // could so try to visit them just in case. + node.children + .filter(viewFilter::matches) + .forEach { + addChildToVisit(it) + } + + // This node was an emitted Android View, so trampoline back to the View renderer. + node.view?.takeIf(viewFilter::matches)?.let { view -> + addChildToVisit(view, classicViewVisitor) + } + } +} + +private fun IntBounds.describeSize(): String? { + return if (left != right || top != bottom) { + formatPixelDimensions(width = right - left, height = bottom - top) + } else { + null + } +} diff --git a/radiography/src/main/java/radiography/compose/SlotTables.kt b/radiography/src/main/java/radiography/compose/SlotTables.kt new file mode 100644 index 0000000..cc38720 --- /dev/null +++ b/radiography/src/main/java/radiography/compose/SlotTables.kt @@ -0,0 +1,31 @@ +@file:JvmName("SlotTables") + +package radiography.compose + +import androidx.compose.runtime.SlotTable +import androidx.ui.tooling.asTree +import radiography.ViewFilter +import radiography.ViewFilters +import radiography.ViewStateRenderer +import radiography.ViewStateRenderers +import radiography.ViewTreeRenderingVisitor +import radiography.renderTreeString + +/** + * Scans a particular [SlotTable], ie. an entire Compose tree. You probably want to use + * [Radiography.scan][radiography.Radiography.scan] instead. + */ +@ExperimentalRadiographyComposeApi +@JvmSynthetic +fun SlotTable.scan( + viewStateRenderers: List = ViewStateRenderers.DefaultsNoPii, + viewFilter: ViewFilter = ViewFilters.NoFilter +): String = buildString { + val rootGroup = asTree() + val viewVisitor = ViewTreeRenderingVisitor(viewStateRenderers, viewFilter) + val visitor = LayoutInfoVisitor(viewStateRenderers, viewFilter, viewVisitor) + + rootGroup.layoutInfos.forEach { + renderTreeString(it, visitor) + } +} diff --git a/sample-compose/src/androidTest/java/com/squareup/radiography/sample/compose/ComposeSampleSmokeTest.kt b/sample-compose/src/androidTest/java/com/squareup/radiography/sample/compose/ComposeSampleSmokeTest.kt deleted file mode 100644 index 32fba68..0000000 --- a/sample-compose/src/androidTest/java/com/squareup/radiography/sample/compose/ComposeSampleSmokeTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.squareup.radiography.sample.compose - -import androidx.ui.test.android.createAndroidComposeRule -import androidx.ui.test.assertIsDisplayed -import androidx.ui.test.onNodeWithText -import org.junit.Rule -import org.junit.Test - -class ComposeSampleSmokeTest { - - @get:Rule - val activityRule = createAndroidComposeRule() - - @Test fun displaysInitialScreen() { - onNodeWithText("The password is Baguette").assertIsDisplayed() - } -} diff --git a/sample-compose/src/androidTest/java/com/squareup/radiography/sample/compose/ComposeSampleUiTest.kt b/sample-compose/src/androidTest/java/com/squareup/radiography/sample/compose/ComposeSampleUiTest.kt new file mode 100644 index 0000000..e717efa --- /dev/null +++ b/sample-compose/src/androidTest/java/com/squareup/radiography/sample/compose/ComposeSampleUiTest.kt @@ -0,0 +1,37 @@ +package com.squareup.radiography.sample.compose + +import androidx.ui.test.android.createAndroidComposeRule +import androidx.ui.test.assert +import androidx.ui.test.assertIsDisplayed +import androidx.ui.test.hasSubstring +import androidx.ui.test.onChild +import androidx.ui.test.onNodeWithTag +import androidx.ui.test.onNodeWithText +import androidx.ui.test.performTextReplacement +import org.junit.Rule +import org.junit.Test + +class ComposeSampleUiTest { + + @get:Rule + val activityRule = createAndroidComposeRule() + + @Test fun launches() { + onNodeWithText("The password is Baguette").assertIsDisplayed() + } + + @Test fun displaysHierarchyInline() { + onNodeWithTag(LIVE_HIERARCHY_TEST_TAG) + .onChild() + .assert(hasSubstring("The password is Baguette")) + .assert(hasSubstring("Unchecked")) + + onNodeWithTag(TEXT_FIELD_TEST_TAG) + .performTextReplacement("foobar") + + onNodeWithTag(LIVE_HIERARCHY_TEST_TAG) + .onChild() + .assert(hasSubstring("The password is Baguette")) + .assert(hasSubstring("foobar")) + } +} diff --git a/sample-compose/src/main/java/com/squareup/radiography/sample/compose/ComposeSampleApp.kt b/sample-compose/src/main/java/com/squareup/radiography/sample/compose/ComposeSampleApp.kt index a7cbfc6..670b0b9 100644 --- a/sample-compose/src/main/java/com/squareup/radiography/sample/compose/ComposeSampleApp.kt +++ b/sample-compose/src/main/java/com/squareup/radiography/sample/compose/ComposeSampleApp.kt @@ -7,7 +7,8 @@ import android.util.Log import android.widget.LinearLayout import android.widget.TextView import androidx.appcompat.app.AlertDialog.Builder -import androidx.compose.foundation.Box +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ScrollableRow import androidx.compose.foundation.Text import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -18,12 +19,15 @@ import androidx.compose.material.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.onCommit import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ContextAmbient import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import radiography.Radiography import radiography.ViewFilter @@ -35,11 +39,21 @@ import radiography.ViewStateRenderers.DefaultsNoPii import radiography.ViewStateRenderers.ViewRenderer import radiography.ViewStateRenderers.textViewRenderer import radiography.ViewStateRenderers.viewStateRendererFor +import radiography.compose.ComposeLayoutFilters.skipTestTagsFilter +import radiography.compose.ComposeLayoutRenderers.LayoutIdRenderer +import radiography.compose.ComposeLayoutRenderers.StandardSemanticsRenderer +import radiography.compose.ComposeLayoutRenderers.composeTextRenderer +import radiography.compose.ExperimentalRadiographyComposeApi +internal const val TEXT_FIELD_TEST_TAG = "text-field" +internal const val LIVE_HIERARCHY_TEST_TAG = "live-hierarchy" + +@OptIn(ExperimentalRadiographyComposeApi::class, ExperimentalAnimationApi::class) @Composable fun ComposeSampleApp() { val (isChecked, onCheckChanged) = remember { mutableStateOf(false) } var text by remember { mutableStateOf("") } val context = ContextAmbient.current + val liveHierarchy = remember { mutableStateOf(null) } Column { Text("The password is Baguette", style = MaterialTheme.typography.body2) @@ -47,20 +61,38 @@ import radiography.ViewStateRenderers.viewStateRendererFor Checkbox(checked = isChecked, onCheckedChange = onCheckChanged) Text("Check me, or don't.") } - TextField(value = text, onValueChange = { text = it }, label = { Text("Text Field") }) + TextField( + value = text, + onValueChange = { text = it }, + label = { Text("Text Field") }, + modifier = Modifier.testTag(TEXT_FIELD_TEST_TAG) + ) // Include a classic Android view in the composition. AndroidView(::TextView) { @SuppressLint("SetTextI18n") it.text = "inception" } - Box(Modifier.testTag("show-rendering")) { - Button(onClick = { showSelectionDialog(context) }) { - Text("Show string rendering dialog") + Button(onClick = { showSelectionDialog(context) }) { + Text("Show string rendering dialog") + } + + liveHierarchy.value?.let { + ScrollableRow(modifier = Modifier.testTag(LIVE_HIERARCHY_TEST_TAG)) { + Text(liveHierarchy.value.orEmpty(), fontFamily = FontFamily.Monospace, fontSize = 8.sp) } } + + onCommit { + liveHierarchy.value = Radiography.scan( + viewStateRenderers = DefaultsIncludingPii, + // Don't trigger infinite recursion. + viewFilter = skipTestTagsFilter(LIVE_HIERARCHY_TEST_TAG) + ) + } } } +@OptIn(ExperimentalRadiographyComposeApi::class) private fun showSelectionDialog(context: Context) { val renderings = listOf( "Default" to { @@ -69,6 +101,9 @@ private fun showSelectionDialog(context: Context) { "Focused window" to { Radiography.scan(viewFilter = FocusedWindowViewFilter) }, + "Skip testTag(\"$TEXT_FIELD_TEST_TAG\")" to { + Radiography.scan(viewFilter = skipTestTagsFilter(TEXT_FIELD_TEST_TAG)) + }, "Focused window and custom filter" to { Radiography.scan(viewFilter = FocusedWindowViewFilter and object : ViewFilter { override fun matches(view: Any): Boolean = view !is LinearLayout @@ -82,7 +117,10 @@ private fun showSelectionDialog(context: Context) { viewStateRenderers = listOf( ViewRenderer, textViewRenderer(includeTextViewText = true, textViewTextMaxLength = 4), - CheckableRenderer + CheckableRenderer, + LayoutIdRenderer, + StandardSemanticsRenderer, + composeTextRenderer(includeText = true, maxTextLength = 4) ) ) }, diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index d31617f..ca8aff8 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -28,7 +28,7 @@ android { } defaultConfig { - minSdkVersion(17) + minSdkVersion(21) targetSdkVersion(30) applicationId = "com.squareup.radiography.sample" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/settings.gradle.kts b/settings.gradle.kts index 32904c3..421305e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,8 @@ rootProject.name = "radiography" include( + ":compose-tests", + ":compose-unsupported-tests", ":radiography", ":sample", ":sample-compose"