Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions Android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import java.util.Properties

// Compose ui-test-junit4-android pulls Espresso 3.5.x; align all androidx.test artifacts or
// ActivityScenario / ActivityInvoker can fail at runtime (see android/android-test#2259).
configurations.configureEach {
if (name.contains("androidTest", ignoreCase = true)) {
resolutionStrategy {
force(
"androidx.test:runner:1.7.0",
"androidx.test:rules:1.7.0",
"androidx.test:core:1.7.0",
"androidx.test:monitor:1.8.0",
"androidx.test.espresso:espresso-core:3.7.0",
"androidx.test.espresso:espresso-idling-resource:3.7.0",
)
}
}
}

plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
Expand Down Expand Up @@ -27,6 +44,7 @@ android {
defaultConfig {
minSdk = libs.versions.android.sdk.min.get().toInt()
targetSdk = libs.versions.android.sdk.compile.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// skip.tools.skip-build-plugin will automatically use Skip.env properties for:
// applicationId = PRODUCT_BUNDLE_IDENTIFIER
// versionCode = CURRENT_PROJECT_VERSION
Expand All @@ -35,6 +53,7 @@ android {

buildFeatures {
buildConfig = true
compose = true
}

lint {
Expand Down Expand Up @@ -81,3 +100,15 @@ android {
}
}
}

dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
androidTestImplementation("androidx.test:runner:1.7.0")
androidTestImplementation("androidx.test:rules:1.7.0")
androidTestImplementation("androidx.test:core:1.7.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.4.0-beta02")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package showcase.module

import android.util.Log
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.ResultsReporter
import androidx.test.uiautomator.UiDevice
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.junit.runners.Parameterized

/**
* Screenshot + semantics tree for every Showcase playground listed in [PlaygroundListView] / [ComposeFlexibleRegressionTestPlan.md].
* Search filtering matches [PlaygroundNavigationView.matchingPlaygroundTypes] (word-prefix match).
*/
@RunWith(Parameterized::class)
class ComposeFlexibleRegressionScreenshotTest(
private val rowTitle: String,
private val searchQuery: String,
@Suppress("unused") private val caseIndex: Int,
) {

private val composeTestRule = createAndroidComposeRule<MainActivity>()

private val semanticsTreeLogger by lazy {
ComposeSemanticsTreeLogger(composeTestRule, "ComposeFlexReg", "ComposeFlexTree")
}

private lateinit var resultsReporter: ResultsReporter

@get:Rule
val ruleChain: RuleChain = RuleChain.outerRule(object : TestWatcher() {
override fun starting(description: Description) {
val ctx = InstrumentationRegistry.getInstrumentation().targetContext
PlaygroundScreenshotHarness.applyShowcaseTabPrefs(ctx)
PlaygroundScreenshotHarness.grantShowcaseRuntimePermissions(ctx.packageName)
}

override fun failed(e: Throwable?, description: Description) {
Log.e("ComposeFlexReg", "Failed ${description.methodName} ($rowTitle)", e)
try {
repeat(4) {
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack()
Thread.sleep(150)
}
} catch (_: Throwable) {
// best-effort recovery for the next parameterized case
}
}
}).around(composeTestRule)

@Before
fun setupResultsReporter() {
// Avoid `/`, `[`, `]` in names — Gradle additional-test-output paths must be valid on disk.
resultsReporter = ResultsReporter("${javaClass.simpleName}_case$caseIndex")
}

@After
fun reportAdditionalOutput() {
if (::resultsReporter.isInitialized) {
resultsReporter.reportToInstrumentation()
}
}

@Test
fun openPlayground_captureScreenshotAndSemantics() {
PlaygroundScreenshotHarness.openPlaygroundFromList(
composeTestRule,
searchQuery,
rowTitle,
)
composeTestRule.waitForIdle()

val base = PlaygroundScreenshotHarness.safePngBaseName(rowTitle)
PlaygroundScreenshotHarness.captureComposeRootPng(
composeTestRule,
resultsReporter,
semanticsTreeLogger,
"$base.png",
"ComposeFlexible regression: $rowTitle",
)
}

companion object {
/**
* Localized titles from [PlaygroundType.title] in [PlaygroundListView.swift] (enum order).
*/
private val ALL_LOCALIZED_PLAYGROUND_TITLES = listOf(
"Accessibility",
"Alert",
"Animation",
"Audio",
"Background",
"Blur",
"Border",
"Button",
"Color",
"ColorScheme",
"Compose",
"Context Menu",
"ConfirmationDialog",
"Content Margins",
"DatePicker",
"DisclosureGroup",
"Divider",
"DocumentPicker",
"Environment",
"FocusState",
"Form",
"Frame",
"GeometryChange",
"GeometryReader",
"ViewThatFits",
"Gestures",
"Gradients",
"Graphics",
"Grids",
"Haptic Feedback",
"Icons",
"Image",
"Keyboard",
"Keychain",
"Link",
"Label",
"List",
"Localization",
"Lottie Animation",
"Map",
"Menu",
"Modifiers",
"NavigationStack",
"Notification",
"Observable",
"Offset/Position",
"OnSubmit",
"Overlay",
"Pasteboard",
"Picker",
"Preferences",
"ProgressView",
"Redacted",
"SafeArea",
"ScenePhase",
"ScrollView",
"Searchable",
"SecureField",
"Shadow",
"Shape",
"ShareLink",
"Sheet",
"Slider",
"Spacer",
"SQL",
"Stacks",
"State",
"Storage",
"Symbol",
"Table",
"TabView",
"Text",
"TextEditor",
"TextField",
"Timer",
"Toggle",
"Toolbar",
"Transition",
"Video Player",
"Web Authentication Session",
"WebBrowser",
"WebView",
"ZIndex",
)

@JvmStatic
@Parameterized.Parameters(name = "case_{2}")
fun parameters(): Collection<Array<Any>> {
return ALL_LOCALIZED_PLAYGROUND_TITLES.mapIndexed { index, title ->
val query = PlaygroundScreenshotHarness.searchPrefixForPlayground(
title,
ALL_LOCALIZED_PLAYGROUND_TITLES,
)
arrayOf<Any>(title, query, index)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package showcase.module

import android.util.Log
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.test.isRoot
import androidx.compose.ui.test.junit4.ComposeTestRule

/**
* Dumps the Compose semantics tree (unmerged) to logcat, matching the style used by navigation-stack tests.
*/
class ComposeSemanticsTreeLogger(
private val rule: ComposeTestRule,
private val snapshotInfoTag: String,
private val treeLineTagPrefix: String,
) {
private var treeSnapshotCounter = 0

fun logTree(stage: String) {
treeSnapshotCounter += 1
val safeStage = stage.replace(" ", "_")
Log.i(snapshotInfoTag, "snapshot=$treeSnapshotCounter stage=$safeStage")
try {
val tag = "$treeLineTagPrefix$treeSnapshotCounter"
val roots = rule
.onAllNodes(isRoot(), useUnmergedTree = true)
.fetchSemanticsNodes()
Log.d(tag, "Printing with useUnmergedTree = 'true', roots=${roots.size}")
roots.forEachIndexed { index, root ->
Log.d(tag, "Root[$index]:")
logSemanticsNode(tag, root, depth = 1)
}
} catch (t: Throwable) {
Log.w(snapshotInfoTag, "tree logging unavailable for stage=$safeStage", t)
}
}

private fun logSemanticsNode(tag: String, node: SemanticsNode, depth: Int) {
val indent = " ".repeat(depth)
Log.d(tag, "${indent}Node #${node.id} at ${node.boundsInRoot}")
if (node.config.toString().isNotBlank()) {
Log.d(tag, "${indent}config=${node.config}")
}
node.children.forEach { child ->
logSemanticsNode(tag, child, depth + 1)
}
}
}
Loading