From bfaa82f67ba0f3705e6ea6645549b0b914351e6a Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Sat, 28 Mar 2026 10:46:42 -0700 Subject: [PATCH 1/3] Compose Testing UI test --- Android/app/build.gradle.kts | 31 ++ ...vigationStackPlaygroundInstrumentedTest.kt | 372 ++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 Android/app/src/androidTest/kotlin/showcase/module/NavigationStackPlaygroundInstrumentedTest.kt diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index aa5d426..1858567 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -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) @@ -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 @@ -35,6 +53,7 @@ android { buildFeatures { buildConfig = true + compose = true } lint { @@ -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.3.0") +} diff --git a/Android/app/src/androidTest/kotlin/showcase/module/NavigationStackPlaygroundInstrumentedTest.kt b/Android/app/src/androidTest/kotlin/showcase/module/NavigationStackPlaygroundInstrumentedTest.kt new file mode 100644 index 0000000..310bb66 --- /dev/null +++ b/Android/app/src/androidTest/kotlin/showcase/module/NavigationStackPlaygroundInstrumentedTest.kt @@ -0,0 +1,372 @@ +package showcase.module +import android.os.SystemClock +import android.content.Context +import android.util.Log +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.test.isRoot +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +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 + +private const val TAB_BAR_TEST_TAG = "skip_ui_automation_tab_bar" +private const val SEARCH_FIELD_TEST_TAG = "skip_ui_automation_search_field" + +private const val PLAYGROUND_TITLE = "Showcase" +private const val PLAYGROUND_SEARCH_TEXT = "NavigationStack" +private const val ROOT_POP_BUTTON = "Pop" + +private const val NAV_BACK_CONTENT_DESC = "Back" +private const val SHEET_DISMISS_BUTTON = "Dismiss" +private const val PLAYGROUND_PUSHED_TEXT = "Pushed" + +@RunWith(AndroidJUnit4::class) +class NavigationStackPlaygroundInstrumentedTest { + + private val composeTestRule = createAndroidComposeRule() + private var treeSnapshotCounter = 0 + + @get:Rule + val ruleChain: RuleChain = RuleChain.outerRule(object : TestWatcher() { + override fun starting(description: Description) { + InstrumentationRegistry.getInstrumentation().targetContext + .getSharedPreferences("defaults", Context.MODE_PRIVATE) + .edit() + .remove("searchText") + .commit() + } + + override fun failed(e: Throwable?, description: Description) { + Log.e("NavStackTest", "Failed ${description.methodName}", e) + logTree("failed_${description.methodName}") + } + }).around(composeTestRule) + + @Test + fun openNavigationStackPlayground_showsPopButton() { + openNavigationStackPlayground() + composeTestRule.onNodeWithText(ROOT_POP_BUTTON).assertExists() + } + + @Test + fun presentWithBinding_presentsAndPopsBack() { + openNavigationStackPlayground() + + composeTestRule.onNodeWithText("Present with binding").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(PLAYGROUND_PUSHED_TEXT).assertExists() + navigateBackViaToolbar() + + composeTestRule.onNodeWithText(ROOT_POP_BUTTON).assertExists() + composeTestRule.onNodeWithText(PLAYGROUND_PUSHED_TEXT).assertDoesNotExist() + } + + @Test + fun presentWithItemBinding_chainsItemDestinations_andPops() { + openNavigationStackPlayground() + + composeTestRule.onNodeWithText("Present with item binding").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Item value: 42").assertExists() + + composeTestRule.onNodeWithText("Navigate forward to 43").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Item value: 43").assertExists() + + composeTestRule.onNodeWithText("Navigate back (set nil)").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Item value: 42").assertExists() + + composeTestRule.onNodeWithText("Navigate back (dismiss)").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(ROOT_POP_BUTTON).assertExists() + composeTestRule.onNodeWithText("Item value: 42").assertDoesNotExist() + } + + @Test + fun navigationLinks_pushesAndPopsBack() { + openNavigationStackPlayground() + waitForNavigationThrottle() + + composeTestRule.onNode(hasText("NavigationLink") and hasClickAction()).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(PLAYGROUND_PUSHED_TEXT).assertExists() + navigateBackViaToolbar() + composeTestRule.onNodeWithText(ROOT_POP_BUTTON).assertExists() + + composeTestRule.onNode(hasText("NavigationLink .buttonStyle") and hasClickAction()).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(PLAYGROUND_PUSHED_TEXT).assertExists() + navigateBackViaToolbar() + composeTestRule.onNodeWithText(ROOT_POP_BUTTON).assertExists() + } + + @Test + fun pathBindingSheet_mutatesPathAndMaintainsBackStack() { + openNavigationStackPlayground() + composeTestRule.onNodeWithText("Path binding sheet").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("path.append(1)").assertExists() + composeTestRule.onNodeWithText("path += [1, 2]").assertExists() + composeTestRule.onNodeWithText("Navigate back").assertDoesNotExist() + + composeTestRule.onNodeWithText("path.append(1)").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Navigate back").assertExists() + composeTestRule.onNodeWithText("path.removeLast()").assertExists() + + composeTestRule.onNodeWithText("path.removeLast()").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Navigate back").assertDoesNotExist() + + composeTestRule.onNodeWithText("path += [1, 2]").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("path.removeLast(2)").assertExists() + composeTestRule.onNodeWithText("path.reverse()").assertExists() + composeTestRule.onNodeWithText("Path: 1, 2").assertExists() + + composeTestRule.onNodeWithText("path.reverse()").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Path: 2, 1").assertExists() + + composeTestRule.onNodeWithText("path.removeLast(2); path.append(3)").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Path: 3").assertExists() + composeTestRule.onNodeWithText("path.removeLast(2)").assertDoesNotExist() + + composeTestRule.onNodeWithText("path.removeLast()").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Navigate back").assertDoesNotExist() + + composeTestRule.onNodeWithText(SHEET_DISMISS_BUTTON).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(ROOT_POP_BUTTON).assertExists() + popBackToShowcaseIfNeeded() + } + + @Test + fun pathBindingSheet_withInitialStack_shrinksAndDismisses() { + openNavigationStackPlayground() + composeTestRule.onNodeWithText("Path binding sheet with initial stack").performClick() + composeTestRule.waitForIdle() + + // With an initial non-empty path, the sheet starts on a pushed destination. + // Root-level toolbar actions like "Dismiss" are not expected yet. + composeTestRule.onNodeWithText("Path: 1, 2").assertExists() + composeTestRule.onNodeWithText("Navigate back").assertExists() + composeTestRule.onNodeWithText("path.removeLast(2)").assertExists() + + composeTestRule.onNodeWithText("path.removeLast()").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Path: 1").assertExists() + composeTestRule.onNodeWithText("path.removeLast(2)").assertDoesNotExist() + + composeTestRule.onNodeWithText("Navigate back").performClick() + composeTestRule.waitForIdle() + + // After popping the internal nav destination, the sheet should remain open and the path becomes empty. + composeTestRule.onNodeWithText(SHEET_DISMISS_BUTTON).assertExists() + composeTestRule.onNodeWithText("Navigate back").assertDoesNotExist() + composeTestRule.onNodeWithText("path.append(1)").assertExists() + + composeTestRule.onNodeWithText(SHEET_DISMISS_BUTTON).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(ROOT_POP_BUTTON).assertExists() + popBackToShowcaseIfNeeded() + } + + @Test + fun navigationPathBindingSheet_mixedTypeBackStackOperations() { + openNavigationStackPlayground() + composeTestRule.onNodeWithText("NavigationPath binding sheet").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(SHEET_DISMISS_BUTTON).assertExists() + composeTestRule.onNodeWithText("Path count: 0").assertExists() + + composeTestRule.onNodeWithText("path.append(1)").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Path count: 1").assertExists() + + composeTestRule.onNodeWithText("path.append(\"X\")").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Value: X").assertExists() + + composeTestRule.onNodeWithText("path.removeLast()").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Path count: 1").assertExists() + composeTestRule.onNodeWithText("Value: X").assertDoesNotExist() + + composeTestRule.onNodeWithText("Navigate forward").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Path count: 2").assertExists() + + composeTestRule.onNodeWithText("Navigate back").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Path count: 1").assertExists() + + composeTestRule.onNodeWithText("path += [2, 3]").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Path count: 3").assertExists() + composeTestRule.onNodeWithText("path.removeLast(2)").assertExists() + + composeTestRule.onNodeWithText("path.removeLast(2)").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Path count: 1").assertExists() + composeTestRule.onNodeWithText("path.removeLast(2)").assertDoesNotExist() + + // We are one level deep in the sheet stack (Path count: 1), so pop to the + // sheet root before trying to use the root-level Dismiss toolbar button. + composeTestRule.onNodeWithText("Navigate back").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Path count: 0").assertExists() + + logTree("navigationPathBindingSheet_beforeDismissClick") + composeTestRule.onNodeWithText(SHEET_DISMISS_BUTTON).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(ROOT_POP_BUTTON).assertExists() + popBackToShowcaseIfNeeded() + } + + private fun openNavigationStackPlayground() { + logTree("openNavigationStackPlayground_start") + popBackToShowcaseIfNeeded() + + waitForIdleLogged("openNavigationStackPlayground_initial") + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val pkg = composeTestRule.activity.packageName + device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 5_000) + logTree("openNavigationStackPlayground_afterDeviceWait") + + // Open the Showcase tab inside the bottom Material tab bar. + clickNodeLogged("open_showcase_tab") { + composeTestRule.onNode( + hasText(PLAYGROUND_TITLE) and hasAnyAncestor(hasTestTag(TAB_BAR_TEST_TAG)), + ).performClick() + } + waitForIdleLogged("openNavigationStackPlayground_afterShowcaseClick") + + // Search for "NavigationStack" and open the corresponding row. + clickNodeLogged("focus_search_field_input") { + composeTestRule.onNodeWithTag(SEARCH_FIELD_TEST_TAG, useUnmergedTree = true) + .performTextInput(PLAYGROUND_SEARCH_TEXT) + } + waitForIdleLogged("openNavigationStackPlayground_afterSearchInput") + + // Exclude the search field subtree so we hit the list row, not the typed text in the field. + clickNodeLogged("open_navigationstack_row") { + composeTestRule.onNode( + hasText(PLAYGROUND_SEARCH_TEXT) and hasTestTag(SEARCH_FIELD_TEST_TAG).not(), + ).performClick() + } + waitForIdleLogged("openNavigationStackPlayground_afterRowClick") + logTree("openNavigationStackPlayground_end") + } + + private fun navigateBackViaToolbar() { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + logTree("navigateBackViaToolbar_before") + + // First try the explicit toolbar back icon (stable on this playground). + try { + composeTestRule.onNodeWithText(PLAYGROUND_TITLE).assertExists() + composeTestRule.onNode(hasContentDescription(NAV_BACK_CONTENT_DESC)).performClick() + } catch (_: Throwable) { + // Fallback to system back if semantics don't expose the icon description for some reason. + device.pressBack() + } + + SystemClock.sleep(250) + waitForIdleLogged("navigateBackViaToolbar_after") + logTree("navigateBackViaToolbar_after") + } + + private fun popBackToShowcaseIfNeeded() { + logTree("popBackToShowcaseIfNeeded_before") + try { + composeTestRule.onNodeWithText(ROOT_POP_BUTTON).performClick() + waitForIdleLogged("popBackToShowcaseIfNeeded_afterPop") + } catch (_: Throwable) { + // Assume we're already on the Showcase list / not in the playground. + logTree("popBackToShowcaseIfNeeded_noPopNeeded") + } + logTree("popBackToShowcaseIfNeeded_after") + } + + private fun waitForIdleLogged(stage: String) { + try { + composeTestRule.waitForIdle() + } catch (t: Throwable) { + logTree("waitForIdleFailed_$stage") + throw t + } + } + + private fun clickNodeLogged(stage: String, action: () -> Unit) { + logTree("beforeClick_$stage") + try { + action() + } catch (t: Throwable) { + logTree("clickFailed_$stage") + throw t + } + logTree("afterClick_$stage") + } + + // wait for minimumNavigationInterval in Navigation.swift + private fun waitForNavigationThrottle() { + SystemClock.sleep(400) + } + + private fun logTree(stage: String) { + treeSnapshotCounter += 1 + val safeStage = stage.replace(" ", "_") + Log.i("NavStackTest", "snapshot=$treeSnapshotCounter stage=$safeStage") + try { + val tag = "NavStackTree$treeSnapshotCounter" + val roots = composeTestRule + .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("NavStackTest", "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) + } + } +} From fc4cd3314a2459ecdec98f8e47587056a0ada826 Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Sat, 4 Apr 2026 19:51:13 -0700 Subject: [PATCH 2/3] upgrade uiautomator so we can use ResultsReporter --- Android/app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 1858567..89de5cb 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -110,5 +110,5 @@ dependencies { 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.3.0") + androidTestImplementation("androidx.test.uiautomator:uiautomator:2.4.0-beta02") } From 8866bbc23433596f0c0e2871909a7ffb130a1814 Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Sat, 4 Apr 2026 19:52:39 -0700 Subject: [PATCH 3/3] Tests for https://github.com/skiptools/skip-ui/issues/389 --- ...ComposeFlexibleRegressionScreenshotTest.kt | 192 ++++++++++++++++++ .../module/ComposeSemanticsTreeLogger.kt | 47 +++++ ...vigationStackPlaygroundInstrumentedTest.kt | 40 +--- .../module/PlaygroundScreenshotHarness.kt | 133 ++++++++++++ .../module/StackPlaygroundInstrumentedTest.kt | 130 ++++++++++++ 5 files changed, 511 insertions(+), 31 deletions(-) create mode 100644 Android/app/src/androidTest/kotlin/showcase/module/ComposeFlexibleRegressionScreenshotTest.kt create mode 100644 Android/app/src/androidTest/kotlin/showcase/module/ComposeSemanticsTreeLogger.kt create mode 100644 Android/app/src/androidTest/kotlin/showcase/module/PlaygroundScreenshotHarness.kt create mode 100644 Android/app/src/androidTest/kotlin/showcase/module/StackPlaygroundInstrumentedTest.kt diff --git a/Android/app/src/androidTest/kotlin/showcase/module/ComposeFlexibleRegressionScreenshotTest.kt b/Android/app/src/androidTest/kotlin/showcase/module/ComposeFlexibleRegressionScreenshotTest.kt new file mode 100644 index 0000000..dcfe63e --- /dev/null +++ b/Android/app/src/androidTest/kotlin/showcase/module/ComposeFlexibleRegressionScreenshotTest.kt @@ -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() + + 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> { + return ALL_LOCALIZED_PLAYGROUND_TITLES.mapIndexed { index, title -> + val query = PlaygroundScreenshotHarness.searchPrefixForPlayground( + title, + ALL_LOCALIZED_PLAYGROUND_TITLES, + ) + arrayOf(title, query, index) + } + } + } +} diff --git a/Android/app/src/androidTest/kotlin/showcase/module/ComposeSemanticsTreeLogger.kt b/Android/app/src/androidTest/kotlin/showcase/module/ComposeSemanticsTreeLogger.kt new file mode 100644 index 0000000..40460bc --- /dev/null +++ b/Android/app/src/androidTest/kotlin/showcase/module/ComposeSemanticsTreeLogger.kt @@ -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) + } + } +} diff --git a/Android/app/src/androidTest/kotlin/showcase/module/NavigationStackPlaygroundInstrumentedTest.kt b/Android/app/src/androidTest/kotlin/showcase/module/NavigationStackPlaygroundInstrumentedTest.kt index 310bb66..94da9e9 100644 --- a/Android/app/src/androidTest/kotlin/showcase/module/NavigationStackPlaygroundInstrumentedTest.kt +++ b/Android/app/src/androidTest/kotlin/showcase/module/NavigationStackPlaygroundInstrumentedTest.kt @@ -8,8 +8,6 @@ import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText -import androidx.compose.ui.semantics.SemanticsNode -import androidx.compose.ui.test.isRoot import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -42,7 +40,10 @@ private const val PLAYGROUND_PUSHED_TEXT = "Pushed" class NavigationStackPlaygroundInstrumentedTest { private val composeTestRule = createAndroidComposeRule() - private var treeSnapshotCounter = 0 + + private val semanticsTreeLogger by lazy { + ComposeSemanticsTreeLogger(composeTestRule, "NavStackTest", "NavStackTree") + } @get:Rule val ruleChain: RuleChain = RuleChain.outerRule(object : TestWatcher() { @@ -50,6 +51,7 @@ class NavigationStackPlaygroundInstrumentedTest { InstrumentationRegistry.getInstrumentation().targetContext .getSharedPreferences("defaults", Context.MODE_PRIVATE) .edit() + .putString("tab", "showcase") .remove("searchText") .commit() } @@ -263,7 +265,9 @@ class NavigationStackPlaygroundInstrumentedTest { // Open the Showcase tab inside the bottom Material tab bar. clickNodeLogged("open_showcase_tab") { composeTestRule.onNode( - hasText(PLAYGROUND_TITLE) and hasAnyAncestor(hasTestTag(TAB_BAR_TEST_TAG)), + hasText(PLAYGROUND_TITLE, substring = false) and + hasClickAction() and + hasAnyAncestor(hasTestTag(TAB_BAR_TEST_TAG)), ).performClick() } waitForIdleLogged("openNavigationStackPlayground_afterShowcaseClick") @@ -341,32 +345,6 @@ class NavigationStackPlaygroundInstrumentedTest { } private fun logTree(stage: String) { - treeSnapshotCounter += 1 - val safeStage = stage.replace(" ", "_") - Log.i("NavStackTest", "snapshot=$treeSnapshotCounter stage=$safeStage") - try { - val tag = "NavStackTree$treeSnapshotCounter" - val roots = composeTestRule - .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("NavStackTest", "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) - } + semanticsTreeLogger.logTree(stage) } } diff --git a/Android/app/src/androidTest/kotlin/showcase/module/PlaygroundScreenshotHarness.kt b/Android/app/src/androidTest/kotlin/showcase/module/PlaygroundScreenshotHarness.kt new file mode 100644 index 0000000..2e8f490 --- /dev/null +++ b/Android/app/src/androidTest/kotlin/showcase/module/PlaygroundScreenshotHarness.kt @@ -0,0 +1,133 @@ +package showcase.module + +import android.Manifest +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.ResultsReporter +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import java.io.FileOutputStream + +object PlaygroundScreenshotHarness { + const val TAB_BAR_TEST_TAG = "skip_ui_automation_tab_bar" + const val SEARCH_FIELD_TEST_TAG = "skip_ui_automation_search_field" + private const val SHOWCASE_TAB_LABEL = "Showcase" + + fun applyShowcaseTabPrefs(context: Context) { + context.getSharedPreferences("defaults", Context.MODE_PRIVATE) + .edit() + .putString("tab", "showcase") + .remove("searchText") + .commit() + } + + /** Pre-grant permissions so playgrounds (Audio, Camera, etc.) do not block on system dialogs. */ + fun grantShowcaseRuntimePermissions(packageName: String) { + val ui = InstrumentationRegistry.getInstrumentation().uiAutomation + val perms = buildList { + add(Manifest.permission.RECORD_AUDIO) + add(Manifest.permission.CAMERA) + if (Build.VERSION.SDK_INT >= 33) { + add(Manifest.permission.POST_NOTIFICATIONS) + } + } + for (perm in perms) { + try { + ui.grantRuntimePermission(packageName, perm) + } catch (_: Throwable) { + // Ignore unknown or already-granted permissions. + } + } + } + + /** + * Mirrors [PlaygroundNavigationView.matchingPlaygroundTypes]: any word in the title starts with [prefix] (case-insensitive). + */ + fun titleMatchesSearchPrefix(title: String, prefix: String): Boolean { + val p = prefix.lowercase().trim() + if (p.isEmpty()) return true + return title.split(" ").any { word -> + word.isNotEmpty() && word.lowercase().startsWith(p) + } + } + + /** + * Shortest prefix of a word in [title] so the in-app filter is reasonably narrow. + * Uniqueness is not required (e.g. "Animation" and "Lottie Animation" both match "Anim"); + * the row tap uses [title] with an exact text match. + */ + fun searchPrefixForPlayground(title: String, allTitles: List, maxMatches: Int = 12): String { + for (word in title.split(" ")) { + if (word.isEmpty()) continue + for (len in 1..word.length) { + val prefix = word.substring(0, len) + if (!titleMatchesSearchPrefix(title, prefix)) continue + val n = allTitles.count { t -> titleMatchesSearchPrefix(t, prefix) } + if (n <= maxMatches) return prefix + } + } + val first = title.split(" ").firstOrNull { it.isNotEmpty() } ?: return title + return first + } + + fun openPlaygroundFromList( + rule: ComposeTestRule, + searchQuery: String, + rowExactTitle: String, + ) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val pkg = InstrumentationRegistry.getInstrumentation().targetContext.packageName + device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 5_000) + + rule.onNode( + hasText(SHOWCASE_TAB_LABEL, substring = false) and + hasClickAction() and + hasAnyAncestor(hasTestTag(TAB_BAR_TEST_TAG)), + ).performClick() + rule.waitForIdle() + + rule.onNodeWithTag(SEARCH_FIELD_TEST_TAG, useUnmergedTree = true) + .performTextInput(searchQuery) + rule.waitForIdle() + + rule.onNode( + hasText(rowExactTitle, substring = false) and + hasTestTag(SEARCH_FIELD_TEST_TAG).not(), + ).performClick() + rule.waitForIdle() + Thread.sleep(400) + } + + fun captureComposeRootPng( + rule: ComposeTestRule, + resultsReporter: ResultsReporter, + semanticsTreeLogger: ComposeSemanticsTreeLogger, + fileName: String, + title: String, + ) { + rule.waitForIdle() + semanticsTreeLogger.logTree("screenshot_${fileName.substringBeforeLast('.')}") + val bitmap = rule.onRoot().captureToImage().asAndroidBitmap() + val file = resultsReporter.addNewFile(fileName, title) + FileOutputStream(file).use { out -> + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)) { "PNG compress failed" } + } + } + + fun safePngBaseName(displayTitle: String): String = + "regression_" + displayTitle.replace(Regex("[^a-zA-Z0-9]+"), "_").trim('_').lowercase() +} diff --git a/Android/app/src/androidTest/kotlin/showcase/module/StackPlaygroundInstrumentedTest.kt b/Android/app/src/androidTest/kotlin/showcase/module/StackPlaygroundInstrumentedTest.kt new file mode 100644 index 0000000..af902ff --- /dev/null +++ b/Android/app/src/androidTest/kotlin/showcase/module/StackPlaygroundInstrumentedTest.kt @@ -0,0 +1,130 @@ +package showcase.module + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.captureToImage +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.ResultsReporter +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import java.io.FileOutputStream +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.TestName +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith + +private const val TAB_BAR_TEST_TAG = "skip_ui_automation_tab_bar" +private const val SEARCH_FIELD_TEST_TAG = "skip_ui_automation_search_field" + +private const val PLAYGROUND_TITLE = "Showcase" +/** Search query; list row title is "Stacks" (see PlaygroundListView). */ +private const val PLAYGROUND_SEARCH_TEXT = "Stack" +private const val PLAYGROUND_ROW_TEXT = "Stacks" + +@RunWith(AndroidJUnit4::class) +class StackPlaygroundInstrumentedTest { + + private val composeTestRule = createAndroidComposeRule() + + private val semanticsTreeLogger by lazy { + ComposeSemanticsTreeLogger(composeTestRule, "StackPlaygroundTest", "StackPlaygroundTree") + } + + @get:Rule + val testName: TestName = TestName() + + private lateinit var resultsReporter: ResultsReporter + + @get:Rule + val ruleChain: RuleChain = RuleChain.outerRule(object : TestWatcher() { + override fun starting(description: Description) { + InstrumentationRegistry.getInstrumentation().targetContext + .getSharedPreferences("defaults", Context.MODE_PRIVATE) + .edit() + .putString("tab", "showcase") + .remove("searchText") + .commit() + } + + override fun failed(e: Throwable?, description: Description) { + Log.e("StackPlaygroundTest", "Failed ${description.methodName}", e) + } + }).around(composeTestRule) + + @Before + fun setupResultsReporter() { + resultsReporter = ResultsReporter("${javaClass.simpleName}_${testName.methodName}") + } + + @After + fun reportAdditionalOutput() { + if (::resultsReporter.isInitialized) { + resultsReporter.reportToInstrumentation() + } + } + + @Test + fun openStackPlayground_capturesScreenshot() { + openStackPlayground() + + composeTestRule.onNodeWithText("Fixed vs Expanding:").assertExists() + composeTestRule.waitForIdle() + + captureComposeRootPng( + "stack_playground.png", + "Stack playground (Compose root)", + ) + } + + private fun captureComposeRootPng(fileName: String, title: String) { + composeTestRule.waitForIdle() + semanticsTreeLogger.logTree("screenshot_${fileName.substringBeforeLast('.')}") + val bitmap = composeTestRule.onRoot().captureToImage().asAndroidBitmap() + val file = resultsReporter.addNewFile(fileName, title) + FileOutputStream(file).use { out -> + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)) { "PNG compress failed" } + } + } + + private fun openStackPlayground() { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val pkg = composeTestRule.activity.packageName + device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 5_000) + + composeTestRule.onNode( + hasText(PLAYGROUND_TITLE, substring = false) and + hasClickAction() and + hasAnyAncestor(hasTestTag(TAB_BAR_TEST_TAG)), + ).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(SEARCH_FIELD_TEST_TAG, useUnmergedTree = true) + .performTextInput(PLAYGROUND_SEARCH_TEXT) + composeTestRule.waitForIdle() + + composeTestRule.onNode( + hasText(PLAYGROUND_ROW_TEXT, substring = false) and + hasTestTag(SEARCH_FIELD_TEST_TAG).not(), + ).performClick() + composeTestRule.waitForIdle() + } +}